ubiquitous-invention/plans/Plan-multitenant-saas-hardening/Epic-test-foundation/Task-bootstrap-vitest-and-ci.md
Randall Stillwell 48defb2ffd 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>
2026-06-02 09:23:55 -05:00

113 lines
6.5 KiB
Markdown

---
kind: task
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: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-06-02"
---
# Task summary
Add Vitest to `packages/database`, `packages/shared`, and `packages/ai`. Write one real test in each. Wire `pnpm test` through Turborepo. Add `.github/workflows/ci.yml`.
## Description
### Vitest setup
Install at the workspace root:
```
pnpm -w add -D vitest @vitest/coverage-v8
```
In each target package, add `vitest.config.ts` (minimal, ESM) and a `test` script in `package.json`:
```json
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
```
Then in `turbo.json`, add a `test` pipeline entry that depends on `build` only where strictly needed (probably not for these packages):
```json
"test": {
"dependsOn": ["^build"],
"outputs": []
}
```
Add `pnpm test` to the root `package.json` scripts as `turbo test`.
### First tests (don't fake them)
- `packages/database`: pick one of the markdown-backlog helpers (`parseBacklogMarkdown` in `src/markdown-backlog/parse.ts`). Write a test that parses a known-good Plan-template fixture and asserts the fields. Then write a test for a malformed file (non-map front matter) and assert it throws the documented error. This is high-value: the importer's parsing rules are load-bearing.
- `packages/shared`: pick a zod schema and verify happy + sad paths. If `generateId` was just rewritten to use Web Crypto, add a test that asserts the output looks like a UUID v4 (regex match). Cheap, but it catches future "let's import randomUUID from somewhere again" regressions.
- `packages/ai`: harder because of provider env. Mock the provider in the test (Vercel AI SDK has `MockLanguageModel` patterns). Verify your prompt-assembly helper produces the right message array given a known input.
### GitHub Actions
Create `.github/workflows/ci.yml`:
- Runs on `pull_request` and `push` to `main`.
- Matrix: just Node 20 for now.
- Steps: checkout, setup-node, setup-pnpm, `pnpm install --frozen-lockfile`, `pnpm lint`, `pnpm type-check`, `pnpm test`.
- Cache pnpm store via `actions/cache`. Don't bother with Turbo Remote Cache — local Turbo caching inside the runner is enough at this scale.
Pin pnpm to 9.x (same as local; `AGENTS.md` says pnpm 9). Don't use the corepack auto-detect mode for now — explicit versions are more reproducible.
### Anti-goals
- No React Testing Library. No JSDOM environment. Adding browser-shape tests is its own project.
- No coverage thresholds in v1. Get tests running first; threshold-policing comes when there's enough surface to police.
## Subtasks
- [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
Unassigned
## Status
done
## Estimation
M
## Acceptance criteria
- [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
- Epic: `./Epic-test-foundation.md`
- Plan: `../Plan-multitenant-saas-hardening.md`