|
| 1 | +import assert from "node:assert/strict"; |
| 2 | +import { mkdtemp, rm, writeFile } from "node:fs/promises"; |
| 3 | +import { tmpdir } from "node:os"; |
| 4 | +import { join, resolve } from "node:path"; |
| 5 | +import { chromium } from "@playwright/test"; |
| 6 | +import { parseCapturedTabInputBatch } from "../shared/input-events"; |
| 7 | + |
| 8 | +const html = `<!doctype html> |
| 9 | +<html> |
| 10 | + <head> |
| 11 | + <style> |
| 12 | + body { margin: 0; } |
| 13 | + .control { position: absolute; left: 40px; width: 220px; height: 30px; } |
| 14 | + </style> |
| 15 | + </head> |
| 16 | + <body> |
| 17 | + <input id="password" class="control" type="password" aria-label="Password" style="top: 40px"> |
| 18 | + <div id="editable" class="control" contenteditable style="top: 80px">Private text</div> |
| 19 | + <div id="textbox" class="control" role="textbox" style="top: 120px">Custom text</div> |
| 20 | + <div id="pin" class="control" aria-label="PIN widget" style="top: 160px">PIN</div> |
| 21 | + <button id="secret" class="control" aria-label="Secret button" style="top: 200px">Secret</button> |
| 22 | + <label id="email-label" class="control" for="email" style="top: 240px">Email address</label> |
| 23 | + <input id="email" class="control" type="email" style="top: 280px"> |
| 24 | + <form id="payment" class="control" style="top: 320px; height: 80px"> |
| 25 | + <input id="card" autocomplete="cc-number" style="width: 210px; height: 30px"> |
| 26 | + <button id="pay" type="button" style="position: absolute; top: 40px; left: 0; width: 210px; height: 30px">Confirm payment</button> |
| 27 | + </form> |
| 28 | + <div id="shadow-host" class="control" style="top: 420px"></div> |
| 29 | + <div id="cap-extension-recorder-overlay" class="control" style="top: 460px"> |
| 30 | + <button id="overlay-button" style="width: 210px; height: 30px">Recorder control</button> |
| 31 | + </div> |
| 32 | + <button id="public" class="control" style="left: 420px; top: 420px">Public action</button> |
| 33 | + <form class="control" style="left: 420px; top: 470px; height: 80px"> |
| 34 | + <input name="shipping-address" style="width: 210px; height: 30px"> |
| 35 | + <button id="shipping" type="button" style="position: absolute; top: 40px; left: 0; width: 210px; height: 30px">Update shipping</button> |
| 36 | + </form> |
| 37 | + <script> |
| 38 | + const shadowInput = document.createElement("input"); |
| 39 | + shadowInput.setAttribute("aria-label", "Shadow private field"); |
| 40 | + shadowInput.style.cssText = "width: 210px; height: 30px"; |
| 41 | + document.getElementById("shadow-host").attachShadow({ mode: "open" }).append(shadowInput); |
| 42 | + </script> |
| 43 | + </body> |
| 44 | +</html>`; |
| 45 | + |
| 46 | +const tempDirectory = await mkdtemp(join(tmpdir(), "cap-input-privacy-")); |
| 47 | +const entry = join(tempDirectory, "entry.ts"); |
| 48 | +await writeFile( |
| 49 | + entry, |
| 50 | + `import { initTabInputCapture } from ${JSON.stringify(resolve(import.meta.dir, "input-events.ts"))};\ninitTabInputCapture();\n`, |
| 51 | +); |
| 52 | + |
| 53 | +const bundled = await Bun.build({ |
| 54 | + entrypoints: [entry], |
| 55 | + target: "browser", |
| 56 | + format: "iife", |
| 57 | + splitting: false, |
| 58 | +}); |
| 59 | +assert.ok(bundled.success, bundled.logs.map((log) => log.message).join("\n")); |
| 60 | +const contentScript = await bundled.outputs[0]?.text(); |
| 61 | +assert.ok(contentScript); |
| 62 | + |
| 63 | +const server = Bun.serve({ |
| 64 | + hostname: "127.0.0.1", |
| 65 | + port: 0, |
| 66 | + fetch() { |
| 67 | + return new Response(html, { |
| 68 | + headers: { "Content-Type": "text/html; charset=utf-8" }, |
| 69 | + }); |
| 70 | + }, |
| 71 | +}); |
| 72 | +const browser = await chromium.launch({ headless: true }); |
| 73 | + |
| 74 | +try { |
| 75 | + const page = await browser.newPage({ |
| 76 | + viewport: { width: 900, height: 600 }, |
| 77 | + }); |
| 78 | + const pageErrors: string[] = []; |
| 79 | + page.on("pageerror", (error) => pageErrors.push(error.message)); |
| 80 | + await page.addInitScript(() => { |
| 81 | + type ContentListener = ( |
| 82 | + message: { type: string; recordingId?: string }, |
| 83 | + sender: unknown, |
| 84 | + respond: (value: unknown) => void, |
| 85 | + ) => boolean; |
| 86 | + (window as Window & { capInputBatches?: unknown[] }).capInputBatches = []; |
| 87 | + Object.defineProperty(window, "chrome", { |
| 88 | + configurable: true, |
| 89 | + value: { |
| 90 | + runtime: { |
| 91 | + lastError: null, |
| 92 | + onMessage: { |
| 93 | + addListener: (listener: ContentListener) => { |
| 94 | + ( |
| 95 | + window as Window & { |
| 96 | + capInputListener?: ContentListener; |
| 97 | + } |
| 98 | + ).capInputListener = listener; |
| 99 | + }, |
| 100 | + }, |
| 101 | + sendMessage: ( |
| 102 | + message: unknown, |
| 103 | + callback: (value: unknown) => void, |
| 104 | + ) => { |
| 105 | + ( |
| 106 | + window as Window & { capInputBatches?: unknown[] } |
| 107 | + ).capInputBatches?.push(message); |
| 108 | + callback({ ok: true }); |
| 109 | + }, |
| 110 | + }, |
| 111 | + storage: { |
| 112 | + onChanged: { addListener: () => undefined }, |
| 113 | + }, |
| 114 | + }, |
| 115 | + }); |
| 116 | + }); |
| 117 | + await page.goto(`http://127.0.0.1:${server.port}/capture.html`); |
| 118 | + await page.addScriptTag({ content: contentScript }); |
| 119 | + assert.deepEqual(pageErrors, []); |
| 120 | + assert.equal( |
| 121 | + await page.evaluate( |
| 122 | + () => |
| 123 | + typeof ( |
| 124 | + window as Window & { |
| 125 | + capInputListener?: (...args: unknown[]) => unknown; |
| 126 | + } |
| 127 | + ).capInputListener, |
| 128 | + ), |
| 129 | + "function", |
| 130 | + ); |
| 131 | + const requestCapture = (type: string) => |
| 132 | + page.evaluate((requestType) => { |
| 133 | + const listener = ( |
| 134 | + window as Window & { |
| 135 | + capInputListener?: ( |
| 136 | + message: { type: string; recordingId: string }, |
| 137 | + sender: unknown, |
| 138 | + respond: (value: unknown) => void, |
| 139 | + ) => boolean; |
| 140 | + } |
| 141 | + ).capInputListener; |
| 142 | + if (!listener) throw new Error("Content listener did not load"); |
| 143 | + return new Promise<unknown>((resolve) => { |
| 144 | + listener( |
| 145 | + { type: requestType, recordingId: "privacy-replay" }, |
| 146 | + {}, |
| 147 | + resolve, |
| 148 | + ); |
| 149 | + }); |
| 150 | + }, type); |
| 151 | + assert.deepEqual(await requestCapture("input-capture-start"), { ok: true }); |
| 152 | + |
| 153 | + const privateSelectors = [ |
| 154 | + "#password", |
| 155 | + "#editable", |
| 156 | + "#textbox", |
| 157 | + "#pin", |
| 158 | + "#secret", |
| 159 | + "#email-label", |
| 160 | + "#email", |
| 161 | + "#card", |
| 162 | + "#pay", |
| 163 | + "#shadow-host input", |
| 164 | + "#overlay-button", |
| 165 | + ]; |
| 166 | + const privateBounds = await Promise.all( |
| 167 | + privateSelectors.map(async (selector) => { |
| 168 | + const bounds = await page.locator(selector).boundingBox(); |
| 169 | + assert.ok(bounds, selector); |
| 170 | + return bounds; |
| 171 | + }), |
| 172 | + ); |
| 173 | + for (const selector of privateSelectors) await page.locator(selector).click(); |
| 174 | + const publicBounds = await page.locator("#public").boundingBox(); |
| 175 | + assert.ok(publicBounds); |
| 176 | + await page.locator("#public").click(); |
| 177 | + const shippingBounds = await page.locator("#shipping").boundingBox(); |
| 178 | + assert.ok(shippingBounds); |
| 179 | + await page.locator("#shipping").click(); |
| 180 | + await page.keyboard.press("Escape"); |
| 181 | + assert.deepEqual(await requestCapture("input-capture-stop"), { ok: true }); |
| 182 | + |
| 183 | + const rawBatches = await page.evaluate( |
| 184 | + () => |
| 185 | + (window as Window & { capInputBatches?: unknown[] }).capInputBatches ?? |
| 186 | + [], |
| 187 | + ); |
| 188 | + const batches = rawBatches.map(parseCapturedTabInputBatch); |
| 189 | + assert.ok(batches.length > 0); |
| 190 | + assert.ok(batches.every((batch) => batch !== null)); |
| 191 | + const pointerSamples = batches.flatMap( |
| 192 | + (batch) => |
| 193 | + batch?.events.flatMap((event) => |
| 194 | + event.kind === "move" || event.kind === "down" || event.kind === "up" |
| 195 | + ? [ |
| 196 | + { |
| 197 | + kind: event.kind, |
| 198 | + x: event.x * batch.viewportWidth, |
| 199 | + y: event.y * batch.viewportHeight, |
| 200 | + }, |
| 201 | + ] |
| 202 | + : [], |
| 203 | + ) ?? [], |
| 204 | + ); |
| 205 | + const inside = ( |
| 206 | + point: { x: number; y: number }, |
| 207 | + bounds: { x: number; y: number; width: number; height: number }, |
| 208 | + ) => |
| 209 | + point.x >= bounds.x && |
| 210 | + point.x <= bounds.x + bounds.width && |
| 211 | + point.y >= bounds.y && |
| 212 | + point.y <= bounds.y + bounds.height; |
| 213 | + assert.ok( |
| 214 | + pointerSamples.some( |
| 215 | + (point) => point.kind === "down" && inside(point, publicBounds), |
| 216 | + ), |
| 217 | + ); |
| 218 | + assert.ok( |
| 219 | + pointerSamples.some( |
| 220 | + (point) => point.kind === "down" && inside(point, shippingBounds), |
| 221 | + ), |
| 222 | + ); |
| 223 | + for (const bounds of privateBounds) { |
| 224 | + assert.equal( |
| 225 | + pointerSamples.some((point) => inside(point, bounds)), |
| 226 | + false, |
| 227 | + ); |
| 228 | + } |
| 229 | + assert.ok( |
| 230 | + batches.some((batch) => |
| 231 | + batch?.events.some( |
| 232 | + (event) => event.kind === "keyDown" && event.key === "Escape", |
| 233 | + ), |
| 234 | + ), |
| 235 | + ); |
| 236 | + assert.deepEqual(pageErrors, []); |
| 237 | + process.stdout.write( |
| 238 | + `${JSON.stringify({ privateTargets: privateSelectors.length, publicPointerEvents: pointerSamples.length, batches: batches.length, pageErrors })}\n`, |
| 239 | + ); |
| 240 | +} finally { |
| 241 | + await browser.close(); |
| 242 | + server.stop(true); |
| 243 | + await rm(tempDirectory, { recursive: true, force: true }); |
| 244 | +} |
0 commit comments