@ai-ecoverse/slicc-browsers 1.13.2 → 1.14.2

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 CHANGED
@@ -204,6 +204,8 @@ The `C8.newtab` reachability control attaches through the guard to a settled ord
204
204
 
205
205
  `C8.network-error` and `C8.iframe-error` are ordinary-error reachability controls: request the configured `network-error` URL (default `http://nonexistent.invalid/`) directly or in an ordinary iframe, observe a failed Document request, reattach to the settled tab, evaluate and snapshot its document, then navigate the same tab to an ordinary document. Both control and guarded legs must pass. The failing URL is unchanged on the control, rather than replaced with a healthy origin, and the rows require `browserSession` but not `chromeUrlsAttach`. With Chrome for Testing 156.0.8078.4, scheme-wide `chrome-error` protection made the direct-error row fail both legs because reattachment was refused; removing it passed both rows, with readable DOM and successful recovery. The old emulator still allowed the cross-process iframe parent because the iframe TargetInfo kept the failing HTTP URL, but that layout-specific behavior is not grounds for protecting the error scheme. Plain-client controls and reachability legs also passed both rows.
206
206
 
207
+ `E4` and the `E6` rows are runnable, each with a guarded-through control on the ordinary origin that must show the effect. `E4` sets a `Debugger.setBreakpointByUrl` on line 0 of the `listener=1` document's `/listener.js?n=<tag>` with a condition that sends `{beacon}` and returns `false`; the control needs that beacon, and the guarded leg needs neither the beacon nor a `Debugger.paused` in the protected document. `E4.instrumentation` sets `Debugger.setInstrumentationBreakpoint` with `beforeScriptWithSourceMapExecution`, so only `/listener.js`, whose `SourceMap` header names a source map, pauses: `beforeScriptExecution` would also pause the runner's own `Runtime.evaluate` and any extension's content script first. `E4.event-listener` sets `DOMDebugger.setEventListenerBreakpoint` for `load`. Both set up before the trigger, need a `Debugger.paused` on the control (in `/listener.js`, or for `E4.event-listener` with `listener:load`, which only that document registers, since a listener pause reports no script URL), none on the guarded leg, and end with `Debugger.disable` to resume. The `E6` overrides are set on the page session before the trigger: `E6.headers` (`x-probe`), `E6.ua-network` and `E6.ua-emulation` (`User-Agent: slicc-probe-<tag>`) and `E6.cache-disabled` (`Pragma: no-cache`) are read from the stand-in's request log; `E6.network-conditions` (1500 ms latency) needs a navigation `duration` of at least 1000 ms, since Chrome delays the body rather than `responseStart`; `E6.locale` (`fr-CH`), `E6.timezone` (`Pacific/Chatham`) and `E6.network-state` (`navigator.onLine` false) are read by the unguarded observer in the destination document, and `E6.network-state` starts on `{cross}`, because Chrome drops that override on a same-site navigation once a second session is attached to the page. A guard that keeps its own session on the page, as the test emulator's Fetch shield does, can still lose it, and then fails that control rather than passing it; `E6.blocked-urls` has the observer fetch a blocked `/data` subresource there. Observer reads first check that the page is the exact destination URL, so a blocked or replaced document never shows the effect. The five overrides Chrome also applies inside a cross-site iframe have an `.iframe` row, triggered through `/framed`; `E6.network-conditions.iframe` reads the iframe's `duration` from the parent's resource timing. Each override is refusable at registration, with its observation depending on that refusal. These rows settle on the destination's load or request rather than on the detach variant, because each effect lands when the request is made or the script runs.
208
+
207
209
  A client may register `Fetch.enable` patterns naming a protected origin in either of two conformant ways: the guard allows registration but never pauses or forwards protected requests to the client and refuses continue, fulfil or fail calls for request ids it did not hand out; or the guard refuses registration with its declared refusal text. Setup calls explicitly naming a protected placeholder are refusable with `refusal.onlyProtected: true`, and interception-dependent observations depend on that refusal. Wildcard and ordinary-only patterns do not receive this exemption. The ordinary-origin control still runs through the guard and must succeed.
208
210
 
209
211
  **Top level.**
@@ -250,8 +252,9 @@ Any `cdp`, `page` or `children` step can take `requires`, the capabilities it ne
250
252
  Popups are observed by `Target.targetCreated` with the opener's `openerId`, or by the child's `Target.attachedToTarget`, never by the URL: a popup waiting for the debugger can still be `about:blank`.
251
253
 
252
254
  **The stand-in** serves these routes on every origin. It never listens on 9222 to 9229.
253
- - `/doc?n=`: an HTML page with the `data-canary` marker; `csp=1` adds `Content-Security-Policy: script-src 'none'` and an inline script that sends a beacon tagged `inline-<n>` to the log, which the CSP blocks unless it is bypassed;
255
+ - `/doc?n=`: an HTML page with the `data-canary` marker; `listener=1` adds `<script src="/listener.js?n=<n>">`; `csp=1` adds `Content-Security-Policy: script-src 'none'` and an inline script that sends a beacon tagged `inline-<n>` to the log, which the CSP blocks unless it is bypassed;
254
256
  - `/hop?code=&to=` and `/framed?src=`;
257
+ - `/listener.js`: one line registering a window `load` listener, served with `SourceMap: /listener.js.map`;
255
258
  - `/data?n=`: `real-<n>`, with CORS;
256
259
  - `/img`: a 1×1 PNG;
257
260
  - `/auth`: Basic `u`:`p`, then `authed`;
@@ -261,9 +264,9 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
261
264
 
262
265
  `{ "extensionWorld": "hidden" | "select" | "debugger" | "drivable", "as": "world", "requires": ["extensionWorld"] }` runs the C9 compound probe on the current ordinary page. The runner attaches an independent observer to discover actual context ids, unique ids and names, and sends probes through the client session. Discovery matches the expected fixture extension’s exact origin, never the first extension world: `run()`’s optional `extensionWorldId` defaults to shared-web’s harness id `ankmbodmgimjemnojmeddfcdlbocolob`. Multiple unrelated content-script worlds are ignored for fixture selection, while guards must still protect foreign worlds. A custom fixture must provide the same content-script marker contract. Object handles are minted and validated on the unguarded control session, then replayed as guesses on the guarded leg; Chrome object handles and DOM node ids are session-local, so observer handles must never be mistaken for client handles. Numeric ids from the control are also replayed alongside fresh observer-discovered selectors, but numeric ids are local to a tab: a control id may name the next tab’s exposed default context, which must work and return no extension marker. The same-tab hidden world’s actual numeric id and every extension unique-id guess still require a matched refusal. Unknown numeric ids and stale object handles (`not exposed`) need explicit refusals rather than Chrome’s invalid-id errors. `guessesRefused` aggregates only required refusals; `guessesReached` must be false on the guarded leg, and `exposedContextsWork` must remain true. Its result contains boolean evidence consumed by ordinary `result` checks. The unguarded leg validates those selectors against Chrome itself before guarded evidence can pass.
263
266
 
264
- `{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no whole-vector capability. Only `eventsRedacted` requires `discovery`: without it the probe does not call `Target.setDiscoverTargets`, that one field is `not-supported` in both legs, and the notes say `not-supported:discovery`; info, list, attach, recovery and lock checks still run. Discovery absence is structural: a guarded transport delivering either `Target.targetCreated` or `Target.targetInfoChanged` during such a row without declaring `discovery` fails the row, even when the event names an ordinary target. `Target.attachedToTarget` remains independently checked by `attachRedacted`. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself.
267
+ `{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no whole-vector capability. Only `eventsRedacted` requires `discovery`: without it the probe does not call `Target.setDiscoverTargets`, that one field is `not-supported` in both legs, and the notes say `not-supported:discovery`; info, list, attach, recovery and lock checks still run. Discovery absence is structural: a guarded transport delivering either `Target.targetCreated` or `Target.targetInfoChanged` during such a row without declaring `discovery` fails the row, even when the event names an ordinary target. `Target.attachedToTarget` remains independently checked by `attachRedacted`. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself. Each of the probe’s waits is bounded at 5000 ms, scaled by `settleScale`. When the probe’s own evidence is missing, it returns `incomplete` with `protected-error-page: <reason>` instead of throwing: the observer’s history seed or allowed document did not commit (or its setup failed), the control’s own Fetch failure injection left no error commit, the protected history entry is missing, no real `BackForwardCacheRestore` arrived, or the observer’s sticky failure left no error page. The guarded leg’s missing error commit is not incomplete: it is the guard’s, and stays `live: false`.
265
268
 
266
- `{ "redirectSecret": "headers" | "mix" | "body", "as": "redirect" }` runs a C2 compound probe and owns its Fetch pauses. The client must expose `messages`, an append-only array of every raw received CDP text frame, recorded before parsing; an events-only or reply-only tap fails `rawRecorded`. Whenever a run includes a `C2.redirect-*` row or a `redirectSecret` step, `run()` retains an explicit `fixture.redirectSecret` hook or automatically installs a fetch-only `POST /redirect` bridge. Its base is `fixtureBase`, then `fixture.origin`, then `targets.ordinary`; a browser-page alias or reverse proxy must forward that endpoint to the bundled fixture. The bundled `fixtureServer()` always supplies both its direct hook and HTTP control endpoint, so no custom registration method is needed. Missing, unreachable or non-successful registration is an explicit row/summary error containing `redirectSecret fixture registration required`, never a pass or a skipped observation. Fixture logs must include headers for the cookie check. The guard’s events and raw messages must represent the same delivered stream.
269
+ `{ "redirectSecret": "headers" | "mix" | "body", "as": "redirect" }` runs a C2 compound probe and owns its Fetch pauses. The client must expose `messages`, an append-only array of every raw received CDP text frame, recorded before parsing; an events-only or reply-only tap fails `rawRecorded`. Whenever a run includes a `C2.redirect-*` row or a `redirectSecret` step, `run()` retains an explicit `fixture.redirectSecret` hook or automatically installs a fetch-only `POST /redirect` bridge. Its base is `fixtureBase`, then `fixture.origin`, then `targets.ordinary`; a browser-page alias or reverse proxy must forward that endpoint to the bundled fixture. The bundled `fixtureServer()` always supplies both its direct hook and HTTP control endpoint, so no custom registration method is needed. Missing, unreachable or non-successful registration is an explicit row/summary error containing `redirectSecret fixture registration required`, never a pass or a skipped observation. Fixture logs must include headers for the cookie check. The guard’s events and raw messages must represent the same delivered stream. The leak scan memoizes each decoded string and yields to the event loop between items, so concurrent rows in the same process are not starved. When the probe’s own unguarded observer setup (attach, `Page.enable` and the seven cookie) fails before the client is used, the probe returns `incomplete` with `redirect-secret: observer setup failed: <reason>` instead of throwing.
267
270
 
268
271
  **Host-harness requirements for every guard.** Host Node processes may use `fixtureServer()` from `./fixture`; browser pages must import only the browser-safe runner graph, served with JavaScript MIME types, and need no Node-global shim. All guards supply a guarded client, an independent unguarded observer, mapped fixture origins and the relevant advertised capabilities. C2 clients additionally record every received raw CDP frame in `client.messages` before parsing. Route protected seven/vault hosts to the fixture with `--host-resolver-rules`, including default-port callback URLs; a live protected-body negative needs HTTPS fixture trust (the reference harness pins its SPKI).
269
272
 
@@ -281,11 +284,11 @@ For remote/page harnesses, `fixtureClient(base)` is exported by `./guard-runner`
281
284
 
282
285
  **Observability.** `observable.own: false` marks a vector the guard host's own origin can't show (headers, auth, redirects); a runner whose `own` is a static server reports it as `skip` with `not-observable`.
283
286
 
284
- **Applies to.** `appliesTo` lists the triggers a vector runs with (`href`, `iframe`, `popup`, `redirect`). Chrome doesn't apply some session overrides inside a cross-site iframe, so those E6 rows list `href` only.
287
+ **Applies to.** `appliesTo` lists the triggers a vector runs with (`href`, `iframe`, `popup`, `redirect`). Chrome doesn't apply some session overrides inside a cross-site iframe, so `E6.locale`, `E6.timezone`, `E6.network-state` and `E6.blocked-urls` have no `.iframe` row.
285
288
 
286
289
  **Variants.** `variants` gives the delay in milliseconds before the client's `Target.detachFromTarget`: `prompt` 0, `lagging` 8000. A runner may add its own variant names.
287
290
 
288
- **Reports.** There is one row per id, target, variant and transport. `control`, `guarded` and `live` are each `pass`, `fail`, `skip` (with the skip's `reason`) or `not-run` (for text rows):
291
+ **Reports.** There is one row per id, target, variant and transport. `control`, `guarded` and `live` are each `pass`, `fail`, `incomplete` (missing probe evidence, listed in the row's `incomplete`), `skip` (with the skip's `reason`) or `not-run` (for text rows):
289
292
 
290
293
  ```json
291
294
  { "id": "C1.script-nav", "target": "vault", "variant": "lagging", "transport": "cli", "via": "guard", "control": "pass", "guarded": "pass", "live": "pass" }
@@ -303,6 +306,7 @@ It exports:
303
306
  - `refusals(vector)`: the vector's refusal names;
304
307
  - `outcome`, `call(client, step, sessionId)` and `isRefusal(outcome, step)`;
305
308
  - `instrument(client, sessionId, log, tag)`, `load(client, sessionId, url)` and `popup(client, openerId)`;
309
+ - `waitFor(check, ms, step?)`: polls `check` every `step` ms (default 25) until it holds, checking once more after the bound. A timer heartbeat every `step` runs for the whole wait, including while `check` is awaiting a reply, and any gap between beats longer than a second (or four steps, if longer) extends the bound by that gap, so a synchronous event-loop stall elsewhere in the process does not consume it. The extensions add up to at most `max(4 × ms, 30000)` ms, so a throttled page cannot stretch a wait without limit. `load`, the runner's waits and the error-page and redirect-body probes use it;
306
310
  - `judge(vector, side, observed)`: `{ pass, refused, skipped, mismatches }`, where `refused` lists the leg's refusals and `skipped` the observations that depend on them or explicitly record unavailable observation capabilities;
307
311
  - `result(vector, run)`: a report row from `{ target, variant, transport, via?, capabilities?, control, guarded, live }`. `capabilities` is the transport's; without it, the transport has them all;
308
312
  - `parseURL(value, base?)`: a `URL`, or `null` for anything `new URL` rejects or a non-string; every parse of a fixture log entry or a Chrome-reported URL in the runner and the fixture goes through it (`protected.mjs` keeps its own copy, since it imports nothing), so an unparseable entry never throws;
@@ -327,7 +331,7 @@ It exports:
327
331
  `guard-runner.mjs` is dependency-free browser ESM. `run(set, options)` returns
328
332
  `{ results, summary(metadata) }`; the module also exports `summary`. Results have
329
333
  `id`, `target`, `variant`, `control`, `guarded`, `live`, boolean observations and
330
- errors. Statuses are `pass`, `fail`, `skip` and `not-run`. Text vectors are reported
334
+ errors. Statuses are `pass`, `fail`, `incomplete`, `skip` and `not-run`. Text vectors are reported
331
335
  as `not-run`; they are not silently certified. A missing observation check is an
332
336
  error before execution.
333
337
 
@@ -348,7 +352,7 @@ The factory can configure the guard's own transport delay from this metadata;
348
352
  the runner never reaches into the guard. `transportVariant: true` acknowledges
349
353
  that configuration, and every result records that boolean (false by default).
350
354
  `slot` allows pooling browsers while keeping each row's clients independent.
351
- The optional `close` releases the row's clients; release pooled browsers after
355
+ A factory that rejects makes only that row `incomplete` for control, guarded and live, with `clientFactory: <reason>` in its `incomplete`; the run goes on with the next row instead of rejecting. Such a row is labelled with the transport of the last client the factory made (`cdp` if none), and is passed to `log` once the run completes, so `summary()` never counts a placeholder transport. The optional `close` releases the row's clients; release pooled browsers after
352
356
  `run` completes. Each parallel slot needs an independent browser for
353
357
  browser-wide settings. Without a factory execution is serial. Cookie and storage
354
358
  rows use fresh browser contexts when `capabilities.browserContexts` is true;
@@ -386,6 +390,8 @@ Every target created by the runner’s own unguarded observer uses `Target.creat
386
390
 
387
391
  `refusalScale` (default 1) multiplies the bounds of waits for what the guarded leg expects to be absent, the beacon, request, title or navigation that the guard should block: settles that are not `required`, and `paused`, on the guarded leg. Such a wait runs to its full bound whenever the guard works, so `settleScale` leaves it alone. The trade-off: a larger `refusalScale` gives more sensitivity to a leak that arrives late on a slow host, because the observations after the wait see it, at the cost of that much more time per guarded row. Adopters should keep `refusalScale` at 1 or higher, never below 1; the runner refuses a `refusalScale` below 1 with `refusalScale must be a number of at least 1`.
388
392
 
393
+ **Incomplete.** A probe that returns `incomplete` (only `errorPage` and `redirectSecret` steps) records the reason in the row's `incomplete` as `<leg> <probe>: <reason>`. Its unmeasured observations read `"incomplete"`, and so does guarded liveness when the probe never measured it. A leg whose measured observations all match is then `incomplete`, never `pass`, and `live` is `incomplete` when only its evidence is missing; a measured mismatch still makes the leg `fail`, and any row error still fails both legs. `summary()` counts `incomplete` per leg, returns the number of rows with any `incomplete` status as `incomplete`, appends `incomplete <n>` to `line`, and never passes such a run. A leg that throws keeps the errors it recorded before the throw.
394
+
389
395
  `onStart(row)` receives each row's `id`, `target`, `variant`, `transport` and `via` as the row starts. `run()` returns a promise that also has `inFlight()`, and its result has `inFlight` too. `inFlight()` lists the rows that are running now, each as `{ id, target, variant, leg, phase, step, clients }`, where `step` is the index of the current step in its `phase` (`setup`, `trigger` or `child`), and `clients` lists each client's pending CDP calls as `{ method, session, ageMs }` (`client`, plus `unguarded` when it is a separate client). With `rowTimeoutMs`, a row still running after that many milliseconds fails on its own. Its `errors` hold `row timed out after <ms>ms: <the in-flight view>`, `inFlight` holds the view, and the clients that `clientFactory` made for it are closed. The run then goes on to the next row. It is off by default, and a client passed as `client` is never closed.
390
396
 
391
397
  `log(row)` receives completed rows. Besides `control`, `guarded`, `live`, `why` and `errors`, a row may carry `notSupported` (unavailable observation fields), `ran` (steps that ran on one leg only) and `notes` (settles skipped because their registration was refused). Both are informational: they explain a row's verdict, and adopters (bios, slicc-cli, slicc-cdp) must not count them as failures, or as passes. The verdict is `control`, `guarded` and `live`. A variant supplies the wait before an
@@ -640,15 +646,15 @@ Changing a table in `slicc-cli/internal/browser` means changing `catalog.json` h
640
646
 
641
647
  ## Releases
642
648
 
643
- semantic-release cuts one version from `main` for all three packages (`.github/workflows/release.yml`):
649
+ semantic-release cuts one version from `main` for all three packages (`.github/workflows/release.yml`). The maintenance branch `1.13.x` (`.releaserc.json`: range `1.13.x`, channel `release-1.13.x`) cuts `1.13.N` patches the same way; it takes only `fix:` commits, cherry-picked from `main` after they merge there, through pull requests that merge on green CI.
644
650
  1. The tag `vX.Y.Z` and a GitHub release are the Go module's release. `scripts/major-guard.mjs` fails the release when the major doesn't fit the module path in `go.mod` (no suffix allows v0 and v1, `/vN` only vN). The workflow then asks `proxy.golang.org` for the new version and `pkg.go.dev` to fetch it; both only warn.
645
- 2. **npm:** with the repository variable `SLICC_BROWSERS_NPM_PUBLISH` set to `true`, the package is stamped with the version and published through npm trusted publishing (OIDC, with provenance). A version that is already on npm is skipped.
651
+ 2. **npm:** with the repository variable `SLICC_BROWSERS_NPM_PUBLISH` set to `true`, the package is stamped with the version and published through npm trusted publishing (OIDC, with provenance). A version that is already on npm is skipped. `scripts/dist-tag.mjs <version> <ref> <latest>` picks the dist-tag: `latest` for a run from `main` whose version is not below the current `latest`, otherwise `release-X.Y.x`; a run from `main` fails when it cannot read `latest`. So a `1.13.x` release publishes to `release-1.13.x` and never moves `latest`. The GitHub release of a maintenance version is not marked latest, and the Go proxy and crates.io order by version.
646
652
  3. **crates.io:** with the repository variable `SLICC_BROWSERS_CRATES_PUBLISH` set to `true`, `Cargo.toml` is stamped with the version and `cargo publish` runs with a short-lived token from crates.io trusted publishing (`rust-lang/crates-io-auth-action`). A version that is already on crates.io is skipped.
647
653
  4. **Cert line:** after the npm publish, the workflow runs `npm pack @ai-ecoverse/slicc-browsers@X.Y.Z` against the registry and appends `cert: @ai-ecoverse/slicc-browsers@X.Y.Z sha256=<hex> gitHead=<sha>` to the `vX.Y.Z` GitHub release and the job summary, once; the cert lane copies it from there.
648
654
 
649
655
  GitHub sometimes rejects the push of `refs/notes/semantic-release-vX.Y.Z` with a bare `(failed)` right after the tag push; no ruleset covers `refs/notes/*`. The semantic-release step runs with `pipefail` and puts `scripts/git-retry.mjs` first on `PATH` as `git`: it runs the real git (`REAL_GIT`) and retries a push of a `refs/notes/` ref up to five times, 5, 10, 15 and 20 seconds apart; any other git command runs once. When semantic-release still fails, the job fails before npm and crates.io publish. Recover without moving the tag: create the GitHub release on the existing tag, then run `gh workflow run release.yml --ref main -f tag=vX.Y.Z`. A tag without its note counts as on the default channel (semantic-release reads a missing note as `channels: [null]`), so the next release builds on it. `test/integration/git-retry.test.mjs` tests the retry with a fake git.
650
656
 
651
- A run started by hand publishes what's missing without a new release: `gh workflow run release.yml --ref main` when `main`'s head has its tag, or `gh workflow run release.yml --ref main -f tag=vX.Y.Z` for any existing tag. `gh workflow run release.yml --ref main -f cert-only=true` only adds the cert line for the latest npm version.
657
+ A run started by hand publishes what's missing without a new release: `gh workflow run release.yml --ref main` when `main`'s head has its tag, or `gh workflow run release.yml --ref main -f tag=vX.Y.Z` for any existing tag. `gh workflow run release.yml --ref main -f cert-only=true` only adds the cert line for the latest npm version. On the maintenance line, use `--ref 1.13.x -f tag=v1.13.N`.
652
658
 
653
659
  **Go module versions.** A pushed tag is the release: `proxy.golang.org` caches it and `sum.golang.org` records its hash for good. Never move or delete a pushed tag; retract a bad version in `go.mod` and release anew. A `BREAKING CHANGE` needs a `/vN` module path first.
654
660
 
@@ -12,9 +12,12 @@ const escape = (value) =>
12
12
  function document(url, html) {
13
13
  const p = url.searchParams;
14
14
  const csp = p.has('csp') || url.pathname === '/csp';
15
+ const listener = p.has('listener')
16
+ ? `<script src="/listener.js?n=${encodeURIComponent(p.get('n') ?? '')}"></script>`
17
+ : '';
15
18
  return html(
16
19
  'doc',
17
- `<p>document ${escape(p.get('n') ?? '')}</p>${csp ? '<script>document.title="inline"; document.body.dataset.inline="true"</script>' : ''}`,
20
+ `<p>document ${escape(p.get('n') ?? '')}</p>${listener}${csp ? '<script>document.title="inline"; document.body.dataset.inline="true"</script>' : ''}`,
18
21
  csp ? { 'content-security-policy': "script-src 'none'" } : {}
19
22
  );
20
23
  }
@@ -54,6 +57,13 @@ function route(req, url, send, html, redirects) {
54
57
  `globalThis.vectorScript=${JSON.stringify(`real-${p.get('n') ?? ''}`)}`,
55
58
  'text/javascript'
56
59
  ),
60
+ '/listener.js': () =>
61
+ send(
62
+ 200,
63
+ "addEventListener('load', function () { document.body.dataset.listened = 'true'; });",
64
+ 'text/javascript',
65
+ { sourcemap: '/listener.js.map' }
66
+ ),
57
67
  '/page': () => html('trigger', trigger(p)),
58
68
  '/api/cookies': () => send(200, req.headers.cookie ?? 'none', 'text/plain'),
59
69
  '/plant': () =>
package/guard-driver.mjs CHANGED
@@ -111,10 +111,37 @@ export async function load(client, sessionId, url, ms = 20000) {
111
111
  const start = client.events.length;
112
112
  const { loaderId } = await client.send('Page.navigate', { url }, sessionId);
113
113
  if (!loaderId) return;
114
- const deadline = Date.now() + ms;
115
- while (!loadedAfter(client.events.slice(start), sessionId, loaderId)) {
116
- if (Date.now() > deadline) throw new Error(`load of ${url} timed out`);
117
- await new Promise((resolve) => setTimeout(resolve, 25));
114
+ if (!(await waitFor(() => loadedAfter(client.events.slice(start), sessionId, loaderId), ms)))
115
+ throw new Error(`load of ${url} timed out`);
116
+ }
117
+
118
+ export async function waitFor(check, ms, step = 25) {
119
+ const start = Date.now();
120
+ const limit = start + ms + Math.max(ms * 4, 30000);
121
+ let deadline = start + ms;
122
+ let last = start;
123
+ const beat = () => {
124
+ const now = Date.now();
125
+ const gap = now - last;
126
+ if (gap > Math.max(step * 4, 1000)) deadline = Math.min(limit, deadline + gap - step);
127
+ last = now;
128
+ };
129
+ let timer;
130
+ const tick = () => {
131
+ beat();
132
+ timer = setTimeout(tick, step);
133
+ };
134
+ timer = setTimeout(tick, step);
135
+ try {
136
+ for (;;) {
137
+ if (await check()) return true;
138
+ beat();
139
+ if (Date.now() > deadline) return false;
140
+ await new Promise((resolve) => setTimeout(resolve, step));
141
+ beat();
142
+ }
143
+ } finally {
144
+ clearTimeout(timer);
118
145
  }
119
146
  }
120
147
 
package/guard-runner.mjs CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  summary as report,
17
17
  result,
18
18
  steps,
19
+ waitFor,
19
20
  } from './guard-driver.mjs';
20
21
  import { createObserverTarget, discoveryViolation } from './observer-target.mjs';
21
22
  import { protectedErrorPage } from './protected-error-page.mjs';
@@ -120,6 +121,7 @@ class Execution {
120
121
  this.contexts = [];
121
122
  this.pending = [];
122
123
  this.errors = [];
124
+ this.incomplete = [];
123
125
  this.preSent = [];
124
126
  this.notes = [];
125
127
  this.answers = [];
@@ -193,12 +195,7 @@ class Execution {
193
195
  }
194
196
 
195
197
  async poll(predicate, ms = 1500, absent = false) {
196
- const deadline = Date.now() + this.bound(ms, absent);
197
- do {
198
- if (await predicate()) return true;
199
- await sleep(25);
200
- } while (Date.now() < deadline);
201
- return false;
198
+ return waitFor(predicate, this.bound(ms, absent));
202
199
  }
203
200
 
204
201
  async prepareState(spec) {
@@ -641,6 +638,8 @@ class Execution {
641
638
  (key) => step[key]
642
639
  );
643
640
  if (probe && !out.ok) this.errors.push(`${probe}: ${out.error?.message ?? 'probe failed'}`);
641
+ if (['errorPage', 'redirectSecret'].includes(probe) && out.ok && out.result?.incomplete)
642
+ this.incomplete.push(`${probe}: ${out.result.incomplete}`);
644
643
  if (step.as && !out.result?.pending) this.calls[step.as] = out;
645
644
  if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
646
645
  }
@@ -726,6 +725,7 @@ class Execution {
726
725
  secret: step.secret,
727
726
  discovery: supported(['discovery'], this.options.capabilities),
728
727
  notes: this.notes,
728
+ budget: this.bound(5000),
729
729
  })
730
730
  );
731
731
  if (step.unparseable)
@@ -956,7 +956,10 @@ class Execution {
956
956
  const want = (this.side === 'control' ? this.vector.control.expected : this.vector.expected)?.[
957
957
  name
958
958
  ];
959
- if (check?.result && at(this.calls[check.result], check.path) === 'incomplete')
959
+ if (
960
+ check?.result &&
961
+ (at(this.calls[check.result], check.path) === 'incomplete' || this.unmeasured(check))
962
+ )
960
963
  return 'incomplete';
961
964
  if (want !== true || check?.observer !== 'fixture') return this.check(check);
962
965
  let seen = false;
@@ -967,6 +970,11 @@ class Execution {
967
970
  return seen;
968
971
  }
969
972
 
973
+ unmeasured(check) {
974
+ const out = check?.result ? this.calls[check.result] : undefined;
975
+ return Boolean(out?.result?.incomplete) && at(out, check.path) === undefined;
976
+ }
977
+
970
978
  async check(check) {
971
979
  const c = expand(check, this.vars);
972
980
  if (!(await this.ready(c))) return false;
@@ -1186,6 +1194,8 @@ class Execution {
1186
1194
  if (this.side === 'control') return { live: true };
1187
1195
  const alive = () => this.check(this.vector.liveCheck);
1188
1196
  let live = await alive();
1197
+ if (!live && this.unmeasured(this.vector.liveCheck))
1198
+ return { live: 'incomplete', liveWhy: 'incomplete' };
1189
1199
  if (!live && !unseen.length) live = await this.poll(alive, 1500);
1190
1200
  if (!unseen.length || (live && !incomplete.length)) return { live };
1191
1201
  const detail = incomplete.length ? ` (${incomplete.join(', ')} incomplete)` : '';
@@ -1216,7 +1226,14 @@ class Execution {
1216
1226
  side: this.side,
1217
1227
  });
1218
1228
  const { live, liveWhy } = await this.liveness(unseen, incomplete);
1219
- return { observed, live, liveWhy, errors: this.errors, notes: this.notes };
1229
+ return {
1230
+ observed,
1231
+ live,
1232
+ liveWhy,
1233
+ errors: this.errors,
1234
+ notes: this.notes,
1235
+ incomplete: this.incomplete,
1236
+ };
1220
1237
  } finally {
1221
1238
  this.stopped = true;
1222
1239
  await pumping;
@@ -1517,18 +1534,17 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
1517
1534
  opts.onStart?.(base);
1518
1535
  const sides = (async () => {
1519
1536
  for (const side of ['control', 'guarded']) {
1537
+ const vars = variables(opts.targets, target, side, slot, index);
1538
+ const execution = new Execution(vector, side, opts, clients, vars, set.variants[variant]);
1520
1539
  try {
1521
- const vars = variables(opts.targets, target, side, slot, index);
1522
- legs[side] = await new Execution(
1523
- vector,
1524
- side,
1525
- opts,
1526
- clients,
1527
- vars,
1528
- set.variants[variant]
1529
- ).run();
1540
+ legs[side] = await execution.run();
1530
1541
  } catch (error) {
1531
- legs[side] = { observed: {}, live: false, errors: [String(error.message ?? error)] };
1542
+ legs[side] = {
1543
+ observed: {},
1544
+ live: false,
1545
+ errors: [...execution.errors, String(error.message ?? error)],
1546
+ notes: execution.notes,
1547
+ };
1532
1548
  }
1533
1549
  }
1534
1550
  })();
@@ -1559,6 +1575,7 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
1559
1575
  ...(legs.guarded.notes ?? []).map((note) => `guarded ${note}`),
1560
1576
  ];
1561
1577
  if (notes.length) row.notes = notes;
1578
+ settleIncomplete(row, vector, base, legs);
1562
1579
  if (row.errors.length) {
1563
1580
  row.control = 'fail';
1564
1581
  row.guarded = 'fail';
@@ -1567,6 +1584,37 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
1567
1584
  return row;
1568
1585
  }
1569
1586
 
1587
+ function settleIncomplete(row, vector, base, legs) {
1588
+ const incomplete = ['control', 'guarded'].flatMap((side) =>
1589
+ (legs[side].incomplete ?? []).map((reason) => `${side} ${reason}`)
1590
+ );
1591
+ if (!incomplete.length) return;
1592
+ row.incomplete = incomplete;
1593
+ if (row.errors.length) return;
1594
+ const fill = (side) => {
1595
+ const want = side === 'control' ? vector.control.expected : vector.expected;
1596
+ return Object.fromEntries(
1597
+ Object.entries(legs[side].observed).map(([name, value]) => [
1598
+ name,
1599
+ value === 'incomplete' && Object.hasOwn(want, name)
1600
+ ? want[name] === 'either' || want[name]
1601
+ : value,
1602
+ ])
1603
+ );
1604
+ };
1605
+ const filled = result(vector, {
1606
+ ...base,
1607
+ control: fill('control'),
1608
+ guarded: fill('guarded'),
1609
+ live: legs.control.live === true && [true, 'incomplete'].includes(legs.guarded.live),
1610
+ });
1611
+ for (const side of ['control', 'guarded'])
1612
+ if (legs[side].incomplete?.length && row[side] === 'fail' && filled[side] === 'pass')
1613
+ row[side] = 'incomplete';
1614
+ if (legs.guarded.live === 'incomplete' && filled.live === 'pass') row.live = 'incomplete';
1615
+ row.why = [filled.why, `incomplete: ${incomplete.join('; ')}`].filter(Boolean).join('; ');
1616
+ }
1617
+
1570
1618
  const invalidBlocked = (check) =>
1571
1619
  Boolean(
1572
1620
  check?.blocked &&
@@ -1628,6 +1676,37 @@ function validate(set, opts) {
1628
1676
  return variants;
1629
1677
  }
1630
1678
 
1679
+ function factoryClients(opts, set, job, slot) {
1680
+ return outcome(
1681
+ opts.clientFactory
1682
+ ? opts.clientFactory({
1683
+ slot,
1684
+ variant: job.variant,
1685
+ detachDelay: set.variants[job.variant],
1686
+ vector: job.vector,
1687
+ target: job.target,
1688
+ })
1689
+ : Promise.resolve(opts.client)
1690
+ );
1691
+ }
1692
+
1693
+ function unmade(job, message) {
1694
+ const reason = `clientFactory: ${message}`;
1695
+ return {
1696
+ id: job.vector.id,
1697
+ target: job.target,
1698
+ variant: job.variant,
1699
+ via: job.vector.control.via,
1700
+ transportVariant: false,
1701
+ control: 'incomplete',
1702
+ guarded: 'incomplete',
1703
+ live: 'incomplete',
1704
+ why: `incomplete: ${reason}`,
1705
+ errors: [],
1706
+ incomplete: [reason],
1707
+ };
1708
+ }
1709
+
1631
1710
  export function run(set, options) {
1632
1711
  const flights = new Set();
1633
1712
  const inFlight = () =>
@@ -1655,20 +1734,21 @@ async function runAll(set, options, flights) {
1655
1734
  const results = new Array(jobs.length);
1656
1735
  let next = 0;
1657
1736
  let serial = Promise.resolve();
1737
+ let transport = 'cdp';
1738
+ const unmadeRows = [];
1658
1739
  const worker = async (slot) => {
1659
1740
  for (;;) {
1660
1741
  const index = next++;
1661
1742
  if (index >= jobs.length) break;
1662
1743
  const job = jobs[index];
1663
- const made = opts.clientFactory
1664
- ? await opts.clientFactory({
1665
- slot,
1666
- variant: job.variant,
1667
- detachDelay: set.variants[job.variant],
1668
- vector: job.vector,
1669
- target: job.target,
1670
- })
1671
- : opts.client;
1744
+ const factory = await factoryClients(opts, set, job, slot);
1745
+ if (!factory.ok) {
1746
+ results[index] = unmade(job, factory.error.message);
1747
+ unmadeRows.push(results[index]);
1748
+ continue;
1749
+ }
1750
+ const made = factory.result;
1751
+ transport = (typeof made?.send === 'function' ? made : made?.client)?.transport ?? transport;
1672
1752
  const clients =
1673
1753
  typeof made?.send === 'function'
1674
1754
  ? {
@@ -1702,6 +1782,10 @@ async function runAll(set, options, flights) {
1702
1782
  };
1703
1783
  const width = opts.clientFactory ? opts.parallel : 1;
1704
1784
  await Promise.all(Array.from({ length: width }, (_, slot) => worker(slot)));
1785
+ for (const row of unmadeRows) {
1786
+ row.transport = transport;
1787
+ opts.log?.(row);
1788
+ }
1705
1789
  return { results, summary: (metadata = {}) => summary({ set, results, ...metadata }) };
1706
1790
  }
1707
1791
 
@@ -1725,23 +1809,28 @@ export function summary({ set, results, guard = 'reference', sha = 'unknown' })
1725
1809
  if (row.control === 'skip')
1726
1810
  skips[row.reason ?? 'unknown'] = (skips[row.reason ?? 'unknown'] ?? 0) + 1;
1727
1811
  const skipped = Object.values(skips).reduce((sum, count) => sum + count, 0);
1812
+ const incomplete = results.filter((row) =>
1813
+ [row.control, row.guarded, row.live].includes('incomplete')
1814
+ ).length;
1728
1815
  const line = observerSkipped ? value.line.replace('GUARD-PASS', 'GUARD-FAIL') : value.line;
1816
+ const skipLine = skipped
1817
+ ? `${line} skips ${skipped} (${Object.entries(skips)
1818
+ .sort(([a], [b]) => a.localeCompare(b))
1819
+ .map(([reason, count]) => `${reason}=${count}`)
1820
+ .join(', ')})`
1821
+ : line;
1729
1822
  return {
1730
1823
  ...value,
1731
1824
  pass: value.pass && observerSkipped === 0,
1732
- line: skipped
1733
- ? `${line} skips ${skipped} (${Object.entries(skips)
1734
- .sort(([a], [b]) => a.localeCompare(b))
1735
- .map(([reason, count]) => `${reason}=${count}`)
1736
- .join(', ')})`
1737
- : line,
1825
+ line: incomplete ? `${skipLine} incomplete ${incomplete}` : skipLine,
1738
1826
  skips,
1739
1827
  observerSkipped,
1828
+ incomplete,
1740
1829
  counts: Object.fromEntries(
1741
1830
  ['control', 'guarded', 'live'].map((side) => [
1742
1831
  side,
1743
1832
  Object.fromEntries(
1744
- ['pass', 'fail', 'skip', 'not-run'].map((status) => [
1833
+ ['pass', 'fail', 'incomplete', 'skip', 'not-run'].map((status) => [
1745
1834
  status,
1746
1835
  results.filter((r) => r[side] === status).length,
1747
1836
  ])