@ai-ecoverse/slicc-browsers 1.11.0 → 1.12.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 CHANGED
@@ -182,7 +182,7 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
182
182
  - `C5.continue-request` and `C5.continue-response`, added next to `C5.document`;
183
183
  - the `C8` rows for privileged pages: `C8.navigate` (a page navigates to `chrome://settings`, `chrome://password-manager`, an extension page or `file:///`; refused), `C8.view-source` (the same through `view-source:`; refused), `C8.create` (`Target.createTarget` straight on those pages; refused), `C8.browser-ui-listed` (`Target.getTargets` lists no `browser_ui` target) and `C8.browser-ui-attach` (`Target.attachToTarget` by a `browser_ui` target's id; refused). All five have an unguarded control, since the guard refuses them only on the privileged target. Two cases aren't rows, because Chrome itself blocks them, so an unguarded client can't tell a guard from none: a page's `window.open('chrome://settings')`, and an `about:` alias such as `about:settings`, which `Page.navigate` and `Target.createTarget` both turn into `about:blank#blocked`. The `about:` aliases are in `protected.json` instead.
184
184
 
185
- The `C9` foreign extension isolated-world family requires `extensionWorld`: a browser hosting a foreign content-script extension on the ordinary fixture page. `C9.world-hidden` requires the page default context to remain visible after `Runtime.enable`, while the extension context and Runtime/Debugger identifiers remain hidden without cutting or detaching the session. `C9.world-select` probes numeric and unique context selectors, object and argument handles, promises, properties, named bindings and DOM node resolution; each command selecting the hidden world must fail with the `slicc: refused:` prefix, never an ordinary browser error. Numeric guesses also sweep ids 1–32: an id not exposed to this client must be refused, while every exposed numeric context must remain usable; none of those guarded probes may reach the fixture content-script marker. The marker combines the content script’s global lexical `root` binding with its extension-id DOM marker and `chrome.runtime.id`, and is independently required to be absent from the page default world. `C9.world-debugger` pauses inside that content script: the control must reach its frame, while the guard must refuse or hide it. `C9.page-drivable` evaluates `self.origin`, captures a DOM snapshot and dispatches an Input click on the same ordinary page; cutting the entire session fails this regression row. All four use an unguarded control, the default browser context and observer discovery of the real extension world, not an invented isolated world. Protected default contexts remain session-cutting evidence; hiding a foreign isolated world does not weaken any seven-origin row.
185
+ The `C9` foreign extension isolated-world family requires `extensionWorld`: a browser hosting a foreign content-script extension on the ordinary fixture page. `C9.world-hidden` requires the page default context to remain visible after `Runtime.enable`, while the extension context and Runtime/Debugger identifiers remain hidden without cutting or detaching the session. `C9.world-select` probes numeric and unique context selectors, object and argument handles, promises, properties, named bindings and DOM node resolution; each command selecting the hidden world must fail with the `slicc: refused:` prefix, never an ordinary browser error. Numeric guesses also sweep ids 1–32: an id not exposed to this client must be refused, while every exposed numeric context must remain usable; none of those guarded probes may reach the fixture content-script marker. The marker combines the content script’s global lexical `root` binding with its extension-id DOM marker and `chrome.runtime.id`, and is independently required to be absent from the page default world. `C9.world-debugger` pauses inside that content script: the control must reach its frame, while the guard must refuse or hide it. The control waits up to 5000 ms (scaled by `settleScale`) for `Debugger.paused`; when it never pauses, `reachable` records `"incomplete"`, not `false`, and the row fails with `extensionWorld debugger: no Debugger.paused within <ms>ms`. The guarded leg waits up to 1500 ms, scaled by `refusalScale`. `C9.page-drivable` evaluates `self.origin`, captures a DOM snapshot and dispatches an Input click on the same ordinary page; cutting the entire session fails this regression row. All four use an unguarded control, the default browser context and observer discovery of the real extension world, not an invented isolated world. Protected default contexts remain session-cutting evidence; hiding a foreign isolated world does not weaken any seven-origin row.
186
186
 
187
187
  The integration launcher loads shared-web 1.25.2’s harness extension exactly once through `Extensions.loadUnpacked` (`ankmbodmgimjemnojmeddfcdlbocolob`), whose content script runs on HTTP(S) pages. shared-web 1.25.2 also loads it by default in its own harness. A transport unable to host a foreign content-script extension omits `extensionWorld` and reports `not-supported:extensionWorld`; an advertised capability with no observed world fails, rather than silently skipping.
188
188
 
@@ -192,7 +192,7 @@ The integration launcher loads shared-web 1.25.2’s harness extension exactly o
192
192
 
193
193
  The `C8.newtab` reachability control attaches through the guard to a settled ordinary new tab, including its internal frames, then navigates that same tab to an ordinary document. Both legs must succeed, including without a guard. It requires `chromeUrlsAttach`, so an extension transport reports it as `skip` with `not-supported:chromeUrlsAttach`. The C8 protection rows expect a refusal on the guarded leg and run their controls unguarded, so they don't require it and still run on extension transports. It uses the default browser context, since an incognito new tab omits the ordinary new tab's internal frames. It covers `chrome://newtab/` and directly opened `chrome://new-tab-page/`.
194
194
 
195
- `C8.protected-error-page` touches an allowed tab, scripts a protected navigation, and requires the guard to fail its Document request. The independent observer verifies the committed error document and the exact unreachable/reported URL pair. The session stays attached; reload, navigation to `about:blank` and close succeed, while discovery and target events redact URL and title to the origin. The unguarded control uses independent Fetch failure injection and keeps raw discovery values. `C8.protected-document-restore` seeds a real protected document before attachment, navigates away, then restores it from BFCache in the same tab after the error/recovery sequence; evaluate, attach and close must be refused. A real `BackForwardCacheRestore` commit is required, so a second failed network request cannot masquerade as this control. `C8.protected-error-page-sticky` additionally fails a request after that real protected restore and requires the already-protected tab to remain locked. All three require `browserSession`, have unguarded controls and clean up their own targets. The regression tests include URL-only locking, unconditional error-page unlocking, and a plain client with only request-failure injection.
195
+ `C8.protected-error-page` touches an allowed tab, scripts a protected navigation, and requires the guard to fail its Document request. The independent observer verifies the committed error document and the exact unreachable/reported URL pair. The session stays attached; reload, navigation to `about:blank` and close succeed, while `Target.getTargetInfo`, `Target.getTargets`, the `Target.attachedToTarget` of a fresh attach and the `targetCreated`/`targetInfoChanged` events redact URL and title to the origin (`infoRedacted`, `listRedacted`, `attachRedacted`, `eventsRedacted`), and none of them shows the full failed URL (`fullURL`). The failed URL carries a secret path, `?query` and `#fragment`; the `.query` and `.fragment` variants of this row and of `C8.protected-error-page-sticky` fail `/doc?query-secret=…` and `/doc#fragment-secret-…` instead, so no path, query or fragment secret may survive alone either. The unguarded control uses independent Fetch failure injection and keeps raw discovery values: its `fullURL` must be `true`. `C8.protected-document-restore` seeds a real protected document before attachment, navigates away, then restores it from BFCache in the same tab after the error/recovery sequence; evaluate, attach and close must be refused. A real `BackForwardCacheRestore` commit is required, so a second failed network request cannot masquerade as this control. `C8.protected-error-page-sticky` additionally fails a request after that real protected restore and requires the already-protected tab to remain locked. They run on browser-session and page-session-only transports alike, have unguarded controls and clean up their own targets. The regression tests include URL-only locking, unconditional error-page unlocking, and a plain client with only request-failure injection.
196
196
 
197
197
  `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.
198
198
 
@@ -253,7 +253,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
253
253
 
254
254
  `{ "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.
255
255
 
256
- `{ "errorPage": "recover", "as": "errorPage", "requires": ["browserSession"] }` (also modes `restore` and `sticky`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself.
256
+ `{ "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 capability. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself.
257
257
 
258
258
  `{ "unparseable": true, "as": "unparseable" }` runs the C8 malformed-URL compound probe on the current ordinary page: independent browser ground truth, raw/canonical destination commands, discovery/attach and cookie checks. Boolean evidence is consumed by ordinary `result` checks, with native Chrome outcomes retained in its diagnostics.
259
259
 
@@ -366,7 +366,7 @@ every alternative of an `any`, or a required (`all`, `not` or plain) check, need
366
366
  selects one variant or an array; omission selects all. `parallel` defaults to 1.
367
367
  With the `browserContexts` capability each leg runs in its own browser context. A `Target.createBrowserContext` that fails with a timeout is retried once, and the row's `notes` records it; any other failure fails the row.
368
368
 
369
- `settleScale` (default 1) multiplies the bounds of waits for events the leg expects to happen: settles (beacon, served, title and load) on the control leg and `required` settles on either leg, `paused` on the control leg, page and child loads, the wait for a background step's `as` result (`<as> did not complete within <ms>ms`), and the wait for an observation the leg expects to be `true`. Adopters on slow hosts can set 3 or 4. Every wait polls and returns as soon as its event arrives, so a larger bound only costs time when something is really missing, and each timeout error names the bound it used. An observation read from the fixture (`observer: "fixture"`, such as a beacon or a served request) that the leg expects to be `true` is polled for up to 5000 ms (scaled) instead of read once. One that the leg expects to be `false` is still read once.
369
+ `settleScale` (default 1) multiplies the bounds of waits for events the leg expects to happen: settles (beacon, served, title and load) on the control leg and `required` settles on either leg, `paused` on the control leg, page and child loads, the wait for a background step's `as` result (`<as> did not complete within <ms>ms`), the wait for the pre-action beacons, the C9 control's `Debugger.paused`, and the wait for an observation the leg expects to be `true`. Adopters on slow hosts can set 3 or 4. Every wait polls and returns as soon as its event arrives, so a larger bound only costs time when something is really missing, and each timeout error names the bound it used. An observation read from the fixture (`observer: "fixture"`, such as a beacon or a served request) that the leg expects to be `true` is polled for up to 5000 ms (scaled) instead of read once. One that the leg expects to be `false` is still read once.
370
370
 
371
371
  `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`.
372
372
 
@@ -401,7 +401,13 @@ observations: a missing or failing effect fails the control. Guarded liveness
401
401
  requires the exact target URL in the fixture log or a target-origin `/live`
402
402
  heartbeat; alternatively a pre-action `/live?tag=...&step=pre&to=...` request and
403
403
  an exact-URL block/error from the client or unguarded frame tree. An error for a
404
- different URL is not evidence. `blocked` accepts either `{ url }` for one exact URL
404
+ different URL is not evidence. Before judging, the runner waits, bounded at 5 s (scaled by `settleScale`), for the
405
+ pre-action beacon of every trigger step it ran, since a keepalive beacon has no ordering
406
+ guarantee. When one never arrives, an `eitherWhen` that needs it is `"incomplete"` and
407
+ excluded from that field's expectations, and the guarded leg is not live with
408
+ `not live: pre beacon not observed for <url>` in the row's `why`, never a false
409
+ mismatch. Once the beacon is in, a failing `eitherWhen` whose field mismatches, and a
410
+ failing `liveCheck`, are rechecked for up to 1.5 s (scaled) before they count. `blocked` accepts either `{ url }` for one exact URL
405
411
  or `{ anyOf: [url, ...] }` for a nonempty list of exact URLs; each URL can be a
406
412
  string or `{ hops, to, index? }`. Both forms allow `session` and `frame`. With `frame`,
407
413
  the unguarded observer also reads the page's out-of-process iframe targets (matched by
@@ -73,7 +73,17 @@ async function drivable(client, session, live) {
73
73
  };
74
74
  }
75
75
 
76
- async function debuggerWorld({ client, reader, session, observer, world, events, live }) {
76
+ async function debuggerWorld({
77
+ client,
78
+ reader,
79
+ session,
80
+ observer,
81
+ world,
82
+ events,
83
+ live,
84
+ side,
85
+ pauseMs,
86
+ }) {
77
87
  const debug = await outcome(client.send('Debugger.enable', {}, session));
78
88
  const probe = outcome(
79
89
  reader.send(
@@ -87,7 +97,7 @@ async function debuggerWorld({ client, reader, session, observer, world, events,
87
97
  observer
88
98
  )
89
99
  );
90
- const deadline = Date.now() + 1500;
100
+ const deadline = Date.now() + pauseMs;
91
101
  while (
92
102
  debug.ok &&
93
103
  !events().some((event) => event.method === 'Debugger.paused') &&
@@ -123,9 +133,11 @@ async function debuggerWorld({ client, reader, session, observer, world, events,
123
133
  event.method === 'Debugger.scriptParsed' && event.params.executionContextId === world.id
124
134
  ) || Boolean(paused);
125
135
  await outcome(client.send('Debugger.disable', {}, session));
136
+ const incomplete = side === 'control' && debug.ok && !paused;
126
137
  return {
127
138
  exists: true,
128
- reachable: reached,
139
+ reachable: incomplete ? 'incomplete' : reached,
140
+ ...(incomplete ? { incomplete: `no Debugger.paused within ${pauseMs}ms` } : {}),
129
141
  protected: refused(debug) || frameRefused || !exposed,
130
142
  alive: await live(),
131
143
  };
@@ -310,6 +322,7 @@ export async function extensionWorld({
310
322
  side,
311
323
  controls,
312
324
  extensionId = DEFAULT_EXTENSION_WORLD_ID,
325
+ pauseMs = side === 'control' ? 5000 : 1500,
313
326
  }) {
314
327
  const attached = await reader.send('Target.attachToTarget', { targetId, flatten: true });
315
328
  const observer = attached.sessionId;
@@ -370,7 +383,18 @@ export async function extensionWorld({
370
383
  await outcome(client.send('Debugger.disable', {}, session));
371
384
  return { exists: true, listed, leaks, defaultSeen, alive: enabled.ok && (await live()) };
372
385
  }
373
- const input = { client, reader, session, observer, world, events, live, side, controls };
386
+ const input = {
387
+ client,
388
+ reader,
389
+ session,
390
+ observer,
391
+ world,
392
+ events,
393
+ live,
394
+ side,
395
+ controls,
396
+ pauseMs,
397
+ };
374
398
  if (mode === 'drivable') return await drivable(client, session, live);
375
399
  if (mode === 'debugger') return await debuggerWorld(input);
376
400
  return await selectWorld(input);
package/guard-driver.mjs CHANGED
@@ -144,10 +144,14 @@ export function judge(vector, side, observed) {
144
144
  (observed?.[k] === 'not-supported' && vector.observe?.[k]?.requires?.length > 0) ||
145
145
  (vector.observe?.[k]?.dependsOn ?? []).some((d) => refused.includes(d))
146
146
  );
147
+ const incomplete = Object.keys(want).filter(
148
+ (k) => !skipped.includes(k) && observed?.[`${k}.either`] === 'incomplete'
149
+ );
147
150
  const mismatches = Object.entries(want)
148
151
  .filter(
149
152
  ([k, v]) =>
150
153
  !skipped.includes(k) &&
154
+ !incomplete.includes(k) &&
151
155
  !matches(observed?.[`${k}.either`] === true ? 'either' : v, observed?.[k])
152
156
  )
153
157
  .map(([k, v]) => `${k}: got ${q(observed?.[k])}, want ${q(v)}`);
@@ -222,7 +226,7 @@ export function result(vector, run) {
222
226
  why.push('refused on the protected target without a passing control');
223
227
  } else why.push(...g.mismatches);
224
228
  const live = run.live === true ? 'pass' : 'fail';
225
- if (live === 'fail') why.push('not live');
229
+ if (live === 'fail') why.push(['not live', run.liveWhy].filter(Boolean).join(': '));
226
230
  return {
227
231
  ...row,
228
232
  control,
package/guard-runner.mjs CHANGED
@@ -114,6 +114,7 @@ class Execution {
114
114
  this.contexts = [];
115
115
  this.pending = [];
116
116
  this.errors = [];
117
+ this.preSent = [];
117
118
  this.notes = [];
118
119
  this.answers = [];
119
120
  this.internalSessions = new Set();
@@ -236,6 +237,17 @@ class Execution {
236
237
  return read.ok && canary.untouched(state.value, read.result?.result?.value);
237
238
  }
238
239
 
240
+ async awaitPre() {
241
+ const sent = [...new Set(this.preSent)];
242
+ if (!sent.length || !usesPre(this.vector)) return [];
243
+ const missing = async () => {
244
+ const seen = await Promise.all(sent.map((url) => this.attemptCheck({ pre: url })));
245
+ return sent.filter((_, index) => !seen[index]);
246
+ };
247
+ await this.poll(async () => (await missing()).length === 0, 5000);
248
+ return missing();
249
+ }
250
+
239
251
  async attemptCheck(c) {
240
252
  const logs = owned((await this.options.fixture.log()).slice(this.requestStart), this.vars.tag);
241
253
  if (c.pre) {
@@ -609,6 +621,8 @@ class Execution {
609
621
  this.errors.push(
610
622
  `${step.refusal.name} failed without a refusal: ${String(out.error?.message ?? out.error)}`
611
623
  );
624
+ if (step.extensionWorld && out.result?.incomplete)
625
+ this.errors.push(`extensionWorld ${step.extensionWorld}: ${out.result.incomplete}`);
612
626
  if ((step.extensionWorld || step.unparseable || step.errorPage) && !out.ok)
613
627
  this.errors.push(
614
628
  `${step.unparseable ? 'unparseable' : step.errorPage ? 'errorPage' : 'extensionWorld'}: ${out.error?.message ?? 'probe failed'}`
@@ -675,6 +689,7 @@ class Execution {
675
689
  internalSessions: this.internalSessions,
676
690
  mode: step.errorPage,
677
691
  side: this.side,
692
+ secret: step.secret,
678
693
  })
679
694
  );
680
695
  if (step.unparseable)
@@ -701,6 +716,7 @@ class Execution {
701
716
  origin: this.vars.ordinary,
702
717
  side: this.side,
703
718
  controls: (this.clients.extensionWorldControls ??= {}),
719
+ pauseMs: this.bound(this.side === 'control' ? 5000 : 1500, this.side !== 'control'),
704
720
  extensionId: this.options.extensionWorldId,
705
721
  })
706
722
  );
@@ -834,16 +850,19 @@ class Execution {
834
850
  this.pageTarget = tab.targetId;
835
851
  return { ok: true, result: tab };
836
852
  }
853
+ if (pre && step.page !== 'framed') this.preSent.push(url);
837
854
  if (step.page === 'navigate') {
838
855
  if (pre) await this.evaluate(pre, session);
839
856
  return this.navigate(this.client, session, url);
840
857
  }
841
- if (step.page === 'framed')
858
+ if (step.page === 'framed') {
859
+ if (phase === 'trigger') this.preSent.push(url);
842
860
  return this.navigate(
843
861
  this.client,
844
862
  session,
845
863
  `${step.from}/framed?tag=${encodeURIComponent(this.vars.tag)}&url=${encodeURIComponent(url)}&log=${encodeURIComponent(this.vars.log)}`
846
864
  );
865
+ }
847
866
  let expression = pageExpression(step, url);
848
867
  if (pre) expression = `(() => { ${pre} return (${expression}); })()`;
849
868
  const promise = this.evaluate(expression, session);
@@ -900,6 +919,8 @@ class Execution {
900
919
  const want = (this.side === 'control' ? this.vector.control.expected : this.vector.expected)?.[
901
920
  name
902
921
  ];
922
+ if (check?.result && at(this.calls[check.result], check.path) === 'incomplete')
923
+ return 'incomplete';
903
924
  if (want !== true || check?.observer !== 'fixture') return this.check(check);
904
925
  let seen = false;
905
926
  await this.poll(async () => {
@@ -1098,6 +1119,42 @@ class Execution {
1098
1119
  }
1099
1120
  }
1100
1121
 
1122
+ async observeAll(unseen) {
1123
+ const observed = { ...this.refused };
1124
+ const incomplete = [];
1125
+ for (const [name, observation] of Object.entries(this.vector.observe ?? {})) {
1126
+ if (!supported(observation.requires, this.options.capabilities)) {
1127
+ observed[name] = 'not-supported';
1128
+ continue;
1129
+ }
1130
+ if ((observation.dependsOn ?? []).some((dep) => this.refused[dep])) continue;
1131
+ observed[name] = await this.observe(name, observation.check);
1132
+ if (!observation.eitherWhen || this.side !== 'guarded') continue;
1133
+ const holds = await this.either(observation.eitherWhen, observed[name], name, unseen);
1134
+ observed[`${name}.either`] = holds;
1135
+ if (holds === 'incomplete') incomplete.push(name);
1136
+ }
1137
+ return { observed, incomplete };
1138
+ }
1139
+
1140
+ async either(check, value, name, unseen) {
1141
+ const holds = () => this.check(check);
1142
+ if (await holds()) return true;
1143
+ if (unseen.length) return usesPre(check) ? 'incomplete' : false;
1144
+ if (value === this.vector.expected?.[name]) return false;
1145
+ return this.poll(holds, 1500);
1146
+ }
1147
+
1148
+ async liveness(unseen, incomplete) {
1149
+ if (this.side === 'control') return { live: true };
1150
+ const alive = () => this.check(this.vector.liveCheck);
1151
+ let live = await alive();
1152
+ if (!live && !unseen.length) live = await this.poll(alive, 1500);
1153
+ if (!unseen.length || (live && !incomplete.length)) return { live };
1154
+ const detail = incomplete.length ? ` (${incomplete.join(', ')} incomplete)` : '';
1155
+ return { live: false, liveWhy: `pre beacon not observed for ${unseen.join(', ')}${detail}` };
1156
+ }
1157
+
1101
1158
  async run() {
1102
1159
  this.requestStart = (await this.options.fixture.log()).length;
1103
1160
  let pumping;
@@ -1113,25 +1170,16 @@ class Execution {
1113
1170
  await this.execute(plan.setup, undefined, 'setup');
1114
1171
  await this.execute(plan.trigger, undefined, 'trigger');
1115
1172
  await sleep(100);
1116
- const observed = { ...this.refused };
1117
- for (const [name, observation] of Object.entries(this.vector.observe ?? {})) {
1118
- if (!supported(observation.requires, this.options.capabilities)) {
1119
- observed[name] = 'not-supported';
1120
- continue;
1121
- }
1122
- if ((observation.dependsOn ?? []).some((dep) => this.refused[dep])) continue;
1123
- observed[name] = await this.observe(name, observation.check);
1124
- if (observation.eitherWhen && this.side === 'guarded')
1125
- observed[`${name}.either`] = await this.check(observation.eitherWhen);
1126
- }
1173
+ const unseen = await this.awaitPre();
1174
+ const { observed, incomplete } = await this.observeAll(unseen);
1127
1175
  this.options.debug?.({
1128
1176
  events: this.client.events.slice(this.eventStart),
1129
1177
  calls: this.calls,
1130
1178
  requests: await this.options.fixture.log(),
1131
1179
  side: this.side,
1132
1180
  });
1133
- const live = this.side === 'control' ? true : await this.check(this.vector.liveCheck);
1134
- return { observed, live, errors: this.errors, notes: this.notes };
1181
+ const { live, liveWhy } = await this.liveness(unseen, incomplete);
1182
+ return { observed, live, liveWhy, errors: this.errors, notes: this.notes };
1135
1183
  } finally {
1136
1184
  this.stopped = true;
1137
1185
  await pumping;
@@ -1154,6 +1202,12 @@ class Execution {
1154
1202
  }
1155
1203
  }
1156
1204
 
1205
+ const usesPre = (value) =>
1206
+ Boolean(value && typeof value === 'object') &&
1207
+ (Object.hasOwn(value, 'pre') ||
1208
+ Object.hasOwn(value, 'history') ||
1209
+ Object.values(value).some(usesPre));
1210
+
1157
1211
  const observerRequired = (check) => {
1158
1212
  if (check?.observer === 'unguarded') return true;
1159
1213
  if (check?.all) return check.all.some(observerRequired);
@@ -1446,6 +1500,7 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
1446
1500
  control: legs.control.observed,
1447
1501
  guarded: legs.guarded.observed,
1448
1502
  live: legs.control.live && legs.guarded.live,
1503
+ liveWhy: legs.guarded.liveWhy,
1449
1504
  }),
1450
1505
  transportVariant: base.transportVariant,
1451
1506
  observations: { control: legs.control.observed, guarded: legs.guarded.observed },