@shivaedev/vitest-rerun 0.0.0-stage → 0.1.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 +9 -0
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -0
- package/dist/error.d.ts +11 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +7 -0
- package/dist/error.js.map +1 -0
- package/dist/flakyTests.d.ts +30 -0
- package/dist/flakyTests.d.ts.map +1 -0
- package/dist/flakyTests.js +24 -0
- package/dist/flakyTests.js.map +1 -0
- package/dist/internal/TimedJsonReporter.d.ts +5 -0
- package/dist/internal/TimedJsonReporter.d.ts.map +1 -0
- package/dist/internal/TimedJsonReporter.js +17 -0
- package/dist/internal/TimedJsonReporter.js.map +1 -0
- package/dist/internal/attempt.d.ts +2 -0
- package/dist/internal/attempt.d.ts.map +1 -0
- package/dist/internal/attempt.js +10 -0
- package/dist/internal/attempt.js.map +1 -0
- package/dist/internal/junit.d.ts +8 -0
- package/dist/internal/junit.d.ts.map +1 -0
- package/dist/internal/junit.js +22 -0
- package/dist/internal/junit.js.map +1 -0
- package/dist/internal/main.d.ts +3 -0
- package/dist/internal/main.d.ts.map +1 -0
- package/dist/internal/main.js +7 -0
- package/dist/internal/main.js.map +1 -0
- package/dist/internal/mergeAttempt.d.ts +9 -0
- package/dist/internal/mergeAttempt.d.ts.map +1 -0
- package/dist/internal/mergeAttempt.js +87 -0
- package/dist/internal/mergeAttempt.js.map +1 -0
- package/dist/internal/reportPaths.d.ts +13 -0
- package/dist/internal/reportPaths.d.ts.map +1 -0
- package/dist/internal/reportPaths.js +18 -0
- package/dist/internal/reportPaths.js.map +1 -0
- package/dist/internal/runAttempt.d.ts +8 -0
- package/dist/internal/runAttempt.d.ts.map +1 -0
- package/dist/internal/runAttempt.js +102 -0
- package/dist/internal/runAttempt.js.map +1 -0
- package/dist/internal/snapshot.d.ts +20 -0
- package/dist/internal/snapshot.d.ts.map +1 -0
- package/dist/internal/snapshot.js +22 -0
- package/dist/internal/snapshot.js.map +1 -0
- package/dist/options.d.ts +12 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +40 -0
- package/dist/options.js.map +1 -0
- package/dist/rerun.d.ts +5 -0
- package/dist/rerun.d.ts.map +1 -0
- package/dist/rerun.js +51 -0
- package/dist/rerun.js.map +1 -0
- package/docs/north-star.md +38 -0
- package/docs/roadmap.md +18 -0
- package/package.json +77 -3
- package/src/cli.ts +7 -0
- package/src/error.ts +10 -0
- package/src/flakyTests.ts +31 -0
- package/src/internal/TimedJsonReporter.ts +17 -0
- package/src/internal/attempt.ts +15 -0
- package/src/internal/junit.ts +25 -0
- package/src/internal/main.ts +10 -0
- package/src/internal/mergeAttempt.ts +105 -0
- package/src/internal/reportPaths.ts +20 -0
- package/src/internal/runAttempt.ts +126 -0
- package/src/internal/snapshot.ts +43 -0
- package/src/options.ts +47 -0
- package/src/rerun.ts +64 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { type FlakyTest, failureMessage, testcases } from "#flakyTests.ts";
|
|
3
|
+
import { junitClassname } from "#internal/junit.ts";
|
|
4
|
+
import { type FailureHistory, type Files, type Snapshot, type Task, tests } from "#internal/snapshot.ts";
|
|
5
|
+
|
|
6
|
+
type File = Files[number];
|
|
7
|
+
|
|
8
|
+
interface Outcome {
|
|
9
|
+
readonly failures: Record<string, FailureHistory>;
|
|
10
|
+
readonly flaky: FlakyTest[];
|
|
11
|
+
readonly root: string;
|
|
12
|
+
readonly summary: (line: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function mergeTest(first: Task, task: Task) {
|
|
16
|
+
const unresolved = first.result?.state === "fail" && !["pass", "fail"].includes(task.result?.state ?? "");
|
|
17
|
+
if (first.result !== undefined && (first.result.state === "pass" || unresolved)) {
|
|
18
|
+
task.mode = first.mode;
|
|
19
|
+
task.result = first.result;
|
|
20
|
+
task.logs = first.logs ?? [];
|
|
21
|
+
} else if (task.result !== undefined) {
|
|
22
|
+
task.result.duration = (task.result.duration ?? 0) + (first.result?.duration ?? 0);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function restorePassed(original: Task[], rerun: Task[]) {
|
|
27
|
+
for (const task of rerun) {
|
|
28
|
+
const first = original.find((item) => item.id === task.id);
|
|
29
|
+
if (first === undefined) {
|
|
30
|
+
throw new Error(`Rerun collected an unexpected test: ${task.name}`);
|
|
31
|
+
}
|
|
32
|
+
if (task.type === "suite" && first.type === "suite") {
|
|
33
|
+
restorePassed(first.tasks, task.tasks);
|
|
34
|
+
} else {
|
|
35
|
+
mergeTest(first, task);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function finishFile(file: File, next: File, ranTests: boolean) {
|
|
41
|
+
const duration = (file.result?.duration ?? 0) + (next.result?.duration ?? 0);
|
|
42
|
+
if (!ranTests && file.tasks.length > 0 && file.result !== undefined) {
|
|
43
|
+
next.result = { ...file.result };
|
|
44
|
+
}
|
|
45
|
+
if (next.result !== undefined) {
|
|
46
|
+
if (tests(next.tasks).some((test) => test.result?.state === "fail")) {
|
|
47
|
+
next.result.state = "fail";
|
|
48
|
+
}
|
|
49
|
+
next.result.startTime = file.result?.startTime ?? next.result.startTime ?? 0;
|
|
50
|
+
next.result.duration = duration;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function recordAttempts(file: File, retried: Task[], outcome: Outcome) {
|
|
55
|
+
const path = relative(outcome.root, file.filepath);
|
|
56
|
+
const classname = junitClassname(path, file.projectName ?? "");
|
|
57
|
+
for (const { name, task } of testcases(file.tasks).filter((testcase) => testcase.task.result?.state === "fail")) {
|
|
58
|
+
const again = retried.find((item) => item.id === task.id);
|
|
59
|
+
const prior = outcome.failures[task.id] ?? { attempts: 1, messages: [failureMessage(task)] };
|
|
60
|
+
if (again?.result?.state === "pass") {
|
|
61
|
+
delete outcome.failures[task.id];
|
|
62
|
+
outcome.flaky.push({ attempts: prior.attempts + 1, classname, file: path, messages: prior.messages, name });
|
|
63
|
+
outcome.summary(`- Flaky: ${classname} — ${name}`);
|
|
64
|
+
} else if (again?.result?.state === "fail") {
|
|
65
|
+
outcome.failures[task.id] = { attempts: prior.attempts + 1, messages: [...prior.messages, failureMessage(again)] };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mergeFile(file: File, next: File, outcome: Outcome) {
|
|
71
|
+
const original = tests(file.tasks);
|
|
72
|
+
const retried = tests(next.tasks);
|
|
73
|
+
if (original.some((test) => !retried.some((item) => item.id === test.id))) {
|
|
74
|
+
throw new Error(`Rerun did not collect every original test in ${file.filepath}`);
|
|
75
|
+
}
|
|
76
|
+
const ranTests = retried.some(
|
|
77
|
+
(test) => ["pass", "fail"].includes(test.result?.state ?? "") && original.some((item) => item.id === test.id && item.result?.state !== "pass"),
|
|
78
|
+
);
|
|
79
|
+
recordAttempts(file, retried, outcome);
|
|
80
|
+
if (original.length === 0 && next.result?.state === "pass") {
|
|
81
|
+
outcome.summary(`- Flaky: ${junitClassname(relative(outcome.root, file.filepath), file.projectName ?? "")} — collection failure`);
|
|
82
|
+
} else if (original.length > 0) {
|
|
83
|
+
restorePassed(file.tasks, next.tasks);
|
|
84
|
+
}
|
|
85
|
+
finishFile(file, next, ranTests);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface Merged {
|
|
89
|
+
readonly failures: Readonly<Record<string, FailureHistory>>;
|
|
90
|
+
readonly files: Files;
|
|
91
|
+
readonly flaky: readonly FlakyTest[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function mergeAttempt(previous: Snapshot, rerun: Files, root: string, summary: (line: string) => void): Merged {
|
|
95
|
+
const outcome: Outcome = { failures: { ...previous.failures }, flaky: [...previous.flaky], root, summary };
|
|
96
|
+
const files = previous.files.map((file) => {
|
|
97
|
+
const next = rerun.find((item) => item.id === file.id);
|
|
98
|
+
if (next === undefined) {
|
|
99
|
+
return file;
|
|
100
|
+
}
|
|
101
|
+
mergeFile(file, next, outcome);
|
|
102
|
+
return next;
|
|
103
|
+
});
|
|
104
|
+
return { failures: outcome.failures, files, flaky: outcome.flaky };
|
|
105
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
export function reportPaths(outputDir: string) {
|
|
4
|
+
return {
|
|
5
|
+
flaky: join(outputDir, "flaky-tests.json"),
|
|
6
|
+
json: join(outputDir, "tests.json"),
|
|
7
|
+
junit: join(outputDir, "tests.junit.xml"),
|
|
8
|
+
rerunCache: join(outputDir, "vite-rerun"),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function attemptPaths(outputDir: string, attempt: number) {
|
|
13
|
+
return {
|
|
14
|
+
json: join(outputDir, `tests.attempt-${attempt}.json`),
|
|
15
|
+
junit: join(outputDir, `tests.attempt-${attempt}.junit.xml`),
|
|
16
|
+
snapshot: join(outputDir, `tests.attempt-${attempt}.bin`),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const ATTEMPT_REPORT = /^tests\.attempt-\d+\.(?:json|junit\.xml|bin)$/u;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Config, Console, Effect, FileSystem, Schema } from "effect";
|
|
2
|
+
import type { CliOptions, Vitest } from "vitest/node";
|
|
3
|
+
import * as VitestNode from "vitest/node";
|
|
4
|
+
import { failWith } from "#error.ts";
|
|
5
|
+
import { writeFlakyTests } from "#flakyTests.ts";
|
|
6
|
+
import { RerunJunitReporter } from "#internal/junit.ts";
|
|
7
|
+
import { mergeAttempt } from "#internal/mergeAttempt.ts";
|
|
8
|
+
import { attemptPaths, reportPaths } from "#internal/reportPaths.ts";
|
|
9
|
+
import { failedFiles, readSnapshot, type Snapshot, saveSnapshot, tests } from "#internal/snapshot.ts";
|
|
10
|
+
import { TimedJsonReporter } from "#internal/TimedJsonReporter.ts";
|
|
11
|
+
|
|
12
|
+
export interface Attempt {
|
|
13
|
+
readonly attempt: number;
|
|
14
|
+
readonly outputDir: string;
|
|
15
|
+
readonly vitestArgs: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CoverageReport = Schema.Struct({ coverageMap: Schema.optional(Schema.Unknown) });
|
|
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) {
|
|
43
|
+
const failed = new Set(yield* failedFiles(json));
|
|
44
|
+
yield* promise("Vitest could not prepare the rerun", () => vitest.standalone());
|
|
45
|
+
const specifications = yield* promise("Vitest could not list test files", () => vitest.globTestSpecifications());
|
|
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) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const runnable = tests(file.tasks).filter((test) => !["pass", "skip", "todo"].includes(test.result?.state ?? test.mode));
|
|
52
|
+
return [spec.project.createSpecification(spec.moduleId, file.tasks.length === 0 ? {} : { testIds: runnable.map((test) => test.id) }, spec.pool)];
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const mergeCoverage = Effect.fn("VitestRerun.mergeCoverage")(function* (vitest: Vitest, outputDir: string, attempt: number) {
|
|
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) {
|
|
79
|
+
const previous = yield* readSnapshot(attemptPaths(outputDir, attempt - 1).snapshot);
|
|
80
|
+
const final = reportPaths(outputDir);
|
|
81
|
+
const specifications = yield* failedSpecifications(vitest, previous, final.json);
|
|
82
|
+
if (specifications.length === 0) {
|
|
83
|
+
return yield* Effect.fail(failWith("No failed specifications found for the fresh rerun")(undefined));
|
|
84
|
+
}
|
|
85
|
+
yield* promise("Vitest could not rerun the failed tests", () => vitest.runTestSpecifications(specifications));
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
const merged = mergeAttempt(previous, vitest.state.getFiles(), vitest.config.root, (line) => lines.push(line));
|
|
88
|
+
for (const file of merged.files) {
|
|
89
|
+
vitest.state.collectFiles(vitest.getProjectByName(file.projectName ?? ""), [file]);
|
|
90
|
+
}
|
|
91
|
+
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
|
+
if (merged.flaky.length > 0) {
|
|
99
|
+
yield* writeFlakyTests(final.flaky, merged.flaky);
|
|
100
|
+
}
|
|
101
|
+
if (vitest.config.coverage.enabled) {
|
|
102
|
+
yield* mergeCoverage(vitest, outputDir, attempt);
|
|
103
|
+
}
|
|
104
|
+
const snapshot: Snapshot = {
|
|
105
|
+
errors: vitest.state.getUnhandledErrors(),
|
|
106
|
+
failures: merged.failures,
|
|
107
|
+
files: vitest.state.getFiles(),
|
|
108
|
+
flaky: merged.flaky,
|
|
109
|
+
};
|
|
110
|
+
return snapshot;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export const runAttempt = Effect.fn("VitestRerun.runAttempt")(function* (input: Attempt) {
|
|
114
|
+
const { filter, options } = VitestNode.parseCLI(["vitest", "run", ...input.vitestArgs]);
|
|
115
|
+
const rerun = input.attempt > 1;
|
|
116
|
+
const { shard: _shard, ...unsharded } = options;
|
|
117
|
+
const reports = rerun ? attemptPaths(input.outputDir, input.attempt) : reportPaths(input.outputDir);
|
|
118
|
+
const vitest = yield* start(rerun ? unsharded : options, reports, rerun ? reportPaths(input.outputDir).rerunCache : undefined);
|
|
119
|
+
const snapshot: Snapshot = rerun
|
|
120
|
+
? yield* rerunFailed(vitest, input)
|
|
121
|
+
: yield* promise("Vitest could not run the tests", () => vitest.start(filter)).pipe(
|
|
122
|
+
Effect.map(() => ({ errors: vitest.state.getUnhandledErrors(), failures: {}, files: vitest.state.getFiles(), flaky: [] })),
|
|
123
|
+
);
|
|
124
|
+
yield* saveSnapshot(attemptPaths(input.outputDir, input.attempt).snapshot, snapshot);
|
|
125
|
+
return snapshot.files.some((file) => file.result?.state === "fail") || snapshot.errors.length > 0;
|
|
126
|
+
}, Effect.scoped);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { deserialize, serialize } from "node:v8";
|
|
2
|
+
import { Effect, FileSystem, Schema } from "effect";
|
|
3
|
+
import type { Vitest } from "vitest/node";
|
|
4
|
+
import type { FlakyTest } from "#flakyTests.ts";
|
|
5
|
+
|
|
6
|
+
export type Files = ReturnType<Vitest["state"]["getFiles"]>;
|
|
7
|
+
export type Task = Files[number]["tasks"][number];
|
|
8
|
+
|
|
9
|
+
export interface FailureHistory {
|
|
10
|
+
readonly attempts: number;
|
|
11
|
+
readonly messages: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Snapshot {
|
|
15
|
+
readonly errors: readonly unknown[];
|
|
16
|
+
readonly failures: Readonly<Record<string, FailureHistory>>;
|
|
17
|
+
readonly files: Files;
|
|
18
|
+
readonly flaky: readonly FlakyTest[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const Report = Schema.Struct({ testResults: Schema.Array(Schema.Struct({ name: Schema.String, status: Schema.Literals(["failed", "passed"]) })) });
|
|
22
|
+
const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Report));
|
|
23
|
+
|
|
24
|
+
export const failedFiles = Effect.fn("VitestRerun.failedFiles")(function* (path: string) {
|
|
25
|
+
const fs = yield* FileSystem.FileSystem;
|
|
26
|
+
const report = yield* decodeReport(yield* fs.readFileString(path));
|
|
27
|
+
return [...new Set(report.testResults.filter((file) => file.status === "failed").map((file) => file.name))];
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export function tests(tasks: readonly Task[]): Task[] {
|
|
31
|
+
return tasks.flatMap((task) => (task.type === "suite" ? tests(task.tasks) : [task]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const saveSnapshot = Effect.fn("VitestRerun.saveSnapshot")(function* (path: string, snapshot: Snapshot) {
|
|
35
|
+
const fs = yield* FileSystem.FileSystem;
|
|
36
|
+
yield* fs.writeFile(path, serialize(snapshot));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const readSnapshot = Effect.fn("VitestRerun.readSnapshot")(function* (path: string) {
|
|
40
|
+
const fs = yield* FileSystem.FileSystem;
|
|
41
|
+
const snapshot: Snapshot = deserialize(yield* fs.readFile(path));
|
|
42
|
+
return snapshot;
|
|
43
|
+
});
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { VitestRerunError } from "#error.ts";
|
|
4
|
+
|
|
5
|
+
export interface RerunOptions {
|
|
6
|
+
readonly cwd: string;
|
|
7
|
+
readonly outputDir: string;
|
|
8
|
+
readonly reruns: number;
|
|
9
|
+
readonly vitestArgs: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_OUTPUT_DIR = "test-results";
|
|
13
|
+
export const DEFAULT_RERUNS = 1;
|
|
14
|
+
|
|
15
|
+
function option(arg: string, name: string, next: string | undefined): { value: string; consumed: number } | undefined {
|
|
16
|
+
if (arg === name && next !== undefined) {
|
|
17
|
+
return { consumed: 2, value: next };
|
|
18
|
+
}
|
|
19
|
+
return arg.startsWith(`${name}=`) ? { consumed: 1, value: arg.slice(name.length + 1) } : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseOptions(args: readonly string[], cwd: string): Effect.Effect<RerunOptions, VitestRerunError> {
|
|
23
|
+
let outputDir = DEFAULT_OUTPUT_DIR;
|
|
24
|
+
let reruns = String(DEFAULT_RERUNS);
|
|
25
|
+
const vitestArgs: string[] = [];
|
|
26
|
+
for (let index = 0; index < args.length; ) {
|
|
27
|
+
const arg = args[index] ?? "";
|
|
28
|
+
if (arg === "--") {
|
|
29
|
+
vitestArgs.push(...args.slice(index + 1));
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
const output = option(arg, "--output-dir", args[index + 1]);
|
|
33
|
+
const count = option(arg, "--reruns", args[index + 1]);
|
|
34
|
+
if (output !== undefined) {
|
|
35
|
+
outputDir = output.value;
|
|
36
|
+
} else if (count === undefined) {
|
|
37
|
+
vitestArgs.push(arg);
|
|
38
|
+
} else {
|
|
39
|
+
reruns = count.value;
|
|
40
|
+
}
|
|
41
|
+
index += output?.consumed ?? count?.consumed ?? 1;
|
|
42
|
+
}
|
|
43
|
+
if (!/^\d+$/u.test(reruns)) {
|
|
44
|
+
return Effect.fail(new VitestRerunError({ message: `--reruns expects a whole number, got ${JSON.stringify(reruns)}` }));
|
|
45
|
+
}
|
|
46
|
+
return Effect.succeed({ cwd, outputDir: resolve(cwd, outputDir), reruns: Number(reruns), vitestArgs });
|
|
47
|
+
}
|
package/src/rerun.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { extname, join } from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Console, Effect, FileSystem, Option } from "effect";
|
|
5
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
6
|
+
import { ATTEMPT_REPORT, attemptPaths, reportPaths } from "#internal/reportPaths.ts";
|
|
7
|
+
import { failedFiles, readSnapshot } from "#internal/snapshot.ts";
|
|
8
|
+
import type { RerunOptions } from "#options.ts";
|
|
9
|
+
|
|
10
|
+
const ATTEMPT_MODULE = fileURLToPath(new URL(`./internal/attempt${extname(fileURLToPath(import.meta.url))}`, import.meta.url));
|
|
11
|
+
|
|
12
|
+
const removeReports = Effect.fn("VitestRerun.removeReports")(function* (outputDir: string) {
|
|
13
|
+
const fs = yield* FileSystem.FileSystem;
|
|
14
|
+
yield* fs.makeDirectory(outputDir, { recursive: true });
|
|
15
|
+
const { flaky, json, junit } = reportPaths(outputDir);
|
|
16
|
+
const attempts = (yield* fs.readDirectory(outputDir)).filter((name) => ATTEMPT_REPORT.test(name));
|
|
17
|
+
yield* Effect.forEach([flaky, json, junit, ...attempts.map((name) => join(outputDir, name))], (path) => fs.remove(path, { force: true }));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const spawnAttempt = Effect.fn("VitestRerun.spawnAttempt")(function* (options: RerunOptions, attempt: number) {
|
|
21
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
22
|
+
const command = ChildProcess.make(
|
|
23
|
+
process.execPath,
|
|
24
|
+
[...process.execArgv, ATTEMPT_MODULE, String(attempt), options.outputDir, ...options.vitestArgs],
|
|
25
|
+
{ cwd: options.cwd, extendEnv: true, stderr: "inherit", stdin: "inherit", stdout: "inherit" },
|
|
26
|
+
);
|
|
27
|
+
return yield* spawner.exitCode(command).pipe(
|
|
28
|
+
Effect.map((code) => Option.some(Number(code))),
|
|
29
|
+
Effect.catch((error) => Effect.as(Console.error(`vitest-rerun: attempt ${attempt} ended without an exit code: ${error.message}`), Option.none())),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const rerunnable = Effect.fn("VitestRerun.rerunnable")(function* (outputDir: string, attempt: number) {
|
|
34
|
+
const fs = yield* FileSystem.FileSystem;
|
|
35
|
+
const { snapshot } = attemptPaths(outputDir, attempt);
|
|
36
|
+
const { json } = reportPaths(outputDir);
|
|
37
|
+
if (!((yield* fs.exists(snapshot)) && (yield* fs.exists(json)))) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return (yield* readSnapshot(snapshot)).errors.length === 0 && (yield* failedFiles(json)).length > 0;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const keepFirstAttempt = Effect.fn("VitestRerun.keepFirstAttempt")(function* (outputDir: string) {
|
|
44
|
+
const fs = yield* FileSystem.FileSystem;
|
|
45
|
+
const final = reportPaths(outputDir);
|
|
46
|
+
const first = attemptPaths(outputDir, 1);
|
|
47
|
+
yield* fs.copyFile(final.json, first.json);
|
|
48
|
+
yield* fs.copyFile(final.junit, first.junit);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const rerun = Effect.fn("VitestRerun.rerun")(function* (options: RerunOptions) {
|
|
52
|
+
yield* removeReports(options.outputDir);
|
|
53
|
+
let code = yield* spawnAttempt(options, 1);
|
|
54
|
+
for (let attempt = 1; attempt <= options.reruns; attempt += 1) {
|
|
55
|
+
if (!(Option.exists(code, (status) => status !== 0) && (yield* rerunnable(options.outputDir, attempt)))) {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (attempt === 1) {
|
|
59
|
+
yield* keepFirstAttempt(options.outputDir);
|
|
60
|
+
}
|
|
61
|
+
code = yield* spawnAttempt(options, attempt + 1);
|
|
62
|
+
}
|
|
63
|
+
return Option.getOrElse(code, () => 1);
|
|
64
|
+
});
|