| 1 | import assert from "node:assert/strict"; |
| 2 | import { execFileSync } from "node:child_process"; |
| 3 | |
| 4 | // XTest input targets only the disposable Electron window in an isolated Xvfb |
| 5 | // display. It must never run against the developer's active desktop session. |
| 6 | export async function verifyNativeReaderInput(page, evidence = {}) { |
| 7 | evidence.status = "running"; |
| 8 | assert.equal(process.platform, "linux"); |
| 9 | assert.ok(process.env.DISPLAY, "native input requires an isolated Xvfb display"); |
| 10 | const xdo = (...args) => execFileSync("xdotool", args.map(String), { encoding: "utf8", timeout: 10_000 }).trim(); |
| 11 | const windows = xdo("search", "--onlyvisible", "--name", "^Reasonix Handoff Native$").split("\n"); |
| 12 | assert.equal(windows.length, 1, "exactly one disposable native window is required"); |
| 13 | const windowId = windows[0]; |
| 14 | xdo("windowfocus", "--sync", windowId); |
| 15 | const frame = () => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); |
| 16 | const scroll = page.locator(".chat-flow-scroll"); |
| 17 | await scroll.evaluate(el => { |
| 18 | el.focus(); |
| 19 | window.nativeSamples = []; |
| 20 | window.nativeSampling = true; |
| 21 | window.nativePhase = "position"; |
| 22 | window.nativePointerEvents = []; |
| 23 | for (const type of ["pointerdown", "pointerup", "pointermove"]) el.addEventListener(type, event => { |
| 24 | window.nativePointerEvents.push({ type, x: event.clientX, y: event.clientY, buttons: event.buttons, target: event.target === el }); |
| 25 | }); |
| 26 | const sample = () => { |
| 27 | const box = el.getBoundingClientRect(); |
| 28 | const rows = [...el.querySelectorAll("[data-chat-kind]")]; |
| 29 | const visible = rows.some(row => { const rect = row.getBoundingClientRect(); return rect.height > 0 && rect.bottom > box.top && rect.top < box.bottom; }); |
| 30 | window.nativeSamples.push({ phase: window.nativePhase, top: el.scrollTop, height: el.scrollHeight, viewport: el.clientHeight, blank: !visible }); |
| 31 | if (window.nativeSampling) requestAnimationFrame(sample); |
| 32 | }; |
| 33 | sample(); |
| 34 | }); |
| 35 | const top = () => scroll.evaluate(el => el.scrollTop); |
| 36 | try { |
| 37 | xdo("key", "--clearmodifiers", "End"); |
| 38 | await page.waitForFunction(() => { const el = document.querySelector(".chat-flow-scroll"); return el.scrollHeight - el.clientHeight - el.scrollTop <= 1; }); |
| 39 | const box = await scroll.boundingBox(); |
| 40 | assert.ok(box); |
| 41 | const center = { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) }; |
| 42 | xdo("mousemove", "--window", windowId, center.x, center.y); |
| 43 | await page.waitForFunction(() => window.nativePointerEvents.some(event => event.type === "pointermove")); |
| 44 | const observed = await page.evaluate(() => window.nativePointerEvents.filter(event => event.type === "pointermove").at(-1)); |
| 45 | // Window-manager borders can offset X11 window-relative and DOM client |
| 46 | // coordinates. Measure that transform with a harmless move over content. |
| 47 | const offset = { x: center.x - observed.x, y: center.y - observed.y }; |
| 48 | evidence.coordinateOffset = offset; |
| 49 | const beforeWheel = await top(); |
| 50 | await page.evaluate(() => { window.nativePhase = "wheel"; }); |
| 51 | xdo("click", "--repeat", 4, "--delay", 50, 4); |
| 52 | await page.waitForFunction(before => document.querySelector(".chat-flow-scroll").scrollTop < before - 50, beforeWheel); |
| 53 | await frame(); |
| 54 | const afterWheel = await top(); |
| 55 | evidence.wheel = { before: beforeWheel, after: afterWheel }; |
| 56 | await scroll.evaluate(el => el.focus()); |
| 57 | const beforeKeyboard = await top(); |
| 58 | await page.evaluate(() => { window.nativePhase = "keyboard"; }); |
| 59 | xdo("key", "--clearmodifiers", "Page_Up"); |
| 60 | await page.waitForFunction(before => document.querySelector(".chat-flow-scroll").scrollTop < before - 50, beforeKeyboard); |
| 61 | await frame(); |
| 62 | const afterKeyboard = await top(); |
| 63 | evidence.keyboard = { before: beforeKeyboard, after: afterKeyboard }; |
| 64 | await page.evaluate(() => { window.nativePhase = "position"; }); |
| 65 | xdo("key", "--clearmodifiers", "End"); |
| 66 | await page.waitForFunction(() => { const el = document.querySelector(".chat-flow-scroll"); return el.scrollHeight - el.clientHeight - el.scrollTop <= 1; }); |
| 67 | const track = await scroll.evaluate(el => { |
| 68 | const box = el.getBoundingClientRect(); |
| 69 | // clientLeft includes the reserved left gutter with stable both-edges. |
| 70 | const gutter = el.offsetWidth - el.clientWidth - el.clientLeft - parseFloat(getComputedStyle(el).borderRightWidth); |
| 71 | const thumb = Math.max(gutter, (box.height - 2 * gutter) * el.clientHeight / el.scrollHeight); |
| 72 | return { x: box.right - gutter / 2, y: box.bottom - gutter - thumb / 2, to: box.top + box.height / 2, gutter, top: el.scrollTop }; |
| 73 | }); |
| 74 | assert.ok(track.gutter > 0, "native scrollbar must be exposed"); |
| 75 | evidence.track = track; |
| 76 | evidence.phase = "drag-start"; |
| 77 | await page.evaluate(() => { window.nativePhase = "scrollbar"; }); |
| 78 | xdo("mousemove", "--window", windowId, Math.round(track.x + offset.x), Math.round(track.y + offset.y)); |
| 79 | xdo("mousedown", 1); |
| 80 | try { |
| 81 | for (let step = 1; step <= 12; step++) { |
| 82 | xdo("mousemove", "--window", windowId, Math.round(track.x + offset.x), Math.round(track.y + (track.to - track.y) * step / 12 + offset.y)); |
| 83 | // Do not await renderer frames inside a native pointer transaction: |
| 84 | // the scrollbar's modal drag loop may defer JS until mouse release. |
| 85 | xdo("sleep", "0.03"); |
| 86 | } |
| 87 | } finally { xdo("mouseup", 1); } |
| 88 | evidence.phase = "drag-released"; |
| 89 | await page.waitForFunction(before => document.querySelector(".chat-flow-scroll").scrollTop < before - 100, track.top); |
| 90 | await frame(); |
| 91 | evidence.phase = "drag-observed"; |
| 92 | assert.equal(await scroll.getAttribute("data-scroll-mode"), "reader"); |
| 93 | const samples = await page.evaluate(() => { window.nativeSampling = false; return window.nativeSamples; }); |
| 94 | const heights = samples.map(sample => sample.height); |
| 95 | const extent = { initial: heights[0], min: Math.min(...heights), max: Math.max(...heights), final: heights.at(-1) }; |
| 96 | const blankFrames = samples.filter(sample => sample.blank).length; |
| 97 | const reverseDisplacement = Math.max(0, ...samples.slice(1).map((sample, index) => |
| 98 | sample.phase !== "position" && sample.phase === samples[index].phase ? sample.top - samples[index].top : 0)); |
| 99 | assert.equal(blankFrames, 0, "native input must not expose blank transcript frames"); |
| 100 | assert.ok(extent.max - extent.final <= Math.max(96, samples.at(-1).viewport * 0.5), "native input must not collapse the scroll range"); |
| 101 | return Object.assign(evidence, { status: "passed", method: "X11 XTest through xdotool", wheel: { before: beforeWheel, after: afterWheel }, |
| 102 | keyboard: { before: beforeKeyboard, after: afterKeyboard }, scrollbar: { before: track.top, after: await top(), gutter: track.gutter }, |
| 103 | extent, blankFrames, reverseDisplacement, samples }); |
| 104 | } finally { |
| 105 | Object.assign(evidence, await page.evaluate(() => { |
| 106 | window.nativeSampling = false; |
| 107 | return { samples: window.nativeSamples, pointerEvents: window.nativePointerEvents, screen: { |
| 108 | innerWidth, innerHeight, outerWidth, outerHeight, devicePixelRatio, screenX, screenY, |
| 109 | } }; |
| 110 | })); |
| 111 | } |
| 112 | } |
| 113 |