aboutsummaryrefslogtreecommitdiff
path: root/test-suite
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-05-13 01:05:46 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-05-13 01:05:46 -0400
commit33110e803fcacc31dc2859dbc0d470b008556798 (patch)
treebb30dad365139c558f9d9651cb8bca375320cb8e /test-suite
parentd78081aa58ecaa1c72e847c2d095b5f07c391506 (diff)
pi-chrome: humanize synthetic input + anti-automation test suite
Bump 0.8.0 → 0.9.1. Companion extension (service_worker.js): - humanise pointer paths: easing Bezier-ish interpolation w/ wobble, 12–42 steps, 4–18 ms gaps; pointer position persisted across calls - click point picked inside element rect, not dead center - pointer events fill movementX/Y, screenX/Y, pressure (0.5 on down), pointerId, width/height, tilt, composed - click sequence: humanMoveTo → pointerdown → focus → 45–140 ms hold → pointerup → mouseup → click - per-character typing: keydown → keypress → beforeinput → value mutate (or execCommand for contenteditable) → input → keyup, with 25–95 ms hold + 35–140 ms inter-key; auto Shift chord for uppercase/symbols - pressKeyInPage emits same cadence + handles printable insert / Backspace - hoverPage: humanised path + 80–220 ms dwell - dragPage: full HTML5 sequence (dragstart/drag/dragenter/dragover/ dragleave/drop/dragend) with shared DataTransfer + pointer continuity - new scrollPage: wheel events with front-loaded momentum-shaped deltas - self-reload on version drift: SW reads x-pi-chrome-version header, reloads when older than pi-chrome Pi side (index.ts): - new chrome_scroll tool - PI_CHROME_VERSION read from package.json (and re-read on every /next so future bumps take effect without pi restart) - /chrome-doctor escalates version mismatch to top-line ✗ error and skips downstream capability probes when stale - /next response carries x-pi-chrome-version header for SW self-heal Test suite (test-suite/): - 28 challenge pages each catching a different synthetic-event tell: isTrusted, webdriver/fingerprint, mouse entropy/timing/coords, pointer pressure/continuity, per-char cadence + framework invariants, user activation, honeypots, focus order, wheel scroll, drag+ DataTransfer, contenteditable+Selection, paste/clipboard, native select, hover dwell, React valueTracker, keyboard modifiers, touch events, stack-trace fingerprint, viewport-edge clicks, mousemove rate, scroll momentum, IntersectionObserver visibility - shared _lib.js exposes window.__verdict + writes localStorage so index.html dashboard aggregates results - notes/bypass-ideas.md documents fixes still needed per challenge
Diffstat (limited to 'test-suite')
-rw-r--r--test-suite/README.md74
-rw-r--r--test-suite/_lib.js81
-rw-r--r--test-suite/_style.css4
-rw-r--r--test-suite/baseline-dashboard.pngbin0 -> 439285 bytes
-rw-r--r--test-suite/challenges/01-is-trusted-click.html28
-rw-r--r--test-suite/challenges/02-is-trusted-keyboard.html50
-rw-r--r--test-suite/challenges/03-webdriver-flag.html51
-rw-r--r--test-suite/challenges/04-mouse-entropy.html34
-rw-r--r--test-suite/challenges/05-event-timing.html34
-rw-r--r--test-suite/challenges/06-click-coordinates.html29
-rw-r--r--test-suite/challenges/07-pointer-properties.html29
-rw-r--r--test-suite/challenges/08-keyboard-cadence.html37
-rw-r--r--test-suite/challenges/09-composition-input.html45
-rw-r--r--test-suite/challenges/10-user-activation.html31
-rw-r--r--test-suite/challenges/11-honeypot.html36
-rw-r--r--test-suite/challenges/12-fingerprint.html57
-rw-r--r--test-suite/challenges/13-focus-order.html31
-rw-r--r--test-suite/challenges/14-wheel-scroll.html28
-rw-r--r--test-suite/challenges/15-drag-drop-datatransfer.html73
-rw-r--r--test-suite/challenges/16-contenteditable-selection.html54
-rw-r--r--test-suite/challenges/17-paste-clipboard.html48
-rw-r--r--test-suite/challenges/18-native-select.html56
-rw-r--r--test-suite/challenges/19-hover-dwell.html50
-rw-r--r--test-suite/challenges/20-react-value-tracker.html78
-rw-r--r--test-suite/challenges/21-keyboard-modifiers.html65
-rw-r--r--test-suite/challenges/22-touch-events.html66
-rw-r--r--test-suite/challenges/23-stack-trace-fingerprint.html78
-rw-r--r--test-suite/challenges/24-viewport-edge-clicks.html51
-rw-r--r--test-suite/challenges/25-pointer-continuity.html62
-rw-r--r--test-suite/challenges/26-mousemove-rate.html57
-rw-r--r--test-suite/challenges/27-scroll-momentum.html66
-rw-r--r--test-suite/challenges/28-intersection-visibility.html72
-rw-r--r--test-suite/index.html90
-rw-r--r--test-suite/notes/bypass-ideas.md76
-rwxr-xr-xtest-suite/serve.sh6
35 files changed, 1727 insertions, 0 deletions
diff --git a/test-suite/README.md b/test-suite/README.md
new file mode 100644
index 0000000..241976c
--- /dev/null
+++ b/test-suite/README.md
@@ -0,0 +1,74 @@
+# pi-chrome anti-automation test suite
+
+Static pages that each block a different automated-interaction signal. Drive them
+with the `chrome_*` tools to discover where the companion-extension's synthetic
+events still look like a bot, and to verify any humanization fixes.
+
+## Run
+
+```bash
+cd test-suite
+python3 -m http.server 8765
+# open http://127.0.0.1:8765/ in the Chrome window pi-chrome controls
+```
+
+Each challenge page exposes:
+
+- `window.__challenge` — short id
+- `window.__verdict` — `"PENDING" | "PASS" | "FAIL"`
+- `window.__reason` — array of strings, why it failed (or how it passed)
+- `window.__events` — raw event log for forensics
+
+`PASS` means the page believes a real human did the thing.
+`FAIL` means the page caught the automation.
+
+## Aggregate runner
+
+`index.html` lists every challenge, loads each one in an iframe, lets you (or
+the agent) drive the interaction, and tallies verdicts.
+
+Recommended agent flow:
+
+1. `chrome_navigate` to `http://127.0.0.1:8765/`.
+2. For each challenge link, open it, run the listed interaction with the
+ `chrome_*` tools, then read `window.__verdict` / `window.__reason` via
+ `chrome_evaluate`.
+3. Compare against the manual baseline (do the same interaction by hand and
+ confirm `PASS`).
+
+## Challenges
+
+| id | file | what it blocks |
+|----|------|----------------|
+| `is-trusted-click` | `challenges/01-is-trusted-click.html` | click handler ignores `event.isTrusted === false` |
+| `is-trusted-keyboard` | `challenges/02-is-trusted-keyboard.html` | input rejects if `keydown.isTrusted` false |
+| `webdriver-flag` | `challenges/03-webdriver-flag.html` | `navigator.webdriver` truthy / Chrome runtime quirks |
+| `mouse-entropy` | `challenges/04-mouse-entropy.html` | requires organic mousemove path before click |
+| `event-timing` | `challenges/05-event-timing.html` | rejects when pointerdown→pointerup gap < N ms or always-equal |
+| `click-coordinates` | `challenges/06-click-coordinates.html` | rejects clicks that land on exact element center |
+| `pointer-properties` | `challenges/07-pointer-properties.html` | demands pressure / non-zero movementX/Y / pointerId variety |
+| `keyboard-cadence` | `challenges/08-keyboard-cadence.html` | rejects same-tick `keydown`/`keyup` and missing per-char events |
+| `composition-input` | `challenges/09-composition-input.html` | listens for native `keydown` + `keypress` + `input` sequence |
+| `user-activation` | `challenges/10-user-activation.html` | feature gates: clipboard.writeText, fullscreen |
+| `honeypot` | `challenges/11-honeypot.html` | hidden field must stay empty |
+| `fingerprint` | `challenges/12-fingerprint.html` | UA-CH, languages, plugins, permissions consistency |
+| `focus-order` | `challenges/13-focus-order.html` | requires `pointerdown`-driven focus, not direct `.focus()` |
+| `wheel-scroll` | `challenges/14-wheel-scroll.html` | rejects scrollTop jumps without `wheel`/`scroll` events |
+| `drag-drop-datatransfer` | `challenges/15-drag-drop-datatransfer.html` | requires full HTML5 drag cycle with populated `DataTransfer` |
+| `contenteditable-selection` | `challenges/16-contenteditable-selection.html` | requires `selectionchange` + monotonic caret advance per keystroke |
+| `paste-clipboard` | `challenges/17-paste-clipboard.html` | demands real OS paste with `clipboardData` + `inputType=insertFromPaste` |
+| `native-select` | `challenges/18-native-select.html` | rejects `<option>` clicks / `.value=` assignment; needs trusted picker change |
+| `hover-dwell` | `challenges/19-hover-dwell.html` | reveal gated on ≥600ms hover with pointermove activity, latency after reveal |
+| `react-value-tracker` | `challenges/20-react-value-tracker.html` | input must mutate via native `value` setter so React's `_valueTracker` is stale |
+| `keyboard-modifiers` | `challenges/21-keyboard-modifiers.html` | Shift+a chord: modifier ordering, `shiftKey`, `code=KeyA`, `getModifierState` |
+| `touch-events` | `challenges/22-touch-events.html` | real `TouchEvent`+`Touch` (radius, force) — synthetic pointerType=touch fails |
+| `stack-trace-fingerprint` | `challenges/23-stack-trace-fingerprint.html` | inspects handler call stack for extension/eval/Runtime.evaluate frames |
+| `viewport-edge-clicks` | `challenges/24-viewport-edge-clicks.html` | rejects pointer events with clientX/Y outside viewport or (0,0) default |
+| `pointer-continuity` | `challenges/25-pointer-continuity.html` | two clicks far apart need bridging pointermoves with mid-span samples |
+| `mousemove-rate` | `challenges/26-mousemove-rate.html` | mousemove Δt distribution must match ~60–250Hz with jitter |
+| `scroll-momentum` | `challenges/27-scroll-momentum.html` | wheel ΔY trace must show peak + decaying momentum tail ≥100ms |
+| `intersection-visibility` | `challenges/28-intersection-visibility.html` | IntersectionObserver gradient + rAF scroll frames — no teleport |
+
+## What each `PASS` would require in `service_worker.js`
+
+See `notes/bypass-ideas.md`.
diff --git a/test-suite/_lib.js b/test-suite/_lib.js
new file mode 100644
index 0000000..c6d121c
--- /dev/null
+++ b/test-suite/_lib.js
@@ -0,0 +1,81 @@
+// Tiny shared harness for challenge pages.
+// Each page calls Challenge.init({id, instructions}) then Challenge.pass()/fail()
+// based on its own listeners.
+(function () {
+ const events = [];
+ const state = {
+ id: null,
+ verdict: "PENDING",
+ reason: [],
+ events,
+ };
+
+ function render() {
+ const el = document.getElementById("__verdict");
+ if (!el) return;
+ el.textContent = state.verdict;
+ el.dataset.verdict = state.verdict;
+ el.style.background =
+ state.verdict === "PASS" ? "#1f7a1f" :
+ state.verdict === "FAIL" ? "#a11" : "#444";
+ el.style.color = "#fff";
+ const r = document.getElementById("__reason");
+ if (r) r.textContent = state.reason.join("\n");
+ }
+
+ function log(name, detail) {
+ events.push({ t: performance.now(), name, ...detail });
+ if (events.length > 500) events.shift();
+ }
+
+ const Challenge = {
+ init({ id, instructions }) {
+ state.id = id;
+ document.title = `[${state.verdict}] ${id}`;
+ const root = document.body;
+ const bar = document.createElement("div");
+ bar.style.cssText =
+ "position:sticky;top:0;background:#111;color:#eee;padding:8px 12px;font:13px monospace;border-bottom:1px solid #333;z-index:9999";
+ bar.innerHTML = `
+ <b>${id}</b>
+ <span id="__verdict" style="margin-left:8px;padding:2px 8px;border-radius:4px;background:#444">PENDING</span>
+ <span style="margin-left:12px;opacity:.7">${instructions}</span>
+ <pre id="__reason" style="white-space:pre-wrap;margin:6px 0 0;color:#bbb;font:12px monospace"></pre>
+ `;
+ root.insertBefore(bar, root.firstChild);
+ window.__challenge = id;
+ window.__verdict = state.verdict;
+ window.__reason = state.reason;
+ window.__events = state.events;
+ render();
+ },
+ pass(...reasons) {
+ if (state.verdict === "FAIL") return; // sticky
+ state.verdict = "PASS";
+ state.reason.push(...reasons.map((r) => "✓ " + r));
+ window.__verdict = state.verdict;
+ document.title = `[PASS] ${state.id}`;
+ persist(); render();
+ },
+ fail(...reasons) {
+ state.verdict = "FAIL";
+ state.reason.push(...reasons.map((r) => "✗ " + r));
+ window.__verdict = state.verdict;
+ document.title = `[FAIL] ${state.id}`;
+ persist(); render();
+ },
+ log,
+ state,
+ };
+
+ function persist() {
+ try {
+ localStorage.setItem(
+ "pi-chrome-suite:" + state.id,
+ JSON.stringify({ verdict: state.verdict, reason: state.reason, ts: Date.now() })
+ );
+ } catch {}
+ }
+
+ window.Challenge = Challenge;
+})();
diff --git a/test-suite/_style.css b/test-suite/_style.css
new file mode 100644
index 0000000..a90bc85
--- /dev/null
+++ b/test-suite/_style.css
@@ -0,0 +1,4 @@
+body { font: 14px system-ui, sans-serif; margin: 0; background: #1a1a1a; color: #eee; }
+main { padding: 24px; max-width: 720px; }
+code { background:#222;padding:1px 4px;border-radius:3px }
+a { color: #6cf; }
diff --git a/test-suite/baseline-dashboard.png b/test-suite/baseline-dashboard.png
new file mode 100644
index 0000000..c3c6524
--- /dev/null
+++ b/test-suite/baseline-dashboard.png
Binary files differ
diff --git a/test-suite/challenges/01-is-trusted-click.html b/test-suite/challenges/01-is-trusted-click.html
new file mode 100644
index 0000000..50a8050
--- /dev/null
+++ b/test-suite/challenges/01-is-trusted-click.html
@@ -0,0 +1,28 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>01 isTrusted click</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: click the green button. Page only accepts <code>event.isTrusted === true</code>.</p>
+ <button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
+</main>
+<script>
+Challenge.init({
+ id: "is-trusted-click",
+ instructions: "click the green button; isTrusted must be true",
+});
+const btn = document.getElementById("go");
+btn.addEventListener("click", (e) => {
+ Challenge.log("click", { isTrusted: e.isTrusted, x: e.clientX, y: e.clientY });
+ if (e.isTrusted) Challenge.pass("click.isTrusted === true");
+ else Challenge.fail("click.isTrusted === false (synthetic dispatchEvent)");
+}, { capture: true });
+// Also watch pointerdown to detect bot earlier.
+btn.addEventListener("pointerdown", (e) => {
+ Challenge.log("pointerdown", { isTrusted: e.isTrusted, pressure: e.pressure, pointerType: e.pointerType });
+ if (!e.isTrusted) Challenge.fail("pointerdown.isTrusted === false");
+});
+</script>
+</body>
diff --git a/test-suite/challenges/02-is-trusted-keyboard.html b/test-suite/challenges/02-is-trusted-keyboard.html
new file mode 100644
index 0000000..4fce713
--- /dev/null
+++ b/test-suite/challenges/02-is-trusted-keyboard.html
@@ -0,0 +1,50 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>02 isTrusted keyboard</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: type <code>hello</code> into the box. Each character must arrive via a trusted keydown/keypress/input.</p>
+ <input id="t" placeholder="type hello" style="font-size:18px;padding:8px 12px;width:240px">
+</main>
+<script>
+Challenge.init({
+ id: "is-trusted-keyboard",
+ instructions: "type 'hello'; every keydown.isTrusted must be true",
+});
+const t = document.getElementById("t");
+let trustedKeys = 0, untrustedKeys = 0, inputs = 0, untrustedInputs = 0;
+t.addEventListener("keydown", (e) => {
+ Challenge.log("keydown", { key: e.key, isTrusted: e.isTrusted });
+ if (e.isTrusted) trustedKeys++; else untrustedKeys++;
+});
+t.addEventListener("input", (e) => {
+ Challenge.log("input", { isTrusted: e.isTrusted, inputType: e.inputType, data: e.data });
+ inputs++; if (!e.isTrusted) untrustedInputs++;
+});
+t.addEventListener("keyup", () => {
+ if (t.value === "hello") {
+ if (untrustedKeys || untrustedInputs) {
+ Challenge.fail(`untrusted keydowns=${untrustedKeys}, untrusted inputs=${untrustedInputs}`);
+ } else if (trustedKeys < 5) {
+ Challenge.fail(`only ${trustedKeys} trusted keydowns (need >=5)`);
+ } else {
+ Challenge.pass(`${trustedKeys} trusted keydowns, ${inputs} trusted inputs`);
+ }
+ }
+});
+// Programmatic value-set detection.
+const desc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
+Object.defineProperty(t, "value", {
+ get() { return desc.get.call(this); },
+ set(v) {
+ Challenge.log("value-set", { v });
+ if (untrustedKeys === 0 && trustedKeys === 0) {
+ Challenge.fail("value set programmatically without any keydown");
+ }
+ return desc.set.call(this, v);
+ },
+});
+</script>
+</body>
diff --git a/test-suite/challenges/03-webdriver-flag.html b/test-suite/challenges/03-webdriver-flag.html
new file mode 100644
index 0000000..c5953f1
--- /dev/null
+++ b/test-suite/challenges/03-webdriver-flag.html
@@ -0,0 +1,51 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>03 webdriver / runtime fingerprint</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Auto-checks runtime properties commonly inspected by bot-detection scripts.</p>
+ <pre id="rep" style="font:12px monospace;background:#111;color:#eee;padding:12px;border-radius:6px"></pre>
+</main>
+<script>
+Challenge.init({
+ id: "webdriver-flag",
+ instructions: "no interaction; auto-verdict on load",
+});
+const checks = [];
+function chk(name, bad, note) {
+ checks.push({ name, bad, note });
+ if (bad) Challenge.log("flag", { name, note });
+}
+
+chk("navigator.webdriver", navigator.webdriver === true, String(navigator.webdriver));
+chk("languages-empty", !navigator.languages || navigator.languages.length === 0,
+ JSON.stringify(navigator.languages));
+chk("plugins-empty", !navigator.plugins || navigator.plugins.length === 0,
+ `length=${navigator.plugins && navigator.plugins.length}`);
+chk("permissions-notifications-quirk", false, "checked async below");
+
+(async () => {
+ try {
+ const p = await navigator.permissions.query({ name: "notifications" });
+ // Headless Chrome historically returned "denied" while Notification.permission === "default".
+ const mismatch = p.state === "denied" && Notification.permission === "default";
+ checks.push({ name: "permissions-notifications-quirk", bad: mismatch,
+ note: `perm=${p.state} api=${Notification.permission}` });
+ finalize();
+ } catch (e) {
+ checks.push({ name: "permissions-notifications-quirk", bad: false, note: "n/a " + e.message });
+ finalize();
+ }
+})();
+
+function finalize() {
+ const rep = document.getElementById("rep");
+ rep.textContent = checks.map(c => `${c.bad ? "✗" : "✓"} ${c.name} ${c.note}`).join("\n");
+ const failed = checks.filter(c => c.bad);
+ if (failed.length) Challenge.fail(...failed.map(c => `${c.name}: ${c.note}`));
+ else Challenge.pass("all runtime checks clean");
+}
+</script>
+</body>
diff --git a/test-suite/challenges/04-mouse-entropy.html b/test-suite/challenges/04-mouse-entropy.html
new file mode 100644
index 0000000..e4f4693
--- /dev/null
+++ b/test-suite/challenges/04-mouse-entropy.html
@@ -0,0 +1,34 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>04 mouse entropy</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: click the green button after moving the mouse in an organic path. Page requires
+ ≥15 <code>mousemove</code> events with non-zero <code>movementX/Y</code> variance before the click.</p>
+ <button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
+</main>
+<script>
+Challenge.init({
+ id: "mouse-entropy",
+ instructions: "click the button after natural mouse movement",
+});
+const moves = [];
+window.addEventListener("mousemove", (e) => {
+ moves.push({ x: e.clientX, y: e.clientY, mx: e.movementX, my: e.movementY, t: e.timeStamp });
+ if (moves.length > 200) moves.shift();
+});
+document.getElementById("go").addEventListener("click", (e) => {
+ if (moves.length < 15) return Challenge.fail(`only ${moves.length} mousemove events before click`);
+ const dx = moves.map(m => Math.abs(m.mx)).reduce((a,b)=>a+b,0);
+ const dy = moves.map(m => Math.abs(m.my)).reduce((a,b)=>a+b,0);
+ if (dx + dy < 50) return Challenge.fail(`movement deltas tiny: dx=${dx}, dy=${dy}`);
+ // Detect "teleport then click" — all moves clustered at the button.
+ const r = document.getElementById("go").getBoundingClientRect();
+ const inside = moves.filter(m => m.x>=r.left && m.x<=r.right && m.y>=r.top && m.y<=r.bottom).length;
+ if (inside === moves.length) return Challenge.fail("all mousemoves inside target rect (teleport)");
+ Challenge.pass(`${moves.length} moves, |dx|+|dy|=${dx+dy}, ${inside} inside target`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/05-event-timing.html b/test-suite/challenges/05-event-timing.html
new file mode 100644
index 0000000..16269ea
--- /dev/null
+++ b/test-suite/challenges/05-event-timing.html
@@ -0,0 +1,34 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>05 event timing</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: click the button 3 times. Page rejects when <code>pointerdown→pointerup</code> is &lt;30ms
+ or always identical, and when between-click gaps are too uniform.</p>
+ <button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me (0/3)</button>
+</main>
+<script>
+Challenge.init({ id: "event-timing", instructions: "click button 3 times with human-like timing" });
+const btn = document.getElementById("go");
+const ups = [], downs = [], clickTs = [];
+btn.addEventListener("pointerdown", (e) => downs.push(e.timeStamp));
+btn.addEventListener("pointerup", (e) => ups.push(e.timeStamp));
+btn.addEventListener("click", (e) => {
+ clickTs.push(e.timeStamp);
+ btn.textContent = `Click me (${clickTs.length}/3)`;
+ if (clickTs.length < 3) return;
+ const holds = downs.map((d,i)=> (ups[i]??d) - d);
+ const allSame = holds.every(h => Math.abs(h - holds[0]) < 0.5);
+ const tooFast = holds.some(h => h < 30);
+ const gaps = clickTs.slice(1).map((t,i)=> t - clickTs[i]);
+ const gapsUniform = gaps.length > 1 && Math.abs(gaps[0]-gaps[1]) < 5;
+ Challenge.log("timing", { holds, gaps });
+ if (tooFast) return Challenge.fail(`pointer hold too short: ${holds.join(",")}ms`);
+ if (allSame) return Challenge.fail(`pointer holds identical: ${holds.join(",")}ms`);
+ if (gapsUniform) return Challenge.fail(`between-click gaps suspiciously uniform: ${gaps.join(",")}ms`);
+ Challenge.pass(`holds=${holds.map(h=>h.toFixed(1)).join(",")}ms gaps=${gaps.map(g=>g.toFixed(0)).join(",")}ms`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/06-click-coordinates.html b/test-suite/challenges/06-click-coordinates.html
new file mode 100644
index 0000000..e35ae84
--- /dev/null
+++ b/test-suite/challenges/06-click-coordinates.html
@@ -0,0 +1,29 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>06 click coordinates</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: click the green button 5 times. Page rejects when clicks always land on the exact element center.</p>
+ <button id="go" style="padding:24px 40px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me (0/5)</button>
+</main>
+<script>
+Challenge.init({ id: "click-coordinates", instructions: "click button 5 times; coordinates must vary" });
+const btn = document.getElementById("go");
+const pts = [];
+btn.addEventListener("click", (e) => {
+ const r = btn.getBoundingClientRect();
+ const cx = r.left + r.width/2, cy = r.top + r.height/2;
+ pts.push({ x: e.clientX, y: e.clientY, dx: e.clientX-cx, dy: e.clientY-cy });
+ btn.textContent = `Click me (${pts.length}/5)`;
+ if (pts.length < 5) return;
+ const onCenter = pts.filter(p => Math.abs(p.dx) < 0.51 && Math.abs(p.dy) < 0.51).length;
+ const unique = new Set(pts.map(p => `${p.x},${p.y}`)).size;
+ Challenge.log("coords", { pts });
+ if (onCenter >= 4) return Challenge.fail(`${onCenter}/5 clicks on exact center`);
+ if (unique <= 1) return Challenge.fail(`only ${unique} unique click coords`);
+ Challenge.pass(`${unique} unique coords, ${onCenter} on center`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/07-pointer-properties.html b/test-suite/challenges/07-pointer-properties.html
new file mode 100644
index 0000000..753c523
--- /dev/null
+++ b/test-suite/challenges/07-pointer-properties.html
@@ -0,0 +1,29 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>07 pointer properties</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: click the button. Page inspects <code>pointerType</code>, <code>pressure</code>,
+ <code>movementX/Y</code> on the preceding pointermove, and that pointerId is non-zero.</p>
+ <button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
+</main>
+<script>
+Challenge.init({ id: "pointer-properties", instructions: "click button; pointer event details must look real" });
+const btn = document.getElementById("go");
+let lastMove = null;
+window.addEventListener("pointermove", (e) => { lastMove = { mx: e.movementX, my: e.movementY, pid: e.pointerId, type: e.pointerType }; });
+btn.addEventListener("pointerdown", (e) => {
+ Challenge.log("pointerdown", { type: e.pointerType, pressure: e.pressure, pid: e.pointerId, mx: e.movementX, my: e.movementY });
+ const bad = [];
+ if (e.pointerType !== "mouse" && e.pointerType !== "touch" && e.pointerType !== "pen") bad.push(`pointerType=${e.pointerType}`);
+ if (e.pointerType === "mouse" && e.pressure !== 0.5) bad.push(`mouse pressure=${e.pressure} (real=0.5)`);
+ if (e.pointerId === 0) bad.push("pointerId=0");
+ if (!lastMove) bad.push("no preceding pointermove");
+ else if (lastMove.mx === 0 && lastMove.my === 0) bad.push("preceding pointermove had movementX=movementY=0");
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass(`type=${e.pointerType} pressure=${e.pressure} pid=${e.pointerId}`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/08-keyboard-cadence.html b/test-suite/challenges/08-keyboard-cadence.html
new file mode 100644
index 0000000..1de9bbd
--- /dev/null
+++ b/test-suite/challenges/08-keyboard-cadence.html
@@ -0,0 +1,37 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>08 keyboard cadence</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: type <code>secret</code>. Page demands per-key keydown+keypress+keyup with non-uniform gaps and non-zero hold time.</p>
+ <input id="t" placeholder="type 'secret'" style="font-size:18px;padding:8px 12px;width:240px">
+</main>
+<script>
+Challenge.init({ id: "keyboard-cadence", instructions: "type 'secret' with realistic per-key cadence" });
+const t = document.getElementById("t");
+const evs = [];
+["keydown","keypress","keyup","input"].forEach(name => {
+ t.addEventListener(name, (e) => evs.push({ name, key: e.key ?? e.data, t: e.timeStamp, trusted: e.isTrusted }));
+});
+t.addEventListener("keyup", () => {
+ if (t.value !== "secret") return;
+ const downs = evs.filter(e => e.name === "keydown");
+ const presses = evs.filter(e => e.name === "keypress");
+ const ups = evs.filter(e => e.name === "keyup");
+ const bad = [];
+ if (downs.length < 6) bad.push(`only ${downs.length} keydowns (need >=6)`);
+ if (presses.length < 6) bad.push(`only ${presses.length} keypress events`);
+ if (ups.length < 6) bad.push(`only ${ups.length} keyups`);
+ const holds = downs.slice(0, ups.length).map((d,i)=> ups[i].t - d.t);
+ if (holds.some(h => h <= 0)) bad.push(`some keyup at-or-before keydown: ${holds.join(",")}`);
+ if (holds.length && holds.every(h => Math.abs(h-holds[0]) < 0.5)) bad.push(`hold times identical: ${holds.join(",")}`);
+ const gaps = downs.slice(1).map((d,i)=> d.t - downs[i].t);
+ if (gaps.length && gaps.every(g => Math.abs(g-gaps[0]) < 0.5)) bad.push(`keydown gaps identical: ${gaps.join(",")}`);
+ Challenge.log("cadence", { holds, gaps });
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass(`holds=${holds.map(h=>h.toFixed(0)).join(",")} gaps=${gaps.map(g=>g.toFixed(0)).join(",")}`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/09-composition-input.html b/test-suite/challenges/09-composition-input.html
new file mode 100644
index 0000000..b4b2002
--- /dev/null
+++ b/test-suite/challenges/09-composition-input.html
@@ -0,0 +1,45 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>09 framework input invariants</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: type <code>abc</code>. Page asserts React-style invariants: <code>beforeinput</code>
+ fires <em>before</em> <code>input</code>, both per-character, <code>inputType==="insertText"</code>,
+ value mutates between events, and no <code>compositionstart</code> for plain typing.</p>
+ <input id="t" placeholder="type abc" style="font-size:18px;padding:8px 12px;width:240px">
+</main>
+<script>
+Challenge.init({ id: "composition-input", instructions: "type 'abc' producing framework-correct event order" });
+const t = document.getElementById("t");
+const evs = [];
+let sawCompositionStart = false;
+["beforeinput","input","compositionstart","compositionend"].forEach(name => {
+ t.addEventListener(name, (e) => {
+ evs.push({ name, t: e.timeStamp, inputType: e.inputType, data: e.data, value: t.value });
+ if (name === "compositionstart") sawCompositionStart = true;
+ });
+});
+t.addEventListener("input", () => {
+ if (t.value !== "abc") return;
+ const before = evs.filter(e => e.name === "beforeinput");
+ const inp = evs.filter(e => e.name === "input");
+ const bad = [];
+ if (sawCompositionStart) bad.push("compositionstart fired for plain ASCII typing");
+ if (before.length !== 3) bad.push(`beforeinput count=${before.length} (need 3)`);
+ if (inp.length !== 3) bad.push(`input count=${inp.length} (need 3)`);
+ if (before.some(b => b.inputType !== "insertText")) bad.push(`beforeinput.inputType=${before.map(b=>b.inputType).join(",")}`);
+ // beforeinput must precede matching input.
+ for (let i = 0; i < Math.min(before.length, inp.length); i++) {
+ if (before[i].t > inp[i].t) bad.push(`beforeinput[${i}] after input[${i}]`);
+ }
+ // Value must reflect each char incrementally.
+ const seq = inp.map(e => e.value).join("|");
+ if (seq !== "a|ab|abc") bad.push(`value seq at input events: ${seq} (need a|ab|abc)`);
+ Challenge.log("seq", { evs });
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass(`event order and invariants OK; seq=${seq}`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/10-user-activation.html b/test-suite/challenges/10-user-activation.html
new file mode 100644
index 0000000..d0929d8
--- /dev/null
+++ b/test-suite/challenges/10-user-activation.html
@@ -0,0 +1,31 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>10 user activation gates</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: click the button. Handler then tries gated APIs (clipboard.writeText, fullscreen).
+ Success requires <code>navigator.userActivation.isActive</code> and at least one gated API to succeed.</p>
+ <button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
+</main>
+<script>
+Challenge.init({ id: "user-activation", instructions: "click button; user-activation must unlock gated APIs" });
+document.getElementById("go").addEventListener("click", async () => {
+ const ua = navigator.userActivation;
+ Challenge.log("activation", { isActive: ua?.isActive, hasBeenActive: ua?.hasBeenActive });
+ let clip = "skip", fs = "skip";
+ try { await navigator.clipboard.writeText("pi-chrome-test"); clip = "ok"; }
+ catch (e) { clip = "err:" + e.name; }
+ try { await document.documentElement.requestFullscreen(); fs = "ok"; document.exitFullscreen?.(); }
+ catch (e) { fs = "err:" + e.name; }
+ const bad = [];
+ if (!ua?.isActive) bad.push("userActivation.isActive === false (synthetic click)");
+ if (clip.startsWith("err")) bad.push("clipboard.writeText " + clip);
+ if (fs.startsWith("err")) bad.push("requestFullscreen " + fs);
+ Challenge.log("gates", { clip, fs });
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass(`activation active; clipboard=${clip}; fullscreen=${fs}`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/11-honeypot.html b/test-suite/challenges/11-honeypot.html
new file mode 100644
index 0000000..26a6240
--- /dev/null
+++ b/test-suite/challenges/11-honeypot.html
@@ -0,0 +1,36 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>11 honeypot fields</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: fill <em>only</em> the visible "Name" field with <code>alex</code> and submit.
+ Three honeypots (a, b, c) are present that a human cannot see/tab to. Filling any honeypot fails.</p>
+ <form id="f">
+ <label>Name <input name="name" id="name"></label>
+ <!-- off-screen -->
+ <input name="a" aria-hidden="true" tabindex="-1" autocomplete="off"
+ style="position:absolute;left:-9999px;top:-9999px">
+ <!-- display:none -->
+ <input name="b" aria-hidden="true" tabindex="-1" autocomplete="off" style="display:none">
+ <!-- zero-size + visually hidden -->
+ <input name="c" aria-hidden="true" tabindex="-1" autocomplete="off"
+ style="opacity:0;width:0;height:0;border:0;padding:0">
+ <button type="submit">Submit</button>
+ </form>
+</main>
+<script>
+Challenge.init({ id: "honeypot", instructions: "fill only the Name field with 'alex', then submit" });
+const form = document.getElementById("f");
+form.addEventListener("submit", (e) => {
+ e.preventDefault();
+ const fd = new FormData(form);
+ const filled = ["a","b","c"].filter(k => (fd.get(k) ?? "").toString().length > 0);
+ Challenge.log("submit", { name: fd.get("name"), a: fd.get("a"), b: fd.get("b"), c: fd.get("c") });
+ if (filled.length) return Challenge.fail("honeypot(s) filled: " + filled.join(","));
+ if ((fd.get("name") ?? "") !== "alex") return Challenge.fail(`name="${fd.get("name")}" (need 'alex')`);
+ Challenge.pass("only visible field filled");
+});
+</script>
+</body>
diff --git a/test-suite/challenges/12-fingerprint.html b/test-suite/challenges/12-fingerprint.html
new file mode 100644
index 0000000..dbc8a25
--- /dev/null
+++ b/test-suite/challenges/12-fingerprint.html
@@ -0,0 +1,57 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>12 fingerprint consistency</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Auto-check. Looks for cross-API consistency mismatches that betray instrumented Chrome.</p>
+ <pre id="rep" style="font:12px monospace;background:#111;color:#eee;padding:12px;border-radius:6px"></pre>
+</main>
+<script>
+Challenge.init({ id: "fingerprint", instructions: "no interaction" });
+(async () => {
+ const checks = [];
+ const ua = navigator.userAgent;
+ const isChrome = /Chrome\/\d+/.test(ua);
+ checks.push({ name: "ua-is-chrome", bad: !isChrome, note: ua });
+
+ // UA-CH: Chrome should expose userAgentData with brands incl Chromium.
+ const uad = navigator.userAgentData;
+ const hasUAD = !!uad;
+ checks.push({ name: "userAgentData-present", bad: isChrome && !hasUAD, note: hasUAD ? JSON.stringify(uad.brands) : "missing" });
+
+ // Chrome runtime object exists in normal Chrome.
+ checks.push({ name: "window.chrome", bad: isChrome && typeof window.chrome === "undefined",
+ note: typeof window.chrome });
+
+ // languages must match accept-language semantics.
+ checks.push({ name: "languages-includes-language", bad: !navigator.languages?.includes(navigator.language),
+ note: `${navigator.language} vs ${JSON.stringify(navigator.languages)}` });
+
+ // WebGL vendor/renderer should not be Brian Paul / SwiftShader on user profile.
+ try {
+ const gl = document.createElement("canvas").getContext("webgl");
+ const dbg = gl.getExtension("WEBGL_debug_renderer_info");
+ const vendor = dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR);
+ const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
+ const swiftshader = /SwiftShader|llvmpipe|Software/i.test(renderer);
+ checks.push({ name: "webgl-not-software", bad: swiftshader, note: `${vendor} / ${renderer}` });
+ } catch (e) {
+ checks.push({ name: "webgl-not-software", bad: false, note: "n/a " + e.message });
+ }
+
+ // hardwareConcurrency / deviceMemory plausibility.
+ checks.push({ name: "hardwareConcurrency", bad: !(navigator.hardwareConcurrency > 0), note: String(navigator.hardwareConcurrency) });
+
+ // navigator.webdriver again, separate from challenge 03 so it appears here too.
+ checks.push({ name: "webdriver-false", bad: navigator.webdriver === true, note: String(navigator.webdriver) });
+
+ const rep = document.getElementById("rep");
+ rep.textContent = checks.map(c => `${c.bad ? "✗" : "✓"} ${c.name} ${c.note}`).join("\n");
+ const failed = checks.filter(c => c.bad);
+ if (failed.length) Challenge.fail(...failed.map(c => `${c.name}: ${c.note}`));
+ else Challenge.pass("fingerprint consistent with real Chrome");
+})();
+</script>
+</body>
diff --git a/test-suite/challenges/13-focus-order.html b/test-suite/challenges/13-focus-order.html
new file mode 100644
index 0000000..90cbcaa
--- /dev/null
+++ b/test-suite/challenges/13-focus-order.html
@@ -0,0 +1,31 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>13 focus order</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: focus the input by <em>clicking</em> it (not <code>.focus()</code>), then type <code>x</code>.
+ Pointerdown must precede focus, and <code>:focus-visible</code> must be false for pointer focus.</p>
+ <input id="t" placeholder="click then type x" style="font-size:18px;padding:8px 12px;width:240px">
+</main>
+<script>
+Challenge.init({ id: "focus-order", instructions: "click input then type 'x'" });
+const t = document.getElementById("t");
+let lastPointer = -Infinity, lastFocus = -Infinity, sawPointer = false;
+t.addEventListener("pointerdown", (e) => { lastPointer = e.timeStamp; sawPointer = true; });
+t.addEventListener("focus", (e) => {
+ lastFocus = e.timeStamp;
+ if (!sawPointer) Challenge.fail("focus arrived with no preceding pointerdown");
+ else if (lastFocus < lastPointer) Challenge.fail("focus before pointerdown");
+});
+t.addEventListener("input", () => {
+ if (t.value !== "x") return;
+ // For pointer-driven focus, :focus-visible should be false.
+ const fv = t.matches(":focus-visible");
+ Challenge.log("focus-visible", { fv });
+ if (fv) Challenge.fail(":focus-visible true after pointer click (looks like keyboard focus)");
+ else if (window.__verdict !== "FAIL") Challenge.pass("pointerdown→focus→input order ok, :focus-visible=false");
+});
+</script>
+</body>
diff --git a/test-suite/challenges/14-wheel-scroll.html b/test-suite/challenges/14-wheel-scroll.html
new file mode 100644
index 0000000..4f03f24
--- /dev/null
+++ b/test-suite/challenges/14-wheel-scroll.html
@@ -0,0 +1,28 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>14 wheel scroll</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: scroll the box to the bottom. Page rejects raw <code>scrollTop</code> assignment without
+ <code>wheel</code> events.</p>
+ <div id="box" style="height:200px;overflow:auto;border:1px solid #555;background:#fff;color:#111;padding:8px">
+ <div style="height:1200px">scroll me ⬇<br><br>...lots of content...</div>
+ </div>
+</main>
+<script>
+Challenge.init({ id: "wheel-scroll", instructions: "scroll the box to its bottom" });
+const box = document.getElementById("box");
+let wheelCount = 0, lastWheelTs = -Infinity;
+box.addEventListener("wheel", (e) => { wheelCount++; lastWheelTs = e.timeStamp; Challenge.log("wheel", { dy: e.deltaY }); }, { passive: true });
+box.addEventListener("scroll", () => {
+ Challenge.log("scroll", { top: box.scrollTop });
+ if (box.scrollTop + box.clientHeight >= box.scrollHeight - 2) {
+ if (wheelCount === 0) Challenge.fail("scrolled to bottom with zero wheel events");
+ else if (performance.now() - lastWheelTs > 1500) Challenge.fail("wheel events too far before final scroll");
+ else Challenge.pass(`${wheelCount} wheel events accompanied scroll`);
+ }
+});
+</script>
+</body>
diff --git a/test-suite/challenges/15-drag-drop-datatransfer.html b/test-suite/challenges/15-drag-drop-datatransfer.html
new file mode 100644
index 0000000..e0b6d12
--- /dev/null
+++ b/test-suite/challenges/15-drag-drop-datatransfer.html
@@ -0,0 +1,73 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>15 drag-drop DataTransfer</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: drag the <b>red box</b> into the <b>drop zone</b>. Page asserts a full HTML5
+ drag sequence with a populated <code>DataTransfer</code> — synthetic pointer drags
+ dispatched without <code>dragstart</code>/<code>drop</code> + DataTransfer payload fail.</p>
+ <div style="display:flex;gap:24px;align-items:center;margin-top:16px">
+ <div id="src" draggable="true"
+ style="width:80px;height:80px;background:#c33;color:#fff;display:flex;align-items:center;justify-content:center;border-radius:6px;cursor:grab">
+ DRAG
+ </div>
+ <div id="dst"
+ style="width:200px;height:120px;border:2px dashed #888;display:flex;align-items:center;justify-content:center;color:#888">
+ DROP HERE
+ </div>
+ </div>
+</main>
+<script>
+Challenge.init({ id: "drag-drop-datatransfer", instructions: "drag red box into drop zone via real HTML5 drag" });
+const src = document.getElementById("src");
+const dst = document.getElementById("dst");
+
+const seen = { dragstart: 0, drag: 0, dragenter: 0, dragover: 0, drop: 0, dragend: 0 };
+let dtHadTypes = false, dtPayload = null, isTrustedAll = true;
+
+src.addEventListener("dragstart", (e) => {
+ seen.dragstart++;
+ if (!e.isTrusted) isTrustedAll = false;
+ try { e.dataTransfer.setData("text/plain", "payload-" + Math.random().toString(36).slice(2,8)); } catch {}
+ Challenge.log("dragstart", { hasDt: !!e.dataTransfer });
+});
+src.addEventListener("drag", (e) => { seen.drag++; if (!e.isTrusted) isTrustedAll = false; });
+src.addEventListener("dragend", (e) => { seen.dragend++; if (!e.isTrusted) isTrustedAll = false; });
+
+dst.addEventListener("dragenter", (e) => { seen.dragenter++; e.preventDefault(); if (!e.isTrusted) isTrustedAll = false; });
+dst.addEventListener("dragover", (e) => { seen.dragover++; e.preventDefault(); if (!e.isTrusted) isTrustedAll = false; });
+dst.addEventListener("drop", (e) => {
+ e.preventDefault();
+ seen.drop++;
+ if (!e.isTrusted) isTrustedAll = false;
+ const dt = e.dataTransfer;
+ if (dt) {
+ dtHadTypes = (dt.types && dt.types.length > 0);
+ try { dtPayload = dt.getData("text/plain"); } catch {}
+ }
+ Challenge.log("drop", { types: dt && [...dt.types], payload: dtPayload });
+ // dragend fires AFTER drop per spec; give it a tick.
+ setTimeout(evaluate, 50);
+});
+
+function evaluate() {
+ const bad = [];
+ if (!isTrustedAll) bad.push("at least one drag event isTrusted=false");
+ for (const k of ["dragstart","dragover","drop","dragend"]) {
+ if (!seen[k]) bad.push(`missing ${k}`);
+ }
+ if (!dtHadTypes) bad.push("DataTransfer.types empty on drop");
+ if (!dtPayload || !dtPayload.startsWith("payload-")) bad.push("DataTransfer payload missing on drop");
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass(`full drag cycle, payload=${dtPayload}`);
+}
+
+// Fallback: pointer-drag without dragstart triggers fail after a delay.
+let pointerDownInSrc = false;
+src.addEventListener("pointerdown", () => { pointerDownInSrc = true;
+ setTimeout(() => { if (!seen.dragstart && pointerDownInSrc) Challenge.fail("pointerdown on src but no dragstart fired"); }, 1500);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/16-contenteditable-selection.html b/test-suite/challenges/16-contenteditable-selection.html
new file mode 100644
index 0000000..fea2adc
--- /dev/null
+++ b/test-suite/challenges/16-contenteditable-selection.html
@@ -0,0 +1,54 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>16 contenteditable selection</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: focus the editable region and type <code>hello</code>. Page asserts that
+ <code>window.getSelection()</code> reflects per-keystroke caret movement
+ (<code>rangeCount===1</code>, <code>collapsed</code> caret, anchor inside the editor,
+ offset advances 1 per char) and that <code>selectionchange</code> fires.</p>
+ <div id="ed" contenteditable="true"
+ style="min-height:60px;padding:10px;border:1px solid #555;background:#fff;color:#111;font:16px monospace"></div>
+</main>
+<script>
+Challenge.init({ id: "contenteditable-selection", instructions: "focus editor and type 'hello'" });
+const ed = document.getElementById("ed");
+const offsets = [];
+let selChanges = 0;
+
+document.addEventListener("selectionchange", () => {
+ selChanges++;
+ const s = window.getSelection();
+ if (!s || s.rangeCount === 0) return;
+ if (!ed.contains(s.anchorNode)) return;
+ offsets.push({ off: s.anchorOffset, collapsed: s.isCollapsed, t: performance.now(), len: ed.textContent.length });
+});
+
+ed.addEventListener("input", async () => {
+ if (ed.textContent !== "hello") return;
+ // selectionchange is async — wait a tick so the final caret update is observed.
+ await new Promise((r) => setTimeout(r, 30));
+ const bad = [];
+ const sel = window.getSelection();
+ if (!sel) bad.push("getSelection()==null");
+ else {
+ if (sel.rangeCount !== 1) bad.push(`rangeCount=${sel.rangeCount} (need 1)`);
+ if (!sel.isCollapsed) bad.push("selection not collapsed after typing");
+ if (!ed.contains(sel.anchorNode)) bad.push("selection anchor outside editor");
+ if (sel.anchorOffset !== 5) bad.push(`anchorOffset=${sel.anchorOffset} (need 5)`);
+ }
+ if (selChanges < 5) bad.push(`selectionchange count=${selChanges} (need ≥5)`);
+ // offsets should advance monotonically 1,2,3,4,5 (allowing extras from focus)
+ const monotonic = offsets.map(o => o.off);
+ const peak = Math.max(0, ...monotonic);
+ if (peak !== 5) bad.push(`peak caret offset=${peak} during typing (need 5)`);
+ // every caret reading must be collapsed (no spurious ranges)
+ if (offsets.some(o => !o.collapsed)) bad.push("non-collapsed range observed during typing");
+ Challenge.log("sel", { offsets, selChanges });
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass(`selectionchanges=${selChanges}, caret advanced 0→5`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/17-paste-clipboard.html b/test-suite/challenges/17-paste-clipboard.html
new file mode 100644
index 0000000..c19e291
--- /dev/null
+++ b/test-suite/challenges/17-paste-clipboard.html
@@ -0,0 +1,48 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>17 paste clipboard</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: paste the string <code>pi-chrome</code> into the input via a real OS paste
+ (Cmd/Ctrl+V firing a trusted <code>paste</code> event with populated
+ <code>clipboardData</code>). Programmatic <code>value=</code> or <code>setRangeText</code>
+ fail. The <code>input</code> event's <code>inputType</code> must be
+ <code>insertFromPaste</code>.</p>
+ <input id="t" placeholder="paste here" style="font-size:18px;padding:8px 12px;width:280px">
+</main>
+<script>
+Challenge.init({ id: "paste-clipboard", instructions: "paste 'pi-chrome' via OS clipboard" });
+const t = document.getElementById("t");
+let sawPaste = false, pasteIsTrusted = false, pastePayload = null;
+t.addEventListener("paste", (e) => {
+ sawPaste = true;
+ pasteIsTrusted = e.isTrusted;
+ try { pastePayload = e.clipboardData && e.clipboardData.getData("text/plain"); } catch {}
+ Challenge.log("paste", { isTrusted: e.isTrusted, payload: pastePayload });
+});
+
+let lastInputType = null;
+t.addEventListener("input", (e) => {
+ lastInputType = e.inputType;
+ if (t.value !== "pi-chrome") return;
+ const bad = [];
+ if (!sawPaste) bad.push("no paste event fired before value matched");
+ if (!pasteIsTrusted) bad.push("paste event isTrusted=false");
+ if (pastePayload !== "pi-chrome") bad.push(`clipboardData payload="${pastePayload}" (need 'pi-chrome')`);
+ if (lastInputType !== "insertFromPaste") bad.push(`input.inputType="${lastInputType}" (need 'insertFromPaste')`);
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass("trusted paste with clipboardData text/plain match");
+});
+
+// Also fail loudly if value is set without any input event (raw .value=)
+let inputFired = false;
+t.addEventListener("input", () => { inputFired = true; });
+const obs = new MutationObserver(() => {});
+obs.observe(t, { attributes: true, attributeFilter: ["value"] });
+setInterval(() => {
+ if (t.value === "pi-chrome" && !inputFired) Challenge.fail("value matched without input event firing");
+}, 200);
+</script>
+</body>
diff --git a/test-suite/challenges/18-native-select.html b/test-suite/challenges/18-native-select.html
new file mode 100644
index 0000000..7ca7002
--- /dev/null
+++ b/test-suite/challenges/18-native-select.html
@@ -0,0 +1,56 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>18 native select option</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: select the option <code>banana</code> from the native <code>&lt;select&gt;</code>.
+ Page asserts a real picker interaction: <code>mousedown</code>/<code>pointerdown</code>
+ on the &lt;select&gt; (opening the OS picker), then a <code>change</code> event with
+ <code>isTrusted=true</code>. Programmatically setting <code>.value</code> or clicking the
+ option element directly fails (clicking &lt;option&gt; does not change selection in
+ real browsers).</p>
+ <select id="s" style="font-size:18px;padding:6px 10px">
+ <option value="">— pick a fruit —</option>
+ <option value="apple">apple</option>
+ <option value="banana">banana</option>
+ <option value="cherry">cherry</option>
+ </select>
+</main>
+<script>
+Challenge.init({ id: "native-select", instructions: "pick 'banana' from native select" });
+const s = document.getElementById("s");
+
+let sawPointerDownOnSelect = false, pointerDownTrusted = false;
+s.addEventListener("pointerdown", (e) => { sawPointerDownOnSelect = true; pointerDownTrusted = e.isTrusted; });
+s.addEventListener("mousedown", (e) => { sawPointerDownOnSelect = true; pointerDownTrusted ||= e.isTrusted; });
+
+// Trap: if a click bubbles up from an <option>, that's bot-like (real OS pickers don't dispatch this).
+s.addEventListener("click", (e) => {
+ if (e.target && e.target.tagName === "OPTION") {
+ Challenge.fail("click event with target=<option> bubbled to <select> (synthetic option click)");
+ }
+});
+
+let changeTrusted = false;
+s.addEventListener("change", (e) => {
+ changeTrusted = e.isTrusted;
+ Challenge.log("change", { v: s.value, isTrusted: e.isTrusted });
+ if (s.value !== "banana") return Challenge.fail(`selected="${s.value}" (need 'banana')`);
+ const bad = [];
+ if (!changeTrusted) bad.push("change event isTrusted=false");
+ if (!sawPointerDownOnSelect) bad.push("no pointerdown/mousedown on <select> before change");
+ if (!pointerDownTrusted) bad.push("pointerdown on select isTrusted=false");
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass("native picker opened and trusted change fired");
+});
+
+// Also trip if value mutates without change firing.
+let changed = false;
+s.addEventListener("change", () => { changed = true; });
+setInterval(() => {
+ if (s.value === "banana" && !changed) Challenge.fail("value='banana' without any change event");
+}, 200);
+</script>
+</body>
diff --git a/test-suite/challenges/19-hover-dwell.html b/test-suite/challenges/19-hover-dwell.html
new file mode 100644
index 0000000..21ce80a
--- /dev/null
+++ b/test-suite/challenges/19-hover-dwell.html
@@ -0,0 +1,50 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>19 hover dwell</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: hover the trigger and wait until the <b>Confirm</b> button reveals (≥600ms dwell
+ with continuous <code>pointermove</code> activity inside the trigger), then click Confirm.
+ Instant hover-then-click without dwell, or hover with no intermediate pointermove,
+ looks robotic and fails.</p>
+ <div id="trig" style="display:inline-block;padding:16px 24px;background:#2a4;color:#fff;border-radius:6px;cursor:pointer">
+ hover me
+ </div>
+ <button id="go" style="display:none;margin-left:12px;padding:10px 18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Confirm</button>
+</main>
+<script>
+Challenge.init({ id: "hover-dwell", instructions: "hover ≥600ms with movement, then click Confirm" });
+const trig = document.getElementById("trig");
+const btn = document.getElementById("go");
+
+let enterTs = 0, lastMoveTs = 0, moveCount = 0;
+trig.addEventListener("pointerenter", (e) => { enterTs = e.timeStamp; lastMoveTs = e.timeStamp; moveCount = 0; });
+trig.addEventListener("pointermove", (e) => { lastMoveTs = e.timeStamp; moveCount++; });
+trig.addEventListener("pointerleave", () => {
+ if (!btn.dataset.revealed) { enterTs = 0; lastMoveTs = 0; moveCount = 0; }
+});
+
+// Reveal after sustained dwell + activity.
+setInterval(() => {
+ if (btn.dataset.revealed) return;
+ if (!enterTs) return;
+ const dwell = performance.now() - enterTs;
+ if (dwell >= 600 && moveCount >= 3) {
+ btn.style.display = "inline-block";
+ btn.dataset.revealed = "1";
+ btn.dataset.revealTs = performance.now();
+ Challenge.log("revealed", { dwell, moveCount });
+ }
+}, 50);
+
+btn.addEventListener("click", (e) => {
+ if (!btn.dataset.revealed) return Challenge.fail("Confirm clicked before reveal (display:none)");
+ const sinceReveal = e.timeStamp - Number(btn.dataset.revealTs);
+ if (sinceReveal < 80) return Challenge.fail(`clicked ${sinceReveal.toFixed(0)}ms after reveal (too instant)`);
+ if (!e.isTrusted) return Challenge.fail("Confirm click isTrusted=false");
+ Challenge.pass(`dwell+activity+human-latency reveal (Δ=${sinceReveal.toFixed(0)}ms)`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/20-react-value-tracker.html b/test-suite/challenges/20-react-value-tracker.html
new file mode 100644
index 0000000..fb217d1
--- /dev/null
+++ b/test-suite/challenges/20-react-value-tracker.html
@@ -0,0 +1,78 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>20 React _valueTracker</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: set the input to <code>react-ok</code> in a way that React would accept.
+ The page mimics React's <code>_valueTracker</code>: it stamps the input with a
+ hidden tracker holding the last-seen value. An <code>input</code> event is only
+ considered "framework-real" if the tracker's recorded value differs from the
+ current <code>input.value</code> at the moment the event fires
+ (i.e. somebody used the native <code>HTMLInputElement.value</code> setter and
+ dispatched <code>input</code> — what React internals require).</p>
+ <p>Setting <code>el.value = ...</code> directly and dispatching a synthetic <code>input</code>
+ fails because the native setter wasn't used. Calling <code>setRangeText</code> without
+ dispatching <code>input</code> also fails.</p>
+ <input id="t" placeholder="type or fill 'react-ok'" style="font-size:18px;padding:8px 12px;width:240px">
+</main>
+<script>
+Challenge.init({ id: "react-value-tracker", instructions: "make input value 'react-ok' as React expects" });
+
+const t = document.getElementById("t");
+
+// Install a fake React-style _valueTracker.
+(function attachTracker(node) {
+ const nativeProto = Object.getPrototypeOf(node);
+ const desc = Object.getOwnPropertyDescriptor(nativeProto, "value");
+ const nativeGet = desc.get, nativeSet = desc.set;
+ let tracked = nativeGet.call(node); // last value the "framework" has seen
+ node._valueTracker = {
+ getValue() { return tracked; },
+ setValue(v) { tracked = v; },
+ };
+ // Wrap the value property on the *instance* to intercept assignments.
+ Object.defineProperty(node, "value", {
+ configurable: true,
+ get() { return nativeGet.call(this); },
+ set(v) {
+ // If somebody assigns via instance property (the bad path), sync the tracker
+ // so the input event below will look "stale" and we can detect it.
+ tracked = String(v);
+ nativeSet.call(this, v);
+ },
+ });
+})(t);
+
+let passed = false;
+t.addEventListener("input", (e) => {
+ if (passed) return;
+ const proto = Object.getPrototypeOf(t);
+ const nativeGet = Object.getOwnPropertyDescriptor(proto, "value").get;
+ const real = nativeGet.call(t);
+ const tracked = t._valueTracker.getValue();
+ Challenge.log("input", { real, tracked, isTrusted: e.isTrusted });
+ if (real !== "react-ok") return;
+ if (!e.isTrusted && tracked === real) {
+ return Challenge.fail("synthetic input event but _valueTracker already up-to-date (instance value= setter used, not native)");
+ }
+ // The "good" path: native setter ran (tracker still stale), then input dispatched.
+ // After the framework consumes the event it would syncs the tracker.
+ t._valueTracker.setValue(real);
+ passed = true;
+ Challenge.pass(`value 'react-ok' delivered with stale tracker (native setter path); isTrusted=${e.isTrusted}`);
+});
+
+// Trap: value mutates without input ever firing.
+setInterval(() => {
+ if (passed) return;
+ const proto = Object.getPrototypeOf(t);
+ const nativeGet = Object.getOwnPropertyDescriptor(proto, "value").get;
+ if (nativeGet.call(t) === "react-ok" && !passed) {
+ // Only fail if we've been sitting on the value with no event for a while.
+ setTimeout(() => { if (!passed && nativeGet.call(t) === "react-ok") Challenge.fail("value='react-ok' but no input event consumed it"); }, 600);
+ }
+}, 300);
+</script>
+</body>
diff --git a/test-suite/challenges/21-keyboard-modifiers.html b/test-suite/challenges/21-keyboard-modifiers.html
new file mode 100644
index 0000000..870bef6
--- /dev/null
+++ b/test-suite/challenges/21-keyboard-modifiers.html
@@ -0,0 +1,65 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>21 keyboard modifiers</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: focus the input and type a capital <code>A</code> via <b>Shift+a</b>
+ (not Caps Lock, not pasted, not assigned). Page asserts a real Shift chord:
+ <code>keydown</code> for <code>Shift</code> arrives <em>before</em> <code>keydown</code> for
+ <code>A</code>; the <code>A</code> event has <code>shiftKey=true</code>,
+ <code>key==="A"</code>, <code>code==="KeyA"</code>, <code>getModifierState("Shift")===true</code>;
+ and <code>keyup</code> for Shift arrives <em>after</em> <code>keyup</code> for <code>A</code>.</p>
+ <input id="t" placeholder="type Shift+a" style="font-size:18px;padding:8px 12px;width:240px">
+</main>
+<script>
+Challenge.init({ id: "keyboard-modifiers", instructions: "type capital A using Shift+a chord" });
+const t = document.getElementById("t");
+
+const log = [];
+for (const name of ["keydown","keypress","keyup","input"]) {
+ t.addEventListener(name, (e) => {
+ log.push({
+ name,
+ key: e.key, code: e.code,
+ shiftKey: e.shiftKey, isTrusted: e.isTrusted,
+ modShift: e.getModifierState ? e.getModifierState("Shift") : null,
+ t: e.timeStamp,
+ });
+ });
+}
+
+t.addEventListener("input", () => {
+ if (t.value !== "A") return;
+ const bad = [];
+ const downShift = log.find(e => e.name === "keydown" && e.key === "Shift");
+ const downA = log.find(e => e.name === "keydown" && e.code === "KeyA");
+ const upShift = log.find(e => e.name === "keyup" && e.key === "Shift");
+ const upA = log.find(e => e.name === "keyup" && e.code === "KeyA");
+
+ if (!downShift) bad.push("no keydown for Shift");
+ if (!downA) bad.push("no keydown for KeyA");
+ if (!upShift) bad.push("no keyup for Shift");
+ if (!upA) bad.push("no keyup for KeyA");
+
+ if (downShift && downA && downShift.t > downA.t) bad.push("Shift keydown after A keydown");
+ if (upShift && upA && upShift.t < upA.t) bad.push("Shift keyup before A keyup");
+
+ if (downA) {
+ if (downA.key !== "A") bad.push(`A keydown.key="${downA.key}" (need 'A')`);
+ if (downA.code !== "KeyA") bad.push(`A keydown.code="${downA.code}"`);
+ if (!downA.shiftKey) bad.push("A keydown.shiftKey=false");
+ if (downA.modShift !== true) bad.push("getModifierState('Shift')!==true on A keydown");
+ if (!downA.isTrusted) bad.push("A keydown isTrusted=false");
+ }
+ if (downShift) {
+ if (!downShift.isTrusted) bad.push("Shift keydown isTrusted=false");
+ }
+
+ Challenge.log("chord", { log });
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass("Shift+a chord, modifiers + ordering correct");
+});
+</script>
+</body>
diff --git a/test-suite/challenges/22-touch-events.html b/test-suite/challenges/22-touch-events.html
new file mode 100644
index 0000000..042ddf4
--- /dev/null
+++ b/test-suite/challenges/22-touch-events.html
@@ -0,0 +1,66 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>22 touch events</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: tap the green pad on a touch-capable device (or via DevTools touch
+ emulation). Page asserts a real <code>touchstart</code>/<code>touchend</code> sequence:
+ <code>touches</code> / <code>changedTouches</code> are
+ <code>TouchList</code> objects containing <code>Touch</code> instances with
+ <code>identifier</code>, <code>clientX/Y</code>, <code>radiusX/Y</code>, and
+ <code>force</code> in [0,1]. Synthetic <code>PointerEvent</code> with
+ <code>pointerType="touch"</code> alone does not satisfy this — touch events must
+ fire too.</p>
+ <p><b>Note for caller:</b> requires Chrome touch emulation on (DevTools → Sensors →
+ "Touch: Force enabled") or a real touchscreen. This is intentional: pi-chrome's
+ synthetic <code>pointerType:'touch'</code> does NOT also dispatch TouchEvents.</p>
+ <div id="pad" style="width:200px;height:200px;background:#1f7a1f;border-radius:12px;color:#fff;display:flex;align-items:center;justify-content:center;font:18px monospace;touch-action:none;user-select:none">
+ TAP
+ </div>
+</main>
+<script>
+Challenge.init({ id: "touch-events", instructions: "tap the pad on a touch-capable device" });
+const pad = document.getElementById("pad");
+
+let touchStart = null, touchEnd = null;
+pad.addEventListener("touchstart", (e) => {
+ touchStart = e;
+ Challenge.log("touchstart", { tl: e.touches.length, ct: e.changedTouches.length, isTrusted: e.isTrusted });
+}, { passive: true });
+pad.addEventListener("touchend", (e) => {
+ touchEnd = e;
+ const bad = [];
+ if (!touchStart) return Challenge.fail("touchend without touchstart");
+ if (!e.isTrusted || !touchStart.isTrusted) bad.push("touch event isTrusted=false");
+
+ const ts = touchStart.changedTouches;
+ if (!ts || ts.length === 0) bad.push("touchstart.changedTouches empty");
+ else {
+ const T = ts[0];
+ // Window.Touch should exist as the constructor.
+ if (typeof window.Touch !== "function") bad.push("window.Touch constructor missing (no native TouchEvent support)");
+ if (!(T instanceof Touch)) bad.push("changedTouches[0] not instanceof Touch");
+ if (typeof T.identifier !== "number") bad.push("Touch.identifier not number");
+ if (!Number.isFinite(T.clientX) || !Number.isFinite(T.clientY)) bad.push("Touch.clientX/Y invalid");
+ if (!("force" in T) || typeof T.force !== "number" || T.force < 0 || T.force > 1) bad.push(`Touch.force=${T.force}`);
+ if (!("radiusX" in T) || typeof T.radiusX !== "number") bad.push("Touch.radiusX missing");
+ }
+
+ if (!(touchStart instanceof TouchEvent)) bad.push("touchstart not instanceof TouchEvent");
+
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass("real TouchEvent + Touch object surface present");
+}, { passive: true });
+
+// Capability check: if TouchEvent constructor missing entirely, hint immediately on click.
+pad.addEventListener("click", () => {
+ if (!touchStart && typeof window.TouchEvent !== "function") {
+ Challenge.fail("TouchEvent constructor not available on this UA (enable touch emulation)");
+ } else if (!touchStart) {
+ Challenge.fail("click fired but no touchstart — synthetic pointer without TouchEvent");
+ }
+});
+</script>
+</body>
diff --git a/test-suite/challenges/23-stack-trace-fingerprint.html b/test-suite/challenges/23-stack-trace-fingerprint.html
new file mode 100644
index 0000000..4c1772b
--- /dev/null
+++ b/test-suite/challenges/23-stack-trace-fingerprint.html
@@ -0,0 +1,78 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>23 stack-trace fingerprint</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: click the button. The page samples call stacks from inside several
+ instrumented globals (<code>Function.prototype.toString</code>,
+ <code>document.querySelector</code>, <code>Element.prototype.click</code>) and inspects
+ the stack of <em>this script's own</em> handler. It fails if it sees telltales of
+ evaluator-injected frames (e.g. <code>at &lt;anonymous&gt;</code> as the only frame, the
+ bridge's <code>new Function</code> wrapper, <code>callFunctionOn</code>,
+ <code>executeScript</code>, or extension URLs).</p>
+ <button id="go" style="padding:14px 22px;font-size:16px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
+</main>
+<script>
+Challenge.init({ id: "stack-trace-fingerprint", instructions: "click the button" });
+
+// Suspicious stack-frame patterns commonly observed when code is invoked via
+// CDP Runtime.evaluate / chrome.scripting.executeScript / new Function bodies
+// dispatched from an MV3 service worker.
+const SUSPICIOUS = [
+ /chrome-extension:\/\//i,
+ /\bnew Function\b/,
+ /Runtime\.evaluate/i,
+ /Runtime\.callFunctionOn/i,
+ /executeScript/i,
+ /content[_-]?script/i,
+ /^\s*at\s+eval\b/m,
+];
+
+function inspectStack(stack) {
+ if (!stack) return ["empty stack"];
+ const hits = SUSPICIOUS.filter(r => r.test(stack)).map(r => r.source);
+ const lines = stack.split("\n").filter(l => l.trim().startsWith("at "));
+ // A natural inline-handler stack has ≥2 frames within this document.
+ const docFrames = lines.filter(l => l.includes(location.pathname.split("/").pop()));
+ const anonOnly = lines.length > 0 && lines.every(l => /<anonymous>/.test(l));
+ const reasons = [];
+ if (hits.length) reasons.push("suspicious frames: " + hits.join(","));
+ if (anonOnly) reasons.push("all stack frames anonymous");
+ if (lines.length && docFrames.length === 0) reasons.push("no frame from this document");
+ return reasons;
+}
+
+// Hook some commonly-touched APIs so any pre-click bridge instrumentation also
+// leaves a trail. Their stacks get inspected the moment the click handler fires.
+const probeStacks = [];
+const oTo = Function.prototype.toString;
+Function.prototype.toString = function () {
+ probeStacks.push({ where: "Function.toString", stack: new Error().stack });
+ return oTo.apply(this, arguments);
+};
+const oQS = Document.prototype.querySelector;
+Document.prototype.querySelector = function (sel) {
+ probeStacks.push({ where: "document.querySelector", sel, stack: new Error().stack });
+ return oQS.apply(this, arguments);
+};
+
+document.getElementById("go").addEventListener("click", (e) => {
+ const ownStack = new Error().stack || "";
+ const reasons = inspectStack(ownStack);
+ // Also check any probe stacks gathered before the click — the bridge often
+ // queries the DOM right before dispatching.
+ const probeBad = [];
+ for (const p of probeStacks) {
+ const r = inspectStack(p.stack);
+ if (r.length) probeBad.push(`${p.where}: ${r.join("; ")}`);
+ }
+ Challenge.log("stacks", { ownStack, probeStacks });
+ if (!e.isTrusted) return Challenge.fail("click isTrusted=false");
+ if (reasons.length) return Challenge.fail(...reasons);
+ if (probeBad.length) return Challenge.fail(...probeBad.slice(0, 3));
+ Challenge.pass("call stack matches an in-page event handler");
+});
+</script>
+</body>
diff --git a/test-suite/challenges/24-viewport-edge-clicks.html b/test-suite/challenges/24-viewport-edge-clicks.html
new file mode 100644
index 0000000..b1ef598
--- /dev/null
+++ b/test-suite/challenges/24-viewport-edge-clicks.html
@@ -0,0 +1,51 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>24 viewport edge clicks</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: click the button. Page rejects any pointer event whose
+ <code>clientX</code>/<code>clientY</code> falls outside the viewport
+ (<code>x &lt; 0</code>, <code>x &gt; innerWidth</code>, etc.) or lands on a coordinate
+ that's not actually inside the button's <code>getBoundingClientRect()</code>.
+ Real pointing devices can't dispatch clicks at negative coordinates;
+ bridges that pass uncoerced selector-center coords (or coordinates from a
+ stale layout) trip this.</p>
+ <button id="go" style="margin:40px;padding:14px 22px;font-size:16px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
+</main>
+<script>
+Challenge.init({ id: "viewport-edge-clicks", instructions: "click the button at valid viewport coords" });
+
+const btn = document.getElementById("go");
+
+// Track ALL pointer/click events on document — even ones that miss the button.
+let suspicious = null;
+for (const name of ["pointerdown","pointerup","click","mousedown","mouseup"]) {
+ document.addEventListener(name, (e) => {
+ const x = e.clientX, y = e.clientY;
+ const w = window.innerWidth, h = window.innerHeight;
+ if (x < 0 || y < 0 || x > w || y > h) {
+ suspicious = `${name} at (${x},${y}) outside viewport ${w}x${h}`;
+ Challenge.fail(suspicious);
+ }
+ if ((x === 0 && y === 0) && e.isTrusted === false) {
+ suspicious = `${name} at (0,0) with isTrusted=false (default synthetic coords)`;
+ Challenge.fail(suspicious);
+ }
+ }, true);
+}
+
+btn.addEventListener("click", (e) => {
+ if (suspicious) return;
+ if (!e.isTrusted) return Challenge.fail("click isTrusted=false");
+ const r = btn.getBoundingClientRect();
+ const x = e.clientX, y = e.clientY;
+ const inside = x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
+ if (!inside) return Challenge.fail(`click at (${x},${y}) outside button rect ${JSON.stringify({l:r.left,t:r.top,r:r.right,b:r.bottom})}`);
+ // Coord must not be exactly integer-center (covered by #06 but reinforced here).
+ Challenge.log("click", { x, y, rect: r });
+ Challenge.pass(`click at (${x.toFixed(1)},${y.toFixed(1)}) inside button rect`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/25-pointer-continuity.html b/test-suite/challenges/25-pointer-continuity.html
new file mode 100644
index 0000000..c441045
--- /dev/null
+++ b/test-suite/challenges/25-pointer-continuity.html
@@ -0,0 +1,62 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>25 pointer continuity</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: click <b>A</b>, then click <b>B</b>. Page requires pointer continuity:
+ between the two clicks there must be intermediate <code>pointermove</code> events
+ whose path roughly connects the two click points (no teleport). Two clicks
+ &gt;100px apart with zero moves between is a synthetic-bridge tell.</p>
+ <div style="display:flex;justify-content:space-between;margin-top:20px">
+ <button id="a" style="padding:14px 22px;background:#36c;color:#fff;border:0;border-radius:6px">A</button>
+ <div style="flex:1"></div>
+ <button id="b" style="padding:14px 22px;background:#c36;color:#fff;border:0;border-radius:6px">B</button>
+ </div>
+</main>
+<script>
+Challenge.init({ id: "pointer-continuity", instructions: "click A, move mouse to B, click B" });
+
+const a = document.getElementById("a");
+const b = document.getElementById("b");
+
+const moves = [];
+window.addEventListener("pointermove", (e) => {
+ moves.push({ x: e.clientX, y: e.clientY, t: e.timeStamp });
+ if (moves.length > 500) moves.shift();
+});
+
+let aClickTs = 0, aPoint = null;
+a.addEventListener("click", (e) => {
+ if (!e.isTrusted) return Challenge.fail("A click isTrusted=false");
+ aClickTs = e.timeStamp;
+ aPoint = { x: e.clientX, y: e.clientY };
+ Challenge.log("A click", aPoint);
+});
+
+b.addEventListener("click", (e) => {
+ if (!aClickTs) return Challenge.fail("B clicked before A");
+ if (!e.isTrusted) return Challenge.fail("B click isTrusted=false");
+ const bPoint = { x: e.clientX, y: e.clientY };
+ const between = moves.filter(m => m.t > aClickTs && m.t < e.timeStamp);
+ Challenge.log("B click", { bPoint, betweenCount: between.length });
+ const dist = Math.hypot(bPoint.x - aPoint.x, bPoint.y - aPoint.y);
+ if (dist < 100) return Challenge.fail(`A-B distance only ${dist.toFixed(0)}px — buttons should be further apart in viewport`);
+ if (between.length < 5) return Challenge.fail(`only ${between.length} pointermove(s) between A and B (need ≥5)`);
+
+ // Path coverage: at least some intermediate move must be ≥30% of the way across.
+ const inSpan = between.filter(m => {
+ const px = (m.x - aPoint.x) / (bPoint.x - aPoint.x || 1);
+ return px > 0.25 && px < 0.75;
+ });
+ if (inSpan.length === 0) return Challenge.fail("no pointermove samples in mid-span between A and B (teleport)");
+
+ // Move timestamps should be spread, not bunched in <20ms.
+ const span = between[between.length-1].t - between[0].t;
+ if (span < 30) return Challenge.fail(`pointermoves bunched in ${span.toFixed(0)}ms (need spread ≥30ms)`);
+
+ Challenge.pass(`${between.length} moves bridging A→B over ${span.toFixed(0)}ms, dist=${dist.toFixed(0)}px`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/26-mousemove-rate.html b/test-suite/challenges/26-mousemove-rate.html
new file mode 100644
index 0000000..6b3722f
--- /dev/null
+++ b/test-suite/challenges/26-mousemove-rate.html
@@ -0,0 +1,57 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>26 mousemove rate</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: sweep the mouse across the box for ~1s, then click the green button.
+ Page measures the inter-event timestamp distribution of <code>mousemove</code>
+ events. Real pointing devices fire at ~60–250 Hz (median Δt 4–18ms). Bridges
+ that flood synthetic moves on a tight <code>for</code>-loop or
+ <code>setTimeout(...,1)</code> produce a degenerate distribution
+ (most Δt &lt; 2ms, or all Δt identical, or median &gt; 50ms with no jitter).</p>
+ <div id="pad" style="height:160px;border:1px solid #555;background:#fff;color:#111;display:flex;align-items:center;justify-content:center;font:13px monospace;user-select:none">
+ sweep me
+ </div>
+ <button id="go" style="margin-top:14px;padding:12px 20px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Done</button>
+</main>
+<script>
+Challenge.init({ id: "mousemove-rate", instructions: "sweep mouse across pad ~1s, then click Done" });
+
+const pad = document.getElementById("pad");
+const stamps = [];
+pad.addEventListener("mousemove", (e) => { stamps.push(e.timeStamp); });
+
+document.getElementById("go").addEventListener("click", (e) => {
+ if (!e.isTrusted) return Challenge.fail("Done click isTrusted=false");
+ if (stamps.length < 20) return Challenge.fail(`only ${stamps.length} mousemove events (need ≥20)`);
+
+ const deltas = [];
+ for (let i = 1; i < stamps.length; i++) deltas.push(stamps[i] - stamps[i-1]);
+ deltas.sort((a,b) => a-b);
+ const median = deltas[Math.floor(deltas.length/2)];
+ const p10 = deltas[Math.floor(deltas.length*0.1)];
+ const p90 = deltas[Math.floor(deltas.length*0.9)];
+ const mean = deltas.reduce((s,x)=>s+x,0) / deltas.length;
+ const variance = deltas.reduce((s,x)=>s+(x-mean)**2,0) / deltas.length;
+ const std = Math.sqrt(variance);
+
+ const allSame = deltas.every(d => Math.abs(d - deltas[0]) < 0.05);
+ const allTiny = deltas.every(d => d < 1.5);
+
+ Challenge.log("dist", { n: deltas.length, median, p10, p90, mean, std });
+ const bad = [];
+ if (allSame) bad.push(`all Δt identical (~${deltas[0].toFixed(2)}ms) — scripted loop`);
+ if (allTiny) bad.push(`all Δt < 1.5ms — synthetic flood`);
+ if (median < 2) bad.push(`median Δt ${median.toFixed(2)}ms too fast for a real device`);
+ if (median > 60) bad.push(`median Δt ${median.toFixed(0)}ms too slow (real moves ~4–18ms)`);
+ if (std < 0.5) bad.push(`Δt std=${std.toFixed(2)}ms — no jitter (scripted)`);
+ // Ratio p90/p10 should be ≥1.8 for organic motion.
+ if (p10 > 0 && (p90 / p10) < 1.5) bad.push(`p90/p10 ratio ${(p90/p10).toFixed(2)} — distribution too narrow`);
+
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass(`n=${deltas.length}, median=${median.toFixed(1)}ms, std=${std.toFixed(1)}ms`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/27-scroll-momentum.html b/test-suite/challenges/27-scroll-momentum.html
new file mode 100644
index 0000000..981ec7b
--- /dev/null
+++ b/test-suite/challenges/27-scroll-momentum.html
@@ -0,0 +1,66 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>27 scroll momentum tail</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: scroll the box to its bottom using a real wheel/trackpad gesture.
+ Page records the <code>deltaY</code> sequence and requires a momentum-like tail:
+ a peak <code>|deltaY|</code>, then a decreasing decay over several events
+ (≥4 wheel events whose <code>|deltaY|</code> is strictly less than the previous
+ by &gt;5%). Constant or single-shot wheel events (one big delta then nothing)
+ fail.</p>
+ <div id="box" style="height:200px;overflow:auto;border:1px solid #555;background:#fff;color:#111;padding:8px">
+ <div style="height:2400px">scroll me ⬇ … continues …</div>
+ </div>
+</main>
+<script>
+Challenge.init({ id: "scroll-momentum", instructions: "scroll the box to its bottom with a real gesture" });
+
+const box = document.getElementById("box");
+const deltas = []; // signed
+const tsLog = [];
+
+box.addEventListener("wheel", (e) => {
+ deltas.push(e.deltaY);
+ tsLog.push(e.timeStamp);
+ if (deltas.length > 200) { deltas.shift(); tsLog.shift(); }
+ Challenge.log("wheel", { dy: e.deltaY, isTrusted: e.isTrusted });
+ if (!e.isTrusted) Challenge.fail("wheel event isTrusted=false");
+}, { passive: true });
+
+box.addEventListener("scroll", () => {
+ if (box.scrollTop + box.clientHeight < box.scrollHeight - 2) return;
+ // We hit bottom. Analyze the deltaY trace.
+ if (deltas.length === 0) return Challenge.fail("reached bottom with zero wheel events");
+ if (deltas.length < 6) return Challenge.fail(`only ${deltas.length} wheel events (need ≥6 for momentum)`);
+
+ const abs = deltas.map(Math.abs);
+ // All same sign? Trackpad inertia keeps signs consistent.
+ const allSameSign = deltas.every(d => Math.sign(d) === Math.sign(deltas[0]));
+ if (!allSameSign) return Challenge.fail("wheel deltaY signs flip — not a continuous scroll gesture");
+
+ // All identical magnitudes? Robotic.
+ const allEqual = abs.every(a => Math.abs(a - abs[0]) < 0.01);
+ if (allEqual) return Challenge.fail(`all |deltaY|=${abs[0]} — no momentum`);
+
+ // Find peak and require decay tail after it.
+ const peakIdx = abs.indexOf(Math.max(...abs));
+ const tail = abs.slice(peakIdx);
+ let decays = 0;
+ for (let i = 1; i < tail.length; i++) {
+ if (tail[i] < tail[i-1] * 0.95) decays++;
+ }
+ Challenge.log("trace", { abs, peakIdx, decays });
+ if (decays < 3) return Challenge.fail(`only ${decays} decay steps after peak — no momentum tail`);
+
+ // Timestamps within the tail should span ≥150ms (real inertia lasts hundreds of ms).
+ const tailTs = tsLog.slice(peakIdx);
+ const span = tailTs[tailTs.length-1] - tailTs[0];
+ if (span < 100) return Challenge.fail(`momentum tail only spans ${span.toFixed(0)}ms`);
+
+ Challenge.pass(`${deltas.length} wheels, peak |dY|=${abs[peakIdx].toFixed(1)}, ${decays} decay steps over ${span.toFixed(0)}ms`);
+});
+</script>
+</body>
diff --git a/test-suite/challenges/28-intersection-visibility.html b/test-suite/challenges/28-intersection-visibility.html
new file mode 100644
index 0000000..1c90e33
--- /dev/null
+++ b/test-suite/challenges/28-intersection-visibility.html
@@ -0,0 +1,72 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>28 IntersectionObserver visibility</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: scroll until the hidden <b>TARGET</b> button comes into view, then click it.
+ Page uses an <code>IntersectionObserver</code> to track when TARGET crosses the
+ viewport. Real scrolling produces a sequence of intersection updates with
+ gradually increasing <code>intersectionRatio</code> (0 → 1) across animation
+ frames. Programmatic teleports (<code>scrollIntoView({behavior:"instant"})</code>,
+ direct <code>scrollTop=</code>) produce exactly one observer callback with
+ <code>ratio≈1</code> and no rAF samples in between — fail.</p>
+ <div id="scroller" style="height:240px;overflow:auto;border:1px solid #555;background:#fff;color:#111">
+ <div style="height:1600px;padding:8px">keep scrolling …</div>
+ <button id="target" style="margin:0 auto 1600px;display:block;padding:14px 22px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">TARGET</button>
+ </div>
+</main>
+<script>
+Challenge.init({ id: "intersection-visibility", instructions: "scroll until TARGET visible, then click" });
+
+const scroller = document.getElementById("scroller");
+const target = document.getElementById("target");
+
+const ratioSamples = [];
+const rafSamples = [];
+
+const io = new IntersectionObserver((entries) => {
+ for (const e of entries) ratioSamples.push({ r: e.intersectionRatio, t: performance.now() });
+}, { root: scroller, threshold: Array.from({length: 21}, (_,i) => i/20) });
+io.observe(target);
+
+// Track rAF ticks during which scroll was happening.
+let lastScrollTop = scroller.scrollTop;
+let scrollingFrames = 0;
+function tick() {
+ if (scroller.scrollTop !== lastScrollTop) {
+ scrollingFrames++;
+ rafSamples.push({ st: scroller.scrollTop, t: performance.now() });
+ lastScrollTop = scroller.scrollTop;
+ }
+ requestAnimationFrame(tick);
+}
+requestAnimationFrame(tick);
+
+target.addEventListener("click", (e) => {
+ if (!e.isTrusted) return Challenge.fail("TARGET click isTrusted=false");
+
+ // Need the target to actually be in the viewport at click time.
+ const rect = target.getBoundingClientRect();
+ const sRect = scroller.getBoundingClientRect();
+ const inViewport = rect.top < sRect.bottom && rect.bottom > sRect.top;
+ if (!inViewport) return Challenge.fail("TARGET clicked while still off-screen");
+
+ // Require at least a gradient of intersection ratios from low to high.
+ const lows = ratioSamples.filter(s => s.r > 0 && s.r < 0.3).length;
+ const mids = ratioSamples.filter(s => s.r >= 0.3 && s.r < 0.7).length;
+ const highs = ratioSamples.filter(s => s.r >= 0.7).length;
+ Challenge.log("io", { samples: ratioSamples.length, lows, mids, highs, scrollingFrames });
+
+ const bad = [];
+ if (ratioSamples.length < 4) bad.push(`only ${ratioSamples.length} IntersectionObserver samples (teleport)`);
+ if (lows === 0) bad.push("no low-ratio IO samples — target appeared instantly");
+ if (mids === 0) bad.push("no mid-ratio IO samples — no smooth approach");
+ if (scrollingFrames < 5) bad.push(`only ${scrollingFrames} rAF frames saw scroll motion`);
+
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass(`IO gradient seen: lows=${lows} mids=${mids} highs=${highs}, rafFrames=${scrollingFrames}`);
+});
+</script>
+</body>
diff --git a/test-suite/index.html b/test-suite/index.html
new file mode 100644
index 0000000..e125f55
--- /dev/null
+++ b/test-suite/index.html
@@ -0,0 +1,90 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>pi-chrome anti-automation test suite</title>
+<link rel="stylesheet" href="_style.css">
+<body>
+<main style="max-width:1100px">
+<h1>pi-chrome anti-automation test suite</h1>
+<p>Each row links to a challenge page. Open it, drive the interaction with <code>chrome_*</code> tools,
+then read <code>window.__verdict</code> via <code>chrome_evaluate</code>. Use the "Run all" button below
+to poll every challenge's verdict into a single table.</p>
+
+<table id="tbl" style="border-collapse:collapse;width:100%;font:13px monospace">
+ <thead><tr><th align=left>id</th><th align=left>file</th><th align=left>verdict</th><th align=left>open</th></tr></thead>
+ <tbody></tbody>
+</table>
+
+<p><button id="poll" style="margin-top:12px;padding:8px 14px">Refresh verdicts from any tab on this origin</button></p>
+
+<h2>How to drive from pi</h2>
+<pre style="background:#111;padding:12px;border-radius:6px;overflow:auto">
+# 1. Start static server in test-suite/
+python3 -m http.server 8765
+
+# 2. From pi:
+chrome_navigate http://127.0.0.1:8765/challenges/01-is-trusted-click.html
+chrome_click selector=#go
+chrome_evaluate JSON.stringify({v:window.__verdict, r:window.__reason})
+</pre>
+
+<script>
+const challenges = [
+ ["is-trusted-click", "challenges/01-is-trusted-click.html"],
+ ["is-trusted-keyboard", "challenges/02-is-trusted-keyboard.html"],
+ ["webdriver-flag", "challenges/03-webdriver-flag.html"],
+ ["mouse-entropy", "challenges/04-mouse-entropy.html"],
+ ["event-timing", "challenges/05-event-timing.html"],
+ ["click-coordinates", "challenges/06-click-coordinates.html"],
+ ["pointer-properties", "challenges/07-pointer-properties.html"],
+ ["keyboard-cadence", "challenges/08-keyboard-cadence.html"],
+ ["composition-input", "challenges/09-composition-input.html"],
+ ["user-activation", "challenges/10-user-activation.html"],
+ ["honeypot", "challenges/11-honeypot.html"],
+ ["fingerprint", "challenges/12-fingerprint.html"],
+ ["focus-order", "challenges/13-focus-order.html"],
+ ["wheel-scroll", "challenges/14-wheel-scroll.html"],
+ ["drag-drop-datatransfer", "challenges/15-drag-drop-datatransfer.html"],
+ ["contenteditable-selection", "challenges/16-contenteditable-selection.html"],
+ ["paste-clipboard", "challenges/17-paste-clipboard.html"],
+ ["native-select", "challenges/18-native-select.html"],
+ ["hover-dwell", "challenges/19-hover-dwell.html"],
+ ["react-value-tracker", "challenges/20-react-value-tracker.html"],
+ ["keyboard-modifiers", "challenges/21-keyboard-modifiers.html"],
+ ["touch-events", "challenges/22-touch-events.html"],
+ ["stack-trace-fingerprint", "challenges/23-stack-trace-fingerprint.html"],
+ ["viewport-edge-clicks", "challenges/24-viewport-edge-clicks.html"],
+ ["pointer-continuity", "challenges/25-pointer-continuity.html"],
+ ["mousemove-rate", "challenges/26-mousemove-rate.html"],
+ ["scroll-momentum", "challenges/27-scroll-momentum.html"],
+ ["intersection-visibility", "challenges/28-intersection-visibility.html"],
+];
+const tbody = document.querySelector("#tbl tbody");
+const rowFor = {};
+for (const [id, file] of challenges) {
+ const tr = document.createElement("tr");
+ tr.innerHTML = `<td>${id}</td><td>${file}</td><td class=v>—</td><td><a target=_blank href="${file}">open</a></td>`;
+ tbody.appendChild(tr);
+ rowFor[id] = tr;
+}
+
+// localStorage rendezvous: each challenge page writes its verdict on change.
+function paint() {
+ for (const [id] of challenges) {
+ const raw = localStorage.getItem("pi-chrome-suite:" + id);
+ if (!raw) continue;
+ try {
+ const { verdict, reason } = JSON.parse(raw);
+ const td = rowFor[id].querySelector(".v");
+ td.textContent = verdict + (reason?.length ? " — " + reason.join(" / ") : "");
+ td.style.background = verdict === "PASS" ? "#1f7a1f" : verdict === "FAIL" ? "#a11" : "#444";
+ td.style.color = "#fff";
+ } catch {}
+ }
+}
+document.getElementById("poll").addEventListener("click", paint);
+window.addEventListener("storage", paint);
+setInterval(paint, 1000);
+paint();
+</script>
+</main>
+</body>
diff --git a/test-suite/notes/bypass-ideas.md b/test-suite/notes/bypass-ideas.md
new file mode 100644
index 0000000..7a019b6
--- /dev/null
+++ b/test-suite/notes/bypass-ideas.md
@@ -0,0 +1,76 @@
+# What it would take to PASS each challenge
+
+`pi-chrome` runs through a content-script bridge, not CDP. That limits the
+solution space — most "real-event" tricks need the chrome.debugger API or
+`chrome.input.synthesizeMouseEvent`. The notes below mark which are reachable
+with the current manifest and which require new permissions.
+
+## 01–02 isTrusted click / keyboard
+**Hard.** `Event.isTrusted` is true only for events the browser dispatched
+itself. Two viable paths:
+
+- **`chrome.debugger` + `Input.dispatchMouseEvent` / `Input.dispatchKeyEvent`.**
+ Adds `"debugger"` permission. Chrome shows a "X is debugging this browser"
+ banner, but events arrive as `isTrusted=true`. Best fix for the highest-value
+ cases; gate it behind a toggle the user opts into.
+- **`chrome.input.ime.*`** — not applicable for general typing.
+
+Today's `dispatchEvent` path will always fail any `isTrusted` check.
+
+## 03 / 12 webdriver + fingerprint
+Cheap wins:
+- Stub `navigator.webdriver` via `chrome.scripting.executeScript({ world: "MAIN", injectImmediately: true, … })` that defines `navigator.webdriver` getter to `undefined`. Already half-supported through `initScript` on `chrome_navigate` — extend to a per-tab content script registered with `chrome.scripting.registerContentScripts({ runAt: "document_start", world: "MAIN" })`.
+- Spoof languages/plugins/permissions only if the user explicitly enables it — by default the real Chrome profile already looks legitimate. The bigger risk is `chrome_evaluate` *itself* leaving traces (Function-constructor stack frames in errors).
+
+## 04 mouse entropy
+Generate a humanised pointer path before any click:
+- Bezier interpolation from current cursor pos (or a random off-target start) to the target point.
+- 20–60 steps, easing curve, ±jitter on each step, varying `movementX/Y`.
+- Implement in `clickPage` before the existing pointerdown sequence; reuse `pointerEventSequence` for each interpolated point.
+
+## 05 event timing
+- Insert `await sleep(rand(40,140))` between `pointerdown` and `pointerup`.
+- Insert `await sleep(rand(80,220))` between successive synthesised clicks when
+ the caller issues them in a loop.
+- Vary by ±20%.
+
+## 06 click coordinates
+- After resolving target rect in `clickPage`, pick `(cx ± rand(-rw*0.3,rw*0.3), cy ± rand(-rh*0.3,rh*0.3))` instead of dead center. Already returns the chosen point — keep that.
+
+## 07 pointer properties
+- Set `pressure: 0.5` for `pointerdown` (currently inits don't set pressure; browser default for mouse pointerdown is 0.5).
+- Set `pointerId: 1` for mouse (already done) and 2+ for touch.
+- Carry `movementX/Y` from the previous interpolated step (needs path state).
+
+## 08 / 09 keyboard cadence + framework invariants
+- Per character: dispatch `keydown` (cancelable, fills `key` + `code` + `keyCode`), then `keypress` for printables, then `beforeinput` `{inputType:"insertText", data:ch}`, then mutate the input's value to `cur+ch` via the native setter, then `input` `{inputType:"insertText", data:ch}`, then `keyup`. Sleep 40–120 ms between chars.
+- Today the bridge fills the entire value in one go and emits one `beforeinput`/`input`. Replace with the per-char loop above.
+- Make sure not to dispatch `compositionstart`/`compositionend` for plain ASCII.
+
+## 10 user activation
+**Impossible with synthetic events.** `navigator.userActivation.isActive` only
+flips on browser-trusted events. Same fix as 01/02 — `chrome.debugger` path.
+Document the limitation; offer a `useDebuggerForActivationGates` opt-in.
+
+## 11 honeypot
+Pure agent-side concern. Before filling/clicking by selector, the bridge could:
+- Reject targets with `display:none`, `visibility:hidden`, `aria-hidden=true`,
+ or off-screen position (`getBoundingClientRect()` outside viewport bounds and
+ not scrolled-into-view), unless the caller passes `force: true`.
+- That logic largely exists (`elementVisible` / `occludedBy` are returned) —
+ promote it from informational to a guard.
+
+## 13 focus order
+- When clicking a focusable element, dispatch `pointerdown` first; only after
+ `mousedown` defaults run let focus settle naturally (browser will move focus
+ on the synthetic `mousedown` only if `isTrusted` is true — alas, no). Manual
+ workaround: dispatch the pointer/mouse sequence, then explicitly call
+ `target.focus({ preventScroll: true })`, then dispatch `focus` with
+ `{ relatedTarget: previouslyFocused }`. Set `:focus-visible` heuristic via
+ `target.blur(); target.focus({ focusVisible: false })` (Chrome 124+).
+
+## 14 wheel scroll
+- For scroll operations, dispatch sequential `wheel` events with `deltaY`
+ chunks (e.g., 60–120 per tick), and let the browser scroll, instead of
+ setting `scrollTop` directly. Provide `chrome_scroll({ uid, dy, dx })` as a
+ first-class tool.
diff --git a/test-suite/serve.sh b/test-suite/serve.sh
new file mode 100755
index 0000000..45efc3c
--- /dev/null
+++ b/test-suite/serve.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+# Serve the test suite on a fixed local port so pi-chrome can drive it.
+cd "$(dirname "$0")"
+PORT="${PORT:-8765}"
+echo "serving http://127.0.0.1:${PORT}/"
+exec python3 -m http.server "${PORT}" --bind 127.0.0.1