FN-9034: use package-local Vitest for scoped runs
Resolve Quality file-scoped tests against the execution worktree's package metadata. - Run scoped tests through each package's local Vitest binary - Omit the removed basic reporter and preserve safe path handling - Cover worktree resolution, package grouping, and reporter behavior Files changed: .changeset/fn-9034-file-scoped-vitest.md | 7 + docs/testing.md | 4 + .../src/__tests__/command-presets.test.ts | 222 +++++++++++++++++++-- .../src/routes/create-routes.ts | 5 + .../src/runner/command-presets.ts | 115 ++++++++++- 5 files changed, 336 insertions(+), 17 deletions(-) Fusion-Task-Id: FN-9034 Fusion-Task-Lineage: f78159b6-2f1e-47bb-a367-e8e4324143e5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9034-file-scoped-vitest.md
Normal file
7
.changeset/fn-9034-file-scoped-vitest.md
Normal file
@@ -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.
|
||||||
@@ -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.
|
`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 <pkg> exec vitest run <package-relative paths>`. 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.
|
||||||
|
|
||||||
<!-- FNXC:WorkspaceBuild 2026-06-30-00:00: FN-7290 keeps root pnpm build operator-facing while allowing unchanged plugin workspaces to skip their package build only when required dist outputs exist and a git-backed content hash matches the last successful plugin build cache entry. Missing dist, absent entries, changed plugin, declared local workspace-dependency, or root build config/tooling inputs, unavailable git hashes, or cache-version changes must rebuild rather than trust mtimes. -->
|
<!-- FNXC:WorkspaceBuild 2026-06-30-00:00: FN-7290 keeps root pnpm build operator-facing while allowing unchanged plugin workspaces to skip their package build only when required dist outputs exist and a git-backed content hash matches the last successful plugin build cache entry. Missing dist, absent entries, changed plugin, declared local workspace-dependency, or root build config/tooling inputs, unavailable git hashes, or cache-version changes must rebuild rather than trust mtimes. -->
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
|
QUALITY_PRESET_IDS,
|
||||||
isQualityPresetId,
|
isQualityPresetId,
|
||||||
isSafeFilePathToken,
|
isSafeFilePathToken,
|
||||||
resolvePresetCommand,
|
resolvePresetCommand,
|
||||||
} from "../runner/command-presets.js";
|
} 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", () => {
|
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", () => {
|
it("accepts known preset ids only", () => {
|
||||||
expect(isQualityPresetId("verify-fast")).toBe(true);
|
expect(isQualityPresetId("verify-fast")).toBe(true);
|
||||||
expect(isQualityPresetId("evil")).toBe(false);
|
expect(isQualityPresetId("evil")).toBe(false);
|
||||||
@@ -37,23 +75,181 @@ describe("command-presets", () => {
|
|||||||
expect(ok.ok).toBe(true);
|
expect(ok.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds file-scoped command from safe paths only", () => {
|
describe("file-scoped execution-cwd resolution", () => {
|
||||||
const r = resolvePresetCommand({
|
it("uses projectRoot when cwd is omitted", () => {
|
||||||
preset: "file-scoped",
|
const projectRoot = createWorkspace("project", "@fusion/engine");
|
||||||
projectRoot: "/repo",
|
const result = resolvePresetCommand({ preset: "file-scoped", projectRoot, filePaths: [ENGINE_TEST] });
|
||||||
filePaths: ["src/a.ts", "../etc/passwd", "src/b.ts"],
|
|
||||||
|
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) {
|
it("uses projectRoot when cwd equals projectRoot", () => {
|
||||||
expect(r.command).toContain("vitest run");
|
const projectRoot = createWorkspace("project-cwd", "@fusion/engine");
|
||||||
expect(r.command).toContain("src/a.ts");
|
const result = resolvePresetCommand({
|
||||||
expect(r.command).not.toContain("passwd");
|
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", () => {
|
it("rejects empty and all-unsafe file paths", () => {
|
||||||
const r = resolvePresetCommand({ preset: "file-scoped", projectRoot: "/repo", filePaths: [] });
|
const empty = resolvePresetCommand({ preset: "file-scoped", projectRoot: "/repo", filePaths: [] });
|
||||||
expect(r.ok).toBe(false);
|
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", () => {
|
it("rejects unsafe path tokens", () => {
|
||||||
|
|||||||
@@ -240,10 +240,15 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
|
|||||||
typeof settings.verificationCommandTimeoutMs === "number"
|
typeof settings.verificationCommandTimeoutMs === "number"
|
||||||
? settings.verificationCommandTimeoutMs
|
? settings.verificationCommandTimeoutMs
|
||||||
: undefined;
|
: 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({
|
const resolved = resolvePresetCommand({
|
||||||
preset,
|
preset,
|
||||||
testCommand,
|
testCommand,
|
||||||
projectRoot: rootDir,
|
projectRoot: rootDir,
|
||||||
|
cwd,
|
||||||
filePaths,
|
filePaths,
|
||||||
confirmFullSuite,
|
confirmFullSuite,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import type { QualityPresetId } from "../store/quality-types.js";
|
import type { QualityPresetId } from "../store/quality-types.js";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -24,6 +26,8 @@ export interface ResolvePresetInput {
|
|||||||
testCommand?: string | null;
|
testCommand?: string | null;
|
||||||
/** Absolute project root */
|
/** Absolute project root */
|
||||||
projectRoot: string;
|
projectRoot: string;
|
||||||
|
/** Execution cwd for the run; defaults to projectRoot. */
|
||||||
|
cwd?: string;
|
||||||
/** File paths relative to project/worktree for file-scoped preset */
|
/** File paths relative to project/worktree for file-scoped preset */
|
||||||
filePaths?: string[];
|
filePaths?: string[];
|
||||||
confirmFullSuite?: boolean;
|
confirmFullSuite?: boolean;
|
||||||
@@ -45,6 +49,111 @@ export function isSafeFilePathToken(path: string): boolean {
|
|||||||
return true;
|
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<string>();
|
||||||
|
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<string, { name: string; paths: Set<string> }>();
|
||||||
|
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<string>() };
|
||||||
|
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 {
|
export function resolvePresetCommand(input: ResolvePresetInput): ResolvePresetResult {
|
||||||
switch (input.preset) {
|
switch (input.preset) {
|
||||||
case "project-test": {
|
case "project-test": {
|
||||||
@@ -67,15 +176,13 @@ export function resolvePresetCommand(input: ResolvePresetInput): ResolvePresetRe
|
|||||||
if (paths.length === 0) {
|
if (paths.length === 0) {
|
||||||
return { ok: false, reason: "No changed files for file-scoped run", code: "empty_files" };
|
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) {
|
if (safe.length === 0) {
|
||||||
return { ok: false, reason: "No safe file paths for file-scoped run", code: "empty_files" };
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
command: `pnpm exec vitest run ${joined}`,
|
command: buildFileScopedCommand(input.cwd ?? input.projectRoot, safe),
|
||||||
label: "File-scoped tests",
|
label: "File-scoped tests",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user