@a11ign/screenreader-fleet 0.5.2 → 0.6.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 +37 -0
- package/dist/{capture-client.d.mts → capture-client.d.ts} +2 -2
- package/dist/{check-worker-code.d.mts → check-worker-code.d.ts} +2 -3
- package/dist/check-worker-code.mjs +1 -1
- package/dist/{cli-flags.d.mts → cli-flags.d.ts} +5 -5
- package/dist/{code-drift.d.mts → code-drift.d.ts} +7 -7
- package/dist/{command-line-census.d.mts → command-line-census.d.ts} +3 -3
- package/dist/{compare-workers.d.mts → compare-workers.d.ts} +1 -1
- package/dist/{control-plane-isolation.d.mts → control-plane-isolation.d.ts} +1 -1
- package/dist/{doctor.d.mts → doctor.d.ts} +57 -31
- package/dist/doctor.mjs +4 -4
- package/dist/{fleet-consistency.d.mts → fleet-consistency.d.ts} +50 -85
- package/dist/fleet-consistency.mjs +4 -0
- package/dist/{fleet-env.d.mts → fleet-env.d.ts} +45 -45
- package/dist/{fleet-scripts.d.mts → fleet-scripts.d.ts} +1 -1
- package/dist/{git-safe-env.d.mts → git-safe-env.d.ts} +3 -3
- package/dist/{guest-run.d.mts → guest-run.d.ts} +5 -5
- package/dist/{host-address.d.mts → host-address.d.ts} +4 -4
- package/dist/{host-capacity.d.mts → host-capacity.d.ts} +5 -5
- package/dist/{host-metrics.d.mts → host-metrics.d.ts} +23 -15
- package/dist/index.mjs +1 -1
- package/dist/{measure-guard.d.mts → measure-guard.d.ts} +14 -14
- package/dist/{npm-cli-executable.d.mts → npm-cli-executable.d.ts} +4 -4
- package/dist/{probe-outcome.d.mts → probe-outcome.d.ts} +43 -44
- package/dist/{protocol-guard.d.mts → protocol-guard.d.ts} +3 -3
- package/dist/source-walk.d.ts +11 -0
- package/dist/{transient-fault.d.mts → transient-fault.d.ts} +1 -1
- package/dist/tsx-import.d.ts +6 -0
- package/dist/{utm-deprecated.d.mts → utm-deprecated.d.ts} +1 -1
- package/dist/worker-code-check.d.ts +95 -0
- package/dist/worker-code-check.mjs +1 -1
- package/dist/{worker-health.d.mts → worker-health.d.ts} +2 -2
- package/dist/{worker-http.d.mts → worker-http.d.ts} +51 -51
- package/dist/{worker-stats.d.mts → worker-stats.d.ts} +40 -20
- package/package.json +30 -19
- package/src/local-worker/build-vm.sh +2 -2
- package/src/local-worker/clone-worker.sh +1 -1
- package/src/local-worker/create-utm-vm.sh +1 -1
- package/src/local-worker/fetch-windows-iso.sh +5 -5
- package/src/local-worker/worker-ctl.sh +12 -12
- package/src/provisioning/apply-foreground-lock-timeout.ps1 +1 -1
- package/src/provisioning/bootstrap-windows-worker.ps1 +1 -1
- package/src/provisioning/diagnose-nvda-worker.ps1 +1 -1
- package/src/provisioning/stamp-provision-revision.ps1 +3 -3
- package/dist/source-walk.d.mts +0 -11
- package/dist/worker-code-check.d.mts +0 -54
- /package/dist/{normalise-fleet.d.mts → normalise-fleet.d.ts} +0 -0
- /package/dist/{src_fleet-scripts_mjs.mjs → src_fleet-scripts_ts.mjs} +0 -0
- /package/dist/{src_git-safe-env_mjs.mjs → src_git-safe-env_ts.mjs} +0 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the fleet running the code this checkout expects — asked BEFORE a capture run, not after it.
|
|
3
|
+
*
|
|
4
|
+
* ## The hole this closes
|
|
5
|
+
*
|
|
6
|
+
* `run-job.yml` refuses to run at a commit other than the one asked for, and the comment above that
|
|
7
|
+
* refusal says why: *"a job that quietly runs four commits behind reports success for code you did not
|
|
8
|
+
* ask for."* That guard covers the LAB. It says nothing about the twelve machines that actually take the
|
|
9
|
+
* captures, and those are a second checkout, deployed by a separate command nobody is forced to run.
|
|
10
|
+
*
|
|
11
|
+
* So a capture run could be dispatched at the right commit, on a lab that proved it was at the right
|
|
12
|
+
* commit, and still capture with the PREVIOUS release of `capture-core.mjs`. Measured on 2026-08-25: after
|
|
13
|
+
* `MAX_TAB_STOPS` went 12 -> 150 and `collectByType` started recording `prevCount`, the real-page corpus
|
|
14
|
+
* held both populations at once, and the only way to read it was to bucket captures by whether they
|
|
15
|
+
* carried the new diagnostic mark at all. The evidence was mixed, the run reported success, and the
|
|
16
|
+
* separation had to be done by hand afterwards.
|
|
17
|
+
*
|
|
18
|
+
* `npm run worker:code` has answered this question correctly the whole time. It is a separate command a
|
|
19
|
+
* human must remember, which is this repo's own definition of a check that does not happen — and it was
|
|
20
|
+
* remembered by hand four times in one day before this existed.
|
|
21
|
+
*
|
|
22
|
+
* ## Why a REFUSAL, and why on any difference at all
|
|
23
|
+
*
|
|
24
|
+
* `workerCode` is deliberately outside the capture cache key ("it changes when a comment changes, and
|
|
25
|
+
* invalidating the WHOLE corpus over a reworded comment is how a cache becomes something people turn
|
|
26
|
+
* off") and deliberately outside
|
|
27
|
+
* `fleet-consistency.mjs`'s `MUST_MATCH` for the same reason. Both of those are the right call for
|
|
28
|
+
* the questions they answer — *is this evidence still valid* and *are these guests interchangeable*.
|
|
29
|
+
*
|
|
30
|
+
* This is a third question with a different answer: *am I about to capture with the code I asked for*. A
|
|
31
|
+
* comment-only drift is a false alarm here and it costs one `fleet:deploy`; a real drift costs a corpus and
|
|
32
|
+
* is invisible, because nothing downstream keys on `workerCode`. That asymmetry is the whole argument.
|
|
33
|
+
*
|
|
34
|
+
* It is a PRECONDITION and never a key: nothing here invalidates a cached capture.
|
|
35
|
+
*
|
|
36
|
+
* ## The comparison itself lives in `code-drift.mjs`, and this file is the reason for the split
|
|
37
|
+
*
|
|
38
|
+
* `resolveExpectedWorkerCode` below needs `codeVersion`/`workerSourceDir`, reached through a SUBPATH export
|
|
39
|
+
* (`@a11ign/screenreader-worker/code-version`) rather than a relative path — a relative one drags
|
|
40
|
+
* `nvda-worker`'s `.mjs` files into this package's own tsc project and the build dies with TS5055 ("would
|
|
41
|
+
* overwrite input file"). That subpath resolves through `node_modules`, which is exactly what
|
|
42
|
+
* `packages/control` does not have (ADR 0012) — so when `lab-job.mjs` needed this same comparison BEFORE
|
|
43
|
+
* dispatching to the lab, it could not import this file. `code-drift.mjs` is the part of this file with no
|
|
44
|
+
* opinion about what "expected" means: it takes the hash as a parameter, imports nothing but
|
|
45
|
+
* `node:child_process`, and is safe from both places. This file supplies the one thing only it can compute.
|
|
46
|
+
*/
|
|
47
|
+
import { codeDrift, describeCodeDrift, describeEmptyPool, readWorkerCode, remedyLines, workerSourceDirty } from "./code-drift.js";
|
|
48
|
+
/**
|
|
49
|
+
* The hash every worker is expected to be serving, and WHERE IT CAME FROM. ONE function, asked by `a11ign-worker-code` and by
|
|
50
|
+
* `assertFleetRunsThisCheckout` alike, so the two cannot be given different hashers again (a11ign/a11ign#3781).
|
|
51
|
+
*
|
|
52
|
+
* With a layer clone present it is the CLONE's: the clone's own `code-version.mjs` over the clone's `src/`, which is what
|
|
53
|
+
* `layerCodeVersion("nvda-worker")` computes for the deploy and the lab, and what a guest is told to be on (`--layer-ref`). Asking the
|
|
54
|
+
* installed package instead made every worker read stale the first time a guest was deployed at a sha whose `.mjs` differed from the
|
|
55
|
+
* release, with a remedy ("redeploy") that could not clear it.
|
|
56
|
+
*
|
|
57
|
+
* With none it is the installed package's (a11ign/a11ign#3740: the one the package was RELEASED with, `codeVersion()` and no
|
|
58
|
+
* directory, because the built package's `workerSourceDir()` is `dist/` and holds none of the files a guest runs), and `source` and
|
|
59
|
+
* `note` say so, so a reading is never silent about which it was.
|
|
60
|
+
*
|
|
61
|
+
* ASYNC because the clone's hasher can only be imported dynamically, as `layerCodeVersion` does.
|
|
62
|
+
*
|
|
63
|
+
* @param {{ checkoutRoot?: string }} [options]
|
|
64
|
+
* @returns {Promise<{ code: string, source: "clone" | "installed", sourceDir: string, note: string }>}
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveExpectedWorkerCode({ checkoutRoot }?: {
|
|
67
|
+
checkoutRoot?: string;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
code: string;
|
|
70
|
+
source: "clone" | "installed";
|
|
71
|
+
sourceDir: string;
|
|
72
|
+
note: string;
|
|
73
|
+
}>;
|
|
74
|
+
export { codeDrift, describeCodeDrift, describeEmptyPool, readWorkerCode, remedyLines, workerSourceDirty };
|
|
75
|
+
/**
|
|
76
|
+
* Refuse to capture with a fleet that is not running this checkout.
|
|
77
|
+
*
|
|
78
|
+
* Called at the boundary of every capture entry point, for the reason `assertWorkerUrl` is: the
|
|
79
|
+
* alternative is discovering it in the evidence weeks later, where a stale worker looks like a page that
|
|
80
|
+
* changed. **Both entry points, not one** — a remedy that reaches one of several paths is the shape this
|
|
81
|
+
* repo has paid for three times over (`anchorToTop`, `ensureSpeechChannel`, `waitForAnnouncement`), and
|
|
82
|
+
* `capture-preflight.test.ts` pins that both call it.
|
|
83
|
+
*
|
|
84
|
+
* A thin wrapper over `assertWorkersServe`, supplying the one thing only this file can compute: the hash.
|
|
85
|
+
*
|
|
86
|
+
* @param {string[]} workers
|
|
87
|
+
* @param {{when?: string, allow?: boolean, read?: (url: string) => Promise<string|null>, bareMetalUrls?: string[], checkoutRoot?: string}} options
|
|
88
|
+
*/
|
|
89
|
+
export declare function assertFleetRunsThisCheckout(workers: string[], options?: {
|
|
90
|
+
when?: string;
|
|
91
|
+
allow?: boolean;
|
|
92
|
+
read?: (url: string) => Promise<string | null>;
|
|
93
|
+
bareMetalUrls?: string[];
|
|
94
|
+
checkoutRoot?: string;
|
|
95
|
+
}): Promise<void>;
|
|
@@ -4,7 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { requestJson } from "./worker-http.mjs";
|
|
7
|
-
import { sandboxGitEnv } from "./src_git-safe-
|
|
7
|
+
import { sandboxGitEnv } from "./src_git-safe-env_ts.mjs";
|
|
8
8
|
const HEALTH_TIMEOUT_MS = 15000;
|
|
9
9
|
function codeDrift(expected, readings) {
|
|
10
10
|
const stale = [];
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* @param {{ busy?: boolean, ready?: boolean } | null | undefined} health
|
|
37
37
|
* @returns {boolean}
|
|
38
38
|
*/
|
|
39
|
-
export function workerIsUsable(health: {
|
|
39
|
+
export declare function workerIsUsable(health: {
|
|
40
40
|
busy?: boolean;
|
|
41
41
|
ready?: boolean;
|
|
42
42
|
} | null | undefined): boolean;
|
|
@@ -44,7 +44,7 @@ export function workerIsUsable(health: {
|
|
|
44
44
|
* @param {{ captures?: number, recoveries?: number, failures?: number } | null | undefined} vitals
|
|
45
45
|
* @returns {{ degraded: boolean, reason: string | null, recoveryShare: number | null }}
|
|
46
46
|
*/
|
|
47
|
-
export function assessWorker(vitals: {
|
|
47
|
+
export declare function assessWorker(vitals: {
|
|
48
48
|
captures?: number;
|
|
49
49
|
recoveries?: number;
|
|
50
50
|
failures?: number;
|
|
@@ -1,3 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a CLIENT should wait for a capture. One definition, because five had drifted.
|
|
3
|
+
*
|
|
4
|
+
* It must exceed the worker's own hard timeout (`CAPTURE_HARD_TIMEOUT_DEFAULT_MS`, 520 s) or the client
|
|
5
|
+
* gives up first, and a capture the worker would have completed is reported as a client failure. Five
|
|
6
|
+
* clients sat at 300 s -- `compare-workers`, `bench-capture`, `evidence-check`, `repeat-capture` and
|
|
7
|
+
* `capture-real-pages` -- against that 520 s. On the generated corpus nothing noticed, because a 1,338-byte
|
|
8
|
+
* page finishes in seconds. On REAL pages it silently dropped whatever used its budget, biasing the
|
|
9
|
+
* real-page corpus toward small simple pages: precisely the axis that corpus exists to add.
|
|
10
|
+
*
|
|
11
|
+
* 560,000 -> 620,000 on architecture-audit.md §14.5: `runCapture` (server.mjs) spends up to
|
|
12
|
+
* `DESKTOP_PREPARE_TIMEOUT_MS` (60 s) clearing the desktop BEFORE the hard-timeout-wrapped capture attempt
|
|
13
|
+
* even starts, sequentially rather than overlapping it -- so the true worst case a worker can legitimately
|
|
14
|
+
* take is 60 s + 520 s = 580 s, not 520 s alone. The old 560 s ceiling sat BELOW that, so a real page that
|
|
15
|
+
* used the full prepare budget and the full capture budget was killed by the CLIENT first and reported as
|
|
16
|
+
* a client failure for work the worker would have finished -- the exact shape this constant already exists
|
|
17
|
+
* to prevent, one rung further out. 620,000 keeps the same 40 s margin above the new true worst case that
|
|
18
|
+
* the original 560,000 kept above 520,000. `budget-ladder.test.ts` asserts the full sequence, not only the
|
|
19
|
+
* capture attempt inside it.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately NOT imported from `@a11ign/screenreader-worker`: this package runs on macOS and Linux and must
|
|
22
|
+
* not depend on a win32-only one. `budget-ladder.test.ts` enforces the relationship instead, over every
|
|
23
|
+
* client it DISCOVERS rather than a list -- which is how the 300 s clients stayed invisible while a guard
|
|
24
|
+
* for exactly this existed and read one hardcoded path.
|
|
25
|
+
*
|
|
26
|
+
* `DATASET_CAPTURE_TIMEOUT_MS` still overrides it in the dataset runner, which is the only client that
|
|
27
|
+
* wants a per-run ceiling.
|
|
28
|
+
*/
|
|
29
|
+
export declare const CAPTURE_CLIENT_TIMEOUT_MS = 620000;
|
|
30
|
+
/**
|
|
31
|
+
* How long a capture's silent connection may idle before the OS proves it is still there.
|
|
32
|
+
*
|
|
33
|
+
* 15 s, and RAISING IT TO 60 s WAS TRIED AND REVERTED — by this file's own test, which refuses a delay
|
|
34
|
+
* above ~30 s because common NAT idle timeouts start there.
|
|
35
|
+
*
|
|
36
|
+
* The reasoning for raising it was that the async path removed the long-lived connection, so nothing needs
|
|
37
|
+
* an aggressive value. That is true of the async path and FALSE of the `A11Y_SYNC_CAPTURE` escape hatch,
|
|
38
|
+
* which still holds one socket silent for a whole capture. At 60 s the first probe would fire after the
|
|
39
|
+
* reap, so the hatch would be unprotected while the constant looked deliberate — a guard weakened for a
|
|
40
|
+
* reason that did not cover the case it exists for.
|
|
41
|
+
*
|
|
42
|
+
* It stays aggressive until item D explains why THIS path reaps in seconds when the literature says
|
|
43
|
+
* minutes. An unexplained number is not a solved problem.
|
|
44
|
+
*
|
|
45
|
+
* EXPORTED so its test can key on the exact value. Node's own HTTP SERVER calls `setKeepAlive(true, 5000)`
|
|
46
|
+
* on every socket it accepts, so a test that merely looked for "keepalive with a plausible delay" matched
|
|
47
|
+
* the server's call and passed with this hook DELETED — found by mutation, not by reading.
|
|
48
|
+
*/
|
|
49
|
+
export declare const KEEPALIVE_DELAY_MS = 15000;
|
|
1
50
|
/**
|
|
2
51
|
* A worker address, validated at the BOUNDARY where it enters the program.
|
|
3
52
|
*
|
|
@@ -26,7 +75,7 @@
|
|
|
26
75
|
* @param {{ source?: string }} [options] what to name in the error, e.g. "--worker"
|
|
27
76
|
* @returns {string} the address, trailing slash removed
|
|
28
77
|
*/
|
|
29
|
-
export function assertWorkerUrl(value: string | null | undefined, { source }?: {
|
|
78
|
+
export declare function assertWorkerUrl(value: string | null | undefined, { source }?: {
|
|
30
79
|
source?: string;
|
|
31
80
|
}): string;
|
|
32
81
|
/**
|
|
@@ -41,7 +90,7 @@ export function assertWorkerUrl(value: string | null | undefined, { source }?: {
|
|
|
41
90
|
* strictly worse than saying the value is untyped. The SHAPE that matters is checked where it is
|
|
42
91
|
* defined: `capture-core`'s `Capture` typedef, and the worker's own `/health` contract.
|
|
43
92
|
*/
|
|
44
|
-
export function requestJson(url: string, { method, body, timeoutMs }?: {
|
|
93
|
+
export declare function requestJson(url: string, { method, body, timeoutMs }?: {
|
|
45
94
|
method?: string;
|
|
46
95
|
body?: unknown;
|
|
47
96
|
timeoutMs?: number;
|
|
@@ -51,52 +100,3 @@ export function requestJson(url: string, { method, body, timeoutMs }?: {
|
|
|
51
100
|
text: string;
|
|
52
101
|
json: any;
|
|
53
102
|
}>;
|
|
54
|
-
/**
|
|
55
|
-
* How long a CLIENT should wait for a capture. One definition, because five had drifted.
|
|
56
|
-
*
|
|
57
|
-
* It must exceed the worker's own hard timeout (`CAPTURE_HARD_TIMEOUT_DEFAULT_MS`, 520 s) or the client
|
|
58
|
-
* gives up first, and a capture the worker would have completed is reported as a client failure. Five
|
|
59
|
-
* clients sat at 300 s -- `compare-workers`, `bench-capture`, `evidence-check`, `repeat-capture` and
|
|
60
|
-
* `capture-real-pages` -- against that 520 s. On the generated corpus nothing noticed, because a 1,338-byte
|
|
61
|
-
* page finishes in seconds. On REAL pages it silently dropped whatever used its budget, biasing the
|
|
62
|
-
* real-page corpus toward small simple pages: precisely the axis that corpus exists to add.
|
|
63
|
-
*
|
|
64
|
-
* 560,000 -> 620,000 on architecture-audit.md §14.5: `runCapture` (server.mjs) spends up to
|
|
65
|
-
* `DESKTOP_PREPARE_TIMEOUT_MS` (60 s) clearing the desktop BEFORE the hard-timeout-wrapped capture attempt
|
|
66
|
-
* even starts, sequentially rather than overlapping it -- so the true worst case a worker can legitimately
|
|
67
|
-
* take is 60 s + 520 s = 580 s, not 520 s alone. The old 560 s ceiling sat BELOW that, so a real page that
|
|
68
|
-
* used the full prepare budget and the full capture budget was killed by the CLIENT first and reported as
|
|
69
|
-
* a client failure for work the worker would have finished -- the exact shape this constant already exists
|
|
70
|
-
* to prevent, one rung further out. 620,000 keeps the same 40 s margin above the new true worst case that
|
|
71
|
-
* the original 560,000 kept above 520,000. `budget-ladder.test.ts` asserts the full sequence, not only the
|
|
72
|
-
* capture attempt inside it.
|
|
73
|
-
*
|
|
74
|
-
* Deliberately NOT imported from `@a11ign/screenreader-worker`: this package runs on macOS and Linux and must
|
|
75
|
-
* not depend on a win32-only one. `budget-ladder.test.ts` enforces the relationship instead, over every
|
|
76
|
-
* client it DISCOVERS rather than a list -- which is how the 300 s clients stayed invisible while a guard
|
|
77
|
-
* for exactly this existed and read one hardcoded path.
|
|
78
|
-
*
|
|
79
|
-
* `DATASET_CAPTURE_TIMEOUT_MS` still overrides it in the dataset runner, which is the only client that
|
|
80
|
-
* wants a per-run ceiling.
|
|
81
|
-
*/
|
|
82
|
-
export const CAPTURE_CLIENT_TIMEOUT_MS: 620000;
|
|
83
|
-
/**
|
|
84
|
-
* How long a capture's silent connection may idle before the OS proves it is still there.
|
|
85
|
-
*
|
|
86
|
-
* 15 s, and RAISING IT TO 60 s WAS TRIED AND REVERTED — by this file's own test, which refuses a delay
|
|
87
|
-
* above ~30 s because common NAT idle timeouts start there.
|
|
88
|
-
*
|
|
89
|
-
* The reasoning for raising it was that the async path removed the long-lived connection, so nothing needs
|
|
90
|
-
* an aggressive value. That is true of the async path and FALSE of the `A11Y_SYNC_CAPTURE` escape hatch,
|
|
91
|
-
* which still holds one socket silent for a whole capture. At 60 s the first probe would fire after the
|
|
92
|
-
* reap, so the hatch would be unprotected while the constant looked deliberate — a guard weakened for a
|
|
93
|
-
* reason that did not cover the case it exists for.
|
|
94
|
-
*
|
|
95
|
-
* It stays aggressive until item D explains why THIS path reaps in seconds when the literature says
|
|
96
|
-
* minutes. An unexplained number is not a solved problem.
|
|
97
|
-
*
|
|
98
|
-
* EXPORTED so its test can key on the exact value. Node's own HTTP SERVER calls `setKeepAlive(true, 5000)`
|
|
99
|
-
* on every socket it accepts, so a test that merely looked for "keepalive with a plausible delay" matched
|
|
100
|
-
* the server's call and passed with this hook DELETED — found by mutation, not by reading.
|
|
101
|
-
*/
|
|
102
|
-
export const KEEPALIVE_DELAY_MS: 15000;
|
|
@@ -1,6 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robust statistics for comparing workers, and an explicit refusal to call a difference real when the
|
|
3
|
+
* samples do not support it.
|
|
4
|
+
*
|
|
5
|
+
* This module exists because of a specific, repeated mistake: concluding from one measurement. Over one
|
|
6
|
+
* session a 2x difference between two guests was attributed, in turn, to the display being blanked, to
|
|
7
|
+
* in-memory accumulation, to background CPU, to Edge's launch, to the Edge profile, to the sweep, and to
|
|
8
|
+
* Edge's startup boost. Every one of those was a single measurement, and every one was wrong.
|
|
9
|
+
*
|
|
10
|
+
* Two things caused that, and both are fixed here rather than in a comment.
|
|
11
|
+
*
|
|
12
|
+
* **Means lied.** One 63 s outlier in an eight-run sample moved the mean by 5 s and made a healthy
|
|
13
|
+
* worker look broken. Capture time has a long right tail by nature — a mute screen reader costs ~86 s
|
|
14
|
+
* against a normal ~12 s — so the mean measures how often the tail was hit, not what a capture costs.
|
|
15
|
+
* The median and the interquartile range do not move when one run goes bad.
|
|
16
|
+
*
|
|
17
|
+
* **Sequential sampling confounded.** Measuring worker A for five minutes and then worker B for five
|
|
18
|
+
* minutes attributes any drift in the host during those ten minutes to the difference between the
|
|
19
|
+
* workers. Interleaving round-robin makes drift common to all of them.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* One worker's samples, summarised.
|
|
23
|
+
*
|
|
24
|
+
* Named as a typedef rather than repeated inline because four functions pass it around, and this module's
|
|
25
|
+
* whole purpose is refusing to claim a difference the samples do not support — so `q1`, `q3` and `iqr`
|
|
26
|
+
* travelling together, as one thing with one name, is the point rather than a formality.
|
|
27
|
+
*/
|
|
28
|
+
export type Summary = {
|
|
29
|
+
n: number;
|
|
30
|
+
median: number;
|
|
31
|
+
q1: number;
|
|
32
|
+
q3: number;
|
|
33
|
+
iqr: number;
|
|
34
|
+
min: number;
|
|
35
|
+
max: number;
|
|
36
|
+
};
|
|
1
37
|
/** Linear-interpolated quantile. Fine for the sample sizes here and has no dependencies. */
|
|
2
38
|
/** @param {number[]} values @param {number} q @returns {number|null} */
|
|
3
|
-
export function quantile(values: number[], q: number): number | null;
|
|
39
|
+
export declare function quantile(values: number[], q: number): number | null;
|
|
4
40
|
/**
|
|
5
41
|
* The shape of one worker's samples.
|
|
6
42
|
*
|
|
@@ -11,7 +47,7 @@ export function quantile(values: number[], q: number): number | null;
|
|
|
11
47
|
* @param {number[]} values
|
|
12
48
|
* @returns {Summary|null}
|
|
13
49
|
*/
|
|
14
|
-
export function describe(values: number[]): Summary | null;
|
|
50
|
+
export declare function describe(values: number[]): Summary | null;
|
|
15
51
|
/**
|
|
16
52
|
* Compare workers and say, conservatively, whether a difference is supported.
|
|
17
53
|
*
|
|
@@ -25,7 +61,7 @@ export function describe(values: number[]): Summary | null;
|
|
|
25
61
|
* @returns {{ stats: Record<string, Summary>, slowest: string|null, fastest: string|null,
|
|
26
62
|
* distinguishable: boolean, verdict: string }}
|
|
27
63
|
*/
|
|
28
|
-
export function compareWorkers(samplesByWorker: Record<string, number[]>): {
|
|
64
|
+
export declare function compareWorkers(samplesByWorker: Record<string, number[]>): {
|
|
29
65
|
stats: Record<string, Summary>;
|
|
30
66
|
slowest: string | null;
|
|
31
67
|
fastest: string | null;
|
|
@@ -43,23 +79,7 @@ export function compareWorkers(samplesByWorker: Record<string, number[]>): {
|
|
|
43
79
|
* @returns {Record<string, number | null>} null where the worker captured nothing — "no idea", which
|
|
44
80
|
* must not be confused with a rate of zero ("perfectly reliable").
|
|
45
81
|
*/
|
|
46
|
-
export function recoveryRates(deltas: Record<string, {
|
|
82
|
+
export declare function recoveryRates(deltas: Record<string, {
|
|
47
83
|
recoveries: number;
|
|
48
84
|
captures: number;
|
|
49
85
|
}>): Record<string, number | null>;
|
|
50
|
-
/**
|
|
51
|
-
* One worker's samples, summarised.
|
|
52
|
-
*
|
|
53
|
-
* Named as a typedef rather than repeated inline because four functions pass it around, and this module's
|
|
54
|
-
* whole purpose is refusing to claim a difference the samples do not support — so `q1`, `q3` and `iqr`
|
|
55
|
-
* travelling together, as one thing with one name, is the point rather than a formality.
|
|
56
|
-
*/
|
|
57
|
-
export type Summary = {
|
|
58
|
-
n: number;
|
|
59
|
-
median: number;
|
|
60
|
-
q1: number;
|
|
61
|
-
q3: number;
|
|
62
|
-
iqr: number;
|
|
63
|
-
min: number;
|
|
64
|
-
max: number;
|
|
65
|
-
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a11ign/screenreader-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|
|
@@ -10,47 +10,47 @@
|
|
|
10
10
|
"default": "./dist/index.mjs"
|
|
11
11
|
},
|
|
12
12
|
"./health": {
|
|
13
|
-
"types": "./dist/worker-health.d.
|
|
13
|
+
"types": "./dist/worker-health.d.ts",
|
|
14
14
|
"default": "./dist/worker-health.mjs"
|
|
15
15
|
},
|
|
16
16
|
"./capacity": {
|
|
17
|
-
"types": "./dist/host-capacity.d.
|
|
17
|
+
"types": "./dist/host-capacity.d.ts",
|
|
18
18
|
"default": "./dist/host-capacity.mjs"
|
|
19
19
|
},
|
|
20
20
|
"./probe-outcome": {
|
|
21
|
-
"types": "./dist/probe-outcome.d.
|
|
21
|
+
"types": "./dist/probe-outcome.d.ts",
|
|
22
22
|
"default": "./dist/probe-outcome.mjs"
|
|
23
23
|
},
|
|
24
24
|
"./worker-http": {
|
|
25
|
-
"types": "./dist/worker-http.d.
|
|
25
|
+
"types": "./dist/worker-http.d.ts",
|
|
26
26
|
"default": "./dist/worker-http.mjs"
|
|
27
27
|
},
|
|
28
28
|
"./cli-flags": {
|
|
29
|
-
"types": "./dist/cli-flags.d.
|
|
29
|
+
"types": "./dist/cli-flags.d.ts",
|
|
30
30
|
"default": "./dist/cli-flags.mjs"
|
|
31
31
|
},
|
|
32
32
|
"./transient-fault": {
|
|
33
|
-
"types": "./dist/transient-fault.d.
|
|
33
|
+
"types": "./dist/transient-fault.d.ts",
|
|
34
34
|
"default": "./dist/transient-fault.mjs"
|
|
35
35
|
},
|
|
36
36
|
"./capture-client": {
|
|
37
|
-
"types": "./dist/capture-client.d.
|
|
37
|
+
"types": "./dist/capture-client.d.ts",
|
|
38
38
|
"default": "./dist/capture-client.mjs"
|
|
39
39
|
},
|
|
40
40
|
"./host-address": {
|
|
41
|
-
"types": "./dist/host-address.d.
|
|
41
|
+
"types": "./dist/host-address.d.ts",
|
|
42
42
|
"default": "./dist/host-address.mjs"
|
|
43
43
|
},
|
|
44
44
|
"./fleet-env": {
|
|
45
|
-
"types": "./dist/fleet-env.d.
|
|
45
|
+
"types": "./dist/fleet-env.d.ts",
|
|
46
46
|
"default": "./dist/fleet-env.mjs"
|
|
47
47
|
},
|
|
48
48
|
"./fleet-consistency": {
|
|
49
|
-
"types": "./dist/fleet-consistency.d.
|
|
49
|
+
"types": "./dist/fleet-consistency.d.ts",
|
|
50
50
|
"default": "./dist/fleet-consistency.mjs"
|
|
51
51
|
},
|
|
52
52
|
"./worker-code-check": {
|
|
53
|
-
"types": "./dist/worker-code-check.d.
|
|
53
|
+
"types": "./dist/worker-code-check.d.ts",
|
|
54
54
|
"default": "./dist/worker-code-check.mjs"
|
|
55
55
|
}
|
|
56
56
|
},
|
|
@@ -73,9 +73,18 @@
|
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@a11ign/evidence": "0.1.0",
|
|
76
|
-
"@a11ign/toolchain": "0.1
|
|
76
|
+
"@a11ign/toolchain": "0.3.1",
|
|
77
|
+
"@changesets/cli": "3.0.3",
|
|
78
|
+
"@eslint/js": "^10.0.1",
|
|
77
79
|
"@rslib/core": "1.0.3",
|
|
80
|
+
"@rstest/core": "0.12.3",
|
|
81
|
+
"@types/node": "^26.6.4",
|
|
82
|
+
"eslint": "^10.12.0",
|
|
83
|
+
"globals": "^17.13.0",
|
|
84
|
+
"jiti": "^2.7.0",
|
|
85
|
+
"tsx": "^4.23.15",
|
|
78
86
|
"typescript": "^6.0.3",
|
|
87
|
+
"typescript-eslint": "^8.71.1",
|
|
79
88
|
"yaml": "^2.9.1"
|
|
80
89
|
},
|
|
81
90
|
"engines": {
|
|
@@ -84,11 +93,6 @@
|
|
|
84
93
|
"publishConfig": {
|
|
85
94
|
"access": "public"
|
|
86
95
|
},
|
|
87
|
-
"repository": {
|
|
88
|
-
"type": "git",
|
|
89
|
-
"url": "git+https://github.com/a11ign/screenreader-fleet.git",
|
|
90
|
-
"directory": "packages/worker-fleet"
|
|
91
|
-
},
|
|
92
96
|
"homepage": "https://github.com/a11ign/screenreader-fleet",
|
|
93
97
|
"keywords": [
|
|
94
98
|
"accessibility",
|
|
@@ -99,7 +103,14 @@
|
|
|
99
103
|
"utm",
|
|
100
104
|
"vm"
|
|
101
105
|
],
|
|
106
|
+
"repository": {
|
|
107
|
+
"type": "git",
|
|
108
|
+
"url": "git+https://github.com/a11ign/screenreader-fleet.git"
|
|
109
|
+
},
|
|
102
110
|
"scripts": {
|
|
103
|
-
"build": "rslib build"
|
|
111
|
+
"build": "rslib build",
|
|
112
|
+
"lint": "eslint .",
|
|
113
|
+
"typecheck": "tsc --noEmit",
|
|
114
|
+
"test": "rstest run"
|
|
104
115
|
}
|
|
105
116
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Build a local NVDA capture worker VM on Apple Silicon, unattended.
|
|
3
3
|
#
|
|
4
|
-
# ./
|
|
4
|
+
# ./src/local-worker/build-vm.sh /path/to/Win11_ARM64.iso
|
|
5
5
|
#
|
|
6
6
|
# Produces a self-contained VM directory (default ~/a11y-worker-vm) holding the disk
|
|
7
7
|
# image, UEFI vars and a run script. That directory IS the portable artifact: copy it
|
|
@@ -40,7 +40,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
|
40
40
|
die() { echo "error: $*" >&2; exit 1; }
|
|
41
41
|
info() { echo "==> $*"; }
|
|
42
42
|
|
|
43
|
-
[ -n "$WIN_ISO" ] || die "usage: $0 <windows-11-arm64.iso> (build one with CrystalFetch, or
|
|
43
|
+
[ -n "$WIN_ISO" ] || die "usage: $0 <windows-11-arm64.iso> (build one with CrystalFetch, or src/local-worker/fetch-windows-iso.sh)"
|
|
44
44
|
[ -f "$WIN_ISO" ] || die "not found: $WIN_ISO"
|
|
45
45
|
command -v qemu-system-aarch64 >/dev/null || die "qemu missing: brew install qemu"
|
|
46
46
|
command -v qemu-img >/dev/null || die "qemu-img missing: brew install qemu"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Clone the local NVDA worker VM into an additional, independent worker.
|
|
3
3
|
#
|
|
4
|
-
# ./
|
|
4
|
+
# ./src/local-worker/clone-worker.sh [new-name] # default: a11y-worker-2
|
|
5
5
|
#
|
|
6
6
|
# One worker serves one capture at a time by design (one desktop, one foreground window, one
|
|
7
7
|
# NVDA), so throughput scales by running more of them. On APFS the clone is copy-on-write, so
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Create the NVDA worker VM in UTM, fully from the CLI. No GUI clicking.
|
|
3
3
|
#
|
|
4
|
-
# ./
|
|
4
|
+
# ./src/local-worker/create-utm-vm.sh <windows-arm64.iso> [support.iso]
|
|
5
5
|
#
|
|
6
6
|
# Why UTM rather than plain QEMU: homebrew QEMU + HVF cannot boot Windows 11 ARM64 on
|
|
7
7
|
# Apple Silicon (open upstream bug, https://gitlab.com/qemu-project/qemu/-/issues/2893,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Build an official Windows 11 ARM64 ISO on macOS, from the CLI.
|
|
3
3
|
#
|
|
4
|
-
# ./
|
|
4
|
+
# ./src/local-worker/fetch-windows-iso.sh [outdir]
|
|
5
5
|
#
|
|
6
6
|
# Microsoft's ARM64 ISO download is a session-token web flow that does not script
|
|
7
7
|
# cleanly, so this uses UUP dump: it fetches the same Unified Update Platform packages
|
|
@@ -16,7 +16,7 @@ set -euo pipefail
|
|
|
16
16
|
|
|
17
17
|
# architecture-audit.md §8: builds an ISO for a local UTM worker VM, which is deprecated -- "The UTM is
|
|
18
18
|
# deprecated, that was a testing thing." (repository owner, 2026-09-05). Bare-metal boxes install via
|
|
19
|
-
# PXE/autounattend.xml instead — see
|
|
19
|
+
# PXE/autounattend.xml instead — see src/provisioning/bare-metal/.
|
|
20
20
|
echo "DEPRECATED: fetch-windows-iso.sh feeds a local UTM worker VM build. UTM was a testing path and is not the fleet." >&2
|
|
21
21
|
echo "Capture on the bare-metal fleet instead: npm run fleet:status, npm run fleet:deploy." >&2
|
|
22
22
|
|
|
@@ -100,7 +100,7 @@ cd "$OUT_DIR"
|
|
|
100
100
|
EXISTING="$(ls -t "$OUT_DIR"/*.ISO "$OUT_DIR"/*.iso 2>/dev/null | grep -vi support | head -1 || true)"
|
|
101
101
|
if [ -n "$EXISTING" ] && xorriso -indev "$EXISTING" -report_el_torito plain 2>/dev/null | grep -q UEFI; then
|
|
102
102
|
echo "ISO ready (already built): $EXISTING"
|
|
103
|
-
echo "Next: ./
|
|
103
|
+
echo "Next: ./src/local-worker/create-utm-vm.sh \"$EXISTING\""
|
|
104
104
|
exit 0
|
|
105
105
|
fi
|
|
106
106
|
if [ -n "$EXISTING" ]; then
|
|
@@ -234,5 +234,5 @@ fi
|
|
|
234
234
|
|
|
235
235
|
echo
|
|
236
236
|
echo "ISO ready: $ISO"
|
|
237
|
-
echo "Next: ./
|
|
238
|
-
echo " ./
|
|
237
|
+
echo "Next: ./src/local-worker/build-vm.sh \"$ISO\""
|
|
238
|
+
echo " ./src/local-worker/create-utm-vm.sh \"$ISO\""
|
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
# while you are not capturing.
|
|
4
4
|
#
|
|
5
5
|
# npm run worker:ctl -- up # make it ready (start or resume), wait for /health
|
|
6
|
-
# ./
|
|
7
|
-
# ./
|
|
8
|
-
# ./
|
|
9
|
-
# ./
|
|
10
|
-
# ./
|
|
11
|
-
# ./
|
|
12
|
-
# ./
|
|
13
|
-
# ./
|
|
6
|
+
# ./src/local-worker/worker-ctl.sh pause # freeze it: ~0.6% CPU, instant resume, RAM not guaranteed
|
|
7
|
+
# ./src/local-worker/worker-ctl.sh stop # shut it down: nothing held, ~15 s to come back
|
|
8
|
+
# ./src/local-worker/worker-ctl.sh status # state, resource use, health
|
|
9
|
+
# ./src/local-worker/worker-ctl.sh json # the same, machine-readable (used by the CLI)
|
|
10
|
+
# ./src/local-worker/worker-ctl.sh pool # every a11y-worker* VM, as JSON
|
|
11
|
+
# ./src/local-worker/worker-ctl.sh pool-up # start them all, wait for health
|
|
12
|
+
# ./src/local-worker/worker-ctl.sh pool-stop # shut the whole pool down
|
|
13
|
+
# ./src/local-worker/worker-ctl.sh pool-pause # freeze the whole pool
|
|
14
14
|
#
|
|
15
15
|
# One VM serves one capture at a time, so throughput comes from more VMs. `pool` reports the
|
|
16
16
|
# lot; add one with clone-worker.sh (which handles the duplicate-MAC trap).
|
|
17
17
|
# Operate on a single named VM with A11Y_VM_NAME=a11y-worker-2.
|
|
18
|
-
# ./
|
|
19
|
-
# ./
|
|
18
|
+
# ./src/local-worker/worker-ctl.sh idle-pause 15 # watch, then pause after 15 idle minutes
|
|
19
|
+
# ./src/local-worker/worker-ctl.sh idle-stop 30 # same but shut down instead
|
|
20
20
|
#
|
|
21
21
|
# Measured on an M4 Max, 4 vCPU / 8 GB guest. Every number here was observed on this
|
|
22
22
|
# machine; none is an estimate:
|
|
@@ -117,7 +117,7 @@ resolve_uuid() {
|
|
|
117
117
|
[ -n "${A11Y_VM_UUID:-}" ] && { echo "$A11Y_VM_UUID"; return; }
|
|
118
118
|
local matches
|
|
119
119
|
matches="$(utmctl list | awk -v n="$VM_NAME" '$3 == n { print $1 }')"
|
|
120
|
-
[ -n "$matches" ] || die "no VM named '$VM_NAME' (create one:
|
|
120
|
+
[ -n "$matches" ] || die "no VM named '$VM_NAME' (create one: src/local-worker/create-utm-vm.sh)"
|
|
121
121
|
if [ "$(echo "$matches" | wc -l | tr -d ' ')" -gt 1 ]; then
|
|
122
122
|
# Do NOT guess, and do NOT suggest deleting one. Duplicate registrations under the same
|
|
123
123
|
# name point at the SAME <name>.utm bundle, so `utmctl delete` on either removes that
|
|
@@ -317,7 +317,7 @@ case "$CMD" in
|
|
|
317
317
|
echo " it is down for 5-10s during a restart; if it persists:" >&2
|
|
318
318
|
echo " utmctl exec <uuid> --cmd powershell.exe -NoProfile -Command 'Start-ScheduledTask -TaskName a11ysrv'" >&2
|
|
319
319
|
else
|
|
320
|
-
# `$0` is this file's path, which after M6 is `
|
|
320
|
+
# `$0` is this file's path, which after M6 is `src/local-worker/worker-ctl.sh` — true,
|
|
321
321
|
# and not what anyone wants to type. The npm alias is the stable way to say it, and it is what the docs use.
|
|
322
322
|
echo " no guest IP either, so the VM itself is not ready. Try 'npm run worker:ctl -- up'." >&2
|
|
323
323
|
fi
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
# and run-server.cmd (so every worker start re-applies it for that session).
|
|
23
23
|
#
|
|
24
24
|
# Style note: `#` line comments and no param() block, matching the other scripts here --
|
|
25
|
-
# see
|
|
25
|
+
# see src/provisioning/diagnose-nvda-worker.ps1 for why.
|
|
26
26
|
|
|
27
27
|
$ErrorActionPreference = 'Stop'
|
|
28
28
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# Run this ONCE, in the VM, in an elevated PowerShell, right after Windows setup:
|
|
4
4
|
#
|
|
5
5
|
# Set-ExecutionPolicy -Scope Process Bypass -Force
|
|
6
|
-
# irm https://raw.githubusercontent.com/a11ign/
|
|
6
|
+
# irm https://raw.githubusercontent.com/a11ign/screenreader-fleet/main/src/provisioning/bootstrap-windows-worker.ps1 | iex
|
|
7
7
|
#
|
|
8
8
|
# ...or, if you already have the repo, just run this file. It installs the
|
|
9
9
|
# prerequisites, makes the box reachable over SSH, clones the repo, and then hands
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# VERDICT per layer rather than raw dumps, so the first FAIL is the thing to fix.
|
|
5
5
|
# Exits non-zero if any check failed. Copy it over and run it with -File:
|
|
6
6
|
#
|
|
7
|
-
# scp
|
|
7
|
+
# scp src/provisioning/diagnose-nvda-worker.ps1 user@host:C:/Users/user/
|
|
8
8
|
# ssh user@host "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\user\diagnose-nvda-worker.ps1"
|
|
9
9
|
#
|
|
10
10
|
# Do NOT pipe this to `powershell -Command -`. That mode silently truncated this
|
|
@@ -47,8 +47,8 @@ param(
|
|
|
47
47
|
Set-StrictMode -Version Latest
|
|
48
48
|
$ErrorActionPreference = 'Stop'
|
|
49
49
|
|
|
50
|
-
#
|
|
51
|
-
# declared in `packages/control/layers.json`, and what its launchers reach outside it in the layer's own
|
|
50
|
+
# THREE OF THE FIVE PATHS ARE NOT WRITTEN HERE (ADR 0039 item 6d, #3397; this repository's own file since the flat layout, a11ign/a11ign#4216).
|
|
51
|
+
# Where the worker layer and this fleet layer live is declared in `packages/control/layers.json`, and what its launchers reach outside it in the layer's own
|
|
52
52
|
# `src/launcher-reach.cmd`, which `run-capture-check.cmd` `call`s. Both are READ, so a path cannot change in
|
|
53
53
|
# the launcher and stay behind in the stamp. A declaration that is absent or does not say THROWS: the stamp
|
|
54
54
|
# must not fall back to a literal, because a literal that was right yesterday is the stamp describing less
|
|
@@ -77,7 +77,7 @@ $FOREGROUND_LOCK = Get-DeclaredReach -Name 'FLT'
|
|
|
77
77
|
# The single definition. Explicit paths rather than a filename search, because `main.yml` is not unique
|
|
78
78
|
# in this repo and a search would silently pick the wrong one.
|
|
79
79
|
$ENVIRONMENT_FILES = @(
|
|
80
|
-
'
|
|
80
|
+
(Get-LayerFile -Layer 'screenreader-fleet' -Relative 'src/provisioning/provision-nvda-worker.ps1')
|
|
81
81
|
$RUN_SERVER
|
|
82
82
|
$FOREGROUND_LOCK
|
|
83
83
|
'packages/control/ansible/roles/worker/defaults/main.yml'
|
package/dist/source-walk.d.mts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Every non-test source file under `packages/`, as `[relativePath, source]`.
|
|
3
|
-
*
|
|
4
|
-
* @param {{ root?: string }} [options]
|
|
5
|
-
* @returns {Array<[string, string]>}
|
|
6
|
-
*/
|
|
7
|
-
export function sourceFiles({ root }?: {
|
|
8
|
-
root?: string;
|
|
9
|
-
}): Array<[string, string]>;
|
|
10
|
-
/** The `packages/` directory, resolved from this module rather than from the caller's cwd. */
|
|
11
|
-
export const PACKAGES: string;
|