diff --git a/README.md b/README.md index ac6faac..aa31ee5 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ Start with a focused config that points Sniffler at your source roots and test m Sniffler fills in defaults for source extensions, workspace discovery, TSConfig paths, cache behavior, and output format. See [docs/config.md](docs/config.md) for the full configuration reference. +If you have a shared setup module that reaches into the rest of the app, add it under `tests.sharedTargets` so Sniffler still walks the dependency graph from that module into its imports. For example, when `src/global.ts` imports `src/some-other.ts`, a change to `src/some-other.ts` can still select every test that shares `src/global.ts`. + Then run the CLI from the project root: ```bash diff --git a/docs/config.md b/docs/config.md index dac0216..dfd8bf4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -64,7 +64,8 @@ Every property is optional. Missing properties are filled from the defaults belo } }, "tests": { - "manifest": ".sniffler/test-map.json" + "manifest": ".sniffler/test-map.json", + "sharedTargets": [] }, "cache": { "path": ".sniffler/cache.json", @@ -98,6 +99,7 @@ type SnifflerConfig = { }; tests?: { manifest?: string; + sharedTargets?: string[]; }; cache?: { path?: string; @@ -317,6 +319,27 @@ The path is resolved from the current working directory. The manifest must exist Each entry maps one E2E test file to the source paths or glob targets it covers. +### `tests.sharedTargets` + +Extra source targets Sniffler appends to every test entry before it matches the graph. + +Default: + +```json +[] +``` + +Use this for global setup files that affect every test through the dependency graph. For example, if `src/global.ts` imports `src/some-other.ts`, set `tests.sharedTargets` to `["src/global.ts"]`. When `src/some-other.ts` changes, Sniffler still analyzes the graph, finds the path from `src/some-other.ts` to `src/global.ts`, and selects every test that shares that setup module. + +```json +{ + "tests": { + "manifest": ".sniffler/test-map.json", + "sharedTargets": ["src/global.ts"] + } +} +``` + ### `cache.path` Path where Sniffler stores graph cache data. diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index 1838a53..5ee460f 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -17,6 +17,7 @@ export type SnifflerConfig = { }; tests?: { manifest?: string; + sharedTargets?: ReadonlyArray; }; cache?: { path?: string; @@ -51,7 +52,8 @@ export const defaultConfig = { } }, tests: { - manifest: ".sniffler/test-map.json" + manifest: ".sniffler/test-map.json", + sharedTargets: [] }, cache: { path: ".sniffler/cache.json", diff --git a/src/config/load-config.ts b/src/config/load-config.ts index ce17c70..ebdd629 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -231,6 +231,18 @@ const validateConfig = (value: unknown, path: string): SnifflerConfigFile => { `Invalid config in ${path}: tests.manifest must be a string.` ); } + + if ( + "sharedTargets" in value.tests && + value.tests.sharedTargets !== undefined && + !isStringArray(value.tests.sharedTargets) + ) { + throw createLoadError( + "SNIFFLER_INVALID_CONFIG", + path, + `Invalid config in ${path}: tests.sharedTargets must be an array of strings.` + ); + } } if ("cache" in value && value.cache !== undefined) { @@ -299,7 +311,8 @@ const normalizeConfig = (config: SnifflerConfigFile): SnifflerConfig => { } }, tests: { - manifest: config.tests?.manifest ?? defaultConfig.tests?.manifest + manifest: config.tests?.manifest ?? defaultConfig.tests?.manifest, + sharedTargets: config.tests?.sharedTargets ?? defaultConfig.tests?.sharedTargets }, cache: { path: config.cache?.path ?? defaultConfig.cache?.path, diff --git a/src/impact/impact-command.ts b/src/impact/impact-command.ts index 119c245..d6333b0 100644 --- a/src/impact/impact-command.ts +++ b/src/impact/impact-command.ts @@ -66,6 +66,10 @@ const sortUniqueStrings = (values: ReadonlyArray): Array => { return [...new Set(values)].sort((left, right) => left.localeCompare(right)); }; +const mergeTargets = (targets: ReadonlyArray, sharedTargets: ReadonlyArray): Array => { + return [...new Set([...targets, ...sharedTargets])]; +}; + const hashText = (text: string): string => { return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex"); }; @@ -418,8 +422,18 @@ export const selectImpact = async ( const testMap = await diagnostics.time("impact.testMap.load", async () => { return await loadTestMap(fs, normalizePath(join(cwd, config.tests?.manifest ?? ".sniffler/test-map.json"))); }); + const sharedTargets = config.tests?.sharedTargets ?? []; + const expandedTestMap = + sharedTargets.length === 0 + ? testMap + : { + tests: testMap.tests.map((entry) => ({ + test: entry.test, + targets: mergeTargets(entry.targets, sharedTargets) + })) + }; const recommendedTests = await diagnostics.time("impact.tests.match", async () => { - return matchTests({ testMap, impact }); + return matchTests({ testMap: expandedTestMap, impact }); }); diagnostics.record("recommendedTests", recommendedTests.length); diagnostics.record("warnings", warnings.length); diff --git a/tests/config.test.ts b/tests/config.test.ts index 5e4c448..7b8add8 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -17,6 +17,7 @@ describe("loadConfig", () => { expect(result.configPath).toBe(defaultConfigPath); expect(result.config.tests?.manifest).toBe("custom/test-map.json"); + expect(result.config.tests?.sharedTargets).toEqual([]); expect(result.config.output?.format).toBe("text"); expect(result.config.cache?.stale).toBe("content"); expect(result.config.source?.includeNodeModules).toBe(false); @@ -117,4 +118,34 @@ describe("loadConfig", () => { message: expect.stringContaining("source.includeNodeModules must be a boolean") }); }); + + it("loads tests.sharedTargets when provided", async () => { + const fs = createMemoryFileSystem({ + [defaultConfigPath]: JSON.stringify({ + tests: { + sharedTargets: ["src/global.ts", "src/setup.ts"] + } + }) + }); + + const result = await loadConfig({ fs }); + + expect(result.config.tests?.sharedTargets).toEqual(["src/global.ts", "src/setup.ts"]); + }); + + it("fails with an actionable error when tests.sharedTargets is not a string array", async () => { + const fs = createMemoryFileSystem({ + [defaultConfigPath]: JSON.stringify({ + tests: { + sharedTargets: ["src/global.ts", 123] + } + }) + }); + + await expect(loadConfig({ fs })).rejects.toMatchObject({ + code: "SNIFFLER_INVALID_CONFIG", + path: defaultConfigPath, + message: expect.stringContaining("tests.sharedTargets must be an array of strings") + }); + }); }); diff --git a/tests/shared-targets.test.ts b/tests/shared-targets.test.ts new file mode 100644 index 0000000..34d85f4 --- /dev/null +++ b/tests/shared-targets.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryFileSystem } from "../src/filesystem/memory-filesystem.js"; +import { selectImpact } from "../src/impact/impact-command.js"; + +const createSharedTargetsFixtureFileSystem = (testMap: Record) => { + return createMemoryFileSystem({ + ".sniffler/config.json": JSON.stringify({ + source: { + roots: ["src"], + extensions: [".ts"], + ignore: [] + }, + workspaces: { + strategies: [] + }, + tests: { + manifest: ".sniffler/test-map.json", + sharedTargets: ["src/global.ts"] + } + }), + ".sniffler/test-map.json": JSON.stringify(testMap), + "src/global.ts": 'import "./some-other.ts";\nexport const global = true;\n', + "src/some-other.ts": "export const someOther = true;\n", + "src/alpha.ts": "export const alpha = true;\n", + "src/beta.ts": "export const beta = true;\n" + }); +}; + +describe("sharedTargets", () => { + it("selects every test through the shared target and keeps the dependency graph path", async () => { + const fs = createSharedTargetsFixtureFileSystem({ + tests: [ + { + test: "alpha.spec.ts", + targets: ["src/alpha.ts"] + }, + { + test: "beta.spec.ts", + targets: ["src/beta.ts"] + } + ] + }); + + const result = await selectImpact({ changedFiles: ["src/some-other.ts"] }, { fs, cwd: "." }); + + expect(result.recommendedTests).toEqual([ + { + test: "alpha.spec.ts", + reasons: [ + { + changedFile: "src/some-other.ts", + declaredTarget: "src/global.ts", + dependencyPath: ["src/some-other.ts", "src/global.ts"] + } + ] + }, + { + test: "beta.spec.ts", + reasons: [ + { + changedFile: "src/some-other.ts", + declaredTarget: "src/global.ts", + dependencyPath: ["src/some-other.ts", "src/global.ts"] + } + ] + } + ]); + }); + + it("dedupes shared targets that already exist on the test entry", async () => { + const fs = createSharedTargetsFixtureFileSystem({ + tests: [ + { + test: "alpha.spec.ts", + targets: ["src/global.ts"] + } + ] + }); + + const result = await selectImpact({ changedFiles: ["src/some-other.ts"] }, { fs, cwd: "." }); + + expect(result.recommendedTests).toEqual([ + { + test: "alpha.spec.ts", + reasons: [ + { + changedFile: "src/some-other.ts", + declaredTarget: "src/global.ts", + dependencyPath: ["src/some-other.ts", "src/global.ts"] + } + ] + } + ]); + }); +});