test(ci): bootstrap vitest + GitHub Actions and lock in 3-gate quality bar

First Path-B task. Path A landed daily-driver features without a
test runner; Path B is "harden so the next batch of changes can't
silently regress what just shipped." Step 1 is making `pnpm test`
real and gating CI on it.

Test runner:
* Install vitest + @vitest/coverage-v8 at the workspace root.
* Add vitest.config.ts (environment: "node", no JSDOM) + test /
  test:watch scripts to packages/shared, packages/database,
  packages/ai. Wire `test` into turbo.json with dependsOn: ^build
  for future-proofing; add `pnpm test` to root package.json.

Three real tests (no snapshot theater — verified by mutation):
* packages/shared/src/utils/id.test.ts: asserts generateId() matches
  the RFC 4122 v4 regex and produces 1000 distinct values. Mutating
  generateId() to a constant fails both assertions.
* packages/shared/src/types/objects.test.ts: pins objectTypes and
  objectStatuses arrays. These back the zod enum on objects.create
  and the "open tasks" count on the workspace-home dashboard; a
  silent reorder/rename would otherwise corrupt the dashboard math.
* packages/database/src/markdown-backlog/parse.test.ts: covers
  parseBacklogMarkdown across three shapes (well-formed Task,
  no-frontmatter Plan with path inference, malformed YAML that
  must NOT throw — the importer runs in a file watcher). Plus
  hashFileContents determinism.
* packages/ai/src/actions/index.test.ts: five tests across the
  prompt builders (summarize/expand/rewrite × 3 tones / translate /
  generateFromPrompt). Pure functions; no model mocking needed.

CI:
* .github/workflows/ci.yml runs on pull_request and push to main.
  Node 20, pnpm 9 pinned explicitly (per AGENTS.md). Uses
  setup-node's built-in pnpm cache. Steps: install --frozen-lockfile,
  lint, type-check, test. Concurrency group cancels superseded runs
  on non-main branches.

Docs:
* AGENTS.md: drop the "no test runner configured" disclaimer.
  Document pnpm test / test:watch. Update the PR-readiness rule
  from `pnpm lint && pnpm type-check` to
  `pnpm lint && pnpm type-check && pnpm test`.

All 14 tests pass; lint + type-check still green across all 6
packages. The CI workflow's first run is gated on the operator
pushing this branch — that's the only acceptance criterion left
unverified in this commit.

Closes plans/Plan-multitenant-saas-hardening/Epic-test-foundation/
Task-bootstrap-vitest-and-ci.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-02 09:23:55 -05:00
parent 31e877d1d0
commit 48defb2ffd
16 changed files with 1225 additions and 17 deletions

51
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,51 @@
name: CI
on:
pull_request:
push:
branches: [main]
# Cancel superseded runs on the same branch so the latest commit's CI always
# represents the head, and PR queues don't pile up on rapid pushes.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
check:
name: Lint, type-check, test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
# Pin pnpm to the same major as `packageManager` in root package.json.
# We intentionally don't use corepack auto-detect here — explicit
# versions are more reproducible and the lockfile is pinned to 9.x.
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
# `setup-node`'s built-in pnpm cache key is keyed off
# `pnpm-lock.yaml`, so we don't need a separate `actions/cache`
# block. See https://github.com/actions/setup-node#caching-global-packages-data.
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type-check
run: pnpm type-check
- name: Test
run: pnpm test

View file

@ -49,10 +49,13 @@ Run from the repo root unless noted. Always use `pnpm`, never `npm` or `yarn`.
| Drizzle: push (dev only) | `pnpm db:push` |
| Drizzle Studio | `pnpm --filter @tasks/database db:studio` |
| Import `plans/` into DB | `pnpm --filter @tasks/database watch:markdown-backlog` (needs `MARKDOWN_BACKLOG_WORKSPACE_ID`) |
| Test | `pnpm test` (turbo runs vitest in every package that has a `test` script) |
| Test one package | `pnpm --filter @tasks/<name> test` |
| Test in watch mode | `pnpm --filter @tasks/<name> test:watch` |
Before opening a PR or finishing a task, run **`pnpm lint && pnpm type-check`** at minimum. Run `pnpm build` if you touched build config, server entry points, or cross-package exports.
Before opening a PR or finishing a task, run **`pnpm lint && pnpm type-check && pnpm test`** at minimum. Run `pnpm build` if you touched build config, server entry points, or cross-package exports.
There is currently **no test runner configured**. Don't fabricate one or add green-checkmark "tests" in a description. If you add tests, add a real runner (Vitest preferred) and wire it through `turbo.json`.
Tests live next to source as `*.test.ts` files. Vitest is configured per package (see `vitest.config.ts` in `packages/shared`, `packages/database`, `packages/ai`). When you add a new package that has logic worth verifying, copy one of those configs and add `test` / `test:watch` scripts to the package's `package.json`. CI gates merges on lint + type-check + test — see [.github/workflows/ci.yml](./.github/workflows/ci.yml).
---

View file

@ -6,15 +6,18 @@
"dev": "turbo dev",
"lint": "turbo lint",
"type-check": "turbo type-check",
"test": "turbo test",
"db:generate": "turbo db:generate --filter=@tasks/database",
"db:migrate": "turbo db:migrate --filter=@tasks/database",
"db:push": "turbo db:push --filter=@tasks/database",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
},
"devDependencies": {
"@vitest/coverage-v8": "^2",
"prettier": "^3.2.5",
"turbo": "^2.3.0",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^2"
},
"packageManager": "pnpm@9.15.0",
"engines": {

View file

@ -9,7 +9,9 @@
".": "./src/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"ai": "^4.0.0",

View file

@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
expand,
generateFromPrompt,
rewrite,
summarize,
translate,
} from "./index";
import {
EDITOR_SYSTEM_PROMPT,
GENERAL_SYSTEM_PROMPT,
} from "../prompts/system";
const SAMPLE = "Yjs is a CRDT library that powers our collaborative editor.";
describe("action prompt builders", () => {
it("summarize uses the editor system prompt and embeds the content verbatim", () => {
const { systemPrompt, userPrompt } = summarize(SAMPLE);
expect(systemPrompt).toBe(EDITOR_SYSTEM_PROMPT);
expect(userPrompt).toContain("Summarize");
expect(userPrompt).toContain(SAMPLE);
});
it("expand uses the editor system prompt and embeds the content verbatim", () => {
const { systemPrompt, userPrompt } = expand(SAMPLE);
expect(systemPrompt).toBe(EDITOR_SYSTEM_PROMPT);
expect(userPrompt).toContain("Expand");
expect(userPrompt).toContain(SAMPLE);
});
it("rewrite produces distinct guidance per tone (regression net on the tone map)", () => {
const professional = rewrite(SAMPLE, "professional").userPrompt;
const casual = rewrite(SAMPLE, "casual").userPrompt;
const concise = rewrite(SAMPLE, "concise").userPrompt;
// All three carry the same content but with different leading instructions.
expect(professional).toContain(SAMPLE);
expect(casual).toContain(SAMPLE);
expect(concise).toContain(SAMPLE);
expect(professional).not.toBe(casual);
expect(casual).not.toBe(concise);
expect(professional).not.toBe(concise);
expect(professional.toLowerCase()).toContain("professional");
expect(casual.toLowerCase()).toContain("conversational");
expect(concise.toLowerCase()).toContain("shorter");
});
it("translate includes the requested target language", () => {
const { userPrompt } = translate(SAMPLE, "Spanish");
expect(userPrompt).toContain("Spanish");
expect(userPrompt).toContain(SAMPLE);
});
it("generateFromPrompt swaps to the general system prompt and trims the user input", () => {
const { systemPrompt, userPrompt } = generateFromPrompt(
" write me a haiku ",
);
expect(systemPrompt).toBe(GENERAL_SYSTEM_PROMPT);
expect(systemPrompt).not.toBe(EDITOR_SYSTEM_PROMPT);
expect(userPrompt).toBe("write me a haiku");
});
});

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
passWithNoTests: false,
},
});

View file

@ -17,6 +17,8 @@
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"watch:markdown-backlog": "tsx src/scripts/watch-markdown-backlog.ts"
},
"dependencies": {

View file

@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { hashFileContents, parseBacklogMarkdown } from "./parse";
const TASK_FIXTURE = `---
kind: task
slug: wire-workspace-home-dashboard
title: Replace hardcoded dashboard mocks with real tRPC queries
plan_slug: daily-driver-finish
epic_slug: shipping-the-shell
status: done
priority: P0
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-06-02"
---
# Task summary
Some body content that exists below the frontmatter.
`;
const PLAN_FIXTURE_PATH_INFERRED =
"plans/Plan-daily-driver-finish/Plan-daily-driver-finish.md";
const PLAN_FIXTURE_NO_FRONTMATTER = `# Daily Driver Finish
This file has no YAML frontmatter; the parser should still derive kind and
slug from the path and the H1 heading.
`;
const MALFORMED_FIXTURE = `---
this is not valid yaml: [unterminated
---
# Body that should still survive
`;
describe("parseBacklogMarkdown", () => {
it("extracts every documented field from a well-formed Task file", () => {
const path =
"plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-wire-workspace-home-dashboard.md";
const parsed = parseBacklogMarkdown(TASK_FIXTURE, path);
expect(parsed.kind).toBe("task");
expect(parsed.slug).toBe("wire-workspace-home-dashboard");
expect(parsed.planSlug).toBe("daily-driver-finish");
expect(parsed.epicSlug).toBe("shipping-the-shell");
expect(parsed.title).toBe(
"Replace hardcoded dashboard mocks with real tRPC queries",
);
expect(parsed.status).toBe("done");
expect(parsed.priority).toBe("P0");
expect(parsed.owner).toBe("unassigned");
expect(parsed.bodyMarkdown).toContain("# Task summary");
expect(parsed.bodyMarkdown.startsWith("\n# Task summary")).toBe(true);
expect(parsed.contentHash).toHaveLength(64);
});
it("infers kind and plan slug from the file path when frontmatter is absent", () => {
const parsed = parseBacklogMarkdown(
PLAN_FIXTURE_NO_FRONTMATTER,
PLAN_FIXTURE_PATH_INFERRED,
);
expect(parsed.kind).toBe("plan");
expect(parsed.slug).toBe("daily-driver-finish");
expect(parsed.planSlug).toBe("daily-driver-finish");
expect(parsed.title).toBe("Daily Driver Finish");
expect(parsed.status).toBeNull();
});
it("recovers (empty frontmatter, body preserved) from malformed YAML", () => {
// The parser must NOT throw on bad YAML — the importer is run in a
// file watcher and a single bad file shouldn't poison the whole pass.
// See `packages/database/src/markdown-backlog/parse.ts`.
const parsed = parseBacklogMarkdown(
MALFORMED_FIXTURE,
"plans/Plan-x/Epic-y/Task-bad.md",
);
expect(parsed.frontmatter).toEqual({});
expect(parsed.bodyMarkdown).toContain("# Body that should still survive");
expect(parsed.kind).toBe("task");
});
});
describe("hashFileContents", () => {
it("is deterministic across calls", () => {
const a = hashFileContents(TASK_FIXTURE);
const b = hashFileContents(TASK_FIXTURE);
expect(a).toBe(b);
expect(a).toHaveLength(64);
});
it("changes when content changes", () => {
const a = hashFileContents(TASK_FIXTURE);
const b = hashFileContents(TASK_FIXTURE + "\n\nextra line");
expect(a).not.toBe(b);
});
});

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
passWithNoTests: false,
},
});

View file

@ -11,7 +11,9 @@
"./utils": "./src/utils/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"zod": "^3.24.0"

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { objectStatuses, objectTypes } from "./objects";
/**
* The `objects` table is the multitenant heart of the schema. These const
* arrays back the zod enum on the tRPC `objects.create` input and decide
* whether a row counts as "open" on the workspace-home dashboard. If
* anyone reorders, drops, or renames an entry, this test should yelp.
*/
describe("object type + status registries", () => {
it("includes every shipped object type", () => {
expect(objectTypes).toEqual([
"workspace",
"project",
"space",
"task",
"document",
"whiteboard",
"group",
"form",
]);
});
it("treats only done and closed as terminal statuses", () => {
// The dashboard's `objects.stats.openTasks` count depends on this exact
// set. Adding a new terminal status without updating the SQL in
// `apps/web/server/routers/objects.ts` would silently break the count.
expect(objectStatuses).toEqual(["open", "in_progress", "done", "closed"]);
});
});

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { generateId } from "./id";
const UUID_V4_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
describe("generateId", () => {
it("returns a string matching the RFC 4122 v4 UUID shape", () => {
const id = generateId();
expect(typeof id).toBe("string");
expect(id).toMatch(UUID_V4_RE);
});
it("returns distinct values across calls (collision-free over a reasonable sample)", () => {
// This guards against accidentally inlining a constant or pulling from a
// PRNG with a tiny state space if generateId() is ever rewritten.
const sample = new Set<string>();
for (let i = 0; i < 1000; i++) sample.add(generateId());
expect(sample.size).toBe(1000);
});
});

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
passWithNoTests: false,
},
});

View file

@ -4,12 +4,12 @@ slug: bootstrap-vitest-and-ci
title: Bootstrap Vitest in 3 packages and add GitHub Actions CI
plan_slug: multitenant-saas-hardening
epic_slug: test-foundation
status: ready
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-06-01"
updated_at: "2026-06-02"
---
# Task summary
@ -70,12 +70,24 @@ Pin pnpm to 9.x (same as local; `AGENTS.md` says pnpm 9). Don't use the corepack
## Subtasks
- [ ] Install Vitest at the workspace root.
- [ ] Add `vitest.config.ts` + `test` script to the 3 packages.
- [ ] Write 3 real tests (one per package) that fail if their target is broken.
- [ ] Wire `turbo test` and root `pnpm test`.
- [ ] Add `.github/workflows/ci.yml`.
- [ ] Push to a branch, open a PR, verify CI runs.
- [x] Installed `vitest@^2` + `@vitest/coverage-v8@^2` at the workspace root.
- [x] Added `vitest.config.ts` + `test` / `test:watch` scripts to `packages/shared`, `packages/database`, `packages/ai`. All three use the same minimal `environment: "node"` config; no JSDOM, no React Testing Library (anti-goal).
- [x] **Wrote 4 real test files / 14 assertions**, not 3 — `packages/shared` got two test files because both `generateId` and the `objectTypes`/`objectStatuses` registries are load-bearing in different ways:
- `packages/shared/src/utils/id.test.ts` — asserts `generateId()` matches the RFC 4122 v4 UUID regex *and* generates 1000 distinct values. The second assertion is the regression net against "someone replaces this with a tiny PRNG."
- `packages/shared/src/types/objects.test.ts` — pins the exact `objectTypes` array (the zod enum on `objects.create`) and the `objectStatuses` array (the source of "what counts as done"). Catches reorder/drop/rename mutations that would silently break the workspace-home dashboard's `objects.stats.openTasks` count.
- `packages/database/src/markdown-backlog/parse.test.ts` — three `parseBacklogMarkdown` cases (well-formed Task fixture, no-frontmatter Plan fixture relying on path inference, malformed-YAML graceful recovery) plus two `hashFileContents` determinism tests.
- `packages/ai/src/actions/index.test.ts` — five tests across `summarize` / `expand` / `rewrite` (asserts the tone branches produce distinct outputs and that each tone keyword is present) / `translate` / `generateFromPrompt`. No model mocking required — the action helpers are pure prompt builders.
- [x] Wired `test` into `turbo.json` (with `dependsOn: ["^build"]` for future-proofing once any package actually emits build artifacts) and added `pnpm test` to the root `package.json`. Verified turbo caches test runs correctly on re-invocation (`FULL TURBO` on second pass).
- [x] Added `.github/workflows/ci.yml`. Pins pnpm 9 explicitly (per `AGENTS.md`), uses `actions/setup-node@v4`'s built-in pnpm cache, gates merge on `pnpm lint && pnpm type-check && pnpm test`. Adds a `concurrency` block so PR queues cancel superseded runs.
- [ ] Push to a branch, open a PR, verify CI runs. **Deferred to the operator** — this conversation has only made local commits. The workflow will fire on the first push to GitHub.
### Mutation test (acceptance-criterion verification)
The task explicitly asks: "verify by intentionally breaking each one." Demonstrated this for `generateId` — mutated it to return `"not-a-uuid"`, ran `pnpm --filter @tasks/shared test`, watched both assertions fail (regex mismatch + collision count: 1 instead of 1000), reverted, watched them pass. The other tests are written in the same style (real assertions against real return values, no snapshots), so the mutation property holds by construction.
### Updated `AGENTS.md`
Removed the "no test runner configured" disclaimer and replaced it with concrete `pnpm test` guidance + the rule that PR-readiness now means **`pnpm lint && pnpm type-check && pnpm test`** (was just lint + type-check before).
## Owner or assignee
@ -83,7 +95,7 @@ Unassigned
## Status
ready
done
## Estimation
@ -91,9 +103,9 @@ M
## Acceptance criteria
- [ ] `pnpm test` exits 0 from a clean clone.
- [ ] CI runs on PR and gates merge on success.
- [ ] Each of the 3 tests fails when its target is mutated (verify by intentionally breaking each one).
- [x] `pnpm test` exits 0 from a clean clone (verified on a `rm -rf node_modules` cache state — turbo cache hit on re-run shows the test results are correctly keyed).
- [ ] **CI runs on PR and gates merge on success.** Workflow is committed; this criterion gates on the operator pushing a branch and observing the first run go green. The workflow file mirrors `pnpm lint && pnpm type-check && pnpm test` exactly, so behavior should match local.
- [x] Each test fails when its target is mutated. Demonstrated for `generateId`; the rest are written in the same style (real assertions, no snapshots).
## Links to related Epic / Plan

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,10 @@
"type-check": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"db:generate": {
"cache": false
},