@shivaedev/vitest-rerun 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +5 -3
- package/dist/{internal/junit.d.ts → adapters/RerunJunitReporter.d.ts} +3 -2
- package/dist/adapters/RerunJunitReporter.d.ts.map +1 -0
- package/dist/{internal/junit.js → adapters/RerunJunitReporter.js} +7 -4
- package/dist/adapters/RerunJunitReporter.js.map +1 -0
- package/dist/adapters/TimedJsonReporter.d.ts.map +1 -0
- package/dist/{internal/adapters → adapters}/TimedJsonReporter.js +1 -1
- package/dist/adapters/TimedJsonReporter.js.map +1 -0
- package/dist/adapters/VitestRunner.d.ts +48 -0
- package/dist/adapters/VitestRunner.d.ts.map +1 -0
- package/dist/adapters/VitestRunner.js +67 -0
- package/dist/adapters/VitestRunner.js.map +1 -0
- package/dist/adapters/deferCoverageThresholds.d.ts +4 -0
- package/dist/adapters/deferCoverageThresholds.d.ts.map +1 -0
- package/dist/adapters/deferCoverageThresholds.js +46 -0
- package/dist/adapters/deferCoverageThresholds.js.map +1 -0
- package/dist/adapters/mergeSnapshotSummary.d.ts +4 -0
- package/dist/adapters/mergeSnapshotSummary.d.ts.map +1 -0
- package/dist/adapters/mergeSnapshotSummary.js +19 -0
- package/dist/adapters/mergeSnapshotSummary.js.map +1 -0
- package/dist/adapters/tasks.d.ts +4 -0
- package/dist/adapters/tasks.d.ts.map +1 -0
- package/dist/adapters/tasks.js +2 -0
- package/dist/adapters/tasks.js.map +1 -0
- package/dist/adapters/writeMergedReports.d.ts +8 -0
- package/dist/adapters/writeMergedReports.d.ts.map +1 -0
- package/dist/adapters/writeMergedReports.js +21 -0
- package/dist/adapters/writeMergedReports.js.map +1 -0
- package/dist/cli.js +6 -3
- package/dist/cli.js.map +1 -1
- package/dist/flakyTests.d.ts +1 -1
- package/dist/flakyTests.d.ts.map +1 -1
- package/dist/internal/attempt.js +5 -6
- package/dist/internal/attempt.js.map +1 -1
- package/dist/internal/junitClassname.d.ts +2 -0
- package/dist/internal/junitClassname.d.ts.map +1 -0
- package/dist/internal/junitClassname.js +4 -0
- package/dist/internal/junitClassname.js.map +1 -0
- package/dist/internal/mergeAttempt.d.ts +21 -1
- package/dist/internal/mergeAttempt.d.ts.map +1 -1
- package/dist/internal/mergeAttempt.js +15 -19
- package/dist/internal/mergeAttempt.js.map +1 -1
- package/dist/internal/refuseDeclaredRetries.d.ts +4 -0
- package/dist/internal/refuseDeclaredRetries.d.ts.map +1 -0
- package/dist/internal/refuseDeclaredRetries.js +17 -0
- package/dist/internal/refuseDeclaredRetries.js.map +1 -0
- package/dist/internal/runAttempt.d.ts +2 -2
- package/dist/internal/runAttempt.d.ts.map +1 -1
- package/dist/internal/runAttempt.js +37 -73
- package/dist/internal/runAttempt.js.map +1 -1
- package/dist/internal/snapshot.d.ts +62 -6
- package/dist/internal/snapshot.d.ts.map +1 -1
- package/dist/internal/snapshot.js +19 -0
- package/dist/internal/snapshot.js.map +1 -1
- package/dist/internal/summaryLine.d.ts +3 -0
- package/dist/internal/summaryLine.d.ts.map +1 -0
- package/dist/internal/summaryLine.js +10 -0
- package/dist/internal/summaryLine.js.map +1 -0
- package/dist/rerun.d.ts.map +1 -1
- package/dist/rerun.js +2 -2
- package/dist/rerun.js.map +1 -1
- package/package.json +7 -4
- package/src/{internal/junit.ts → adapters/RerunJunitReporter.ts} +7 -4
- package/src/{internal/adapters → adapters}/TimedJsonReporter.ts +1 -1
- package/src/adapters/VitestRunner.ts +97 -0
- package/src/adapters/deferCoverageThresholds.ts +48 -0
- package/src/adapters/mergeSnapshotSummary.ts +21 -0
- package/src/adapters/tasks.ts +4 -0
- package/src/adapters/writeMergedReports.ts +27 -0
- package/src/cli.ts +6 -3
- package/src/flakyTests.ts +1 -1
- package/src/internal/SnapshotFiles.ts +1 -1
- package/src/internal/attempt.ts +7 -6
- package/src/internal/junitClassname.ts +3 -0
- package/src/internal/mergeAttempt.ts +18 -22
- package/src/internal/refuseDeclaredRetries.ts +22 -0
- package/src/internal/runAttempt.ts +36 -89
- package/src/internal/snapshot.ts +21 -4
- package/src/internal/summaryLine.ts +10 -0
- package/src/rerun.ts +2 -2
- package/dist/internal/adapters/TimedJsonReporter.d.ts.map +0 -1
- package/dist/internal/adapters/TimedJsonReporter.js.map +0 -1
- package/dist/internal/junit.d.ts.map +0 -1
- package/dist/internal/junit.js.map +0 -1
- /package/dist/{internal/adapters → adapters}/TimedJsonReporter.d.ts +0 -0
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import { JUnitReporter, type Vitest } from "vitest/node";
|
|
3
|
-
|
|
4
|
-
export function junitClassname(file: string, project: string): string {
|
|
5
|
-
return project === "" ? file : `|${project}| ${file}`;
|
|
6
|
-
}
|
|
3
|
+
import { junitClassname } from "#internal/junitClassname.ts";
|
|
7
4
|
|
|
8
5
|
export class RerunJunitReporter extends JUnitReporter {
|
|
6
|
+
unhandledErrors: NonNullable<Parameters<JUnitReporter["onTestRunEnd"]>[1]> = [];
|
|
9
7
|
readonly #root: { path: string };
|
|
10
8
|
|
|
11
9
|
constructor(outputFile: string) {
|
|
@@ -18,6 +16,11 @@ export class RerunJunitReporter extends JUnitReporter {
|
|
|
18
16
|
this.#root = root;
|
|
19
17
|
}
|
|
20
18
|
|
|
19
|
+
override onTestRunEnd(...args: Parameters<JUnitReporter["onTestRunEnd"]>) {
|
|
20
|
+
this.unhandledErrors = args[1] ?? [];
|
|
21
|
+
return super.onTestRunEnd(...args);
|
|
22
|
+
}
|
|
23
|
+
|
|
21
24
|
override onInit(vitest: Vitest) {
|
|
22
25
|
this.#root.path = vitest.config.root;
|
|
23
26
|
return super.onInit(vitest);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { experimental_getRunnerTask, JsonReporter, type TestModule } from "vitest/node";
|
|
4
4
|
import { VitestRerunError } from "#error.ts";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as VitestNode from "vitest/node";
|
|
3
|
+
import { defineService } from "@shivaedev/service/defineService.ts";
|
|
4
|
+
import { failWith } from "#error.ts";
|
|
5
|
+
import type { Snapshot } from "#internal/snapshot.ts";
|
|
6
|
+
import { deferCoverageThresholds } from "./deferCoverageThresholds.ts";
|
|
7
|
+
import { mergeSnapshotSummary } from "./mergeSnapshotSummary.ts";
|
|
8
|
+
import { RerunJunitReporter } from "./RerunJunitReporter.ts";
|
|
9
|
+
import { TimedJsonReporter } from "./TimedJsonReporter.ts";
|
|
10
|
+
import type { Files } from "./tasks.ts";
|
|
11
|
+
import { writeMergedReports } from "./writeMergedReports.ts";
|
|
12
|
+
|
|
13
|
+
interface RunOptions {
|
|
14
|
+
readonly cacheDir?: string;
|
|
15
|
+
readonly reports: { readonly json: string; readonly junit: string };
|
|
16
|
+
readonly rerun: boolean;
|
|
17
|
+
readonly vitestArgs: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Selection {
|
|
21
|
+
readonly fileId: string;
|
|
22
|
+
readonly testIds?: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function promise<A>(message: string, run: () => Promise<A>) {
|
|
26
|
+
return Effect.tryPromise({ catch: failWith(message), try: run });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const open = Effect.fn("VitestRerun.open")(function* (input: RunOptions) {
|
|
30
|
+
const { filter, options } = yield* Effect.try({
|
|
31
|
+
catch: (cause) => failWith(`Could not parse Vitest arguments: ${cause instanceof Error ? cause.message : String(cause)}`)(cause),
|
|
32
|
+
try: () => VitestNode.parseCLI(["vitest", "run", ...input.vitestArgs]),
|
|
33
|
+
});
|
|
34
|
+
const { shard: _shard, ...unsharded } = options;
|
|
35
|
+
const junit = new RerunJunitReporter(input.reports.junit);
|
|
36
|
+
const vitest = yield* Effect.acquireRelease(
|
|
37
|
+
promise("Vitest could not start", () =>
|
|
38
|
+
VitestNode.createVitest(
|
|
39
|
+
{
|
|
40
|
+
...(input.rerun ? unsharded : options),
|
|
41
|
+
reporters: ["default", new TimedJsonReporter({ outputFile: input.reports.json }), junit],
|
|
42
|
+
retry: 0,
|
|
43
|
+
watch: false,
|
|
44
|
+
},
|
|
45
|
+
input.cacheDir === undefined ? {} : { cacheDir: input.cacheDir },
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
(instance) => Effect.promise(() => instance.close()),
|
|
49
|
+
);
|
|
50
|
+
const finishCoverage = yield* deferCoverageThresholds(vitest);
|
|
51
|
+
return {
|
|
52
|
+
collect: (files: Files, summary: Snapshot["summary"]) =>
|
|
53
|
+
Effect.sync(() => {
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
vitest.state.collectFiles(vitest.getProjectByName(file.projectName ?? ""), [file]);
|
|
56
|
+
}
|
|
57
|
+
vitest.snapshot.summary = mergeSnapshotSummary(summary, vitest.snapshot.summary);
|
|
58
|
+
}),
|
|
59
|
+
finish: (outputDir: string, attempt: number, reports: RunOptions["reports"]) =>
|
|
60
|
+
Effect.gen(function* () {
|
|
61
|
+
const coverage = yield* finishCoverage(outputDir, attempt, input.reports.json);
|
|
62
|
+
yield* writeMergedReports(vitest, reports, coverage, junit.unhandledErrors);
|
|
63
|
+
}),
|
|
64
|
+
read: () =>
|
|
65
|
+
Effect.sync(() => ({
|
|
66
|
+
errors: vitest.state.getUnhandledErrors(),
|
|
67
|
+
files: vitest.state.getFiles(),
|
|
68
|
+
root: vitest.config.root,
|
|
69
|
+
summary: vitest.snapshot.summary,
|
|
70
|
+
})),
|
|
71
|
+
rerun: (selected: readonly Selection[]) =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
yield* promise("Vitest could not prepare the rerun", () => vitest.standalone());
|
|
74
|
+
const specifications = yield* promise("Vitest could not list test files", () => vitest.globTestSpecifications());
|
|
75
|
+
const failed = specifications.flatMap((spec) => {
|
|
76
|
+
const selection = selected.find((item) => item.fileId === spec.taskId);
|
|
77
|
+
return selection === undefined
|
|
78
|
+
? []
|
|
79
|
+
: [spec.project.createSpecification(spec.moduleId, selection.testIds === undefined ? {} : { testIds: selection.testIds }, spec.pool)];
|
|
80
|
+
});
|
|
81
|
+
if (failed.length === 0) {
|
|
82
|
+
return yield* Effect.fail(failWith("No failed specifications found for the fresh rerun")(undefined));
|
|
83
|
+
}
|
|
84
|
+
yield* promise("Vitest could not rerun the failed tests", () => vitest.runTestSpecifications(failed));
|
|
85
|
+
}),
|
|
86
|
+
start: () => promise("Vitest could not run the tests", () => vitest.start(filter)).pipe(Effect.asVoid),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const VitestRunner = defineService({
|
|
91
|
+
id: "@shivaedev/vitest-rerun/VitestRunner",
|
|
92
|
+
initialize: Effect.void,
|
|
93
|
+
methods: () => ({ open }),
|
|
94
|
+
requires: [],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export type AttemptRunner = Effect.Success<ReturnType<typeof open>>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Effect, FileSystem, Schema } from "effect";
|
|
2
|
+
import { BaseCoverageProvider, type CoverageProvider, type Vitest } from "vitest/node";
|
|
3
|
+
import { failWith } from "#error.ts";
|
|
4
|
+
import { attemptPaths } from "#internal/reportPaths.ts";
|
|
5
|
+
|
|
6
|
+
const CoverageReport = Schema.Struct({ coverageMap: Schema.optional(Schema.Unknown) });
|
|
7
|
+
const decodeCoverage = Schema.decodeUnknownEffect(Schema.fromJsonString(CoverageReport));
|
|
8
|
+
|
|
9
|
+
export const deferCoverageThresholds = Effect.fn("VitestRerun.deferCoverageThresholds")(function* (vitest: Vitest) {
|
|
10
|
+
const provider = yield* Effect.tryPromise({
|
|
11
|
+
catch: failWith("Vitest could not load its coverage provider"),
|
|
12
|
+
try: () => vitest.createCoverageProvider(),
|
|
13
|
+
});
|
|
14
|
+
const options = provider?.resolveOptions();
|
|
15
|
+
const thresholds = options?.thresholds;
|
|
16
|
+
if (options !== undefined) {
|
|
17
|
+
options.thresholds = {};
|
|
18
|
+
}
|
|
19
|
+
return Effect.fn("VitestRerun.finishCoverage")(function* (outputDir: string, attempt: number, json: string) {
|
|
20
|
+
if (provider === null || options === undefined) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const fs = yield* FileSystem.FileSystem;
|
|
24
|
+
const maps: unknown[] = [];
|
|
25
|
+
for (let earlier = 1; earlier <= attempt; earlier += 1) {
|
|
26
|
+
const path = earlier === attempt ? json : attemptPaths(outputDir, earlier).json;
|
|
27
|
+
const { coverageMap } = yield* decodeCoverage(yield* fs.readFileString(path));
|
|
28
|
+
if (coverageMap !== undefined) {
|
|
29
|
+
maps.push(coverageMap);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
options.thresholds = thresholds ?? {};
|
|
33
|
+
return yield* Effect.tryPromise({ catch: failWith("Vitest could not merge coverage"), try: () => reportMergedCoverage(provider, maps) });
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
async function reportMergedCoverage(provider: CoverageProvider, maps: unknown[]): Promise<unknown> {
|
|
38
|
+
let merged: unknown;
|
|
39
|
+
if (provider instanceof BaseCoverageProvider) {
|
|
40
|
+
const generate = provider.generateReports.bind(provider);
|
|
41
|
+
provider.generateReports = async (map, allTestsRun) => {
|
|
42
|
+
merged = map;
|
|
43
|
+
await generate(map, allTestsRun);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
await provider.mergeReports?.(maps);
|
|
47
|
+
return merged;
|
|
48
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SnapshotSummary } from "vitest";
|
|
2
|
+
import type { Snapshot } from "#internal/snapshot.ts";
|
|
3
|
+
|
|
4
|
+
export function mergeSnapshotSummary(previous: Snapshot["summary"], current: SnapshotSummary): SnapshotSummary {
|
|
5
|
+
return {
|
|
6
|
+
added: previous.added + current.added,
|
|
7
|
+
didUpdate: previous.didUpdate || current.didUpdate,
|
|
8
|
+
failure: previous.failure || current.failure,
|
|
9
|
+
filesAdded: previous.filesAdded + current.filesAdded,
|
|
10
|
+
filesRemoved: previous.filesRemoved + current.filesRemoved,
|
|
11
|
+
filesRemovedList: [...previous.filesRemovedList, ...current.filesRemovedList],
|
|
12
|
+
filesUnmatched: previous.filesUnmatched + current.filesUnmatched,
|
|
13
|
+
filesUpdated: previous.filesUpdated + current.filesUpdated,
|
|
14
|
+
matched: previous.matched + current.matched,
|
|
15
|
+
total: previous.total + current.total,
|
|
16
|
+
unchecked: previous.unchecked + current.unchecked,
|
|
17
|
+
uncheckedKeysByFile: [...previous.uncheckedKeysByFile.map((file) => ({ ...file, keys: [...file.keys] })), ...current.uncheckedKeysByFile],
|
|
18
|
+
unmatched: previous.unmatched + current.unmatched,
|
|
19
|
+
updated: previous.updated + current.updated,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { Vitest } from "vitest/node";
|
|
3
|
+
import { failWith } from "#error.ts";
|
|
4
|
+
import { RerunJunitReporter } from "./RerunJunitReporter.ts";
|
|
5
|
+
import { TimedJsonReporter } from "./TimedJsonReporter.ts";
|
|
6
|
+
|
|
7
|
+
export function writeMergedReports(
|
|
8
|
+
vitest: Vitest,
|
|
9
|
+
reports: { json: string; junit: string },
|
|
10
|
+
coverage: unknown,
|
|
11
|
+
errors: RerunJunitReporter["unhandledErrors"],
|
|
12
|
+
) {
|
|
13
|
+
return Effect.tryPromise({
|
|
14
|
+
catch: failWith("Vitest could not write the merged reports"),
|
|
15
|
+
try: async () => {
|
|
16
|
+
const json = new TimedJsonReporter({ outputFile: reports.json });
|
|
17
|
+
const junit = new RerunJunitReporter(reports.junit);
|
|
18
|
+
await json.onInit(vitest);
|
|
19
|
+
if (coverage !== undefined) {
|
|
20
|
+
json.onCoverage(coverage);
|
|
21
|
+
}
|
|
22
|
+
await json.onTestRunEnd(vitest.state.getTestModules());
|
|
23
|
+
await junit.onInit(vitest);
|
|
24
|
+
await junit.onTestRunEnd(vitest.state.getTestModules(), errors);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
|
|
4
|
+
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
5
|
+
import { Effect, Exit, Runtime } from "effect";
|
|
5
6
|
import { main } from "#internal/main.ts";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
NodeRuntime.runMain(main(process.argv.slice(2), process.cwd()).pipe(Effect.provide(NodeServices.layer)), {
|
|
9
|
+
teardown: (exit, onExit) => (Exit.isSuccess(exit) ? onExit(Number(exit.value)) : Runtime.defaultTeardown(exit, onExit)),
|
|
10
|
+
});
|
package/src/flakyTests.ts
CHANGED
package/src/internal/attempt.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
|
|
4
|
+
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
5
|
+
import { Console, Effect, Exit, Runtime } from "effect";
|
|
6
|
+
import { VitestRunner } from "#adapters/VitestRunner.ts";
|
|
5
7
|
import { runAttempt } from "#internal/runAttempt.ts";
|
|
6
8
|
|
|
7
9
|
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
8
10
|
const [attempt = "1", outputDir = ".", ...vitestArgs] = process.argv.slice(2);
|
|
9
|
-
|
|
11
|
+
NodeRuntime.runMain(
|
|
10
12
|
runAttempt({ attempt: Number(attempt), outputDir, vitestArgs }).pipe(
|
|
11
13
|
Effect.catchTag("VitestRerunError", (error) =>
|
|
12
14
|
Effect.as(Console.error(`vitest-rerun: ${error.message}`, ...(error.cause === undefined ? [] : [error.cause])), true),
|
|
13
15
|
),
|
|
16
|
+
Effect.provide(VitestRunner.layer),
|
|
14
17
|
Effect.provide(NodeServices.layer),
|
|
15
18
|
),
|
|
19
|
+
{ teardown: (exit, onExit) => (Exit.isSuccess(exit) ? onExit(Number(exit.value)) : Runtime.defaultTeardown(exit, onExit)) },
|
|
16
20
|
);
|
|
17
|
-
if (failed) {
|
|
18
|
-
process.exitCode = 1;
|
|
19
|
-
}
|
|
20
21
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
|
+
import type { Files, Task } from "#adapters/tasks.ts";
|
|
3
4
|
import { VitestRerunError } from "#error.ts";
|
|
4
5
|
import { type FlakyTest, failureMessage, testcases } from "#flakyTests.ts";
|
|
5
|
-
import { junitClassname } from "#internal/
|
|
6
|
-
import { type FailureHistory, type
|
|
6
|
+
import { junitClassname } from "#internal/junitClassname.ts";
|
|
7
|
+
import { type FailureHistory, type Snapshot, tests } from "#internal/snapshot.ts";
|
|
7
8
|
|
|
8
9
|
type File = Files[number];
|
|
9
10
|
|
|
@@ -14,9 +15,9 @@ interface Outcome {
|
|
|
14
15
|
readonly summary: (line: string) => void;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
function
|
|
18
|
-
const
|
|
19
|
-
if (first.result !== undefined && (first.result.state
|
|
18
|
+
function mergeResult(first: Task, task: Task) {
|
|
19
|
+
const completed = ["pass", "fail"].includes(task.result?.state ?? "");
|
|
20
|
+
if (first.result !== undefined && ["pass", "fail"].includes(first.result.state) && !completed) {
|
|
20
21
|
task.mode = first.mode;
|
|
21
22
|
task.result = first.result;
|
|
22
23
|
task.logs = first.logs ?? [];
|
|
@@ -25,31 +26,29 @@ function mergeTest(first: Task, task: Task) {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
function
|
|
29
|
+
function mergeTasks(original: Task[], rerun: Task[]) {
|
|
29
30
|
for (const task of rerun) {
|
|
30
31
|
const first = original.find((item) => item.id === task.id);
|
|
31
32
|
if (first === undefined) {
|
|
32
33
|
continue;
|
|
33
34
|
}
|
|
34
35
|
if (task.type === "suite" && first.type === "suite") {
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
36
|
+
mergeTasks(first.tasks, task.tasks);
|
|
37
|
+
}
|
|
38
|
+
mergeResult(first, task);
|
|
39
|
+
if (task.type === "suite" && task.tasks.some((child) => child.result?.state === "fail") && task.result !== undefined) {
|
|
40
|
+
task.result.state = "fail";
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
function finishFile(file: File, next: File
|
|
43
|
-
|
|
44
|
-
if (!ranTests && file.tasks.length > 0 && file.result !== undefined) {
|
|
45
|
-
next.result = { ...file.result };
|
|
46
|
-
}
|
|
45
|
+
function finishFile(file: File, next: File) {
|
|
46
|
+
mergeResult(file, next);
|
|
47
47
|
if (next.result !== undefined) {
|
|
48
|
-
if (
|
|
48
|
+
if (next.tasks.some((task) => task.result?.state === "fail")) {
|
|
49
49
|
next.result.state = "fail";
|
|
50
50
|
}
|
|
51
51
|
next.result.startTime = file.result?.startTime ?? next.result.startTime ?? 0;
|
|
52
|
-
next.result.duration = duration;
|
|
53
52
|
}
|
|
54
53
|
}
|
|
55
54
|
|
|
@@ -72,16 +71,13 @@ function recordAttempts(file: File, retried: Task[], outcome: Outcome) {
|
|
|
72
71
|
function mergeFile(file: File, next: File, outcome: Outcome) {
|
|
73
72
|
const original = tests(file.tasks);
|
|
74
73
|
const retried = tests(next.tasks);
|
|
75
|
-
const ranTests = retried.some(
|
|
76
|
-
(test) => ["pass", "fail"].includes(test.result?.state ?? "") && original.some((item) => item.id === test.id && item.result?.state !== "pass"),
|
|
77
|
-
);
|
|
78
74
|
recordAttempts(file, retried, outcome);
|
|
79
75
|
if (original.length === 0 && next.result?.state === "pass") {
|
|
80
76
|
outcome.summary(`- Flaky: ${junitClassname(relative(outcome.root, file.filepath), file.projectName ?? "")} — collection failure`);
|
|
81
77
|
} else if (original.length > 0) {
|
|
82
|
-
|
|
78
|
+
mergeTasks(file.tasks, next.tasks);
|
|
83
79
|
}
|
|
84
|
-
finishFile(file, next
|
|
80
|
+
finishFile(file, next);
|
|
85
81
|
}
|
|
86
82
|
|
|
87
83
|
interface Merged {
|
|
@@ -93,7 +89,7 @@ interface Merged {
|
|
|
93
89
|
function unexpectedTask(original: readonly Task[], rerun: readonly Task[]): Task | undefined {
|
|
94
90
|
for (const task of rerun) {
|
|
95
91
|
const first = original.find((item) => item.id === task.id);
|
|
96
|
-
if (first === undefined) {
|
|
92
|
+
if (first === undefined || first.name !== task.name || first.type !== task.type) {
|
|
97
93
|
return task;
|
|
98
94
|
}
|
|
99
95
|
const unexpected = first.type === "suite" && task.type === "suite" ? unexpectedTask(first.tasks, task.tasks) : undefined;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import type { Files } from "#adapters/tasks.ts";
|
|
4
|
+
import { VitestRerunError } from "#error.ts";
|
|
5
|
+
import { testcases } from "#flakyTests.ts";
|
|
6
|
+
import { junitClassname } from "#internal/junitClassname.ts";
|
|
7
|
+
|
|
8
|
+
export const refuseDeclaredRetries = Effect.fn("VitestRerun.refuseDeclaredRetries")(function* (files: Files, root: string) {
|
|
9
|
+
const offenders = files.flatMap((file) =>
|
|
10
|
+
testcases(file.tasks).flatMap(({ name, task }) => {
|
|
11
|
+
const count = typeof task.retry === "number" ? task.retry : (task.retry?.count ?? 0);
|
|
12
|
+
return count > 0 ? [`${junitClassname(relative(root, file.filepath), file.projectName ?? "")} > ${name}`] : [];
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
if (offenders.length > 0) {
|
|
16
|
+
return yield* Effect.fail(
|
|
17
|
+
new VitestRerunError({
|
|
18
|
+
message: `Retries are global only and recoveries are recorded in flaky-tests.json. Remove per-test and per-suite retry options: ${offenders.sort().join("; ")}`,
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type
|
|
3
|
-
import * as VitestNode from "vitest/node";
|
|
4
|
-
import { failWith } from "#error.ts";
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { type AttemptRunner, type Selection, VitestRunner } from "#adapters/VitestRunner.ts";
|
|
5
3
|
import { writeFlakyTests } from "#flakyTests.ts";
|
|
6
|
-
import { TimedJsonReporter } from "#internal/adapters/TimedJsonReporter.ts";
|
|
7
|
-
import { RerunJunitReporter } from "#internal/junit.ts";
|
|
8
4
|
import { mergeAttempt } from "#internal/mergeAttempt.ts";
|
|
5
|
+
import { refuseDeclaredRetries } from "#internal/refuseDeclaredRetries.ts";
|
|
9
6
|
import { attemptPaths, reportPaths } from "#internal/reportPaths.ts";
|
|
10
|
-
import { failedFiles, readSnapshot, type Snapshot, saveSnapshot, tests } from "#internal/snapshot.ts";
|
|
7
|
+
import { failedFiles, hasSuiteErrors, readSnapshot, type Snapshot, saveSnapshot, tests } from "#internal/snapshot.ts";
|
|
8
|
+
import { summaryLine } from "#internal/summaryLine.ts";
|
|
11
9
|
|
|
12
10
|
export interface Attempt {
|
|
13
11
|
readonly attempt: number;
|
|
@@ -15,112 +13,61 @@ export interface Attempt {
|
|
|
15
13
|
readonly vitestArgs: readonly string[];
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
const
|
|
19
|
-
const decodeCoverage = Schema.decodeUnknownEffect(Schema.fromJsonString(CoverageReport));
|
|
20
|
-
|
|
21
|
-
function promise<A>(message: string, run: () => Promise<A>) {
|
|
22
|
-
return Effect.tryPromise({ catch: failWith(message), try: run });
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function start(options: CliOptions, reports: { json: string; junit: string }, cacheDir: string | undefined) {
|
|
26
|
-
return Effect.acquireRelease(
|
|
27
|
-
promise("Vitest could not start", () =>
|
|
28
|
-
VitestNode.createVitest(
|
|
29
|
-
{
|
|
30
|
-
...options,
|
|
31
|
-
reporters: ["default", new TimedJsonReporter({ outputFile: reports.json }), new RerunJunitReporter(reports.junit)],
|
|
32
|
-
retry: 0,
|
|
33
|
-
watch: false,
|
|
34
|
-
},
|
|
35
|
-
cacheDir === undefined ? {} : { cacheDir },
|
|
36
|
-
),
|
|
37
|
-
),
|
|
38
|
-
(vitest) => Effect.promise(() => vitest.close()),
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const failedSpecifications = Effect.fn("VitestRerun.failedSpecifications")(function* (vitest: Vitest, previous: Snapshot, json: string) {
|
|
16
|
+
const failedSelections = Effect.fn("VitestRerun.failedSelections")(function* (previous: Snapshot, json: string) {
|
|
43
17
|
const failed = new Set(yield* failedFiles(json));
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return specifications.flatMap((spec) => {
|
|
47
|
-
const file = previous.files.find((item) => item.id === spec.taskId && failed.has(spec.moduleId) && item.result?.state === "fail");
|
|
48
|
-
if (file === undefined) {
|
|
18
|
+
return previous.files.flatMap((file): Selection[] => {
|
|
19
|
+
if (!failed.has(file.filepath) || file.result?.state !== "fail") {
|
|
49
20
|
return [];
|
|
50
21
|
}
|
|
51
22
|
const runnable = tests(file.tasks).filter((test) => !["pass", "skip", "todo"].includes(test.result?.state ?? test.mode));
|
|
52
|
-
return [
|
|
23
|
+
return [{ fileId: file.id, ...(file.tasks.length === 0 || hasSuiteErrors(file) ? {} : { testIds: runnable.map((test) => test.id) }) }];
|
|
53
24
|
});
|
|
54
25
|
});
|
|
55
26
|
|
|
56
|
-
const
|
|
57
|
-
const fs = yield* FileSystem.FileSystem;
|
|
58
|
-
const maps: unknown[] = [];
|
|
59
|
-
for (let earlier = 1; earlier <= attempt; earlier += 1) {
|
|
60
|
-
const { coverageMap } = yield* decodeCoverage(yield* fs.readFileString(attemptPaths(outputDir, earlier).json));
|
|
61
|
-
if (coverageMap !== undefined) {
|
|
62
|
-
maps.push(coverageMap);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
const provider = yield* promise("Vitest could not load its coverage provider", () => vitest.createCoverageProvider());
|
|
66
|
-
yield* promise("Vitest could not merge coverage", async () => provider?.mergeReports?.(maps));
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
const summaryLine = Effect.fn("VitestRerun.summaryLine")(function* (line: string) {
|
|
70
|
-
const fs = yield* FileSystem.FileSystem;
|
|
71
|
-
const summary = yield* Config.String("GITHUB_STEP_SUMMARY").pipe(Config.withDefault(""));
|
|
72
|
-
yield* Console.log(line);
|
|
73
|
-
if (summary !== "") {
|
|
74
|
-
yield* fs.writeFileString(summary, `${line}\n`, { flag: "a" });
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const rerunFailed = Effect.fn("VitestRerun.rerunFailed")(function* (vitest: Vitest, { attempt, outputDir }: Attempt) {
|
|
27
|
+
const rerunFailed = Effect.fn("VitestRerun.rerunFailed")(function* (runner: AttemptRunner, { attempt, outputDir }: Attempt) {
|
|
79
28
|
const previous = yield* readSnapshot(attemptPaths(outputDir, attempt - 1).snapshot);
|
|
80
29
|
const final = reportPaths(outputDir);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
yield* promise("Vitest could not rerun the failed tests", () => vitest.runTestSpecifications(specifications));
|
|
30
|
+
yield* runner.rerun(yield* failedSelections(previous, final.json));
|
|
31
|
+
const current = yield* runner.read();
|
|
32
|
+
yield* refuseDeclaredRetries(current.files, current.root);
|
|
86
33
|
const lines: string[] = [];
|
|
87
|
-
const merged = yield* mergeAttempt(previous,
|
|
88
|
-
|
|
89
|
-
vitest.state.collectFiles(vitest.getProjectByName(file.projectName ?? ""), [file]);
|
|
90
|
-
}
|
|
34
|
+
const merged = yield* mergeAttempt(previous, current.files, current.root, (line) => lines.push(line));
|
|
35
|
+
yield* runner.collect(merged.files, previous.summary);
|
|
91
36
|
yield* Effect.forEach(lines, summaryLine);
|
|
92
|
-
for (const reporter of [new TimedJsonReporter({ outputFile: final.json }), new RerunJunitReporter(final.junit)]) {
|
|
93
|
-
yield* promise("Vitest could not write the merged reports", async () => {
|
|
94
|
-
await reporter.onInit(vitest);
|
|
95
|
-
await reporter.onTestRunEnd(vitest.state.getTestModules());
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
37
|
if (merged.flaky.length > 0) {
|
|
99
38
|
yield* writeFlakyTests(final.flaky, merged.flaky);
|
|
100
39
|
}
|
|
101
|
-
|
|
102
|
-
yield* mergeCoverage(vitest, outputDir, attempt);
|
|
103
|
-
}
|
|
40
|
+
const state = yield* runner.read();
|
|
104
41
|
const snapshot: Snapshot = {
|
|
105
|
-
errors:
|
|
42
|
+
errors: state.errors,
|
|
106
43
|
failures: merged.failures,
|
|
107
|
-
files:
|
|
44
|
+
files: state.files,
|
|
108
45
|
flaky: merged.flaky,
|
|
46
|
+
summary: state.summary,
|
|
109
47
|
};
|
|
110
48
|
return snapshot;
|
|
111
49
|
});
|
|
112
50
|
|
|
113
51
|
export const runAttempt = Effect.fn("VitestRerun.runAttempt")(function* (input: Attempt) {
|
|
114
|
-
const
|
|
52
|
+
const vitest = yield* VitestRunner;
|
|
115
53
|
const rerun = input.attempt > 1;
|
|
116
|
-
const { shard: _shard, ...unsharded } = options;
|
|
117
54
|
const reports = rerun ? attemptPaths(input.outputDir, input.attempt) : reportPaths(input.outputDir);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
55
|
+
const runner = yield* vitest.open({
|
|
56
|
+
...(rerun ? { cacheDir: reportPaths(input.outputDir).rerunCache } : {}),
|
|
57
|
+
reports,
|
|
58
|
+
rerun,
|
|
59
|
+
vitestArgs: input.vitestArgs,
|
|
60
|
+
});
|
|
61
|
+
let snapshot: Snapshot;
|
|
62
|
+
if (rerun) {
|
|
63
|
+
snapshot = yield* rerunFailed(runner, input);
|
|
64
|
+
} else {
|
|
65
|
+
yield* runner.start();
|
|
66
|
+
const state = yield* runner.read();
|
|
67
|
+
yield* refuseDeclaredRetries(state.files, state.root);
|
|
68
|
+
snapshot = { errors: state.errors, failures: {}, files: state.files, flaky: [], summary: state.summary };
|
|
69
|
+
}
|
|
70
|
+
yield* runner.finish(input.outputDir, input.attempt, reportPaths(input.outputDir));
|
|
124
71
|
yield* saveSnapshot(attemptPaths(input.outputDir, input.attempt).snapshot, snapshot);
|
|
125
72
|
return snapshot.files.some((file) => file.result?.state === "fail") || snapshot.errors.length > 0;
|
|
126
73
|
}, Effect.scoped);
|
package/src/internal/snapshot.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { deserialize, serialize } from "node:v8";
|
|
2
2
|
import { Effect, FileSystem, Schema } from "effect";
|
|
3
|
-
import type {
|
|
3
|
+
import type { Task } from "#adapters/tasks.ts";
|
|
4
4
|
import { failWith } from "#error.ts";
|
|
5
5
|
import { FlakyTest } from "#flakyTests.ts";
|
|
6
6
|
import { SnapshotFiles } from "./SnapshotFiles.ts";
|
|
7
7
|
|
|
8
|
-
export type Files = ReturnType<Vitest["state"]["getFiles"]>;
|
|
9
|
-
export type Task = Files[number]["tasks"][number];
|
|
10
|
-
|
|
11
8
|
export interface FailureHistory {
|
|
12
9
|
readonly attempts: number;
|
|
13
10
|
readonly messages: readonly string[];
|
|
@@ -18,6 +15,22 @@ const SnapshotSchema = Schema.Struct({
|
|
|
18
15
|
failures: Schema.Record(Schema.String, Schema.Struct({ attempts: Schema.Number, messages: Schema.Array(Schema.String) })),
|
|
19
16
|
files: SnapshotFiles,
|
|
20
17
|
flaky: Schema.Array(FlakyTest),
|
|
18
|
+
summary: Schema.Struct({
|
|
19
|
+
added: Schema.Number,
|
|
20
|
+
didUpdate: Schema.Boolean,
|
|
21
|
+
failure: Schema.Boolean,
|
|
22
|
+
filesAdded: Schema.Number,
|
|
23
|
+
filesRemoved: Schema.Number,
|
|
24
|
+
filesRemovedList: Schema.Array(Schema.String),
|
|
25
|
+
filesUnmatched: Schema.Number,
|
|
26
|
+
filesUpdated: Schema.Number,
|
|
27
|
+
matched: Schema.Number,
|
|
28
|
+
total: Schema.Number,
|
|
29
|
+
unchecked: Schema.Number,
|
|
30
|
+
uncheckedKeysByFile: Schema.Array(Schema.Struct({ filePath: Schema.String, keys: Schema.Array(Schema.String) })),
|
|
31
|
+
unmatched: Schema.Number,
|
|
32
|
+
updated: Schema.Number,
|
|
33
|
+
}),
|
|
21
34
|
});
|
|
22
35
|
export type Snapshot = typeof SnapshotSchema.Type;
|
|
23
36
|
|
|
@@ -37,6 +50,10 @@ export function tests(tasks: readonly Task[]): Task[] {
|
|
|
37
50
|
return tasks.flatMap((task) => (task.type === "suite" ? tests(task.tasks) : [task]));
|
|
38
51
|
}
|
|
39
52
|
|
|
53
|
+
export function hasSuiteErrors(task: Task): boolean {
|
|
54
|
+
return task.type === "suite" && ((task.result?.errors?.length ?? 0) > 0 || task.tasks.some(hasSuiteErrors));
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
export const saveSnapshot = Effect.fn("VitestRerun.saveSnapshot")(function* (path: string, snapshot: Snapshot) {
|
|
41
58
|
const fs = yield* FileSystem.FileSystem;
|
|
42
59
|
yield* fs.writeFile(path, serialize(snapshot));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Config, Console, Effect, FileSystem } from "effect";
|
|
2
|
+
|
|
3
|
+
export const summaryLine = Effect.fn("VitestRerun.summaryLine")(function* (line: string) {
|
|
4
|
+
const fs = yield* FileSystem.FileSystem;
|
|
5
|
+
const summary = yield* Config.String("GITHUB_STEP_SUMMARY").pipe(Config.withDefault(""));
|
|
6
|
+
yield* Console.log(line);
|
|
7
|
+
if (summary !== "") {
|
|
8
|
+
yield* fs.writeFileString(summary, `${line}\n`, { flag: "a" });
|
|
9
|
+
}
|
|
10
|
+
});
|