Skip to content
Merged
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
23 changes: 8 additions & 15 deletions docs/e2e-impact-selector-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,21 +529,14 @@ PR-created caches may be scoped to the PR merge ref. Sniffler must remain correc
Text output is optimized for humans:

```text
Changed files:
packages/ui/src/Button.tsx

Affected modules:
packages/ui/src/Button.tsx
apps/mobile/src/components/CheckoutForm.tsx
apps/mobile/src/screens/CheckoutScreen.tsx

Recommended E2E tests:
e2e/checkout.spec.ts
target: apps/mobile/src/screens/CheckoutScreen.tsx
path: packages/ui/src/Button.tsx -> apps/mobile/src/components/CheckoutForm.tsx -> apps/mobile/src/screens/CheckoutScreen.tsx

Warnings:
packages/app/src/routes.ts:12 dynamic import target is not statically resolvable
✓ e2e/checkout.spec.ts
depends on affected apps/mobile/src/screens/CheckoutScreen.tsx

Impact 1 test selected
Changed 1 file
Affected 3 modules
Warnings 1 warning
Run with --diagnostics to inspect warning details.
```

JSON output is optimized for CI:
Expand Down
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,8 @@ const buildCli = (io: CliIO, deps: CliDeps, rawArgs: ReadonlyArray<string>) => {
run: async (diagnostics) => {
const result = await runImpactCommand(parsed.input, {
...deps,
diagnostics
diagnostics,
...(options.diagnostics === true ? { diagnosticsPath: ".sniffler/diagnostics.json" } : {})
});

return {
Expand Down
3 changes: 2 additions & 1 deletion src/impact/impact-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type ImpactCommandDeps = {
cwd?: string;
gitDiff?: GitDiffProvider;
diagnostics?: Diagnostics;
diagnosticsPath?: string;
staleChecker?: StaleChecker;
cacheStoreFactory?: (input: { cache: GraphCache | null; staleChecker: StaleChecker }) => GraphCacheStore;
};
Expand Down Expand Up @@ -167,7 +168,7 @@ export const runImpactCommand = async (
const config = (await loadConfig({ fs, configPath: input.configPath })).config;
const format = input.format ?? config.output?.format ?? "text";
const rendered = await (deps.diagnostics ?? noopDiagnostics).time("impact.output.render", async () => {
return format === "json" ? renderJsonOutput(output) : renderTextOutput(output);
return format === "json" ? renderJsonOutput(output) : renderTextOutput(output, { diagnosticsPath: deps.diagnosticsPath });
});

return {
Expand Down
130 changes: 56 additions & 74 deletions src/output/text-output.ts
Original file line number Diff line number Diff line change
@@ -1,107 +1,89 @@
import type { ImpactOutput } from "./output-types.js";
import { compareTestMatchReasons } from "../test-map/recommend-tests.js";

export type TextOutputOptions = {
diagnosticsPath?: string;
};

const sortUniqueStrings = (values: ReadonlyArray<string>): Array<string> => {
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
};

const formatPath = (path: ReadonlyArray<string>): string => {
return path.join(" -> ");
const colors = {
green: (value: string) => `\u001b[32m${value}\u001b[39m`,
yellow: (value: string) => `\u001b[33m${value}\u001b[39m`,
cyan: (value: string) => `\u001b[36m${value}\u001b[39m`,
dim: (value: string) => `\u001b[2m${value}\u001b[22m`
};

const formatContainmentEdges = (
edges: ReadonlyArray<{
from: string;
to: string;
synthetic?: {
kind: "containment";
from: string;
to: string;
};
}>
): Array<string> => {
return edges.map((edge) => {
if (edge.synthetic !== undefined) {
return ` synthetic containment: ${formatPath([edge.from, edge.to])}`;
}
const pluralize = (count: number, singular: string, plural = `${singular}s`): string => {
return count === 1 ? singular : plural;
};

return ` containment edge: ${formatPath([edge.from, edge.to])}`;
});
const formatCount = (count: number, singular: string, plural?: string): string => {
return `${count} ${pluralize(count, singular, plural)}`;
};

export const renderTextOutput = (output: ImpactOutput): string => {
const lines: string[] = [];
const changedFiles = sortUniqueStrings(output.changedFiles);
const affectedModules = sortUniqueStrings(output.affectedModules);
const recommendedTests = [...output.recommendedTests].sort((left, right) => left.test.localeCompare(right.test));
const warnings = sortUniqueStrings(output.warnings);
const padLabel = (label: string): string => {
return label.padStart(11);
};

lines.push("Changed files:");
const formatSummaryLine = (label: string, value: string): string => {
return `${colors.dim(padLabel(label))} ${value}`;
};

if (changedFiles.length === 0) {
lines.push(" none");
} else {
for (const changedFile of changedFiles) {
lines.push(` ${changedFile}`);
}
const formatReason = (reason: ImpactOutput["recommendedTests"][number]["reasons"][number]): string => {
if (reason.kind === "run-all") {
return `runs because ${colors.cyan(reason.changedFile)} matches ${colors.cyan(reason.declaredTarget)}`;
}

lines.push("");
lines.push("Affected modules:");
return `depends on affected ${colors.cyan(reason.declaredTarget)}`;
};

if (affectedModules.length === 0) {
lines.push(" none");
} else {
for (const affectedModule of affectedModules) {
lines.push(` ${affectedModule}`);
}
const formatWarningSummary = (warningCount: number, diagnosticsPath?: string): Array<string> => {
if (warningCount === 0) {
return [];
}

lines.push("");
lines.push("Recommended E2E tests:");
if (diagnosticsPath !== undefined) {
return [formatSummaryLine("Diagnostics", colors.cyan(diagnosticsPath))];
}

return [` ${colors.yellow("Run with --diagnostics")} ${colors.dim("to inspect warning details.")}`];
};

export const renderTextOutput = (output: ImpactOutput, options: TextOutputOptions = {}): string => {
const lines: string[] = [];
const changedFiles = sortUniqueStrings(output.changedFiles);
const affectedModules = sortUniqueStrings(output.affectedModules);
const recommendedTests = [...output.recommendedTests].sort((left, right) => left.test.localeCompare(right.test));
const warnings = sortUniqueStrings(output.warnings);

if (recommendedTests.length === 0) {
lines.push(" none");
lines.push(` ${colors.yellow("○")} ${colors.dim("No E2E tests selected")}`);
} else {
for (const test of recommendedTests) {
lines.push(` ${test.test}`);
lines.push(` ${colors.green("✓")} ${colors.cyan(test.test)}`);

const reasons = [...test.reasons].sort(compareTestMatchReasons);
const [firstReason, ...remainingReasons] = reasons;

for (const reason of reasons) {
if (reason.kind === "run-all") {
lines.push(` run all: ${reason.declaredTarget}`);
lines.push(` changed: ${reason.changedFile}`);
continue;
}

if (reason.kind === "containment") {
lines.push(` containment target: ${reason.declaredTarget}`);
lines.push(` changed: ${reason.changedFile}`);
lines.push(` invalidated root: ${reason.invalidatedRoot}`);
lines.push(` reverse path: ${formatPath(reason.dependencyPath)}`);
lines.push(` containment path: ${formatPath(reason.containmentPath)}`);
if (reason.containmentPathEdges !== undefined && reason.containmentPathEdges.length > 0) {
lines.push(" containment edges:");
lines.push(...formatContainmentEdges(reason.containmentPathEdges));
}
continue;
}

lines.push(` target: ${reason.declaredTarget}`);
lines.push(` path: ${formatPath(reason.dependencyPath)}`);
if (firstReason !== undefined) {
lines.push(` ${formatReason(firstReason)}`);
}
}
}

if (warnings.length > 0) {
lines.push("");
lines.push("Warnings:");

for (const warning of warnings) {
lines.push(` ${warning}`);
if (remainingReasons.length > 0) {
lines.push(` ${colors.dim(`+ ${formatCount(remainingReasons.length, "more reason")}`)}`);
}
}
}

lines.push("");
lines.push(formatSummaryLine("Impact", formatCount(recommendedTests.length, "test selected", "tests selected")));
lines.push(formatSummaryLine("Changed", formatCount(changedFiles.length, "file")));
lines.push(formatSummaryLine("Affected", formatCount(affectedModules.length, "module")));
lines.push(formatSummaryLine("Warnings", formatCount(warnings.length, "warning")));
lines.push(...formatWarningSummary(warnings.length, options.diagnosticsPath));

return `${lines.join("\n")}\n`;
};
38 changes: 21 additions & 17 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,11 @@ describe("CLI impact command", () => {
);

expect(result.exitCode).toBe(0);
expect(output.join("")).toContain("Changed files:");
expect(output.join("")).toContain("src/shared.ts");
expect(output.join("")).toContain("Recommended E2E tests:");
expect(output.join("")).toContain("Changed");
expect(output.join("")).toContain("1 file");
expect(output.join("")).toContain("e2e/feature.spec.ts");
expect(output.join("")).toContain("path: src/shared.ts -> src/feature.ts");
expect(output.join("")).toContain("depends on affected");
expect(output.join("")).toContain("src/feature.ts");
});

it("does not write diagnostics by default", async () => {
Expand Down Expand Up @@ -185,8 +185,10 @@ describe("CLI impact command", () => {
);

expect(result.exitCode).toBe(0);
expect(output.join("")).toContain("Recommended E2E tests:");
expect(output.join("")).not.toContain("diagnostics.json");
expect(output.join("")).toContain("Impact");
expect(output.join("")).toContain("Diagnostics");
expect(output.join("")).toContain(".sniffler/diagnostics.json");
expect(output.join("")).not.toContain("Run with --diagnostics");
expect(await fs.exists(".sniffler/diagnostics.json")).toBe(true);

const diagnostics = await fs.readJson<{
Expand Down Expand Up @@ -333,7 +335,7 @@ describe("CLI impact command", () => {
);

expect(result.exitCode).toBe(0);
expect(output.join("")).toContain("Recommended E2E tests:");
expect(output.join("")).toContain("Impact");

const diagnostics = await fs.readJson<{
metrics: Record<string, number | string | boolean>;
Expand Down Expand Up @@ -371,7 +373,7 @@ describe("CLI impact command", () => {
);

expect(result.exitCode).toBe(0);
expect(output.join("")).toContain("Recommended E2E tests:");
expect(output.join("")).toContain("Impact");
const diagnostics = await fs.readJson<{
metrics: Record<string, number | string | boolean>;
}>(".sniffler/diagnostics.json");
Expand All @@ -396,8 +398,8 @@ describe("CLI impact command", () => {
);

expect(result.exitCode).toBe(0);
expect(output.join("")).toContain("src/shared.ts");
expect(output.join("")).toContain("src/feature.ts");
expect(output.join("")).toContain("Changed");
expect(output.join("")).toContain("2 files");
});

it("renders text output for legacy --changed files", async () => {
Expand All @@ -418,8 +420,9 @@ describe("CLI impact command", () => {
);

expect(result.exitCode).toBe(0);
expect(output.join("")).toContain("src/shared.ts");
expect(output.join("")).toContain("Recommended E2E tests:");
expect(output.join("")).toContain("Changed");
expect(output.join("")).toContain("1 file");
expect(output.join("")).toContain("e2e/feature.spec.ts");
});

it("renders JSON output for base/head mode", async () => {
Expand Down Expand Up @@ -484,8 +487,8 @@ describe("CLI impact command", () => {
);

expect(result.exitCode).toBe(0);
expect(output.join("")).toContain("run all: pnpm-lock.yaml");
expect(output.join("")).toContain("changed: pnpm-lock.yaml");
expect(output.join("")).toContain("runs because");
expect(output.join("")).toContain("pnpm-lock.yaml");
expect(output.join("")).toContain("e2e/app.spec.ts");
expect(output.join("")).toContain("e2e/feature.spec.ts");
});
Expand Down Expand Up @@ -556,7 +559,8 @@ describe("CLI impact command", () => {

expect(result.exitCode).toBe(0);
expect(output.join("")).toContain("e2e/app.spec.ts");
expect(output.join("")).toContain("src/Button.android.ts -> src/app.ts");
expect(output.join("")).toContain("depends on affected");
expect(output.join("")).toContain("src/app.ts");
});

it("rejects no selection", async () => {
Expand Down Expand Up @@ -692,8 +696,8 @@ describe("CLI impact command", () => {
);

expect(result.exitCode).toBe(0);
expect(output.join("")).toContain("Recommended E2E tests:");
expect(output.join("")).toContain("none");
expect(output.join("")).toContain("Impact");
expect(output.join("")).toContain("No E2E tests selected");
});

it("exposes the impact API from the top-level factory", async () => {
Expand Down
Loading