Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -98,6 +99,7 @@ type SnifflerConfig = {
};
tests?: {
manifest?: string;
sharedTargets?: string[];
};
cache?: {
path?: string;
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/config/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type SnifflerConfig = {
};
tests?: {
manifest?: string;
sharedTargets?: ReadonlyArray<string>;
};
cache?: {
path?: string;
Expand Down Expand Up @@ -51,7 +52,8 @@ export const defaultConfig = {
}
},
tests: {
manifest: ".sniffler/test-map.json"
manifest: ".sniffler/test-map.json",
sharedTargets: []
},
cache: {
path: ".sniffler/cache.json",
Expand Down
15 changes: 14 additions & 1 deletion src/config/load-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion src/impact/impact-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ const sortUniqueStrings = (values: ReadonlyArray<string>): Array<string> => {
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
};

const mergeTargets = (targets: ReadonlyArray<string>, sharedTargets: ReadonlyArray<string>): Array<string> => {
return [...new Set([...targets, ...sharedTargets])];
};

const hashText = (text: string): string => {
return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
};
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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")
});
});
});
95 changes: 95 additions & 0 deletions tests/shared-targets.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
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"]
}
]
}
]);
});
});