@a11ign/screenreader-fleet 0.0.0-reserved.0 → 0.2.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/LICENSE +661 -0
- package/README.md +94 -2
- package/dist/capture-client.d.mts +49 -0
- package/dist/capture-client.d.mts.map +1 -0
- package/dist/capture-client.mjs +352 -0
- package/dist/capture-client.mjs.map +1 -0
- package/dist/check-worker-code.d.mts +34 -0
- package/dist/check-worker-code.d.mts.map +1 -0
- package/dist/check-worker-code.mjs +142 -0
- package/dist/check-worker-code.mjs.map +1 -0
- package/dist/cli-flags.d.mts +71 -0
- package/dist/cli-flags.d.mts.map +1 -0
- package/dist/cli-flags.mjs +207 -0
- package/dist/cli-flags.mjs.map +1 -0
- package/dist/code-drift.d.mts +140 -0
- package/dist/code-drift.d.mts.map +1 -0
- package/dist/code-drift.mjs +284 -0
- package/dist/code-drift.mjs.map +1 -0
- package/dist/command-line-census.d.mts +33 -0
- package/dist/command-line-census.d.mts.map +1 -0
- package/dist/command-line-census.mjs +96 -0
- package/dist/command-line-census.mjs.map +1 -0
- package/dist/compare-workers.d.mts +3 -0
- package/dist/compare-workers.d.mts.map +1 -0
- package/dist/compare-workers.mjs +332 -0
- package/dist/compare-workers.mjs.map +1 -0
- package/dist/control-plane-isolation.d.mts +45 -0
- package/dist/control-plane-isolation.d.mts.map +1 -0
- package/dist/control-plane-isolation.mjs +67 -0
- package/dist/control-plane-isolation.mjs.map +1 -0
- package/dist/deploy-worker.d.mts +3 -0
- package/dist/deploy-worker.d.mts.map +1 -0
- package/dist/deploy-worker.mjs +333 -0
- package/dist/deploy-worker.mjs.map +1 -0
- package/dist/doctor.d.mts +216 -0
- package/dist/doctor.d.mts.map +1 -0
- package/dist/doctor.mjs +962 -0
- package/dist/doctor.mjs.map +1 -0
- package/dist/fleet-consistency.d.mts +235 -0
- package/dist/fleet-consistency.d.mts.map +1 -0
- package/dist/fleet-consistency.mjs +436 -0
- package/dist/fleet-consistency.mjs.map +1 -0
- package/dist/fleet-env.d.mts +228 -0
- package/dist/fleet-env.d.mts.map +1 -0
- package/dist/fleet-env.mjs +509 -0
- package/dist/fleet-env.mjs.map +1 -0
- package/dist/fleet-scripts.d.mts +11 -0
- package/dist/fleet-scripts.d.mts.map +1 -0
- package/dist/fleet-scripts.mjs +41 -0
- package/dist/fleet-scripts.mjs.map +1 -0
- package/dist/git-safe-env.d.mts +10 -0
- package/dist/git-safe-env.d.mts.map +1 -0
- package/dist/git-safe-env.mjs +44 -0
- package/dist/git-safe-env.mjs.map +1 -0
- package/dist/guest-run.d.mts +26 -0
- package/dist/guest-run.d.mts.map +1 -0
- package/dist/guest-run.mjs +164 -0
- package/dist/guest-run.mjs.map +1 -0
- package/dist/host-address.d.mts +33 -0
- package/dist/host-address.d.mts.map +1 -0
- package/dist/host-address.mjs +105 -0
- package/dist/host-address.mjs.map +1 -0
- package/dist/host-capacity.d.mts +64 -0
- package/dist/host-capacity.d.mts.map +1 -0
- package/dist/host-capacity.mjs +152 -0
- package/dist/host-capacity.mjs.map +1 -0
- package/dist/host-metrics.d.mts +116 -0
- package/dist/host-metrics.d.mts.map +1 -0
- package/dist/host-metrics.mjs +201 -0
- package/dist/host-metrics.mjs.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/local-vm.d.ts +125 -0
- package/dist/local-vm.d.ts.map +1 -0
- package/dist/local-vm.js +360 -0
- package/dist/local-vm.js.map +1 -0
- package/dist/measure-guard.d.mts +34 -0
- package/dist/measure-guard.d.mts.map +1 -0
- package/dist/measure-guard.mjs +73 -0
- package/dist/measure-guard.mjs.map +1 -0
- package/dist/normalise-fleet.d.mts +2 -0
- package/dist/normalise-fleet.d.mts.map +1 -0
- package/dist/normalise-fleet.mjs +76 -0
- package/dist/normalise-fleet.mjs.map +1 -0
- package/dist/npm-cli-executable.d.mts +42 -0
- package/dist/npm-cli-executable.d.mts.map +1 -0
- package/dist/npm-cli-executable.mjs +159 -0
- package/dist/npm-cli-executable.mjs.map +1 -0
- package/dist/probe-outcome.d.mts +89 -0
- package/dist/probe-outcome.d.mts.map +1 -0
- package/dist/probe-outcome.mjs +104 -0
- package/dist/probe-outcome.mjs.map +1 -0
- package/dist/protocol-guard.d.mts +34 -0
- package/dist/protocol-guard.d.mts.map +1 -0
- package/dist/protocol-guard.mjs +121 -0
- package/dist/protocol-guard.mjs.map +1 -0
- package/dist/source-walk.d.mts +12 -0
- package/dist/source-walk.d.mts.map +1 -0
- package/dist/source-walk.mjs +56 -0
- package/dist/source-walk.mjs.map +1 -0
- package/dist/transient-fault.d.mts +6 -0
- package/dist/transient-fault.d.mts.map +1 -0
- package/dist/transient-fault.mjs +86 -0
- package/dist/transient-fault.mjs.map +1 -0
- package/dist/utm-deprecated.d.mts +6 -0
- package/dist/utm-deprecated.d.mts.map +1 -0
- package/dist/utm-deprecated.mjs +23 -0
- package/dist/utm-deprecated.mjs.map +1 -0
- package/dist/worker-code-check.d.mts +29 -0
- package/dist/worker-code-check.d.mts.map +1 -0
- package/dist/worker-code-check.mjs +85 -0
- package/dist/worker-code-check.mjs.map +1 -0
- package/dist/worker-health.d.mts +56 -0
- package/dist/worker-health.d.mts.map +1 -0
- package/dist/worker-health.mjs +73 -0
- package/dist/worker-health.mjs.map +1 -0
- package/dist/worker-http.d.mts +103 -0
- package/dist/worker-http.d.mts.map +1 -0
- package/dist/worker-http.mjs +277 -0
- package/dist/worker-http.mjs.map +1 -0
- package/dist/worker-stats.d.mts +66 -0
- package/dist/worker-stats.d.mts.map +1 -0
- package/dist/worker-stats.mjs +143 -0
- package/dist/worker-stats.mjs.map +1 -0
- package/package.json +96 -4
- package/src/local-worker/autounattend.xml +280 -0
- package/src/local-worker/build-vm.sh +218 -0
- package/src/local-worker/clone-worker.sh +141 -0
- package/src/local-worker/create-utm-vm.sh +202 -0
- package/src/local-worker/fetch-windows-iso.sh +238 -0
- package/src/local-worker/first-boot.cmd +58 -0
- package/src/local-worker/worker-ctl.sh +442 -0
- package/src/provisioning/README.md +28 -0
- package/src/provisioning/apply-foreground-lock-timeout.ps1 +71 -0
- package/src/provisioning/bare-metal/README.md +213 -0
- package/src/provisioning/bare-metal/a11y-bootstrap.service +58 -0
- package/src/provisioning/bare-metal/autounattend.xml +428 -0
- package/src/provisioning/bare-metal/serve-bootstrap.sh +86 -0
- package/src/provisioning/bootstrap-control-plane.sh +463 -0
- package/src/provisioning/bootstrap-windows-worker.ps1 +649 -0
- package/src/provisioning/build-lean-worker-image.ps1 +275 -0
- package/src/provisioning/diagnose-nvda-worker.ps1 +174 -0
- package/src/provisioning/provision-nvda-worker.ps1 +827 -0
- package/src/provisioning/set-display-mode.ps1 +411 -0
- package/src/provisioning/stamp-provision-revision.ps1 +184 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* HTTP to a capture worker, on a connection that may stay silent for ten minutes.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is not `fetch`
|
|
6
|
+
*
|
|
7
|
+
* Node's global `fetch` is undici, and undici caps the wait for RESPONSE HEADERS at 300 s
|
|
8
|
+
* (`headersTimeout`). `AbortSignal.timeout()` does not govern it — it is a separate mechanism, settable
|
|
9
|
+
* only through a dispatcher. Measured on Node v24.7.0 against a server that withheld headers for 310 s:
|
|
10
|
+
*
|
|
11
|
+
* global fetch + AbortSignal.timeout(560s) : THREW — TypeError: fetch failed :: UND_ERR_HEADERS_TIMEOUT
|
|
12
|
+
* node:http request : SURVIVED — got 200
|
|
13
|
+
*
|
|
14
|
+
* That cap lands squarely inside this project's own budget ladder. `capture-pure.mjs` sets a 420 s sweep
|
|
15
|
+
* budget inside a 520 s hard timeout, deliberately raised so that "a real page was sampled not validated",
|
|
16
|
+
* and the three clients then declared 560 s, 560 s and 320 s. Every one of those numbers is above 300 s, so
|
|
17
|
+
* the effective ceiling was undici's — not any constant in this repo.
|
|
18
|
+
*
|
|
19
|
+
* The failure is silent and it destroys evidence. The worker writes its status and body together at the END
|
|
20
|
+
* of a capture (`send(res, 200, {...})`), so headers arrive last; a capture that takes 301 s therefore has
|
|
21
|
+
* its socket torn down by the CLIENT at the moment it finishes, and the worker writes a completed capture
|
|
22
|
+
* into a dead socket. The host sees `fetch failed`, classifies it transient, and retries — paying another
|
|
23
|
+
* full capture to reach the same cliff. Three such pages in a row on one worker trips `shouldEvictWorker`
|
|
24
|
+
* and removes a machine that was never faulty.
|
|
25
|
+
*
|
|
26
|
+
* `cli.ts` had the diagnosis exactly right in a comment — it names `UND_ERR_HEADERS_TIMEOUT` and the ~300 s
|
|
27
|
+
* cap — and then raised an `AbortSignal`, which governs a different mechanism. This repo's own "a comment
|
|
28
|
+
* that names an ambiguity, above code that resolves it by assumption", one step worse: the comment named
|
|
29
|
+
* the mechanism and the code still addressed another one.
|
|
30
|
+
*
|
|
31
|
+
* ## Why not an undici dispatcher
|
|
32
|
+
*
|
|
33
|
+
* `undici` is not a dependency here, and installing it would not help by itself: the standalone package
|
|
34
|
+
* keeps its own global dispatcher, so `setGlobalDispatcher` does not reach Node's built-in `fetch`. Using
|
|
35
|
+
* it would mean importing undici's `fetch` too. `node:http` is in core, has no client-side headers cap at
|
|
36
|
+
* all, and gives us one explicit deadline over the whole exchange instead of three interacting ones.
|
|
37
|
+
*
|
|
38
|
+
* ## Errors carry a CODE
|
|
39
|
+
*
|
|
40
|
+
* `node:http` reports `error.code` (`ECONNREFUSED`, `EHOSTUNREACH`, `ECONNRESET`) where `fetch` collapsed
|
|
41
|
+
* everything into `TypeError: fetch failed`. That is strictly better — `capture-faults.mjs` records what
|
|
42
|
+
* matching on prose costs — but it is a behaviour change the retry logic has to know about, so
|
|
43
|
+
* `isTransient` now keys on those codes. `EHOSTUNREACH` matters most: it is how a bare-metal worker's NIC
|
|
44
|
+
* waking from selective suspend presents, and under `fetch` it was transient only by accident, because the
|
|
45
|
+
* wrapper said "fetch failed".
|
|
46
|
+
*/
|
|
47
|
+
import { request as httpRequest } from "node:http";
|
|
48
|
+
import { request as httpsRequest } from "node:https";
|
|
49
|
+
/**
|
|
50
|
+
* How long a CLIENT should wait for a capture. One definition, because five had drifted.
|
|
51
|
+
*
|
|
52
|
+
* It must exceed the worker's own hard timeout (`CAPTURE_HARD_TIMEOUT_DEFAULT_MS`, 520 s) or the client
|
|
53
|
+
* gives up first, and a capture the worker would have completed is reported as a client failure. Five
|
|
54
|
+
* clients sat at 300 s -- `compare-workers`, `bench-capture`, `evidence-check`, `repeat-capture` and
|
|
55
|
+
* `capture-real-pages` -- against that 520 s. On the generated corpus nothing noticed, because a 1,338-byte
|
|
56
|
+
* page finishes in seconds. On REAL pages it silently dropped whatever used its budget, biasing the
|
|
57
|
+
* real-page corpus toward small simple pages: precisely the axis that corpus exists to add.
|
|
58
|
+
*
|
|
59
|
+
* 560,000 -> 620,000 on architecture-audit.md §14.5: `runCapture` (server.mjs) spends up to
|
|
60
|
+
* `DESKTOP_PREPARE_TIMEOUT_MS` (60 s) clearing the desktop BEFORE the hard-timeout-wrapped capture attempt
|
|
61
|
+
* even starts, sequentially rather than overlapping it -- so the true worst case a worker can legitimately
|
|
62
|
+
* 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
|
|
63
|
+
* used the full prepare budget and the full capture budget was killed by the CLIENT first and reported as
|
|
64
|
+
* a client failure for work the worker would have finished -- the exact shape this constant already exists
|
|
65
|
+
* to prevent, one rung further out. 620,000 keeps the same 40 s margin above the new true worst case that
|
|
66
|
+
* the original 560,000 kept above 520,000. `budget-ladder.test.ts` asserts the full sequence, not only the
|
|
67
|
+
* capture attempt inside it.
|
|
68
|
+
*
|
|
69
|
+
* Deliberately NOT imported from `@a11ign/screenreader-worker`: this package runs on macOS and Linux and must
|
|
70
|
+
* not depend on a win32-only one. `budget-ladder.test.ts` enforces the relationship instead, over every
|
|
71
|
+
* client it DISCOVERS rather than a list -- which is how the 300 s clients stayed invisible while a guard
|
|
72
|
+
* for exactly this existed and read one hardcoded path.
|
|
73
|
+
*
|
|
74
|
+
* `DATASET_CAPTURE_TIMEOUT_MS` still overrides it in the dataset runner, which is the only client that
|
|
75
|
+
* wants a per-run ceiling.
|
|
76
|
+
*/
|
|
77
|
+
export const CAPTURE_CLIENT_TIMEOUT_MS = 620_000;
|
|
78
|
+
/**
|
|
79
|
+
* How long a capture's silent connection may idle before the OS proves it is still there.
|
|
80
|
+
*
|
|
81
|
+
* 15 s, and RAISING IT TO 60 s WAS TRIED AND REVERTED — by this file's own test, which refuses a delay
|
|
82
|
+
* above ~30 s because common NAT idle timeouts start there.
|
|
83
|
+
*
|
|
84
|
+
* The reasoning for raising it was that the async path removed the long-lived connection, so nothing needs
|
|
85
|
+
* an aggressive value. That is true of the async path and FALSE of the `A11Y_SYNC_CAPTURE` escape hatch,
|
|
86
|
+
* which still holds one socket silent for a whole capture. At 60 s the first probe would fire after the
|
|
87
|
+
* reap, so the hatch would be unprotected while the constant looked deliberate — a guard weakened for a
|
|
88
|
+
* reason that did not cover the case it exists for.
|
|
89
|
+
*
|
|
90
|
+
* It stays aggressive until item D explains why THIS path reaps in seconds when the literature says
|
|
91
|
+
* minutes. An unexplained number is not a solved problem.
|
|
92
|
+
*
|
|
93
|
+
* EXPORTED so its test can key on the exact value. Node's own HTTP SERVER calls `setKeepAlive(true, 5000)`
|
|
94
|
+
* on every socket it accepts, so a test that merely looked for "keepalive with a plausible delay" matched
|
|
95
|
+
* the server's call and passed with this hook DELETED — found by mutation, not by reading.
|
|
96
|
+
*/
|
|
97
|
+
export const KEEPALIVE_DELAY_MS = 15_000;
|
|
98
|
+
/**
|
|
99
|
+
* A worker address, validated at the BOUNDARY where it enters the program.
|
|
100
|
+
*
|
|
101
|
+
* `requestJson` already calls `new URL(url)`, which throws `ERR_INVALID_URL` on a malformed address — so an
|
|
102
|
+
* empty host dies in under a second, in principle. In practice it did not, and the way it did not is the
|
|
103
|
+
* reason this function exists.
|
|
104
|
+
*
|
|
105
|
+
* `--worker=http://:8765` reached `capture-real-pages.mjs` because nothing there did more than check the
|
|
106
|
+
* value was truthy, and `http://:8765` is truthy. The readiness loop then caught the resulting
|
|
107
|
+
* `ERR_INVALID_URL` in a bare `catch` whose only content was the comment "mid-boot or mid-restart; keep
|
|
108
|
+
* waiting", and so classified a permanent
|
|
109
|
+
* programmer error as a transient network condition: 60 attempts, 5 s apart, per page — then recorded
|
|
110
|
+
* "worker never became ready" as a failure of the PAGE. Four shards spent 29 minutes that way while every
|
|
111
|
+
* worker sat idle, and the run blamed the corpus.
|
|
112
|
+
*
|
|
113
|
+
* So the fix is two-part and both halves are needed: refuse the value here, and stop the readiness loop
|
|
114
|
+
* swallowing what it cannot recover from. Validating without fixing the catch leaves the next unrecoverable
|
|
115
|
+
* error to be absorbed the same way.
|
|
116
|
+
*
|
|
117
|
+
* Node's URL parser does the work. There is no regex here on purpose — a hand-rolled one would accept
|
|
118
|
+
* `http://:8765` again, since the only thing wrong with it is an empty host. Note `http:/x` and
|
|
119
|
+
* `http:///path` DO parse, to host `x` and host `path`; that is the parser's business and not something to
|
|
120
|
+
* second-guess here.
|
|
121
|
+
*
|
|
122
|
+
* @param {string | null | undefined} value the raw `--worker=` or `A11Y_WORKER` value
|
|
123
|
+
* @param {{ source?: string }} [options] what to name in the error, e.g. "--worker"
|
|
124
|
+
* @returns {string} the address, trailing slash removed
|
|
125
|
+
*/
|
|
126
|
+
export function assertWorkerUrl(value, { source = "--worker" } = {}) {
|
|
127
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
128
|
+
throw new Error(`${source} is required and was empty. Give a worker address, e.g. ${source}=http://192.0.2.10:8765`);
|
|
129
|
+
}
|
|
130
|
+
const raw = value.trim().replace(/\/$/, "");
|
|
131
|
+
let target;
|
|
132
|
+
try {
|
|
133
|
+
target = new URL(raw);
|
|
134
|
+
}
|
|
135
|
+
catch (cause) {
|
|
136
|
+
// A missing HOST lands here rather than in a hostname check below, and there is no such check on
|
|
137
|
+
// purpose: verified against Node's parser, no `http:`/`https:` URL can parse with an empty hostname —
|
|
138
|
+
// `http://`, `http://:8765` and `http://:/x` all throw. A `!target.hostname` branch would therefore be
|
|
139
|
+
// unreachable, which is this repo's own most-repeated defect, so the message that belongs to that case
|
|
140
|
+
// is folded in here where it can actually be read.
|
|
141
|
+
throw new Error(`${source}=${raw} is not a URL. Expected something like http://192.0.2.10:8765\n`
|
|
142
|
+
+ "If the host is missing, that is what a shell variable expanding to nothing looks like: a bash "
|
|
143
|
+
+ "array does not survive `nohup bash -c`, and zsh does not word-split a scalar. Both produce "
|
|
144
|
+
+ "exactly this.", { cause });
|
|
145
|
+
}
|
|
146
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
147
|
+
throw new Error(`${source}=${raw} must be http: or https:, not ${target.protocol}`);
|
|
148
|
+
}
|
|
149
|
+
return raw;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* One request, with a single deadline covering connect, headers and body.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} url
|
|
155
|
+
* @param {{ method?: string, body?: unknown, timeoutMs?: number }} [options]
|
|
156
|
+
* @returns {Promise<{ status: number, ok: boolean, text: string, json: any }>}
|
|
157
|
+
* `json: any`, not `unknown`. This is a JSON body off the wire, and every caller reads named fields
|
|
158
|
+
* from it -- `body.error`, `body.fault`, `health.busy`, `body.transcript`. `unknown` makes each of
|
|
159
|
+
* those a cast, and a cast written to satisfy a checker asserts a shape nobody verified, which is
|
|
160
|
+
* strictly worse than saying the value is untyped. The SHAPE that matters is checked where it is
|
|
161
|
+
* defined: `capture-core`'s `Capture` typedef, and the worker's own `/health` contract.
|
|
162
|
+
*/
|
|
163
|
+
export function requestJson(url, { method = "GET", body, timeoutMs = 30_000 } = {}) {
|
|
164
|
+
const target = new URL(url);
|
|
165
|
+
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
166
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
const req = send(target, { method, headers: requestHeaders(payload) }, (res) => {
|
|
169
|
+
collect(res).then((text) => {
|
|
170
|
+
clearTimeout(deadline);
|
|
171
|
+
// `?? 0` because node types `statusCode` optional -- it is set on every client response, and a 0
|
|
172
|
+
// would fall out of the 2xx range exactly as an absent one should, so the fallback cannot change
|
|
173
|
+
// an answer. Named once rather than read three times.
|
|
174
|
+
const status = res.statusCode ?? 0;
|
|
175
|
+
resolve({ status, ok: status >= 200 && status < 300, text, json: parse(text) });
|
|
176
|
+
}, failWith);
|
|
177
|
+
});
|
|
178
|
+
// Our own deadline, because node:http has no equivalent of a total-request timeout: its `timeout`
|
|
179
|
+
// option measures socket INACTIVITY, which a worker holding a connection open while NVDA reads a page
|
|
180
|
+
// never trips. The message says "timed out" so the prose fallback in isTransient still classifies it,
|
|
181
|
+
// and the code says so too for anything that prefers codes.
|
|
182
|
+
const deadline = setTimeout(() => {
|
|
183
|
+
// Typed as an ErrnoException so the `code` this deliberately attaches is describable. Recovery in
|
|
184
|
+
// this repo is keyed on CODES and never on message text (`capture-faults.mjs`), so the field is
|
|
185
|
+
// load-bearing rather than decorative.
|
|
186
|
+
const error = /** @type {NodeJS.ErrnoException} */ (new Error(`Request to ${url} timed out after ${timeoutMs} ms`));
|
|
187
|
+
error.code = "ETIMEDOUT";
|
|
188
|
+
req.destroy(error);
|
|
189
|
+
}, timeoutMs);
|
|
190
|
+
/** @param {unknown} error */
|
|
191
|
+
function failWith(error) {
|
|
192
|
+
clearTimeout(deadline);
|
|
193
|
+
reject(error);
|
|
194
|
+
}
|
|
195
|
+
// KEEPALIVE, KEPT ON PRECAUTIONARY GROUNDS ONLY. ITS DIAGNOSIS WAS WRONG, AND THE REASON IS TOPOLOGY.
|
|
196
|
+
//
|
|
197
|
+
// The story attached to this was: a capture holds a connection SILENT for minutes, so NAT and Wi-Fi
|
|
198
|
+
// power-save reap it as idle. It was argued from *High Performance Browser Networking* -- "Most mobile
|
|
199
|
+
// carriers set a 5-30 minute NAT connection timeout" -- and that passage is about MOBILE CARRIERS AND
|
|
200
|
+
// THE PUBLIC INTERNET.
|
|
201
|
+
//
|
|
202
|
+
// THIS IS A LAN. The control plane and every worker sit on the same /24; the route between them is
|
|
203
|
+
// `link#14`, direct on the link layer with no gateway hop. NAT happens at the router on the
|
|
204
|
+
// way OUT. There is no translation table between these hosts, so there is nothing here that a NAT
|
|
205
|
+
// timeout could expire. The theory was inapplicable from the first line, and the mismatch went
|
|
206
|
+
// unnoticed because the quotation fitted the SYMPTOM.
|
|
207
|
+
//
|
|
208
|
+
// The measurements agree, which is how it was caught rather than believed. Keepalive explicitly OFF,
|
|
209
|
+
// ~26 s silent captures:
|
|
210
|
+
//
|
|
211
|
+
// 8 sequential on one box 0 lost
|
|
212
|
+
// 15 across five boxes at once 0 lost <- the exact condition the 9/40 was measured under
|
|
213
|
+
//
|
|
214
|
+
// 0 of 23. At the old 22% rate P(0 in 23) = 0.78^23 = 0.4%, so the rate really did change -- and these
|
|
215
|
+
// trials ran WITHOUT the keepalive, so it is not shown to be why. Losses went 9/40 -> 0 when it landed
|
|
216
|
+
// and nobody tested removing it: "a green result vouches for the mechanism", committed inside the work
|
|
217
|
+
// that kept finding that trap elsewhere.
|
|
218
|
+
//
|
|
219
|
+
// So: the losses were REAL and their cause is UNKNOWN. NAT is excluded by topology; the worker's own
|
|
220
|
+
// `keepAliveTimeout` and `requestTimeout` were excluded by measurement; the boxes' NIC power settings
|
|
221
|
+
// were already correct. What is left is the Wi-Fi association itself or something transient, and this
|
|
222
|
+
// comment will not guess again.
|
|
223
|
+
//
|
|
224
|
+
// Item A is unaffected and that is the useful part: the async path holds no long connection at all, so
|
|
225
|
+
// it is immune whatever the mechanism was. A STRUCTURAL FIX DOES NOT DEPEND ON THE DIAGNOSIS BEING
|
|
226
|
+
// RIGHT, which is the strongest argument there is for preferring one.
|
|
227
|
+
req.on("socket", (socket) => {
|
|
228
|
+
socket.setKeepAlive(true, KEEPALIVE_DELAY_MS);
|
|
229
|
+
// Nagle would batch the request itself; a capture POST is one small write followed by a wait, so
|
|
230
|
+
// there is nothing to batch and delaying it only adds latency to the request that starts the work.
|
|
231
|
+
socket.setNoDelay(true);
|
|
232
|
+
});
|
|
233
|
+
req.on("error", failWith);
|
|
234
|
+
if (payload !== null)
|
|
235
|
+
req.write(payload);
|
|
236
|
+
req.end();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/** @param {string | null} payload */
|
|
240
|
+
function requestHeaders(payload) {
|
|
241
|
+
if (payload === null)
|
|
242
|
+
return { accept: "application/json" };
|
|
243
|
+
return {
|
|
244
|
+
accept: "application/json",
|
|
245
|
+
"content-type": "application/json",
|
|
246
|
+
// Explicit, so the worker never has to read a chunked body. Byte length, not character count:
|
|
247
|
+
// a non-ASCII task string would otherwise under-declare and the worker would wait for bytes
|
|
248
|
+
// that never arrive.
|
|
249
|
+
"content-length": Buffer.byteLength(payload),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* @param {import("node:http").IncomingMessage} res
|
|
254
|
+
* @returns {Promise<string>}
|
|
255
|
+
*/
|
|
256
|
+
function collect(res) {
|
|
257
|
+
return new Promise((resolve, reject) => {
|
|
258
|
+
let text = "";
|
|
259
|
+
res.setEncoding("utf8");
|
|
260
|
+
res.on("data", (/** @type {string} */ chunk) => { text += chunk; });
|
|
261
|
+
res.on("end", () => resolve(text));
|
|
262
|
+
res.on("error", reject);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Parsed JSON, or undefined — a worker error page is not a parse failure worth throwing over.
|
|
267
|
+
* @param {string} text
|
|
268
|
+
*/
|
|
269
|
+
function parse(text) {
|
|
270
|
+
try {
|
|
271
|
+
return JSON.parse(text);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
//# sourceMappingURL=worker-http.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker-http.mjs","sourceRoot":"","sources":["../src/worker-http.mjs"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Linear-interpolated quantile. Fine for the sample sizes here and has no dependencies. */
|
|
2
|
+
/** @param {number[]} values @param {number} q @returns {number|null} */
|
|
3
|
+
export function quantile(values: number[], q: number): number | null;
|
|
4
|
+
/**
|
|
5
|
+
* The shape of one worker's samples.
|
|
6
|
+
*
|
|
7
|
+
* `iqr` is the spread that matters: half the runs fall inside it, so two workers whose IQRs overlap are
|
|
8
|
+
* not distinguishable by these samples however different their medians look.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* @param {number[]} values
|
|
12
|
+
* @returns {Summary|null}
|
|
13
|
+
*/
|
|
14
|
+
export function describe(values: number[]): Summary | null;
|
|
15
|
+
/**
|
|
16
|
+
* Compare workers and say, conservatively, whether a difference is supported.
|
|
17
|
+
*
|
|
18
|
+
* The bar is deliberately crude and deliberately strict: medians must differ by more than the wider
|
|
19
|
+
* worker's IQR, and the two IQRs must not overlap. That is a weaker claim than a significance test and a
|
|
20
|
+
* much stronger one than "the mean was higher", which is what produced every wrong conclusion this
|
|
21
|
+
* module was written after. When the samples do not clear it, the verdict is "not distinguishable" —
|
|
22
|
+
* which is a real answer, not a failure to find one.
|
|
23
|
+
*
|
|
24
|
+
* @param {Record<string, number[]>} samplesByWorker
|
|
25
|
+
* @returns {{ stats: Record<string, Summary>, slowest: string|null, fastest: string|null,
|
|
26
|
+
* distinguishable: boolean, verdict: string }}
|
|
27
|
+
*/
|
|
28
|
+
export function compareWorkers(samplesByWorker: Record<string, number[]>): {
|
|
29
|
+
stats: Record<string, Summary>;
|
|
30
|
+
slowest: string | null;
|
|
31
|
+
fastest: string | null;
|
|
32
|
+
distinguishable: boolean;
|
|
33
|
+
verdict: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Recovery rate per worker — the reliability statistic, as opposed to the speed one.
|
|
37
|
+
*
|
|
38
|
+
* Counted from the worker's own `vitals.recoveries` across the run, because a guest whose screen reader
|
|
39
|
+
* keeps dying still returns evidence and still records zero failures. Speed and reliability are separate
|
|
40
|
+
* questions and a fast worker can be the unreliable one.
|
|
41
|
+
*
|
|
42
|
+
* @param {Record<string, {recoveries: number, captures: number}>} deltas
|
|
43
|
+
* @returns {Record<string, number | null>} null where the worker captured nothing — "no idea", which
|
|
44
|
+
* must not be confused with a rate of zero ("perfectly reliable").
|
|
45
|
+
*/
|
|
46
|
+
export function recoveryRates(deltas: Record<string, {
|
|
47
|
+
recoveries: number;
|
|
48
|
+
captures: number;
|
|
49
|
+
}>): 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
|
+
};
|
|
66
|
+
//# sourceMappingURL=worker-stats.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker-stats.d.mts","sourceRoot":"","sources":["../src/worker-stats.mjs"],"names":[],"mappings":"AAuCA,4FAA4F;AAC5F,wEAAwE;AACxE,iCADY,MAAM,EAAE,KAAiB,MAAM,GAAc,MAAM,GAAC,IAAI,CAOnE;AAED;;;;;GAKG;AACH;;;GAGG;AACH,iCAHW,MAAM,EAAE,GACN,OAAO,GAAC,IAAI,CAexB;AAOD;;;;;;;;;;;;GAYG;AACH,gDAJW,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,GACtB;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,OAAO,EAAE,MAAM,GAAC,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,GAAC,IAAI,CAAC;IAC3E,eAAe,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAqCzD;AAED;;;;;;;;;;GAUG;AACH,sCAJW,MAAM,CAAC,MAAM,EAAE;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAC,CAAC,GACpD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAUzC;;;;;;;;sBAtHY;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAC/D,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAC"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Robust statistics for comparing workers, and an explicit refusal to call a difference real when the
|
|
4
|
+
* samples do not support it.
|
|
5
|
+
*
|
|
6
|
+
* This module exists because of a specific, repeated mistake: concluding from one measurement. Over one
|
|
7
|
+
* session a 2x difference between two guests was attributed, in turn, to the display being blanked, to
|
|
8
|
+
* in-memory accumulation, to background CPU, to Edge's launch, to the Edge profile, to the sweep, and to
|
|
9
|
+
* Edge's startup boost. Every one of those was a single measurement, and every one was wrong.
|
|
10
|
+
*
|
|
11
|
+
* Two things caused that, and both are fixed here rather than in a comment.
|
|
12
|
+
*
|
|
13
|
+
* **Means lied.** One 63 s outlier in an eight-run sample moved the mean by 5 s and made a healthy
|
|
14
|
+
* worker look broken. Capture time has a long right tail by nature — a mute screen reader costs ~86 s
|
|
15
|
+
* against a normal ~12 s — so the mean measures how often the tail was hit, not what a capture costs.
|
|
16
|
+
* The median and the interquartile range do not move when one run goes bad.
|
|
17
|
+
*
|
|
18
|
+
* **Sequential sampling confounded.** Measuring worker A for five minutes and then worker B for five
|
|
19
|
+
* minutes attributes any drift in the host during those ten minutes to the difference between the
|
|
20
|
+
* workers. Interleaving round-robin makes drift common to all of them.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* One worker's samples, summarised.
|
|
24
|
+
*
|
|
25
|
+
* Named as a typedef rather than repeated inline because four functions pass it around, and this module's
|
|
26
|
+
* whole purpose is refusing to claim a difference the samples do not support — so `q1`, `q3` and `iqr`
|
|
27
|
+
* travelling together, as one thing with one name, is the point rather than a formality.
|
|
28
|
+
*
|
|
29
|
+
* @typedef {{n: number, median: number, q1: number, q3: number, iqr: number,
|
|
30
|
+
* min: number, max: number}} Summary
|
|
31
|
+
*/
|
|
32
|
+
/** Below this many rounds, report the numbers but never claim a difference. */
|
|
33
|
+
const MIN_ROUNDS_FOR_A_VERDICT = 5;
|
|
34
|
+
/** @param {number[]} values @returns {number[]} */
|
|
35
|
+
const sorted = (values) => [...values].sort((a, b) => a - b);
|
|
36
|
+
/** Linear-interpolated quantile. Fine for the sample sizes here and has no dependencies. */
|
|
37
|
+
/** @param {number[]} values @param {number} q @returns {number|null} */
|
|
38
|
+
export function quantile(values, q) {
|
|
39
|
+
if (!values.length)
|
|
40
|
+
return null;
|
|
41
|
+
const s = sorted(values);
|
|
42
|
+
const position = (s.length - 1) * q;
|
|
43
|
+
const low = Math.floor(position), high = Math.ceil(position);
|
|
44
|
+
return low === high ? s[low] : s[low] + (s[high] - s[low]) * (position - low);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The shape of one worker's samples.
|
|
48
|
+
*
|
|
49
|
+
* `iqr` is the spread that matters: half the runs fall inside it, so two workers whose IQRs overlap are
|
|
50
|
+
* not distinguishable by these samples however different their medians look.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* @param {number[]} values
|
|
54
|
+
* @returns {Summary|null}
|
|
55
|
+
*/
|
|
56
|
+
export function describe(values) {
|
|
57
|
+
if (!values.length)
|
|
58
|
+
return null;
|
|
59
|
+
// Non-null by construction: `quantile` returns null only for an empty list, and the guard above has
|
|
60
|
+
// already excluded that. Written as a local rather than an assertion so the reason is stated once.
|
|
61
|
+
const q1 = /** @type {number} */ (quantile(values, 0.25));
|
|
62
|
+
const q3 = /** @type {number} */ (quantile(values, 0.75));
|
|
63
|
+
return {
|
|
64
|
+
n: values.length,
|
|
65
|
+
median: /** @type {number} */ (quantile(values, 0.5)),
|
|
66
|
+
q1, q3, iqr: q3 - q1,
|
|
67
|
+
min: Math.min(...values),
|
|
68
|
+
max: Math.max(...values),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Do two interquartile ranges overlap at all? @param {Summary} a @param {Summary} b */
|
|
72
|
+
function overlaps(a, b) {
|
|
73
|
+
return a.q1 <= b.q3 && b.q1 <= a.q3;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Compare workers and say, conservatively, whether a difference is supported.
|
|
77
|
+
*
|
|
78
|
+
* The bar is deliberately crude and deliberately strict: medians must differ by more than the wider
|
|
79
|
+
* worker's IQR, and the two IQRs must not overlap. That is a weaker claim than a significance test and a
|
|
80
|
+
* much stronger one than "the mean was higher", which is what produced every wrong conclusion this
|
|
81
|
+
* module was written after. When the samples do not clear it, the verdict is "not distinguishable" —
|
|
82
|
+
* which is a real answer, not a failure to find one.
|
|
83
|
+
*
|
|
84
|
+
* @param {Record<string, number[]>} samplesByWorker
|
|
85
|
+
* @returns {{ stats: Record<string, Summary>, slowest: string|null, fastest: string|null,
|
|
86
|
+
* distinguishable: boolean, verdict: string }}
|
|
87
|
+
*/
|
|
88
|
+
export function compareWorkers(samplesByWorker) {
|
|
89
|
+
/** @type {Record<string, Summary>} */
|
|
90
|
+
const stats = {};
|
|
91
|
+
for (const [worker, values] of Object.entries(samplesByWorker)) {
|
|
92
|
+
const description = describe(values);
|
|
93
|
+
if (description)
|
|
94
|
+
stats[worker] = description;
|
|
95
|
+
}
|
|
96
|
+
const names = Object.keys(stats);
|
|
97
|
+
if (names.length < 2) {
|
|
98
|
+
return { stats, slowest: null, fastest: null, distinguishable: false, verdict: "need at least two workers with samples" };
|
|
99
|
+
}
|
|
100
|
+
const rounds = Math.min(...names.map((n) => stats[n].n));
|
|
101
|
+
const byMedian = [...names].sort((a, b) => stats[a].median - stats[b].median);
|
|
102
|
+
const fastest = byMedian[0], slowest = byMedian[byMedian.length - 1];
|
|
103
|
+
const gap = stats[slowest].median - stats[fastest].median;
|
|
104
|
+
const widest = Math.max(stats[fastest].iqr, stats[slowest].iqr);
|
|
105
|
+
if (rounds < MIN_ROUNDS_FOR_A_VERDICT) {
|
|
106
|
+
return {
|
|
107
|
+
stats, slowest, fastest, distinguishable: false,
|
|
108
|
+
verdict: `only ${rounds} round(s) — too few to claim anything. Run at least ${MIN_ROUNDS_FOR_A_VERDICT}.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (gap <= widest || overlaps(stats[fastest], stats[slowest])) {
|
|
112
|
+
return {
|
|
113
|
+
stats, slowest, fastest, distinguishable: false,
|
|
114
|
+
verdict: `NOT DISTINGUISHABLE: medians differ by ${gap.toFixed(1)} but the spread is ${widest.toFixed(1)} ` +
|
|
115
|
+
"and the interquartile ranges overlap. These samples do not support a difference between the workers.",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
stats, slowest, fastest, distinguishable: true,
|
|
120
|
+
verdict: `${slowest} is slower than ${fastest} by ${gap.toFixed(1)} (medians), ` +
|
|
121
|
+
`with non-overlapping interquartile ranges over ${rounds} interleaved rounds.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Recovery rate per worker — the reliability statistic, as opposed to the speed one.
|
|
126
|
+
*
|
|
127
|
+
* Counted from the worker's own `vitals.recoveries` across the run, because a guest whose screen reader
|
|
128
|
+
* keeps dying still returns evidence and still records zero failures. Speed and reliability are separate
|
|
129
|
+
* questions and a fast worker can be the unreliable one.
|
|
130
|
+
*
|
|
131
|
+
* @param {Record<string, {recoveries: number, captures: number}>} deltas
|
|
132
|
+
* @returns {Record<string, number | null>} null where the worker captured nothing — "no idea", which
|
|
133
|
+
* must not be confused with a rate of zero ("perfectly reliable").
|
|
134
|
+
*/
|
|
135
|
+
export function recoveryRates(deltas) {
|
|
136
|
+
/** @type {Record<string, number | null>} */
|
|
137
|
+
const rates = {};
|
|
138
|
+
for (const [worker, d] of Object.entries(deltas)) {
|
|
139
|
+
rates[worker] = d.captures > 0 ? d.recoveries / d.captures : null;
|
|
140
|
+
}
|
|
141
|
+
return rates;
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=worker-stats.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker-stats.mjs","sourceRoot":"","sources":["../src/worker-stats.mjs"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,99 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a11ign/screenreader-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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.",
|
|
4
5
|
"license": "AGPL-3.0-or-later",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./health": {
|
|
13
|
+
"default": "./dist/worker-health.mjs"
|
|
14
|
+
},
|
|
15
|
+
"./capacity": {
|
|
16
|
+
"default": "./dist/host-capacity.mjs"
|
|
17
|
+
},
|
|
18
|
+
"./probe-outcome": {
|
|
19
|
+
"types": "./dist/probe-outcome.d.mts",
|
|
20
|
+
"default": "./dist/probe-outcome.mjs"
|
|
21
|
+
},
|
|
22
|
+
"./worker-http": {
|
|
23
|
+
"types": "./dist/worker-http.d.mts",
|
|
24
|
+
"default": "./dist/worker-http.mjs"
|
|
25
|
+
},
|
|
26
|
+
"./cli-flags": {
|
|
27
|
+
"default": "./dist/cli-flags.mjs"
|
|
28
|
+
},
|
|
29
|
+
"./transient-fault": {
|
|
30
|
+
"types": "./dist/transient-fault.d.mts",
|
|
31
|
+
"default": "./dist/transient-fault.mjs"
|
|
32
|
+
},
|
|
33
|
+
"./capture-client": {
|
|
34
|
+
"types": "./dist/capture-client.d.mts",
|
|
35
|
+
"default": "./dist/capture-client.mjs"
|
|
36
|
+
},
|
|
37
|
+
"./host-address": {
|
|
38
|
+
"types": "./dist/host-address.d.mts",
|
|
39
|
+
"default": "./dist/host-address.mjs"
|
|
40
|
+
},
|
|
41
|
+
"./fleet-env": {
|
|
42
|
+
"types": "./dist/fleet-env.d.mts",
|
|
43
|
+
"default": "./dist/fleet-env.mjs"
|
|
44
|
+
},
|
|
45
|
+
"./fleet-consistency": {
|
|
46
|
+
"types": "./dist/fleet-consistency.d.mts",
|
|
47
|
+
"default": "./dist/fleet-consistency.mjs"
|
|
48
|
+
},
|
|
49
|
+
"./worker-code-check": {
|
|
50
|
+
"types": "./dist/worker-code-check.d.mts",
|
|
51
|
+
"default": "./dist/worker-code-check.mjs"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"bin": {
|
|
55
|
+
"a11ign-doctor": "./dist/doctor.mjs",
|
|
56
|
+
"a11ign-worker-code": "./dist/check-worker-code.mjs",
|
|
57
|
+
"a11ign-worker-compare": "./dist/compare-workers.mjs",
|
|
58
|
+
"a11ign-worker-ctl": "./src/local-worker/worker-ctl.sh",
|
|
59
|
+
"a11ign-worker-deploy": "./dist/deploy-worker.mjs"
|
|
60
|
+
},
|
|
61
|
+
"files": [
|
|
62
|
+
"dist",
|
|
63
|
+
"src/local-worker",
|
|
64
|
+
"src/provisioning",
|
|
65
|
+
"README.md",
|
|
66
|
+
"LICENSE"
|
|
67
|
+
],
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"@a11ign/judge": "0.1.0",
|
|
70
|
+
"@a11ign/screenreader-worker": "0.1.0"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@a11ign/evidence": "0.1.0",
|
|
74
|
+
"yaml": "^2.9.1",
|
|
75
|
+
"typescript": "^6.0.3"
|
|
76
|
+
},
|
|
77
|
+
"engines": {
|
|
78
|
+
"node": ">=20"
|
|
79
|
+
},
|
|
80
|
+
"publishConfig": {
|
|
81
|
+
"access": "public"
|
|
82
|
+
},
|
|
83
|
+
"repository": {
|
|
84
|
+
"type": "git",
|
|
85
|
+
"url": "git+https://github.com/a11ign/screenreader-fleet.git",
|
|
86
|
+
"directory": "packages/worker-fleet"
|
|
87
|
+
},
|
|
88
|
+
"homepage": "https://github.com/a11ign/screenreader-fleet",
|
|
89
|
+
"keywords": [
|
|
90
|
+
"accessibility",
|
|
91
|
+
"a11y",
|
|
92
|
+
"nvda",
|
|
93
|
+
"screen-reader",
|
|
94
|
+
"windows",
|
|
95
|
+
"utm",
|
|
96
|
+
"vm"
|
|
97
|
+
],
|
|
98
|
+
"scripts": {}
|
|
99
|
+
}
|