4c1110f1
fix: serialize web clipboard writes
a73x 2026-08-18 12:27
Commit message
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -129,6 +129,7 @@ class Tile { | |||
| 129 | this.statusText = 'connecting'; | 129 | this.statusText = 'connecting'; |
| 130 | this.pendingClipboard = null; | 130 | this.pendingClipboard = null; |
| 131 | this.clipboardVersion = 0; | 131 | this.clipboardVersion = 0; |
| 132 | this.clipboardWriteActive = false; | ||
| 132 | 133 | ||
| 133 | this.el = document.createElement('div'); | 134 | this.el = document.createElement('div'); |
| 134 | this.el.className = 'tile'; | 135 | this.el.className = 'tile'; |
| @@ -139,6 +140,7 @@ class Tile { | |||
| 139 | this.copyButton.addEventListener('click', (ev) => { | 140 | this.copyButton.addEventListener('click', (ev) => { |
| 140 | ev.stopPropagation(); | 141 | ev.stopPropagation(); |
| 141 | this.copyPendingClipboard(); | 142 | this.copyPendingClipboard(); |
| 143 | ime.focus(); | ||
| 142 | }); | 144 | }); |
| 143 | this.canvas = document.createElement('canvas'); | 145 | this.canvas = document.createElement('canvas'); |
| 144 | this.el.appendChild(this.canvas); | 146 | this.el.appendChild(this.canvas); |
| @@ -471,14 +473,31 @@ class Tile { | |||
| 471 | } | 473 | } |
| 472 | 474 | ||
| 473 | async tryClipboardWrite(version, automatic) { | 475 | async tryClipboardWrite(version, automatic) { |
| 476 | if (this.clipboardWriteActive) return; | ||
| 474 | if (version !== this.clipboardVersion || this.pendingClipboard === null) return; | 477 | if (version !== this.clipboardVersion || this.pendingClipboard === null) return; |
| 475 | const text = this.pendingClipboard; | 478 | const text = this.pendingClipboard; |
| 479 | this.clipboardWriteActive = true; | ||
| 480 | let succeeded = false; | ||
| 476 | try { | 481 | try { |
| 477 | const writeText = navigator.clipboard?.writeText; | 482 | const writeText = navigator.clipboard?.writeText; |
| 478 | if (typeof writeText !== 'function') throw new Error('clipboard unavailable'); | 483 | if (typeof writeText !== 'function') throw new Error('clipboard unavailable'); |
| 479 | await writeText.call(navigator.clipboard, text); | 484 | await writeText.call(navigator.clipboard, text); |
| 485 | succeeded = true; | ||
| 480 | } catch (_) { | 486 | } catch (_) { |
| 481 | if (version !== this.clipboardVersion || this.pendingClipboard !== text) return; | 487 | // Expected platform failures are rendered below, never allowed to |
| 488 | // reject the promise launched by onMessage or the button handler. | ||
| 489 | } | ||
| 490 | this.clipboardWriteActive = false; | ||
| 491 | |||
| 492 | // A newer event owns the one pending slot. Serialize its write after | ||
| 493 | // this one and skip every intermediate value it already replaced. | ||
| 494 | if (version !== this.clipboardVersion || this.pendingClipboard !== text) { | ||
| 495 | if (this.zoomed && this.pendingClipboard !== null) | ||
| 496 | return this.tryClipboardWrite(this.clipboardVersion, true); | ||
| 497 | return; | ||
| 498 | } | ||
| 499 | |||
| 500 | if (!succeeded) { | ||
| 482 | this.copyButton.className = automatic | 501 | this.copyButton.className = automatic |
| 483 | ? 'copy-request on' | 502 | ? 'copy-request on' |
| 484 | : 'copy-request on error'; | 503 | : 'copy-request on error'; |
| @@ -486,7 +505,6 @@ class Tile { | |||
| 486 | return; | 505 | return; |
| 487 | } | 506 | } |
| 488 | 507 | ||
| 489 | if (version !== this.clipboardVersion || this.pendingClipboard !== text) return; | ||
| 490 | this.pendingClipboard = null; | 508 | this.pendingClipboard = null; |
| 491 | this.copyButton.className = 'copy-request on'; | 509 | this.copyButton.className = 'copy-request on'; |
| 492 | this.copyButton.textContent = 'Copied'; | 510 | this.copyButton.textContent = 'Copied'; |
| @@ -745,6 +763,21 @@ document.addEventListener('keydown', (ev) => { | |||
| 745 | const t = zoomedTile; | 763 | const t = zoomedTile; |
| 746 | if (!t) return; | 764 | if (!t) return; |
| 747 | if (ev.isComposing) return; // IME owns it; compositionend delivers | 765 | if (ev.isComposing) return; // IME owns it; compositionend delivers |
| 766 | // A visible clipboard retry is real browser chrome inside the tile. | ||
| 767 | // Let its native Enter/Space activation and Tab navigation work rather | ||
| 768 | // than translating those keys into terminal input. | ||
| 769 | if (ev.target === t.copyButton && | ||
| 770 | (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar' || ev.key === 'Tab')) return; | ||
| 771 | // The hidden IME normally owns focus, so plain Tab would otherwise be | ||
| 772 | // encoded for the terminal. When a retry is visible, use that one key | ||
| 773 | // to make the recovery control keyboard reachable. | ||
| 774 | if (ev.target === ime && ev.key === 'Tab' && | ||
| 775 | !ev.shiftKey && !ev.altKey && !ev.ctrlKey && !ev.metaKey && | ||
| 776 | t.pendingClipboard !== null && t.copyButton.classList.contains('on')) { | ||
| 777 | ev.preventDefault(); | ||
| 778 | t.copyButton.focus(); | ||
| 779 | return; | ||
| 780 | } | ||
| 748 | const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0); | 781 | const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0); |
| 749 | // Leave genuine browser chords alone (copy/paste arrive as events): | 782 | // Leave genuine browser chords alone (copy/paste arrive as events): |
| 750 | // Ctrl+Shift+C/V everywhere, and on macOS the WHOLE Cmd family, which | 783 | // Ctrl+Shift+C/V everywhere, and on macOS the WHOLE Cmd family, which |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -301,7 +301,13 @@ function deferred() { | |||
| 301 | return { promise, resolve, reject }; | 301 | return { promise, resolve, reject }; |
| 302 | } | 302 | } |
| 303 | 303 | ||
| 304 | function executableCss(source) { | ||
| 305 | return source.replace(/\/\*[\s\S]*?\*\//g, (comment) => | ||
| 306 | comment.replace(/[^\n\r]/g, ' ')); | ||
| 307 | } | ||
| 308 | |||
| 304 | function browserShell(source) { | 309 | function browserShell(source) { |
| 310 | let activeElement = null; | ||
| 305 | class FakeClassList { | 311 | class FakeClassList { |
| 306 | constructor(owner) { this.owner = owner; } | 312 | constructor(owner) { this.owner = owner; } |
| 307 | add(...names) { | 313 | add(...names) { |
| @@ -314,6 +320,7 @@ function browserShell(source) { | |||
| 314 | this.owner.className = this.owner.className.split(/\s+/) | 320 | this.owner.className = this.owner.className.split(/\s+/) |
| 315 | .filter((name) => name && !gone.has(name)).join(' '); | 321 | .filter((name) => name && !gone.has(name)).join(' '); |
| 316 | } | 322 | } |
| 323 | contains(name) { return this.owner.className.split(/\s+/).includes(name); } | ||
| 317 | } | 324 | } |
| 318 | 325 | ||
| 319 | class FakeElement { | 326 | class FakeElement { |
| @@ -377,8 +384,8 @@ function browserShell(source) { | |||
| 377 | setTransform() {}, fillRect() {}, fillText() {}, | 384 | setTransform() {}, fillRect() {}, fillText() {}, |
| 378 | }; | 385 | }; |
| 379 | } | 386 | } |
| 380 | focus() { this.focused = true; } | 387 | focus() { activeElement = this; } |
| 381 | blur() { this.focused = false; } | 388 | blur() { if (activeElement === this) activeElement = null; } |
| 382 | } | 389 | } |
| 383 | 390 | ||
| 384 | const elements = { | 391 | const elements = { |
| @@ -386,10 +393,13 @@ function browserShell(source) { | |||
| 386 | ime: new FakeElement('input'), | 393 | ime: new FakeElement('input'), |
| 387 | wall: new FakeElement('div'), | 394 | wall: new FakeElement('div'), |
| 388 | }; | 395 | }; |
| 396 | const documentListeners = new Map(); | ||
| 389 | const document = { | 397 | const document = { |
| 390 | createElement: (tag) => new FakeElement(tag), | 398 | createElement: (tag) => new FakeElement(tag), |
| 391 | getElementById: (id) => elements[id], | 399 | getElementById: (id) => elements[id], |
| 392 | addEventListener() {}, | 400 | get activeElement() { return activeElement; }, |
| 401 | addEventListener(type, fn) { documentListeners.set(type, fn); }, | ||
| 402 | dispatchEvent(type, event) { return documentListeners.get(type)?.(event); }, | ||
| 393 | }; | 403 | }; |
| 394 | const timers = []; | 404 | const timers = []; |
| 395 | const navigator = { clipboard: undefined }; | 405 | const navigator = { clipboard: undefined }; |
| @@ -411,8 +421,10 @@ function browserShell(source) { | |||
| 411 | } | 421 | } |
| 412 | 422 | ||
| 413 | async function flushPromises() { | 423 | async function flushPromises() { |
| 414 | await Promise.resolve(); | 424 | // Some clipboard fakes deliberately use then/finally to model the |
| 415 | await Promise.resolve(); | 425 | // external side effect and active-call accounting. Drain enough turns |
| 426 | // for the Tile's await continuation and a serialized follow-up to run. | ||
| 427 | for (let i = 0; i < 8; i++) await Promise.resolve(); | ||
| 416 | } | 428 | } |
| 417 | 429 | ||
| 418 | async function verifyClipboardShell(shell, html) { | 430 | async function verifyClipboardShell(shell, html) { |
| @@ -449,7 +461,8 @@ async function verifyClipboardShell(shell, html) { | |||
| 449 | 461 | ||
| 450 | const architecture = tileFor('architecture'); | 462 | const architecture = tileFor('architecture'); |
| 451 | const hasClipboardState = architecture.tile.pendingClipboard === null | 463 | const hasClipboardState = architecture.tile.pendingClipboard === null |
| 452 | && architecture.tile.clipboardVersion === 0; | 464 | && architecture.tile.clipboardVersion === 0 |
| 465 | && architecture.tile.clipboardWriteActive === false; | ||
| 453 | const hasCopyButton = architecture.tile.copyButton?.tagName === 'BUTTON'; | 466 | const hasCopyButton = architecture.tile.copyButton?.tagName === 'BUTTON'; |
| 454 | check('tile initializes clipboard request state', hasClipboardState, true); | 467 | check('tile initializes clipboard request state', hasClipboardState, true); |
| 455 | check('tile constructor stores a real copy button', hasCopyButton, true); | 468 | check('tile constructor stores a real copy button', hasCopyButton, true); |
| @@ -518,38 +531,61 @@ async function verifyClipboardShell(shell, html) { | |||
| 518 | check('automatic rejection shows retry without error', rejected.tile.copyButton.className, 'copy-request on'); | 531 | check('automatic rejection shows retry without error', rejected.tile.copyButton.className, 'copy-request on'); |
| 519 | 532 | ||
| 520 | const latest = tileFor('old'); | 533 | const latest = tileFor('old'); |
| 521 | const first = deferred(), second = deferred(); | 534 | const serial = { |
| 522 | const writes = [first, second]; | 535 | active: 0, maxActive: 0, system: null, calls: [], |
| 523 | h.navigator.clipboard = { writeText: () => writes.shift().promise }; | 536 | writeText(text) { |
| 524 | const firstRun = latest.tile.onClipboardEffect(); | 537 | const done = deferred(); |
| 525 | const newest = Buffer.from('new', 'utf8').toString('base64'); | 538 | this.calls.push({ text, done }); |
| 526 | new Uint8Array(latest.memory.buffer).fill(0); | 539 | this.active++; |
| 527 | new Uint8Array(latest.memory.buffer).set(Buffer.from(newest, 'ascii'), 17); | 540 | this.maxActive = Math.max(this.maxActive, this.active); |
| 528 | latest.tile.core.mux_clipboard_len = () => newest.length; | 541 | return done.promise.then(() => { this.system = text; }).finally(() => { this.active--; }); |
| 529 | const secondRun = latest.tile.onClipboardEffect(); | 542 | }, |
| 530 | second.reject(new Error('new denied')); | 543 | }; |
| 531 | await secondRun; | 544 | h.navigator.clipboard = { writeText: serial.writeText.bind(serial) }; |
| 532 | first.resolve(); | 545 | const latestRun = latest.tile.onClipboardEffect(); |
| 533 | await firstRun; | 546 | for (const value of ['middle one', 'middle two', 'newest']) { |
| 534 | check('late old success cannot clear newer pending text', latest.tile.pendingClipboard, 'new'); | 547 | const encoded = Buffer.from(value, 'utf8').toString('base64'); |
| 535 | check('late old success cannot overwrite newer UI', latest.tile.copyButton.textContent, 'Copy'); | 548 | new Uint8Array(latest.memory.buffer).fill(0); |
| 536 | 549 | new Uint8Array(latest.memory.buffer).set(Buffer.from(encoded, 'ascii'), 17); | |
| 537 | const lateFailure = tileFor('old failure'); | 550 | latest.tile.core.mux_clipboard_len = () => encoded.length; |
| 538 | const oldFailure = deferred(), newFailure = deferred(); | 551 | latest.tile.onClipboardEffect(); |
| 539 | const failureWrites = [oldFailure, newFailure]; | 552 | } |
| 540 | h.navigator.clipboard = { writeText: () => failureWrites.shift().promise }; | 553 | check('unresolved clipboard writes have one external call', serial.calls.length, 1); |
| 541 | const oldFailureRun = lateFailure.tile.onClipboardEffect(); | 554 | check('unresolved clipboard writes retain only latest pending text', latest.tile.pendingClipboard, 'newest'); |
| 542 | const later = Buffer.from('new failure', 'utf8').toString('base64'); | 555 | check('clipboard writes never overlap before settlement', serial.maxActive, 1); |
| 543 | new Uint8Array(lateFailure.memory.buffer).fill(0); | 556 | serial.calls[0].done.resolve(); |
| 544 | new Uint8Array(lateFailure.memory.buffer).set(Buffer.from(later, 'ascii'), 17); | 557 | await flushPromises(); |
| 545 | lateFailure.tile.core.mux_clipboard_len = () => later.length; | 558 | check('settled old write starts exactly one coalesced follow-up', serial.calls.length, 2); |
| 546 | const newFailureRun = lateFailure.tile.onClipboardEffect(); | 559 | check('coalesced follow-up skips intermediate clipboard values', serial.calls[1]?.text, 'newest'); |
| 547 | newFailure.reject(new Error('new denied')); | 560 | check('old success cannot overwrite newer pending UI', `${latest.tile.copyButton.className}|${latest.tile.copyButton.textContent}`, 'copy-request|Copy'); |
| 548 | await newFailureRun; | 561 | serial.calls[1].done.resolve(); |
| 549 | oldFailure.reject(new Error('old denied')); | 562 | await latestRun; |
| 550 | await oldFailureRun; | 563 | check('serialized clipboard writes have maximum concurrency one', serial.maxActive, 1); |
| 551 | check('late old failure cannot replace newer pending text', lateFailure.tile.pendingClipboard, 'new failure'); | 564 | check('serialized external clipboard finishes with newest value', serial.system, 'newest'); |
| 552 | check('late old failure cannot overwrite newer UI', lateFailure.tile.copyButton.textContent, 'Copy'); | 565 | check('newest serialized success clears pending text', latest.tile.pendingClipboard, null); |
| 566 | |||
| 567 | const afterFailure = tileFor('old failure'); | ||
| 568 | const failedSerial = { calls: [] }; | ||
| 569 | h.navigator.clipboard = { writeText: (text) => { | ||
| 570 | const done = deferred(); | ||
| 571 | failedSerial.calls.push({ text, done }); | ||
| 572 | return done.promise; | ||
| 573 | } }; | ||
| 574 | const afterFailureRun = afterFailure.tile.onClipboardEffect(); | ||
| 575 | const afterFailureText = Buffer.from('latest after failure', 'utf8').toString('base64'); | ||
| 576 | new Uint8Array(afterFailure.memory.buffer).fill(0); | ||
| 577 | new Uint8Array(afterFailure.memory.buffer).set(Buffer.from(afterFailureText, 'ascii'), 17); | ||
| 578 | afterFailure.tile.core.mux_clipboard_len = () => afterFailureText.length; | ||
| 579 | afterFailure.tile.onClipboardEffect(); | ||
| 580 | check('new value queues behind unresolved old write', failedSerial.calls.length, 1); | ||
| 581 | failedSerial.calls[0].done.reject(new Error('old denied')); | ||
| 582 | await flushPromises(); | ||
| 583 | check('old failure starts only latest queued value', failedSerial.calls[1]?.text, 'latest after failure'); | ||
| 584 | check('stale old failure does not show fallback UI', `${afterFailure.tile.copyButton.className}|${afterFailure.tile.copyButton.textContent}`, 'copy-request|Copy'); | ||
| 585 | failedSerial.calls[1].done.reject(new Error('latest denied')); | ||
| 586 | await afterFailureRun; | ||
| 587 | check('latest failure retains latest pending request', afterFailure.tile.pendingClipboard, 'latest after failure'); | ||
| 588 | check('latest automatic failure shows fallback UI', `${afterFailure.tile.copyButton.className}|${afterFailure.tile.copyButton.textContent}`, 'copy-request on|Copy'); | ||
| 553 | 589 | ||
| 554 | const timerRace = tileFor('copied first'); | 590 | const timerRace = tileFor('copied first'); |
| 555 | h.navigator.clipboard = { writeText: () => Promise.resolve() }; | 591 | h.navigator.clipboard = { writeText: () => Promise.resolve() }; |
| @@ -568,32 +604,78 @@ async function verifyClipboardShell(shell, html) { | |||
| 568 | 604 | ||
| 569 | const leaving = tileFor('leave'); | 605 | const leaving = tileFor('leave'); |
| 570 | const inFlight = deferred(); | 606 | const inFlight = deferred(); |
| 571 | h.navigator.clipboard = { writeText: () => inFlight.promise }; | 607 | const leavingCalls = []; |
| 608 | h.navigator.clipboard = { writeText: (text) => { leavingCalls.push(text); return inFlight.promise; } }; | ||
| 572 | const leavingRun = leaving.tile.onClipboardEffect(); | 609 | const leavingRun = leaving.tile.onClipboardEffect(); |
| 610 | const queuedAfterLeave = Buffer.from('queued then left', 'utf8').toString('base64'); | ||
| 611 | new Uint8Array(leaving.memory.buffer).fill(0); | ||
| 612 | new Uint8Array(leaving.memory.buffer).set(Buffer.from(queuedAfterLeave, 'ascii'), 17); | ||
| 613 | leaving.tile.core.mux_clipboard_len = () => queuedAfterLeave.length; | ||
| 614 | leaving.tile.onClipboardEffect(); | ||
| 615 | check('unresolved write keeps queued latest out of clipboard API', leavingCalls.length, 1); | ||
| 573 | let reflows = 0; | 616 | let reflows = 0; |
| 574 | leaving.tile.exitScroll = () => {}; | 617 | leaving.tile.exitScroll = () => {}; |
| 575 | leaving.tile.reflow = () => { reflows++; }; | 618 | leaving.tile.reflow = () => { reflows++; }; |
| 576 | h.setZoomedTile(leaving.tile); | 619 | h.setZoomedTile(leaving.tile); |
| 577 | h.unzoom(); | 620 | h.unzoom(); |
| 578 | check('unzoom invalidates clipboard work before reflow', leaving.tile.clipboardVersion, 2); | 621 | check('unzoom invalidates clipboard work before reflow', leaving.tile.clipboardVersion, 3); |
| 579 | check('unzoom clears pending clipboard text', leaving.tile.pendingClipboard, null); | 622 | check('unzoom clears pending clipboard text', leaving.tile.pendingClipboard, null); |
| 580 | check('unzoom hides and resets copy button', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy'); | 623 | check('unzoom hides and resets copy button', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy'); |
| 581 | check('unzoom still reflows once', reflows, 1); | 624 | check('unzoom still reflows once', reflows, 1); |
| 582 | inFlight.resolve(); | 625 | inFlight.resolve(); |
| 583 | await leavingRun; | 626 | await leavingRun; |
| 627 | check('unzoom prevents queued clipboard follow-up', leavingCalls.length, 1); | ||
| 584 | check('in-flight success after unzoom remains invisible', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy'); | 628 | check('in-flight success after unzoom remains invisible', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy'); |
| 585 | 629 | ||
| 586 | const click = tileFor('click retry'); | 630 | const click = tileFor('click retry'); |
| 587 | h.navigator.clipboard = undefined; | 631 | h.navigator.clipboard = undefined; |
| 588 | await click.tile.onClipboardEffect(); | 632 | await click.tile.onClipboardEffect(); |
| 633 | h.setZoomedTile(click.tile); | ||
| 634 | const keyCalls = []; | ||
| 635 | click.tile.sendKey = (...args) => { keyCalls.push(args.join(',')); }; | ||
| 636 | let tabPrevented = false; | ||
| 637 | h.elements.ime.focus(); | ||
| 638 | h.document.dispatchEvent('keydown', { | ||
| 639 | key: 'Tab', target: h.elements.ime, | ||
| 640 | preventDefault: () => { tabPrevented = true; }, | ||
| 641 | }); | ||
| 642 | check('visible retry Tab is captured for button focus', tabPrevented, true); | ||
| 643 | check('visible retry Tab focuses copy button', h.document.activeElement, click.tile.copyButton); | ||
| 644 | check('visible retry Tab emits no terminal key', keyCalls.length, 0); | ||
| 645 | |||
| 646 | for (const key of ['Enter', ' ', 'Tab']) { | ||
| 647 | let prevented = false; | ||
| 648 | h.document.dispatchEvent('keydown', { | ||
| 649 | key, target: click.tile.copyButton, | ||
| 650 | preventDefault: () => { prevented = true; }, | ||
| 651 | }); | ||
| 652 | check(`copy button ${JSON.stringify(key)} keeps native behavior`, prevented, false); | ||
| 653 | } | ||
| 654 | check('copy button native keys emit no terminal bytes', keyCalls.length, 0); | ||
| 655 | |||
| 589 | const clickWrites = []; | 656 | const clickWrites = []; |
| 590 | h.navigator.clipboard = { writeText: (text) => { clickWrites.push(text); return Promise.resolve(); } }; | 657 | h.navigator.clipboard = { writeText: (text) => { clickWrites.push(text); return Promise.resolve(); } }; |
| 591 | let stopped = false; | 658 | let stopped = false; |
| 659 | click.tile.copyButton.focus(); | ||
| 592 | click.tile.copyButton.dispatchEvent('click', { stopPropagation: () => { stopped = true; } }); | 660 | click.tile.copyButton.dispatchEvent('click', { stopPropagation: () => { stopped = true; } }); |
| 593 | await flushPromises(); | 661 | await flushPromises(); |
| 594 | check('copy button click stops tile click propagation', stopped, true); | 662 | check('copy button click stops tile click propagation', stopped, true); |
| 595 | check('copy button click takes manual clipboard path', clickWrites.join('|'), 'click retry'); | 663 | check('copy button click takes manual clipboard path', clickWrites.join('|'), 'click retry'); |
| 596 | check('copy button manual path clears pending on success', click.tile.pendingClipboard, null); | 664 | check('copy button manual path clears pending on success', click.tile.pendingClipboard, null); |
| 665 | check('copy button interaction restores IME focus', h.document.activeElement, h.elements.ime); | ||
| 666 | |||
| 667 | const terminalTab = tileFor('terminal tab'); | ||
| 668 | h.setZoomedTile(terminalTab.tile); | ||
| 669 | const terminalKeys = []; | ||
| 670 | terminalTab.tile.sendKey = (...args) => { terminalKeys.push(args.join(',')); }; | ||
| 671 | let terminalTabPrevented = false; | ||
| 672 | h.elements.ime.focus(); | ||
| 673 | h.document.dispatchEvent('keydown', { | ||
| 674 | key: 'Tab', target: h.elements.ime, | ||
| 675 | preventDefault: () => { terminalTabPrevented = true; }, | ||
| 676 | }); | ||
| 677 | check('hidden fallback preserves terminal Tab prevention', terminalTabPrevented, true); | ||
| 678 | check('hidden fallback preserves terminal Tab encoding', terminalKeys.join('|'), '2,0,0'); | ||
| 597 | 679 | ||
| 598 | const header = click.tile.el.querySelector('header'); | 680 | const header = click.tile.el.querySelector('header'); |
| 599 | check('tile header places copy button between label and badge', header.children.map((el) => el.className).join('|'), 'label|copy-request on|badge connecting'); | 681 | check('tile header places copy button between label and badge', header.children.map((el) => el.className).join('|'), 'label|copy-request on|badge connecting'); |
| @@ -602,9 +684,21 @@ async function verifyClipboardShell(shell, html) { | |||
| 602 | check('tile header does not interpolate label into innerHTML', click.tile.el.innerHTML.includes('<unsafe-label>'), false); | 684 | check('tile header does not interpolate label into innerHTML', click.tile.el.innerHTML.includes('<unsafe-label>'), false); |
| 603 | check('tile header does not interpolate session into innerHTML', click.tile.el.innerHTML.includes('<unsafe-session>'), false); | 685 | check('tile header does not interpolate session into innerHTML', click.tile.el.innerHTML.includes('<unsafe-session>'), false); |
| 604 | 686 | ||
| 605 | check('page styles copy control hidden by default', /\.copy-request\s*\{[^}]*display\s*:\s*none\s*;[^}]*\}/s.test(html), true); | 687 | const hiddenRule = /\.copy-request\s*\{[^}]*display\s*:\s*none\s*;[^}]*\}/s; |
| 606 | check('page styles copy control visible on request', /\.copy-request\.on\s*\{[^}]*display\s*:\s*(?!none)[^;}]+\s*;[^}]*\}/s.test(html), true); | 688 | const visibleRule = /\.copy-request\.on\s*\{[^}]*display\s*:\s*(?!none)[^;}]+\s*;[^}]*\}/s; |
| 607 | check('page gives failed copy a distinct style', /\.copy-request\.error\s*\{[^}]+\}/s.test(html), true); | 689 | const errorRule = /\.copy-request\.error\s*\{[^}]+\}/s; |
| 690 | const commentedRules = executableCss(`/* | ||
| 691 | .copy-request { display: none; } | ||
| 692 | .copy-request.on { display: inline-block; } | ||
| 693 | .copy-request.error { color: red; } | ||
| 694 | */`); | ||
| 695 | check('CSS masker rejects commented hidden rule decoy', hiddenRule.test(commentedRules), false); | ||
| 696 | check('CSS masker rejects commented visible rule decoy', visibleRule.test(commentedRules), false); | ||
| 697 | check('CSS masker rejects commented error rule decoy', errorRule.test(commentedRules), false); | ||
| 698 | const executableHtmlCss = executableCss(html); | ||
| 699 | check('page styles copy control hidden by default', hiddenRule.test(executableHtmlCss), true); | ||
| 700 | check('page styles copy control visible on request', visibleRule.test(executableHtmlCss), true); | ||
| 701 | check('page gives failed copy a distinct style', errorRule.test(executableHtmlCss), true); | ||
| 608 | } | 702 | } |
| 609 | 703 | ||
| 610 | // --- wire builders (layouts golden-pinned in protocol.zig) --- | 704 | // --- wire builders (layouts golden-pinned in protocol.zig) --- |