diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4207e7d4..4c8c1521 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,6 +180,29 @@ jobs: - name: Typecheck (whole repo) run: bun run typecheck + remaining: + # Test directories without a package job above, so no suite escapes CI. + name: check (codex, mcp, opencode-v2, acceptance) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - uses: actions/setup-node@v7 + with: + node-version: ${{ env.NODE_CURRENT }} + + - name: Install deps + run: bun install --frozen-lockfile + + - name: Test codex, mcp, opencode-v2, acceptance + run: bun test test/workit-codex test/workit-mcp test/opencode-v2 test/acceptance + shared: # test/shared/helpers has no standalone tests (helpers are exercised via the # importing packages' jobs); the job guards that the helpers still compile. @@ -216,12 +239,8 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] node: [24.20.0] steps: - # The phase-9 traceability gate asserts the DECLARED branch is active; - # the default PR checkout is detached at the merge commit, so check out - # the head ref (fallback: the default ref, e.g. main pushes). - uses: actions/checkout@v7 with: - ref: ${{ github.head_ref }} fetch-depth: 0 - uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/windows-inner-suite-probe.yml b/.github/workflows/windows-inner-suite-probe.yml deleted file mode 100644 index fe3b8592..00000000 --- a/.github/workflows/windows-inner-suite-probe.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Windows inner-suite probe - -# AR-14 diagnostic (dispatch-only): reproduces the exact inner `bun test` run -# the AR-14 negative-fixture test performs (handoff + sdd + repo suites) on -# windows-latest, with FULL output so the real failing test name is visible — -# the AR-14 assertion truncates stderr to 2000 chars and hides it. - -on: - workflow_dispatch: - -jobs: - probe: - runs-on: windows-latest - steps: - - uses: actions/checkout@v7 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.4.1" - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Run the AR-14 inner suites with full output - run: bun test test/workit-core/handoff.test.ts test/workit-core/sdd.test.ts test/workit-core/repo.test.ts diff --git a/bunfig.toml b/bunfig.toml index 6d046a0a..4cf63387 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -5,3 +5,7 @@ linker = "hoisted" # fires when a pin is deliberately moved to a fresh release. Machine-global # `min-release-age` in ~/.npmrc still guards every other project. minimumReleaseAge = 0 + +[test] +# Fail fast with one clear message when the PATH node is older than Node 24. +preload = ["./test/shared/node-guard.ts"] diff --git a/package.json b/package.json index e519ea9f..ebe4bfb3 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,10 @@ "knip": "knip --no-progress --no-config-hints && knip --no-progress --production --files && bun scripts/check-reachability.ts", "format": "oxfmt packages/workit-core/src packages/workit-mcp/src packages/workit-opencode/src packages/workit-cli/src packages/workit-cursor/mcp/run-server.ts packages/workit-codex/hooks packages/workit-codex/scripts packages/workit-pi/src packages/workit-pi/extensions test '!test/fixtures/cursor-schemas/**' package.json packages/*/package.json tsconfig.json", "format:check": "oxfmt --check packages/workit-core/src packages/workit-mcp/src packages/workit-opencode/src packages/workit-cli/src packages/workit-cursor/mcp/run-server.ts packages/workit-codex/hooks packages/workit-codex/scripts packages/workit-pi/src packages/workit-pi/extensions test '!test/fixtures/cursor-schemas/**' package.json packages/*/package.json tsconfig.json", - "test": "bun test", + "test": "bun scripts/test.ts unit", + "test:packaging": "bun scripts/test.ts packaging", "typecheck": "tsc --noEmit", - "check": "bun run build && bun run lint && bun run format:check && bun test && tsc --noEmit" + "check": "bun run build && bun run lint && bun run format:check && bun run test && bun run test:packaging && tsc --noEmit" }, "devDependencies": { "@earendil-works/pi-coding-agent": "0.85.1", diff --git a/packages/workit-core/src/core/host-install.ts b/packages/workit-core/src/core/host-install.ts index 57945b4b..42c911b5 100644 --- a/packages/workit-core/src/core/host-install.ts +++ b/packages/workit-core/src/core/host-install.ts @@ -1,5 +1,13 @@ import { spawnSync } from "node:child_process"; -import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { + accessSync, + constants, + existsSync, + readFileSync, + readdirSync, + realpathSync, + statSync, +} from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -448,6 +456,14 @@ export function runHostCommand( } const findNpmCli = (npm: string): string | null => { + // `npm` is usually a symlink to npm-cli.js (Node's bin/npm, a package's + // node_modules/.bin/npm); resolve it before guessing install layouts. + try { + const resolved = realpathSync(npm); + if (path.basename(resolved) === "npm-cli.js") return resolved; + } catch { + // Fall through to the layout candidates. + } const dir = path.dirname(npm); const candidates = [ path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"), diff --git a/scripts/test.ts b/scripts/test.ts new file mode 100644 index 00000000..f8475dbe --- /dev/null +++ b/scripts/test.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env bun +// Test tiers. `bun run test` runs the fast domain/unit tier; `bun run +// test:packaging` runs the suites that pack tarballs, run npm/Pi installs, +// drive doctor against installed artifacts, or need docker. Packaging suites +// build what they pack (packWorkspacePackages builds every adapter into a +// sandbox), so neither tier needs `bun run build` first. Plain `bun test` +// still runs everything. Extra arguments pass through to `bun test`. +import { spawnSync } from "node:child_process"; + +const PACKAGING = [ + "test/artifacts/**", + "test/opencode-v2/contract.test.ts", + "test/opencode-v2/lifecycle.test.ts", + "test/opencode-v2/matrix.test.ts", + "test/workit-cli/doctor.test.ts", + "test/workit-cli/packed-cli.test.ts", + "test/workit-cli/platform-install.test.ts", + "test/workit-codex/packed-launcher.test.ts", + "test/workit-core/cursor-install-mcp.test.ts", + "test/workit-core/doctor.test.ts", + "test/workit-core/install-scripts.test.ts", + "test/workit-pi/stock-pi.test.ts", +]; + +const [tier, ...rest] = process.argv.slice(2); +if (tier !== "unit" && tier !== "packaging") { + console.error("usage: bun scripts/test.ts [bun test args]"); + process.exit(2); +} +const selection = + tier === "packaging" + ? PACKAGING.map((pattern) => `./${pattern.replace(/\/\*\*$/, "")}`) + : PACKAGING.map((pattern) => `--path-ignore-patterns=${pattern}`); +const run = spawnSync("bun", ["test", ...selection, ...rest], { stdio: "inherit" }); +process.exit(run.status ?? 1); diff --git a/test/artifacts/cursor-install-invariants.test.ts b/test/artifacts/cursor-install-invariants.test.ts index 4844bb2e..71fa3f23 100644 --- a/test/artifacts/cursor-install-invariants.test.ts +++ b/test/artifacts/cursor-install-invariants.test.ts @@ -68,7 +68,6 @@ const byName = (packs: ReturnType, name: string) = test( "Cursor build, package, and packed CLI doctor enforce exact canonical method skills and Workit identity", () => { - expect(WORKIT).toHaveLength(14); const fixture = mkdtempSync(path.join(os.tmpdir(), "wk-cursor-invariants-")); try { const missingWorkitRepo = copyBuildFixture(path.join(fixture, "missing-workit")); diff --git a/test/artifacts/manifests.test.ts b/test/artifacts/manifests.test.ts index 5f8a3f7c..3bb12bd3 100644 --- a/test/artifacts/manifests.test.ts +++ b/test/artifacts/manifests.test.ts @@ -2,7 +2,10 @@ import { expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; -import { WORKIT_METHOD_SKILLS } from "@/packages/workit-core/src/core/skill-manifests"; +import { + WORKIT_METHOD_SKILLS, + WORKIT_SKILL_ALIASES, +} from "@/packages/workit-core/src/core/skill-manifests"; import { SUPPORT_MATRIX } from "@/packages/workit-core/src/core/support-matrix"; import { listTarball, @@ -248,22 +251,7 @@ test( for (const skill of WORKIT_METHOD_SKILLS) { expect(entries, `skills/${skill}/SKILL.md`).toContain(`skills/${skill}/SKILL.md`); } - for (const alias of [ - "wk-babysit", - "wk-blast-radius", - "wk-challenge", - "wk-debug", - "wk-deslop", - "wk-diagram", - "wk-green-run", - "wk-handoff", - "wk-implement", - "wk-mockup", - "wk-plan", - "wk-review", - "wk-steer", - "wk-tdd", - ]) { + for (const alias of Object.keys(WORKIT_SKILL_ALIASES)) { expect(entries, `commands/${alias}.md`).toContain(`commands/${alias}.md`); } expect( diff --git a/test/artifacts/opencode-skill-contract.test.ts b/test/artifacts/opencode-skill-contract.test.ts index cf86e191..1b999612 100644 --- a/test/artifacts/opencode-skill-contract.test.ts +++ b/test/artifacts/opencode-skill-contract.test.ts @@ -20,7 +20,6 @@ const tarballSkillNames = (tarball: string, prefix: string): string[] => .sort(); test("opencode packed tarball ships exactly the canonical method skills", () => { - expect(WORKIT).toHaveLength(14); const tarball = byName(packWorkspacePackages(), OPENCODE).tarball; expect(tarballSkillNames(tarball, "assets/skills/")).toEqual(WORKIT); expect(tarballSkillNames(tarball, "assets/vendor/superpowers/skills/")).toEqual([]); diff --git a/test/artifacts/package-contents.test.ts b/test/artifacts/package-contents.test.ts index 3f29330e..46416805 100644 --- a/test/artifacts/package-contents.test.ts +++ b/test/artifacts/package-contents.test.ts @@ -1,14 +1,6 @@ import { expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, -} from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { @@ -72,28 +64,12 @@ test("runtime TS allowlist permits vendor examples and exactly the root loader s } }); -test("opencode tarball ships one bundled dist entry plus fourteen method skills (RR-02/PT-06/PT-07)", () => { +test("opencode tarball ships one bundled dist entry and no commands, templates or vendor trees", () => { const packs = packWorkspacePackages(); const tarball = byName(packs, OPENCODE).tarball; const entries = listTarball(tarball); expect(entries).toContain("dist/plugin.js"); - expect(entries.filter((e) => e.startsWith("assets/skills/") && e.endsWith("/SKILL.md"))).toEqual([ - "assets/skills/workit-babysit/SKILL.md", - "assets/skills/workit-behavioral-tdd/SKILL.md", - "assets/skills/workit-blast-radius/SKILL.md", - "assets/skills/workit-challenge/SKILL.md", - "assets/skills/workit-debug/SKILL.md", - "assets/skills/workit-deslop/SKILL.md", - "assets/skills/workit-diagram/SKILL.md", - "assets/skills/workit-green-run/SKILL.md", - "assets/skills/workit-handoff/SKILL.md", - "assets/skills/workit-implement/SKILL.md", - "assets/skills/workit-mockup/SKILL.md", - "assets/skills/workit-plan/SKILL.md", - "assets/skills/workit-review/SKILL.md", - "assets/skills/workit-steer/SKILL.md", - ]); expect(entries.some((e) => e.startsWith("assets/commands/"))).toBe(false); expect(entries.some((e) => e.startsWith("assets/templates/"))).toBe(false); expect(entries.some((e) => e.startsWith("assets/vendor/"))).toBe(false); @@ -181,16 +157,6 @@ test("core tarball keeps its source package layout without legacy vendor shell ( expect(entries).not.toContain("scripts/verify-project.sh"); }); -test("no packed package.json carries a workspace:, file: or git: protocol (CA-03)", () => { - const packs = packWorkspacePackages(); - for (const pack of packs) { - const raw = readTarballFile(pack.tarball, "package.json"); - expect(raw, pack.packageName).not.toContain("workspace:"); - expect(raw, pack.packageName).not.toContain("file:"); - expect(raw, pack.packageName).not.toContain("git:"); - } -}); - test("packed runtime JS imports no core source subpaths and no checkout paths", () => { const packs = packWorkspacePackages(); const normalized = REPO_ROOT.split(path.sep).join("/"); @@ -403,16 +369,3 @@ test("adapter tarballs ship no legacy vendor trees", () => { ).toBe(false); } }); - -test("tracked CLI template mirrors stay byte-identical to the core templates", () => { - // The CLI package tracks copies of the execution templates (shipped to - // projects by hygiene scaffolding); a fix in one copy must land in both. - for (const name of ["execution-contract.md", "plan-template.md"]) { - const core = readFileSync(path.join(REPO_ROOT, "packages/workit-core/templates", name), "utf8"); - const mirror = readFileSync( - path.join(REPO_ROOT, "packages/workit-cli/assets/templates", name), - "utf8", - ); - expect(mirror, name).toBe(core); - } -}); diff --git a/test/artifacts/packed-runtime.test.ts b/test/artifacts/packed-runtime.test.ts index e97d2f17..81989bbe 100644 --- a/test/artifacts/packed-runtime.test.ts +++ b/test/artifacts/packed-runtime.test.ts @@ -19,11 +19,14 @@ import { installPackedPackage, isolatedEnv, npmRegistryReachable, - packReleaseCandidate, packWorkspacePackages, tarballSpec, REPO_ROOT, } from "@/test/shared/helpers/packages"; +import { + WORKIT_METHOD_SKILLS, + WORKIT_SKILL_ALIASES, +} from "@/packages/workit-core/src/core/skill-manifests"; // Task 7 packed-runtime gate: from EXTRACTED tarballs with repository node_modules // unavailable, the packaged adapters load/boot under plain Node without Bun or a @@ -208,8 +211,8 @@ test( }; const cleanup = await mod.default.setup(fake as never); if (typeof cleanup === "function") cleanup(); - expect(skills).toHaveLength(14); - expect(commands).toHaveLength(14); + expect(skills).toHaveLength(WORKIT_METHOD_SKILLS.length); + expect(commands).toHaveLength(Object.keys(WORKIT_SKILL_ALIASES).length); for (const skill of skills) { expect(existsSync(skill.path), skill.id).toBe(true); expect(realpathSync(skill.path).startsWith(realpathSync(skillsPath)), skill.id).toBe(true); @@ -498,20 +501,6 @@ test( { timeout: 60_000 }, ); -test( - "the runtime gate exercises the same final candidate artifacts (CA-30)", - () => { - // Both calls resolve to the same cached pack, so this asserts the gate uses - // the final candidate, not fresh-vs-cached bytes. Byte-stability across a - // forced repack is proven in release-candidate.test.ts ("a fresh repack - // yields byte-identical sha256") and phase-0-candidate.test.ts (D12). - const candidate = packReleaseCandidate(); - const packs = packWorkspacePackages(); - expect(candidate.map((p) => p.sha256)).toEqual(packs.map((p) => p.sha256)); - }, - { timeout: 60_000 }, -); - test( "declared platform matrix is pinned across CI, engines, and lockfiles (PT-11/PT-12)", () => { diff --git a/test/artifacts/phase-0-candidate.test.ts b/test/artifacts/phase-0-candidate.test.ts index d7b2a4ce..3fd098c3 100644 --- a/test/artifacts/phase-0-candidate.test.ts +++ b/test/artifacts/phase-0-candidate.test.ts @@ -18,7 +18,6 @@ import { isolatedEnv, listTarball, npmRegistryReachable, - packReleaseCandidate, packWorkspacePackages, tarballSpec, readTarballFile, @@ -52,9 +51,6 @@ if (!npmRegistryOk) { const tmp = (prefix: string) => mkdtempSync(path.join(os.tmpdir(), prefix)); -const hasEntry = (tarball: string, prefix: string) => - listTarball(tarball).some((entry) => entry === prefix || entry.startsWith(prefix)); - test( "isolatedEnv strips script-specific path overrides that could re-point at the repo", () => { @@ -100,36 +96,6 @@ test( { timeout: 60_000 }, ); -test( - "the final release candidate is byte-stable with the phase-0 pack (CA-30)", - () => { - // Force a fresh pack against the earlier (cached) phase-0 pack: comparing two - // calls that both hit the module cache would be comparing an array with - // itself (D12). Order matters — packWorkspacePackages() must run first so the - // force actually repacks. - const packs = packWorkspacePackages(); - const candidate = packReleaseCandidate({ force: true }); - expect(candidate.map((p) => p.sha256)).toEqual(packs.map((p) => p.sha256)); - }, - { timeout: 60_000 }, -); - -test( - "packed adapter core dependency equals the packed core version (RR-01)", - () => { - const packs = packWorkspacePackages(); - const coreVersion = JSON.parse( - readTarballFile(packs.find((p) => p.packageName === CORE)!.tarball, "package.json"), - ).version; - for (const name of [MCP, OPENCODE, CURSOR, CODEX, CLI]) { - const pack = packs.find((p) => p.packageName === name)!; - const pkg = JSON.parse(readTarballFile(pack.tarball, "package.json")); - expect(pkg.dependencies["@brainervirus/workit-core"], name).toBe(`^${coreVersion}`); - } - }, - { timeout: 60_000 }, -); - test( "packed tarballs carry no workspace: or local protocols, only valid ranges (CA-03)", () => { @@ -150,55 +116,6 @@ test( { timeout: 60_000 }, ); -test( - "expected entry files ship in each packed tarball", - () => { - const packs = packWorkspacePackages(); - const byName = (name: string) => packs.find((p) => p.packageName === name)!; - - const opencode = byName(OPENCODE).tarball; - expect(hasEntry(opencode, "dist/plugin.js")).toBe(true); - expect(hasEntry(opencode, "assets/skills/")).toBe(true); - - const cursor = byName(CURSOR).tarball; - for (const f of [ - "dist/mcp-server.js", - "dist/cursor-session-start.js", - "dist/workit-hook.js", - "mcp.json", - "assets/logo.svg", - ".cursor-plugin/plugin.json", - "hooks/hooks-cursor.json", - ]) { - expect(hasEntry(cursor, f), f).toBe(true); - } - expect(hasEntry(cursor, "mcp/run-server.sh")).toBe(false); - expect(hasEntry(cursor, "hooks/session-start")).toBe(false); - const cursorPkg = JSON.parse(readTarballFile(cursor, "package.json")); - expect(cursorPkg.bin["workit-cursor-mcp"]).toBe("./dist/mcp-server.js"); - expect(cursorPkg.bin["workit-cursor-session-start"]).toBe("./dist/cursor-session-start.js"); - expect(cursorPkg.bin["workit-cursor-hook"]).toBe("./dist/workit-hook.js"); - - const core = byName(CORE).tarball; - for (const f of [ - "src/core.ts", - "scripts/rewrite-workspace-deps.ts", - "scripts/install-opencode-plugin.sh", - "scripts/sync-runtime.sh", - "templates/", - "skills/", - ]) { - expect(hasEntry(core, f), f).toBe(true); - } - - const cli = byName(CLI).tarball; - expect(hasEntry(cli, "dist/index.js")).toBe(true); - const cliPkg = JSON.parse(readTarballFile(cli, "package.json")); - expect(cliPkg.bin.workit).toBe("./dist/index.js"); - }, - { timeout: 60_000 }, -); - type McpClient = { child: ChildProcess; request: (method: string, params: unknown) => Promise<{ result?: unknown; error?: unknown }>; diff --git a/test/artifacts/phase-9-traceability.test.ts b/test/artifacts/phase-9-traceability.test.ts deleted file mode 100644 index 401f43c4..00000000 --- a/test/artifacts/phase-9-traceability.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -import { expect, test } from "bun:test"; -import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { REPO_ROOT } from "@/test/shared/helpers/packages"; - -const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -// Task 31 Phase 9 traceability gate (AR-15, CA-44, CA-45): every post-audit -// finding (POST-01..15), every AR row (AR-01..15), and every Phase 9 -// acceptance criterion (CA-33..45) maps to an exact test file+case or an -// exact command. A row is prose-only when its evidence target does not exist; -// the gate fails naming those rows. Evidence for behavior proven by Tasks -// 24-30 is referenced here, never re-implemented. - -const DECLARED_BRANCH = "feature/workit-reliability-overhaul"; -const PLAN = path.join(REPO_ROOT, "docs", "workit-reliability-overhaul", "plan.md"); -const SPEC = path.join(REPO_ROOT, "docs", "workit-reliability-overhaul", "spec.md"); - -type Row = { row: string; requirement: string; evidence: string[] }; - -// Evidence grammar: "test-file::exact test name" resolves against the named -// test file; "command:bun run