@a11ign/screenreader-fleet 0.4.0 → 0.4.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.
@@ -1,2 +1,18 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ /**
3
+ * Where the report is written: under the caller's `--runs-dir=`, else the monorepo's own `runs/`, else a refusal naming
4
+ * the directory and the flag (a11ign/a11ign#3767).
5
+ *
6
+ * Resolved from THIS module, never the cwd -- same reason as `doctor.mjs`'s `runsDirFor`, which this duplicates because
7
+ * this package cannot import `@a11ign/lab`'s canonical `runs/` resolution without a dependency cycle. From an INSTALLED
8
+ * package `../../../` is `node_modules`, so the default would write the report under the dependency. A supplied
9
+ * directory need not exist: the report's own `mkdirSync` makes it, which is what asking for one means.
10
+ * `baseDir` is INJECTED so a test can stand in for either layout.
11
+ *
12
+ * @param {{ argv?: readonly string[], baseDir?: string }} [options]
13
+ * @returns {string}
14
+ */
15
+ export function outDirFor({ argv, baseDir }?: {
16
+ argv?: readonly string[];
17
+ baseDir?: string;
18
+ }): string;
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { resolve } from "node:path";
6
6
  import { describeProbe, probeHealth, WORKER_PROBE_TIMEOUT_MS } from "./probe-outcome.mjs";
7
7
  import { CAPTURE_CLIENT_TIMEOUT_MS, requestJson } from "./worker-http.mjs";
8
- import { refuseUnknownFlags } from "./cli-flags.mjs";
8
+ import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
9
9
  async function refuseIfBusy(workers, { what, timeoutMs = WORKER_PROBE_TIMEOUT_MS, request }) {
10
10
  const states = await Promise.all(workers.map(async (worker)=>{
11
11
  const probe = await probeHealth(worker, {
@@ -221,12 +221,22 @@ function diffHost(before, after) {
221
221
  }
222
222
  refuseUnknownFlags([
223
223
  "--rounds=",
224
- "--runs="
224
+ "--runs=",
225
+ "--runs-dir="
225
226
  ], {
226
227
  entry: import.meta.url,
227
228
  command: "npm run worker:compare"
228
229
  });
229
- const OUT = resolve(fileURLToPath(new URL("../../../", import.meta.url)), "runs/worker-compare");
230
+ const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
231
+ const isMonorepoRoot = (root)=>existsSync(resolve(root, "packages")) && existsSync(resolve(root, "package.json"));
232
+ function outDirFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
233
+ const supplied = flagValue(argv, "runs-dir");
234
+ if ("" === supplied) throw new Error("worker:compare: --runs-dir= is empty. Pass --runs-dir=<the runs directory>.");
235
+ if (void 0 !== supplied) return resolve(supplied, "worker-compare");
236
+ const root = resolve(baseDir, "..", "..", "..");
237
+ if (isMonorepoRoot(root)) return resolve(root, "runs", "worker-compare");
238
+ throw new Error(`worker:compare: no runs directory: ${root} is not a checkout (this is an installed package), so there is no runs/ to write worker-compare/ under. Pass --runs-dir=<the runs directory>.`);
239
+ }
230
240
  const MS_PER_S = 1000;
231
241
  function refuseIfRunsReadonly() {
232
242
  if ("1" !== process.env.A11Y_RUNS_READONLY) return;
@@ -234,6 +244,14 @@ function refuseIfRunsReadonly() {
234
244
  console.error(" Unset A11Y_RUNS_READONLY to run this for real.");
235
245
  process.exit(3);
236
246
  }
247
+ function refuseUnlessOutDir() {
248
+ try {
249
+ return outDirFor();
250
+ } catch (error) {
251
+ process.stderr.write(`${error.message}\n`);
252
+ return process.exit(2);
253
+ }
254
+ }
237
255
  const compare_workers_args = process.argv.slice(2);
238
256
  const runs = Number(compare_workers_args.find((a)=>a.startsWith("--rounds="))?.slice(9) ?? compare_workers_args.find((a)=>a.startsWith("--runs="))?.slice(7) ?? 6);
239
257
  const [page, ...compare_workers_workers] = compare_workers_args.filter((a)=>!a.startsWith("--"));
@@ -297,6 +315,7 @@ async function main() {
297
315
  process.stderr.write("usage: npm run worker:compare -- <page-url> <worker> <worker> [--rounds=6]\n");
298
316
  process.exit(2);
299
317
  }
318
+ const outDir = refuseUnlessOutDir();
300
319
  refuseIfRunsReadonly();
301
320
  await refuseIfBusy(compare_workers_workers, {
302
321
  what: `worker:compare against ${page}`
@@ -334,10 +353,11 @@ async function main() {
334
353
  results,
335
354
  vitalsBefore,
336
355
  vitalsAfter,
337
- hostBefore
356
+ hostBefore,
357
+ outDir
338
358
  });
339
359
  }
340
- function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
360
+ function report({ results, vitalsBefore, vitalsAfter, hostBefore, outDir }) {
341
361
  const allPhases = [
342
362
  ...new Set(Object.values(results).flatMap((r)=>r.phases.flatMap(Object.keys)))
343
363
  ];
@@ -355,10 +375,10 @@ function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
355
375
  const { foundations, hostAfter } = reportFoundations({
356
376
  hostBefore
357
377
  });
358
- mkdirSync(OUT, {
378
+ mkdirSync(outDir, {
359
379
  recursive: true
360
380
  });
361
- writeFileSync(resolve(OUT, "compare.json"), JSON.stringify({
381
+ writeFileSync(resolve(outDir, "compare.json"), JSON.stringify({
362
382
  page: page,
363
383
  rounds: runs,
364
384
  results,
@@ -368,7 +388,7 @@ function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
368
388
  hostBefore,
369
389
  hostAfter
370
390
  }, null, 2) + "\n", "utf8");
371
- process.stdout.write(`\nReport: ${resolve(OUT, "compare.json")}\n`);
391
+ process.stdout.write(`\nReport: ${resolve(outDir, "compare.json")}\n`);
372
392
  }
373
393
  function reportWallTime({ results, vitalsBefore, vitalsAfter }) {
374
394
  const wallSeconds = Object.fromEntries(compare_workers_workers.map((w)=>[
@@ -458,3 +478,4 @@ function reportFoundations({ hostBefore }) {
458
478
  };
459
479
  }
460
480
  if (import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href) await main();
481
+ export { outDirFor };
package/dist/doctor.d.mts CHANGED
@@ -1,4 +1,21 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * The `runs/` directory doctor reads the dataset from: the caller's `--runs-dir=`, else the monorepo's own `runs/`,
4
+ * else a refusal naming the directory and the flag (a11ign/a11ign#3767).
5
+ *
6
+ * `@a11ign/lab` owns the canonical `runs/` resolution (`packages/lab/src/dataset-paths.mjs`), but `lab` depends on
7
+ * `worker-fleet`, so this package cannot import it without a cycle: the same computation, duplicated (and again in
8
+ * `compare-workers.mjs`) rather than left cwd-anchored. From an INSTALLED package `../../../` is `node_modules`, so the
9
+ * default is taken only when that directory is a checkout; a missing `runs/` inside one is normal (a fresh clone).
10
+ * `baseDir` is INJECTED so a test can stand in for either layout.
11
+ *
12
+ * @param {{ argv?: readonly string[], baseDir?: string }} [options]
13
+ * @returns {string}
14
+ */
15
+ export function runsDirFor({ argv, baseDir }?: {
16
+ argv?: readonly string[];
17
+ baseDir?: string;
18
+ }): string;
2
19
  /**
3
20
  * The remedy for a failed local-pool query, as a runnable command plus the advice that is not one.
4
21
  *
@@ -198,7 +215,7 @@ export function errorDocument(error: unknown): {
198
215
  * The whole run, as a function of its steps and its streams, returning an exit code.
199
216
  *
200
217
  * @param {{ steps?: (() => unknown)[], json?: boolean, out?: (line: string) => void,
201
- * err?: (line: string) => void }} [deps]
218
+ * err?: (line: string) => void, runsDir?: () => string }} [deps]
202
219
  * @returns {Promise<number>}
203
220
  */
204
221
  export function doctorRun(deps?: {
@@ -206,6 +223,7 @@ export function doctorRun(deps?: {
206
223
  json?: boolean;
207
224
  out?: (line: string) => void;
208
225
  err?: (line: string) => void;
226
+ runsDir?: () => string;
209
227
  }): Promise<number>;
210
228
  export function addCheck(name: string, ok: boolean, detail: string, remedy?: string | null | {
211
229
  fix: string | null;
package/dist/doctor.mjs CHANGED
@@ -10,7 +10,7 @@ import { availableHostMemoryMb, workersHostCanRun } from "./host-capacity.mjs";
10
10
  import { fleetConsistency, describeMismatches } from "./fleet-consistency.mjs";
11
11
  import { fleetScriptPaths } from "./src_fleet-scripts_mjs.mjs";
12
12
  import { namedInventoryWorkers, configuredWorkers } from "./fleet-env.mjs";
13
- import { refuseUnknownFlags } from "./cli-flags.mjs";
13
+ import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
14
14
  import { requestJson } from "./worker-http.mjs";
15
15
  import { sandboxGitEnv } from "./src_git-safe-env_mjs.mjs";
16
16
  import { assessWorker } from "./worker-health.mjs";
@@ -72,18 +72,32 @@ function shimInvocation(shim, script, args) {
72
72
  };
73
73
  }
74
74
  refuseUnknownFlags([
75
- "--json"
75
+ "--json",
76
+ "--runs-dir="
76
77
  ], {
77
78
  entry: import.meta.url,
78
79
  command: "pnpm run doctor"
79
80
  });
80
81
  const doctor_run = promisify(execFile);
81
82
  const JSON_OUT = process.argv.includes("--json");
83
+ const REFUSED = 2;
82
84
  const WORKERS_ENV = configuredWorkers();
83
85
  const PAGES_PORT = Number(process.env.DATASET_PAGES_PORT || 5050);
84
86
  const CTL = fleetScriptPaths().workerCtl;
85
87
  const SCORER_MODEL_DIR = fileURLToPath(new URL("../../scorer/models/screenreader-scorer/", import.meta.url));
86
- const DATASET = resolve(fileURLToPath(new URL("../../../", import.meta.url)), "runs/screenreader-dataset");
88
+ const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
89
+ const isMonorepoRoot = (root)=>external_node_fs_existsSync(resolve(root, "packages")) && external_node_fs_existsSync(resolve(root, "package.json"));
90
+ function runsDirFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
91
+ const supplied = flagValue(argv, "runs-dir");
92
+ if (void 0 !== supplied) {
93
+ if (external_node_fs_existsSync(supplied)) return resolve(supplied);
94
+ throw new Error(`doctor: --runs-dir=${supplied} does not exist. Pass --runs-dir=<the runs directory>.`);
95
+ }
96
+ const root = resolve(baseDir, "..", "..", "..");
97
+ if (isMonorepoRoot(root)) return resolve(root, "runs");
98
+ throw new Error(`doctor: no runs directory: ${root} is not a checkout (this is an installed package), so there is no runs/ to default to. Pass --runs-dir=<the runs directory>.`);
99
+ }
100
+ const datasetDir = ()=>resolve(runsDirFor(), "screenreader-dataset");
87
101
  const PROBE_TIMEOUT_MS = 8000;
88
102
  const checks = [];
89
103
  const GATES = Object.freeze({
@@ -419,7 +433,7 @@ function checkHostCapacity(pool) {
419
433
  add("host memory", true, limit >= poolSize ? detail : `${detail}; the rest stay stopped so the run does not swap (override: A11Y_MAX_WORKERS)`);
420
434
  }
421
435
  async function checkDatasetPages() {
422
- const manifestPath = resolve(DATASET, "manifest.json");
436
+ const manifestPath = resolve(datasetDir(), "manifest.json");
423
437
  if (!external_node_fs_existsSync(manifestPath)) return add("dataset", false, "no manifest — the dataset has not been generated", "pnpm run training:generate");
424
438
  const sample = JSON.parse(readFileSync(manifestPath, "utf8")).cases?.[0]?.id;
425
439
  const probe = sample ? `${sample}/good.html` : "";
@@ -434,7 +448,7 @@ async function checkDatasetPages() {
434
448
  }
435
449
  }
436
450
  function checkRunState() {
437
- const progress = resolve(DATASET, "capture-progress.json");
451
+ const progress = resolve(datasetDir(), "capture-progress.json");
438
452
  if (!external_node_fs_existsSync(progress)) return add("run", true, "no capture run recorded");
439
453
  const p = JSON.parse(readFileSync(progress, "utf8"));
440
454
  if (!p.startedAt) return add("run", true, "no capture run recorded");
@@ -475,7 +489,14 @@ function errorDocument(error) {
475
489
  };
476
490
  }
477
491
  async function doctorRun(deps = {}) {
478
- const { steps = DEFAULT_STEPS, json = JSON_OUT, out = console.log, err = console.error } = deps;
492
+ const { steps = DEFAULT_STEPS, json = JSON_OUT, out = console.log, err = console.error, runsDir = runsDirFor } = deps;
493
+ try {
494
+ runsDir();
495
+ } catch (error) {
496
+ if (json) out(JSON.stringify(errorDocument(error), null, 2));
497
+ else err(error.message);
498
+ return REFUSED;
499
+ }
479
500
  try {
480
501
  for (const step of steps)await step();
481
502
  } catch (error) {
@@ -511,4 +532,4 @@ async function main() {
511
532
  process.exit(await doctorRun());
512
533
  }
513
534
  if (import.meta.url === pathToFileURL(process.argv[1] ? external_node_fs_realpathSync(process.argv[1]) : "").href) await main();
514
- export { addCheck, allChecks, checkoutRootFor, doctorRun, errorDocument, fleetAgreementLine, gatingChecks, isRunnableCommand, nextCommand, readyFrom, resolvesToThisCheckout, tscProjectUpToDate, workerControlFix };
535
+ export { addCheck, allChecks, checkoutRootFor, doctorRun, errorDocument, fleetAgreementLine, gatingChecks, isRunnableCommand, nextCommand, readyFrom, resolvesToThisCheckout, runsDirFor, tscProjectUpToDate, workerControlFix };
@@ -20,6 +20,27 @@ export function configuredWorkers(): Array<{
20
20
  name: string;
21
21
  url: string;
22
22
  }>;
23
+ /**
24
+ * The two files the COMMAND reads, from the caller or else from the monorepo layout, and a refusal naming the path
25
+ * and the flag when the one it would read is not there (a11ign/a11ign#3767).
26
+ *
27
+ * `INVENTORY` above points into `packages/control`, which an installed `@a11ign/screenreader-fleet` does not have, so
28
+ * `fleet-env --list` there died on an ENOENT for a path nobody could have guessed. The library functions keep their
29
+ * default because their answer for "no inventory here" is `[]`, which is supported; a command that prints
30
+ * `export A11Y_WORKERS=''` for a missing file would be the same silent wrong answer, so it refuses.
31
+ *
32
+ * `baseDir` is INJECTED so a test can stand in for either layout without moving a file.
33
+ *
34
+ * @param {{ argv?: readonly string[], baseDir?: string }} [options]
35
+ * @returns {{ inventoryPath: string, groupVarsPath: string }}
36
+ */
37
+ export function inventoryPathsFor({ argv, baseDir }?: {
38
+ argv?: readonly string[];
39
+ baseDir?: string;
40
+ }): {
41
+ inventoryPath: string;
42
+ groupVarsPath: string;
43
+ };
23
44
  /**
24
45
  * Which group each line of the inventory sits in, index-aligned with `text.split(/\r?\n/)`.
25
46
  *
@@ -1,9 +1,12 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
2
3
  import { fileURLToPath, pathToFileURL } from "node:url";
3
4
  import { assertWorkerUrl } from "./worker-http.mjs";
4
- import { refuseUnknownFlags } from "./cli-flags.mjs";
5
+ import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
5
6
  refuseUnknownFlags([
6
- "--list"
7
+ "--list",
8
+ "--inventory=",
9
+ "--group-vars="
7
10
  ], {
8
11
  entry: import.meta.url,
9
12
  command: "npm run fleet:env"
@@ -19,8 +22,32 @@ function configuredWorkers() {
19
22
  url
20
23
  }));
21
24
  }
22
- const INVENTORY = fileURLToPath(new URL("../../control/ansible/inventory.yml", import.meta.url));
23
- const GROUP_VARS = fileURLToPath(new URL("../../control/ansible/group_vars/a11y_workers.yml", import.meta.url));
25
+ const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
26
+ const monorepoAnsibleFile = (relative, baseDir = MODULE_DIR)=>resolve(baseDir, "..", "..", "control", "ansible", relative);
27
+ const INVENTORY = monorepoAnsibleFile("inventory.yml");
28
+ const GROUP_VARS = monorepoAnsibleFile("group_vars/a11y_workers.yml");
29
+ function inventoryPathsFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
30
+ const inventoryPath = existingPath({
31
+ flag: "inventory",
32
+ supplied: flagValue(argv, "inventory"),
33
+ fallback: monorepoAnsibleFile("inventory.yml", baseDir)
34
+ });
35
+ const groupVarsPath = existingPath({
36
+ flag: "group-vars",
37
+ supplied: flagValue(argv, "group-vars"),
38
+ fallback: monorepoAnsibleFile("group_vars/a11y_workers.yml", baseDir)
39
+ });
40
+ return {
41
+ inventoryPath,
42
+ groupVarsPath
43
+ };
44
+ }
45
+ function existingPath({ flag, supplied, fallback }) {
46
+ const path = supplied ?? fallback;
47
+ if (existsSync(path)) return path;
48
+ const origin = void 0 === supplied ? "the monorepo layout this command defaults to has no such file" : "no such file";
49
+ throw new Error(`fleet:env: ${path} does not exist (${origin}). Pass --${flag}=<file>.`);
50
+ }
24
51
  const HOST_LINE = /^\s*ansible_host\s*:\s*(\S+)\s*$/;
25
52
  const SUSPECT = /ansible_host\s*:/;
26
53
  const KEY_LINE = /^(\s*)([A-Za-z_][\w.-]*)\s*:/;
@@ -207,8 +234,16 @@ function fleetEnvOutput(text, { port = DEFAULT_WORKER_PORT, mode = "env" } = {})
207
234
  };
208
235
  }
209
236
  function main() {
210
- const port = portFromGroupVars(readFileSync(GROUP_VARS, "utf8"));
211
- const { stdout, stderr } = fleetEnvOutput(readFileSync(INVENTORY, "utf8"), {
237
+ let paths;
238
+ try {
239
+ paths = inventoryPathsFor();
240
+ } catch (error) {
241
+ process.stderr.write(`${error.message}\n`);
242
+ process.exitCode = 2;
243
+ return;
244
+ }
245
+ const port = portFromGroupVars(readFileSync(paths.groupVarsPath, "utf8"));
246
+ const { stdout, stderr } = fleetEnvOutput(readFileSync(paths.inventoryPath, "utf8"), {
212
247
  port,
213
248
  mode: process.argv.includes("--list") ? "list" : "env"
214
249
  });
@@ -216,4 +251,4 @@ function main() {
216
251
  process.stdout.write(stdout);
217
252
  }
218
253
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main();
219
- export { DEFAULT_WORKER_PORT, WORKER_GROUP, configuredWorkers, fleetEnvOutput, groupPerLine, hostsOutOfCaptureSet, inventoryWorkerUrls, namedInventoryWorkers, portFromGroupVars, resolveWorkerPool, workerNamesFromInventory, workersFromInventory };
254
+ export { DEFAULT_WORKER_PORT, WORKER_GROUP, configuredWorkers, fleetEnvOutput, groupPerLine, hostsOutOfCaptureSet, inventoryPathsFor, inventoryWorkerUrls, namedInventoryWorkers, portFromGroupVars, resolveWorkerPool, workerNamesFromInventory, workersFromInventory };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a11ign/screenreader-fleet",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Host-side lifecycle, health and capacity for a fleet of Windows NVDA capture workers: lease one, judge whether it is degrading, and know how many the host can afford.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",