diff --git a/.changeset/fn-9034-file-scoped-vitest.md b/.changeset/fn-9034-file-scoped-vitest.md new file mode 100644 index 0000000000..8fc0156392 --- /dev/null +++ b/.changeset/fn-9034-file-scoped-vitest.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make Quality file-scoped tests run with each package's local Vitest binary. +category: fix +dev: Resolves package ownership from the execution worktree and uses Vitest's default reporter. diff --git a/docs/testing.md b/docs/testing.md index 462001f59b..0d79a46822 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -54,6 +54,10 @@ pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (N `pnpm check:workspace-package-graph` verifies that every `workspace:` dependency or override in the root importer or a glob-matched workspace manifest resolves to a glob-covered workspace package, and that no package directory under `packages/` or `plugins/` falls outside `pnpm-workspace.yaml` package globs. +### Quality file-scoped preset + +Commands emitted by the Quality `file-scoped` preset run the package-local binary as `pnpm --filter exec vitest run `. Package ownership is resolved from the execution cwd, so task worktrees and disposable QA worktrees use their own workspace metadata. The emitted command passes no reporter flag and relies on Vitest's default reporter; Vitest 4 removed `--reporter=basic`, which must never be emitted. This applies only to the Quality command builder: configured `--reporter=dot` call sites and the documented executor scoped-verification command remain valid Vitest 4 usage. + `pnpm build` runs `scripts/build-workspace.mjs`: non-plugin workspace packages with build scripts still build on every run (excluding `@fusion/desktop` and `@fusion/mobile`), while plugin packages under `plugins/` and `plugins/examples/` can be skipped when `.fusion/cache/plugin-build-cache.json` records the same content hash as the current plugin package inputs plus declared local workspace-dependency inputs, root TypeScript/pnpm/build-tooling inputs, and all required `dist/` outputs are present. A plugin rebuild is forced for a missing or partial `dist/`, no successful-build cache entry, changed tracked or untracked plugin/dependency/root build inputs, unavailable git content hash, or build-cache version changes. The cache is an optimization only; cache writes are best-effort and a failed package build still makes `pnpm build` exit nonzero with the planned package names. diff --git a/plugins/fusion-plugin-quality/src/__tests__/command-presets.test.ts b/plugins/fusion-plugin-quality/src/__tests__/command-presets.test.ts index 445bea876e..7822a76351 100644 --- a/plugins/fusion-plugin-quality/src/__tests__/command-presets.test.ts +++ b/plugins/fusion-plugin-quality/src/__tests__/command-presets.test.ts @@ -1,11 +1,49 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { + QUALITY_PRESET_IDS, isQualityPresetId, isSafeFilePathToken, resolvePresetCommand, } from "../runner/command-presets.js"; +const ENGINE_TEST = "packages/engine/src/__tests__/foo.test.ts"; + +function writePackage(root: string, packageRoot: string, packageName: string): void { + mkdirSync(join(root, packageRoot, "src", "__tests__"), { recursive: true }); + writeFileSync(join(root, packageRoot, "package.json"), JSON.stringify({ name: packageName })); +} + +function writeWorkspace(root: string, enginePackageName: string, includeWorkspace = true): void { + if (includeWorkspace) { + writeFileSync(join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n - 'plugins/*'\n"); + } + writePackage(root, "packages/engine", enginePackageName); +} + +function expectDefaultReporter(command: string): void { + expect(command).not.toContain("--reporter"); + expect(command).not.toContain("--reporter=basic"); +} + describe("command-presets", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function createWorkspace(name: string, enginePackageName: string, includeWorkspace = true): string { + const root = mkdtempSync(join(tmpdir(), `quality-command-presets-${name}-`)); + tempDirs.push(root); + writeWorkspace(root, enginePackageName, includeWorkspace); + return root; + } + it("accepts known preset ids only", () => { expect(isQualityPresetId("verify-fast")).toBe(true); expect(isQualityPresetId("evil")).toBe(false); @@ -37,23 +75,181 @@ describe("command-presets", () => { expect(ok.ok).toBe(true); }); - it("builds file-scoped command from safe paths only", () => { - const r = resolvePresetCommand({ - preset: "file-scoped", - projectRoot: "/repo", - filePaths: ["src/a.ts", "../etc/passwd", "src/b.ts"], + describe("file-scoped execution-cwd resolution", () => { + it("uses projectRoot when cwd is omitted", () => { + const projectRoot = createWorkspace("project", "@fusion/engine"); + const result = resolvePresetCommand({ preset: "file-scoped", projectRoot, filePaths: [ENGINE_TEST] }); + + expect(result).toMatchObject({ + ok: true, + command: "pnpm --filter '@fusion/engine' exec vitest run 'src/__tests__/foo.test.ts'", + }); + if (result.ok) expectDefaultReporter(result.command); }); - expect(r.ok).toBe(true); - if (r.ok) { - expect(r.command).toContain("vitest run"); - expect(r.command).toContain("src/a.ts"); - expect(r.command).not.toContain("passwd"); + + it("uses projectRoot when cwd equals projectRoot", () => { + const projectRoot = createWorkspace("project-cwd", "@fusion/engine"); + const result = resolvePresetCommand({ + preset: "file-scoped", + projectRoot, + cwd: projectRoot, + filePaths: [ENGINE_TEST], + }); + + expect(result).toMatchObject({ + ok: true, + command: "pnpm --filter '@fusion/engine' exec vitest run 'src/__tests__/foo.test.ts'", + }); + if (result.ok) expectDefaultReporter(result.command); + }); + + it("uses a divergent task worktree as the workspace resolution root", () => { + const projectRoot = createWorkspace("project-root", "@fusion/engine"); + const worktree = createWorkspace("task-worktree", "@fusion/engine-worktree"); + const result = resolvePresetCommand({ + preset: "file-scoped", + projectRoot, + cwd: worktree, + filePaths: [ENGINE_TEST], + }); + + expect(result).toMatchObject({ + ok: true, + command: "pnpm --filter '@fusion/engine-worktree' exec vitest run 'src/__tests__/foo.test.ts'", + }); + if (result.ok) { + expect(result.command).not.toContain("@fusion/engine'"); + expectDefaultReporter(result.command); + } + }); + + it("does not fall back to projectRoot workspace metadata for a QA worktree without pnpm-workspace.yaml", () => { + const projectRoot = createWorkspace("project-with-workspace", "@fusion/engine"); + const qaWorktree = createWorkspace("qa-worktree", "@fusion/engine-qa", false); + const result = resolvePresetCommand({ + preset: "file-scoped", + projectRoot, + cwd: qaWorktree, + filePaths: [ENGINE_TEST], + }); + + expect(result).toMatchObject({ + ok: true, + command: "pnpm exec vitest run 'packages/engine/src/__tests__/foo.test.ts'", + }); + if (result.ok) { + expect(result.command).not.toContain("--filter"); + expectDefaultReporter(result.command); + } + }); + + it("falls back without throwing when cwd is unreadable or missing", () => { + const projectRoot = createWorkspace("project-missing-cwd", "@fusion/engine"); + const result = resolvePresetCommand({ + preset: "file-scoped", + projectRoot, + cwd: join(projectRoot, "does-not-exist"), + filePaths: [ENGINE_TEST], + }); + + expect(result).toMatchObject({ + ok: true, + command: "pnpm exec vitest run 'packages/engine/src/__tests__/foo.test.ts'", + }); + if (result.ok) expectDefaultReporter(result.command); + }); + }); + + it("emits sorted package-local segments for multiple packages and plugins", () => { + const projectRoot = createWorkspace("multi-package", "@fusion/engine"); + writePackage(projectRoot, "packages/core", "@fusion/core"); + writePackage(projectRoot, "plugins/quality", "@fusion-plugin-examples/quality"); + const result = resolvePresetCommand({ + preset: "file-scoped", + projectRoot, + filePaths: [ + "plugins/quality/src/__tests__/quality.test.ts", + ENGINE_TEST, + "packages/core/src/__tests__/core.test.ts", + ], + }); + + expect(result).toMatchObject({ + ok: true, + command: + "pnpm --filter '@fusion/core' exec vitest run 'src/__tests__/core.test.ts' && pnpm --filter '@fusion/engine' exec vitest run 'src/__tests__/foo.test.ts' && pnpm --filter '@fusion-plugin-examples/quality' exec vitest run 'src/__tests__/quality.test.ts'", + }); + if (result.ok) expectDefaultReporter(result.command); + }); + + it("excludes unresolved paths and uses the root-local form when none resolve", () => { + const projectRoot = createWorkspace("unresolved", "@fusion/engine"); + const mixed = resolvePresetCommand({ + preset: "file-scoped", + projectRoot, + filePaths: [ENGINE_TEST, "docs/testing.md"], + }); + expect(mixed).toMatchObject({ + ok: true, + command: "pnpm --filter '@fusion/engine' exec vitest run 'src/__tests__/foo.test.ts'", + }); + + const unresolved = resolvePresetCommand({ + preset: "file-scoped", + projectRoot, + filePaths: ["docs/testing.md", "scripts/foo.test.mjs"], + }); + expect(unresolved).toMatchObject({ + ok: true, + command: "pnpm exec vitest run 'docs/testing.md' 'scripts/foo.test.mjs'", + }); + if (unresolved.ok) expectDefaultReporter(unresolved.command); + }); + + it("deduplicates paths and never emits unsafe tokens", () => { + const projectRoot = createWorkspace("safe-paths", "@fusion/engine"); + const result = resolvePresetCommand({ + preset: "file-scoped", + projectRoot, + filePaths: [ENGINE_TEST, ENGINE_TEST, "../etc/passwd", "a;rm -rf /", "/abs/path", "bad\npath"], + }); + expect(result).toMatchObject({ + ok: true, + command: "pnpm --filter '@fusion/engine' exec vitest run 'src/__tests__/foo.test.ts'", + }); + if (result.ok) { + expect(result.command).not.toContain("passwd"); + expect(result.command).not.toContain("rm -rf"); + expect(result.command).not.toContain("abs/path"); + expectDefaultReporter(result.command); } }); - it("rejects empty file-scoped", () => { - const r = resolvePresetCommand({ preset: "file-scoped", projectRoot: "/repo", filePaths: [] }); - expect(r.ok).toBe(false); + it("rejects empty and all-unsafe file paths", () => { + const empty = resolvePresetCommand({ preset: "file-scoped", projectRoot: "/repo", filePaths: [] }); + const unsafe = resolvePresetCommand({ + preset: "file-scoped", + projectRoot: "/repo", + filePaths: ["../etc/passwd", "a;rm -rf /", "/abs/path", "bad\0path", "bad\npath"], + }); + expect(empty).toMatchObject({ ok: false, code: "empty_files" }); + expect(unsafe).toMatchObject({ ok: false, code: "empty_files" }); + }); + + it("never emits the removed basic reporter for any resolved preset command", () => { + const projectRoot = createWorkspace("reporters", "@fusion/engine"); + const results = QUALITY_PRESET_IDS.map((preset) => + resolvePresetCommand({ + preset, + projectRoot, + filePaths: [ENGINE_TEST], + testCommand: "pnpm test", + confirmFullSuite: true, + }), + ); + for (const result of results) { + if (result.ok) expect(result.command).not.toContain("--reporter=basic"); + } }); it("rejects unsafe path tokens", () => { diff --git a/plugins/fusion-plugin-quality/src/routes/create-routes.ts b/plugins/fusion-plugin-quality/src/routes/create-routes.ts index b4b195a0bb..904d343e96 100644 --- a/plugins/fusion-plugin-quality/src/routes/create-routes.ts +++ b/plugins/fusion-plugin-quality/src/routes/create-routes.ts @@ -240,10 +240,15 @@ export function createQualityRoutes(): PluginRouteDefinition[] { typeof settings.verificationCommandTimeoutMs === "number" ? settings.verificationCommandTimeoutMs : undefined; + /* + FNXC:Quality 2026-08-13-22:33: + File-scoped package resolution must use this exact execution cwd, not just projectRoot, because live and disposable QA worktrees can have different workspace metadata. The same cwd is persisted on the run and passed to the shell runner below. + */ const resolved = resolvePresetCommand({ preset, testCommand, projectRoot: rootDir, + cwd, filePaths, confirmFullSuite, }); diff --git a/plugins/fusion-plugin-quality/src/runner/command-presets.ts b/plugins/fusion-plugin-quality/src/runner/command-presets.ts index 55f7eecb8b..4b97baf740 100644 --- a/plugins/fusion-plugin-quality/src/runner/command-presets.ts +++ b/plugins/fusion-plugin-quality/src/runner/command-presets.ts @@ -1,3 +1,5 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import type { QualityPresetId } from "../store/quality-types.js"; /* @@ -24,6 +26,8 @@ export interface ResolvePresetInput { testCommand?: string | null; /** Absolute project root */ projectRoot: string; + /** Execution cwd for the run; defaults to projectRoot. */ + cwd?: string; /** File paths relative to project/worktree for file-scoped preset */ filePaths?: string[]; confirmFullSuite?: boolean; @@ -45,6 +49,111 @@ export function isSafeFilePathToken(path: string): boolean { return true; } +/** Shell-safe single-argument quoting for commands evaluated by the runner shell. */ +function quoteArg(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function parseWorkspaceGlobs(workspaceYaml: string): string[] { + const globs: string[] = []; + let inPackages = false; + for (const rawLine of workspaceYaml.split("\n")) { + const line = rawLine.trimEnd(); + if (/^packages\s*:/.test(line)) { + inPackages = true; + continue; + } + if (!inPackages) continue; + if (/^\S/.test(line) && line.trim() !== "") break; + const match = line.match(/^\s+-\s+['"]?([^'"#\s]+)['"]?/); + if (match?.[1]) globs.push(match[1]); + } + return globs; +} + +function resolveWorkspacePackageRoots(rootDir: string, globs: string[]): string[] { + const roots = new Set(); + for (const glob of globs) { + const starIndex = glob.indexOf("*"); + if (starIndex === -1) { + if (existsSync(join(rootDir, glob, "package.json"))) roots.add(glob); + continue; + } + const prefix = glob.slice(0, starIndex).replace(/\/$/, ""); + let entries: string[]; + try { + entries = readdirSync(join(rootDir, prefix), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + continue; + } + for (const entry of entries) { + const packageRoot = `${prefix}/${entry}`; + if (existsSync(join(rootDir, packageRoot, "package.json"))) roots.add(packageRoot); + } + } + return [...roots]; +} + +function groupPathsByWorkspacePackage( + rootDir: string, + paths: string[], +): Array<{ root: string; name: string; paths: string[] }> { + let workspaceYaml: string; + try { + workspaceYaml = readFileSync(join(rootDir, "pnpm-workspace.yaml"), "utf8"); + } catch { + return []; + } + const packageRoots = resolveWorkspacePackageRoots(rootDir, parseWorkspaceGlobs(workspaceYaml)); + const groups = new Map }>(); + for (const path of paths) { + let matchedRoot: string | undefined; + for (const packageRoot of packageRoots) { + if (path.startsWith(`${packageRoot}/`) && (!matchedRoot || packageRoot.length > matchedRoot.length)) { + matchedRoot = packageRoot; + } + } + if (!matchedRoot) continue; + let group = groups.get(matchedRoot); + if (!group) { + try { + const parsed = JSON.parse(readFileSync(join(rootDir, matchedRoot, "package.json"), "utf8")) as { + name?: unknown; + }; + if (typeof parsed.name !== "string" || !parsed.name) continue; + group = { name: parsed.name, paths: new Set() }; + groups.set(matchedRoot, group); + } catch { + continue; + } + } + group.paths.add(path.slice(matchedRoot.length + 1)); + } + return [...groups.entries()] + .map(([root, group]) => ({ root, name: group.name, paths: [...group.paths].sort() })) + .filter((group) => group.paths.length > 0) + .sort((left, right) => left.root.localeCompare(right.root)); +} + +/* +FNXC:Quality 2026-08-13-22:28: +Quality file-scoped commands must invoke each package-local Vitest binary because the workspace root has no Vitest dependency. Resolve package ownership against the execution cwd because task runs execute in live or disposable QA worktrees, and emit no reporter flag because Vitest 4 removed basic and its default reporter is correct here. This boundary applies only to commands emitted below: configured --reporter=dot usage in engine merger commands, scripts, and package tests remains valid and intentionally untouched. +*/ +function buildFileScopedCommand(resolutionRoot: string, paths: string[]): string { + const groups = groupPathsByWorkspacePackage(resolutionRoot, paths); + if (groups.length === 0) { + return `pnpm exec vitest run ${paths.map(quoteArg).join(" ")}`; + } + return groups + .map( + (group) => + `pnpm --filter ${quoteArg(group.name)} exec vitest run ${group.paths.map(quoteArg).join(" ")}`, + ) + .join(" && "); +} + export function resolvePresetCommand(input: ResolvePresetInput): ResolvePresetResult { switch (input.preset) { case "project-test": { @@ -67,15 +176,13 @@ export function resolvePresetCommand(input: ResolvePresetInput): ResolvePresetRe if (paths.length === 0) { return { ok: false, reason: "No changed files for file-scoped run", code: "empty_files" }; } - const safe = paths.filter(isSafeFilePathToken); + const safe = [...new Set(paths.filter(isSafeFilePathToken))].sort(); if (safe.length === 0) { return { ok: false, reason: "No safe file paths for file-scoped run", code: "empty_files" }; } - // Prefer vitest path list; keep command server-built. - const joined = safe.map((p) => JSON.stringify(p)).join(" "); return { ok: true, - command: `pnpm exec vitest run ${joined}`, + command: buildFileScopedCommand(input.cwd ?? input.projectRoot, safe), label: "File-scoped tests", }; }