@ai-ecoverse/slicc-browsers 1.7.7 → 1.7.8

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
@@ -81,7 +81,7 @@ Every feature builds for `wasm32-wasip1`.
81
81
  - `rank`: `tiers`, 1 to 6: `sevenOpen` (an endpoint with a seven tab), `endpoint` (any other endpoint), `chrome` (installed `chromeId`), `chromium` (other installed Chromium browsers), `electron` (installed Electron apps), `six` (an endpoint in SLICC six's profile, with or without a seven tab; see [SLICC six's profiles](#slicc-sixs-profiles)).
82
82
  - `discovery`:
83
83
  - `activePortFile` (`DevToolsActivePort`): its first line is the port (1 to 65535) and its second the browser's websocket path, which starts with `/`. A file without both is ignored. A file is live only when its port answers `/json/version` with a `webSocketDebuggerUrl` whose path is that websocket path; otherwise it's stale and ignored. When the port accepts connections but serves no `/json/version`, the endpoint is listed as `wsOnly`, with `ws://127.0.0.1:<port><path>`;
84
- - `probePorts` (9222 to 9229, probed by `slicc` only);
84
+ - `probePorts` (9222 to 9229);
85
85
  - every endpoint's `cdp` is `<scheme>://<host>:<port>`, with the host in lower case and `localhost` and `::1` written as `127.0.0.1`, whatever form the URL was found in. Endpoints with the same host and port, written that way, are one endpoint. A `ws://` `--cdp` URL that is listed as `wsOnly` keeps its websocket URL as given;
86
86
  - `sourceRank`: when two sources find the same host and port, the lower rank wins, and on a tie the first found;
87
87
  - `names`: an endpoint's name when it isn't from a known browser's user-data dir: `electron` when the user agent contains `electronUserAgent`, else `products[<product>]` for the product before the `/` in `/json/version`'s `Browser`, else that product, else `fallback`;
@@ -131,7 +131,7 @@ A same-site frame shares its parent's process and has no target of its own. Ever
131
131
  ## Guard vectors
132
132
 
133
133
  Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-vectors.schema.json`): bios, slicc-cdp, slicc-extension and slicc-cli. The sources are listed in `sources`:
134
- - slicc-cli's `protectVectors` at `2d5d981` (`test/integration/testdata/link/protect.js`), with the ids kept;
134
+ - slicc-cli's `protectVectors` at `2d5d981` (`test/integration/testdata/link/protect.js`), with the ids kept, and at `fe33052` for `C3.redirect-popup-stays-out` and `C3.direct-popup-stays-out`: a popup opened straight onto, or through a redirect chain to, a protected origin never reaches the client, not at open and not after the page later navigates itself and auto-attach runs again;
135
135
  - slicc-cdp's `test/integration/guard-vectors.json` at `4907a45`, where its `E1`, `E2`, `E3`, `E5` and `B2` are renamed `E1.onload`, `E2.binding`, `E3.bypass-csp`, `E5.console` and `B2.headers`;
136
136
  - the cert lane's E and B rows;
137
137
  - `C5.continue-request` and `C5.continue-response`, added next to `C5.document`.
@@ -171,7 +171,7 @@ Any `cdp`, `page` or `children` step can take `requires`, the capabilities it ne
171
171
  `hops` routes `url` through `/hop` on each `origin`, with its `code`.
172
172
  - `{ "answer": { "url", "stage"?, "cdp"?, "params"?, "hold"? } }`: answer each paused request or auth challenge for `url` with that call; `hold` leaves it paused. Every other paused request continues unchanged.
173
173
  - `{ "children": steps, "where"? }`: run these steps on each target auto-attached from then on, then resume it. `where` scopes them to the row's own target: `{ "openerId": "page" }` (opened by the row's page) or `{ "urlPrefix": url }`; other attached targets are resumed and otherwise left alone.
174
- - `{ "settle": { "beacon"?, "title"?, "ms"?, "variant"?, "session"? } }`: wait for a beacon from `beacon`, for the page `title`, or for the session to detach. It waits up to `ms` (10000 by default), then another 600 ms. `variant` waits the run's detach variant instead.
174
+ - `{ "settle": { "beacon"?, "served"?, "title"?, "ms"?, "variant"?, "session"? } }`: wait for a beacon from `beacon`, for the fixture to log a request for the URL `served`, for the page `title`, or for the session to detach. It waits up to `ms` (10000 by default), then another 600 ms. `variant` waits the run's detach variant instead.
175
175
  - `{ "wait": ms }`.
176
176
  - `{ "paused": { "url", "ms" } }`: wait until the client has seen the pause that an `answer` rule for `url` matches (`Fetch.requestPaused` at the rule's stage, or `Fetch.authRequired` for a `Fetch.continueWithAuth` rule). It waits up to `ms`. On the control leg, a pause that never comes fails the row with `paused: no pause for <url> within <ms>ms`. On the guarded leg the guard may hide that pause, so the step waits the full `ms` and doesn't fail. The C5 guessed-id vectors and `C5.auth` use it before their second request, so the probe's interception id falls between the two ids the guess spans.
177
177
  - `{ "guess": { "between", "cdp", "params" }, "as" }`: the call for each request id between the ids paused for the two URLs.
@@ -191,7 +191,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
191
191
 
192
192
  **The control rule.** Every vector has a control on an ordinary origin: the same setup and trigger against an unprotected origin must succeed. A missing or failing control fails the id. With `via: "guard"`, the control runs through the guard. With `via: "unguarded"`, the runner runs setup and trigger through its own CDP against the ordinary origin and against the protected stand-in, and both must show the effect. The runner reports the control's transport as `via`.
193
193
 
194
- **Refusals.** Each refusable step has a refusal name: its `as`, or `refused.<i>`, where `i` is its index in the vector's steps, setup then trigger, with each `children` step's steps right after it, counted from 0 before `on` is applied. When a guard refuses the step and the error matches `refusal.match`, the runner sets that name to `true` in the leg's observations. On that leg, an observation whose `dependsOn` lists the name is skipped; every other observation is checked as usual. A guard that refuses the step on every origin passes in one of two ways: through the guard, with the step refused on both legs, or with an unguarded control and the guard's matched refusal on the guarded leg. With `via: "guard"`, a refusal on one leg only fails that leg. The comparison only counts steps that ran on both legs. A step that ran on one leg only (for example a `children` step on the control's popup, when the guarded leg never gets that popup attached) doesn't fail the row by itself, and the row reports it in `ran`, as `{ "refused.5": "control-only" }` or `"guarded-only"`. Observations that depend on such a step follow their `dependsOn` on each leg. A guard that allows the step must pass its control through the guard.
194
+ **Refusals.** Each refusable step has a refusal name: its `as`, or `refused.<i>`, where `i` is its index in the vector's steps, setup then trigger, with each `children` step's steps right after it, counted from 0 before `on` is applied. When a guard refuses the step and the error matches `refusal.match`, the runner sets that name to `true` in the leg's observations. On that leg, an observation whose `dependsOn` lists the name is skipped; every other observation is checked as usual. A guard that refuses the step on every origin passes in one of two ways: through the guard, with the step refused on both legs, or with an unguarded control and the guard's matched refusal on the guarded leg. With `via: "guard"`, a refusal on one leg only fails that leg. The comparison only counts steps that ran on both legs. A step that ran on one leg only (for example a `children` step on the control's popup, when the guarded leg never gets that popup attached) doesn't fail the row by itself, and the row reports it in `ran`, as `{ "refused.5": "control-only" }` or `"guarded-only"`. Observations that depend on such a step follow their `dependsOn` on each leg. A refusable step that fails without a matching refusal, for example with an internal error or a timeout, is not a refusal: it fails the row with `<name> failed without a refusal: <message>`. A step skipped because an earlier step failed therefore never passes through `ran`, and only a matched refusal skips the observations that depend on it. A guard that allows the step must pass its control through the guard.
195
195
 
196
196
  **Liveness.** A runner checks `live` through its own CDP, never through the guard path: either the protected document loaded (title, marker or URL), or the guard returned its own refusal for a refusable step. A failing `live` fails the id.
197
197
 
@@ -285,7 +285,11 @@ target. In observations and liveness checks, an `any` alternative that reference
285
285
  a `null` origin is dropped and the rest are evaluated; the row skips only when
286
286
  every alternative of an `any`, or a required (`all`, `not` or plain) check, needs it. Tags and cookie names are unique per leg. `variant`
287
287
  selects one variant or an array; omission selects all. `parallel` defaults to 1.
288
- `log(row)` receives completed rows. Besides `control`, `guarded`, `live`, `why` and `errors`, a row may carry `ran` (steps that ran on one leg only) and `notes` (settles skipped because their registration was refused). A variant supplies the wait before an
288
+ 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.
289
+
290
+ `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.
291
+
292
+ `log(row)` receives completed rows. Besides `control`, `guarded`, `live`, `why` and `errors`, a row may carry `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
289
293
  explicit `Target.detachFromTarget` step and for a `settle.variant` step.
290
294
 
291
295
  Every runnable vector has a declarative `liveCheck`; every observation has a
@@ -439,7 +443,7 @@ A six endpoint has tier `six`, whatever its seven tabs. **A program never choose
439
443
 
440
444
  ## Output schema 1
441
445
 
442
- `schema/browsers.schema.json` describes `slicc browsers --json`, which `playwright-cli list --json` also prints. [slicc-cli's README](https://github.com/ai-ecoverse/slicc-cli#slicc-browsers---json---cdp-url---no-bundle-scan) is the authoritative spec for it; this schema is a copy and follows it. Fields can be added; `source` is one of `devtools-active-port`, `flag`, `probe` and `process`. `tier` is 1 to 6, and `six: true` marks an endpoint in SLICC six's profile (tier 6).
446
+ `schema/browsers.schema.json` describes `slicc browsers --json`, which `playwright-cli list --json` also prints. [slicc-cli's README](https://github.com/ai-ecoverse/slicc-cli#slicc-browsers---json---cdp-url---no-bundle-scan) is the authoritative spec for it; this schema is a copy and follows it. Fields can be added; `source` is one of `devtools-active-port`, `flag`, `probe` and `process`. `tier` is 1 to 6, and `six: true` marks an endpoint in SLICC six's profile (tier 6). An endpoint may carry `tabs`: every page target except those on protected origins. Seven's own tabs are protected, so they are in `sevenTabs` only, never in `tabs`. `playwright-cli list --json` prints it, and a corpus case that checks it needs `tabs`.
443
447
 
444
448
  ## The corpus
445
449
 
@@ -447,7 +451,7 @@ Each case is a fake machine: a home tree, `DevToolsActivePort` files, fake `/jso
447
451
 
448
452
  **`case.json`:**
449
453
  - `description`, and `os?`: the OSes the case runs on (`darwin`, `linux`, `windows`; default all).
450
- - `needs`: what a program must support to run the case: `user-data-dirs` (`DevToolsActivePort` in browsers' default user-data dirs), `seven-profiles` (in the profile roots), `flag` (`--cdp` URLs), `process`, `probe` (port probes, `slicc` only), `origins` (extra seven origins), `installed` (ranking installed browsers), `six` (finding and marking SLICC six's profiles). A program skips a case that needs something it doesn't do.
454
+ - `needs`: what a program must support to run the case: `user-data-dirs` (`DevToolsActivePort` in browsers' default user-data dirs), `seven-profiles` (in the profile roots), `flag` (`--cdp` URLs), `process`, `probe` (port probes), `origins` (extra seven origins), `installed` (ranking installed browsers), `six` (finding and marking SLICC six's profiles), `tabs` (each endpoint's `tabs` list). A program skips a case that needs something it doesn't do.
451
455
  - `env?`: environment variables for discovery, which sees nothing else from the real environment.
452
456
  - `files?`: file contents by path. Every path starts with a token.
453
457
  - `cdp?`, `origins?`, `probePorts?`: `--cdp` URLs, extra seven origins, and the ports to probe.
@@ -508,7 +512,7 @@ for case in corpus().cases {
508
512
 
509
513
  Go 1.26, Rust stable with `wasm32-wasip1`, Node ≥ 24.
510
514
  - `npm run corpus` regenerates `corpus.json` from `corpus/`; the pre-commit hook and CI fail when it's stale.
511
- - `npm run validate` checks `catalog.json`, `corpus.json`, `protected.json` and `guard-vectors.json` against the schemas, runs the protected-origin vectors through `protected.mjs`, and checks what a schema can't say: unique ids, bundle ids and names, known Windows roots, rising tiers, compiling patterns, known tokens, tier order, `sevenOpen`, `six` and `auto` in each `expected.json`, and no port from 9222 to 9229.
515
+ - `npm run validate` checks `catalog.json`, `corpus.json`, `protected.json` and `guard-vectors.json` against the schemas, runs the protected-origin vectors through `protected.mjs`, and checks what a schema can't say: unique ids, bundle ids and names, known Windows roots, rising tiers, compiling patterns, known tokens, tier order, `sevenOpen`, `tabs` (which holds no entry of `sevenTabs`), `six` and `auto` in each `expected.json`, and no port from 9222 to 9229.
512
516
  - `npm run lint`: the slicc lint tools (Biome, no comments, no unit tests in git, `AGENTS.md`), `gofmt`, `go mod tidy -diff`, `go vet`, golangci-lint, `cargo fmt --check` and clippy.
513
517
  - `npm test`: `validate`, the Go tests in `test/integration/`, and `cargo test --all-features`.
514
518
  - `npm run build`: the Go module, the crate (also for `wasm32-wasip1`), and `npm pack --dry-run`.
package/corpus.json CHANGED
@@ -664,6 +664,124 @@
664
664
  ]
665
665
  }
666
666
  },
667
+ {
668
+ "name": "endpoint-tabs",
669
+ "case": {
670
+ "description": "tabs lists every page target except those on protected origins; seven's own tabs are only in sevenTabs, and iframes, workers and other protected pages are left out",
671
+ "needs": [
672
+ "flag",
673
+ "tabs"
674
+ ],
675
+ "cdp": [
676
+ "127.0.0.1:9331"
677
+ ],
678
+ "http": {
679
+ "127.0.0.1:9331": {
680
+ "version": {
681
+ "Browser": "Chrome/154.0.8037.98",
682
+ "Protocol-Version": "1.3",
683
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.8037.98 Safari/537.36",
684
+ "V8-Version": "15.4.79.1",
685
+ "WebKit-Version": "537.36 (@a1b2c3)",
686
+ "webSocketDebuggerUrl": "ws://127.0.0.1:9331/devtools/browser/t1"
687
+ },
688
+ "list": [
689
+ {
690
+ "id": "P1",
691
+ "type": "page",
692
+ "url": "https://lars.sliccy.ai/",
693
+ "title": "seven",
694
+ "webSocketDebuggerUrl": "ws://127.0.0.1/devtools/page/P1"
695
+ },
696
+ {
697
+ "id": "P2",
698
+ "type": "page",
699
+ "url": "https://example.test/docs",
700
+ "title": "docs",
701
+ "webSocketDebuggerUrl": "ws://127.0.0.1/devtools/page/P2"
702
+ },
703
+ {
704
+ "id": "P3",
705
+ "type": "page",
706
+ "url": "https://www.sliccy.ai/",
707
+ "title": "SLICC",
708
+ "webSocketDebuggerUrl": "ws://127.0.0.1/devtools/page/P3"
709
+ },
710
+ {
711
+ "id": "P4",
712
+ "type": "page",
713
+ "url": "https://vault.sliccy.ai/",
714
+ "title": "vault",
715
+ "webSocketDebuggerUrl": "ws://127.0.0.1/devtools/page/P4"
716
+ },
717
+ {
718
+ "id": "P5",
719
+ "type": "page",
720
+ "url": "about:blank",
721
+ "title": "",
722
+ "webSocketDebuggerUrl": "ws://127.0.0.1/devtools/page/P5"
723
+ },
724
+ {
725
+ "id": "P6",
726
+ "type": "iframe",
727
+ "url": "https://example.test/frame",
728
+ "title": "frame",
729
+ "webSocketDebuggerUrl": "ws://127.0.0.1/devtools/page/P6"
730
+ },
731
+ {
732
+ "id": "P7",
733
+ "type": "service_worker",
734
+ "url": "https://example.test/sw.js",
735
+ "title": "worker",
736
+ "webSocketDebuggerUrl": "ws://127.0.0.1/devtools/page/P7"
737
+ }
738
+ ]
739
+ }
740
+ }
741
+ },
742
+ "expected": {
743
+ "schema": 1,
744
+ "minChromium": 116,
745
+ "entries": [
746
+ {
747
+ "id": "cdp:127.0.0.1:9331",
748
+ "kind": "endpoint",
749
+ "tier": 1,
750
+ "name": "Chrome",
751
+ "version": "154.0.8037.98",
752
+ "endpoint": {
753
+ "cdp": "http://127.0.0.1:9331",
754
+ "webSocketDebuggerUrl": "ws://127.0.0.1:9331/devtools/browser/t1",
755
+ "name": "Chrome",
756
+ "version": "154.0.8037.98",
757
+ "product": "Chrome/154.0.8037.98",
758
+ "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.8037.98 Safari/537.36",
759
+ "source": "flag",
760
+ "sevenOpen": true,
761
+ "sevenTabs": [
762
+ {
763
+ "id": "P1",
764
+ "url": "https://lars.sliccy.ai/",
765
+ "title": "seven"
766
+ }
767
+ ],
768
+ "tabs": [
769
+ {
770
+ "id": "P2",
771
+ "url": "https://example.test/docs",
772
+ "title": "docs"
773
+ },
774
+ {
775
+ "id": "P5",
776
+ "url": "about:blank",
777
+ "title": ""
778
+ }
779
+ ]
780
+ }
781
+ }
782
+ ]
783
+ }
784
+ },
667
785
  {
668
786
  "name": "extra-origins",
669
787
  "case": {
package/guard-runner.mjs CHANGED
@@ -385,12 +385,21 @@ class Execution {
385
385
  }
386
386
  }
387
387
 
388
+ async createContext() {
389
+ const client = this.reader ?? this.client;
390
+ try {
391
+ return (await client.send('Target.createBrowserContext', {})).browserContextId;
392
+ } catch (error) {
393
+ const message = String(error?.message ?? error);
394
+ if (!/timed? ?out/i.test(message)) throw error;
395
+ this.notes.push(`Target.createBrowserContext retried once after: ${message}`);
396
+ return (await client.send('Target.createBrowserContext', {})).browserContextId;
397
+ }
398
+ }
399
+
388
400
  async seed() {
389
401
  if (supported(['browserContexts'], this.options.capabilities)) {
390
- const { browserContextId } = await (this.reader ?? this.client).send(
391
- 'Target.createBrowserContext',
392
- {}
393
- );
402
+ const browserContextId = await this.createContext();
394
403
  this.vars.context = browserContextId;
395
404
  this.contexts.push(browserContextId);
396
405
  }
@@ -414,7 +423,7 @@ class Execution {
414
423
  }
415
424
 
416
425
  async pump() {
417
- while (!this.stopped) {
426
+ while (!this.stopped && !this.clients.flight?.abandoned) {
418
427
  for (const event of this.client.events.slice(this.eventStart)) {
419
428
  if (this.processed.has(event)) continue;
420
429
  this.processed.add(event);
@@ -546,32 +555,47 @@ class Execution {
546
555
  .some((e) => e.method === 'Target.detachedFromTarget' && e.params?.sessionId === session);
547
556
  }
548
557
 
558
+ record(step, out, session, phase, gone) {
559
+ const refused =
560
+ !gone &&
561
+ (step.refusable
562
+ ? isRefusal(out, step)
563
+ : /refused/.test(String(out.error?.message ?? out.error)));
564
+ this.trace.push({
565
+ phase,
566
+ declared: Boolean(step.refusable),
567
+ session,
568
+ out,
569
+ refused,
570
+ ...(gone ? { detached: true } : {}),
571
+ });
572
+ if (step.refusable) this.refused[step.refusal.name] = refused;
573
+ if (step.refusable && !out.ok && !refused && !gone)
574
+ this.errors.push(
575
+ `${step.refusal.name} failed without a refusal: ${String(out.error?.message ?? out.error)}`
576
+ );
577
+ if (step.as && !out.result?.pending) this.calls[step.as] = out;
578
+ if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
579
+ }
580
+
549
581
  async execute(list, childSession, phase = 'child') {
550
582
  if (phase === 'trigger') await this.childrenSettled();
551
- for (const raw of list) {
583
+ for (const [index, raw] of list.entries()) {
584
+ if (this.clients.flight)
585
+ Object.assign(this.clients.flight, { leg: this.side, phase, step: index });
552
586
  const step = expand(raw, this.vars);
553
587
  if (!supported(step.requires, this.options.capabilities) || step.text) continue;
554
588
  if (!childSession && step.session === 'child') await this.childrenSettled();
555
589
  const session = childSession ?? this.session(step.session);
556
590
  const out = await this.operation(step, session, phase);
557
591
  if (!out) continue;
558
- const gone = Boolean(childSession) && !out.ok && this.detached(childSession);
559
- const refused =
560
- !gone &&
561
- (step.refusable
562
- ? isRefusal(out, step)
563
- : /refused/.test(String(out.error?.message ?? out.error)));
564
- this.trace.push({
565
- phase,
566
- declared: Boolean(step.refusable),
567
- session,
592
+ this.record(
593
+ step,
568
594
  out,
569
- refused,
570
- ...(gone ? { detached: true } : {}),
571
- });
572
- if (step.refusable) this.refused[step.refusal.name] = refused;
573
- if (step.as && !out.result?.pending) this.calls[step.as] = out;
574
- if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
595
+ session,
596
+ phase,
597
+ Boolean(childSession) && !out.ok && this.detached(childSession)
598
+ );
575
599
  this.options.debug?.({ step, out, side: this.side });
576
600
  }
577
601
  }
@@ -608,7 +632,7 @@ class Execution {
608
632
  const refused = () => (settle.dependsOn ?? []).some((name) => this.refused[name]);
609
633
  const skipped = () => {
610
634
  this.notes.push(
611
- `settle:skipped-refused ${settle.beacon ? `beacon ${settle.beacon}` : settle.title ? `title ${settle.title}` : 'load'}`
635
+ `settle:skipped-refused ${settle.beacon ? `beacon ${settle.beacon}` : settle.served ? `request ${settle.served}` : settle.title ? `title ${settle.title}` : 'load'}`
612
636
  );
613
637
  };
614
638
  if (refused()) return skipped();
@@ -619,6 +643,7 @@ class Execution {
619
643
  }
620
644
  const met = await this.poll(async () => {
621
645
  if (refused()) return true;
646
+ if (settle.served) return this.requestCheck({ served: settle.served, tagged: false });
622
647
  if (settle.beacon)
623
648
  return (
624
649
  (await this.requestCheck({ beacon: settle.beacon }, this.actionRequestStart)) &&
@@ -639,7 +664,7 @@ class Execution {
639
664
  if (refused()) return skipped();
640
665
  if (settle.required && !met)
641
666
  this.errors.push(
642
- `settle: no ${settle.beacon ? `beacon from ${settle.beacon}` : settle.title ? `title ${settle.title}` : 'load'} within ${settle.ms ?? 1500}ms`
667
+ `settle: no ${settle.beacon ? `beacon from ${settle.beacon}` : settle.served ? `request for ${settle.served}` : settle.title ? `title ${settle.title}` : 'load'} within ${settle.ms ?? 1500}ms`
643
668
  );
644
669
  }
645
670
 
@@ -1138,8 +1163,87 @@ function variables(targets, target, side, slot, index) {
1138
1163
  return vars;
1139
1164
  }
1140
1165
 
1141
- async function executeRow(job, opts, clients, set, slot, index) {
1166
+ function tracked(client, calls, name) {
1167
+ if (!client || typeof client.send !== 'function') return client;
1168
+ let next = 0;
1169
+ const send = (method, params, sessionId) => {
1170
+ const id = ++next;
1171
+ calls.set(id, { name, method, session: sessionId, since: Date.now() });
1172
+ return Promise.resolve()
1173
+ .then(() => client.send(method, params, sessionId))
1174
+ .finally(() => calls.delete(id));
1175
+ };
1176
+ return new Proxy(client, {
1177
+ get(base, key) {
1178
+ if (key === 'send') return send;
1179
+ const value = Reflect.get(base, key);
1180
+ return typeof value === 'function' ? value.bind(base) : value;
1181
+ },
1182
+ });
1183
+ }
1184
+
1185
+ function view(flight, base, clients) {
1186
+ const now = Date.now();
1187
+ const pending = (name) =>
1188
+ [...flight.calls.values()]
1189
+ .filter((call) => call.name === name)
1190
+ .map(({ method, session, since }) => ({ method, session, ageMs: now - since }));
1191
+ return {
1192
+ id: base.id,
1193
+ target: base.target,
1194
+ variant: base.variant,
1195
+ leg: flight.leg,
1196
+ phase: flight.phase,
1197
+ step: flight.step,
1198
+ clients: {
1199
+ client: pending('client'),
1200
+ ...(clients.unguarded && clients.unguarded !== clients.client
1201
+ ? { unguarded: pending('unguarded') }
1202
+ : {}),
1203
+ },
1204
+ };
1205
+ }
1206
+
1207
+ async function watch(work, ms, snapshot) {
1208
+ if (!ms) {
1209
+ await work;
1210
+ return undefined;
1211
+ }
1212
+ let timer;
1213
+ const expired = new Promise((resolve) => {
1214
+ timer = setTimeout(() => resolve(snapshot()), ms);
1215
+ });
1216
+ const stuck = await Promise.race([work.then(() => undefined), expired]);
1217
+ clearTimeout(timer);
1218
+ return stuck;
1219
+ }
1220
+
1221
+ async function timedOut(stuck, base, made, work, opts) {
1222
+ const close = made.close;
1223
+ made.close = undefined;
1224
+ await close?.call(made);
1225
+ work.catch(() => {});
1226
+ const failed = {
1227
+ ...base,
1228
+ control: 'fail',
1229
+ guarded: 'fail',
1230
+ live: 'fail',
1231
+ why: `row timed out after ${opts.rowTimeoutMs}ms`,
1232
+ errors: [`row timed out after ${opts.rowTimeoutMs}ms: ${JSON.stringify(stuck)}`],
1233
+ inFlight: stuck,
1234
+ };
1235
+ opts.log?.(failed);
1236
+ return failed;
1237
+ }
1238
+
1239
+ async function executeRow(job, opts, made, set, slot, index, flights) {
1142
1240
  const { target, variant } = job;
1241
+ const calls = new Map();
1242
+ const client = tracked(made.client, calls, 'client');
1243
+ const unguarded =
1244
+ made.unguarded === made.client ? client : tracked(made.unguarded, calls, 'unguarded');
1245
+ const flight = { calls, leg: undefined, phase: undefined, step: undefined };
1246
+ const clients = { ...made, client, unguarded, flight };
1143
1247
  const scoped = available(job.vector, target, opts.targets);
1144
1248
  const vector = scoped.vector ?? job.vector;
1145
1249
  const base = {
@@ -1197,20 +1301,30 @@ async function executeRow(job, opts, clients, set, slot, index) {
1197
1301
  reason: 'not-supported:unguarded-observer',
1198
1302
  };
1199
1303
  const legs = {};
1200
- for (const side of ['control', 'guarded']) {
1201
- try {
1202
- const vars = variables(opts.targets, target, side, slot, index);
1203
- legs[side] = await new Execution(
1204
- vector,
1205
- side,
1206
- opts,
1207
- clients,
1208
- vars,
1209
- set.variants[variant]
1210
- ).run();
1211
- } catch (error) {
1212
- legs[side] = { observed: {}, live: false, errors: [String(error.message ?? error)] };
1304
+ flights.add({ flight, base, clients });
1305
+ opts.onStart?.(base);
1306
+ const sides = (async () => {
1307
+ for (const side of ['control', 'guarded']) {
1308
+ try {
1309
+ const vars = variables(opts.targets, target, side, slot, index);
1310
+ legs[side] = await new Execution(
1311
+ vector,
1312
+ side,
1313
+ opts,
1314
+ clients,
1315
+ vars,
1316
+ set.variants[variant]
1317
+ ).run();
1318
+ } catch (error) {
1319
+ legs[side] = { observed: {}, live: false, errors: [String(error.message ?? error)] };
1320
+ }
1213
1321
  }
1322
+ })();
1323
+ const stuck = await watch(sides, opts.rowTimeoutMs, () => view(flight, base, clients));
1324
+ for (const entry of flights) if (entry.flight === flight) flights.delete(entry);
1325
+ if (stuck) {
1326
+ flight.abandoned = true;
1327
+ return timedOut(stuck, base, made, sides, opts);
1214
1328
  }
1215
1329
  const row = {
1216
1330
  ...result(vector, {
@@ -1287,7 +1401,16 @@ function validate(set, opts) {
1287
1401
  return variants;
1288
1402
  }
1289
1403
 
1290
- export async function run(set, options) {
1404
+ export function run(set, options) {
1405
+ const flights = new Set();
1406
+ const inFlight = () =>
1407
+ [...flights].map(({ flight, base, clients }) => view(flight, base, clients));
1408
+ const running = runAll(set, options, flights).then((done) => ({ ...done, inFlight }));
1409
+ running.inFlight = inFlight;
1410
+ return running;
1411
+ }
1412
+
1413
+ async function runAll(set, options, flights) {
1291
1414
  const opts = { capabilities: {}, variant: Object.keys(set.variants), parallel: 1, ...options };
1292
1415
  const variants = validate(set, opts);
1293
1416
  const jobs = set.vectors.flatMap((vector) =>
@@ -1331,11 +1454,11 @@ export async function run(set, options) {
1331
1454
  });
1332
1455
  await previous;
1333
1456
  try {
1334
- results[index] = await executeRow(job, opts, clients, set, slot, index);
1457
+ results[index] = await executeRow(job, opts, clients, set, slot, index, flights);
1335
1458
  } finally {
1336
1459
  done();
1337
1460
  }
1338
- } else results[index] = await executeRow(job, opts, clients, set, slot, index);
1461
+ } else results[index] = await executeRow(job, opts, clients, set, slot, index, flights);
1339
1462
  } finally {
1340
1463
  await clients.close?.();
1341
1464
  }
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.7.7",
3
+ "setVersion": "1.7.8",
4
4
  "sources": [
5
5
  {
6
6
  "repo": "ai-ecoverse/slicc-cli",
7
7
  "ref": "2d5d98188667cd70180f825c7cba985399167732",
8
8
  "path": "test/integration/testdata/link/protect.js"
9
9
  },
10
+ {
11
+ "repo": "ai-ecoverse/slicc-cli",
12
+ "ref": "fe33052afd7bf94e98f38f85e95d29df7cb9e8c2",
13
+ "path": "test/integration/testdata/link/protect.js"
14
+ },
10
15
  {
11
16
  "repo": "ai-ecoverse/slicc-cdp",
12
17
  "ref": "4907a45d5ab8547483eaa013d4378deafe9f82d8",
@@ -4825,6 +4830,302 @@
4825
4830
  "observer": "client"
4826
4831
  }
4827
4832
  },
4833
+ {
4834
+ "id": "C3.redirect-popup-stays-out",
4835
+ "family": "C",
4836
+ "kind": "cdp",
4837
+ "scope": "any",
4838
+ "setup": [
4839
+ {
4840
+ "text": "an allowed opener with auto-attach opens a popup on a redirect chain that ends on a page that later navigates itself to another path of its origin, then auto-attaches again without detaching"
4841
+ },
4842
+ {
4843
+ "page": "tab",
4844
+ "url": "{ordinary}/doc?n=opener"
4845
+ },
4846
+ {
4847
+ "cdp": "Target.setAutoAttach",
4848
+ "params": {
4849
+ "autoAttach": true,
4850
+ "waitForDebuggerOnStart": true,
4851
+ "flatten": true
4852
+ },
4853
+ "session": "browser",
4854
+ "refusable": true,
4855
+ "refusal": {
4856
+ "match": "^slicc: refused"
4857
+ },
4858
+ "requires": ["autoAttach", "browserSession"]
4859
+ },
4860
+ {
4861
+ "children": [
4862
+ {
4863
+ "cdp": "Page.enable"
4864
+ }
4865
+ ],
4866
+ "where": {
4867
+ "openerId": "page"
4868
+ },
4869
+ "requires": ["autoAttach"]
4870
+ }
4871
+ ],
4872
+ "trigger": [
4873
+ {
4874
+ "page": "open",
4875
+ "url": "{target}/page?k=deferred&defer=3000&n={tag}&to={target}/doc?n={tag}-later",
4876
+ "hops": [
4877
+ {
4878
+ "origin": "{ordinary}",
4879
+ "code": 302
4880
+ },
4881
+ {
4882
+ "origin": "{cross}",
4883
+ "code": 307
4884
+ }
4885
+ ]
4886
+ },
4887
+ {
4888
+ "settle": {
4889
+ "served": "{target}/doc?n={tag}-later",
4890
+ "ms": 15000,
4891
+ "required": true
4892
+ }
4893
+ },
4894
+ {
4895
+ "cdp": "Target.setAutoAttach",
4896
+ "params": {
4897
+ "autoAttach": true,
4898
+ "waitForDebuggerOnStart": false,
4899
+ "flatten": true
4900
+ },
4901
+ "session": "browser",
4902
+ "refusable": true,
4903
+ "refusal": {
4904
+ "match": "refused"
4905
+ },
4906
+ "requires": ["autoAttach", "browserSession"]
4907
+ },
4908
+ {
4909
+ "wait": 1000
4910
+ },
4911
+ {
4912
+ "cdp": "Target.setAutoAttach",
4913
+ "params": {
4914
+ "autoAttach": false,
4915
+ "waitForDebuggerOnStart": false,
4916
+ "flatten": true
4917
+ },
4918
+ "session": "browser",
4919
+ "refusable": true,
4920
+ "refusal": {
4921
+ "match": "refused"
4922
+ },
4923
+ "requires": ["autoAttach", "browserSession"]
4924
+ }
4925
+ ],
4926
+ "targets": ["seven", "vault", "vault-x", "apex"],
4927
+ "requires": ["autoAttach", "browserSession"],
4928
+ "observe": {
4929
+ "committed": {
4930
+ "means": "the stand-in served the popup's first page on {target}",
4931
+ "dependsOn": [],
4932
+ "check": {
4933
+ "served": "/page",
4934
+ "origin": "{target}",
4935
+ "observer": "fixture"
4936
+ }
4937
+ },
4938
+ "navigatedLater": {
4939
+ "means": "the popup navigated itself to {target}/doc?n={tag}-later",
4940
+ "dependsOn": [],
4941
+ "check": {
4942
+ "served": "{target}/doc?n={tag}-later",
4943
+ "observer": "fixture"
4944
+ }
4945
+ },
4946
+ "attached": {
4947
+ "means": "the client got Target.attachedToTarget for the popup on {target}, at open or later",
4948
+ "dependsOn": ["refused.2"],
4949
+ "requires": ["autoAttach"],
4950
+ "check": {
4951
+ "attached": "{target}",
4952
+ "session": "child",
4953
+ "observer": "client"
4954
+ }
4955
+ }
4956
+ },
4957
+ "live": "the runner's own CDP sees {target}'s document load (title, marker or URL), or the guard returned its refusal for a refusable step",
4958
+ "control": {
4959
+ "origin": "ordinary",
4960
+ "via": "guard",
4961
+ "expected": {
4962
+ "committed": true,
4963
+ "navigatedLater": true,
4964
+ "attached": true
4965
+ }
4966
+ },
4967
+ "expected": {
4968
+ "committed": true,
4969
+ "navigatedLater": true,
4970
+ "attached": false
4971
+ },
4972
+ "liveCheck": {
4973
+ "any": [
4974
+ {
4975
+ "loaded": "{target}/doc?n={tag}-later",
4976
+ "observer": "fixture"
4977
+ },
4978
+ {
4979
+ "registration": "any",
4980
+ "observer": "client"
4981
+ }
4982
+ ],
4983
+ "observer": "client"
4984
+ }
4985
+ },
4986
+ {
4987
+ "id": "C3.direct-popup-stays-out",
4988
+ "family": "C",
4989
+ "kind": "cdp",
4990
+ "scope": "any",
4991
+ "setup": [
4992
+ {
4993
+ "text": "an allowed opener with auto-attach opens a popup straight on a page that later navigates itself to another path of its origin, then auto-attaches again without detaching"
4994
+ },
4995
+ {
4996
+ "page": "tab",
4997
+ "url": "{ordinary}/doc?n=opener"
4998
+ },
4999
+ {
5000
+ "cdp": "Target.setAutoAttach",
5001
+ "params": {
5002
+ "autoAttach": true,
5003
+ "waitForDebuggerOnStart": true,
5004
+ "flatten": true
5005
+ },
5006
+ "session": "browser",
5007
+ "refusable": true,
5008
+ "refusal": {
5009
+ "match": "^slicc: refused"
5010
+ },
5011
+ "requires": ["autoAttach", "browserSession"]
5012
+ },
5013
+ {
5014
+ "children": [
5015
+ {
5016
+ "cdp": "Page.enable"
5017
+ }
5018
+ ],
5019
+ "where": {
5020
+ "openerId": "page"
5021
+ },
5022
+ "requires": ["autoAttach"]
5023
+ }
5024
+ ],
5025
+ "trigger": [
5026
+ {
5027
+ "page": "open",
5028
+ "url": "{target}/page?k=deferred&defer=3000&n={tag}&to={target}/doc?n={tag}-later"
5029
+ },
5030
+ {
5031
+ "settle": {
5032
+ "served": "{target}/doc?n={tag}-later",
5033
+ "ms": 15000,
5034
+ "required": true
5035
+ }
5036
+ },
5037
+ {
5038
+ "cdp": "Target.setAutoAttach",
5039
+ "params": {
5040
+ "autoAttach": true,
5041
+ "waitForDebuggerOnStart": false,
5042
+ "flatten": true
5043
+ },
5044
+ "session": "browser",
5045
+ "refusable": true,
5046
+ "refusal": {
5047
+ "match": "refused"
5048
+ },
5049
+ "requires": ["autoAttach", "browserSession"]
5050
+ },
5051
+ {
5052
+ "wait": 1000
5053
+ },
5054
+ {
5055
+ "cdp": "Target.setAutoAttach",
5056
+ "params": {
5057
+ "autoAttach": false,
5058
+ "waitForDebuggerOnStart": false,
5059
+ "flatten": true
5060
+ },
5061
+ "session": "browser",
5062
+ "refusable": true,
5063
+ "refusal": {
5064
+ "match": "refused"
5065
+ },
5066
+ "requires": ["autoAttach", "browserSession"]
5067
+ }
5068
+ ],
5069
+ "targets": ["seven", "vault", "vault-x", "apex"],
5070
+ "requires": ["autoAttach", "browserSession"],
5071
+ "observe": {
5072
+ "committed": {
5073
+ "means": "the stand-in served the popup's first page on {target}",
5074
+ "dependsOn": [],
5075
+ "check": {
5076
+ "served": "/page",
5077
+ "origin": "{target}",
5078
+ "observer": "fixture"
5079
+ }
5080
+ },
5081
+ "navigatedLater": {
5082
+ "means": "the popup navigated itself to {target}/doc?n={tag}-later",
5083
+ "dependsOn": [],
5084
+ "check": {
5085
+ "served": "{target}/doc?n={tag}-later",
5086
+ "observer": "fixture"
5087
+ }
5088
+ },
5089
+ "attached": {
5090
+ "means": "the client got Target.attachedToTarget for the popup on {target}, at open or later",
5091
+ "dependsOn": ["refused.2"],
5092
+ "requires": ["autoAttach"],
5093
+ "check": {
5094
+ "attached": "{target}",
5095
+ "session": "child",
5096
+ "observer": "client"
5097
+ }
5098
+ }
5099
+ },
5100
+ "live": "the runner's own CDP sees {target}'s document load (title, marker or URL), or the guard returned its refusal for a refusable step",
5101
+ "control": {
5102
+ "origin": "ordinary",
5103
+ "via": "guard",
5104
+ "expected": {
5105
+ "committed": true,
5106
+ "navigatedLater": true,
5107
+ "attached": true
5108
+ }
5109
+ },
5110
+ "expected": {
5111
+ "committed": true,
5112
+ "navigatedLater": true,
5113
+ "attached": false
5114
+ },
5115
+ "liveCheck": {
5116
+ "any": [
5117
+ {
5118
+ "loaded": "{target}/doc?n={tag}-later",
5119
+ "observer": "fixture"
5120
+ },
5121
+ {
5122
+ "registration": "any",
5123
+ "observer": "client"
5124
+ }
5125
+ ],
5126
+ "observer": "client"
5127
+ }
5128
+ },
4828
5129
  {
4829
5130
  "id": "C4.static-cross-site",
4830
5131
  "family": "C",
@@ -5458,11 +5759,24 @@
5458
5759
  "means": "a child frame is a chrome-error page for {target}/doc?n={tag}",
5459
5760
  "dependsOn": ["refused.8"],
5460
5761
  "check": {
5461
- "result": "frameTree",
5462
- "path": "result",
5463
- "equals": {
5464
- "$regex": "chrome-error://"
5465
- },
5762
+ "any": [
5763
+ {
5764
+ "result": "frameTree",
5765
+ "path": "result",
5766
+ "equals": {
5767
+ "$regex": "chrome-error://"
5768
+ },
5769
+ "observer": "client"
5770
+ },
5771
+ {
5772
+ "frameBlocked": {
5773
+ "url": "{target}/doc?n={tag}",
5774
+ "session": "page",
5775
+ "frame": true
5776
+ },
5777
+ "observer": "unguarded"
5778
+ }
5779
+ ],
5466
5780
  "observer": "client"
5467
5781
  }
5468
5782
  }
@@ -5690,11 +6004,24 @@
5690
6004
  "means": "a child frame is a chrome-error page for {target}/doc?n={tag}",
5691
6005
  "dependsOn": ["refused.8"],
5692
6006
  "check": {
5693
- "result": "frameTree",
5694
- "path": "result",
5695
- "equals": {
5696
- "$regex": "chrome-error://"
5697
- },
6007
+ "any": [
6008
+ {
6009
+ "result": "frameTree",
6010
+ "path": "result",
6011
+ "equals": {
6012
+ "$regex": "chrome-error://"
6013
+ },
6014
+ "observer": "client"
6015
+ },
6016
+ {
6017
+ "frameBlocked": {
6018
+ "url": "{target}/doc?n={tag}",
6019
+ "session": "page",
6020
+ "frame": true
6021
+ },
6022
+ "observer": "unguarded"
6023
+ }
6024
+ ],
5698
6025
  "observer": "client"
5699
6026
  }
5700
6027
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.7.7",
3
+ "version": "1.7.8",
4
4
  "description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -65,7 +65,7 @@
65
65
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
66
66
  "release": "semantic-release",
67
67
  "test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
68
- "test:guard-integration": "node --test test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs",
68
+ "test:guard-integration": "node --test test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-diagnostics.test.mjs",
69
69
  "test:guard-sweep": "node scripts/test-guards.mjs",
70
70
  "test:guard-target": "node --test test/integration/guard-runner.test.mjs",
71
71
  "test:unit:guard": "node --experimental-test-coverage --test --test-coverage-include=guard-runner.mjs --test-coverage-include=fixture-server.mjs --test-coverage-lines=100 test/unit/guard-*.test.mjs"
@@ -75,7 +75,8 @@
75
75
  "wsOnly": { "const": true },
76
76
  "six": { "const": true },
77
77
  "sevenOpen": { "type": "boolean" },
78
- "sevenTabs": { "type": "array", "items": { "$ref": "#/$defs/tab" } }
78
+ "sevenTabs": { "type": "array", "items": { "$ref": "#/$defs/tab" } },
79
+ "tabs": { "type": "array", "items": { "$ref": "#/$defs/tab" } }
79
80
  }
80
81
  },
81
82
  "installed": {
@@ -47,7 +47,8 @@
47
47
  "probe",
48
48
  "origins",
49
49
  "installed",
50
- "six"
50
+ "six",
51
+ "tabs"
51
52
  ]
52
53
  }
53
54
  },
@@ -582,6 +582,9 @@
582
582
  "beacon": {
583
583
  "type": "string"
584
584
  },
585
+ "served": {
586
+ "type": "string"
587
+ },
585
588
  "title": {
586
589
  "type": "string"
587
590
  },