@ai-ecoverse/slicc-browsers 1.16.2 → 1.18.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.
@@ -29,6 +29,17 @@ function trigger(p) {
29
29
  return `<p>trigger</p><button onclick="go()">Go</button>${frame}<script>const to=${JSON.stringify(to)};function go(){const k=${JSON.stringify(kind)};if(k==='href'||k==='deferred')location.href=to;if(k==='replace')location.replace(to);if(k==='popup')window.open(to);if(k==='form'){const u=new URL(to);const f=document.createElement('form');f.action=u.origin+u.pathname;for(const [name,value] of u.searchParams){const i=document.createElement('input');i.type='hidden';i.name=name;i.value=value;f.append(i);}document.body.append(f);f.submit();}return 'went';}${kind === 'deferred' ? `setTimeout(go,${Number(p.get('defer') ?? 3000)});` : ''}</script>`;
30
30
  }
31
31
 
32
+ function nest(p) {
33
+ const n = p.get('n') ?? '';
34
+ const marker = escape(`frame-marker-${n}`);
35
+ const child =
36
+ p.get('kind') === 'srcdoc'
37
+ ? `<iframe name="${marker}" srcdoc="${escape(`<p>${marker}</p>`)}"></iframe>`
38
+ : `<iframe name="${marker}"></iframe><script>const f=document.querySelector('iframe');const p=f.contentDocument.createElement('p');p.textContent=f.name;f.contentDocument.body.append(p)</script>`;
39
+ const tag = JSON.stringify(n).replaceAll('<', '\\u003c');
40
+ return `<p>${marker}</p>${child}<script>const n=${tag};history.replaceState(null,'',location.pathname+'/path-'+'secret-'+n+'?query-'+'secret='+n+'#fragment-'+'secret-'+n)</script>`;
41
+ }
42
+
32
43
  function route(req, url, send, html, redirects) {
33
44
  const secret =
34
45
  redirects.get(`${url.hostname}${url.pathname}${url.search ?? ''}`) ??
@@ -80,6 +91,16 @@ function route(req, url, send, html, redirects) {
80
91
  `<script>navigator.sendBeacon(${JSON.stringify(pre)})</script><iframe src="${escape(to)}"></iframe>`
81
92
  );
82
93
  },
94
+ '/bfcache': () => {
95
+ const marker = JSON.stringify(`bfcache-marker-${p.get('n') ?? ''}`).replaceAll(
96
+ '<',
97
+ '\\u003c'
98
+ );
99
+ return html(
100
+ 'bfcache',
101
+ `<p id="bfcache-marker"></p><script>globalThis.__bfcacheMarker=${marker};sessionStorage.setItem('bfcache-marker',globalThis.__bfcacheMarker);document.querySelector('p').textContent=globalThis.__bfcacheMarker;globalThis.__bfcachePersisted=false;addEventListener('pageshow',e=>{globalThis.__bfcachePersisted=e.persisted});addEventListener('keydown',()=>{globalThis.__bfcacheKeys=(globalThis.__bfcacheKeys||0)+1})</script>`
102
+ );
103
+ },
83
104
  '/doc': () => document(url, html),
84
105
  '/csp': () => document(url, html),
85
106
  };
@@ -95,6 +116,7 @@ function route(req, url, send, html, redirects) {
95
116
  'canary',
96
117
  `<p data-canary="${escape(p.get('value') ?? 'planted')}">canary-marker ${escape(url.host)}</p>`
97
118
  );
119
+ if (url.pathname.startsWith('/nest')) return html('nest', nest(p));
98
120
  const handler = routes[url.pathname];
99
121
  if (handler) return handler();
100
122
  return send(404, 'missing', 'text/plain');
package/frame-read.mjs ADDED
@@ -0,0 +1,182 @@
1
+ import { outcome, parseURL, waitFor } from './guard-driver.mjs';
2
+ import { createObserverTarget } from './observer-target.mjs';
3
+
4
+ export const frameReads = ['frameTree', 'axFull', 'axPartial', 'document', 'node', 'snapshot'];
5
+
6
+ const unanswered = (out) => !out.ok && /timed out|closed/i.test(out.error?.message ?? '');
7
+
8
+ export function frameReadCalls(layout) {
9
+ return {
10
+ frameTree: [['Page.getFrameTree', {}]],
11
+ axFull: [
12
+ ['Accessibility.getFullAXTree', { frameId: layout.frame }],
13
+ ['Accessibility.getFullAXTree', { frameId: layout.child }],
14
+ ],
15
+ axPartial: [
16
+ ['Accessibility.getPartialAXTree', { backendNodeId: layout.marker, fetchRelatives: true }],
17
+ ],
18
+ document: [['DOM.getDocument', { depth: -1, pierce: true }]],
19
+ node: [
20
+ ['DOM.describeNode', { backendNodeId: layout.childDocument, depth: -1, pierce: true }],
21
+ ['DOM.getOuterHTML', { backendNodeId: layout.marker }],
22
+ ],
23
+ snapshot: [['DOMSnapshot.captureSnapshot', { computedStyles: [] }]],
24
+ };
25
+ }
26
+
27
+ export function frameNeedles(tag) {
28
+ return [
29
+ `frame-marker-${tag}`,
30
+ `path-secret-${tag}`,
31
+ `query-secret=${tag}`,
32
+ `fragment-secret-${tag}`,
33
+ ];
34
+ }
35
+
36
+ const shows = (value, needles) => {
37
+ const text = JSON.stringify(value) ?? '';
38
+ return needles.some((needle) => text.includes(needle));
39
+ };
40
+
41
+ function find(node, test) {
42
+ if (!node) return undefined;
43
+ if (test(node)) return node;
44
+ for (const next of [
45
+ ...(node.children ?? []),
46
+ node.contentDocument,
47
+ ...(node.shadowRoots ?? []),
48
+ ]) {
49
+ const hit = find(next, test);
50
+ if (hit) return hit;
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ export async function frameLayout(reader, session, { framed, marker, kind }) {
56
+ const { frameTree } = await reader.send('Page.getFrameTree', {}, session);
57
+ const host = (frameTree.childFrames ?? []).find((node) => {
58
+ const url = parseURL(node.frame.url);
59
+ return url?.origin === parseURL(framed)?.origin && url.pathname.startsWith('/nest');
60
+ });
61
+ const child = (host?.childFrames ?? []).find((node) => node.frame.name === marker);
62
+ if (!child || child.frame.url !== kind) return undefined;
63
+ const { root } = await reader.send('DOM.getDocument', { depth: -1, pierce: true }, session);
64
+ const owner = find(
65
+ root,
66
+ (node) =>
67
+ node.nodeName === 'IFRAME' &&
68
+ node.frameId === child.frame.id &&
69
+ (node.attributes ?? []).includes(marker)
70
+ );
71
+ const text = find(
72
+ owner?.contentDocument,
73
+ (node) => node.nodeType === 3 && node.nodeValue === marker
74
+ );
75
+ const paragraph = find(owner?.contentDocument, (node) => (node.children ?? []).includes(text));
76
+ if (!paragraph) return undefined;
77
+ return {
78
+ frame: host.frame.id,
79
+ child: child.frame.id,
80
+ childDocument: owner.contentDocument.backendNodeId,
81
+ marker: paragraph.backendNodeId,
82
+ };
83
+ }
84
+
85
+ async function read(client, session, calls, needles) {
86
+ let shown = false;
87
+ let silent = false;
88
+ for (const [method, params] of calls) {
89
+ const out = await outcome(client.send(method, params, session));
90
+ shown ||= shows(out, needles);
91
+ silent ||= unanswered(out);
92
+ }
93
+ return shown || (silent ? 'incomplete' : false);
94
+ }
95
+
96
+ async function enable(client, session) {
97
+ for (const method of ['Page.enable', 'DOM.enable', 'Accessibility.enable'])
98
+ await outcome(client.send(method, {}, session));
99
+ }
100
+
101
+ export async function frameRead({
102
+ client,
103
+ reader,
104
+ top,
105
+ framed,
106
+ tag,
107
+ mode,
108
+ side,
109
+ notes = [],
110
+ budget = 5000,
111
+ }) {
112
+ const kind = mode === 'srcdoc' ? 'about:srcdoc' : 'about:blank';
113
+ const marker = `frame-marker-${tag}`;
114
+ const needles = frameNeedles(tag);
115
+ const nest = `${framed}/nest?n=${encodeURIComponent(tag)}&kind=${mode}`;
116
+ const page = `${top}/framed?tag=${encodeURIComponent(tag)}&url=${encodeURIComponent(nest)}`;
117
+ const result = {};
118
+ const missing = [];
119
+ const done = () =>
120
+ missing.length ? { ...result, incomplete: `frame-read: ${missing.join('; ')}` } : result;
121
+ const opened = await outcome(
122
+ createObserverTarget(reader, { url: 'about:blank', background: true }, notes)
123
+ );
124
+ if (!opened.ok)
125
+ return { incomplete: `frame-read: observer setup failed: ${opened.error.message}` };
126
+ const { targetId } = opened.result;
127
+ let session;
128
+ try {
129
+ const observer = (await reader.send('Target.attachToTarget', { targetId, flatten: true }))
130
+ .sessionId;
131
+ await enable(reader, observer);
132
+ await reader.send('Page.navigate', { url: page }, observer);
133
+ let layout;
134
+ await waitFor(async () => {
135
+ const out = await outcome(frameLayout(reader, observer, { framed, marker, kind }));
136
+ layout = out.result;
137
+ return Boolean(layout);
138
+ }, budget);
139
+ if (!layout) {
140
+ await outcome(reader.send('Target.detachFromTarget', { sessionId: observer }));
141
+ return {
142
+ incomplete: `frame-read: no ${kind} child of ${framed} in the page's renderer within ${budget}ms`,
143
+ };
144
+ }
145
+ result.live = true;
146
+ const calls = frameReadCalls(layout);
147
+ const proven = {};
148
+ for (const name of frameReads) {
149
+ proven[name] = (await read(reader, observer, calls[name], needles)) === true;
150
+ if (!proven[name]) missing.push(`observer ${name} did not show the frame's content`);
151
+ }
152
+ await outcome(reader.send('Target.detachFromTarget', { sessionId: observer }));
153
+ const start = client.events.length;
154
+ const attached = await outcome(
155
+ client.send('Target.attachToTarget', { targetId, flatten: true })
156
+ );
157
+ session = attached.result?.sessionId;
158
+ result.attached = Boolean(session);
159
+ if (!session && (unanswered(attached) || side === 'control')) {
160
+ missing.push(`attach ${attached.error?.message ?? 'returned no session'}`);
161
+ return done();
162
+ }
163
+ if (session) await enable(client, session);
164
+ for (const name of frameReads) {
165
+ const value = session ? await read(client, session, calls[name], needles) : false;
166
+ if (value === 'incomplete') missing.push(`${name} unanswered`);
167
+ result[`${name}Shown`] = value === true || proven[name] ? value : 'incomplete';
168
+ }
169
+ result.eventsShown = shows(
170
+ client.events
171
+ .slice(start)
172
+ .filter(
173
+ (event) => session && (event.sessionId === session || event.params?.sessionId === session)
174
+ ),
175
+ needles
176
+ );
177
+ return done();
178
+ } finally {
179
+ if (session) await outcome(client.send('Target.detachFromTarget', { sessionId: session }));
180
+ await outcome(reader.send('Target.closeTarget', { targetId }));
181
+ }
182
+ }
package/guard-runner.mjs CHANGED
@@ -2,7 +2,9 @@ import { fixtureClient, prepareFixture } from './fixture-client.mjs';
2
2
 
3
3
  export { fixtureClient };
4
4
 
5
+ import { bfcacheRestore } from './bfcache-restore.mjs';
5
6
  import { extensionWorld } from './extension-world.mjs';
7
+ import { frameRead } from './frame-read.mjs';
6
8
  import {
7
9
  beacon,
8
10
  canary,
@@ -669,12 +671,21 @@ class Execution {
669
671
  );
670
672
  if (step.extensionWorld && out.result?.incomplete)
671
673
  this.errors.push(`extensionWorld ${step.extensionWorld}: ${out.result.incomplete}`);
672
- const probe = ['redirectSecret', 'unparseable', 'errorPage', 'extensionWorld'].find(
673
- (key) => step[key]
674
- );
674
+ const probe = [
675
+ 'redirectSecret',
676
+ 'unparseable',
677
+ 'errorPage',
678
+ 'extensionWorld',
679
+ 'frameRead',
680
+ 'bfcacheRestore',
681
+ ].find((key) => step[key]);
675
682
  if (probe && !out.ok) this.errors.push(`${probe}: ${out.error?.message ?? 'probe failed'}`);
676
683
  if (step.client && !out.ok) this.errors.push(`client exit: ${out.error.message}`);
677
- if (['errorPage', 'redirectSecret'].includes(probe) && out.ok && out.result?.incomplete)
684
+ if (
685
+ ['errorPage', 'redirectSecret', 'frameRead', 'bfcacheRestore'].includes(probe) &&
686
+ out.ok &&
687
+ out.result?.incomplete
688
+ )
678
689
  this.incomplete.push(`${probe}: ${out.result.incomplete}`);
679
690
  if (step.as && !out.result?.pending) this.calls[step.as] = out;
680
691
  if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
@@ -767,6 +778,36 @@ class Execution {
767
778
  budget: this.bound(5000),
768
779
  })
769
780
  );
781
+ if (step.bfcacheRestore)
782
+ return outcome(
783
+ bfcacheRestore({
784
+ client: this.client,
785
+ reader: this.reader,
786
+ delayClient: this.clients.client,
787
+ ordinary: this.vars.ordinary,
788
+ protectedOrigin: step.origin,
789
+ tag: this.vars.tag,
790
+ mode: step.bfcacheRestore,
791
+ forwardAudit: supported(['forwardAudit'], this.options.capabilities),
792
+ side: this.side,
793
+ notes: this.notes,
794
+ budget: this.bound(5000),
795
+ })
796
+ );
797
+ if (step.frameRead)
798
+ return outcome(
799
+ frameRead({
800
+ client: this.client,
801
+ reader: this.reader,
802
+ top: step.from,
803
+ framed: this.vars.target,
804
+ tag: this.vars.tag,
805
+ mode: step.frameRead,
806
+ side: this.side,
807
+ notes: this.notes,
808
+ budget: this.bound(5000),
809
+ })
810
+ );
770
811
  if (step.unparseable)
771
812
  return outcome(
772
813
  unparseableURLs({
@@ -1278,6 +1319,11 @@ class Execution {
1278
1319
  errors: this.errors,
1279
1320
  notes: this.notes,
1280
1321
  incomplete: this.incomplete,
1322
+ evidence: Object.fromEntries(
1323
+ Object.entries(this.calls)
1324
+ .filter(([, out]) => out?.result?.delay && out.result.probes)
1325
+ .map(([name, out]) => [name, out.result])
1326
+ ),
1281
1327
  };
1282
1328
  } finally {
1283
1329
  this.stopped = true;
@@ -1420,6 +1466,7 @@ function variables(targets, target, side, slot, index) {
1420
1466
  tag,
1421
1467
  cookie: `canary-${tag}`,
1422
1468
  };
1469
+ vars.protectedTarget = targets[target];
1423
1470
  vars.target = side === 'control' ? vars.plain : targets[target];
1424
1471
  vars.targetHost = new URL(vars.target).hostname;
1425
1472
  vars.responseOrigin = vars.target === vars.ordinary ? '*' : vars.ordinary;
@@ -1501,6 +1548,14 @@ async function timedOut(stuck, base, made, work, opts) {
1501
1548
  return failed;
1502
1549
  }
1503
1550
 
1551
+ function probeEvidence(legs) {
1552
+ return [legs.control.evidence, legs.guarded.evidence].some(
1553
+ (evidence) => Object.keys(evidence ?? {}).length
1554
+ )
1555
+ ? { evidence: { control: legs.control.evidence, guarded: legs.guarded.evidence } }
1556
+ : {};
1557
+ }
1558
+
1504
1559
  async function executeRow(job, opts, made, set, slot, index, flights) {
1505
1560
  const { target, variant } = job;
1506
1561
  const calls = new Map();
@@ -1588,6 +1643,7 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
1588
1643
  observations: { control: legs.control.observed, guarded: legs.guarded.observed },
1589
1644
  errors: [...legs.control.errors, ...legs.guarded.errors],
1590
1645
  designDependent: vector.designDependent ?? [],
1646
+ ...probeEvidence(legs),
1591
1647
  };
1592
1648
  const notes = [
1593
1649
  ...(legs.control.notes ?? []).map((note) => `control ${note}`),
@@ -1604,6 +1660,11 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
1604
1660
  }
1605
1661
 
1606
1662
  function observerSkip(vector, opts, made, clients) {
1663
+ if (
1664
+ vector.requires?.includes('inboundDelay') &&
1665
+ typeof clients.client.inboundDelay !== 'function'
1666
+ )
1667
+ return 'not-supported:inboundDelay';
1607
1668
  if (needsObserver(vector) && !clients.unguarded) return 'not-supported:unguarded-observer';
1608
1669
  if (!exitsClient(vector)) return undefined;
1609
1670
  if (!opts.clientFactory) return 'not-supported:clientExit';
@@ -1687,8 +1748,7 @@ function settleIncomplete(row, vector, base, legs) {
1687
1748
  live: legs.control.live === true && [true, 'incomplete'].includes(legs.guarded.live),
1688
1749
  });
1689
1750
  for (const side of ['control', 'guarded'])
1690
- if (legs[side].incomplete?.length && row[side] === 'fail' && filled[side] === 'pass')
1691
- row[side] = 'incomplete';
1751
+ if (legs[side].incomplete?.length && filled[side] === 'pass') row[side] = 'incomplete';
1692
1752
  if (legs.guarded.live === 'incomplete' && filled.live === 'pass') row.live = 'incomplete';
1693
1753
  row.why = [filled.why, `incomplete: ${incomplete.join('; ')}`].filter(Boolean).join('; ');
1694
1754
  }