@webhands/core 0.5.0 → 0.6.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 +20 -4
- package/dist/errors.d.ts +92 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +100 -0
- package/dist/errors.js.map +1 -1
- package/dist/hand-host.d.ts +198 -5
- package/dist/hand-host.d.ts.map +1 -1
- package/dist/hand-host.js +664 -21
- package/dist/hand-host.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/playwright-attach-transport.d.ts +8 -1
- package/dist/playwright-attach-transport.d.ts.map +1 -1
- package/dist/playwright-attach-transport.js +19 -4
- package/dist/playwright-attach-transport.js.map +1 -1
- package/dist/playwright-launch-transport.d.ts.map +1 -1
- package/dist/playwright-launch-transport.js +13 -4
- package/dist/playwright-launch-transport.js.map +1 -1
- package/dist/profile-location.d.ts +19 -0
- package/dist/profile-location.d.ts.map +1 -1
- package/dist/profile-location.js +21 -0
- package/dist/profile-location.js.map +1 -1
- package/dist/seam.d.ts +501 -7
- package/dist/seam.d.ts.map +1 -1
- package/dist/seam.js +31 -0
- package/dist/seam.js.map +1 -1
- package/dist/session-rpc.d.ts +63 -1
- package/dist/session-rpc.d.ts.map +1 -1
- package/dist/session-rpc.js +174 -11
- package/dist/session-rpc.js.map +1 -1
- package/dist/stub-transport.d.ts.map +1 -1
- package/dist/stub-transport.js +74 -6
- package/dist/stub-transport.js.map +1 -1
- package/dist/test-fixtures/fixture-pages.d.ts.map +1 -1
- package/dist/test-fixtures/fixture-pages.js +994 -0
- package/dist/test-fixtures/fixture-pages.js.map +1 -1
- package/dist/test-fixtures/fixture-server.d.ts.map +1 -1
- package/dist/test-fixtures/fixture-server.js +33 -3
- package/dist/test-fixtures/fixture-server.js.map +1 -1
- package/package.json +1 -1
- package/src/errors.ts +134 -1
- package/src/hand-host.ts +797 -21
- package/src/index.ts +20 -1
- package/src/playwright-attach-transport.ts +25 -3
- package/src/playwright-launch-transport.ts +13 -2
- package/src/profile-location.ts +25 -0
- package/src/seam.ts +535 -7
- package/src/session-rpc.ts +276 -14
- package/src/stub-transport.ts +83 -6
- package/src/test-fixtures/fixture-pages.ts +1010 -0
- package/src/test-fixtures/fixture-server.ts +32 -3
|
@@ -101,6 +101,36 @@ const CLICK_TYPE = `<!doctype html>
|
|
|
101
101
|
</html>
|
|
102
102
|
`;
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A page whose submit button triggers a SLOW navigation (PRD story 8, the
|
|
106
|
+
* "real submit button" path). Clicking `#slow-submit` navigates to
|
|
107
|
+
* `index.html?delayMs=1500`; the fixture server holds that response back ~1.5s,
|
|
108
|
+
* so the navigation the click schedules takes far longer than the verb's short
|
|
109
|
+
* actionability budget. The element is a perfectly normal, visible, actionable
|
|
110
|
+
* button, so `click` must perform the click and NOT mistake the slow post-click
|
|
111
|
+
* navigation for a non-actionable element (which would wrongly route it to the
|
|
112
|
+
* dispatch escape and re-click a page already navigating away). The test
|
|
113
|
+
* asserts the effect: after the click, the reader ends up on `index.html`.
|
|
114
|
+
*/
|
|
115
|
+
const SLOW_SUBMIT = `<!doctype html>
|
|
116
|
+
<html lang="en">
|
|
117
|
+
<head>
|
|
118
|
+
<meta charset="utf-8" />
|
|
119
|
+
<title>slow submit fixture</title>
|
|
120
|
+
</head>
|
|
121
|
+
<body>
|
|
122
|
+
<h1 id="heading">Slow Submit Fixture</h1>
|
|
123
|
+
<!-- GET form: the action's query is dropped and rebuilt from the fields,
|
|
124
|
+
so delayMs is carried as a hidden field (a bare action query would be
|
|
125
|
+
lost on submit). -->
|
|
126
|
+
<form action="/index.html" method="get">
|
|
127
|
+
<input type="hidden" name="delayMs" value="1500" />
|
|
128
|
+
<input id="slow-submit" type="submit" value="Continue" />
|
|
129
|
+
</form>
|
|
130
|
+
</body>
|
|
131
|
+
</html>
|
|
132
|
+
`;
|
|
133
|
+
|
|
104
134
|
/**
|
|
105
135
|
* A page that NAVIGATES itself to `index.html` ~150ms after load, the way a
|
|
106
136
|
* landing/redirect page bounces to the real destination. `goto` here settles on
|
|
@@ -199,12 +229,992 @@ const COOKIES = `<!doctype html>
|
|
|
199
229
|
</html>
|
|
200
230
|
`;
|
|
201
231
|
|
|
232
|
+
/**
|
|
233
|
+
* A structured-LIST page for the Tier-1 `query` extraction verb plus the state
|
|
234
|
+
* verbs `exists`/`count`/`isVisible`/`getAttribute` (prd
|
|
235
|
+
* `broaden-agent-verb-surface`, R2). Controlled, deterministic markup the seam
|
|
236
|
+
* tests assert one ROW PER MATCH against, never third-party DOM:
|
|
237
|
+
*
|
|
238
|
+
* - `.result` is a LIST of three rows (a mini shopping result set), each with a
|
|
239
|
+
* distinct `data-asin` attribute, a `.title` text, a `.price` text, and an
|
|
240
|
+
* anchor with an `href` — so a multi-match `query` returns three rows and
|
|
241
|
+
* `--limit` can bound them. The title/price live in CHILD elements so a row's
|
|
242
|
+
* `innerText` property carries the whole composed text (proving `props` reads
|
|
243
|
+
* live runtime state, not markup).
|
|
244
|
+
* - `#optin` is a checkbox whose `checked` ATTRIBUTE is absent in the markup but
|
|
245
|
+
* whose live `checked` PROPERTY is set `true` by script after load — the
|
|
246
|
+
* controlled attrs-vs-props DIVERGENCE: `attrs:['checked']` reads `null`
|
|
247
|
+
* (no markup attribute) while `props:['checked']` reads `true` (live state).
|
|
248
|
+
* It also carries `value="on"` (a present markup attribute) and a runtime
|
|
249
|
+
* `type` property, so an attribute and a property that genuinely differ are
|
|
250
|
+
* both observable on one element.
|
|
251
|
+
* - `#hidden-row` is a present-but-HIDDEN element (`display:none`) carrying a
|
|
252
|
+
* `data-sitekey`, so `pw:['visible']` reads `false` for it (actionability-
|
|
253
|
+
* grade visibility) while a `getAttribute('data-sitekey')` still reads its
|
|
254
|
+
* value, and a VISIBLE `#shown-row` reads `pw:['visible'] === true`.
|
|
255
|
+
* - There is deliberately NO `.absent` element, so a `query`/`count`/`exists`
|
|
256
|
+
* against `.absent` exercises the empty match-set (`[]` / `0` / `false`).
|
|
257
|
+
*/
|
|
258
|
+
const QUERY_LIST = `<!doctype html>
|
|
259
|
+
<html lang="en">
|
|
260
|
+
<head>
|
|
261
|
+
<meta charset="utf-8" />
|
|
262
|
+
<title>query list fixture</title>
|
|
263
|
+
</head>
|
|
264
|
+
<body>
|
|
265
|
+
<h1 id="heading">Query List Fixture</h1>
|
|
266
|
+
|
|
267
|
+
<ul id="results">
|
|
268
|
+
<li class="result" data-asin="A001">
|
|
269
|
+
<a class="link" href="/item/A001"
|
|
270
|
+
><span class="title">Alpha Widget</span></a
|
|
271
|
+
>
|
|
272
|
+
<span class="price">$10.00</span>
|
|
273
|
+
</li>
|
|
274
|
+
<li class="result" data-asin="B002">
|
|
275
|
+
<a class="link" href="/item/B002"
|
|
276
|
+
><span class="title">Bravo Widget</span></a
|
|
277
|
+
>
|
|
278
|
+
<span class="price">$20.00</span>
|
|
279
|
+
</li>
|
|
280
|
+
<li class="result" data-asin="C003">
|
|
281
|
+
<a class="link" href="/item/C003"
|
|
282
|
+
><span class="title">Charlie Widget</span></a
|
|
283
|
+
>
|
|
284
|
+
<span class="price">$30.00</span>
|
|
285
|
+
</li>
|
|
286
|
+
</ul>
|
|
287
|
+
|
|
288
|
+
<!-- attrs-vs-props divergence: no \`checked\` attribute in markup, but the
|
|
289
|
+
live \`checked\` property is set true after load. -->
|
|
290
|
+
<input id="optin" type="checkbox" value="on" />
|
|
291
|
+
|
|
292
|
+
<!-- a present-but-hidden element carrying a readable attribute -->
|
|
293
|
+
<div
|
|
294
|
+
id="hidden-row"
|
|
295
|
+
class="sitekey"
|
|
296
|
+
data-sitekey="sk-hidden-123"
|
|
297
|
+
style="display: none"
|
|
298
|
+
></div>
|
|
299
|
+
<div id="shown-row" class="sitekey" data-sitekey="sk-shown-456">visible</div>
|
|
300
|
+
|
|
301
|
+
<script>
|
|
302
|
+
// Toggle the live property WITHOUT touching the markup attribute, so
|
|
303
|
+
// attrs:['checked'] (null) and props:['checked'] (true) genuinely differ.
|
|
304
|
+
document.getElementById('optin').checked = true;
|
|
305
|
+
</script>
|
|
306
|
+
</body>
|
|
307
|
+
</html>
|
|
308
|
+
`;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A page exercising the Tier-2 `press` verb (prd `broaden-agent-verb-surface`,
|
|
312
|
+
* story 8). It RECORDS keyboard events deterministically so a test asserts the
|
|
313
|
+
* verb fired the right key, not merely that it did not throw:
|
|
314
|
+
*
|
|
315
|
+
* - `#focus-input` is a text input. A `keydown` listener appends each event's
|
|
316
|
+
* `key` (and a `+` for each held modifier) to `#keylog`, so a single key, a
|
|
317
|
+
* named key (Enter/ArrowLeft), and a chord (`Control+a`) are all observable.
|
|
318
|
+
* - `#counter` is driven by ArrowUp/ArrowDown on a SECOND input (`#game`),
|
|
319
|
+
* modelling a game's keyboard control: ArrowUp increments, ArrowDown
|
|
320
|
+
* decrements, so `press('ArrowUp')` at that locator moves the counter.
|
|
321
|
+
* - The page focuses `#focus-input` on load, so a `press(key)` with NO locator
|
|
322
|
+
* (the focused-element form) lands there — the test asserts the focused-element
|
|
323
|
+
* path AND the at-a-locator path against the SAME recorder.
|
|
324
|
+
*/
|
|
325
|
+
const KEYBOARD = `<!doctype html>
|
|
326
|
+
<html lang="en">
|
|
327
|
+
<head>
|
|
328
|
+
<meta charset="utf-8" />
|
|
329
|
+
<title>keyboard fixture</title>
|
|
330
|
+
</head>
|
|
331
|
+
<body>
|
|
332
|
+
<h1 id="heading">Keyboard Fixture</h1>
|
|
333
|
+
<input id="focus-input" type="text" aria-label="Focus Input" />
|
|
334
|
+
<pre id="keylog"></pre>
|
|
335
|
+
|
|
336
|
+
<input id="game" type="text" aria-label="Game" />
|
|
337
|
+
<p id="counter">0</p>
|
|
338
|
+
|
|
339
|
+
<script>
|
|
340
|
+
function describe(e) {
|
|
341
|
+
var mods = '';
|
|
342
|
+
if (e.ctrlKey) mods += 'Control+';
|
|
343
|
+
if (e.altKey) mods += 'Alt+';
|
|
344
|
+
if (e.shiftKey) mods += 'Shift+';
|
|
345
|
+
if (e.metaKey) mods += 'Meta+';
|
|
346
|
+
return mods + e.key;
|
|
347
|
+
}
|
|
348
|
+
var MODIFIER_KEYS = {Control: 1, Alt: 1, Shift: 1, Meta: 1};
|
|
349
|
+
var log = document.getElementById('keylog');
|
|
350
|
+
document
|
|
351
|
+
.getElementById('focus-input')
|
|
352
|
+
.addEventListener('keydown', function (e) {
|
|
353
|
+
// A chord (Control+a) fires a bare-modifier keydown (key === 'Control')
|
|
354
|
+
// before the real key; ignore those so the log records one entry per
|
|
355
|
+
// logical press.
|
|
356
|
+
if (MODIFIER_KEYS[e.key]) return;
|
|
357
|
+
log.textContent += (log.textContent ? ',' : '') + describe(e);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
var counter = document.getElementById('counter');
|
|
361
|
+
document.getElementById('game').addEventListener('keydown', function (e) {
|
|
362
|
+
if (e.key === 'ArrowUp')
|
|
363
|
+
counter.textContent = String(Number(counter.textContent) + 1);
|
|
364
|
+
if (e.key === 'ArrowDown')
|
|
365
|
+
counter.textContent = String(Number(counter.textContent) - 1);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Focus the recorder input so a press() with NO locator lands here.
|
|
369
|
+
document.getElementById('focus-input').focus();
|
|
370
|
+
</script>
|
|
371
|
+
</body>
|
|
372
|
+
</html>
|
|
373
|
+
`;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* A page exercising the Tier-2 `hover` verb (prd `broaden-agent-verb-surface`,
|
|
377
|
+
* story 9). `#menu` reveals a `#menu-item` ONLY while `#menu` is hovered (CSS
|
|
378
|
+
* `:hover`), AND a `mouseenter` listener flips `#hover-state` to `entered`, so
|
|
379
|
+
* the test asserts the hover affordance fired (the item became visible / the
|
|
380
|
+
* state changed), not merely that `hover` did not throw — something `click`
|
|
381
|
+
* could not surface.
|
|
382
|
+
*/
|
|
383
|
+
const HOVER = `<!doctype html>
|
|
384
|
+
<html lang="en">
|
|
385
|
+
<head>
|
|
386
|
+
<meta charset="utf-8" />
|
|
387
|
+
<title>hover fixture</title>
|
|
388
|
+
<style>
|
|
389
|
+
#menu-item {
|
|
390
|
+
display: none;
|
|
391
|
+
}
|
|
392
|
+
#menu:hover #menu-item {
|
|
393
|
+
display: block;
|
|
394
|
+
}
|
|
395
|
+
</style>
|
|
396
|
+
</head>
|
|
397
|
+
<body>
|
|
398
|
+
<h1 id="heading">Hover Fixture</h1>
|
|
399
|
+
<div id="menu">
|
|
400
|
+
Menu
|
|
401
|
+
<div id="menu-item">Reveal-on-hover item</div>
|
|
402
|
+
</div>
|
|
403
|
+
<p id="hover-state">idle</p>
|
|
404
|
+
<script>
|
|
405
|
+
document.getElementById('menu').addEventListener('mouseenter', function () {
|
|
406
|
+
document.getElementById('hover-state').textContent = 'entered';
|
|
407
|
+
});
|
|
408
|
+
</script>
|
|
409
|
+
</body>
|
|
410
|
+
</html>
|
|
411
|
+
`;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* A page exercising the Tier-2 `select` verb (prd `broaden-agent-verb-surface`,
|
|
415
|
+
* story 10). `#color` is a native `<select>` with three options whose VALUE and
|
|
416
|
+
* LABEL deliberately DIFFER (value `r` / label `Red`, etc.), so a select-by-value
|
|
417
|
+
* and a select-by-label are distinguishable. A `change` listener mirrors the
|
|
418
|
+
* chosen value into `#chosen`, and the test also reads the live `value`
|
|
419
|
+
* property, so the choice is asserted as reflected in the element's STATE.
|
|
420
|
+
*/
|
|
421
|
+
const SELECT = `<!doctype html>
|
|
422
|
+
<html lang="en">
|
|
423
|
+
<head>
|
|
424
|
+
<meta charset="utf-8" />
|
|
425
|
+
<title>select fixture</title>
|
|
426
|
+
</head>
|
|
427
|
+
<body>
|
|
428
|
+
<h1 id="heading">Select Fixture</h1>
|
|
429
|
+
<select id="color" aria-label="Color">
|
|
430
|
+
<option value="r">Red</option>
|
|
431
|
+
<option value="g">Green</option>
|
|
432
|
+
<option value="b">Blue</option>
|
|
433
|
+
</select>
|
|
434
|
+
<p id="chosen">r</p>
|
|
435
|
+
<script>
|
|
436
|
+
var sel = document.getElementById('color');
|
|
437
|
+
sel.addEventListener('change', function () {
|
|
438
|
+
document.getElementById('chosen').textContent = sel.value;
|
|
439
|
+
});
|
|
440
|
+
</script>
|
|
441
|
+
</body>
|
|
442
|
+
</html>
|
|
443
|
+
`;
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* A page exercising the Tier-2 `scroll` verb (prd `broaden-agent-verb-surface`,
|
|
447
|
+
* story 11). The body is much taller than the viewport, with `#far-target` near
|
|
448
|
+
* the BOTTOM (off-viewport at load), so:
|
|
449
|
+
*
|
|
450
|
+
* - `scroll --to (#far-target)` brings it into view (its `pw:['visible']` /
|
|
451
|
+
* `scrollY` change is observable).
|
|
452
|
+
* - `scroll --by 0,400` scrolls the page DOWN by 400px, so `window.scrollY`
|
|
453
|
+
* moves by the given amount.
|
|
454
|
+
*
|
|
455
|
+
* A tall `#spacer` provides the scroll distance; `#far-target` sits after it.
|
|
456
|
+
*/
|
|
457
|
+
const SCROLL = `<!doctype html>
|
|
458
|
+
<html lang="en">
|
|
459
|
+
<head>
|
|
460
|
+
<meta charset="utf-8" />
|
|
461
|
+
<title>scroll fixture</title>
|
|
462
|
+
</head>
|
|
463
|
+
<body>
|
|
464
|
+
<h1 id="heading">Scroll Fixture</h1>
|
|
465
|
+
<div id="spacer" style="height: 4000px">spacer</div>
|
|
466
|
+
<div id="far-target">Far target at the bottom</div>
|
|
467
|
+
</body>
|
|
468
|
+
</html>
|
|
469
|
+
`;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* A page exercising the Tier-2 `drag` verb (prd `broaden-agent-verb-surface`,
|
|
473
|
+
* story 12). `#drag-source` is a draggable element and `#drop-target` is a drop
|
|
474
|
+
* zone wired with the HTML5 drag-and-drop events: on `drop`, the handler moves
|
|
475
|
+
* the source INTO the target and flips `#drop-state` to `dropped`, so the test
|
|
476
|
+
* asserts the drop handler RAN (the DOM order / state changed), not merely that
|
|
477
|
+
* `drag` did not throw.
|
|
478
|
+
*/
|
|
479
|
+
const DRAG = `<!doctype html>
|
|
480
|
+
<html lang="en">
|
|
481
|
+
<head>
|
|
482
|
+
<meta charset="utf-8" />
|
|
483
|
+
<title>drag fixture</title>
|
|
484
|
+
<style>
|
|
485
|
+
#drag-source,
|
|
486
|
+
#drop-target {
|
|
487
|
+
width: 120px;
|
|
488
|
+
height: 120px;
|
|
489
|
+
margin: 10px;
|
|
490
|
+
}
|
|
491
|
+
#drag-source {
|
|
492
|
+
background: #cde;
|
|
493
|
+
}
|
|
494
|
+
#drop-target {
|
|
495
|
+
background: #edc;
|
|
496
|
+
}
|
|
497
|
+
</style>
|
|
498
|
+
</head>
|
|
499
|
+
<body>
|
|
500
|
+
<h1 id="heading">Drag Fixture</h1>
|
|
501
|
+
<div id="drag-source" draggable="true">Drag me</div>
|
|
502
|
+
<div id="drop-target">Drop here</div>
|
|
503
|
+
<p id="drop-state">idle</p>
|
|
504
|
+
<script>
|
|
505
|
+
var source = document.getElementById('drag-source');
|
|
506
|
+
var target = document.getElementById('drop-target');
|
|
507
|
+
source.addEventListener('dragstart', function (e) {
|
|
508
|
+
e.dataTransfer.setData('text/plain', 'drag-source');
|
|
509
|
+
});
|
|
510
|
+
target.addEventListener('dragover', function (e) {
|
|
511
|
+
e.preventDefault();
|
|
512
|
+
});
|
|
513
|
+
target.addEventListener('drop', function (e) {
|
|
514
|
+
e.preventDefault();
|
|
515
|
+
target.appendChild(source);
|
|
516
|
+
document.getElementById('drop-state').textContent = 'dropped';
|
|
517
|
+
});
|
|
518
|
+
</script>
|
|
519
|
+
</body>
|
|
520
|
+
</html>
|
|
521
|
+
`;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The SAME-ORIGIN child frame embedded by {@link FRAME_PARENT} (Tier-3
|
|
525
|
+
* frame-scoped `eval`, prd `broaden-agent-verb-surface`, story 13). It carries
|
|
526
|
+
* controlled, deterministic state the parent's top document CANNOT see, so a
|
|
527
|
+
* frame-scoped `eval` is proved to actually land IN the child:
|
|
528
|
+
*
|
|
529
|
+
* - `#child-marker` holds a text only present in the child document, so
|
|
530
|
+
* `eval --frame` reading it proves the expression ran in the child (the top
|
|
531
|
+
* document's `document.getElementById('child-marker')` is `null`).
|
|
532
|
+
* - `window.__childValue` is a runtime-only JS value the top frame's page world
|
|
533
|
+
* cannot reach, the "read a runtime-only value" case.
|
|
534
|
+
* - `window.fireCallback()` flips `#callback-state` to `fired` and sets
|
|
535
|
+
* `window.__callbackFired`, modelling a captcha `data-callback`: a frame-scoped
|
|
536
|
+
* `eval` firing it has an OBSERVABLE effect inside the child (the
|
|
537
|
+
* backward-compatible top-frame `eval` cannot reach it).
|
|
538
|
+
*/
|
|
539
|
+
const FRAME_CHILD = `<!doctype html>
|
|
540
|
+
<html lang="en">
|
|
541
|
+
<head>
|
|
542
|
+
<meta charset="utf-8" />
|
|
543
|
+
<title>frame child fixture</title>
|
|
544
|
+
</head>
|
|
545
|
+
<body>
|
|
546
|
+
<h1 id="child-heading">Frame Child</h1>
|
|
547
|
+
<p id="child-marker">child-only-value</p>
|
|
548
|
+
<p id="callback-state">idle</p>
|
|
549
|
+
<script>
|
|
550
|
+
window.__childValue = 'runtime-only-child-value';
|
|
551
|
+
window.__callbackFired = false;
|
|
552
|
+
window.fireCallback = function () {
|
|
553
|
+
window.__callbackFired = true;
|
|
554
|
+
document.getElementById('callback-state').textContent = 'fired';
|
|
555
|
+
return 'callback-result';
|
|
556
|
+
};
|
|
557
|
+
</script>
|
|
558
|
+
</body>
|
|
559
|
+
</html>
|
|
560
|
+
`;
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The PARENT page for the Tier-3 frame-scoped `eval` (prd
|
|
564
|
+
* `broaden-agent-verb-surface`, story 13). It embeds {@link FRAME_CHILD} as a
|
|
565
|
+
* SAME-ORIGIN child frame (`#main-iframe`, relative `src`), mimicking the
|
|
566
|
+
* Imperva `#main-iframe` structure the idea names. The top document carries a
|
|
567
|
+
* DIFFERENT `#child-marker`-less state so a test can tell the top frame from the
|
|
568
|
+
* child frame:
|
|
569
|
+
*
|
|
570
|
+
* - `#top-marker` holds a top-document-only text; there is deliberately no
|
|
571
|
+
* `#child-marker` in the top document, so `eval` with no frame reading
|
|
572
|
+
* `document.getElementById('child-marker')` is `null` (backward-compatible
|
|
573
|
+
* top-frame default) while `eval --frame '#main-iframe'` reading it is the
|
|
574
|
+
* child value.
|
|
575
|
+
* - `#cross-iframe` is an iframe whose `src` is set by the TEST to a SECOND
|
|
576
|
+
* fixture server (a different port == a different origin), so a frame-scoped
|
|
577
|
+
* `eval` against it must fail LOUD with the cross-origin typed error. It is
|
|
578
|
+
* left blank here and pointed cross-origin by the test (the fixture server
|
|
579
|
+
* serves one origin, so the cross-origin half is wired in the test).
|
|
580
|
+
*/
|
|
581
|
+
const FRAME_PARENT = `<!doctype html>
|
|
582
|
+
<html lang="en">
|
|
583
|
+
<head>
|
|
584
|
+
<meta charset="utf-8" />
|
|
585
|
+
<title>frame parent fixture</title>
|
|
586
|
+
</head>
|
|
587
|
+
<body>
|
|
588
|
+
<h1 id="heading">Frame Parent</h1>
|
|
589
|
+
<p id="top-marker">top-only-value</p>
|
|
590
|
+
<iframe
|
|
591
|
+
id="main-iframe"
|
|
592
|
+
name="main-iframe"
|
|
593
|
+
src="/frame-child.html"
|
|
594
|
+
width="320"
|
|
595
|
+
height="200"
|
|
596
|
+
></iframe>
|
|
597
|
+
</body>
|
|
598
|
+
</html>
|
|
599
|
+
`;
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* A page exercising the Tier-4 coordinate `mouse` verb (prd
|
|
603
|
+
* `broaden-agent-verb-surface`, R3, stories 17-18). It records WHERE a
|
|
604
|
+
* coordinate click/press landed so a test asserts the verb acted at the
|
|
605
|
+
* intended VIEWPORT pixel, and proves the VIEWPORT-screenshot <-> `mouse`
|
|
606
|
+
* coordinate contract end to end:
|
|
607
|
+
*
|
|
608
|
+
* - `#hit-target` is an absolutely-positioned box at a KNOWN viewport position
|
|
609
|
+
* and size. Its `click` handler flips `#hit-state` to `hit` and records the
|
|
610
|
+
* event's `clientX,clientY` into `#hit-coords`, so a `mouse({action:'click',
|
|
611
|
+
* x, y})` at a coordinate OVER the box runs the box's handler (assert the
|
|
612
|
+
* effect), and a coordinate OUTSIDE it does NOT (the look-then-click loop is
|
|
613
|
+
* only meaningful if the click lands where the agent aimed).
|
|
614
|
+
* - `#move-target` flips `#move-state` to `moved` on `mouseenter`, so a bare
|
|
615
|
+
* `mouse({action:'move', x, y})` (no button) is observable too.
|
|
616
|
+
* - `#down-up-target` records `mousedown`/`mouseup` so the press/release halves
|
|
617
|
+
* are exercised.
|
|
618
|
+
*
|
|
619
|
+
* The page pins `margin:0` and a deterministic layout so the box's viewport
|
|
620
|
+
* coordinates are stable; the test reads the box's real `getBoundingClientRect`
|
|
621
|
+
* (via `eval`) and clicks its centre, so it never hard-codes a pixel that a
|
|
622
|
+
* platform default could shift.
|
|
623
|
+
*/
|
|
624
|
+
const COORDINATE = `<!doctype html>
|
|
625
|
+
<html lang="en">
|
|
626
|
+
<head>
|
|
627
|
+
<meta charset="utf-8" />
|
|
628
|
+
<title>coordinate fixture</title>
|
|
629
|
+
<style>
|
|
630
|
+
html,
|
|
631
|
+
body {
|
|
632
|
+
margin: 0;
|
|
633
|
+
padding: 0;
|
|
634
|
+
}
|
|
635
|
+
#hit-target {
|
|
636
|
+
position: absolute;
|
|
637
|
+
left: 100px;
|
|
638
|
+
top: 80px;
|
|
639
|
+
width: 120px;
|
|
640
|
+
height: 90px;
|
|
641
|
+
background: #4a7;
|
|
642
|
+
}
|
|
643
|
+
#move-target {
|
|
644
|
+
position: absolute;
|
|
645
|
+
left: 300px;
|
|
646
|
+
top: 80px;
|
|
647
|
+
width: 80px;
|
|
648
|
+
height: 80px;
|
|
649
|
+
background: #74a;
|
|
650
|
+
}
|
|
651
|
+
#down-up-target {
|
|
652
|
+
position: absolute;
|
|
653
|
+
left: 100px;
|
|
654
|
+
top: 220px;
|
|
655
|
+
width: 120px;
|
|
656
|
+
height: 90px;
|
|
657
|
+
background: #a74;
|
|
658
|
+
}
|
|
659
|
+
</style>
|
|
660
|
+
</head>
|
|
661
|
+
<body>
|
|
662
|
+
<div id="hit-target"></div>
|
|
663
|
+
<div id="move-target"></div>
|
|
664
|
+
<div id="down-up-target"></div>
|
|
665
|
+
<p id="hit-state">untouched</p>
|
|
666
|
+
<p id="hit-coords"></p>
|
|
667
|
+
<p id="move-state">idle</p>
|
|
668
|
+
<p id="down-up-state">idle</p>
|
|
669
|
+
<script>
|
|
670
|
+
var hit = document.getElementById('hit-target');
|
|
671
|
+
hit.addEventListener('click', function (e) {
|
|
672
|
+
document.getElementById('hit-state').textContent = 'hit';
|
|
673
|
+
document.getElementById('hit-coords').textContent =
|
|
674
|
+
Math.round(e.clientX) + ',' + Math.round(e.clientY);
|
|
675
|
+
});
|
|
676
|
+
document
|
|
677
|
+
.getElementById('move-target')
|
|
678
|
+
.addEventListener('mouseenter', function () {
|
|
679
|
+
document.getElementById('move-state').textContent = 'moved';
|
|
680
|
+
});
|
|
681
|
+
var du = document.getElementById('down-up-target');
|
|
682
|
+
du.addEventListener('mousedown', function () {
|
|
683
|
+
document.getElementById('down-up-state').textContent = 'down';
|
|
684
|
+
});
|
|
685
|
+
du.addEventListener('mouseup', function () {
|
|
686
|
+
var el = document.getElementById('down-up-state');
|
|
687
|
+
el.textContent = el.textContent === 'down' ? 'down-up' : 'up-only';
|
|
688
|
+
});
|
|
689
|
+
</script>
|
|
690
|
+
</body>
|
|
691
|
+
</html>
|
|
692
|
+
`;
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* A page exercising the Tier-4 `screenshot` verb's ELEMENT scope (prd
|
|
696
|
+
* `broaden-agent-verb-surface`, R3, story 17). `#widget` is a fixed-size,
|
|
697
|
+
* solid-colour box (a stand-in for a captcha widget) at a known size, so an
|
|
698
|
+
* element-clipped screenshot of it produces a PNG whose dimensions match the
|
|
699
|
+
* widget, not the viewport. A taller body makes a FULL-PAGE shot strictly
|
|
700
|
+
* taller than a VIEWPORT shot, so the three scopes are distinguishable by the
|
|
701
|
+
* returned dimensions.
|
|
702
|
+
*/
|
|
703
|
+
const SCREENSHOT = `<!doctype html>
|
|
704
|
+
<html lang="en">
|
|
705
|
+
<head>
|
|
706
|
+
<meta charset="utf-8" />
|
|
707
|
+
<title>screenshot fixture</title>
|
|
708
|
+
<style>
|
|
709
|
+
html,
|
|
710
|
+
body {
|
|
711
|
+
margin: 0;
|
|
712
|
+
padding: 0;
|
|
713
|
+
}
|
|
714
|
+
#widget {
|
|
715
|
+
width: 200px;
|
|
716
|
+
height: 150px;
|
|
717
|
+
background: #2a6;
|
|
718
|
+
}
|
|
719
|
+
#tall {
|
|
720
|
+
height: 3000px;
|
|
721
|
+
background: linear-gradient(#fff, #036);
|
|
722
|
+
}
|
|
723
|
+
</style>
|
|
724
|
+
</head>
|
|
725
|
+
<body>
|
|
726
|
+
<div id="widget">widget</div>
|
|
727
|
+
<div id="tall">tall body for full-page</div>
|
|
728
|
+
</body>
|
|
729
|
+
</html>
|
|
730
|
+
`;
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* The SAME-ORIGIN TOKEN-HARVEST captcha child frame (prd
|
|
734
|
+
* `broaden-agent-verb-surface`, stories 6-7; the
|
|
735
|
+
* `frame-aware-query-token-harvest-captcha-proof` task). It is the child
|
|
736
|
+
* embedded by {@link TOKEN_CAPTCHA_PARENT} as `#main-iframe`, and it carries the
|
|
737
|
+
* whole token-harvest widget the way a real hCaptcha/Imperva `#main-iframe`
|
|
738
|
+
* does (per `work/notes/findings/click-and-type-already-frame-scoped-via-
|
|
739
|
+
* framelocator.md` and `…playwright-cross-origin-frame-captcha-mechanics.md`):
|
|
740
|
+
*
|
|
741
|
+
* - `div.h-captcha[data-sitekey]` is the PAGE-READABLE sitekey an agent reads
|
|
742
|
+
* through a `frameLocator('#main-iframe').locator('.h-captcha')` +
|
|
743
|
+
* `attrs:['data-sitekey']` (the one frame-aware READ the spike found missing).
|
|
744
|
+
* `data-callback="onCaptchaFinished"` names the page callback, exactly as the
|
|
745
|
+
* real widget wires it.
|
|
746
|
+
* - `textarea#h-captcha-response` is the same-origin RESPONSE SINK an agent
|
|
747
|
+
* `type`s the provider token into (the delivery half the spike proved already
|
|
748
|
+
* works through the `frameLocator` hop).
|
|
749
|
+
* - `window.onCaptchaFinished(token)` is the page callback: it ACCEPTS the token
|
|
750
|
+
* ONLY when it matches what was written into the sink (so a real token must
|
|
751
|
+
* travel sink -> callback, not be conjured), flips `#captcha-state` to
|
|
752
|
+
* `verified`, sets `window.__captchaSolved`, and reveals `#protected-content`
|
|
753
|
+
* (the page ADVANCES). A mismatched/empty token flips it to `rejected` and the
|
|
754
|
+
* page does NOT advance, so the proof asserts a real transition the verbs
|
|
755
|
+
* drove, not a no-op.
|
|
756
|
+
*
|
|
757
|
+
* webhands ships NO solver and NO key: the token is whatever the agent brings
|
|
758
|
+
* (in the proof, a TEST FAKE provider mints one: no real network, no real key).
|
|
759
|
+
*/
|
|
760
|
+
const TOKEN_CAPTCHA_CHILD = `<!doctype html>
|
|
761
|
+
<html lang="en">
|
|
762
|
+
<head>
|
|
763
|
+
<meta charset="utf-8" />
|
|
764
|
+
<title>token-harvest captcha frame</title>
|
|
765
|
+
</head>
|
|
766
|
+
<body>
|
|
767
|
+
<h1 id="captcha-heading">Verify you are human</h1>
|
|
768
|
+
<!-- The page-readable sitekey + the callback name, the real widget shape. -->
|
|
769
|
+
<div
|
|
770
|
+
class="h-captcha"
|
|
771
|
+
data-sitekey="sk-token-harvest-abc123"
|
|
772
|
+
data-callback="onCaptchaFinished"
|
|
773
|
+
></div>
|
|
774
|
+
<!-- The same-origin response sink the token is typed into. -->
|
|
775
|
+
<textarea id="h-captcha-response" name="h-captcha-response"></textarea>
|
|
776
|
+
<p id="captcha-state">pending</p>
|
|
777
|
+
<div id="protected-content" style="display: none">protected resource</div>
|
|
778
|
+
<script>
|
|
779
|
+
window.__captchaSolved = false;
|
|
780
|
+
// The page callback the agent fires AFTER writing the token into the sink.
|
|
781
|
+
// It accepts the token only if it matches the sink value (the token must
|
|
782
|
+
// genuinely travel sink -> callback), modelling the real widget's
|
|
783
|
+
// inject-token-then-fire-callback flow. The page ADVANCES on success.
|
|
784
|
+
window.onCaptchaFinished = function (token) {
|
|
785
|
+
var sink = document.getElementById('h-captcha-response');
|
|
786
|
+
var state = document.getElementById('captcha-state');
|
|
787
|
+
if (token && token === sink.value) {
|
|
788
|
+
window.__captchaSolved = true;
|
|
789
|
+
state.textContent = 'verified';
|
|
790
|
+
document.getElementById('protected-content').style.display = '';
|
|
791
|
+
return 'verified';
|
|
792
|
+
}
|
|
793
|
+
state.textContent = 'rejected';
|
|
794
|
+
return 'rejected';
|
|
795
|
+
};
|
|
796
|
+
</script>
|
|
797
|
+
</body>
|
|
798
|
+
</html>
|
|
799
|
+
`;
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* The PARENT page for the SAME-ORIGIN TOKEN-HARVEST captcha proof (prd
|
|
803
|
+
* `broaden-agent-verb-surface`, stories 6-7). It embeds {@link
|
|
804
|
+
* TOKEN_CAPTCHA_CHILD} as a SAME-ORIGIN child frame (`#main-iframe`, relative
|
|
805
|
+
* `src`), mirroring the Imperva `#main-iframe` structure the findings give for
|
|
806
|
+
* the reachable token-harvest path: the sitekey, the response sink, and the
|
|
807
|
+
* callback ALL live one same-origin frame down, addressed via a
|
|
808
|
+
* `frameLocator('#main-iframe')` hop in the locator string (no `--frame` flag,
|
|
809
|
+
* R1). The top document carries only a heading, so the captcha widget is
|
|
810
|
+
* genuinely IN the child frame (a top-document `.h-captcha` query is empty).
|
|
811
|
+
*/
|
|
812
|
+
const TOKEN_CAPTCHA_PARENT = `<!doctype html>
|
|
813
|
+
<html lang="en">
|
|
814
|
+
<head>
|
|
815
|
+
<meta charset="utf-8" />
|
|
816
|
+
<title>token-harvest captcha host</title>
|
|
817
|
+
</head>
|
|
818
|
+
<body>
|
|
819
|
+
<h1 id="host-heading">Protected page</h1>
|
|
820
|
+
<iframe
|
|
821
|
+
id="main-iframe"
|
|
822
|
+
name="main-iframe"
|
|
823
|
+
src="/token-captcha-child.html"
|
|
824
|
+
width="320"
|
|
825
|
+
height="240"
|
|
826
|
+
></iframe>
|
|
827
|
+
</body>
|
|
828
|
+
</html>
|
|
829
|
+
`;
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* The Tier-4 CROSS-ORIGIN nested-frame fixture (prd
|
|
833
|
+
* `broaden-agent-verb-surface`, R3, stories 17-19), mirroring the synthetic
|
|
834
|
+
* doubly-nested cross-origin tree the finding
|
|
835
|
+
* `playwright-cross-origin-frame-captcha-mechanics.md` spike-verified:
|
|
836
|
+
*
|
|
837
|
+
* ```
|
|
838
|
+
* top (host)
|
|
839
|
+
* └─ iframe#child-frame ← CROSS-ORIGIN (a second fixture-server origin)
|
|
840
|
+
* └─ iframe#child-frame ← CROSS-ORIGIN AGAIN (a third origin)
|
|
841
|
+
* └─ the tile grid + token sink
|
|
842
|
+
* ```
|
|
843
|
+
*
|
|
844
|
+
* Because the fixture server serves IDENTICAL pages on every port, the test
|
|
845
|
+
* composes the three origins by passing each child frame's absolute URL in a
|
|
846
|
+
* `?child=<url>` query param; this page reads its OWN query string and points
|
|
847
|
+
* `#child-frame` at the given child URL. Every level uses the SAME iframe id
|
|
848
|
+
* (`#child-frame`), so a `frameLocator('#child-frame').frameLocator('#child-frame')`
|
|
849
|
+
* chain reads two cross-origin boundaries deep. With no `child` param the frame
|
|
850
|
+
* is removed, so the SAME page is the host, the WAF level, and the deepest
|
|
851
|
+
* captcha level depending on the URL the test embeds. The deepest level (no
|
|
852
|
+
* `child`) carries the controlled tile/token state the READ asserts, and a
|
|
853
|
+
* `#tile-1` widget an element-clipped screenshot targets.
|
|
854
|
+
*/
|
|
855
|
+
const NESTED_FRAME = `<!doctype html>
|
|
856
|
+
<html lang="en">
|
|
857
|
+
<head>
|
|
858
|
+
<meta charset="utf-8" />
|
|
859
|
+
<title>nested frame fixture</title>
|
|
860
|
+
<style>
|
|
861
|
+
#tile-1 {
|
|
862
|
+
width: 90px;
|
|
863
|
+
height: 90px;
|
|
864
|
+
background: #c33;
|
|
865
|
+
}
|
|
866
|
+
</style>
|
|
867
|
+
</head>
|
|
868
|
+
<body>
|
|
869
|
+
<h1 id="level-heading">nested frame level</h1>
|
|
870
|
+
<!-- The tile grid + token sink, present at EVERY level but meaningful at
|
|
871
|
+
the deepest (captcha) one the test reads into. -->
|
|
872
|
+
<div id="challenge" class="challenge">
|
|
873
|
+
<div id="tile-1" class="tile" data-tile="1">tile 1</div>
|
|
874
|
+
<div class="tile" data-tile="2">tile 2</div>
|
|
875
|
+
<div class="tile" data-tile="3">tile 3</div>
|
|
876
|
+
<textarea id="h-captcha-response" name="h-captcha-response">deep-token-123</textarea>
|
|
877
|
+
</div>
|
|
878
|
+
<iframe id="child-frame" name="child-frame" width="360" height="300"></iframe>
|
|
879
|
+
<script>
|
|
880
|
+
// Read this level's OWN ?child=<url> and point the nested frame at it, so
|
|
881
|
+
// the test composes the cross-origin tree across distinct origins. Every
|
|
882
|
+
// level uses the SAME iframe id (#child-frame) so a
|
|
883
|
+
// frameLocator('#child-frame').frameLocator('#child-frame') chain reads two
|
|
884
|
+
// cross-origin boundaries deep.
|
|
885
|
+
var params = new URLSearchParams(window.location.search);
|
|
886
|
+
var child = params.get('child');
|
|
887
|
+
var frame = document.getElementById('child-frame');
|
|
888
|
+
if (child) {
|
|
889
|
+
frame.src = child;
|
|
890
|
+
} else if (frame && frame.parentNode) {
|
|
891
|
+
// Deepest level: no child, remove the empty trailing iframe so the tree
|
|
892
|
+
// ends cleanly at the tile grid.
|
|
893
|
+
frame.parentNode.removeChild(frame);
|
|
894
|
+
}
|
|
895
|
+
</script>
|
|
896
|
+
</body>
|
|
897
|
+
</html>
|
|
898
|
+
`;
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* The Tier-4 VISION/TILE captcha fixture (prd `broaden-agent-verb-surface`, R3,
|
|
902
|
+
* story 17; the `vision-tile-captcha-end-to-end-proof` task). It mirrors the
|
|
903
|
+
* doubly-nested CROSS-ORIGIN tree the finding
|
|
904
|
+
* `playwright-cross-origin-frame-captcha-mechanics.md` spike-verified, but unlike
|
|
905
|
+
* the read-only {@link NESTED_FRAME} fixture its deepest level is an INTERACTIVE
|
|
906
|
+
* tile challenge that ADVANCES when the right tiles are clicked:
|
|
907
|
+
*
|
|
908
|
+
* ```
|
|
909
|
+
* top (host)
|
|
910
|
+
* └─ iframe#child-frame ← CROSS-ORIGIN (a second fixture-server origin, the WAF level)
|
|
911
|
+
* └─ iframe#child-frame ← CROSS-ORIGIN AGAIN (a third origin, the captcha level)
|
|
912
|
+
* └─ the interactive 3x3 tile grid + challenge state
|
|
913
|
+
* ```
|
|
914
|
+
*
|
|
915
|
+
* Cross-origin composition is the SAME `?child=<url>` mechanism
|
|
916
|
+
* {@link NESTED_FRAME} uses (the fixture server serves identical pages on every
|
|
917
|
+
* port, so the test threads three distinct origins by passing each child's
|
|
918
|
+
* absolute URL). Every level shares the iframe id `#child-frame`, so a
|
|
919
|
+
* `frameLocator('#child-frame').frameLocator('#child-frame')` chain reaches the
|
|
920
|
+
* deepest (captcha) level two cross-origin boundaries deep.
|
|
921
|
+
*
|
|
922
|
+
* The deepest level (no `?child`) is the challenge. It is the part that makes
|
|
923
|
+
* this a vision/tile PROOF rather than a static read:
|
|
924
|
+
*
|
|
925
|
+
* - A 3x3 grid of `.tile` cells, each absolutely positioned at a KNOWN, distinct
|
|
926
|
+
* spot (so an element-clipped/viewport screenshot of the widget shows them at
|
|
927
|
+
* stable coordinates, and a `bbox` read gives each tile's VIEWPORT-relative
|
|
928
|
+
* centre — the coordinate<->screenshot bridge). Each tile carries `data-tile`
|
|
929
|
+
* (its index `0..8`) and `data-target` (`"1"` for the tiles the challenge wants
|
|
930
|
+
* selected, `"0"` otherwise). The TARGET set is fixed in the markup, so the
|
|
931
|
+
* test's selection is DETERMINISTIC (it stands in for a vision model's
|
|
932
|
+
* decision; webhands ships no solver).
|
|
933
|
+
* - Clicking a tile (a real coordinate `mouse` click runs the tile's own click
|
|
934
|
+
* handler) toggles its `selected` state and appends its index to `#selection`.
|
|
935
|
+
* Clicking a tile that is NOT a target marks the attempt `wrong` (so a sloppy
|
|
936
|
+
* coordinate that hit the neighbouring tile is OBSERVABLE, not silently
|
|
937
|
+
* tolerated — the coordinate contract is load-bearing).
|
|
938
|
+
* - `#submit` checks the selection against the target set; when EXACTLY the
|
|
939
|
+
* target tiles are selected it flips `#challenge-state` to `solved` and sets
|
|
940
|
+
* `window.__solved = true` (the challenge ADVANCES). Until then it reads
|
|
941
|
+
* `pending` (or `wrong` after a mis-click), so the proof asserts a real state
|
|
942
|
+
* transition the verbs drove, not a no-op.
|
|
943
|
+
*
|
|
944
|
+
* All challenge state lives in the deepest frame's DOM, so the proof reads it
|
|
945
|
+
* through the SAME cross-origin `frameLocator` chain the clicks act through.
|
|
946
|
+
*/
|
|
947
|
+
const TILE_CAPTCHA = `<!doctype html>
|
|
948
|
+
<html lang="en">
|
|
949
|
+
<head>
|
|
950
|
+
<meta charset="utf-8" />
|
|
951
|
+
<title>tile captcha fixture</title>
|
|
952
|
+
<style>
|
|
953
|
+
html,
|
|
954
|
+
body {
|
|
955
|
+
margin: 0;
|
|
956
|
+
padding: 0;
|
|
957
|
+
}
|
|
958
|
+
/* #grid is the positioned WIDGET (a known size), so an element-clipped
|
|
959
|
+
screenshot of it clips the tile grid, and the absolutely-positioned
|
|
960
|
+
tiles lay out relative to IT at stable, distinct coordinates. */
|
|
961
|
+
#grid {
|
|
962
|
+
position: relative;
|
|
963
|
+
width: 300px;
|
|
964
|
+
height: 300px;
|
|
965
|
+
}
|
|
966
|
+
.tile {
|
|
967
|
+
position: absolute;
|
|
968
|
+
width: 90px;
|
|
969
|
+
height: 90px;
|
|
970
|
+
background: #cdd;
|
|
971
|
+
box-sizing: border-box;
|
|
972
|
+
border: 1px solid #899;
|
|
973
|
+
}
|
|
974
|
+
.tile.selected {
|
|
975
|
+
background: #3a7;
|
|
976
|
+
}
|
|
977
|
+
</style>
|
|
978
|
+
</head>
|
|
979
|
+
<body>
|
|
980
|
+
<!-- The child iframe comes FIRST and is pinned to the top-left, so on a
|
|
981
|
+
PARENT level (one with ?child) the nested tree starts at the viewport
|
|
982
|
+
origin and the deepest grid stays IN the viewport — the coordinate<->
|
|
983
|
+
screenshot contract only holds for on-screen tiles. The challenge UI is
|
|
984
|
+
hidden on parent levels (it is meaningful only at the deepest level). -->
|
|
985
|
+
<iframe id="child-frame" name="child-frame" width="380" height="560"></iframe>
|
|
986
|
+
<!-- The interactive challenge, present at EVERY level (so the chain is
|
|
987
|
+
uniform) but meaningful only at the deepest (no-child) one the proof
|
|
988
|
+
reads + clicks into. -->
|
|
989
|
+
<div id="challenge" class="challenge">
|
|
990
|
+
<p id="prompt">Select the marked tiles</p>
|
|
991
|
+
<p id="challenge-state">pending</p>
|
|
992
|
+
<p id="selection"></p>
|
|
993
|
+
<div id="grid"></div>
|
|
994
|
+
<button id="submit" type="button">Verify</button>
|
|
995
|
+
</div>
|
|
996
|
+
<script>
|
|
997
|
+
// The fixed TARGET set (the tiles the challenge wants). Deterministic, so
|
|
998
|
+
// the proof's selection stands in for a vision model with zero solver code.
|
|
999
|
+
var TARGETS = [0, 4, 8];
|
|
1000
|
+
var GRID = document.getElementById('grid');
|
|
1001
|
+
var selection = [];
|
|
1002
|
+
for (var i = 0; i < 9; i++) {
|
|
1003
|
+
(function (index) {
|
|
1004
|
+
var tile = document.createElement('div');
|
|
1005
|
+
tile.className = 'tile';
|
|
1006
|
+
tile.id = 'tile-' + index;
|
|
1007
|
+
tile.setAttribute('data-tile', String(index));
|
|
1008
|
+
tile.setAttribute(
|
|
1009
|
+
'data-target',
|
|
1010
|
+
TARGETS.indexOf(index) >= 0 ? '1' : '0',
|
|
1011
|
+
);
|
|
1012
|
+
tile.style.left = (index % 3) * 100 + 'px';
|
|
1013
|
+
tile.style.top = Math.floor(index / 3) * 100 + 'px';
|
|
1014
|
+
tile.textContent = String(index);
|
|
1015
|
+
tile.addEventListener('click', function () {
|
|
1016
|
+
var at = selection.indexOf(index);
|
|
1017
|
+
if (at >= 0) {
|
|
1018
|
+
selection.splice(at, 1);
|
|
1019
|
+
tile.classList.remove('selected');
|
|
1020
|
+
} else {
|
|
1021
|
+
selection.push(index);
|
|
1022
|
+
tile.classList.add('selected');
|
|
1023
|
+
}
|
|
1024
|
+
document.getElementById('selection').textContent = selection
|
|
1025
|
+
.slice()
|
|
1026
|
+
.sort(function (a, b) {
|
|
1027
|
+
return a - b;
|
|
1028
|
+
})
|
|
1029
|
+
.join(',');
|
|
1030
|
+
});
|
|
1031
|
+
GRID.appendChild(tile);
|
|
1032
|
+
})(i);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
window.__solved = false;
|
|
1036
|
+
document.getElementById('submit').addEventListener('click', function () {
|
|
1037
|
+
var chosen = selection.slice().sort(function (a, b) {
|
|
1038
|
+
return a - b;
|
|
1039
|
+
});
|
|
1040
|
+
var want = TARGETS.slice().sort(function (a, b) {
|
|
1041
|
+
return a - b;
|
|
1042
|
+
});
|
|
1043
|
+
var ok =
|
|
1044
|
+
chosen.length === want.length &&
|
|
1045
|
+
chosen.every(function (v, i) {
|
|
1046
|
+
return v === want[i];
|
|
1047
|
+
});
|
|
1048
|
+
var state = document.getElementById('challenge-state');
|
|
1049
|
+
if (ok) {
|
|
1050
|
+
window.__solved = true;
|
|
1051
|
+
state.textContent = 'solved';
|
|
1052
|
+
} else {
|
|
1053
|
+
state.textContent = 'wrong';
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
// Cross-origin composition, mirroring NESTED_FRAME: read this level's
|
|
1058
|
+
// ?child=<url> and point #child-frame at it. On a PARENT level we HIDE this
|
|
1059
|
+
// level's own challenge UI and pin the iframe to the top, so the only thing
|
|
1060
|
+
// occupying the viewport is the nested tree — keeping the DEEPEST grid on
|
|
1061
|
+
// screen (its tiles' viewport coordinates are what the mouse verb clicks).
|
|
1062
|
+
// The
|
|
1063
|
+
// deepest level (no child) removes the empty trailing iframe and shows the
|
|
1064
|
+
// challenge.
|
|
1065
|
+
var params = new URLSearchParams(window.location.search);
|
|
1066
|
+
var child = params.get('child');
|
|
1067
|
+
var frame = document.getElementById('child-frame');
|
|
1068
|
+
if (child) {
|
|
1069
|
+
frame.src = child;
|
|
1070
|
+
document.getElementById('challenge').style.display = 'none';
|
|
1071
|
+
} else if (frame && frame.parentNode) {
|
|
1072
|
+
frame.parentNode.removeChild(frame);
|
|
1073
|
+
}
|
|
1074
|
+
</script>
|
|
1075
|
+
</body>
|
|
1076
|
+
</html>
|
|
1077
|
+
`;
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* A page for the durable `query` `ref` (prd `broaden-agent-verb-surface`, R4;
|
|
1081
|
+
* task `query-durable-ref-handle`). A results list whose rows exercise the
|
|
1082
|
+
* REF PREFERENCE LADDER and the loud-stale contract WITHOUT any framework, by
|
|
1083
|
+
* driving the exact reconciliation shapes the React/Svelte spike measured via
|
|
1084
|
+
* plain DOM mutations:
|
|
1085
|
+
*
|
|
1086
|
+
* - Each `.result` has a per-row buy button. Charlie's button carries a STABLE
|
|
1087
|
+
* UNIQUE id (`#buy-charlie`) and a `data-testid` (ladder step 1 reuse). Other
|
|
1088
|
+
* buy buttons are ANONYMOUS (no id/testid/name), so a ref for them must MINT
|
|
1089
|
+
* (ladder step 2).
|
|
1090
|
+
* - `#dupe-a` / `#dupe-b` rows both carry `data-testid="dupe"` (NON-unique), so a
|
|
1091
|
+
* ref must VERIFY uniqueness and fall through (mint) rather than reuse it.
|
|
1092
|
+
* - `window.__prepend()` inserts a NEW row at the TOP (index drift): a positional
|
|
1093
|
+
* `.nth(i)` now points at the wrong row, but a ref rides with its element.
|
|
1094
|
+
* - `window.__replaceCharlie()` REMOVES Charlie's row and inserts a fresh node in
|
|
1095
|
+
* its place (the keyed NODE-REPLACEMENT case): a MINTED ref on it goes stale
|
|
1096
|
+
* (resolve-to-zero), a reused `#buy-charlie` ref also goes stale if the new
|
|
1097
|
+
* node lacks it — both must fail LOUD, never act on the wrong element.
|
|
1098
|
+
* - `window.__cloneAnon(id)` deep-clones an anonymous row carrying a minted attr,
|
|
1099
|
+
* so its ref resolves to MORE THAN ONE element (ambiguous) — also loud-stale.
|
|
1100
|
+
*/
|
|
1101
|
+
const REF_LIST = `<!doctype html>
|
|
1102
|
+
<html lang="en">
|
|
1103
|
+
<head>
|
|
1104
|
+
<meta charset="utf-8" />
|
|
1105
|
+
<title>ref list fixture</title>
|
|
1106
|
+
</head>
|
|
1107
|
+
<body>
|
|
1108
|
+
<h1 id="heading">Ref List Fixture</h1>
|
|
1109
|
+
<ul id="results">
|
|
1110
|
+
<li class="result" data-name="Alpha">
|
|
1111
|
+
<span class="title">Alpha Widget</span>
|
|
1112
|
+
<button class="buy">Buy</button>
|
|
1113
|
+
</li>
|
|
1114
|
+
<li class="result" data-name="Bravo">
|
|
1115
|
+
<span class="title">Bravo Widget</span>
|
|
1116
|
+
<button class="buy">Buy</button>
|
|
1117
|
+
</li>
|
|
1118
|
+
<li class="result" data-name="Charlie" id="row-charlie">
|
|
1119
|
+
<span class="title">Charlie Widget</span>
|
|
1120
|
+
<button class="buy" id="buy-charlie" data-testid="buy-charlie">
|
|
1121
|
+
Buy
|
|
1122
|
+
</button>
|
|
1123
|
+
</li>
|
|
1124
|
+
<li class="result" data-name="Delta">
|
|
1125
|
+
<span class="title">Delta Widget</span>
|
|
1126
|
+
<button class="buy">Buy</button>
|
|
1127
|
+
</li>
|
|
1128
|
+
</ul>
|
|
1129
|
+
|
|
1130
|
+
<!-- two rows sharing a NON-unique data-testid, to prove uniqueness is
|
|
1131
|
+
VERIFIED and a duplicate falls through the ladder to a mint. -->
|
|
1132
|
+
<ul id="dupes">
|
|
1133
|
+
<li class="dupe-row" data-testid="dupe"><button class="buy">A</button></li>
|
|
1134
|
+
<li class="dupe-row" data-testid="dupe"><button class="buy">B</button></li>
|
|
1135
|
+
</ul>
|
|
1136
|
+
|
|
1137
|
+
<!-- a recorder so a test can prove WHICH buy button was clicked, not merely
|
|
1138
|
+
that the click did not throw. -->
|
|
1139
|
+
<pre id="clicklog"></pre>
|
|
1140
|
+
|
|
1141
|
+
<script>
|
|
1142
|
+
document.addEventListener('click', function (e) {
|
|
1143
|
+
var btn = e.target.closest('button.buy');
|
|
1144
|
+
if (!btn) return;
|
|
1145
|
+
var row = btn.closest('.result, .dupe-row');
|
|
1146
|
+
var label = row ? row.getAttribute('data-name') || row.textContent.trim() : '?';
|
|
1147
|
+
document.getElementById('clicklog').textContent += label + ';';
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
function makeRow(name) {
|
|
1151
|
+
var li = document.createElement('li');
|
|
1152
|
+
li.className = 'result';
|
|
1153
|
+
li.setAttribute('data-name', name);
|
|
1154
|
+
var title = document.createElement('span');
|
|
1155
|
+
title.className = 'title';
|
|
1156
|
+
title.textContent = name + ' Widget';
|
|
1157
|
+
var buy = document.createElement('button');
|
|
1158
|
+
buy.className = 'buy';
|
|
1159
|
+
buy.textContent = 'Buy';
|
|
1160
|
+
li.appendChild(title);
|
|
1161
|
+
li.appendChild(buy);
|
|
1162
|
+
return li;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Index drift: a NEW row at the TOP. Existing element nodes are KEPT (a
|
|
1166
|
+
// reused stable attr AND a minted attr ride with them); only positions
|
|
1167
|
+
// shift, so a positional .nth(i) now picks the wrong row.
|
|
1168
|
+
window.__prepend = function () {
|
|
1169
|
+
var ul = document.getElementById('results');
|
|
1170
|
+
ul.insertBefore(makeRow('Zeta'), ul.firstElementChild);
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
// NODE REPLACEMENT: remove Charlie's row entirely and insert a FRESH node
|
|
1174
|
+
// in its place (no minted attr, no #buy-charlie). A ref minted on the old
|
|
1175
|
+
// Charlie button resolves to ZERO; a reused #buy-charlie ref also to ZERO.
|
|
1176
|
+
window.__replaceCharlie = function () {
|
|
1177
|
+
var old = document.getElementById('row-charlie');
|
|
1178
|
+
var fresh = makeRow('Charlie');
|
|
1179
|
+
old.parentNode.replaceChild(fresh, old);
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
// AMBIGUITY: deep-clone a node carrying a minted attribute so the ref now
|
|
1183
|
+
// resolves to MORE THAN ONE element.
|
|
1184
|
+
window.__cloneByAttr = function (attr, value) {
|
|
1185
|
+
var el = document.querySelector('[' + attr + '="' + value + '"]');
|
|
1186
|
+
if (!el) return false;
|
|
1187
|
+
var host = document.getElementById('results');
|
|
1188
|
+
host.appendChild(el.closest('.result, .dupe-row, button').cloneNode(true));
|
|
1189
|
+
return true;
|
|
1190
|
+
};
|
|
1191
|
+
</script>
|
|
1192
|
+
</body>
|
|
1193
|
+
</html>
|
|
1194
|
+
`;
|
|
1195
|
+
|
|
202
1196
|
/** Map of request path (relative to root, no leading slash) to page markup. */
|
|
203
1197
|
export const FIXTURE_PAGES: Readonly<Record<string, string>> = {
|
|
204
1198
|
'index.html': INDEX,
|
|
205
1199
|
'click-type.html': CLICK_TYPE,
|
|
206
1200
|
'delayed.html': DELAYED_CONTENT,
|
|
1201
|
+
'slow-submit.html': SLOW_SUBMIT,
|
|
207
1202
|
'redirecting.html': REDIRECTING,
|
|
208
1203
|
'eval.html': EVAL,
|
|
209
1204
|
'cookies.html': COOKIES,
|
|
1205
|
+
'query-list.html': QUERY_LIST,
|
|
1206
|
+
'ref-list.html': REF_LIST,
|
|
1207
|
+
'keyboard.html': KEYBOARD,
|
|
1208
|
+
'hover.html': HOVER,
|
|
1209
|
+
'select.html': SELECT,
|
|
1210
|
+
'scroll.html': SCROLL,
|
|
1211
|
+
'drag.html': DRAG,
|
|
1212
|
+
'frame-parent.html': FRAME_PARENT,
|
|
1213
|
+
'frame-child.html': FRAME_CHILD,
|
|
1214
|
+
'coordinate.html': COORDINATE,
|
|
1215
|
+
'screenshot.html': SCREENSHOT,
|
|
1216
|
+
'token-captcha-parent.html': TOKEN_CAPTCHA_PARENT,
|
|
1217
|
+
'token-captcha-child.html': TOKEN_CAPTCHA_CHILD,
|
|
1218
|
+
'nested-frame.html': NESTED_FRAME,
|
|
1219
|
+
'tile-captcha.html': TILE_CAPTCHA,
|
|
210
1220
|
};
|