@avi2dg/checks 0.49.0 → 0.50.1

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,22 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.50.1
6
+
7
+ Released 2026-10-20.
8
+
9
+ ### Fixes
10
+
11
+ - **delivery:** admit the self-hosted iphone runner in private-repo ci-wiring [#193](https://github.com/avi2d/checks/pull/193)
12
+
13
+ ## 0.50.0
14
+
15
+ Released 2026-10-20.
16
+
17
+ ### Features
18
+
19
+ - **testing:** cap local checks-test at the unit tier plus a declared smoke list [#191](https://github.com/avi2d/checks/pull/191)
20
+
5
21
  ## 0.49.0
6
22
 
7
23
  Released 2026-10-20.
@@ -48,7 +48,8 @@ A here-document that `sh`, `bash`, `dash`, `ksh` or `zsh` reads is a script, and
48
48
  The text of any other here-document is data, so a call there counts only inside a command substitution when the delimiter is unquoted.
49
49
  A mutation job never names `CI_RUNS_ON` directly in `runs-on`, as `vars.CI_RUNS_ON` or `vars['CI_RUNS_ON']`, so an override for an outage cannot send its mutation runs to hosted runners.
50
50
  Any other job that names `CI_RUNS_ON` in `runs-on` carries exactly the hosted-default expression `${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}`.
51
- In a private repository each mutation job carries exactly the labels `[self-hosted, Linux, X64, winbox]` and every other job exactly the hosted-default expression.
51
+ In a private repository each mutation job carries exactly the labels `[self-hosted, Linux, X64, winbox]`.
52
+ Every other job there carries exactly the hosted-default expression, or exactly the labels `[self-hosted, macOS, iphone]` of the Mac CI runner that holds the iPhone Simulator.
52
53
  A public repository may keep `runs-on: ubuntu-latest` on every job, because a pull request from a fork runs its own code on the runner.
53
54
  The check knows a repository is private only from GitHub's event, so a run outside CI judges only the rules that hold in either.
54
55
 
@@ -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 default suite with `bun test --randomize --parallel=<workers>`, as [Parallel runs](#parallel-runs) says, and reads Bun's JUnit report from that run.
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 does not read a skip list from `package.json`.
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
- The command takes no arguments for the default suite, except the worker count of [Parallel runs](#parallel-runs) and the file `--junit` keeps the report at.
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
- Its named tier options are `--tier=live`, `--tier=pixel`, `--tier=unit` and `--tier=e2e`, and a tier run takes `--parallel=<workers>` too.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.49.0",
3
+ "version": "0.50.1",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -84,6 +84,7 @@ const OVERRIDE_ACCESS = String.raw`vars\s*(?:\.\s*CI_RUNS_ON\b|\[\s*'CI_RUNS_ON'
84
84
  const RUNNER_OVERRIDE = new RegExp(String.raw`\b${OVERRIDE_ACCESS}`, "i");
85
85
  const HOSTED_OVERRIDE = new RegExp(String.raw`^\$\{\{\s*${OVERRIDE_ACCESS}\s*\|\|\s*'ubuntu-latest'\s*\}\}$`, "i");
86
86
  const MUTATION_RUNNER = ["self-hosted", "Linux", "X64", "winbox"];
87
+ const DEVICE_RUNNER = ["self-hosted", "macOS", "iphone"];
87
88
  const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
88
89
  const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
89
90
 
@@ -294,7 +295,7 @@ function runnerFault(runsOn: unknown, mutation: boolean, visibility: Visibility)
294
295
  if (mutation && overridden) return `a mutation job reads CI_RUNS_ON, so an override moves its mutation runs off winbox; ${pin}`;
295
296
  if (mutation) return visibility === "private" && !sameLabels(runsOn, MUTATION_RUNNER) ? `a mutation job in a private repository runs off winbox; ${pin}` : undefined;
296
297
  const hosted = typeof runsOn === "string" && HOSTED_OVERRIDE.test(runsOn.trim());
297
- if (hosted || (visibility !== "private" && !overridden)) return undefined;
298
+ if (hosted || sameLabels(runsOn, DEVICE_RUNNER) || (visibility !== "private" && !overridden)) return undefined;
298
299
  return `the job is not on a hosted runner CI_RUNS_ON can override; set runs-on: ${HOSTED_DEFAULT}`;
299
300
  }
300
301
 
@@ -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
- export const readSkipDeclarations = Effect.fn("readSkipDeclarations")(function* (
148
- root: string,
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().filter((file) => selectedTierFile(file, tier)).sort()),
160
- catch: (cause) => new SkipDeclarationError({ message: `cannot find test files: ${String(cause)}` }),
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
+ });
@@ -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 { readSkipDeclarations, type Environment, type SkipDeclaration, type TestTier } from "./test-skips.ts";
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 runSuite = Effect.fn("runSuite")(function* (outfile: string, { tier, workers }: TestOptions) {
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 args = ["test", "--randomize", `--parallel=${workerCount(workers)}`, ...tierArgs(tier), ...reporterArgs(outfile)];
223
+ const command = ["test", "--randomize", `--parallel=${workerCount(workers)}`, ...args, ...reporterArgs(outfile)];
175
224
  return yield* spawner.exitCode(
176
- ChildProcess.make(process.execPath, args, {
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 = (yield* runSuite(outfile, options)) === ChildProcessSpawner.ExitCode(0);
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 });