@avi2dg/checks 0.9.0 → 0.10.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/README.md +152 -14
- package/package.json +12 -2
- package/scripts/ci-wiring.ts +68 -25
- package/scripts/flake.ts +153 -0
- package/scripts/gates.ts +2 -0
- package/scripts/test-layout.ts +4 -4
- package/scripts/test-report.ts +115 -0
- package/scripts/test.ts +153 -0
package/README.md
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Deterministic checks shared across my TypeScript repos. One package,
|
|
4
4
|
`@avi2dg/checks`: the `checks-lint` entry point that runs every lint gate
|
|
5
|
-
below over a range it resolves itself, the
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
below over a range it resolves itself, the `checks-test` entry point
|
|
6
|
+
that runs the suite and refuses an undeclared skip, the `checks-flake`
|
|
7
|
+
run that records the seeds a failing test fails with, the oxlint base
|
|
8
|
+
config, the tsconfig fragment with the Effect language-service block,
|
|
9
|
+
the shared commitlint config, the shared dependency-cruiser base, the
|
|
10
|
+
test-layout check with its bunfig preset, the commit-identity check, the
|
|
11
|
+
comment gate with its backtest, the oxlint suppressions ratchet, the
|
|
12
|
+
Stryker mutation-testing preset with its no-regression comparator, the
|
|
13
|
+
CI-wiring check, and the Effect error-channel plugin compiled to
|
|
14
|
+
JavaScript.
|
|
12
15
|
|
|
13
16
|
Published as `@avi2dg/checks` on the public npm registry.
|
|
14
17
|
|
|
@@ -54,9 +57,12 @@ cp node_modules/@avi2dg/checks/bunfig.toml bunfig.toml
|
|
|
54
57
|
```json
|
|
55
58
|
"lint": "oxlint --type-aware && checks-lint",
|
|
56
59
|
"typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
|
|
57
|
-
"test": "
|
|
60
|
+
"test": "checks-test"
|
|
58
61
|
```
|
|
59
62
|
|
|
63
|
+
`checks-test` runs `bun test --randomize` and fails on a skip the
|
|
64
|
+
repository has not declared; see "Test entry point" below.
|
|
65
|
+
|
|
60
66
|
`checks-lint` runs every kit gate a lint needs; see "Lint entry point"
|
|
61
67
|
below. Three of them:
|
|
62
68
|
|
|
@@ -285,8 +291,9 @@ file and the path to move it to when it does not:
|
|
|
285
291
|
them; `tests/fixtures/**` is data and is not parsed. Detection parses
|
|
286
292
|
with swc and reads import specifiers and identifier use, so a test that
|
|
287
293
|
only carries `"node:child_process"` as a string is not a violation.
|
|
288
|
-
- `scripts.test` is exactly `bun test --randomize`
|
|
289
|
-
|
|
294
|
+
- `scripts.test` is exactly `checks-test`, which runs `bun test --randomize`
|
|
295
|
+
(see "Test entry point"), and `scripts.lint` runs this check, itself or
|
|
296
|
+
through `checks-lint` called by its bare bin name.
|
|
290
297
|
- `bunfig.toml` carries every `[test]` key of the shipped preset with the
|
|
291
298
|
same value, and `[test].pathIgnorePatterns` is always
|
|
292
299
|
`["**/tests/quarantine/**"]`: the check pins it itself, so this repo,
|
|
@@ -305,6 +312,109 @@ still run on demand:
|
|
|
305
312
|
bun test --path-ignore-patterns='' tests/quarantine
|
|
306
313
|
```
|
|
307
314
|
|
|
315
|
+
## Test entry point
|
|
316
|
+
|
|
317
|
+
`checks-test` runs the whole suite with `bun test --randomize`, passes
|
|
318
|
+
bun's output through, and then reads bun's JUnit report of the same run.
|
|
319
|
+
bun exits 0 with tests skipped, so a green run says nothing about the
|
|
320
|
+
tests that never ran. `checks-test` fails when a test was skipped, by
|
|
321
|
+
`test.skip`, `test.skipIf`, `test.if`, `describe.skip` or `test.todo`,
|
|
322
|
+
without a declaration in `package.json`:
|
|
323
|
+
|
|
324
|
+
```json
|
|
325
|
+
"testSkips": [
|
|
326
|
+
{
|
|
327
|
+
"file": "tests/e2e/docker.test.ts",
|
|
328
|
+
"test": "images > builds the release image",
|
|
329
|
+
"reason": "the runner has no docker daemon",
|
|
330
|
+
"when": "ci"
|
|
331
|
+
}
|
|
332
|
+
]
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`file` is the path bun reports, relative to the package root, and `test`
|
|
336
|
+
is the name bun's console prints: the describe blocks and the test name
|
|
337
|
+
joined by ` > `. `reason` is required. `when` is `ci` or `local` for a
|
|
338
|
+
test skipped only there, and a declaration without it holds in both;
|
|
339
|
+
`checks-test` counts a run as `ci` when `CI` is set true, as GitHub
|
|
340
|
+
Actions sets it. A declaration that holds for the run but matches no
|
|
341
|
+
skipped test fails a ci run too, so a fixed or renamed test takes its
|
|
342
|
+
declaration with it. A local run only warns about it, because whether a
|
|
343
|
+
test skips there can hang on the machine, such as a docker daemon being
|
|
344
|
+
up:
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
checks-test: 1 skipped test(s) undeclared and 1 declaration(s) matching no skipped test in this ci run:
|
|
348
|
+
tests/pricing.test.ts:12 pricing > rounds half to even: skipped with no declaration; run it, or declare it in package.json testSkips with its reason
|
|
349
|
+
tests/e2e/docker.test.ts > images > builds the release image: declared, but no such test skipped; delete the declaration
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
It exits 1 when a test failed or a skip is undeclared or, in a ci run,
|
|
353
|
+
a declaration stale, and 2 when `testSkips` does not parse or bun
|
|
354
|
+
passed without writing its report. It takes no arguments: a `-t`
|
|
355
|
+
filter reports every test it leaves out as skipped and a path filter
|
|
356
|
+
drops files a declaration names, so a narrowed run is plain
|
|
357
|
+
`bun test --randomize` with the arguments. Files under
|
|
358
|
+
`tests/quarantine/` are never run and so never reported; see "Test
|
|
359
|
+
layout".
|
|
360
|
+
|
|
361
|
+
## Flake run
|
|
362
|
+
|
|
363
|
+
A green run proves nothing failed in that run, not that no test is
|
|
364
|
+
flaky. `checks-flake` runs the whole suite several times, each with its
|
|
365
|
+
own `--seed`, and records per failing test the seeds it failed with:
|
|
366
|
+
|
|
367
|
+
```sh
|
|
368
|
+
checks-flake [--runs <count> | --seed <seed>...] [--report <file>]
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
`--runs` defaults to 10 runs on random seeds, and `--seed`, given once
|
|
372
|
+
per run, replays chosen seeds, such as the ones a report recorded.
|
|
373
|
+
`bun test --randomize --seed=<seed>` puts the suite in the same order,
|
|
374
|
+
so a seed reproduces a failure that hangs on order. `--report` writes the
|
|
375
|
+
record as JSON, every run's seed and failing tests and every failing
|
|
376
|
+
test's seeds, and under GitHub Actions the summary below is appended to
|
|
377
|
+
the job summary:
|
|
378
|
+
|
|
379
|
+
```
|
|
380
|
+
checks-flake: 3 of 10 run(s) failed, 1 test(s) failing in them
|
|
381
|
+
|
|
382
|
+
| Test | Failed | Seeds |
|
|
383
|
+
| --- | --- | --- |
|
|
384
|
+
| tests/cache.test.ts:6 reads the cache | 3 of 10 runs | 2170533150, 4046124386, 180394251 |
|
|
385
|
+
|
|
386
|
+
Reproduce a failing run with bun test --randomize --seed=<seed>.
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
A run that fails with no failing test, such as a test file that throws
|
|
390
|
+
while loading, is listed with its seed on its own line. It exits 1 when
|
|
391
|
+
any run failed and 2 when bun passed without writing its report.
|
|
392
|
+
|
|
393
|
+
A consumer runs it on a schedule and keeps the record as an artifact:
|
|
394
|
+
|
|
395
|
+
```yaml
|
|
396
|
+
on:
|
|
397
|
+
schedule:
|
|
398
|
+
- cron: "17 5 * * *"
|
|
399
|
+
workflow_dispatch:
|
|
400
|
+
jobs:
|
|
401
|
+
flake:
|
|
402
|
+
runs-on: ubuntu-latest
|
|
403
|
+
steps:
|
|
404
|
+
- uses: actions/checkout@v5
|
|
405
|
+
- uses: oven-sh/setup-bun@v2
|
|
406
|
+
- run: bun install --frozen-lockfile
|
|
407
|
+
- run: bunx checks-flake --runs 10 --report flake-report.json
|
|
408
|
+
- uses: actions/upload-artifact@v4
|
|
409
|
+
if: always()
|
|
410
|
+
with:
|
|
411
|
+
name: flake-report
|
|
412
|
+
path: flake-report.json
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
and declares the step in `ciWiring.scheduled`, so `checks-ci-wiring`
|
|
416
|
+
fails once the schedule stops running it; see "CI wiring".
|
|
417
|
+
|
|
308
418
|
## Dependency rules
|
|
309
419
|
|
|
310
420
|
`.dependency-cruiser.cjs` extends the shared base, which carries
|
|
@@ -555,9 +665,31 @@ ci-wiring: 1 of 8 gate(s) do not run on pull requests to main:
|
|
|
555
665
|
.github/workflows/release.yml job publish step 7: .github/workflows/release.yml does not trigger on pull_request
|
|
556
666
|
```
|
|
557
667
|
|
|
558
|
-
It exits 2 when `package.json` declares no gates,
|
|
559
|
-
|
|
560
|
-
|
|
668
|
+
It exits 2 when `package.json` declares no gates, `scheduled` is not an
|
|
669
|
+
array, a gate or scheduled command is not one plain command, or a
|
|
670
|
+
workflow does not parse. Whether a workflow is well formed is
|
|
671
|
+
actionlint's question, not this one's.
|
|
672
|
+
|
|
673
|
+
A command a schedule must run, such as the flake run, goes in
|
|
674
|
+
`"scheduled"` beside `"gates"`:
|
|
675
|
+
|
|
676
|
+
```json
|
|
677
|
+
"ciWiring": {
|
|
678
|
+
"gates": ["bun run lint", "bun run typecheck", "bun run test"],
|
|
679
|
+
"scheduled": ["bunx checks-flake --runs 10 --report flake-report.json"]
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
Each counts only as a step of the same plain shape in a workflow whose
|
|
684
|
+
`on` carries `schedule` with at least one `cron`, under the same
|
|
685
|
+
`if: false`, `continue-on-error: true` and `needs` rules as a gate. It
|
|
686
|
+
exits 1 naming each one no schedule runs:
|
|
687
|
+
|
|
688
|
+
```
|
|
689
|
+
ci-wiring: 1 of 1 scheduled command(s) do not run on a schedule:
|
|
690
|
+
bunx checks-flake --runs 10 --report flake-report.json
|
|
691
|
+
.github/workflows/ci.yml job checks step 5: .github/workflows/ci.yml does not trigger on a schedule
|
|
692
|
+
```
|
|
561
693
|
|
|
562
694
|
### Gate selection
|
|
563
695
|
|
|
@@ -741,6 +873,11 @@ The shared Stryker preset's `json` reporter writes
|
|
|
741
873
|
that check, which is why a selection without it, or without another
|
|
742
874
|
gate that applies everywhere, is refused as `checks-lint` reads it:
|
|
743
875
|
nothing would check the selection otherwise.
|
|
876
|
+
- `checks-test` runs bun itself rather than reading a report some other
|
|
877
|
+
run left: a skip taken only on CI is visible only in CI's own run, and
|
|
878
|
+
an earlier run's report may be stale or narrowed. It reads the JUnit
|
|
879
|
+
report bun writes to a temporary directory, since bun has no other
|
|
880
|
+
per-test output meant for a program.
|
|
744
881
|
- `checks-ci-wiring` runs inside `lint`, not in a workflow of its own:
|
|
745
882
|
deleting the step that runs a check is the violation it catches, so the
|
|
746
883
|
local `lint` is where it has to fail.
|
|
@@ -781,7 +918,8 @@ a tag off `main` and reruns the build, `dist/` check, lint, typecheck
|
|
|
781
918
|
and tests before it publishes:
|
|
782
919
|
|
|
783
920
|
```sh
|
|
784
|
-
|
|
921
|
+
tag="v$(bun -p 'require("./package.json").version')"
|
|
922
|
+
git tag "$tag" && git push origin "$tag"
|
|
785
923
|
```
|
|
786
924
|
|
|
787
925
|
The `release` workflow publishes the tagged version through npm
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Deterministic checks shared across the captain's TypeScript repos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
"scripts/gates.ts",
|
|
22
22
|
"scripts/lint.ts",
|
|
23
23
|
"scripts/test-layout.ts",
|
|
24
|
+
"scripts/test.ts",
|
|
25
|
+
"scripts/test-report.ts",
|
|
26
|
+
"scripts/flake.ts",
|
|
24
27
|
"scripts/commit-identity.ts",
|
|
25
28
|
"scripts/mutation-compare.ts",
|
|
26
29
|
"scripts/ci-wiring.ts",
|
|
@@ -41,6 +44,8 @@
|
|
|
41
44
|
"./scripts/lint-coverage.sh": "./scripts/lint-coverage.sh",
|
|
42
45
|
"./scripts/lint.ts": "./scripts/lint.ts",
|
|
43
46
|
"./scripts/test-layout.ts": "./scripts/test-layout.ts",
|
|
47
|
+
"./scripts/test.ts": "./scripts/test.ts",
|
|
48
|
+
"./scripts/flake.ts": "./scripts/flake.ts",
|
|
44
49
|
"./scripts/commit-identity.ts": "./scripts/commit-identity.ts",
|
|
45
50
|
"./scripts/mutation-compare.ts": "./scripts/mutation-compare.ts",
|
|
46
51
|
"./scripts/ci-wiring.ts": "./scripts/ci-wiring.ts",
|
|
@@ -58,6 +63,8 @@
|
|
|
58
63
|
"checks-lint": "scripts/lint.ts",
|
|
59
64
|
"checks-lint-coverage": "scripts/lint-coverage.sh",
|
|
60
65
|
"checks-test-layout": "scripts/test-layout.ts",
|
|
66
|
+
"checks-test": "scripts/test.ts",
|
|
67
|
+
"checks-flake": "scripts/flake.ts",
|
|
61
68
|
"checks-commit-identity": "scripts/commit-identity.ts",
|
|
62
69
|
"checks-mutation-compare": "scripts/mutation-compare.ts",
|
|
63
70
|
"checks-ci-wiring": "scripts/ci-wiring.ts",
|
|
@@ -69,7 +76,7 @@
|
|
|
69
76
|
"build": "bun build effect-channel/index.ts --outdir dist --target node --format esm",
|
|
70
77
|
"lint": "oxlint --type-aware && bun scripts/lint.ts && depcruise --config .dependency-cruiser.cjs .",
|
|
71
78
|
"typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
|
|
72
|
-
"test": "bun test
|
|
79
|
+
"test": "bun scripts/test.ts"
|
|
73
80
|
},
|
|
74
81
|
"ciWiring": {
|
|
75
82
|
"gates": [
|
|
@@ -79,6 +86,9 @@
|
|
|
79
86
|
"bun run typecheck",
|
|
80
87
|
"bun run test",
|
|
81
88
|
"./node_modules/.bin/commitlint"
|
|
89
|
+
],
|
|
90
|
+
"scheduled": [
|
|
91
|
+
"bun scripts/flake.ts --runs 10 --report flake-report.json"
|
|
82
92
|
]
|
|
83
93
|
},
|
|
84
94
|
"peerDependencies": {
|
package/scripts/ci-wiring.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type Gate = {
|
|
|
21
21
|
|
|
22
22
|
export type Declaration = {
|
|
23
23
|
readonly gates: readonly Gate[];
|
|
24
|
+
readonly scheduled: readonly Gate[];
|
|
24
25
|
readonly defaultBranch: string;
|
|
25
26
|
readonly lintGates: readonly KitGate[];
|
|
26
27
|
};
|
|
@@ -53,6 +54,8 @@ type RunStep = {
|
|
|
53
54
|
readonly script: string;
|
|
54
55
|
};
|
|
55
56
|
|
|
57
|
+
type Trigger = (workflow: Workflow) => string | undefined;
|
|
58
|
+
|
|
56
59
|
export class WiringError extends Schema.TaggedError<WiringError>()("WiringError", {
|
|
57
60
|
message: Schema.String,
|
|
58
61
|
}) {}
|
|
@@ -161,7 +164,7 @@ function selects(patterns: readonly string[], branch: string): boolean {
|
|
|
161
164
|
return selected;
|
|
162
165
|
}
|
|
163
166
|
|
|
164
|
-
|
|
167
|
+
const pullRequestTrigger = (branch: string): Trigger => (workflow) => {
|
|
165
168
|
const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
|
|
166
169
|
const events = isRecord(on) ? Object.keys(on) : (names(on) ?? []);
|
|
167
170
|
if (!events.includes("pull_request")) return `${workflow.path} does not trigger on pull_request`;
|
|
@@ -183,7 +186,14 @@ function triggerBlocker(workflow: Workflow, branch: string): string | undefined
|
|
|
183
186
|
const missing = types === undefined ? [] : GATING_TYPES.filter((type) => !types.includes(type));
|
|
184
187
|
if (missing.length > 0) return `${workflow.path} limits pull_request to types without ${missing.join(", ")}`;
|
|
185
188
|
return undefined;
|
|
186
|
-
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const scheduleTrigger: Trigger = (workflow) => {
|
|
192
|
+
const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
|
|
193
|
+
const schedule = isRecord(on) ? on["schedule"] : undefined;
|
|
194
|
+
const crons = Array.isArray(schedule) ? schedule.filter((entry) => isRecord(entry) && typeof entry["cron"] === "string") : [];
|
|
195
|
+
return crons.length > 0 ? undefined : `${workflow.path} does not trigger on a schedule`;
|
|
196
|
+
};
|
|
187
197
|
|
|
188
198
|
// GitHub prefixes any other if: with success(), so only a status function overrides a needed job's skip.
|
|
189
199
|
function skippedBy(jobs: Readonly<Record<string, unknown>>, id: string, seen: readonly string[]): string | undefined {
|
|
@@ -202,7 +212,7 @@ function localCall(uses: unknown): string | undefined {
|
|
|
202
212
|
return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
|
|
203
213
|
}
|
|
204
214
|
|
|
205
|
-
function runSteps(workflows: readonly Workflow[],
|
|
215
|
+
function runSteps(workflows: readonly Workflow[], trigger: Trigger): readonly RunStep[] {
|
|
206
216
|
const documents = new Map(workflows.map((workflow) => [workflow.path, workflow.document]));
|
|
207
217
|
const steps: RunStep[] = [];
|
|
208
218
|
|
|
@@ -240,7 +250,7 @@ function runSteps(workflows: readonly Workflow[], branch: string): readonly RunS
|
|
|
240
250
|
};
|
|
241
251
|
|
|
242
252
|
for (const workflow of workflows) {
|
|
243
|
-
visit(workflow.document, workflow.path,
|
|
253
|
+
visit(workflow.document, workflow.path, trigger(workflow), [workflow.path]);
|
|
244
254
|
}
|
|
245
255
|
return steps;
|
|
246
256
|
}
|
|
@@ -250,10 +260,9 @@ function entryPointCommand(gate: Command, lintGates: readonly KitGate[]): Comman
|
|
|
250
260
|
return index === -1 ? undefined : [...gate.slice(0, index), ENTRY_POINT.bin];
|
|
251
261
|
}
|
|
252
262
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const entryPoint = entryPointCommand(gate.words, declaration.lintGates);
|
|
263
|
+
function gapsAmong(gates: readonly Gate[], steps: readonly RunStep[], lintGates: readonly KitGate[]): readonly Gap[] {
|
|
264
|
+
return gates.flatMap((gate) => {
|
|
265
|
+
const entryPoint = entryPointCommand(gate.words, lintGates);
|
|
257
266
|
const commands = [
|
|
258
267
|
{ command: gate.command, words: gate.words },
|
|
259
268
|
...(entryPoint === undefined ? [] : [{ command: entryPoint.join(" "), words: entryPoint }]),
|
|
@@ -274,10 +283,16 @@ export function findGaps(declaration: Declaration, workflows: readonly Workflow[
|
|
|
274
283
|
});
|
|
275
284
|
}
|
|
276
285
|
|
|
277
|
-
export function
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
286
|
+
export function findGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
|
|
287
|
+
return gapsAmong(declaration.gates, runSteps(workflows, pullRequestTrigger(declaration.defaultBranch)), declaration.lintGates);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function findScheduledGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
|
|
291
|
+
return gapsAmong(declaration.scheduled, runSteps(workflows, scheduleTrigger), declaration.lintGates);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function gapLines(gaps: readonly Gap[]): readonly string[] {
|
|
295
|
+
const lines: string[] = [];
|
|
281
296
|
for (const gap of gaps) {
|
|
282
297
|
lines.push(` ${gap.gate}`);
|
|
283
298
|
if (gap.blocked.length === 0) {
|
|
@@ -285,11 +300,38 @@ export function formatReport(declaration: Declaration, gaps: readonly Gap[]): st
|
|
|
285
300
|
}
|
|
286
301
|
for (const { location, blocker } of gap.blocked) lines.push(` ${location}: ${blocker}`);
|
|
287
302
|
}
|
|
288
|
-
return lines
|
|
303
|
+
return lines;
|
|
289
304
|
}
|
|
290
305
|
|
|
291
306
|
const decodeWiring = Schema.decodeUnknownEffect(LintWiring);
|
|
292
307
|
|
|
308
|
+
export function formatReport(declaration: Declaration, gaps: readonly Gap[]): string {
|
|
309
|
+
const target = `pull requests to ${declaration.defaultBranch}`;
|
|
310
|
+
if (gaps.length === 0) return `ci-wiring: ${declaration.gates.length} gate(s) run on ${target}`;
|
|
311
|
+
return [`ci-wiring: ${gaps.length} of ${declaration.gates.length} gate(s) do not run on ${target}:`, ...gapLines(gaps)].join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function formatScheduledReport(declaration: Declaration, gaps: readonly Gap[]): string {
|
|
315
|
+
const total = declaration.scheduled.length;
|
|
316
|
+
if (gaps.length === 0) return `ci-wiring: ${total} scheduled command(s) run on a schedule`;
|
|
317
|
+
return [`ci-wiring: ${gaps.length} of ${total} scheduled command(s) do not run on a schedule:`, ...gapLines(gaps)].join("\n");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const plainCommands = Effect.fnUntraced(function* (
|
|
321
|
+
listed: readonly unknown[],
|
|
322
|
+
subject: string,
|
|
323
|
+
): Effect.fn.Return<readonly Gate[], WiringError> {
|
|
324
|
+
const gates: Gate[] = [];
|
|
325
|
+
for (const command of listed) {
|
|
326
|
+
const words = typeof command === "string" ? plainCommand(command) : undefined;
|
|
327
|
+
if (typeof command !== "string" || words === undefined) {
|
|
328
|
+
return yield* new WiringError({ message: `${subject} ${JSON.stringify(command)} is not one plain command` });
|
|
329
|
+
}
|
|
330
|
+
gates.push({ command, words });
|
|
331
|
+
}
|
|
332
|
+
return gates;
|
|
333
|
+
});
|
|
334
|
+
|
|
293
335
|
export const parseDeclaration = Effect.fnUntraced(function* (
|
|
294
336
|
manifest: unknown,
|
|
295
337
|
source: string,
|
|
@@ -299,22 +341,18 @@ export const parseDeclaration = Effect.fnUntraced(function* (
|
|
|
299
341
|
if (!Array.isArray(listed) || listed.length === 0) {
|
|
300
342
|
return yield* new WiringError({ message: `${source} sets no ciWiring.gates, a non-empty array of commands` });
|
|
301
343
|
}
|
|
302
|
-
const
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (typeof command !== "string" || words === undefined) {
|
|
307
|
-
return yield* new WiringError({
|
|
308
|
-
message: `${source} ciWiring gate ${JSON.stringify(command)} is not one plain command`,
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
gates.push({ command, words });
|
|
344
|
+
const gates = yield* plainCommands(listed, `${source} ciWiring gate`);
|
|
345
|
+
const scheduledListed = configured["scheduled"] ?? [];
|
|
346
|
+
if (!Array.isArray(scheduledListed)) {
|
|
347
|
+
return yield* new WiringError({ message: `${source} ciWiring.scheduled is not an array of commands` });
|
|
312
348
|
}
|
|
349
|
+
const scheduled = yield* plainCommands(scheduledListed, `${source} ciWiring scheduled command`);
|
|
313
350
|
const { ciWiring } = yield* decodeWiring(manifest).pipe(
|
|
314
351
|
Effect.mapError((cause) => new WiringError({ message: `${source}: ${cause.message}` })),
|
|
315
352
|
);
|
|
316
353
|
return {
|
|
317
354
|
gates,
|
|
355
|
+
scheduled,
|
|
318
356
|
defaultBranch: ciWiring?.defaultBranch ?? DEFAULT_BRANCH,
|
|
319
357
|
lintGates: selectedGates(ciWiring?.lintGates),
|
|
320
358
|
};
|
|
@@ -389,7 +427,8 @@ export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string)
|
|
|
389
427
|
const wiring = Effect.gen(function* () {
|
|
390
428
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
391
429
|
const declaration = yield* readDeclaration(root);
|
|
392
|
-
const
|
|
430
|
+
const workflows = yield* readWorkflows(root);
|
|
431
|
+
const gaps = findGaps(declaration, workflows);
|
|
393
432
|
const omissions = yield* findOmissions(root, declaration.lintGates);
|
|
394
433
|
const gapReport = formatReport(declaration, gaps);
|
|
395
434
|
yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
|
|
@@ -397,7 +436,11 @@ const wiring = Effect.gen(function* () {
|
|
|
397
436
|
if (omissionReport !== undefined) {
|
|
398
437
|
yield* omissions.length > 0 ? Console.error(omissionReport) : Console.log(omissionReport);
|
|
399
438
|
}
|
|
400
|
-
return gaps.length === 0 && omissions.length === 0;
|
|
439
|
+
if (declaration.scheduled.length === 0) return gaps.length === 0 && omissions.length === 0;
|
|
440
|
+
const scheduledGaps = findScheduledGaps(declaration, workflows);
|
|
441
|
+
const scheduledReport = formatScheduledReport(declaration, scheduledGaps);
|
|
442
|
+
yield* scheduledGaps.length > 0 ? Console.error(scheduledReport) : Console.log(scheduledReport);
|
|
443
|
+
return gaps.length === 0 && omissions.length === 0 && scheduledGaps.length === 0;
|
|
401
444
|
});
|
|
402
445
|
|
|
403
446
|
if (import.meta.main) runMain("ci-wiring", wiring);
|
package/scripts/flake.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Config, Console, Effect, FileSystem, Option, Path, Random, Schema } from "effect";
|
|
3
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
4
|
+
import { runMain, Usage } from "./main.ts";
|
|
5
|
+
import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
|
|
6
|
+
|
|
7
|
+
export type Run = {
|
|
8
|
+
readonly seed: number;
|
|
9
|
+
readonly passed: boolean;
|
|
10
|
+
readonly tests: number;
|
|
11
|
+
readonly failed: readonly TestResult[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type Failure = {
|
|
15
|
+
readonly file: string;
|
|
16
|
+
readonly test: string;
|
|
17
|
+
readonly line: number;
|
|
18
|
+
readonly seeds: readonly number[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type RunRecord = Omit<Run, "failed"> & { readonly failed: readonly string[] };
|
|
22
|
+
|
|
23
|
+
export type FlakeRecord = {
|
|
24
|
+
readonly runs: readonly RunRecord[];
|
|
25
|
+
readonly failures: readonly Failure[];
|
|
26
|
+
readonly outsideTests: readonly number[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type Plan = { readonly seeds: readonly number[] } | { readonly runs: number };
|
|
30
|
+
|
|
31
|
+
const NAME = "checks-flake";
|
|
32
|
+
const USAGE = `usage: ${NAME} [--runs <count> | --seed <seed>...] [--report <file>]`;
|
|
33
|
+
const DEFAULT_RUNS = 10;
|
|
34
|
+
const MAX_SEED = 2 ** 32 - 1;
|
|
35
|
+
|
|
36
|
+
const wholeBetween = (minimum: number, maximum: number) =>
|
|
37
|
+
Schema.decodeUnknownEffect(Schema.NumberFromString.check(Schema.isInt(), Schema.isBetween({ minimum, maximum })));
|
|
38
|
+
const decodeRuns = wholeBetween(1, 1000);
|
|
39
|
+
const decodeSeed = wholeBetween(0, MAX_SEED);
|
|
40
|
+
|
|
41
|
+
const parseArgs = Effect.fnUntraced(function* (args: readonly string[]) {
|
|
42
|
+
const usage = (reason?: string) => new Usage({ message: reason === undefined ? USAGE : `${reason}; ${USAGE}` });
|
|
43
|
+
let runs: string | undefined;
|
|
44
|
+
let report: string | undefined;
|
|
45
|
+
const seeds: string[] = [];
|
|
46
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
47
|
+
const [flag, value] = [args[index], args[index + 1]];
|
|
48
|
+
if (value === undefined) return yield* usage();
|
|
49
|
+
if (flag === "--runs" && runs === undefined) runs = value;
|
|
50
|
+
else if (flag === "--seed") seeds.push(value);
|
|
51
|
+
else if (flag === "--report" && report === undefined) report = value;
|
|
52
|
+
else return yield* usage();
|
|
53
|
+
}
|
|
54
|
+
if (runs !== undefined && seeds.length > 0) return yield* usage();
|
|
55
|
+
const invalid = (flag: string) => (cause: { readonly message: string }) => usage(`${flag}: ${cause.message}`);
|
|
56
|
+
const plan: Plan =
|
|
57
|
+
seeds.length > 0
|
|
58
|
+
? { seeds: yield* Effect.forEach(seeds, (seed) => decodeSeed(seed).pipe(Effect.mapError(invalid("--seed")))) }
|
|
59
|
+
: { runs: runs === undefined ? DEFAULT_RUNS : yield* decodeRuns(runs).pipe(Effect.mapError(invalid("--runs"))) };
|
|
60
|
+
return { plan, report };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const seedsOf = (plan: Plan) =>
|
|
64
|
+
"seeds" in plan ? Effect.succeed(plan.seeds) : Effect.replicateEffect(Random.nextIntBetween(0, MAX_SEED), plan.runs);
|
|
65
|
+
|
|
66
|
+
const runOnce = Effect.fn("runOnce")(function* (seed: number, outfile: string) {
|
|
67
|
+
const fs = yield* FileSystem.FileSystem;
|
|
68
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
69
|
+
const args = ["test", "--randomize", `--seed=${seed}`, ...reporterArgs(outfile)];
|
|
70
|
+
const exitCode = yield* spawner.exitCode(
|
|
71
|
+
ChildProcess.make(process.execPath, args, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }),
|
|
72
|
+
);
|
|
73
|
+
const passed = exitCode === ChildProcessSpawner.ExitCode(0);
|
|
74
|
+
if (!(yield* fs.exists(outfile))) {
|
|
75
|
+
if (passed) return yield* new ReportError({ message: `bun test passed with seed ${seed} but wrote no report` });
|
|
76
|
+
return { seed, passed, tests: 0, failed: [] } satisfies Run;
|
|
77
|
+
}
|
|
78
|
+
const results = yield* parseReport(yield* fs.readFileString(outfile));
|
|
79
|
+
return { seed, passed, tests: results.length, failed: results.filter((result) => result.outcome === "failed") } satisfies Run;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function outcomeOf(run: Run): string {
|
|
83
|
+
if (run.passed) return "passed";
|
|
84
|
+
return run.failed.length === 0 ? "failed outside any test" : `failed, ${run.failed.length} test(s) failing`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function nameOf(result: TestResult): string {
|
|
88
|
+
return `${result.file}${NAME_SEPARATOR}${result.name}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function recordOf(runs: readonly Run[]): FlakeRecord {
|
|
92
|
+
const failures = new Map<string, { file: string; test: string; line: number; seeds: number[] }>();
|
|
93
|
+
for (const run of runs) {
|
|
94
|
+
const failing = new Map(run.failed.map((result) => [nameOf(result), result]));
|
|
95
|
+
for (const [name, result] of failing) {
|
|
96
|
+
const failure = failures.get(name) ?? { file: result.file, test: result.name, line: result.line, seeds: [] };
|
|
97
|
+
failure.seeds.push(run.seed);
|
|
98
|
+
failures.set(name, failure);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
runs: runs.map(({ seed, passed, tests, failed }) => ({ seed, passed, tests, failed: failed.map(nameOf) })),
|
|
103
|
+
failures: [...failures.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line),
|
|
104
|
+
outsideTests: runs.filter((run) => !run.passed && run.failed.length === 0).map((run) => run.seed),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function cell(text: string): string {
|
|
109
|
+
return text.replaceAll("|", "\\|");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function summary(record: FlakeRecord): string {
|
|
113
|
+
const total = record.runs.length;
|
|
114
|
+
const failedRuns = record.runs.filter((run) => !run.passed).length;
|
|
115
|
+
if (failedRuns === 0) return `${NAME}: ${total} run(s) passed, with seeds ${record.runs.map((run) => run.seed).join(", ")}`;
|
|
116
|
+
const lines = [`${NAME}: ${failedRuns} of ${total} run(s) failed, ${record.failures.length} test(s) failing in them`];
|
|
117
|
+
if (record.failures.length > 0) {
|
|
118
|
+
lines.push("", "| Test | Failed | Seeds |", "| --- | --- | --- |");
|
|
119
|
+
for (const failure of record.failures) {
|
|
120
|
+
const place = cell(`${failure.file}:${failure.line} ${failure.test}`);
|
|
121
|
+
lines.push(`| ${place} | ${failure.seeds.length} of ${total} runs | ${failure.seeds.join(", ")} |`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (record.outsideTests.length > 0) {
|
|
125
|
+
lines.push("", `${record.outsideTests.length} run(s) failed outside any test, with seeds ${record.outsideTests.join(", ")}`);
|
|
126
|
+
}
|
|
127
|
+
lines.push("", "Reproduce a failing run with bun test --randomize --seed=<seed>.");
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const flake = Effect.gen(function* () {
|
|
132
|
+
const fs = yield* FileSystem.FileSystem;
|
|
133
|
+
const path = yield* Path.Path;
|
|
134
|
+
const { plan, report } = yield* parseArgs(process.argv.slice(2));
|
|
135
|
+
const seeds = yield* seedsOf(plan);
|
|
136
|
+
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-flake-" });
|
|
137
|
+
|
|
138
|
+
const runs = yield* Effect.forEach(seeds, (seed, index) =>
|
|
139
|
+
runOnce(seed, path.join(dir, `${index}.xml`)).pipe(
|
|
140
|
+
Effect.tap((run) => Console.log(`${NAME}: run ${index + 1} of ${seeds.length}, seed ${seed}: ${outcomeOf(run)}`)),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
const record = recordOf(runs);
|
|
144
|
+
const text = summary(record);
|
|
145
|
+
yield* Console.log(text);
|
|
146
|
+
|
|
147
|
+
if (report !== undefined) yield* fs.writeFileString(report, `${JSON.stringify(record, null, 2)}\n`);
|
|
148
|
+
const stepSummary = yield* Config.option(Config.String("GITHUB_STEP_SUMMARY"));
|
|
149
|
+
if (Option.isSome(stepSummary)) yield* fs.writeFileString(stepSummary.value, `${text}\n`, { flag: "a" });
|
|
150
|
+
return runs.every((run) => run.passed);
|
|
151
|
+
}).pipe(Effect.scoped);
|
|
152
|
+
|
|
153
|
+
if (import.meta.main) runMain(NAME, flake);
|
package/scripts/gates.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type KitGate = Program & {
|
|
|
19
19
|
|
|
20
20
|
export const ENTRY_POINT: Program = { bin: "checks-lint", script: "lint.ts" };
|
|
21
21
|
|
|
22
|
+
export const TEST_ENTRY_POINT: Program = { bin: "checks-test", script: "test.ts" };
|
|
23
|
+
|
|
22
24
|
export const DEFAULT_BRANCH = "main";
|
|
23
25
|
|
|
24
26
|
const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
|
package/scripts/test-layout.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { parse } from "@swc/core";
|
|
|
3
3
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
4
4
|
import { git } from "./git.ts";
|
|
5
5
|
import { runMain } from "./main.ts";
|
|
6
|
-
import { ENTRY_POINT } from "./gates.ts";
|
|
6
|
+
import { ENTRY_POINT, TEST_ENTRY_POINT } from "./gates.ts";
|
|
7
7
|
|
|
8
8
|
export type Violation = {
|
|
9
9
|
readonly file: string;
|
|
@@ -39,13 +39,13 @@ const BANNED_BUN_NAMES: readonly string[] = [
|
|
|
39
39
|
];
|
|
40
40
|
const BANNED_GLOBAL_CALLS: readonly string[] = ["fetch"];
|
|
41
41
|
|
|
42
|
-
export const REQUIRED_TEST_SCRIPT = "bun test --randomize";
|
|
43
42
|
const REQUIRED_TEST_TABLE: Record<string, unknown> = {
|
|
44
43
|
pathIgnorePatterns: ["**/tests/quarantine/**"],
|
|
45
44
|
};
|
|
46
45
|
export const LAYOUT_CHECK_MARK = "scripts/test-layout.ts";
|
|
47
46
|
export const LAYOUT_CHECK_BIN = "checks-test-layout";
|
|
48
47
|
const OWN_ENTRY_POINT = `scripts/${ENTRY_POINT.script}`;
|
|
48
|
+
const TEST_SCRIPTS: readonly string[] = [TEST_ENTRY_POINT.bin, `bun scripts/${TEST_ENTRY_POINT.script}`];
|
|
49
49
|
|
|
50
50
|
export class LayoutError extends Schema.TaggedError<LayoutError>()("LayoutError", {
|
|
51
51
|
message: Schema.String,
|
|
@@ -226,11 +226,11 @@ export function scriptViolations(manifest: unknown): readonly Violation[] {
|
|
|
226
226
|
const test = isRecord(scripts) ? scripts["test"] : undefined;
|
|
227
227
|
const lint = isRecord(scripts) ? scripts["lint"] : undefined;
|
|
228
228
|
const violations: Violation[] = [];
|
|
229
|
-
if (test !==
|
|
229
|
+
if (typeof test !== "string" || !TEST_SCRIPTS.includes(test)) {
|
|
230
230
|
violations.push({
|
|
231
231
|
file,
|
|
232
232
|
line: undefined,
|
|
233
|
-
message: `scripts.test must be exactly "${
|
|
233
|
+
message: `scripts.test must be exactly "${TEST_ENTRY_POINT.bin}", which runs bun test --randomize and judges its skips, found ${JSON.stringify(test ?? null)}`,
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
236
|
if (typeof lint !== "string" || !runsLayoutCheck(lint)) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export type Outcome = "passed" | "failed" | "skipped" | "todo";
|
|
4
|
+
|
|
5
|
+
export type TestResult = {
|
|
6
|
+
readonly file: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly line: number;
|
|
9
|
+
readonly outcome: Outcome;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class ReportError extends Schema.TaggedError<ReportError>()("ReportError", {
|
|
13
|
+
message: Schema.String,
|
|
14
|
+
}) {}
|
|
15
|
+
|
|
16
|
+
type Element = {
|
|
17
|
+
readonly tag: string;
|
|
18
|
+
readonly attributes: Readonly<Record<string, string>>;
|
|
19
|
+
readonly children: Element[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// bun's console prints a test inside describe blocks as "outer > inner > name".
|
|
23
|
+
export const NAME_SEPARATOR = " > ";
|
|
24
|
+
|
|
25
|
+
export function reporterArgs(outfile: string): readonly string[] {
|
|
26
|
+
return ["--reporter=junit", `--reporter-outfile=${outfile}`];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MARKUP =
|
|
30
|
+
/<(?:\?[\s\S]*?\?>|!--[\s\S]*?-->|!\[CDATA\[[\s\S]*?\]\]>|(\/?)([A-Za-z_][\w.:-]*)((?:\s+[A-Za-z_][\w.:-]*\s*=\s*"[^"<]*")*)\s*(\/?)>)/g;
|
|
31
|
+
const ATTRIBUTE = /([A-Za-z_][\w.:-]*)\s*=\s*"([^"<]*)"/g;
|
|
32
|
+
const ENTITY = /&(?:(amp|lt|gt|quot|apos)|#(\d+)|#x([0-9A-Fa-f]+));/g;
|
|
33
|
+
const NAMED_ENTITIES: Readonly<Record<string, string>> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
34
|
+
|
|
35
|
+
const decodeSuite = Schema.decodeUnknownEffect(Schema.Struct({ name: Schema.String }));
|
|
36
|
+
const decodeCase = Schema.decodeUnknownEffect(
|
|
37
|
+
Schema.Struct({ name: Schema.String, file: Schema.String, line: Schema.NumberFromString.check(Schema.isInt()) }),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
function unescape(text: string): string {
|
|
41
|
+
return text.replace(ENTITY, (_, named: string | undefined, decimal: string | undefined, hex: string | undefined) => {
|
|
42
|
+
if (named !== undefined) return NAMED_ENTITIES[named] ?? "";
|
|
43
|
+
return String.fromCodePoint(decimal === undefined ? Number.parseInt(hex ?? "", 16) : Number.parseInt(decimal, 10));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function attributesOf(source: string): Readonly<Record<string, string>> {
|
|
48
|
+
const attributes: Record<string, string> = {};
|
|
49
|
+
for (const [, name, value] of source.matchAll(ATTRIBUTE)) {
|
|
50
|
+
if (name !== undefined && value !== undefined) attributes[name] = unescape(value);
|
|
51
|
+
}
|
|
52
|
+
return attributes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const parseElements = Effect.fnUntraced(function* (xml: string): Effect.fn.Return<Element, ReportError> {
|
|
56
|
+
const malformed = (reason: string) => new ReportError({ message: `the junit report is malformed: ${reason}` });
|
|
57
|
+
const document: Element = { tag: "", attributes: {}, children: [] };
|
|
58
|
+
const open: Element[] = [document];
|
|
59
|
+
let consumed = 0;
|
|
60
|
+
for (const match of xml.matchAll(MARKUP)) {
|
|
61
|
+
if (xml.slice(consumed, match.index).includes("<")) return yield* malformed(`stray < before offset ${match.index}`);
|
|
62
|
+
consumed = match.index + match[0].length;
|
|
63
|
+
const [, closing, tag, attributes, selfClosing] = match;
|
|
64
|
+
if (tag === undefined) continue;
|
|
65
|
+
const parent = open.at(-1);
|
|
66
|
+
if (parent === undefined) return yield* malformed(`<${tag}> after the root closed`);
|
|
67
|
+
if (closing === "/") {
|
|
68
|
+
if (parent.tag !== tag) return yield* malformed(`</${tag}> closes <${parent.tag}>`);
|
|
69
|
+
open.pop();
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const element: Element = { tag, attributes: attributesOf(attributes ?? ""), children: [] };
|
|
73
|
+
parent.children.push(element);
|
|
74
|
+
if (selfClosing !== "/") open.push(element);
|
|
75
|
+
}
|
|
76
|
+
if (xml.slice(consumed).includes("<")) return yield* malformed("stray < after the last element");
|
|
77
|
+
if (open.length !== 1) return yield* malformed(`<${open.at(-1)?.tag}> never closes`);
|
|
78
|
+
const [root, ...extra] = document.children;
|
|
79
|
+
if (root?.tag !== "testsuites" || extra.length > 0) return yield* malformed("the root is not one <testsuites>");
|
|
80
|
+
return root;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function outcomeOf(testcase: Element): Outcome {
|
|
84
|
+
const skipped = testcase.children.find((child) => child.tag === "skipped");
|
|
85
|
+
if (skipped !== undefined) return skipped.attributes["message"] === "TODO" ? "todo" : "skipped";
|
|
86
|
+
return testcase.children.some((child) => child.tag === "failure" || child.tag === "error") ? "failed" : "passed";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const collect = Effect.fnUntraced(function* (
|
|
90
|
+
suite: Element,
|
|
91
|
+
describes: readonly string[],
|
|
92
|
+
results: TestResult[],
|
|
93
|
+
): Effect.fn.Return<void, ReportError> {
|
|
94
|
+
const unreadable = (tag: string) => (cause: { readonly message: string }) =>
|
|
95
|
+
new ReportError({ message: `the junit report holds a <${tag}> bun never writes: ${cause.message}` });
|
|
96
|
+
for (const child of suite.children) {
|
|
97
|
+
if (child.tag === "testsuite") {
|
|
98
|
+
const { name } = yield* decodeSuite(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
|
|
99
|
+
yield* collect(child, [...describes, name], results);
|
|
100
|
+
} else if (child.tag === "testcase") {
|
|
101
|
+
const { name, file, line } = yield* decodeCase(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
|
|
102
|
+
results.push({ file, name: [...describes, name].join(NAME_SEPARATOR), line, outcome: outcomeOf(child) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// A top-level <testsuite> is named for its file, so only the describe blocks nested in it join a test's name.
|
|
108
|
+
export const parseReport = Effect.fn("parseReport")(function* (xml: string) {
|
|
109
|
+
const root = yield* parseElements(xml);
|
|
110
|
+
const results: TestResult[] = [];
|
|
111
|
+
for (const fileSuite of root.children) {
|
|
112
|
+
if (fileSuite.tag === "testsuite") yield* collect(fileSuite, [], results);
|
|
113
|
+
}
|
|
114
|
+
return results;
|
|
115
|
+
});
|
package/scripts/test.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
4
|
+
import { TEST_ENTRY_POINT } from "./gates.ts";
|
|
5
|
+
import { runMain, Usage } from "./main.ts";
|
|
6
|
+
import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
|
|
7
|
+
|
|
8
|
+
const Environment = Schema.Literals(["ci", "local"]);
|
|
9
|
+
|
|
10
|
+
export type Environment = typeof Environment.Type;
|
|
11
|
+
|
|
12
|
+
const SkipDeclaration = Schema.Struct({
|
|
13
|
+
file: Schema.NonEmptyString,
|
|
14
|
+
test: Schema.NonEmptyString,
|
|
15
|
+
reason: Schema.NonEmptyString,
|
|
16
|
+
when: Schema.optionalKey(Environment),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type SkipDeclaration = typeof SkipDeclaration.Type;
|
|
20
|
+
|
|
21
|
+
export type Verdict = {
|
|
22
|
+
readonly environment: Environment;
|
|
23
|
+
readonly skipped: number;
|
|
24
|
+
readonly undeclared: readonly TestResult[];
|
|
25
|
+
readonly stale: readonly SkipDeclaration[];
|
|
26
|
+
readonly unjudged: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class SkipGateError extends Schema.TaggedError<SkipGateError>()("SkipGateError", {
|
|
30
|
+
message: Schema.String,
|
|
31
|
+
}) {}
|
|
32
|
+
|
|
33
|
+
const NAME = TEST_ENTRY_POINT.bin;
|
|
34
|
+
const DECLARATIONS = "package.json testSkips";
|
|
35
|
+
const USAGE = `usage: ${NAME} takes no arguments, since a narrowed run skips every test it leaves out; run bun test --randomize <args> for one`;
|
|
36
|
+
|
|
37
|
+
const decodeManifest = Schema.decodeUnknownEffect(
|
|
38
|
+
Schema.fromJsonString(Schema.Struct({ testSkips: Schema.optionalKey(Schema.Array(SkipDeclaration)) })),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
function matches(declaration: SkipDeclaration, result: TestResult): boolean {
|
|
42
|
+
return declaration.file === result.file && declaration.test === result.name;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function byPlace(a: TestResult, b: TestResult): number {
|
|
46
|
+
return a.file.localeCompare(b.file) || a.line - b.line || a.name.localeCompare(b.name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function judgeSkips(
|
|
50
|
+
results: readonly TestResult[],
|
|
51
|
+
declarations: readonly SkipDeclaration[],
|
|
52
|
+
environment: Environment,
|
|
53
|
+
): Verdict {
|
|
54
|
+
const skipped = results.filter((result) => result.outcome === "skipped" || result.outcome === "todo");
|
|
55
|
+
const applying = declarations.filter((declaration) => (declaration.when ?? environment) === environment);
|
|
56
|
+
return {
|
|
57
|
+
environment,
|
|
58
|
+
skipped: skipped.length,
|
|
59
|
+
undeclared: skipped.filter((result) => !applying.some((declaration) => matches(declaration, result))).sort(byPlace),
|
|
60
|
+
stale: applying.filter((declaration) => !skipped.some((result) => matches(declaration, result))),
|
|
61
|
+
unjudged: declarations.length - applying.length,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function refusedStale(verdict: Verdict): readonly SkipDeclaration[] {
|
|
66
|
+
return verdict.environment === "ci" ? verdict.stale : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function passes(verdict: Verdict): boolean {
|
|
70
|
+
return verdict.undeclared.length === 0 && refusedStale(verdict).length === 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function staleLine(declaration: SkipDeclaration): string {
|
|
74
|
+
return ` ${declaration.file}${NAME_SEPARATOR}${declaration.test}: declared, but no such test skipped; delete the declaration`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function verdictLines(verdict: Verdict): readonly string[] {
|
|
78
|
+
const { environment, skipped, undeclared, unjudged } = verdict;
|
|
79
|
+
const run = `this ${environment} run`;
|
|
80
|
+
if (passes(verdict)) {
|
|
81
|
+
const other = environment === "ci" ? "local" : "ci";
|
|
82
|
+
const aside = unjudged === 0 ? "" : `; ${unjudged} declaration(s) for ${other} not judged in ${run}`;
|
|
83
|
+
return [skipped === 0 ? `${NAME}: no test skipped${aside}` : `${NAME}: ${skipped} skipped test(s), each declared in ${DECLARATIONS}${aside}`];
|
|
84
|
+
}
|
|
85
|
+
const stale = refusedStale(verdict);
|
|
86
|
+
const counted = environment === "ci" ? ` and ${stale.length} declaration(s) matching no skipped test` : "";
|
|
87
|
+
return [
|
|
88
|
+
`${NAME}: ${undeclared.length} skipped test(s) undeclared${counted} in ${run}:`,
|
|
89
|
+
...undeclared.map((result) => {
|
|
90
|
+
const kind = result.outcome === "todo" ? "a todo" : "skipped";
|
|
91
|
+
return ` ${result.file}:${result.line} ${result.name}: ${kind} with no declaration; run it, or declare it in ${DECLARATIONS} with its reason`;
|
|
92
|
+
}),
|
|
93
|
+
...stale.map(staleLine),
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function report(verdict: Verdict): string {
|
|
98
|
+
const warned = verdict.environment === "local" ? verdict.stale : [];
|
|
99
|
+
const warning =
|
|
100
|
+
warned.length === 0
|
|
101
|
+
? []
|
|
102
|
+
: [
|
|
103
|
+
`${NAME}: warning: ${warned.length} declaration(s) matching no skipped test in this local run, refused only in a ci run:`,
|
|
104
|
+
...warned.map(staleLine),
|
|
105
|
+
];
|
|
106
|
+
return [...verdictLines(verdict), ...warning].join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const readDeclarations = Effect.fn("readDeclarations")(function* (root: string) {
|
|
110
|
+
const manifest = (yield* Path.Path).join(root, "package.json");
|
|
111
|
+
const { testSkips } = yield* (yield* FileSystem.FileSystem).readFileString(manifest).pipe(
|
|
112
|
+
Effect.flatMap(decodeManifest),
|
|
113
|
+
Effect.mapError((cause) => new SkipGateError({ message: `cannot read ${DECLARATIONS}: ${cause.message}` })),
|
|
114
|
+
);
|
|
115
|
+
return testSkips ?? [];
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const environmentOf = Config.Boolean("CI").pipe(
|
|
119
|
+
Config.withDefault(false),
|
|
120
|
+
Config.map((ci): Environment => (ci ? "ci" : "local")),
|
|
121
|
+
Effect.mapError((cause) => new SkipGateError({ message: `cannot tell a ci run from a local one: ${cause.message}` })),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const runSuite = Effect.fn("runSuite")(function* (outfile: string) {
|
|
125
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
126
|
+
const args = ["test", "--randomize", ...reporterArgs(outfile)];
|
|
127
|
+
return yield* spawner.exitCode(
|
|
128
|
+
ChildProcess.make(process.execPath, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const testEntry = Effect.gen(function* () {
|
|
133
|
+
if (process.argv.length > 2) return yield* new Usage({ message: USAGE });
|
|
134
|
+
const fs = yield* FileSystem.FileSystem;
|
|
135
|
+
const declarations = yield* readDeclarations(process.cwd());
|
|
136
|
+
const environment = yield* environmentOf;
|
|
137
|
+
|
|
138
|
+
const outfile = (yield* Path.Path).join(yield* fs.makeTempDirectoryScoped({ prefix: "checks-test-" }), "junit.xml");
|
|
139
|
+
const suitePassed = (yield* runSuite(outfile)) === ChildProcessSpawner.ExitCode(0);
|
|
140
|
+
if (!(yield* fs.exists(outfile))) {
|
|
141
|
+
if (!suitePassed) {
|
|
142
|
+
yield* Console.error(`${NAME}: bun test failed before it wrote a report, so no skip was judged`);
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
return yield* new ReportError({ message: `bun test passed but wrote no report to ${outfile}` });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const verdict = judgeSkips(yield* parseReport(yield* fs.readFileString(outfile)), declarations, environment);
|
|
149
|
+
yield* passes(verdict) ? Console.log(report(verdict)) : Console.error(report(verdict));
|
|
150
|
+
return suitePassed && passes(verdict);
|
|
151
|
+
}).pipe(Effect.scoped);
|
|
152
|
+
|
|
153
|
+
if (import.meta.main) runMain(NAME, testEntry);
|