@avi2dg/checks 0.49.0 → 0.50.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
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.50.0
|
|
6
|
+
|
|
7
|
+
Released 2026-10-20.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **testing:** cap local checks-test at the unit tier plus a declared smoke list [#191](https://github.com/avi2d/checks/pull/191)
|
|
12
|
+
|
|
5
13
|
## 0.49.0
|
|
6
14
|
|
|
7
15
|
Released 2026-10-20.
|
|
@@ -27,6 +27,7 @@ It fails unless the repository holds this shape, and names the file and the path
|
|
|
27
27
|
A test at the `e2e`, `live` or `pixel` level may do all of it, under `tests/quarantine/` as well.
|
|
28
28
|
Helpers in `tests/lib/**` answer to the same rule, since an in-process test reaches them.
|
|
29
29
|
- `scripts.test` is `checks-test`, optionally with `--parallel=<workers>`, which runs `bun test --randomize --parallel=<workers>` as [checks-test](checks-test.md#parallel-runs) says.
|
|
30
|
+
It refuses `--full`, so a local run stays capped.
|
|
30
31
|
- `scripts.lint` runs this check, itself or through `checks-lint` called by its bare bin name.
|
|
31
32
|
- `bunfig.toml` carries every `[test]` key of the shipped preset with the same value.
|
|
32
33
|
`[test].pathIgnorePatterns` is the preset's `["**/tests/quarantine/**", "**/tests/live/**", "**/tests/pixel/**", "repos/**"]`, which the check pins itself, so the kit's own repository, whose bunfig is the preset, cannot drift it either.
|
|
@@ -8,7 +8,8 @@ audience: consumers
|
|
|
8
8
|
|
|
9
9
|
## What it checks
|
|
10
10
|
|
|
11
|
-
It runs the
|
|
11
|
+
It runs the unit tier plus the smoke list outside CI and the full suite under CI.
|
|
12
|
+
It runs them with `bun test --randomize --parallel=<workers>`, as [Parallel runs](#parallel-runs) says, and reads Bun's JUnit report from that run.
|
|
12
13
|
It runs Bun with `CI=true`, so `test.only` fails the run in every environment.
|
|
13
14
|
Bun exits zero when tests skip, so `checks-test` checks every skipped test against its source declaration.
|
|
14
15
|
A test that `test.skip`, `test.skipIf`, `test.if`, `test.todo` or an enclosing `describe.skip` skips fails unless the test declares its reason.
|
|
@@ -44,10 +45,25 @@ A skipped test or a todo without `skipReason` fails in every environment.
|
|
|
44
45
|
## What it reads
|
|
45
46
|
|
|
46
47
|
`checks-test` reads test source files and the JUnit report written by its own run.
|
|
47
|
-
It
|
|
48
|
+
It reads no skip list from `package.json`, only the smoke list.
|
|
48
49
|
It counts a run as `ci` when `CI` is true and as `local` otherwise.
|
|
50
|
+
A local run covers `./tests/unit` plus the smoke list the repository declares.
|
|
51
|
+
The smoke list lives in `package.json` under `checks.smoke` as an array of test file paths.
|
|
52
|
+
A repository with no smoke list runs the unit tier alone outside CI.
|
|
53
|
+
A local run with no unit test and no smoke list runs nothing and passes.
|
|
54
|
+
A `ci` run covers the full default suite.
|
|
55
|
+
A smoke entry that names no test file fails the run.
|
|
56
|
+
A smoke entry under `tests/quarantine/`, `tests/live/` or `tests/pixel/` fails the run, since Bun's ignored paths would drop it.
|
|
49
57
|
|
|
50
|
-
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"checks": {
|
|
61
|
+
"smoke": ["tests/e2e/checkout.test.ts"]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The default suite takes only the arguments [Arguments](#arguments) lists.
|
|
51
67
|
Use `checks-test --tier=live` or `checks-test --tier=pixel` for a named test tier.
|
|
52
68
|
Use `checks-test --tier=unit` or `checks-test --tier=e2e` to run only one level of the default suite.
|
|
53
69
|
A live or pixel run clears Bun's ignored paths, runs only `./tests/live` or `./tests/pixel`, and still checks each skip.
|
|
@@ -103,8 +119,10 @@ A timings option waits for Bun to honour timings under random order.
|
|
|
103
119
|
|
|
104
120
|
## Arguments
|
|
105
121
|
|
|
106
|
-
The default command takes no arguments except `--parallel=<workers>`, as [Parallel runs](#parallel-runs) says, and `--junit=<file>`.
|
|
107
|
-
|
|
122
|
+
The default command takes no arguments except `--parallel=<workers>`, as [Parallel runs](#parallel-runs) says, `--full` and `--junit=<file>`.
|
|
123
|
+
`--full` runs the full suite outside CI, so a night run covers what a local run skips.
|
|
124
|
+
A `package.json` script never takes `--full`, as [checks-test-layout](checks-test-layout.md) says of `scripts.test`.
|
|
125
|
+
Its named tier options are `--tier=live`, `--tier=pixel`, `--tier=unit` and `--tier=e2e`, and a tier run takes `--parallel=<workers>` too, but not `--full`.
|
|
108
126
|
`--junit=<file>` keeps the JUnit report Bun wrote at `<file>`, whether the tests passed or failed, so a night run hands it to [checks-night-run](checks-night-run.md).
|
|
109
127
|
A `package.json` script never takes `--junit`, as [checks-test-layout](checks-test-layout.md) says of `scripts.test`.
|
|
110
128
|
Each option appears at most once.
|
|
@@ -116,7 +134,7 @@ It refuses test filters because every test excluded by a filter would look skipp
|
|
|
116
134
|
| --- | --- |
|
|
117
135
|
| 0 | every test passed and every skip has a matching site reason |
|
|
118
136
|
| 1 | a test failed, a parallel worker crashed, a skip lacks a site reason or a declaration is stale in CI |
|
|
119
|
-
| 2 | an argument is not one it takes, a declaration cannot be read, `CI` is not a boolean or Bun wrote no report |
|
|
137
|
+
| 2 | an argument is not one it takes, a declaration or the smoke list cannot be read, a smoke entry names no runnable test, `CI` is not a boolean or Bun wrote no report |
|
|
120
138
|
|
|
121
139
|
## Sample output
|
|
122
140
|
|
package/package.json
CHANGED
|
@@ -252,7 +252,7 @@ export function scriptViolations(manifest: unknown, files: readonly string[] = [
|
|
|
252
252
|
const lint = isRecord(scripts) ? scripts["lint"] : undefined;
|
|
253
253
|
const violations: Violation[] = [];
|
|
254
254
|
const options = scriptOptions(test);
|
|
255
|
-
if (options === undefined || options.tier !== undefined) {
|
|
255
|
+
if (options === undefined || options.tier !== undefined || options.full === true) {
|
|
256
256
|
violations.push({
|
|
257
257
|
file,
|
|
258
258
|
line: undefined,
|
|
@@ -144,21 +144,21 @@ function selectedTierFile(file: string, tier: TestTier | undefined): boolean {
|
|
|
144
144
|
return file.startsWith(`tests/${tier}/`);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
tier: TestTier | undefined,
|
|
150
|
-
) {
|
|
151
|
-
const fs = yield* FileSystem.FileSystem;
|
|
152
|
-
const path = yield* Path.Path;
|
|
153
|
-
const files = yield* Effect.tryPromise({
|
|
147
|
+
const scanTestFiles = Effect.fn("scanTestFiles")(function* (root: string) {
|
|
148
|
+
return yield* Effect.tryPromise({
|
|
154
149
|
try: () =>
|
|
155
150
|
Promise.all(
|
|
156
151
|
TEST_FILES.map((pattern) =>
|
|
157
152
|
Array.fromAsync(new Bun.Glob(pattern).scan({ cwd: root, onlyFiles: true })),
|
|
158
153
|
),
|
|
159
|
-
).then((groups) => groups.flat().
|
|
160
|
-
catch: (cause) => new SkipDeclarationError({ message:
|
|
154
|
+
).then((groups) => groups.flat().sort()),
|
|
155
|
+
catch: (cause) => new SkipDeclarationError({ message: "cannot find test files: " + String(cause) }),
|
|
161
156
|
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const readDeclarationsIn = Effect.fn("readDeclarationsIn")(function* (root: string, files: readonly string[]) {
|
|
160
|
+
const fs = yield* FileSystem.FileSystem;
|
|
161
|
+
const path = yield* Path.Path;
|
|
162
162
|
const declarations: SkipDeclaration[] = [];
|
|
163
163
|
for (const file of files) {
|
|
164
164
|
const source = yield* fs.readFileString(path.join(root, file)).pipe(
|
|
@@ -178,3 +178,38 @@ export const readSkipDeclarations = Effect.fn("readSkipDeclarations")(function*
|
|
|
178
178
|
}
|
|
179
179
|
return declarations;
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
export const readSkipDeclarations = Effect.fn("readSkipDeclarations")(function* (
|
|
183
|
+
root: string,
|
|
184
|
+
tier: TestTier | undefined,
|
|
185
|
+
) {
|
|
186
|
+
const files = (yield* scanTestFiles(root)).filter((file) => selectedTierFile(file, tier));
|
|
187
|
+
return yield* readDeclarationsIn(root, files);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
export type SmokeScope =
|
|
191
|
+
| { readonly kind: "valid"; readonly files: readonly string[] }
|
|
192
|
+
| { readonly kind: "invalid"; readonly message: string };
|
|
193
|
+
|
|
194
|
+
export function smokeFiles(scanned: readonly string[], smoke: readonly string[]): SmokeScope {
|
|
195
|
+
const missing = smoke.filter((file) => !scanned.includes(file));
|
|
196
|
+
if (missing.length > 0) return { kind: "invalid", message: "checks.smoke names no test file at " + missing.join(", ") };
|
|
197
|
+
const ignored = smoke.filter((file) => !selectedTierFile(file, undefined));
|
|
198
|
+
if (ignored.length > 0) {
|
|
199
|
+
return {
|
|
200
|
+
kind: "invalid",
|
|
201
|
+
message: "checks.smoke cannot name a quarantined, live or pixel test at " + ignored.join(", "),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const wanted = new Set(smoke);
|
|
205
|
+
return { kind: "valid", files: scanned.filter((file) => selectedTierFile(file, "unit") || wanted.has(file)) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const readSmokeDeclarations = Effect.fn("readSmokeDeclarations")(function* (
|
|
209
|
+
root: string,
|
|
210
|
+
smoke: readonly string[],
|
|
211
|
+
) {
|
|
212
|
+
const scoped = smokeFiles(yield* scanTestFiles(root), smoke);
|
|
213
|
+
if (scoped.kind === "invalid") return yield* new SkipDeclarationError({ message: scoped.message });
|
|
214
|
+
return { files: scoped.files, declarations: yield* readDeclarationsIn(root, scoped.files) };
|
|
215
|
+
});
|
package/src/testing/test.ts
CHANGED
|
@@ -4,7 +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 {
|
|
7
|
+
import {
|
|
8
|
+
readSkipDeclarations,
|
|
9
|
+
readSmokeDeclarations,
|
|
10
|
+
type Environment,
|
|
11
|
+
type SkipDeclaration,
|
|
12
|
+
type TestTier,
|
|
13
|
+
} from "./test-skips.ts";
|
|
8
14
|
import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
|
|
9
15
|
|
|
10
16
|
export type { Environment, SkipDeclaration } from "./test-skips.ts";
|
|
@@ -24,10 +30,12 @@ export class SkipGateError extends Schema.TaggedError<SkipGateError>()("SkipGate
|
|
|
24
30
|
export type TestOptions = {
|
|
25
31
|
readonly tier?: TestTier;
|
|
26
32
|
readonly workers?: number;
|
|
33
|
+
readonly full?: boolean;
|
|
27
34
|
};
|
|
28
35
|
|
|
29
36
|
const NAME = TEST_ENTRY_POINT.bin;
|
|
30
|
-
const USAGE = `usage: ${NAME} [--tier=live | --tier=pixel | --tier=unit | --tier=e2e] [--parallel=<workers>] [--junit=<file>], and it refuses test filters`;
|
|
37
|
+
const USAGE = `usage: ${NAME} [--tier=live | --tier=pixel | --tier=unit | --tier=e2e] [--parallel=<workers>] [--full] [--junit=<file>], and it refuses test filters`;
|
|
38
|
+
const FULL = "--full";
|
|
31
39
|
const TIERS = new Map<string, TestTier>([
|
|
32
40
|
["--tier=live", "live"],
|
|
33
41
|
["--tier=pixel", "pixel"],
|
|
@@ -40,6 +48,7 @@ const JUNIT = /^--junit=(.+)$/;
|
|
|
40
48
|
function optionOf(arg: string): TestOptions | undefined {
|
|
41
49
|
const tier = TIERS.get(arg);
|
|
42
50
|
if (tier !== undefined) return { tier };
|
|
51
|
+
if (arg === FULL) return { full: true };
|
|
43
52
|
const workers = PARALLEL.exec(arg)?.[1];
|
|
44
53
|
return workers === undefined ? undefined : { workers: Number(workers) };
|
|
45
54
|
}
|
|
@@ -51,6 +60,7 @@ export function testOptions(args: readonly string[]): TestOptions | undefined {
|
|
|
51
60
|
if (option === undefined || Object.keys(option).some((key) => key in options)) return undefined;
|
|
52
61
|
options = { ...options, ...option };
|
|
53
62
|
}
|
|
63
|
+
if (options.tier !== undefined && options.full === true) return undefined;
|
|
54
64
|
return options;
|
|
55
65
|
}
|
|
56
66
|
|
|
@@ -158,6 +168,32 @@ const environmentOf = Config.Boolean("CI").pipe(
|
|
|
158
168
|
Effect.mapError((cause) => new SkipGateError({ message: `cannot tell a ci run from a local one: ${cause.message}` })),
|
|
159
169
|
);
|
|
160
170
|
|
|
171
|
+
export type SuiteSelection =
|
|
172
|
+
| { readonly kind: "full" }
|
|
173
|
+
| { readonly kind: "tier"; readonly tier: TestTier }
|
|
174
|
+
| { readonly kind: "smoke" };
|
|
175
|
+
|
|
176
|
+
export function selectSuite(options: TestOptions, environment: Environment): SuiteSelection {
|
|
177
|
+
if (options.tier !== undefined) return { kind: "tier", tier: options.tier };
|
|
178
|
+
if (options.full === true || environment === "ci") return { kind: "full" };
|
|
179
|
+
return { kind: "smoke" };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const SmokeManifest = Schema.Struct({
|
|
183
|
+
checks: Schema.optional(Schema.Struct({ smoke: Schema.optional(Schema.Array(Schema.NonEmptyString)) })),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
export function smokeList(manifest: unknown): readonly string[] | undefined {
|
|
187
|
+
if (!Schema.is(SmokeManifest)(manifest)) return undefined;
|
|
188
|
+
return manifest.checks?.smoke ?? [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function suiteArgs(selection: SuiteSelection, smoke: readonly string[]): readonly string[] {
|
|
192
|
+
if (selection.kind === "tier") return tierArgs(selection.tier);
|
|
193
|
+
if (selection.kind === "smoke") return ["./tests/unit", ...smoke.map((file) => `./${file}`)];
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
|
|
161
197
|
function tierArgs(tier: TestTier | undefined): readonly string[] {
|
|
162
198
|
if (tier === undefined) return [];
|
|
163
199
|
// The preset ignores tests/live and tests/pixel, so only those tiers clear the ignore list.
|
|
@@ -169,11 +205,24 @@ export function workerCount(workers: number | undefined): number {
|
|
|
169
205
|
return workers ?? availableParallelism();
|
|
170
206
|
}
|
|
171
207
|
|
|
172
|
-
const
|
|
208
|
+
const readSmokeList = Effect.fn("readSmokeList")(function* (root: string) {
|
|
209
|
+
const file = (yield* Path.Path).join(root, "package.json");
|
|
210
|
+
const text = yield* (yield* FileSystem.FileSystem).readFileString(file);
|
|
211
|
+
const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe(
|
|
212
|
+
Effect.mapError((cause) => new SkipGateError({ message: "cannot read " + file + " as JSON: " + cause.message })),
|
|
213
|
+
);
|
|
214
|
+
const smoke = smokeList(manifest);
|
|
215
|
+
if (smoke === undefined) {
|
|
216
|
+
return yield* new SkipGateError({ message: file + ": checks.smoke must be an array of test file paths" });
|
|
217
|
+
}
|
|
218
|
+
return smoke;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const runSuite = Effect.fn("runSuite")(function* (outfile: string, args: readonly string[], workers: number | undefined) {
|
|
173
222
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
174
|
-
const
|
|
223
|
+
const command = ["test", "--randomize", `--parallel=${workerCount(workers)}`, ...args, ...reporterArgs(outfile)];
|
|
175
224
|
return yield* spawner.exitCode(
|
|
176
|
-
ChildProcess.make(process.execPath,
|
|
225
|
+
ChildProcess.make(process.execPath, command, {
|
|
177
226
|
stdin: "ignore",
|
|
178
227
|
stdout: "inherit",
|
|
179
228
|
stderr: "inherit",
|
|
@@ -189,11 +238,25 @@ const testEntry = Effect.gen(function* () {
|
|
|
189
238
|
const { options, junit } = entry;
|
|
190
239
|
const fs = yield* FileSystem.FileSystem;
|
|
191
240
|
const path = yield* Path.Path;
|
|
192
|
-
const declarations = yield* readSkipDeclarations(process.cwd(), options.tier);
|
|
193
241
|
const environment = yield* environmentOf;
|
|
242
|
+
const selection = selectSuite(options, environment);
|
|
243
|
+
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
|
+
};
|
|
251
|
+
if (scope.files?.length === 0) {
|
|
252
|
+
yield* Console.log(`${NAME}: no unit test and no smoke list, so this local run ran nothing; declare checks.smoke or pass ${FULL}`);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
const { declarations } = scope;
|
|
194
256
|
|
|
195
257
|
const outfile = path.join(yield* fs.makeTempDirectoryScoped({ prefix: "checks-test-" }), "junit.xml");
|
|
196
|
-
const suitePassed =
|
|
258
|
+
const suitePassed =
|
|
259
|
+
(yield* runSuite(outfile, suiteArgs(selection, smoke), options.workers)) === ChildProcessSpawner.ExitCode(0);
|
|
197
260
|
const written = yield* fs.exists(outfile);
|
|
198
261
|
if (written && Option.isSome(junit)) {
|
|
199
262
|
yield* fs.makeDirectory(path.dirname(junit.value), { recursive: true });
|