@avi2dg/checks 0.63.1 → 0.65.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 +16 -0
- package/docs/bins.md +3 -4
- package/docs/design.md +3 -2
- package/docs/gates/checks-advisories.md +8 -6
- package/docs/gates/checks-ci-wiring.md +45 -7
- package/docs/gates/checks-flake.md +6 -7
- package/docs/gates/checks-lint.md +1 -1
- package/docs/gates/checks-mutation-scope.md +1 -1
- package/docs/gates/checks-mutation.md +1 -0
- package/docs/gates/checks-night-run.md +32 -26
- package/docs/gates/checks-release-notes.md +5 -0
- package/docs/gates/checks-release-pr.md +2 -1
- package/docs/gates/checks-release-push.md +4 -3
- package/docs/gates/checks-setup.md +2 -0
- package/docs/gates/checks-test.md +28 -1
- package/docs/how-to/set-up-a-repository.md +4 -0
- package/package.json +5 -1
- package/src/delivery/ci-wiring.ts +20 -7
- package/src/delivery/job-timeout.ts +30 -0
- package/src/delivery/release-push.ts +1 -1
- package/src/delivery/release-schedule.ts +53 -18
- package/src/delivery/schedule-policy.ts +64 -0
- package/src/delivery/setup-workflow.ts +43 -0
- package/src/delivery/setup.ts +2 -39
- package/src/testing/affected-tests.ts +261 -0
- package/src/testing/mutation-scope-graph.ts +13 -0
- package/src/testing/mutation-scope.ts +2 -9
- package/src/testing/night-run-findings.ts +7 -4
- package/src/testing/night-run.ts +43 -16
- package/src/testing/test-skips.ts +3 -3
- package/src/testing/test.ts +57 -10
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { Config, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
2
|
+
import { pathsSinceMergeBase, workingTreeFiles } from "../core/git.ts";
|
|
3
|
+
import { decodePullRequestEvent, pullRequestEndsOf } from "../core/lint.ts";
|
|
4
|
+
import { classifyPath } from "../core/scope.ts";
|
|
5
|
+
import { decodeBunfig, importedFile, importsOf, preloadPaths, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
|
|
6
|
+
|
|
7
|
+
export type Narrowing =
|
|
8
|
+
| { readonly kind: "full"; readonly because: string }
|
|
9
|
+
| { readonly kind: "files"; readonly files: readonly string[] };
|
|
10
|
+
|
|
11
|
+
type SelfReference = {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly exports: ReadonlyMap<string, string>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type Repository = {
|
|
17
|
+
readonly files: readonly string[];
|
|
18
|
+
readonly sources: ReadonlyMap<string, string>;
|
|
19
|
+
readonly bins: ReadonlyMap<string, string>;
|
|
20
|
+
readonly self: SelfReference | undefined;
|
|
21
|
+
readonly preloads: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const FIXTURES = "tests/fixtures/";
|
|
25
|
+
// A workflow runs the suite rather than being read by it, so a test that reads one names its path.
|
|
26
|
+
const WORKFLOWS = ".github/";
|
|
27
|
+
const TEST_FILE = /\.test\.tsx?$/;
|
|
28
|
+
const SOURCE_FILE = /\.(?:[cm]?[jt]sx?|astro|svelte|vue|sh)$/;
|
|
29
|
+
const LOCKFILES = new Set(["bun.lock", "bun.lockb", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml"]);
|
|
30
|
+
const TOOL_PINS = new Set([".bun-version", ".npmrc", ".nvmrc", ".node-version", ".tool-versions"]);
|
|
31
|
+
const CONFIG = /(?:^|\.)config\.(?:[cm]?[jt]s|json)$/;
|
|
32
|
+
const STYLESHEET = /\.(?:css|scss|sass|less|pcss|styl)$/;
|
|
33
|
+
const FONT = /\.(?:woff2?|ttf|otf|eot)$/;
|
|
34
|
+
const LAYOUT = /^layout\./i;
|
|
35
|
+
const LISTED_SEGMENTS = /["'`][ \t]*,[ \t]*["'`]/g;
|
|
36
|
+
const TOKEN = /[\w.@/-]+/g;
|
|
37
|
+
const LEADING_HOPS = /^(?:\.{1,2}\/|\/)+/;
|
|
38
|
+
const BARE_IMPORT = /(?:from|import)\s*\(?\s*["']([^"'.][^"']*)["']|require\s*\(\s*["']([^"'.][^"']*)["']\s*\)/g;
|
|
39
|
+
// Bun picks the first condition it knows, and types names no file it runs.
|
|
40
|
+
const CONDITIONS = ["bun", "import", "default", "node", "require"];
|
|
41
|
+
const BUILT = "the build";
|
|
42
|
+
|
|
43
|
+
const basenameOf = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
|
44
|
+
const directoryNode = (directory: string): string => directory + "/";
|
|
45
|
+
|
|
46
|
+
export function sharedReason(path: string, preloads: ReadonlySet<string>): string | undefined {
|
|
47
|
+
if (path.startsWith(FIXTURES)) return undefined;
|
|
48
|
+
const name = basenameOf(path);
|
|
49
|
+
const directories = path.split("/").slice(0, -1);
|
|
50
|
+
if (LOCKFILES.has(name)) return "a lockfile";
|
|
51
|
+
if ((classifyPath(path) === "workflow" && !path.startsWith(WORKFLOWS)) || CONFIG.test(name) || TOOL_PINS.has(name)) {
|
|
52
|
+
return "a config every test runs under";
|
|
53
|
+
}
|
|
54
|
+
if (preloads.has(path)) return "a file Bun preloads into every test";
|
|
55
|
+
if (path.startsWith(TEST_TREE) && !TEST_FILE.test(path)) return "a test helper";
|
|
56
|
+
if (STYLESHEET.test(name)) return "a stylesheet";
|
|
57
|
+
if (FONT.test(name) || directories.includes("fonts")) return "a font";
|
|
58
|
+
if (directories.includes("layouts") || LAYOUT.test(name)) return "a layout";
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A path mentioned by any trailing run of two or more of its segments, or in full, names it.
|
|
63
|
+
function mentionKeys(path: string): readonly string[] {
|
|
64
|
+
const segments = path.split("/");
|
|
65
|
+
const keys = [path];
|
|
66
|
+
for (let start = 1; start <= segments.length - 2; start += 1) keys.push(segments.slice(start).join("/"));
|
|
67
|
+
return keys;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Only a fixture is read by its directory; a source directory a file names is one it lists, as a runner does.
|
|
71
|
+
function directoriesOf(path: string): readonly string[] {
|
|
72
|
+
if (!path.startsWith(FIXTURES)) return [];
|
|
73
|
+
const segments = path.split("/");
|
|
74
|
+
return segments.slice(1).map((_, end) => segments.slice(0, end + 1).join("/"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function exportTarget(target: unknown): string | undefined {
|
|
78
|
+
if (typeof target === "string") return target;
|
|
79
|
+
if (typeof target !== "object" || target === null) return undefined;
|
|
80
|
+
const conditions: Readonly<Record<string, unknown>> = { ...target };
|
|
81
|
+
for (const condition of CONDITIONS) {
|
|
82
|
+
const found = exportTarget(conditions[condition]);
|
|
83
|
+
if (found !== undefined) return found;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function exportsMap(exports: unknown): ReadonlyMap<string, string> {
|
|
89
|
+
if (typeof exports === "string") return new Map([[".", resolved("", exports)]]);
|
|
90
|
+
if (typeof exports !== "object" || exports === null) return new Map();
|
|
91
|
+
const entries: Readonly<Record<string, unknown>> = { ...exports };
|
|
92
|
+
return new Map(Object.entries(entries).flatMap(([key, target]) => {
|
|
93
|
+
const file = exportTarget(target);
|
|
94
|
+
return file === undefined ? [] : [[key, resolved("", file)] as const];
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function selfTarget(spec: string, self: SelfReference): string | undefined {
|
|
99
|
+
if (spec !== self.name && !spec.startsWith(self.name + "/")) return undefined;
|
|
100
|
+
const subpath = spec === self.name ? "." : "./" + spec.slice(self.name.length + 1);
|
|
101
|
+
if (self.exports.size === 0) return resolved("", subpath === "." ? "index" : subpath);
|
|
102
|
+
const exact = self.exports.get(subpath);
|
|
103
|
+
if (exact !== undefined) return exact;
|
|
104
|
+
for (const [key, target] of self.exports) {
|
|
105
|
+
const star = key.indexOf("*");
|
|
106
|
+
if (star === -1) continue;
|
|
107
|
+
const [before, after] = [key.slice(0, star), key.slice(star + 1)];
|
|
108
|
+
if (subpath.startsWith(before) && subpath.endsWith(after) && subpath.length >= key.length - 1) {
|
|
109
|
+
return target.replace("*", subpath.slice(before.length, subpath.length - after.length));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return BUILT;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type Graph = { readonly readers: ReadonlyMap<string, ReadonlySet<string>>; readonly builtReaders: ReadonlySet<string> };
|
|
116
|
+
|
|
117
|
+
function linkTo(readers: Map<string, Set<string>>, read: string, reader: string): void {
|
|
118
|
+
if (read === reader) return;
|
|
119
|
+
readers.set(read, (readers.get(read) ?? new Set()).add(reader));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function selfImports(text: string, self: SelfReference, known: ReadonlySet<string>): readonly string[] {
|
|
123
|
+
return [...text.matchAll(BARE_IMPORT)].flatMap(([, viaImport = "", viaRequire = ""]) => {
|
|
124
|
+
const target = selfTarget(viaImport || viaRequire, self);
|
|
125
|
+
if (target === undefined || target === BUILT) return target === undefined ? [] : [BUILT];
|
|
126
|
+
const found = importedFile(target, known);
|
|
127
|
+
return found.length === 0 ? [BUILT] : found;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function mentioned(text: string, index: ReadonlyMap<string, readonly string[]>): readonly string[] {
|
|
132
|
+
const joined = text.replaceAll(LISTED_SEGMENTS, "/");
|
|
133
|
+
return [...joined.matchAll(TOKEN)].flatMap(([token]) => index.get(token.replace(LEADING_HOPS, "").replace(/\/$/, "")) ?? []);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function mentionIndex(repository: Repository, known: ReadonlySet<string>): ReadonlyMap<string, readonly string[]> {
|
|
137
|
+
const index = new Map<string, string[]>();
|
|
138
|
+
const add = (key: string, node: string) => index.set(key, [...(index.get(key) ?? []), node]);
|
|
139
|
+
const directories = new Set([...known].flatMap(directoriesOf));
|
|
140
|
+
for (const file of known) for (const key of mentionKeys(file)) add(key, file);
|
|
141
|
+
for (const directory of directories) for (const key of mentionKeys(directory)) if (key.includes("/")) add(key, directoryNode(directory));
|
|
142
|
+
for (const [bin, file] of repository.bins) add(bin, file);
|
|
143
|
+
return index;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function graphOf(repository: Repository, changed: readonly string[]): Graph {
|
|
147
|
+
const known = new Set([...repository.files, ...changed]);
|
|
148
|
+
const index = mentionIndex(repository, known);
|
|
149
|
+
const readers = new Map<string, Set<string>>();
|
|
150
|
+
const builtReaders = new Set<string>();
|
|
151
|
+
for (const [file, text] of repository.sources) {
|
|
152
|
+
const self = repository.self === undefined ? [] : selfImports(text, repository.self, known);
|
|
153
|
+
if (self.includes(BUILT)) builtReaders.add(file);
|
|
154
|
+
const named = file.startsWith(TEST_TREE) ? mentioned(text, index) : [];
|
|
155
|
+
for (const read of [...importsOf(file, text, known), ...self.filter((target) => target !== BUILT), ...named]) {
|
|
156
|
+
linkTo(readers, read, file);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { readers, builtReaders };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function reach(graph: Graph, changed: readonly string[], sources: ReadonlyMap<string, string>): ReadonlySet<string> {
|
|
163
|
+
const reached = new Set<string>();
|
|
164
|
+
const pending = [...changed];
|
|
165
|
+
const builds = changed.some((path) => sources.has(path) && !path.startsWith(TEST_TREE));
|
|
166
|
+
if (builds) pending.push(...graph.builtReaders);
|
|
167
|
+
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
|
168
|
+
if (reached.has(node)) continue;
|
|
169
|
+
reached.add(node);
|
|
170
|
+
pending.push(...(graph.readers.get(node) ?? []));
|
|
171
|
+
if (!node.endsWith("/")) pending.push(...directoriesOf(node).map(directoryNode));
|
|
172
|
+
}
|
|
173
|
+
return reached;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function preloadClosure(repository: Repository): ReadonlySet<string> {
|
|
177
|
+
const known = new Set(repository.files);
|
|
178
|
+
const loaded = new Set<string>();
|
|
179
|
+
for (let pending = [...repository.preloads]; pending.length > 0; ) {
|
|
180
|
+
const file = pending.pop() ?? "";
|
|
181
|
+
if (loaded.has(file)) continue;
|
|
182
|
+
loaded.add(file);
|
|
183
|
+
pending.push(...importsOf(file, repository.sources.get(file) ?? "", known));
|
|
184
|
+
}
|
|
185
|
+
return loaded;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A test or its helper reads a file it imports or names by path or bin, and a source reads only what it imports, at any depth.
|
|
189
|
+
// A source that names a path, as a registry does, would make every importer read every file it lists.
|
|
190
|
+
export function narrowTests(changed: readonly string[], repository: Repository, tests: readonly string[]): Narrowing {
|
|
191
|
+
if (changed.length === 0) return { kind: "full", because: "the pull request changes no file" };
|
|
192
|
+
const preloads = preloadClosure(repository);
|
|
193
|
+
for (const path of changed) {
|
|
194
|
+
const reason = sharedReason(path, preloads);
|
|
195
|
+
if (reason !== undefined) return { kind: "full", because: `the pull request changes ${path}, ${reason}` };
|
|
196
|
+
}
|
|
197
|
+
const reached = reach(graphOf(repository, changed), changed, repository.sources);
|
|
198
|
+
return { kind: "files", files: tests.filter((test) => reached.has(test)) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const Manifest = Schema.Struct({
|
|
202
|
+
name: Schema.optionalKey(Schema.String),
|
|
203
|
+
exports: Schema.optionalKey(Schema.Unknown),
|
|
204
|
+
bin: Schema.optionalKey(Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)])),
|
|
205
|
+
});
|
|
206
|
+
const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest));
|
|
207
|
+
|
|
208
|
+
class NarrowingError extends Schema.TaggedError<NarrowingError>()("NarrowingError", {
|
|
209
|
+
message: Schema.String,
|
|
210
|
+
}) {}
|
|
211
|
+
|
|
212
|
+
function binsOf(manifest: typeof Manifest.Type): ReadonlyMap<string, string> {
|
|
213
|
+
const { bin, name } = manifest;
|
|
214
|
+
if (bin === undefined) return new Map();
|
|
215
|
+
if (typeof bin === "string") return name === undefined ? new Map() : new Map([[basenameOf(name), resolved("", bin)]]);
|
|
216
|
+
return new Map(Object.entries(bin).map(([command, file]) => [command, resolved("", file)]));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const readRepository = Effect.fn("readRepository")(function* (root: string) {
|
|
220
|
+
const fs = yield* FileSystem.FileSystem;
|
|
221
|
+
const path = yield* Path.Path;
|
|
222
|
+
const files = yield* workingTreeFiles(root);
|
|
223
|
+
const read = (file: string) => fs.readFileString(path.join(root, file)).pipe(Effect.orElseSucceed(() => ""));
|
|
224
|
+
const sources = new Map(yield* Effect.forEach(files.filter((file) => SOURCE_FILE.test(file)), (file) =>
|
|
225
|
+
read(file).pipe(Effect.map((text) => [file, text] as const)), { concurrency: 16 }));
|
|
226
|
+
const manifest = files.includes("package.json") ? yield* read("package.json").pipe(Effect.flatMap(decodeManifest)) : {};
|
|
227
|
+
const bunfig = files.includes("bunfig.toml")
|
|
228
|
+
? yield* read("bunfig.toml").pipe(
|
|
229
|
+
Effect.flatMap((text) => Effect.try({ try: (): unknown => Bun.TOML.parse(text), catch: (cause) => new NarrowingError({ message: "cannot parse bunfig.toml: " + String(cause) }) })),
|
|
230
|
+
Effect.flatMap(decodeBunfig),
|
|
231
|
+
)
|
|
232
|
+
: {};
|
|
233
|
+
return {
|
|
234
|
+
files,
|
|
235
|
+
sources,
|
|
236
|
+
bins: binsOf(manifest),
|
|
237
|
+
self: manifest.name === undefined ? undefined : { name: manifest.name, exports: exportsMap(manifest.exports) },
|
|
238
|
+
preloads: preloadPaths(bunfig),
|
|
239
|
+
} satisfies Repository;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const pullRequestEvent = Config.all({
|
|
243
|
+
name: Config.String("GITHUB_EVENT_NAME").pipe(Config.withDefault("")),
|
|
244
|
+
path: Config.String("GITHUB_EVENT_PATH").pipe(Config.withDefault("")),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Only a pull_request run has a base to diff against; every other run keeps the suite whole.
|
|
248
|
+
export const readPullRequestNarrowing = Effect.fn("readPullRequestNarrowing")(function* (root: string, tests: readonly string[]) {
|
|
249
|
+
const event = yield* pullRequestEvent.pipe(Effect.orElseSucceed(() => ({ name: "", path: "" })));
|
|
250
|
+
if (event.name !== "pull_request" || event.path === "") return Option.none<Narrowing>();
|
|
251
|
+
const narrowing = Effect.gen(function* () {
|
|
252
|
+
const text = yield* (yield* FileSystem.FileSystem).readFileString(event.path);
|
|
253
|
+
const { pull_request: pullRequest } = yield* decodePullRequestEvent(text);
|
|
254
|
+
const { base, head } = pullRequestEndsOf(pullRequest);
|
|
255
|
+
const changed = yield* pathsSinceMergeBase(base, head, root);
|
|
256
|
+
return narrowTests(changed, yield* readRepository(root), tests);
|
|
257
|
+
});
|
|
258
|
+
return Option.some(yield* narrowing.pipe(
|
|
259
|
+
Effect.catch((cause) => Effect.succeed<Narrowing>({ kind: "full", because: "the pull request's changes cannot be read: " + cause.message })),
|
|
260
|
+
));
|
|
261
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
1
3
|
export const TEST_TREE = "tests/";
|
|
2
4
|
const FIXTURES = "tests/fixtures/";
|
|
3
5
|
const CANDIDATE_EXTENSIONS = [".tsx", ".ts", ".mts", ".cts", ".jsx", ".js", ".mjs", ".cjs", ".json", ".node"];
|
|
@@ -6,6 +8,17 @@ const IMPORT = /(?:from|import)\s*\(?\s*["'](\.[^"']*)["']|require\s*\(\s*["'](\
|
|
|
6
8
|
const LISTED_SEGMENTS = /["'`][ \t]*,[ \t]*["'`]/g;
|
|
7
9
|
const DIRECTORY_ENDS = ['"', "'", "`", '/"', "/'", "/`", "/${"];
|
|
8
10
|
|
|
11
|
+
const Bunfig = Schema.Struct({
|
|
12
|
+
test: Schema.optionalKey(Schema.Struct({ preload: Schema.optionalKey(Schema.Union([Schema.String, Schema.Array(Schema.String)])) })),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const decodeBunfig = Schema.decodeUnknownEffect(Bunfig);
|
|
16
|
+
|
|
17
|
+
export function preloadPaths(bunfig: typeof Bunfig.Type): readonly string[] {
|
|
18
|
+
const preload = bunfig.test?.preload ?? [];
|
|
19
|
+
return (typeof preload === "string" ? [preload] : preload).map((entry) => resolved("", entry));
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
type Needle = { readonly text: string; readonly kind: "file" | "directory"; readonly fixture: string };
|
|
10
23
|
|
|
11
24
|
export function resolved(directory: string, spec: string): string {
|
|
@@ -3,7 +3,7 @@ import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
|
3
3
|
import { escape, minimatch } from "minimatch";
|
|
4
4
|
import { changedPaths, git, pathsAt, pathsOf, rangeEnds, type Change } from "../core/git.ts";
|
|
5
5
|
import { runMain, Usage } from "../core/main.ts";
|
|
6
|
-
import { importedFile, importsOf, readersOf, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
|
|
6
|
+
import { decodeBunfig, importedFile, importsOf, preloadPaths, readersOf, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
|
|
7
7
|
|
|
8
8
|
const NAME = "mutation-scope";
|
|
9
9
|
const USAGE = "usage: mutation-scope.ts <base> [full-baseline-report]";
|
|
@@ -53,12 +53,6 @@ export type Baseline = typeof BaselineSchema.Type;
|
|
|
53
53
|
|
|
54
54
|
const decodeBaseline = Schema.decodeUnknownEffect(Schema.fromJsonString(BaselineSchema));
|
|
55
55
|
|
|
56
|
-
const decodeBunfig = Schema.decodeUnknownEffect(
|
|
57
|
-
Schema.Struct({
|
|
58
|
-
test: Schema.optionalKey(Schema.Struct({ preload: Schema.optionalKey(Schema.Union([Schema.String, Schema.Array(Schema.String)])) })),
|
|
59
|
-
}),
|
|
60
|
-
);
|
|
61
|
-
|
|
62
56
|
export type MutationScope = { readonly scope: readonly string[]; readonly staleTests: readonly string[] };
|
|
63
57
|
|
|
64
58
|
export type Picked = {
|
|
@@ -173,8 +167,7 @@ const preloadsAt = Effect.fn("preloadsAt")(function* (rev: string, paths: Readon
|
|
|
173
167
|
const text = yield* git(["show", `${rev}:${BUNFIG}`]);
|
|
174
168
|
const unparsed = () => new ScopeError({ message: `${BUNFIG} at ${rev} does not parse as TOML whose [test] preload is a path or a list of paths` });
|
|
175
169
|
const bunfig = yield* Effect.try({ try: (): unknown => Bun.TOML.parse(text), catch: unparsed }).pipe(Effect.flatMap(decodeBunfig), Effect.mapError(unparsed));
|
|
176
|
-
|
|
177
|
-
return (typeof preload === "string" ? [preload] : preload).map((entry) => resolved("", entry));
|
|
170
|
+
return preloadPaths(bunfig);
|
|
178
171
|
});
|
|
179
172
|
|
|
180
173
|
const transitivelyImported = Effect.fn("transitivelyImported")(function* (rev: string, roots: readonly string[], paths: ReadonlySet<string>) {
|
|
@@ -20,7 +20,8 @@ export type Finding =
|
|
|
20
20
|
| { readonly kind: "mutation"; readonly file: string; readonly before: Score; readonly after: Score; readonly since: string }
|
|
21
21
|
| { readonly kind: "test"; readonly file: string; readonly name: string }
|
|
22
22
|
| { readonly kind: "crash"; readonly file: string }
|
|
23
|
-
| { readonly kind: "gates"; readonly file: string }
|
|
23
|
+
| { readonly kind: "gates"; readonly file: string }
|
|
24
|
+
| { readonly kind: "mutation-report"; readonly file: string };
|
|
24
25
|
|
|
25
26
|
function scoreOf(file: ReportFile): Score {
|
|
26
27
|
let detected = 0;
|
|
@@ -44,12 +45,13 @@ function fell(before: Score, after: Score): boolean {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// A night judges a key only from a report it read, since a report it lacks leaves every finding in it absent.
|
|
47
|
-
export type Observed = { readonly tests: boolean; readonly gates: boolean; readonly mutation: boolean };
|
|
48
|
+
export type Observed = { readonly tests: boolean; readonly gates: boolean; readonly mutation: boolean; readonly mutationReports: ReadonlySet<string> };
|
|
48
49
|
|
|
49
50
|
export function keyOf(finding: Finding): string {
|
|
50
51
|
if (finding.kind === "mutation") return `mutation:${finding.file}`;
|
|
51
52
|
if (finding.kind === "test") return `test:${finding.file}${NAME_SEPARATOR}${finding.name}`;
|
|
52
53
|
if (finding.kind === "gates") return `gates:${finding.file}`;
|
|
54
|
+
if (finding.kind === "mutation-report") return `mutation-report:${finding.file}`;
|
|
53
55
|
return `test:${finding.file}`;
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -70,8 +72,8 @@ function testFindings(reports: readonly TestReport[]): readonly Finding[] {
|
|
|
70
72
|
return [...new Map(failing.map((finding) => [keyOf(finding), finding])).values()];
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
export function findingsOf(previous: Option.Option<PreviousNight>, tonight: Tonight,
|
|
74
|
-
return [...scoreFindings(previous, tonight.scores), ...testFindings(tonight.tests), ...
|
|
75
|
+
export function findingsOf(previous: Option.Option<PreviousNight>, tonight: Tonight, others: readonly Finding[] = []): readonly Finding[] {
|
|
76
|
+
return [...scoreFindings(previous, tonight.scores), ...testFindings(tonight.tests), ...others].sort((one, other) =>
|
|
75
77
|
keyOf(one).localeCompare(keyOf(other)),
|
|
76
78
|
);
|
|
77
79
|
}
|
|
@@ -97,6 +99,7 @@ export function gatesDecided(log: string): boolean {
|
|
|
97
99
|
export function cleared(key: string, present: ReadonlySet<string>, observed: Observed): boolean {
|
|
98
100
|
if (present.has(key)) return false;
|
|
99
101
|
if (key.startsWith("gates:")) return observed.gates;
|
|
102
|
+
if (key.startsWith("mutation-report:")) return observed.mutationReports.has(key.slice("mutation-report:".length));
|
|
100
103
|
if (!key.startsWith("test:")) return false;
|
|
101
104
|
const [file] = key.slice("test:".length).split(NAME_SEPARATOR);
|
|
102
105
|
return observed.tests && !present.has(`test:${file}`);
|
package/src/testing/night-run.ts
CHANGED
|
@@ -4,12 +4,12 @@ import { ChildProcessSpawner } from "effect/process";
|
|
|
4
4
|
import { DEFAULT_BRANCH } from "../core/gates.ts";
|
|
5
5
|
import { runMain, Usage } from "../core/main.ts";
|
|
6
6
|
import { gh, type GitHubFailure, gitHubJson, REPOSITORY } from "../delivery/github.ts";
|
|
7
|
-
import { parseReport as parseMutationReport } from "./mutation-compare.ts";
|
|
7
|
+
import { parseReport as parseMutationReport, type ReportFile } from "./mutation-compare.ts";
|
|
8
8
|
import { cleared, type Finding, findingsOf, gatesDecided, gatesOf, keyOf, NightRecordSchema, noted, type Observed, type NightRecord, percent, type PreviousNight, scoresOf, type TestReport } from "./night-run-findings.ts";
|
|
9
9
|
import { parseReport as parseTestReport } from "./test-report.ts";
|
|
10
10
|
|
|
11
11
|
const NAME = "checks-night-run";
|
|
12
|
-
const USAGE = `usage: ${NAME} [--mutation <mutation-report>] [--tests <junit-report>]... [--gates <lint-log>]`;
|
|
12
|
+
const USAGE = `usage: ${NAME} [--mutation <mutation-report>]... [--tests <junit-report>]... [--gates <lint-log>]`;
|
|
13
13
|
export const LABEL = "night-run";
|
|
14
14
|
const LABEL_COLOR = "5319e7";
|
|
15
15
|
const LABEL_DESCRIPTION = "A regression a night run on main found";
|
|
@@ -23,7 +23,7 @@ class NightRunFailure extends Schema.TaggedError<NightRunFailure>()("NightRunFai
|
|
|
23
23
|
message: Schema.String,
|
|
24
24
|
}) {}
|
|
25
25
|
|
|
26
|
-
export type Options = { readonly mutation:
|
|
26
|
+
export type Options = { readonly mutation: readonly string[]; readonly tests: readonly string[]; readonly gates: Option.Option<string> };
|
|
27
27
|
|
|
28
28
|
export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]): Effect.fn.Return<Options, Usage> {
|
|
29
29
|
const mutation: string[] = [];
|
|
@@ -35,10 +35,10 @@ export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]):
|
|
|
35
35
|
if (into === undefined || value === undefined || value.startsWith("--")) return yield* new Usage({ message: USAGE });
|
|
36
36
|
into.push(value);
|
|
37
37
|
}
|
|
38
|
-
if (
|
|
38
|
+
if (gates.length > 1 || mutation.length + tests.length + gates.length === 0) {
|
|
39
39
|
return yield* new Usage({ message: USAGE });
|
|
40
40
|
}
|
|
41
|
-
return { mutation
|
|
41
|
+
return { mutation, tests, gates: Option.fromNullishOr(gates[0]) };
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
export type OpenIssue = { readonly number: number; readonly key: string };
|
|
@@ -120,6 +120,7 @@ function fullTitleOf(finding: Finding): string {
|
|
|
120
120
|
if (finding.kind === "mutation") return `Night run: mutation score fell in ${finding.file}`;
|
|
121
121
|
if (finding.kind === "test") return `Night run: ${finding.file} > ${finding.name} fails`;
|
|
122
122
|
if (finding.kind === "gates") return `Night run: nightly gate ${finding.file} failed`;
|
|
123
|
+
if (finding.kind === "mutation-report") return `Night run: no mutation report at ${finding.file}`;
|
|
123
124
|
return `Night run: ${finding.file} crashed its test worker`;
|
|
124
125
|
}
|
|
125
126
|
|
|
@@ -144,6 +145,7 @@ export function findingText(finding: Finding, run: string): string {
|
|
|
144
145
|
}
|
|
145
146
|
if (finding.kind === "test") return `The night run ${run} failed \`${finding.name}\` in \`${finding.file}\`.`;
|
|
146
147
|
if (finding.kind === "gates") return `The night run ${run} failed nightly gate \`${finding.file}\`.`;
|
|
148
|
+
if (finding.kind === "mutation-report") return `The night run ${run} found no mutation report at \`${finding.file}\`: the mutation job that writes it failed, timed out or was cancelled, so no file it mutates was scored.`;
|
|
147
149
|
return `The night run ${run} lost every result of \`${finding.file}\`, because the worker running it crashed.`;
|
|
148
150
|
}
|
|
149
151
|
|
|
@@ -264,13 +266,33 @@ const writeRecord = Effect.fn("writeRecord")(function* (record: NightRecord) {
|
|
|
264
266
|
yield* fs.writeFileString(RECORD, `${JSON.stringify(record, null, 2)}\n`);
|
|
265
267
|
});
|
|
266
268
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
+
type MutationReport = { readonly file: string; readonly files: Option.Option<ReadonlyMap<string, ReportFile>> };
|
|
270
|
+
|
|
271
|
+
// A mutation job that timed out or failed uploads no report, so a missing file is a finding rather than a failed read.
|
|
272
|
+
const mutationReport = Effect.fn("mutationReport")(function* (file: string) {
|
|
273
|
+
if (!(yield* (yield* FileSystem.FileSystem).exists(file).pipe(Effect.mapError(unreadable(file))))) return { file, files: Option.none() } satisfies MutationReport;
|
|
274
|
+
return { file, files: Option.some(yield* parseMutationReport(file, yield* readText(file))) } satisfies MutationReport;
|
|
269
275
|
});
|
|
270
276
|
|
|
277
|
+
function missingReports(reports: readonly MutationReport[]): readonly Finding[] {
|
|
278
|
+
return reports.flatMap(({ file, files }): readonly Finding[] => (Option.isNone(files) ? [{ kind: "mutation-report", file }] : []));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readReports(reports: readonly MutationReport[]): ReadonlySet<string> {
|
|
282
|
+
return new Set(reports.flatMap(({ file, files }) => (Option.isSome(files) ? [file] : [])));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function tonightScores(reports: readonly MutationReport[]): Option.Option<NightRecord["scores"]> {
|
|
286
|
+
const read = reports.flatMap(({ files }) => (Option.isSome(files) ? [...files.value] : []));
|
|
287
|
+
return reports.some(({ files }) => Option.isSome(files)) ? Option.some(scoresOf(new Map(read))) : Option.none();
|
|
288
|
+
}
|
|
289
|
+
|
|
271
290
|
type Filing = { readonly onMain: true; readonly filed: readonly Filed[] } | { readonly onMain: false; readonly ref: string };
|
|
272
291
|
|
|
273
|
-
|
|
292
|
+
// A night missing one report records nothing, so the next night still compares every file with the last night that scored it.
|
|
293
|
+
type Scored = { readonly files: number; readonly missing: number };
|
|
294
|
+
|
|
295
|
+
export function report(run: string, scored: Option.Option<Scored>, previous: Option.Option<PreviousNight>, findings: readonly Finding[], filing: Filing): string {
|
|
274
296
|
const against = Option.isNone(scored)
|
|
275
297
|
? "had no mutation report, so it compared no score"
|
|
276
298
|
: Option.isSome(previous)
|
|
@@ -280,25 +302,30 @@ export function report(run: string, scored: Option.Option<number>, previous: Opt
|
|
|
280
302
|
const listed = filing.onMain ? filing.filed.map((one) => ` ${one.action} #${one.issue} for ${one.key}`) : findings.map((finding) => ` found ${keyOf(finding)}`);
|
|
281
303
|
const recorded = !filing.onMain
|
|
282
304
|
? `${NAME}: the run is on ${filing.ref}, not ${DEFAULT_BRANCH}, so it filed no issue and recorded no score`
|
|
283
|
-
: Option.
|
|
284
|
-
? `${NAME}: recorded
|
|
285
|
-
:
|
|
305
|
+
: Option.isNone(scored)
|
|
306
|
+
? `${NAME}: recorded no score, since it had no mutation report`
|
|
307
|
+
: scored.value.missing === 0
|
|
308
|
+
? `${NAME}: recorded ${scored.value.files} file score(s) in ${RECORD}`
|
|
309
|
+
: `${NAME}: recorded no score, since ${scored.value.missing} mutation report(s) were missing`;
|
|
286
310
|
return [`${NAME}: the night run ${run} ${against}; ${verdict}`, ...listed, recorded].join("\n");
|
|
287
311
|
}
|
|
288
312
|
|
|
289
313
|
const nightRun = Effect.gen(function* () {
|
|
290
314
|
const options = yield* parseArgs(process.argv.slice(2));
|
|
291
315
|
const actions = yield* actionsRun;
|
|
292
|
-
const
|
|
316
|
+
const reports = yield* Effect.forEach(options.mutation, mutationReport);
|
|
317
|
+
const missing = missingReports(reports);
|
|
318
|
+
const scores = tonightScores(reports);
|
|
293
319
|
const tests: TestReport[] = yield* Effect.forEach(options.tests, testReport);
|
|
294
320
|
const log = Option.isSome(options.gates) ? Option.some(yield* readText(options.gates.value)) : Option.none<string>();
|
|
295
321
|
const gates: readonly Finding[] = Option.isSome(log) ? gatesOf(log.value) : [];
|
|
296
322
|
const previous = Option.isSome(scores) ? yield* previousNight(actions) : Option.none<PreviousNight>();
|
|
297
|
-
const observed: Observed = { tests: options.tests.length > 0, gates: Option.isSome(log) && gatesDecided(log.value), mutation: Option.isSome(previous) };
|
|
298
|
-
const findings = findingsOf(previous, { scores: Option.getOrElse(scores, () => ({})), tests }, gates);
|
|
323
|
+
const observed: Observed = { tests: options.tests.length > 0, gates: Option.isSome(log) && gatesDecided(log.value), mutation: Option.isSome(previous) && missing.length === 0, mutationReports: readReports(reports) };
|
|
324
|
+
const findings = findingsOf(previous, { scores: Option.getOrElse(scores, () => ({})), tests }, [...missing, ...gates]);
|
|
299
325
|
const filing: Filing = actions.ref === `refs/heads/${DEFAULT_BRANCH}` ? { onMain: true, filed: yield* fileFindings(findings, observed, actions.run) } : { onMain: false, ref: actions.ref };
|
|
300
|
-
if (filing.onMain && Option.isSome(scores)) yield* writeRecord({ scores: scores.value });
|
|
301
|
-
|
|
326
|
+
if (filing.onMain && Option.isSome(scores) && missing.length === 0) yield* writeRecord({ scores: scores.value });
|
|
327
|
+
const scored = Option.map(scores, (tonight): Scored => ({ files: Object.keys(tonight).length, missing: missing.length }));
|
|
328
|
+
yield* Console.log(report(actions.run, scored, previous, findings, filing));
|
|
302
329
|
return true;
|
|
303
330
|
});
|
|
304
331
|
|
|
@@ -138,13 +138,13 @@ function declarationsIn(source: string, file: string, module: unknown): Declarat
|
|
|
138
138
|
return { kind: "valid", declarations };
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
function selectedTierFile(file: string, tier: TestTier | undefined): boolean {
|
|
141
|
+
export function selectedTierFile(file: string, tier: TestTier | undefined): boolean {
|
|
142
142
|
if (file.startsWith("tests/quarantine/")) return false;
|
|
143
143
|
if (tier === undefined) return !file.startsWith("tests/live/") && !file.startsWith("tests/pixel/");
|
|
144
144
|
return file.startsWith(`tests/${tier}/`);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
const scanTestFiles = Effect.fn("scanTestFiles")(function* (root: string) {
|
|
147
|
+
export const scanTestFiles = Effect.fn("scanTestFiles")(function* (root: string) {
|
|
148
148
|
return yield* Effect.tryPromise({
|
|
149
149
|
try: () =>
|
|
150
150
|
Promise.all(
|
|
@@ -156,7 +156,7 @@ const scanTestFiles = Effect.fn("scanTestFiles")(function* (root: string) {
|
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
const readDeclarationsIn = Effect.fn("readDeclarationsIn")(function* (root: string, files: readonly string[]) {
|
|
159
|
+
export const readDeclarationsIn = Effect.fn("readDeclarationsIn")(function* (root: string, files: readonly string[]) {
|
|
160
160
|
const fs = yield* FileSystem.FileSystem;
|
|
161
161
|
const path = yield* Path.Path;
|
|
162
162
|
const declarations: SkipDeclaration[] = [];
|
package/src/testing/test.ts
CHANGED
|
@@ -4,9 +4,13 @@ import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effec
|
|
|
4
4
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
5
5
|
import { TEST_ENTRY_POINT } from "../core/gates.ts";
|
|
6
6
|
import { runMain, Usage } from "../core/main.ts";
|
|
7
|
+
import { readPullRequestNarrowing } from "./affected-tests.ts";
|
|
7
8
|
import {
|
|
9
|
+
readDeclarationsIn,
|
|
8
10
|
readSkipDeclarations,
|
|
9
11
|
readSmokeDeclarations,
|
|
12
|
+
scanTestFiles,
|
|
13
|
+
selectedTierFile,
|
|
10
14
|
type Environment,
|
|
11
15
|
type SkipDeclaration,
|
|
12
16
|
type TestTier,
|
|
@@ -171,7 +175,8 @@ const environmentOf = Config.Boolean("CI").pipe(
|
|
|
171
175
|
export type SuiteSelection =
|
|
172
176
|
| { readonly kind: "full" }
|
|
173
177
|
| { readonly kind: "tier"; readonly tier: TestTier }
|
|
174
|
-
| { readonly kind: "smoke" }
|
|
178
|
+
| { readonly kind: "smoke" }
|
|
179
|
+
| { readonly kind: "files"; readonly files: readonly string[] };
|
|
175
180
|
|
|
176
181
|
export function selectSuite(options: TestOptions, environment: Environment): SuiteSelection {
|
|
177
182
|
if (options.tier !== undefined) return { kind: "tier", tier: options.tier };
|
|
@@ -191,6 +196,7 @@ export function smokeList(manifest: unknown): readonly string[] | undefined {
|
|
|
191
196
|
export function suiteArgs(selection: SuiteSelection, smoke: readonly string[]): readonly string[] {
|
|
192
197
|
if (selection.kind === "tier") return tierArgs(selection.tier);
|
|
193
198
|
if (selection.kind === "smoke") return ["./tests/unit", ...smoke.map((file) => `./${file}`)];
|
|
199
|
+
if (selection.kind === "files") return selection.files.map((file) => `./${file}`);
|
|
194
200
|
return [];
|
|
195
201
|
}
|
|
196
202
|
|
|
@@ -218,6 +224,37 @@ const readSmokeList = Effect.fn("readSmokeList")(function* (root: string) {
|
|
|
218
224
|
return smoke;
|
|
219
225
|
});
|
|
220
226
|
|
|
227
|
+
// A live or pixel tier and --full run whole, since no pull request picks them.
|
|
228
|
+
export function narrowedTier(options: TestOptions, selection: SuiteSelection): { readonly tier: TestTier | undefined } | undefined {
|
|
229
|
+
if (options.full === true) return undefined;
|
|
230
|
+
if (selection.kind === "full") return { tier: undefined };
|
|
231
|
+
if (selection.kind === "tier" && (selection.tier === "unit" || selection.tier === "e2e")) return { tier: selection.tier };
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const narrowSelection = Effect.fn("narrowSelection")(function* (options: TestOptions, selection: SuiteSelection, environment: Environment) {
|
|
236
|
+
const narrowed = narrowedTier(options, selection);
|
|
237
|
+
if (environment !== "ci" || narrowed === undefined) return selection;
|
|
238
|
+
const tests = (yield* scanTestFiles(process.cwd())).filter((file) => selectedTierFile(file, narrowed.tier));
|
|
239
|
+
const narrowing = yield* readPullRequestNarrowing(process.cwd(), tests);
|
|
240
|
+
if (Option.isNone(narrowing)) return selection;
|
|
241
|
+
if (narrowing.value.kind === "full") {
|
|
242
|
+
yield* Console.log(`${NAME}: ${narrowing.value.because}, so the whole suite runs`);
|
|
243
|
+
return selection;
|
|
244
|
+
}
|
|
245
|
+
yield* Console.log(`${NAME}: the pull request reaches ${narrowing.value.files.length} of ${tests.length} test file(s)`);
|
|
246
|
+
return { kind: "files", files: narrowing.value.files } satisfies SuiteSelection;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const readScope = Effect.fn("readScope")(function* (selection: SuiteSelection, smoke: readonly string[]) {
|
|
250
|
+
if (selection.kind === "smoke") return yield* readSmokeDeclarations(process.cwd(), smoke);
|
|
251
|
+
const declarations =
|
|
252
|
+
selection.kind === "files"
|
|
253
|
+
? yield* readDeclarationsIn(process.cwd(), selection.files)
|
|
254
|
+
: yield* readSkipDeclarations(process.cwd(), selection.kind === "tier" ? selection.tier : undefined);
|
|
255
|
+
return { files: undefined, declarations };
|
|
256
|
+
});
|
|
257
|
+
|
|
221
258
|
const runSuite = Effect.fn("runSuite")(function* (outfile: string, args: readonly string[], workers: number | undefined) {
|
|
222
259
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
223
260
|
const command = ["test", "--randomize", `--parallel=${workerCount(workers)}`, ...args, ...reporterArgs(outfile)];
|
|
@@ -239,15 +276,13 @@ const testEntry = Effect.gen(function* () {
|
|
|
239
276
|
const fs = yield* FileSystem.FileSystem;
|
|
240
277
|
const path = yield* Path.Path;
|
|
241
278
|
const environment = yield* environmentOf;
|
|
242
|
-
const selection = selectSuite(options, environment);
|
|
279
|
+
const selection = yield* narrowSelection(options, selectSuite(options, environment), environment);
|
|
280
|
+
if (selection.kind === "files" && selection.files.length === 0) {
|
|
281
|
+
yield* Console.log(`${NAME}: the pull request changes no file a test reads, so no test ran`);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
243
284
|
const smoke = selection.kind === "smoke" ? yield* readSmokeList(process.cwd()) : [];
|
|
244
|
-
const scope =
|
|
245
|
-
selection.kind === "smoke"
|
|
246
|
-
? yield* readSmokeDeclarations(process.cwd(), smoke)
|
|
247
|
-
: {
|
|
248
|
-
files: undefined,
|
|
249
|
-
declarations: yield* readSkipDeclarations(process.cwd(), selection.kind === "tier" ? selection.tier : undefined),
|
|
250
|
-
};
|
|
285
|
+
const scope = yield* readScope(selection, smoke);
|
|
251
286
|
if (scope.files?.length === 0) {
|
|
252
287
|
yield* Console.log(`${NAME}: no unit test and no smoke list, so this local run ran nothing; declare checks.smoke or pass ${FULL}`);
|
|
253
288
|
return true;
|
|
@@ -257,6 +292,18 @@ const testEntry = Effect.gen(function* () {
|
|
|
257
292
|
const outfile = path.join(yield* fs.makeTempDirectoryScoped({ prefix: "checks-test-" }), "junit.xml");
|
|
258
293
|
const suitePassed =
|
|
259
294
|
(yield* runSuite(outfile, suiteArgs(selection, smoke), options.workers)) === ChildProcessSpawner.ExitCode(0);
|
|
295
|
+
return yield* judgeRun(outfile, junit, suitePassed, declarations, environment);
|
|
296
|
+
}).pipe(Effect.scoped);
|
|
297
|
+
|
|
298
|
+
const judgeRun = Effect.fn("judgeRun")(function* (
|
|
299
|
+
outfile: string,
|
|
300
|
+
junit: Option.Option<string>,
|
|
301
|
+
suitePassed: boolean,
|
|
302
|
+
declarations: Parameters<typeof judgeSkips>[1],
|
|
303
|
+
environment: Environment,
|
|
304
|
+
) {
|
|
305
|
+
const fs = yield* FileSystem.FileSystem;
|
|
306
|
+
const path = yield* Path.Path;
|
|
260
307
|
const written = yield* fs.exists(outfile);
|
|
261
308
|
if (written && Option.isSome(junit)) {
|
|
262
309
|
yield* fs.makeDirectory(path.dirname(junit.value), { recursive: true });
|
|
@@ -276,6 +323,6 @@ const testEntry = Effect.gen(function* () {
|
|
|
276
323
|
const verdict = judgeSkips(results, judged, environment);
|
|
277
324
|
yield* passes(verdict) ? Console.log(report(verdict)) : Console.error(report(verdict));
|
|
278
325
|
return suitePassed && crashedFiles.length === 0 && passes(verdict);
|
|
279
|
-
})
|
|
326
|
+
});
|
|
280
327
|
|
|
281
328
|
if (import.meta.main) runMain(NAME, testEntry);
|