b41f8a73
test: pin web selection lifecycle and interrupt behaviour
a73x 2026-08-18 12:27
Commit message
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -529,8 +529,16 @@ async function verifyClipboardShell(shell, html) { | |||
| 529 | const snapshotCalls = []; | 529 | const snapshotCalls = []; |
| 530 | h.navigator.clipboard = { writeText: (text) => { snapshotCalls.push(text); return snapshotWrite.promise; } }; | 530 | h.navigator.clipboard = { writeText: (text) => { snapshotCalls.push(text); return snapshotWrite.promise; } }; |
| 531 | const snapshotRun = snap.tile.onClipboardEffect(); | 531 | const snapshotRun = snap.tile.onClipboardEffect(); |
| 532 | // The staging bytes are reused the instant that call returns, and the | ||
| 533 | // pending text below is already the decoded value: the DECODE landed | ||
| 534 | // before the first await. That is the whole of what this can pin. It is | ||
| 535 | // deliberately NOT a claim about the `.slice` that takes the bytes — | ||
| 536 | // nothing runs between the copy and `clipboardText`, so swapping in | ||
| 537 | // `.subarray` is invisible to every check in this file (measured). The | ||
| 538 | // copy is still correct, because a view is only safe while nothing can | ||
| 539 | // grow linear memory; it is simply not a property a test can hold up. | ||
| 532 | new Uint8Array(snap.memory.buffer).fill('A'.charCodeAt(0), 17, 17 + snap.encoded.length); | 540 | new Uint8Array(snap.memory.buffer).fill('A'.charCodeAt(0), 17, 17 + snap.encoded.length); |
| 533 | check('zoomed effect snapshots clipboard bytes before await', snap.tile.pendingClipboard, 'snapshot ☃'); | 541 | check('zoomed effect decodes clipboard text before any await', snap.tile.pendingClipboard, 'snapshot ☃'); |
| 534 | check('zoomed effect starts automatic write', snapshotCalls.join('|'), 'snapshot ☃'); | 542 | check('zoomed effect starts automatic write', snapshotCalls.join('|'), 'snapshot ☃'); |
| 535 | check('zoomed effect advances version', snap.tile.clipboardVersion, 1); | 543 | check('zoomed effect advances version', snap.tile.clipboardVersion, 1); |
| 536 | snapshotWrite.resolve(); | 544 | snapshotWrite.resolve(); |
| @@ -985,6 +993,20 @@ async function verifySelectionShell(shell, html) { | |||
| 985 | check('session exit rolls pending scroll request back', exitLifecycle.tile.scrollRequest, null); | 993 | check('session exit rolls pending scroll request back', exitLifecycle.tile.scrollRequest, null); |
| 986 | check('session exit clears pending scroll timeout', exitTimeout?.cleared, true); | 994 | check('session exit clears pending scroll timeout', exitTimeout?.cleared, true); |
| 987 | 995 | ||
| 996 | const controlClearsSelection = makeTile(); | ||
| 997 | controlClearsSelection.tile.selection = { | ||
| 998 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 61, text: 'daemon leg dropped', | ||
| 999 | }; | ||
| 1000 | controlClearsSelection.tile.onMessage(reconnectingControl); | ||
| 1001 | check('daemon reconnect lifecycle clears the retained selection', controlClearsSelection.tile.selection, null); | ||
| 1002 | |||
| 1003 | const exitClearsSelection = makeTile(); | ||
| 1004 | exitClearsSelection.tile.selection = { | ||
| 1005 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 62, text: 'outlived the shell', | ||
| 1006 | }; | ||
| 1007 | exitClearsSelection.tile.onMessage(Uint8Array.from([0, 0x82, 1, 0, 0, 0, 0])); | ||
| 1008 | check('session exit clears the retained selection', exitClearsSelection.tile.selection, null); | ||
| 1009 | |||
| 988 | const countMismatch = makeTile(); | 1010 | const countMismatch = makeTile(); |
| 989 | countMismatch.tile.viewStartRow = 30; | 1011 | countMismatch.tile.viewStartRow = 30; |
| 990 | countMismatch.tile.requestedViewStartRow = 30; | 1012 | countMismatch.tile.requestedViewStartRow = 30; |
| @@ -1229,6 +1251,21 @@ async function verifySelectionShell(shell, html) { | |||
| 1229 | check('matching scrollback echo becomes the displayed start row', scrolling.tile.viewStartRow, 25); | 1251 | check('matching scrollback echo becomes the displayed start row', scrolling.tile.viewStartRow, 25); |
| 1230 | check('scrollback echo paints the history viewport', paintedScroll, 1); | 1252 | check('scrollback echo paints the history viewport', paintedScroll, 1); |
| 1231 | 1253 | ||
| 1254 | // A wheel tick arriving while a fetch is outstanding must not replace | ||
| 1255 | // scrollRequest and scrollRequestTimer without clearing the old timer: | ||
| 1256 | // that leaks one setTimeout per tick and leaves a rollback armed for a | ||
| 1257 | // request nobody is waiting on any more. | ||
| 1258 | const wheelDuringFetch = makeTile(); | ||
| 1259 | wheelDuringFetch.tile.viewStartRow = 30; | ||
| 1260 | wheelDuringFetch.tile.changeScrollPages(1); | ||
| 1261 | const inFlightScroll = wheelDuringFetch.tile.scrollRequest; | ||
| 1262 | const inFlightScrollTimer = wheelDuringFetch.tile.scrollRequestTimer; | ||
| 1263 | wheelDuringFetch.tile.onWheel({ deltaY: -1 }); | ||
| 1264 | check('a wheel tick during a pending fetch sends nothing', wheelDuringFetch.sent.length, 1); | ||
| 1265 | check('a wheel tick during a pending fetch keeps the one request', wheelDuringFetch.tile.scrollRequest, inFlightScroll); | ||
| 1266 | check('a wheel tick during a pending fetch keeps its one timer', wheelDuringFetch.tile.scrollRequestTimer, inFlightScrollTimer); | ||
| 1267 | check('a wheel tick during a pending fetch does not move page intent', wheelDuringFetch.tile.scrollPages, 1); | ||
| 1268 | |||
| 1232 | const failedStage = makeTile(); | 1269 | const failedStage = makeTile(); |
| 1233 | failedStage.tile.scrollPages = 1; | 1270 | failedStage.tile.scrollPages = 1; |
| 1234 | failedStage.tile.viewStartRow = 30; | 1271 | failedStage.tile.viewStartRow = 30; |
| @@ -1940,6 +1977,24 @@ async function verifySelectionShell(shell, html) { | |||
| 1940 | check('Ctrl+C without selection sends one PTY frame', terminalCopy.sent[0]?.type, 0x02); | 1977 | check('Ctrl+C without selection sends one PTY frame', terminalCopy.sent[0]?.type, 0x02); |
| 1941 | check('Ctrl+C fake encoder emits ETX byte', terminalCopy.sent[0]?.payload[0], 3); | 1978 | check('Ctrl+C fake encoder emits ETX byte', terminalCopy.sent[0]?.payload[0], 3); |
| 1942 | 1979 | ||
| 1980 | // The retained selection exists but its reply has not decoded (or came | ||
| 1981 | // back non-ok), so copySelection would return at its own text === null | ||
| 1982 | // guard. Treating "a selection object exists" as "there is something to | ||
| 1983 | // copy" swallows the interrupt for up to the whole response timeout. | ||
| 1984 | const undecodedCopy = makeTile(); | ||
| 1985 | undecodedCopy.tile.selection = authoritativeSelection(null); | ||
| 1986 | undecodedCopy.tile.core.mux_key_encode = () => 1; | ||
| 1987 | undecodedCopy.tile.core.mux_output_len = () => 1; | ||
| 1988 | h.setZoomedTile(undecodedCopy.tile); | ||
| 1989 | const undecodedWrites = []; | ||
| 1990 | h.navigator.clipboard = { writeText: (text) => { undecodedWrites.push(text); return Promise.resolve(); } }; | ||
| 1991 | const undecodedCtrlC = keyEvent({ key: 'c', ctrlKey: true }); | ||
| 1992 | h.document.dispatchEvent('keydown', undecodedCtrlC); | ||
| 1993 | await flushPromises(); | ||
| 1994 | check('Ctrl+C with an undecoded selection still reaches the shell', undecodedCopy.sent[0]?.type, 0x02); | ||
| 1995 | check('Ctrl+C with an undecoded selection sends exactly one frame', undecodedCopy.sent.length, 1); | ||
| 1996 | check('Ctrl+C with an undecoded selection writes no clipboard', undecodedWrites.length, 0); | ||
| 1997 | |||
| 1943 | const altCopy = makeTile(); | 1998 | const altCopy = makeTile(); |
| 1944 | const altKeys = []; | 1999 | const altKeys = []; |
| 1945 | altCopy.tile.selection = authoritativeSelection('must not copy'); | 2000 | altCopy.tile.selection = authoritativeSelection('must not copy'); |