diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-13 01:05:46 -0400 |
|---|---|---|
| committer | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-13 01:05:46 -0400 |
| commit | 33110e803fcacc31dc2859dbc0d470b008556798 (patch) | |
| tree | bb30dad365139c558f9d9651cb8bca375320cb8e | |
| parent | d78081aa58ecaa1c72e847c2d095b5f07c391506 (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
39 files changed, 2145 insertions, 64 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json index 5ceb76e..9fb49f0 100644 --- a/extensions/chrome-profile-bridge/browser-extension/manifest.json +++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Pi Existing Chrome Profile Bridge", - "version": "0.8.0", + "version": "0.9.1", "description": "Lets Pi control tabs in this existing Chrome profile via a local bridge at 127.0.0.1.", "permissions": ["tabs", "scripting", "storage", "activeTab", "alarms", "webNavigation"], "host_permissions": ["<all_urls>", "http://127.0.0.1:17318/*"], diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index ed33470..60293c4 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -43,6 +43,13 @@ async function pollLoop() { cache: "no-store", }); if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`); + const expected = response.headers.get("x-pi-chrome-version"); + const ours = chrome.runtime.getManifest().version; + if (expected && expected !== ours && isVersionOlder(ours, expected)) { + console.warn(`[pi-chrome] extension v${ours} behind pi-chrome v${expected}; reloading extension`); + try { chrome.runtime.reload(); } catch {} + return; + } const payload = await response.json(); if (payload.type === "command") await handleCommand(payload.command); } @@ -74,6 +81,18 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function isVersionOlder(a, b) { + const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0); + const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0); + const n = Math.max(pa.length, pb.length); + for (let i = 0; i < n; i++) { + const x = pa[i] ?? 0, y = pb[i] ?? 0; + if (x < y) return true; + if (x > y) return false; + } + return false; +} + async function dispatch(action, params) { switch (action) { case "tab.version": @@ -122,6 +141,8 @@ async function dispatch(action, params) { return executeActionInTab(params, fillPage, [params.selector ?? null, params.uid ?? null, params.text || "", params.submit === true]); case "page.key": return executeActionInTab(params, pressKeyInPage, [params.key]); + case "page.scroll": + return executeActionInTab(params, scrollPage, [params.selector ?? null, params.uid ?? null, params.deltaY ?? 0, params.deltaX ?? 0, params.steps ?? null]); case "page.console.list": return executeInTab(params, listConsoleMessages, [params.clear === true]); case "page.network.list": @@ -207,6 +228,15 @@ const HELPER_FUNCS = [ occluderAt, pageHash, pointerEventSequence, + sleepPage, + rand, + dispatchPointerLikeEvent, + humanMoveTo, + humanClickPoint, + printableKeyCode, + dispatchKeyEvent, + typeCharacter, + scrollPage, ]; async function executeInTab(params, func, args) { @@ -497,32 +527,94 @@ function pageHash() { return h; } +function sleepPage(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function rand(min, max) { + return min + Math.random() * (max - min); +} + +function dispatchPointerLikeEvent(element, type, x, y, prevX, prevY, opts = {}) { + const isPointer = type.startsWith("pointer"); + const Ctor = isPointer ? PointerEvent : MouseEvent; + const isMove = type === "pointermove" || type === "mousemove"; + const isUpOrClick = type === "pointerup" || type === "mouseup" || type === "click"; + const init = { + bubbles: true, + cancelable: true, + view: window, + clientX: x, + clientY: y, + screenX: x + (window.screenX || 0), + screenY: y + (window.screenY || 0), + movementX: Number.isFinite(prevX) ? x - prevX : 0, + movementY: Number.isFinite(prevY) ? y - prevY : 0, + button: 0, + buttons: isMove || isUpOrClick ? 0 : 1, + }; + if (isPointer) { + init.pointerType = "mouse"; + init.pointerId = 1; + init.isPrimary = true; + init.width = 1; + init.height = 1; + init.pressure = opts.pressure ?? (type === "pointerdown" ? 0.5 : 0); + init.tangentialPressure = 0; + init.tiltX = 0; + init.tiltY = 0; + } + const ev = new Ctor(type, init); + element.dispatchEvent(ev); + return ev.defaultPrevented; +} + function pointerEventSequence(element, x, y, sequence) { let defaultPrevented = false; + const state = getPiChromeState(); + const prevX = state.pointer?.x; + const prevY = state.pointer?.y; for (const type of sequence) { - const isPointer = type.startsWith("pointer"); - const Ctor = isPointer ? PointerEvent : MouseEvent; - const init = { - bubbles: true, - cancelable: true, - view: window, - clientX: x, - clientY: y, - button: 0, - buttons: type === "pointermove" || type === "mousemove" ? 0 : 1, - }; - if (isPointer) { - init.pointerType = "mouse"; - init.pointerId = 1; - init.isPrimary = true; - } - const ev = new Ctor(type, init); - element.dispatchEvent(ev); - if (ev.defaultPrevented) defaultPrevented = true; + defaultPrevented = dispatchPointerLikeEvent(element, type, x, y, prevX, prevY) || defaultPrevented; } + state.pointer = { x, y, t: performance.now() }; return defaultPrevented; } +async function humanMoveTo(x, y, steps) { + const state = getPiChromeState(); + const startX = Number.isFinite(state.pointer?.x) ? state.pointer.x : rand(12, Math.max(24, innerWidth - 12)); + const startY = Number.isFinite(state.pointer?.y) ? state.pointer.y : rand(12, Math.max(24, innerHeight - 12)); + const n = steps || Math.max(12, Math.min(42, Math.round(Math.hypot(x - startX, y - startY) / 18))); + let prevX = startX, prevY = startY; + let defaultPrevented = false; + for (let i = 1; i <= n; i++) { + const t = i / n; + const ease = t * t * (3 - 2 * t); + const wobble = Math.sin(t * Math.PI) * 8; + const px = startX + (x - startX) * ease + rand(-wobble, wobble); + const py = startY + (y - startY) * ease + rand(-wobble, wobble); + const el = document.elementFromPoint(px, py) || document.body || document.documentElement; + defaultPrevented = dispatchPointerLikeEvent(el, "pointermove", px, py, prevX, prevY) || defaultPrevented; + defaultPrevented = dispatchPointerLikeEvent(el, "mousemove", px, py, prevX, prevY) || defaultPrevented; + prevX = px; prevY = py; + await sleepPage(rand(4, 18)); + } + state.pointer = { x, y, t: performance.now() }; + return defaultPrevented; +} + +function humanClickPoint(point) { + if (!point.rect) return { x: point.x, y: point.y }; + const rect = point.rect; + const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1)); + const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1)); + return { + x: rect.left + rect.width / 2 + rand(-insetX, insetX), + y: rect.top + rect.height / 2 + rand(-insetY, insetY), + }; +} + function installPiChromeInstrumentation() { const state = getPiChromeState(); if (state.instrumentationInstalled) return; @@ -773,16 +865,31 @@ function resolvePoint(selector, uid, x, y) { return { element: document.elementFromPoint(x, y), x, y, rect: undefined }; } -function clickPage(selector, uid, x, y) { +async function clickPage(selector, uid, x, y) { installPiChromeInstrumentation(); const before = pageHash(); const point = resolvePoint(selector, uid, x, y); if (!point.element) throw new Error("No element at click point"); + const clickPoint = humanClickPoint(point); + point.x = clickPoint.x; + point.y = clickPoint.y; + point.element = document.elementFromPoint(point.x, point.y) || point.element; const visible = isElementVisible(point.element); const occluded = occluderAt(point.x, point.y, point.element); - const defaultPrevented = pointerEventSequence(point.element, point.x, point.y, [ - "pointerdown", "mousedown", "pointerup", "mouseup", "click", - ]); + let defaultPrevented = await humanMoveTo(point.x, point.y); + const state = getPiChromeState(); + const prevX = state.pointer?.x; + const prevY = state.pointer?.y; + defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerdown", point.x, point.y, prevX, prevY, { pressure: 0.5 }) || defaultPrevented; + defaultPrevented = dispatchPointerLikeEvent(point.element, "mousedown", point.x, point.y, prevX, prevY) || defaultPrevented; + if (typeof point.element.focus === "function" && /^(A|BUTTON|INPUT|TEXTAREA|SELECT|SUMMARY)$/.test(point.element.tagName)) { + try { point.element.focus({ preventScroll: true }); } catch { try { point.element.focus(); } catch {} } + } + await sleepPage(rand(45, 140)); + defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerup", point.x, point.y, prevX, prevY) || defaultPrevented; + defaultPrevented = dispatchPointerLikeEvent(point.element, "mouseup", point.x, point.y, prevX, prevY) || defaultPrevented; + defaultPrevented = dispatchPointerLikeEvent(point.element, "click", point.x, point.y, prevX, prevY) || defaultPrevented; + state.pointer = { x: point.x, y: point.y, t: performance.now() }; // Heuristic: if the clicked thing looks like a media play affordance and the page has paused // audio/video, the synthetic click may not unlock autoplay. Surface a warning. let autoplayHint; @@ -806,38 +913,136 @@ function clickPage(selector, uid, x, y) { }; } -function hoverPage(selector, uid, x, y) { +async function hoverPage(selector, uid, x, y) { installPiChromeInstrumentation(); const point = resolvePoint(selector, uid, x, y); if (!point.element) throw new Error("No element to hover"); - const defaultPrevented = pointerEventSequence(point.element, point.x, point.y, [ - "pointerover", "mouseover", "pointerenter", "mouseenter", "pointermove", "mousemove", - ]); + await humanMoveTo(point.x, point.y); + const state = getPiChromeState(); + const prevX = state.pointer?.x, prevY = state.pointer?.y; + let defaultPrevented = false; + for (const type of ["pointerover", "mouseover", "pointerenter", "mouseenter"]) { + defaultPrevented = dispatchPointerLikeEvent(point.element, type, point.x, point.y, prevX, prevY) || defaultPrevented; + } + // Small dwell so hover-intent handlers fire. + await sleepPage(rand(80, 220)); return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName, defaultPrevented, isTrusted: false }; } -function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) { +async function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) { installPiChromeInstrumentation(); const before = pageHash(); const from = resolvePoint(fromSelector, fromUid, fromX, fromY); const to = resolvePoint(toSelector, toUid, toX, toY); if (!from.element) throw new Error("Drag source element not found"); if (!to.element) throw new Error("Drag target element not found"); - pointerEventSequence(from.element, from.x, from.y, ["pointerover", "pointerdown", "mousedown"]); - for (let i = 1; i <= steps; i++) { - const t = i / steps; - const x = from.x + (to.x - from.x) * t; - const y = from.y + (to.y - from.y) * t; + // Move to source. + await humanMoveTo(from.x, from.y); + const state = getPiChromeState(); + let prevX = state.pointer?.x, prevY = state.pointer?.y; + // Build a shared DataTransfer so HTML5 drag-and-drop handlers can populate / read it. + const dt = new DataTransfer(); + const dragInit = (type, target, x, y) => { + const ev = new DragEvent(type, { + bubbles: true, cancelable: true, composed: true, + clientX: x, clientY: y, + screenX: x + (window.screenX || 0), screenY: y + (window.screenY || 0), + button: 0, buttons: 1, view: window, + dataTransfer: dt, + }); + target.dispatchEvent(ev); + return ev; + }; + dispatchPointerLikeEvent(from.element, "pointerover", from.x, from.y, prevX, prevY); + dispatchPointerLikeEvent(from.element, "pointerdown", from.x, from.y, prevX, prevY, { pressure: 0.5 }); + dispatchPointerLikeEvent(from.element, "mousedown", from.x, from.y, prevX, prevY); + await sleepPage(rand(40, 110)); + dragInit("dragstart", from.element, from.x, from.y); + dragInit("drag", from.element, from.x, from.y); + let lastOver = from.element; + const n = steps || 18; + for (let i = 1; i <= n; i++) { + const t = i / n; + const ease = t * t * (3 - 2 * t); + const wobble = Math.sin(t * Math.PI) * 6; + const x = from.x + (to.x - from.x) * ease + rand(-wobble, wobble); + const y = from.y + (to.y - from.y) * ease + rand(-wobble, wobble); const overEl = document.elementFromPoint(x, y) || to.element; - pointerEventSequence(overEl, x, y, ["pointermove", "mousemove"]); + dispatchPointerLikeEvent(overEl, "pointermove", x, y, prevX, prevY); + dispatchPointerLikeEvent(overEl, "mousemove", x, y, prevX, prevY); + if (overEl !== lastOver) { + dragInit("dragleave", lastOver, x, y); + dragInit("dragenter", overEl, x, y); + lastOver = overEl; + } + dragInit("dragover", overEl, x, y); + dragInit("drag", from.element, x, y); + prevX = x; prevY = y; + await sleepPage(rand(8, 26)); } - pointerEventSequence(to.element, to.x, to.y, ["pointerover", "mouseover", "pointerup", "mouseup"]); + dispatchPointerLikeEvent(to.element, "pointerover", to.x, to.y, prevX, prevY); + dispatchPointerLikeEvent(to.element, "mouseover", to.x, to.y, prevX, prevY); + dragInit("drop", to.element, to.x, to.y); + dragInit("dragend", from.element, to.x, to.y); + dispatchPointerLikeEvent(to.element, "pointerup", to.x, to.y, prevX, prevY); + dispatchPointerLikeEvent(to.element, "mouseup", to.x, to.y, prevX, prevY); + state.pointer = { x: to.x, y: to.y, t: performance.now() }; return { from: { x: from.x, y: from.y }, to: { x: to.x, y: to.y }, - steps, + steps: n, + pageMutated: pageHash() !== before, + note: "Synthetic drag with HTML5 DragEvent + shared DataTransfer. isTrusted is still false.", + }; +} + +async function scrollPage(selector, uid, deltaY, deltaX, steps) { + installPiChromeInstrumentation(); + const before = pageHash(); + let target; + if (selector || uid) { + target = elementBySelectorOrUid(selector, uid); + } else { + target = document.scrollingElement || document.documentElement || document.body; + } + if (!target) throw new Error("No scroll target"); + const rect = target.getBoundingClientRect ? target.getBoundingClientRect() : { left: 0, top: 0, width: innerWidth, height: innerHeight }; + const cx = Math.max(0, Math.min(innerWidth - 1, rect.left + Math.min(rect.width, innerWidth) / 2)); + const cy = Math.max(0, Math.min(innerHeight - 1, rect.top + Math.min(rect.height, innerHeight) / 2)); + const n = Math.max(3, Math.min(40, steps || Math.max(3, Math.ceil(Math.abs(deltaY || 0) / 100)))); + // Front-loaded wheel deltas, momentum-style. + const totalY = deltaY || 0; + const totalX = deltaX || 0; + const weights = []; + for (let i = 1; i <= n; i++) weights.push(1 / i); + const sumW = weights.reduce((a, b) => a + b, 0); + let movedY = 0, movedX = 0; + for (let i = 0; i < n; i++) { + const dy = totalY * (weights[i] / sumW); + const dx = totalX * (weights[i] / sumW); + const ev = new WheelEvent("wheel", { + bubbles: true, cancelable: true, composed: true, view: window, + clientX: cx, clientY: cy, + deltaX: dx, deltaY: dy, deltaMode: 0, + }); + target.dispatchEvent(ev); + if (!ev.defaultPrevented) { + // Apply scroll ourselves; mirrors what the browser would do. + if (target === document.scrollingElement || target === document.documentElement || target === document.body) { + window.scrollBy({ left: dx, top: dy, behavior: "instant" }); + } else { + target.scrollTop += dy; + target.scrollLeft += dx; + } + } + movedY += dy; movedX += dx; + await sleepPage(rand(12, 28)); + } + return { + deltaX: movedX, deltaY: movedY, steps: n, + scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, pageMutated: pageHash() !== before, - note: "Synthetic pointer drag. HTML5 DataTransfer is not synthesized; native drag-and-drop targets may not respond.", + isTrusted: false, }; } @@ -871,23 +1076,90 @@ function setNativeValue(element, value) { else element.value = value; } -function typeIntoPage(selector, uid, text, pressEnter) { - installPiChromeInstrumentation(); - const before = pageHash(); - let element = elementBySelectorOrUid(selector, uid) || document.activeElement; - if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element"); - element.focus(); +function printableKeyCode(ch) { + if (ch === " ") return 32; + const upper = ch.toUpperCase(); + if (/^[A-Z]$/.test(upper)) return upper.charCodeAt(0); + if (/^[0-9]$/.test(ch)) return ch.charCodeAt(0); + return ch.charCodeAt(0) || 0; +} + +function dispatchKeyEvent(element, type, key, mods = {}) { + const code = key.length === 1 && /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : + key.length === 1 && /^[0-9]$/.test(key) ? `Digit${key}` : + key === " " ? "Space" : key; + const SPECIAL = { Enter: 13, Tab: 9, Backspace: 8, Delete: 46, Escape: 27, + ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, " ": 32, Shift: 16, Control: 17, Alt: 18, Meta: 91 }; + const keyCode = key.length === 1 ? printableKeyCode(key) : (SPECIAL[key] ?? 0); + const ev = new KeyboardEvent(type, { + key, + code, + keyCode, + which: keyCode, + charCode: type === "keypress" && key.length === 1 ? key.charCodeAt(0) : 0, + shiftKey: !!mods.shiftKey, + ctrlKey: !!mods.ctrlKey, + altKey: !!mods.altKey, + metaKey: !!mods.metaKey, + bubbles: true, + cancelable: true, + composed: true, + view: window, + }); + element.dispatchEvent(ev); + return ev; +} + +async function typeCharacter(element, ch) { + const needShift = ch.length === 1 && (/^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch)); + if (needShift) { + dispatchKeyEvent(element, "keydown", "Shift", { shiftKey: true }); + await sleepPage(rand(8, 24)); + } + const mods = { shiftKey: needShift }; + const down = dispatchKeyEvent(element, "keydown", ch, mods); + if (down.defaultPrevented) { + if (needShift) dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false }); + return { defaultPrevented: true }; + } + if (ch.length === 1) dispatchKeyEvent(element, "keypress", ch, mods); + if (element.isContentEditable) { - document.execCommand("insertText", false, text); + // execCommand("insertText") fires its own beforeinput + input. Don't double-dispatch. + document.execCommand("insertText", false, ch); } else if ("value" in element) { const start = element.selectionStart ?? element.value.length; const end = element.selectionEnd ?? element.value.length; - setNativeValue(element, element.value.slice(0, start) + text + element.value.slice(end)); - element.selectionStart = element.selectionEnd = start + text.length; - dispatchInputEvents(element, text, "insertText"); + const next = element.value.slice(0, start) + ch + element.value.slice(end); + const before = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: ch }); + element.dispatchEvent(before); + if (!before.defaultPrevented) { + setNativeValue(element, next); + try { element.selectionStart = element.selectionEnd = start + ch.length; } catch {} + element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ch })); + } } else { throw new Error("Focused element is not text-editable"); } + + await sleepPage(rand(25, 95)); + dispatchKeyEvent(element, "keyup", ch, mods); + if (needShift) { + await sleepPage(rand(5, 18)); + dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false }); + } + await sleepPage(rand(35, 140)); + return { defaultPrevented: false }; +} + +async function typeIntoPage(selector, uid, text, pressEnter) { + installPiChromeInstrumentation(); + const before = pageHash(); + let element = elementBySelectorOrUid(selector, uid) || document.activeElement; + if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element"); + element.focus(); + if (!(element.isContentEditable || "value" in element)) throw new Error("Focused element is not text-editable"); + for (const ch of Array.from(text)) await typeCharacter(element, ch); if (pressEnter) pressKeyInPage("Enter"); return { selector, uid, length: text.length, pressEnter, @@ -923,14 +1195,48 @@ function fillPage(selector, uid, text, submit) { }; } -function pressKeyInPage(key) { +async function pressKeyInPage(key) { const normalized = normalizeKey(key); const target = document.activeElement || document.body; - const before = (typeof pageHash === "function") ? pageHash() : 0; - const down = new KeyboardEvent("keydown", { key: normalized, bubbles: true, cancelable: true }); - target.dispatchEvent(down); - const up = new KeyboardEvent("keyup", { key: normalized, bubbles: true, cancelable: true }); - target.dispatchEvent(up); + const before = pageHash(); + const down = dispatchKeyEvent(target, "keydown", normalized); + if (normalized.length === 1) dispatchKeyEvent(target, "keypress", normalized); + // Character insertion for printable keys when focus is in an editable. + if (normalized.length === 1 && !down.defaultPrevented && (target.isContentEditable || ("value" in target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")))) { + if (target.isContentEditable) { + const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized }); + target.dispatchEvent(bi); + if (!bi.defaultPrevented) { + document.execCommand("insertText", false, normalized); + target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized })); + } + } else { + const start = target.selectionStart ?? target.value.length; + const end = target.selectionEnd ?? target.value.length; + const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized }); + target.dispatchEvent(bi); + if (!bi.defaultPrevented) { + setNativeValue(target, target.value.slice(0, start) + normalized + target.value.slice(end)); + try { target.selectionStart = target.selectionEnd = start + 1; } catch {} + target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized })); + } + } + } else if (normalized === "Backspace" && "value" in target) { + const start = target.selectionStart ?? target.value.length; + const end = target.selectionEnd ?? target.value.length; + if (start > 0 || end > start) { + const from = start === end ? start - 1 : start; + const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "deleteContentBackward" }); + target.dispatchEvent(bi); + if (!bi.defaultPrevented) { + setNativeValue(target, target.value.slice(0, from) + target.value.slice(end)); + try { target.selectionStart = target.selectionEnd = from; } catch {} + target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); + } + } + } + await sleepPage(rand(25, 95)); + const up = dispatchKeyEvent(target, "keyup", normalized); if (normalized === "Enter") { const form = target.closest?.("form"); if (form) form.requestSubmit?.(); @@ -939,7 +1245,7 @@ function pressKeyInPage(key) { key: normalized, isTrusted: false, defaultPrevented: down.defaultPrevented || up.defaultPrevented, - pageMutated: (typeof pageHash === "function") ? pageHash() !== before : undefined, + pageMutated: pageHash() !== before, }; } diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index ac176bc..8117d78 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -1,7 +1,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { StringEnum } from "@earendil-works/pi-ai"; import { Type } from "typebox"; -import { existsSync, statSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { dirname, join, resolve } from "node:path"; @@ -46,7 +46,15 @@ type BridgeResult = { error?: string; }; -const PI_CHROME_VERSION = "0.8.0"; +const PI_CHROME_PKG_PATH = resolve(__dirname, "..", "..", "package.json"); +function readPiChromeVersion(): string { + try { + const pkg = JSON.parse(readFileSync(PI_CHROME_PKG_PATH, "utf8")) as { version?: string }; + if (pkg.version) return pkg.version; + } catch {} + return "0.0.0-dev"; +} +const PI_CHROME_VERSION = readPiChromeVersion(); const PI_CHROME_GLOBAL_KEY = "__piChromeProfileBridgeLoaded__"; const DEFAULT_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1"; const DEFAULT_PORT = Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318"); @@ -118,13 +126,14 @@ function readRequestBody(request: IncomingMessage): Promise<string> { }); } -function sendJson(response: ServerResponse, status: number, body: unknown): void { +function sendJson(response: ServerResponse, status: number, body: unknown, extraHeaders?: Record<string, string>): void { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "content-type", "cache-control": "no-store", + ...(extraHeaders ?? {}), }); response.end(JSON.stringify(body)); } @@ -316,7 +325,16 @@ class ChromeProfileBridge { if (command) this.queue.unshift(command); return; } - sendJson(response, 200, command ? { type: "command", command } : { type: "none" }); + // Re-read version on every /next so bumping package.json takes effect without pi restart. + const currentVersion = readPiChromeVersion(); + sendJson( + response, + 200, + command + ? { type: "command", command, expectedExtensionVersion: currentVersion } + : { type: "none", expectedExtensionVersion: currentVersion }, + { "x-pi-chrome-version": currentVersion }, + ); return; } if (request.method === "POST" && url.pathname === "/result") { @@ -438,6 +456,7 @@ Usage rules: const status = bridge.status(); lines.push(`• Local bridge: mode=${status.mode}, url=${status.url}`); let extensionAlive = false; + let versionMismatch = false; try { const started = Date.now(); const version = (await bridge.send("tab.version", {}, 35_000)) as { @@ -447,11 +466,16 @@ Usage rules: }; const latencyMs = Date.now() - started; extensionAlive = true; - lines.push(`✓ Companion Chrome extension responding (ID: ${version.extensionId ?? "?"}, ext v${version.extensionVersion ?? "?"}, latency ${latencyMs}ms)`); if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) { + versionMismatch = true; lines.push( - `⚠ Extension version (${version.extensionVersion}) differs from pi-chrome (${PI_CHROME_VERSION}). Reload "Pi Existing Chrome Profile Bridge" in chrome://extensions to pick up the latest service worker.`, + `✗ EXTENSION VERSION MISMATCH: companion extension is v${version.extensionVersion}, but pi-chrome is v${PI_CHROME_VERSION}.`, + ` All chrome_* tools will run with the OLD extension code until this is fixed.`, + ` Fix: open chrome://extensions and click reload on "Pi Existing Chrome Profile Bridge".`, + ` (Future version drifts will self-heal: the extension now polls pi-chrome's expected version and reloads itself.)`, ); + } else { + lines.push(`✓ Companion Chrome extension responding (ID: ${version.extensionId ?? "?"}, ext v${version.extensionVersion ?? "?"}, latency ${latencyMs}ms)`); } } catch (error) { const message = (error as Error).message; @@ -463,7 +487,7 @@ Usage rules: } } - if (extensionAlive) { + if (extensionAlive && !versionMismatch) { // MAIN-world evaluate probe. try { const value = await bridge.send("page.evaluate", { expression: "1+1", awaitPromise: true, foreground: false }, 10_000); @@ -481,6 +505,8 @@ Usage rules: } catch (error) { lines.push(`⚠ page.probe failed: ${(error as Error).message}`); } + } else if (versionMismatch) { + lines.push(`… Skipped MAIN-world capability checks because the loaded extension is stale.`); } // CDP availability hint. @@ -990,7 +1016,7 @@ Usage rules: pi.registerTool({ name: "chrome_drag", label: "Chrome Drag", - description: "Synthetic pointer drag from one uid/selector/point to another. Dispatches pointerdown → multi-step pointermove → pointerup. Note: HTML5 DataTransfer is NOT synthesized, so native HTML5 drag-and-drop targets may not respond.", + description: "Synthetic drag from one uid/selector/point to another. Dispatches pointerdown → humanised pointermove path → dragstart/drag/dragenter/dragover/dragleave/drop/dragend with a shared HTML5 DataTransfer, then pointerup. isTrusted=false.", promptSnippet: "Drag a Chrome element from one point to another.", parameters: Type.Object({ fromUid: Type.Optional(Type.String()), @@ -1014,6 +1040,28 @@ Usage rules: }); pi.registerTool({ + name: "chrome_scroll", + label: "Chrome Scroll", + description: "Scroll the page or a specific scrollable element by dispatching real wheel events with momentum-shaped deltas, then applying the scroll. Positive deltaY scrolls down. Pass uid/selector to scroll within a container, otherwise the document scrolls.", + promptSnippet: "Scroll a Chrome page or container via wheel events (not raw scrollTop).", + parameters: Type.Object({ + uid: Type.Optional(Type.String()), + selector: Type.Optional(Type.String()), + deltaY: Type.Optional(Type.Number({ description: "Pixels to scroll vertically. Positive = down." })), + deltaX: Type.Optional(Type.Number({ description: "Pixels to scroll horizontally. Positive = right." })), + steps: Type.Optional(Type.Number({ description: "Number of wheel events to dispatch. Defaults to ceil(|deltaY|/100)." })), + targetId: Type.Optional(Type.String()), + urlIncludes: Type.Optional(Type.String()), + titleIncludes: Type.Optional(Type.String()), + background: Type.Optional(Type.Boolean()), + }), + async execute(_id, params): Promise<ToolTextResult> { + const result = await bridge.send("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS); + return { content: [{ type: "text", text: `Scrolled dy=${params.deltaY ?? 0} dx=${params.deltaX ?? 0}` }], details: { result: result as Json } }; + }, + }); + + pi.registerTool({ name: "chrome_upload_file", label: "Chrome Upload File", description: "Programmatically set the files of an <input type=file> element from local file paths. Uses DataTransfer to populate input.files and dispatches input+change events. Does NOT open the native file picker; works with React/Vue/Angular controlled inputs.", diff --git a/package.json b/package.json index 7cfcebc..c4dba0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-chrome", - "version": "0.8.0", + "version": "0.9.1", "description": "Drive your existing logged-in Chrome from Pi — no re-login, no throwaway profile, watch the agent work in real time (or toggle quiet background mode).", "keywords": [ "pi-package", 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 Binary files differnew file mode 100644 index 0000000..c3c6524 --- /dev/null +++ b/test-suite/baseline-dashboard.png 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 <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><select></code>. + Page asserts a real picker interaction: <code>mousedown</code>/<code>pointerdown</code> + on the <select> (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 <option> 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 <anonymous></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 < 0</code>, <code>x > 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 + >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 < 2ms, or all Δt identical, or median > 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 >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 |
