@shivaedev/vitest-rerun 0.2.1 → 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.
Files changed (86) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +5 -3
  3. package/dist/{internal/junit.d.ts → adapters/RerunJunitReporter.d.ts} +3 -2
  4. package/dist/adapters/RerunJunitReporter.d.ts.map +1 -0
  5. package/dist/{internal/junit.js → adapters/RerunJunitReporter.js} +7 -4
  6. package/dist/adapters/RerunJunitReporter.js.map +1 -0
  7. package/dist/adapters/TimedJsonReporter.d.ts.map +1 -0
  8. package/dist/adapters/TimedJsonReporter.js.map +1 -0
  9. package/dist/adapters/VitestRunner.d.ts +48 -0
  10. package/dist/adapters/VitestRunner.d.ts.map +1 -0
  11. package/dist/adapters/VitestRunner.js +67 -0
  12. package/dist/adapters/VitestRunner.js.map +1 -0
  13. package/dist/adapters/deferCoverageThresholds.d.ts +4 -0
  14. package/dist/adapters/deferCoverageThresholds.d.ts.map +1 -0
  15. package/dist/adapters/deferCoverageThresholds.js +46 -0
  16. package/dist/adapters/deferCoverageThresholds.js.map +1 -0
  17. package/dist/adapters/mergeSnapshotSummary.d.ts +4 -0
  18. package/dist/adapters/mergeSnapshotSummary.d.ts.map +1 -0
  19. package/dist/adapters/mergeSnapshotSummary.js +19 -0
  20. package/dist/adapters/mergeSnapshotSummary.js.map +1 -0
  21. package/dist/adapters/tasks.d.ts +4 -0
  22. package/dist/adapters/tasks.d.ts.map +1 -0
  23. package/dist/adapters/tasks.js +2 -0
  24. package/dist/adapters/tasks.js.map +1 -0
  25. package/dist/adapters/writeMergedReports.d.ts +8 -0
  26. package/dist/adapters/writeMergedReports.d.ts.map +1 -0
  27. package/dist/adapters/writeMergedReports.js +21 -0
  28. package/dist/adapters/writeMergedReports.js.map +1 -0
  29. package/dist/cli.js +5 -2
  30. package/dist/cli.js.map +1 -1
  31. package/dist/flakyTests.d.ts +1 -1
  32. package/dist/flakyTests.d.ts.map +1 -1
  33. package/dist/internal/attempt.js +4 -5
  34. package/dist/internal/attempt.js.map +1 -1
  35. package/dist/internal/junitClassname.d.ts +2 -0
  36. package/dist/internal/junitClassname.d.ts.map +1 -0
  37. package/dist/internal/junitClassname.js +4 -0
  38. package/dist/internal/junitClassname.js.map +1 -0
  39. package/dist/internal/mergeAttempt.d.ts +21 -1
  40. package/dist/internal/mergeAttempt.d.ts.map +1 -1
  41. package/dist/internal/mergeAttempt.js +15 -19
  42. package/dist/internal/mergeAttempt.js.map +1 -1
  43. package/dist/internal/refuseDeclaredRetries.d.ts +4 -0
  44. package/dist/internal/refuseDeclaredRetries.d.ts.map +1 -0
  45. package/dist/internal/refuseDeclaredRetries.js +17 -0
  46. package/dist/internal/refuseDeclaredRetries.js.map +1 -0
  47. package/dist/internal/runAttempt.d.ts +2 -2
  48. package/dist/internal/runAttempt.d.ts.map +1 -1
  49. package/dist/internal/runAttempt.js +37 -73
  50. package/dist/internal/runAttempt.js.map +1 -1
  51. package/dist/internal/snapshot.d.ts +62 -6
  52. package/dist/internal/snapshot.d.ts.map +1 -1
  53. package/dist/internal/snapshot.js +19 -0
  54. package/dist/internal/snapshot.js.map +1 -1
  55. package/dist/internal/summaryLine.d.ts +3 -0
  56. package/dist/internal/summaryLine.d.ts.map +1 -0
  57. package/dist/internal/summaryLine.js +10 -0
  58. package/dist/internal/summaryLine.js.map +1 -0
  59. package/dist/rerun.d.ts.map +1 -1
  60. package/dist/rerun.js +2 -2
  61. package/dist/rerun.js.map +1 -1
  62. package/package.json +7 -4
  63. package/src/{internal/junit.ts → adapters/RerunJunitReporter.ts} +7 -4
  64. package/src/adapters/VitestRunner.ts +97 -0
  65. package/src/adapters/deferCoverageThresholds.ts +48 -0
  66. package/src/adapters/mergeSnapshotSummary.ts +21 -0
  67. package/src/adapters/tasks.ts +4 -0
  68. package/src/adapters/writeMergedReports.ts +27 -0
  69. package/src/cli.ts +5 -2
  70. package/src/flakyTests.ts +1 -1
  71. package/src/internal/SnapshotFiles.ts +1 -1
  72. package/src/internal/attempt.ts +6 -5
  73. package/src/internal/junitClassname.ts +3 -0
  74. package/src/internal/mergeAttempt.ts +18 -22
  75. package/src/internal/refuseDeclaredRetries.ts +22 -0
  76. package/src/internal/runAttempt.ts +36 -89
  77. package/src/internal/snapshot.ts +21 -4
  78. package/src/internal/summaryLine.ts +10 -0
  79. package/src/rerun.ts +2 -2
  80. package/dist/internal/adapters/TimedJsonReporter.d.ts.map +0 -1
  81. package/dist/internal/adapters/TimedJsonReporter.js.map +0 -1
  82. package/dist/internal/junit.d.ts.map +0 -1
  83. package/dist/internal/junit.js.map +0 -1
  84. /package/dist/{internal/adapters → adapters}/TimedJsonReporter.d.ts +0 -0
  85. /package/dist/{internal/adapters → adapters}/TimedJsonReporter.js +0 -0
  86. /package/src/{internal/adapters → adapters}/TimedJsonReporter.ts +0 -0
@@ -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,4 @@
1
+ import type { Vitest } from "vitest/node";
2
+
3
+ export type Files = ReturnType<Vitest["state"]["getFiles"]>;
4
+ export type Task = Files[number]["tasks"][number];
@@ -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 * as NodeRuntime from "@effect/platform-node/NodeRuntime";
3
4
  import * as NodeServices from "@effect/platform-node/NodeServices";
4
- import { Effect } from "effect";
5
+ import { Effect, Exit, Runtime } from "effect";
5
6
  import { main } from "#internal/main.ts";
6
7
 
7
- process.exitCode = await Effect.runPromise(main(process.argv.slice(2), process.cwd()).pipe(Effect.provide(NodeServices.layer)));
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
@@ -1,5 +1,5 @@
1
1
  import { Effect, FileSystem, Schema } from "effect";
2
- import type { Task } from "#internal/snapshot.ts";
2
+ import type { Task } from "#adapters/tasks.ts";
3
3
 
4
4
  export const FlakyTest = Schema.Struct({
5
5
  attempts: Schema.Number,
@@ -1,5 +1,5 @@
1
1
  import { Schema } from "effect";
2
- import type { Files } from "./snapshot.ts";
2
+ import type { Files } from "#adapters/tasks.ts";
3
3
 
4
4
  const Result = Schema.Struct({
5
5
  duration: Schema.optional(Schema.Number),
@@ -1,20 +1,21 @@
1
1
  import process from "node:process";
2
2
  import { fileURLToPath } from "node:url";
3
+ import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
3
4
  import * as NodeServices from "@effect/platform-node/NodeServices";
4
- import { Console, Effect } from "effect";
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
- const failed = await Effect.runPromise(
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
  }
@@ -0,0 +1,3 @@
1
+ export function junitClassname(file: string, project: string): string {
2
+ return project === "" ? file : `|${project}| ${file}`;
3
+ }
@@ -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/junit.ts";
6
- import { type FailureHistory, type Files, type Snapshot, type Task, tests } from "#internal/snapshot.ts";
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 mergeTest(first: Task, task: Task) {
18
- const unresolved = first.result?.state === "fail" && !["pass", "fail"].includes(task.result?.state ?? "");
19
- if (first.result !== undefined && (first.result.state === "pass" || unresolved)) {
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 restorePassed(original: Task[], rerun: Task[]) {
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
- restorePassed(first.tasks, task.tasks);
36
- } else {
37
- mergeTest(first, task);
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, ranTests: boolean) {
43
- const duration = (file.result?.duration ?? 0) + (next.result?.duration ?? 0);
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 (tests(next.tasks).some((test) => test.result?.state === "fail")) {
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
- restorePassed(file.tasks, next.tasks);
78
+ mergeTasks(file.tasks, next.tasks);
83
79
  }
84
- finishFile(file, next, ranTests);
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 { 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";
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 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) {
16
+ const failedSelections = Effect.fn("VitestRerun.failedSelections")(function* (previous: Snapshot, json: string) {
43
17
  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) {
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 [spec.project.createSpecification(spec.moduleId, file.tasks.length === 0 ? {} : { testIds: runnable.map((test) => test.id) }, spec.pool)];
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 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) {
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
- 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));
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, 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
- }
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
- if (vitest.config.coverage.enabled) {
102
- yield* mergeCoverage(vitest, outputDir, attempt);
103
- }
40
+ const state = yield* runner.read();
104
41
  const snapshot: Snapshot = {
105
- errors: vitest.state.getUnhandledErrors(),
42
+ errors: state.errors,
106
43
  failures: merged.failures,
107
- files: vitest.state.getFiles(),
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 { filter, options } = VitestNode.parseCLI(["vitest", "run", ...input.vitestArgs]);
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 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
- );
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);
@@ -1,13 +1,10 @@
1
1
  import { deserialize, serialize } from "node:v8";
2
2
  import { Effect, FileSystem, Schema } from "effect";
3
- import type { Vitest } from "vitest/node";
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
+ });
package/src/rerun.ts CHANGED
@@ -58,6 +58,7 @@ const removeSnapshots = Effect.fn("VitestRerun.removeSnapshots")(function* (outp
58
58
  export const rerun = Effect.fn("VitestRerun.rerun")(function* (options: RerunOptions) {
59
59
  yield* removeReports(options.outputDir);
60
60
  let attempts = 1;
61
+ yield* Effect.addFinalizer(() => removeSnapshots(options.outputDir, attempts).pipe(Effect.orDie));
61
62
  let code = yield* spawnAttempt(options, attempts);
62
63
  while (attempts <= options.reruns && Option.exists(code, (status) => status !== 0) && (yield* rerunnable(options.outputDir, attempts))) {
63
64
  if (attempts === 1) {
@@ -66,6 +67,5 @@ export const rerun = Effect.fn("VitestRerun.rerun")(function* (options: RerunOpt
66
67
  attempts += 1;
67
68
  code = yield* spawnAttempt(options, attempts);
68
69
  }
69
- yield* removeSnapshots(options.outputDir, attempts);
70
70
  return Option.getOrElse(code, () => 1);
71
- });
71
+ }, Effect.scoped);
@@ -1 +0,0 @@
1
- {"version":3,"file":"TimedJsonReporter.d.ts","sourceRoot":"","sources":["../../../src/internal/adapters/TimedJsonReporter.ts"],"names":[],"mappings":"AAEA,OAAO,EAA8B,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAIxF,qBAAa,iBAAkB,SAAQ,YAAY;IACnC,YAAY,CAAC,OAAO,EAAE,SAAS,UAAU,EAAE,iBAYzD;CACD"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"TimedJsonReporter.js","sourceRoot":"","sources":["../../../src/internal/adapters/TimedJsonReporter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,YAAY,MAAM,oCAAoC,CAAC;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,0BAA0B,EAAE,YAAY,EAAmB,MAAM,aAAa,CAAC;AACxF,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAEjE,MAAM,OAAO,iBAAkB,SAAQ,YAAY;IACzC,KAAK,CAAC,YAAY,CAAC,OAA8B;QACzD,MAAM,KAAK,CAAC,YAAY,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;QACvC,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC;QACrC,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YACxB,MAAM,IAAI,gBAAgB,CAAC,EAAE,OAAO,EAAE,2CAA2C,EAAE,CAAC,CAAC;QACtF,CAAC;QACD,MAAM,MAAM,CAAC,UAAU,CACtB,gBAAgB,CACf,IAAI,EACJ,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,0BAA0B,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,QAAQ,IAAI,CAAC,CAAC,CACjF,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,CAC1C,CAAC;IACH,CAAC;CACD"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"junit.d.ts","sourceRoot":"","sources":["../../src/internal/junit.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AAEzD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAEpE;AAED,qBAAa,kBAAmB,SAAQ,aAAa;;IAGpD,YAAY,UAAU,EAAE,MAAM,EAQ7B;IAEQ,MAAM,CAAC,MAAM,EAAE,MAAM,iBAG7B;CACD"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"junit.js","sourceRoot":"","sources":["../../src/internal/junit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACrC,OAAO,EAAE,aAAa,EAAe,MAAM,aAAa,CAAC;AAEzD,MAAM,UAAU,cAAc,CAAC,IAAY,EAAE,OAAe;IAC3D,OAAO,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;AACvD,CAAC;AAED,MAAM,OAAO,kBAAmB,SAAQ,aAAa;IAC3C,KAAK,CAAmB;IAEjC,YAAY,UAAkB;QAC7B,MAAM,IAAI,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;QAC1B,KAAK,CAAC;YACL,gBAAgB,EAAE,IAAI;YACtB,iBAAiB,EAAE,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,cAAc,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,CAAC,EAAE,WAAW,CAAC;YAC5G,UAAU;SACV,CAAC,CAAC;QACH,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;IACnB,CAAC;IAEQ,MAAM,CAAC,MAAc;QAC7B,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;QACrC,OAAO,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC7B,CAAC;CACD"}