pi-chrome 0.15.25 → 0.15.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +13 -4
- package/docs/COMPARISON.md +2 -2
- package/docs/EXAMPLES.md +3 -1
- package/docs/FAQ.md +6 -2
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/package.json +2 -1
- package/test-suite/README.md +193 -0
- package/test-suite/_lib.js +130 -0
- package/test-suite/_style.css +31 -0
- package/test-suite/baseline-dashboard.png +0 -0
- package/test-suite/browsergym-action-space.json +44 -0
- package/test-suite/challenges/01-is-trusted-click.html +28 -0
- package/test-suite/challenges/02-is-trusted-keyboard.html +50 -0
- package/test-suite/challenges/03-webdriver-flag.html +51 -0
- package/test-suite/challenges/04-mouse-entropy.html +34 -0
- package/test-suite/challenges/05-event-timing.html +34 -0
- package/test-suite/challenges/06-click-coordinates.html +29 -0
- package/test-suite/challenges/07-pointer-properties.html +29 -0
- package/test-suite/challenges/08-keyboard-cadence.html +37 -0
- package/test-suite/challenges/09-composition-input.html +45 -0
- package/test-suite/challenges/10-user-activation.html +40 -0
- package/test-suite/challenges/11-honeypot.html +36 -0
- package/test-suite/challenges/12-fingerprint.html +59 -0
- package/test-suite/challenges/13-focus-order.html +31 -0
- package/test-suite/challenges/14-wheel-scroll.html +28 -0
- package/test-suite/challenges/15-drag-drop-datatransfer.html +73 -0
- package/test-suite/challenges/16-contenteditable-selection.html +54 -0
- package/test-suite/challenges/17-paste-clipboard.html +48 -0
- package/test-suite/challenges/18-native-select.html +56 -0
- package/test-suite/challenges/19-hover-dwell.html +50 -0
- package/test-suite/challenges/20-react-value-tracker.html +78 -0
- package/test-suite/challenges/21-keyboard-modifiers.html +65 -0
- package/test-suite/challenges/22-touch-events.html +66 -0
- package/test-suite/challenges/23-stack-trace-fingerprint.html +76 -0
- package/test-suite/challenges/24-viewport-edge-clicks.html +51 -0
- package/test-suite/challenges/25-pointer-continuity.html +62 -0
- package/test-suite/challenges/26-mousemove-rate.html +57 -0
- package/test-suite/challenges/27-scroll-momentum.html +66 -0
- package/test-suite/challenges/28-intersection-visibility.html +72 -0
- package/test-suite/challenges/29-shadow-dom-controls.html +44 -0
- package/test-suite/challenges/30-iframe-targeting.html +44 -0
- package/test-suite/challenges/31-file-upload.html +30 -0
- package/test-suite/challenges/32-keyboard-tab-navigation.html +61 -0
- package/test-suite/challenges/33-network-console-capture.html +33 -0
- package/test-suite/challenges/34-dialog-handling.html +28 -0
- package/test-suite/challenges/35-target-blank-popup.html +35 -0
- package/test-suite/challenges/36-modal-focus-trap.html +49 -0
- package/test-suite/challenges/37-autocomplete-combobox.html +42 -0
- package/test-suite/challenges/38-spa-route-change.html +40 -0
- package/test-suite/challenges/39-strict-csp-fallback.html +14 -0
- package/test-suite/challenges/39-strict-csp-fallback.js +27 -0
- package/test-suite/challenges/40-dynamic-wait-readiness.html +41 -0
- package/test-suite/challenges/41-tab-lifecycle.html +44 -0
- package/test-suite/fixtures/pi-chrome-upload.txt +1 -0
- package/test-suite/fixtures/sites/mini-shop/cheats.js +12 -0
- package/test-suite/fixtures/sites/mini-shop/grader.js +29 -0
- package/test-suite/fixtures/sites/mini-shop/index.html +55 -0
- package/test-suite/fixtures/sites/mini-shop/tasks.json +9 -0
- package/test-suite/index.html +193 -0
- package/test-suite/manifest.json +1630 -0
- package/test-suite/manifest.schema.json +73 -0
- package/test-suite/notes/browsergym-compat.md +70 -0
- package/test-suite/notes/bypass-ideas.md +79 -0
- package/test-suite/notes/profiles.md +22 -0
- package/test-suite/notes/runner-spec.md +29 -0
- package/test-suite/notes/scoring.md +44 -0
- package/test-suite/scenarios/choredesk/cheats.js +49 -0
- package/test-suite/scenarios/choredesk/index.html +239 -0
- package/test-suite/serve.sh +6 -0
- package/test-suite/task-manifest.json +416 -0
- package/test-suite/task-manifest.schema.json +59 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>03 webdriver / runtime fingerprint</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Auto-checks runtime properties commonly inspected by bot-detection scripts.</p>
|
|
9
|
+
<pre id="rep" style="font:12px monospace;background:#111;color:#eee;padding:12px;border-radius:6px"></pre>
|
|
10
|
+
</main>
|
|
11
|
+
<script>
|
|
12
|
+
Challenge.init({
|
|
13
|
+
id: "webdriver-flag",
|
|
14
|
+
instructions: "no interaction; auto-verdict on load",
|
|
15
|
+
});
|
|
16
|
+
const checks = [];
|
|
17
|
+
function chk(name, bad, note) {
|
|
18
|
+
checks.push({ name, bad, note });
|
|
19
|
+
if (bad) Challenge.log("flag", { name, note });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
chk("navigator.webdriver", navigator.webdriver === true, String(navigator.webdriver));
|
|
23
|
+
chk("languages-empty", !navigator.languages || navigator.languages.length === 0,
|
|
24
|
+
JSON.stringify(navigator.languages));
|
|
25
|
+
chk("plugins-empty", !navigator.plugins || navigator.plugins.length === 0,
|
|
26
|
+
`length=${navigator.plugins && navigator.plugins.length}`);
|
|
27
|
+
chk("permissions-notifications-quirk", false, "checked async below");
|
|
28
|
+
|
|
29
|
+
(async () => {
|
|
30
|
+
try {
|
|
31
|
+
const p = await navigator.permissions.query({ name: "notifications" });
|
|
32
|
+
// Headless Chrome historically returned "denied" while Notification.permission === "default".
|
|
33
|
+
const mismatch = p.state === "denied" && Notification.permission === "default";
|
|
34
|
+
checks.push({ name: "permissions-notifications-quirk", bad: mismatch,
|
|
35
|
+
note: `perm=${p.state} api=${Notification.permission}` });
|
|
36
|
+
finalize();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
checks.push({ name: "permissions-notifications-quirk", bad: false, note: "n/a " + e.message });
|
|
39
|
+
finalize();
|
|
40
|
+
}
|
|
41
|
+
})();
|
|
42
|
+
|
|
43
|
+
function finalize() {
|
|
44
|
+
const rep = document.getElementById("rep");
|
|
45
|
+
rep.textContent = checks.map(c => `${c.bad ? "✗" : "✓"} ${c.name} ${c.note}`).join("\n");
|
|
46
|
+
const failed = checks.filter(c => c.bad);
|
|
47
|
+
if (failed.length) Challenge.fail(...failed.map(c => `${c.name}: ${c.note}`));
|
|
48
|
+
else Challenge.pass("all runtime checks clean");
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
</body>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>04 mouse entropy</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: click the green button after moving the mouse in an organic path. Page requires
|
|
9
|
+
≥15 <code>mousemove</code> events with non-zero <code>movementX/Y</code> variance before the click.</p>
|
|
10
|
+
<button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
|
|
11
|
+
</main>
|
|
12
|
+
<script>
|
|
13
|
+
Challenge.init({
|
|
14
|
+
id: "mouse-entropy",
|
|
15
|
+
instructions: "click the button after natural mouse movement",
|
|
16
|
+
});
|
|
17
|
+
const moves = [];
|
|
18
|
+
window.addEventListener("mousemove", (e) => {
|
|
19
|
+
moves.push({ x: e.clientX, y: e.clientY, mx: e.movementX, my: e.movementY, t: e.timeStamp });
|
|
20
|
+
if (moves.length > 200) moves.shift();
|
|
21
|
+
});
|
|
22
|
+
document.getElementById("go").addEventListener("click", (e) => {
|
|
23
|
+
if (moves.length < 15) return Challenge.fail(`only ${moves.length} mousemove events before click`);
|
|
24
|
+
const dx = moves.map(m => Math.abs(m.mx)).reduce((a,b)=>a+b,0);
|
|
25
|
+
const dy = moves.map(m => Math.abs(m.my)).reduce((a,b)=>a+b,0);
|
|
26
|
+
if (dx + dy < 50) return Challenge.fail(`movement deltas tiny: dx=${dx}, dy=${dy}`);
|
|
27
|
+
// Detect "teleport then click" — all moves clustered at the button.
|
|
28
|
+
const r = document.getElementById("go").getBoundingClientRect();
|
|
29
|
+
const inside = moves.filter(m => m.x>=r.left && m.x<=r.right && m.y>=r.top && m.y<=r.bottom).length;
|
|
30
|
+
if (inside === moves.length) return Challenge.fail("all mousemoves inside target rect (teleport)");
|
|
31
|
+
Challenge.pass(`${moves.length} moves, |dx|+|dy|=${dx+dy}, ${inside} inside target`);
|
|
32
|
+
});
|
|
33
|
+
</script>
|
|
34
|
+
</body>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>05 event timing</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: click the button 3 times. Page rejects when <code>pointerdown→pointerup</code> is <30ms
|
|
9
|
+
or always identical, and when between-click gaps are too uniform.</p>
|
|
10
|
+
<button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me (0/3)</button>
|
|
11
|
+
</main>
|
|
12
|
+
<script>
|
|
13
|
+
Challenge.init({ id: "event-timing", instructions: "click button 3 times with human-like timing" });
|
|
14
|
+
const btn = document.getElementById("go");
|
|
15
|
+
const ups = [], downs = [], clickTs = [];
|
|
16
|
+
btn.addEventListener("pointerdown", (e) => downs.push(e.timeStamp));
|
|
17
|
+
btn.addEventListener("pointerup", (e) => ups.push(e.timeStamp));
|
|
18
|
+
btn.addEventListener("click", (e) => {
|
|
19
|
+
clickTs.push(e.timeStamp);
|
|
20
|
+
btn.textContent = `Click me (${clickTs.length}/3)`;
|
|
21
|
+
if (clickTs.length < 3) return;
|
|
22
|
+
const holds = downs.map((d,i)=> (ups[i]??d) - d);
|
|
23
|
+
const allSame = holds.every(h => Math.abs(h - holds[0]) < 0.5);
|
|
24
|
+
const tooFast = holds.some(h => h < 30);
|
|
25
|
+
const gaps = clickTs.slice(1).map((t,i)=> t - clickTs[i]);
|
|
26
|
+
const gapsUniform = gaps.length > 1 && Math.abs(gaps[0]-gaps[1]) < 5;
|
|
27
|
+
Challenge.log("timing", { holds, gaps });
|
|
28
|
+
if (tooFast) return Challenge.fail(`pointer hold too short: ${holds.join(",")}ms`);
|
|
29
|
+
if (allSame) return Challenge.fail(`pointer holds identical: ${holds.join(",")}ms`);
|
|
30
|
+
if (gapsUniform) return Challenge.fail(`between-click gaps suspiciously uniform: ${gaps.join(",")}ms`);
|
|
31
|
+
Challenge.pass(`holds=${holds.map(h=>h.toFixed(1)).join(",")}ms gaps=${gaps.map(g=>g.toFixed(0)).join(",")}ms`);
|
|
32
|
+
});
|
|
33
|
+
</script>
|
|
34
|
+
</body>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>06 click coordinates</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: click the green button 5 times. Page rejects when clicks always land on the exact element center.</p>
|
|
9
|
+
<button id="go" style="padding:24px 40px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me (0/5)</button>
|
|
10
|
+
</main>
|
|
11
|
+
<script>
|
|
12
|
+
Challenge.init({ id: "click-coordinates", instructions: "click button 5 times; coordinates must vary" });
|
|
13
|
+
const btn = document.getElementById("go");
|
|
14
|
+
const pts = [];
|
|
15
|
+
btn.addEventListener("click", (e) => {
|
|
16
|
+
const r = btn.getBoundingClientRect();
|
|
17
|
+
const cx = r.left + r.width/2, cy = r.top + r.height/2;
|
|
18
|
+
pts.push({ x: e.clientX, y: e.clientY, dx: e.clientX-cx, dy: e.clientY-cy });
|
|
19
|
+
btn.textContent = `Click me (${pts.length}/5)`;
|
|
20
|
+
if (pts.length < 5) return;
|
|
21
|
+
const onCenter = pts.filter(p => Math.abs(p.dx) < 0.51 && Math.abs(p.dy) < 0.51).length;
|
|
22
|
+
const unique = new Set(pts.map(p => `${p.x},${p.y}`)).size;
|
|
23
|
+
Challenge.log("coords", { pts });
|
|
24
|
+
if (onCenter >= 4) return Challenge.fail(`${onCenter}/5 clicks on exact center`);
|
|
25
|
+
if (unique <= 1) return Challenge.fail(`only ${unique} unique click coords`);
|
|
26
|
+
Challenge.pass(`${unique} unique coords, ${onCenter} on center`);
|
|
27
|
+
});
|
|
28
|
+
</script>
|
|
29
|
+
</body>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>07 pointer properties</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: click the button. Page inspects <code>pointerType</code>, <code>pressure</code>,
|
|
9
|
+
<code>movementX/Y</code> on the preceding pointermove, and that pointerId is non-zero.</p>
|
|
10
|
+
<button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
|
|
11
|
+
</main>
|
|
12
|
+
<script>
|
|
13
|
+
Challenge.init({ id: "pointer-properties", instructions: "click button; pointer event details must look real" });
|
|
14
|
+
const btn = document.getElementById("go");
|
|
15
|
+
let lastMove = null;
|
|
16
|
+
window.addEventListener("pointermove", (e) => { lastMove = { mx: e.movementX, my: e.movementY, pid: e.pointerId, type: e.pointerType }; });
|
|
17
|
+
btn.addEventListener("pointerdown", (e) => {
|
|
18
|
+
Challenge.log("pointerdown", { type: e.pointerType, pressure: e.pressure, pid: e.pointerId, mx: e.movementX, my: e.movementY });
|
|
19
|
+
const bad = [];
|
|
20
|
+
if (e.pointerType !== "mouse" && e.pointerType !== "touch" && e.pointerType !== "pen") bad.push(`pointerType=${e.pointerType}`);
|
|
21
|
+
if (e.pointerType === "mouse" && e.pressure !== 0.5) bad.push(`mouse pressure=${e.pressure} (real=0.5)`);
|
|
22
|
+
if (e.pointerId === 0) bad.push("pointerId=0");
|
|
23
|
+
if (!lastMove) bad.push("no preceding pointermove");
|
|
24
|
+
else if (lastMove.mx === 0 && lastMove.my === 0) bad.push("preceding pointermove had movementX=movementY=0");
|
|
25
|
+
if (bad.length) Challenge.fail(...bad);
|
|
26
|
+
else Challenge.pass(`type=${e.pointerType} pressure=${e.pressure} pid=${e.pointerId}`);
|
|
27
|
+
});
|
|
28
|
+
</script>
|
|
29
|
+
</body>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>08 keyboard cadence</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: type <code>secret</code>. Page demands per-key keydown+keypress+keyup with non-uniform gaps and non-zero hold time.</p>
|
|
9
|
+
<input id="t" placeholder="type 'secret'" style="font-size:18px;padding:8px 12px;width:240px">
|
|
10
|
+
</main>
|
|
11
|
+
<script>
|
|
12
|
+
Challenge.init({ id: "keyboard-cadence", instructions: "type 'secret' with realistic per-key cadence" });
|
|
13
|
+
const t = document.getElementById("t");
|
|
14
|
+
const evs = [];
|
|
15
|
+
["keydown","keypress","keyup","input"].forEach(name => {
|
|
16
|
+
t.addEventListener(name, (e) => evs.push({ name, key: e.key ?? e.data, t: e.timeStamp, trusted: e.isTrusted }));
|
|
17
|
+
});
|
|
18
|
+
t.addEventListener("keyup", () => {
|
|
19
|
+
if (t.value !== "secret") return;
|
|
20
|
+
const downs = evs.filter(e => e.name === "keydown");
|
|
21
|
+
const presses = evs.filter(e => e.name === "keypress");
|
|
22
|
+
const ups = evs.filter(e => e.name === "keyup");
|
|
23
|
+
const bad = [];
|
|
24
|
+
if (downs.length < 6) bad.push(`only ${downs.length} keydowns (need >=6)`);
|
|
25
|
+
if (presses.length < 6) bad.push(`only ${presses.length} keypress events`);
|
|
26
|
+
if (ups.length < 6) bad.push(`only ${ups.length} keyups`);
|
|
27
|
+
const holds = downs.slice(0, ups.length).map((d,i)=> ups[i].t - d.t);
|
|
28
|
+
if (holds.some(h => h <= 0)) bad.push(`some keyup at-or-before keydown: ${holds.join(",")}`);
|
|
29
|
+
if (holds.length && holds.every(h => Math.abs(h-holds[0]) < 0.5)) bad.push(`hold times identical: ${holds.join(",")}`);
|
|
30
|
+
const gaps = downs.slice(1).map((d,i)=> d.t - downs[i].t);
|
|
31
|
+
if (gaps.length && gaps.every(g => Math.abs(g-gaps[0]) < 0.5)) bad.push(`keydown gaps identical: ${gaps.join(",")}`);
|
|
32
|
+
Challenge.log("cadence", { holds, gaps });
|
|
33
|
+
if (bad.length) Challenge.fail(...bad);
|
|
34
|
+
else Challenge.pass(`holds=${holds.map(h=>h.toFixed(0)).join(",")} gaps=${gaps.map(g=>g.toFixed(0)).join(",")}`);
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
</body>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>09 framework input invariants</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: type <code>abc</code>. Page asserts React-style invariants: <code>beforeinput</code>
|
|
9
|
+
fires <em>before</em> <code>input</code>, both per-character, <code>inputType==="insertText"</code>,
|
|
10
|
+
value mutates between events, and no <code>compositionstart</code> for plain typing.</p>
|
|
11
|
+
<input id="t" placeholder="type abc" style="font-size:18px;padding:8px 12px;width:240px">
|
|
12
|
+
</main>
|
|
13
|
+
<script>
|
|
14
|
+
Challenge.init({ id: "composition-input", instructions: "type 'abc' producing framework-correct event order" });
|
|
15
|
+
const t = document.getElementById("t");
|
|
16
|
+
const evs = [];
|
|
17
|
+
let sawCompositionStart = false;
|
|
18
|
+
["beforeinput","input","compositionstart","compositionend"].forEach(name => {
|
|
19
|
+
t.addEventListener(name, (e) => {
|
|
20
|
+
evs.push({ name, t: e.timeStamp, inputType: e.inputType, data: e.data, value: t.value });
|
|
21
|
+
if (name === "compositionstart") sawCompositionStart = true;
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
t.addEventListener("input", () => {
|
|
25
|
+
if (t.value !== "abc") return;
|
|
26
|
+
const before = evs.filter(e => e.name === "beforeinput");
|
|
27
|
+
const inp = evs.filter(e => e.name === "input");
|
|
28
|
+
const bad = [];
|
|
29
|
+
if (sawCompositionStart) bad.push("compositionstart fired for plain ASCII typing");
|
|
30
|
+
if (before.length !== 3) bad.push(`beforeinput count=${before.length} (need 3)`);
|
|
31
|
+
if (inp.length !== 3) bad.push(`input count=${inp.length} (need 3)`);
|
|
32
|
+
if (before.some(b => b.inputType !== "insertText")) bad.push(`beforeinput.inputType=${before.map(b=>b.inputType).join(",")}`);
|
|
33
|
+
// beforeinput must precede matching input.
|
|
34
|
+
for (let i = 0; i < Math.min(before.length, inp.length); i++) {
|
|
35
|
+
if (before[i].t > inp[i].t) bad.push(`beforeinput[${i}] after input[${i}]`);
|
|
36
|
+
}
|
|
37
|
+
// Value must reflect each char incrementally.
|
|
38
|
+
const seq = inp.map(e => e.value).join("|");
|
|
39
|
+
if (seq !== "a|ab|abc") bad.push(`value seq at input events: ${seq} (need a|ab|abc)`);
|
|
40
|
+
Challenge.log("seq", { evs });
|
|
41
|
+
if (bad.length) Challenge.fail(...bad);
|
|
42
|
+
else Challenge.pass(`event order and invariants OK; seq=${seq}`);
|
|
43
|
+
});
|
|
44
|
+
</script>
|
|
45
|
+
</body>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>10 user activation gates</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: click the button. Handler then tries gated APIs (clipboard.writeText, fullscreen).
|
|
9
|
+
Success requires <code>navigator.userActivation.isActive</code> and at least one gated API to succeed.</p>
|
|
10
|
+
<button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
|
|
11
|
+
</main>
|
|
12
|
+
<script>
|
|
13
|
+
Challenge.init({ id: "user-activation", instructions: "click button; user-activation must unlock gated APIs" });
|
|
14
|
+
document.getElementById("go").addEventListener("click", async () => {
|
|
15
|
+
// Capture activation state synchronously before any await (activation can be consumed by gated APIs).
|
|
16
|
+
const ua = navigator.userActivation;
|
|
17
|
+
const wasActive = !!ua?.isActive;
|
|
18
|
+
const hadBeenActive = !!ua?.hasBeenActive;
|
|
19
|
+
Challenge.log("activation", { isActive: wasActive, hasBeenActive: hadBeenActive });
|
|
20
|
+
let clip = "skip", fs = "skip";
|
|
21
|
+
try { await navigator.clipboard.writeText("pi-chrome-test"); clip = "ok"; }
|
|
22
|
+
catch (e) { clip = "err:" + e.name; }
|
|
23
|
+
try { await document.documentElement.requestFullscreen(); fs = "ok"; document.exitFullscreen?.(); }
|
|
24
|
+
catch (e) { fs = "err:" + e.name; }
|
|
25
|
+
Challenge.log("gates", { clip, fs });
|
|
26
|
+
if (!wasActive && !hadBeenActive) {
|
|
27
|
+
return Challenge.fail("userActivation.isActive/hasBeenActive both false (synthetic click)");
|
|
28
|
+
}
|
|
29
|
+
const okCount = [clip, fs].filter(x => x === "ok").length;
|
|
30
|
+
if (okCount === 0) {
|
|
31
|
+
return Challenge.skip(`activation present, but gated APIs blocked by environment/policy: clipboard=${clip}; fullscreen=${fs}`);
|
|
32
|
+
}
|
|
33
|
+
const warnings = [];
|
|
34
|
+
if (clip.startsWith("err")) warnings.push("clipboard.writeText " + clip);
|
|
35
|
+
if (fs.startsWith("err")) warnings.push("requestFullscreen " + fs);
|
|
36
|
+
if (warnings.length) Challenge.pass(`activation active; ${okCount}/2 gates succeeded; ${warnings.join("; ")}`);
|
|
37
|
+
else Challenge.pass(`activation active; clipboard=${clip}; fullscreen=${fs}`);
|
|
38
|
+
});
|
|
39
|
+
</script>
|
|
40
|
+
</body>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>11 honeypot fields</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: fill <em>only</em> the visible "Name" field with <code>alex</code> and submit.
|
|
9
|
+
Three honeypots (a, b, c) are present that a human cannot see/tab to. Filling any honeypot fails.</p>
|
|
10
|
+
<form id="f">
|
|
11
|
+
<label>Name <input name="name" id="name"></label>
|
|
12
|
+
<!-- off-screen -->
|
|
13
|
+
<input name="a" aria-hidden="true" tabindex="-1" autocomplete="off"
|
|
14
|
+
style="position:absolute;left:-9999px;top:-9999px">
|
|
15
|
+
<!-- display:none -->
|
|
16
|
+
<input name="b" aria-hidden="true" tabindex="-1" autocomplete="off" style="display:none">
|
|
17
|
+
<!-- zero-size + visually hidden -->
|
|
18
|
+
<input name="c" aria-hidden="true" tabindex="-1" autocomplete="off"
|
|
19
|
+
style="opacity:0;width:0;height:0;border:0;padding:0">
|
|
20
|
+
<button type="submit">Submit</button>
|
|
21
|
+
</form>
|
|
22
|
+
</main>
|
|
23
|
+
<script>
|
|
24
|
+
Challenge.init({ id: "honeypot", instructions: "fill only the Name field with 'alex', then submit" });
|
|
25
|
+
const form = document.getElementById("f");
|
|
26
|
+
form.addEventListener("submit", (e) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
const fd = new FormData(form);
|
|
29
|
+
const filled = ["a","b","c"].filter(k => (fd.get(k) ?? "").toString().length > 0);
|
|
30
|
+
Challenge.log("submit", { name: fd.get("name"), a: fd.get("a"), b: fd.get("b"), c: fd.get("c") });
|
|
31
|
+
if (filled.length) return Challenge.fail("honeypot(s) filled: " + filled.join(","));
|
|
32
|
+
if ((fd.get("name") ?? "") !== "alex") return Challenge.fail(`name="${fd.get("name")}" (need 'alex')`);
|
|
33
|
+
Challenge.pass("only visible field filled");
|
|
34
|
+
});
|
|
35
|
+
</script>
|
|
36
|
+
</body>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>12 fingerprint consistency</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Auto-check. Looks for cross-API consistency mismatches that betray instrumented Chrome.</p>
|
|
9
|
+
<pre id="rep" style="font:12px monospace;background:#111;color:#eee;padding:12px;border-radius:6px"></pre>
|
|
10
|
+
</main>
|
|
11
|
+
<script>
|
|
12
|
+
Challenge.init({ id: "fingerprint", instructions: "no interaction" });
|
|
13
|
+
(async () => {
|
|
14
|
+
const checks = [];
|
|
15
|
+
const ua = navigator.userAgent;
|
|
16
|
+
const isChrome = /Chrome\/\d+/.test(ua);
|
|
17
|
+
checks.push({ name: "ua-is-chrome", bad: !isChrome, note: ua });
|
|
18
|
+
|
|
19
|
+
// UA-CH: Chrome should expose userAgentData with brands incl Chromium.
|
|
20
|
+
const uad = navigator.userAgentData;
|
|
21
|
+
const hasUAD = !!uad;
|
|
22
|
+
checks.push({ name: "userAgentData-present", bad: isChrome && !hasUAD, note: hasUAD ? JSON.stringify(uad.brands) : "missing" });
|
|
23
|
+
|
|
24
|
+
// Chrome runtime object exists in normal Chrome.
|
|
25
|
+
checks.push({ name: "window.chrome", bad: isChrome && typeof window.chrome === "undefined",
|
|
26
|
+
note: typeof window.chrome });
|
|
27
|
+
|
|
28
|
+
// languages must match accept-language semantics.
|
|
29
|
+
checks.push({ name: "languages-includes-language", bad: !navigator.languages?.includes(navigator.language),
|
|
30
|
+
note: `${navigator.language} vs ${JSON.stringify(navigator.languages)}` });
|
|
31
|
+
|
|
32
|
+
// WebGL vendor/renderer should not be Brian Paul / SwiftShader on user profile.
|
|
33
|
+
try {
|
|
34
|
+
const gl = document.createElement("canvas").getContext("webgl");
|
|
35
|
+
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
|
|
36
|
+
const vendor = dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR);
|
|
37
|
+
const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
|
|
38
|
+
const swiftshader = /SwiftShader|llvmpipe|Software/i.test(renderer);
|
|
39
|
+
checks.push({ name: "webgl-not-software", bad: false, warn: swiftshader, note: `${vendor} / ${renderer}${swiftshader ? " (software renderer; warning in VM/remote contexts)" : ""}` });
|
|
40
|
+
} catch (e) {
|
|
41
|
+
checks.push({ name: "webgl-not-software", bad: false, note: "n/a " + e.message });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// hardwareConcurrency / deviceMemory plausibility.
|
|
45
|
+
checks.push({ name: "hardwareConcurrency", bad: !(navigator.hardwareConcurrency > 0), note: String(navigator.hardwareConcurrency) });
|
|
46
|
+
|
|
47
|
+
// navigator.webdriver again, separate from challenge 03 so it appears here too.
|
|
48
|
+
checks.push({ name: "webdriver-false", bad: navigator.webdriver === true, note: String(navigator.webdriver) });
|
|
49
|
+
|
|
50
|
+
const rep = document.getElementById("rep");
|
|
51
|
+
rep.textContent = checks.map(c => `${c.bad ? "✗" : c.warn ? "!" : "✓"} ${c.name} ${c.note}`).join("\n");
|
|
52
|
+
const failed = checks.filter(c => c.bad);
|
|
53
|
+
const warned = checks.filter(c => c.warn);
|
|
54
|
+
if (failed.length) Challenge.fail(...failed.map(c => `${c.name}: ${c.note}`));
|
|
55
|
+
else if (warned.length) Challenge.warn(...warned.map(c => `${c.name}: ${c.note}`));
|
|
56
|
+
else Challenge.pass("fingerprint consistent with real Chrome");
|
|
57
|
+
})();
|
|
58
|
+
</script>
|
|
59
|
+
</body>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>13 focus order</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: focus the input by <em>clicking</em> it (not <code>.focus()</code>), then type <code>x</code>.
|
|
9
|
+
Pointerdown must precede focus, and <code>:focus-visible</code> must be false for pointer focus.</p>
|
|
10
|
+
<input id="t" placeholder="click then type x" style="font-size:18px;padding:8px 12px;width:240px">
|
|
11
|
+
</main>
|
|
12
|
+
<script>
|
|
13
|
+
Challenge.init({ id: "focus-order", instructions: "click input then type 'x'" });
|
|
14
|
+
const t = document.getElementById("t");
|
|
15
|
+
let lastPointer = -Infinity, lastFocus = -Infinity, sawPointer = false;
|
|
16
|
+
t.addEventListener("pointerdown", (e) => { lastPointer = e.timeStamp; sawPointer = true; });
|
|
17
|
+
t.addEventListener("focus", (e) => {
|
|
18
|
+
lastFocus = e.timeStamp;
|
|
19
|
+
if (!sawPointer) Challenge.fail("focus arrived with no preceding pointerdown");
|
|
20
|
+
else if (lastFocus < lastPointer) Challenge.fail("focus before pointerdown");
|
|
21
|
+
});
|
|
22
|
+
t.addEventListener("input", () => {
|
|
23
|
+
if (t.value !== "x") return;
|
|
24
|
+
// For pointer-driven focus, :focus-visible should be false.
|
|
25
|
+
const fv = t.matches(":focus-visible");
|
|
26
|
+
Challenge.log("focus-visible", { fv });
|
|
27
|
+
if (fv) Challenge.fail(":focus-visible true after pointer click (looks like keyboard focus)");
|
|
28
|
+
else if (window.__verdict !== "FAIL") Challenge.pass("pointerdown→focus→input order ok, :focus-visible=false");
|
|
29
|
+
});
|
|
30
|
+
</script>
|
|
31
|
+
</body>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>14 wheel scroll</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: scroll the box to the bottom. Page rejects raw <code>scrollTop</code> assignment without
|
|
9
|
+
<code>wheel</code> events.</p>
|
|
10
|
+
<div id="box" style="height:200px;overflow:auto;border:1px solid #555;background:#fff;color:#111;padding:8px">
|
|
11
|
+
<div style="height:1200px">scroll me ⬇<br><br>...lots of content...</div>
|
|
12
|
+
</div>
|
|
13
|
+
</main>
|
|
14
|
+
<script>
|
|
15
|
+
Challenge.init({ id: "wheel-scroll", instructions: "scroll the box to its bottom" });
|
|
16
|
+
const box = document.getElementById("box");
|
|
17
|
+
let wheelCount = 0, lastWheelTs = -Infinity;
|
|
18
|
+
box.addEventListener("wheel", (e) => { wheelCount++; lastWheelTs = e.timeStamp; Challenge.log("wheel", { dy: e.deltaY }); }, { passive: true });
|
|
19
|
+
box.addEventListener("scroll", () => {
|
|
20
|
+
Challenge.log("scroll", { top: box.scrollTop });
|
|
21
|
+
if (box.scrollTop + box.clientHeight >= box.scrollHeight - 2) {
|
|
22
|
+
if (wheelCount === 0) Challenge.fail("scrolled to bottom with zero wheel events");
|
|
23
|
+
else if (performance.now() - lastWheelTs > 1500) Challenge.fail("wheel events too far before final scroll");
|
|
24
|
+
else Challenge.pass(`${wheelCount} wheel events accompanied scroll`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
</script>
|
|
28
|
+
</body>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>15 drag-drop DataTransfer</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: drag the <b>red box</b> into the <b>drop zone</b>. Page asserts a full HTML5
|
|
9
|
+
drag sequence with a populated <code>DataTransfer</code> — synthetic pointer drags
|
|
10
|
+
dispatched without <code>dragstart</code>/<code>drop</code> + DataTransfer payload fail.</p>
|
|
11
|
+
<div style="display:flex;gap:24px;align-items:center;margin-top:16px">
|
|
12
|
+
<div id="src" draggable="true"
|
|
13
|
+
style="width:80px;height:80px;background:#c33;color:#fff;display:flex;align-items:center;justify-content:center;border-radius:6px;cursor:grab">
|
|
14
|
+
DRAG
|
|
15
|
+
</div>
|
|
16
|
+
<div id="dst"
|
|
17
|
+
style="width:200px;height:120px;border:2px dashed #888;display:flex;align-items:center;justify-content:center;color:#888">
|
|
18
|
+
DROP HERE
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</main>
|
|
22
|
+
<script>
|
|
23
|
+
Challenge.init({ id: "drag-drop-datatransfer", instructions: "drag red box into drop zone via real HTML5 drag" });
|
|
24
|
+
const src = document.getElementById("src");
|
|
25
|
+
const dst = document.getElementById("dst");
|
|
26
|
+
|
|
27
|
+
const seen = { dragstart: 0, drag: 0, dragenter: 0, dragover: 0, drop: 0, dragend: 0 };
|
|
28
|
+
let dtHadTypes = false, dtPayload = null, isTrustedAll = true;
|
|
29
|
+
|
|
30
|
+
src.addEventListener("dragstart", (e) => {
|
|
31
|
+
seen.dragstart++;
|
|
32
|
+
if (!e.isTrusted) isTrustedAll = false;
|
|
33
|
+
try { e.dataTransfer.setData("text/plain", "payload-" + Math.random().toString(36).slice(2,8)); } catch {}
|
|
34
|
+
Challenge.log("dragstart", { hasDt: !!e.dataTransfer });
|
|
35
|
+
});
|
|
36
|
+
src.addEventListener("drag", (e) => { seen.drag++; if (!e.isTrusted) isTrustedAll = false; });
|
|
37
|
+
src.addEventListener("dragend", (e) => { seen.dragend++; if (!e.isTrusted) isTrustedAll = false; });
|
|
38
|
+
|
|
39
|
+
dst.addEventListener("dragenter", (e) => { seen.dragenter++; e.preventDefault(); if (!e.isTrusted) isTrustedAll = false; });
|
|
40
|
+
dst.addEventListener("dragover", (e) => { seen.dragover++; e.preventDefault(); if (!e.isTrusted) isTrustedAll = false; });
|
|
41
|
+
dst.addEventListener("drop", (e) => {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
seen.drop++;
|
|
44
|
+
if (!e.isTrusted) isTrustedAll = false;
|
|
45
|
+
const dt = e.dataTransfer;
|
|
46
|
+
if (dt) {
|
|
47
|
+
dtHadTypes = (dt.types && dt.types.length > 0);
|
|
48
|
+
try { dtPayload = dt.getData("text/plain"); } catch {}
|
|
49
|
+
}
|
|
50
|
+
Challenge.log("drop", { types: dt && [...dt.types], payload: dtPayload });
|
|
51
|
+
// dragend fires AFTER drop per spec; give it a tick.
|
|
52
|
+
setTimeout(evaluate, 50);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function evaluate() {
|
|
56
|
+
const bad = [];
|
|
57
|
+
if (!isTrustedAll) bad.push("at least one drag event isTrusted=false");
|
|
58
|
+
for (const k of ["dragstart","dragover","drop","dragend"]) {
|
|
59
|
+
if (!seen[k]) bad.push(`missing ${k}`);
|
|
60
|
+
}
|
|
61
|
+
if (!dtHadTypes) bad.push("DataTransfer.types empty on drop");
|
|
62
|
+
if (!dtPayload || !dtPayload.startsWith("payload-")) bad.push("DataTransfer payload missing on drop");
|
|
63
|
+
if (bad.length) Challenge.fail(...bad);
|
|
64
|
+
else Challenge.pass(`full drag cycle, payload=${dtPayload}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Fallback: pointer-drag without dragstart triggers fail after a delay.
|
|
68
|
+
let pointerDownInSrc = false;
|
|
69
|
+
src.addEventListener("pointerdown", () => { pointerDownInSrc = true;
|
|
70
|
+
setTimeout(() => { if (!seen.dragstart && pointerDownInSrc) Challenge.fail("pointerdown on src but no dragstart fired"); }, 1500);
|
|
71
|
+
});
|
|
72
|
+
</script>
|
|
73
|
+
</body>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>16 contenteditable selection</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: focus the editable region and type <code>hello</code>. Page asserts that
|
|
9
|
+
<code>window.getSelection()</code> reflects per-keystroke caret movement
|
|
10
|
+
(<code>rangeCount===1</code>, <code>collapsed</code> caret, anchor inside the editor,
|
|
11
|
+
offset advances 1 per char) and that <code>selectionchange</code> fires.</p>
|
|
12
|
+
<div id="ed" contenteditable="true"
|
|
13
|
+
style="min-height:60px;padding:10px;border:1px solid #555;background:#fff;color:#111;font:16px monospace"></div>
|
|
14
|
+
</main>
|
|
15
|
+
<script>
|
|
16
|
+
Challenge.init({ id: "contenteditable-selection", instructions: "focus editor and type 'hello'" });
|
|
17
|
+
const ed = document.getElementById("ed");
|
|
18
|
+
const offsets = [];
|
|
19
|
+
let selChanges = 0;
|
|
20
|
+
|
|
21
|
+
document.addEventListener("selectionchange", () => {
|
|
22
|
+
selChanges++;
|
|
23
|
+
const s = window.getSelection();
|
|
24
|
+
if (!s || s.rangeCount === 0) return;
|
|
25
|
+
if (!ed.contains(s.anchorNode)) return;
|
|
26
|
+
offsets.push({ off: s.anchorOffset, collapsed: s.isCollapsed, t: performance.now(), len: ed.textContent.length });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
ed.addEventListener("input", async () => {
|
|
30
|
+
if (ed.textContent !== "hello") return;
|
|
31
|
+
// selectionchange is async — wait a tick so the final caret update is observed.
|
|
32
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
33
|
+
const bad = [];
|
|
34
|
+
const sel = window.getSelection();
|
|
35
|
+
if (!sel) bad.push("getSelection()==null");
|
|
36
|
+
else {
|
|
37
|
+
if (sel.rangeCount !== 1) bad.push(`rangeCount=${sel.rangeCount} (need 1)`);
|
|
38
|
+
if (!sel.isCollapsed) bad.push("selection not collapsed after typing");
|
|
39
|
+
if (!ed.contains(sel.anchorNode)) bad.push("selection anchor outside editor");
|
|
40
|
+
if (sel.anchorOffset !== 5) bad.push(`anchorOffset=${sel.anchorOffset} (need 5)`);
|
|
41
|
+
}
|
|
42
|
+
if (selChanges < 5) bad.push(`selectionchange count=${selChanges} (need ≥5)`);
|
|
43
|
+
// offsets should advance monotonically 1,2,3,4,5 (allowing extras from focus)
|
|
44
|
+
const monotonic = offsets.map(o => o.off);
|
|
45
|
+
const peak = Math.max(0, ...monotonic);
|
|
46
|
+
if (peak !== 5) bad.push(`peak caret offset=${peak} during typing (need 5)`);
|
|
47
|
+
// every caret reading must be collapsed (no spurious ranges)
|
|
48
|
+
if (offsets.some(o => !o.collapsed)) bad.push("non-collapsed range observed during typing");
|
|
49
|
+
Challenge.log("sel", { offsets, selChanges });
|
|
50
|
+
if (bad.length) Challenge.fail(...bad);
|
|
51
|
+
else Challenge.pass(`selectionchanges=${selChanges}, caret advanced 0→5`);
|
|
52
|
+
});
|
|
53
|
+
</script>
|
|
54
|
+
</body>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>17 paste clipboard</title>
|
|
4
|
+
<link rel="stylesheet" href="../_style.css">
|
|
5
|
+
<script src="../_lib.js"></script>
|
|
6
|
+
<body>
|
|
7
|
+
<main>
|
|
8
|
+
<p>Goal: paste the string <code>pi-chrome</code> into the input via a real OS paste
|
|
9
|
+
(Cmd/Ctrl+V firing a trusted <code>paste</code> event with populated
|
|
10
|
+
<code>clipboardData</code>). Programmatic <code>value=</code> or <code>setRangeText</code>
|
|
11
|
+
fail. The <code>input</code> event's <code>inputType</code> must be
|
|
12
|
+
<code>insertFromPaste</code>.</p>
|
|
13
|
+
<input id="t" placeholder="paste here" style="font-size:18px;padding:8px 12px;width:280px">
|
|
14
|
+
</main>
|
|
15
|
+
<script>
|
|
16
|
+
Challenge.init({ id: "paste-clipboard", instructions: "paste 'pi-chrome' via OS clipboard" });
|
|
17
|
+
const t = document.getElementById("t");
|
|
18
|
+
let sawPaste = false, pasteIsTrusted = false, pastePayload = null;
|
|
19
|
+
t.addEventListener("paste", (e) => {
|
|
20
|
+
sawPaste = true;
|
|
21
|
+
pasteIsTrusted = e.isTrusted;
|
|
22
|
+
try { pastePayload = e.clipboardData && e.clipboardData.getData("text/plain"); } catch {}
|
|
23
|
+
Challenge.log("paste", { isTrusted: e.isTrusted, payload: pastePayload });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
let lastInputType = null;
|
|
27
|
+
t.addEventListener("input", (e) => {
|
|
28
|
+
lastInputType = e.inputType;
|
|
29
|
+
if (t.value !== "pi-chrome") return;
|
|
30
|
+
const bad = [];
|
|
31
|
+
if (!sawPaste) bad.push("no paste event fired before value matched");
|
|
32
|
+
if (!pasteIsTrusted) bad.push("paste event isTrusted=false");
|
|
33
|
+
if (pastePayload !== "pi-chrome") bad.push(`clipboardData payload="${pastePayload}" (need 'pi-chrome')`);
|
|
34
|
+
if (lastInputType !== "insertFromPaste") bad.push(`input.inputType="${lastInputType}" (need 'insertFromPaste')`);
|
|
35
|
+
if (bad.length) Challenge.fail(...bad);
|
|
36
|
+
else Challenge.pass("trusted paste with clipboardData text/plain match");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Also fail loudly if value is set without any input event (raw .value=)
|
|
40
|
+
let inputFired = false;
|
|
41
|
+
t.addEventListener("input", () => { inputFired = true; });
|
|
42
|
+
const obs = new MutationObserver(() => {});
|
|
43
|
+
obs.observe(t, { attributes: true, attributeFilter: ["value"] });
|
|
44
|
+
setInterval(() => {
|
|
45
|
+
if (t.value === "pi-chrome" && !inputFired) Challenge.fail("value matched without input event firing");
|
|
46
|
+
}, 200);
|
|
47
|
+
</script>
|
|
48
|
+
</body>
|