a73x

c719ac17

feat: handle osc 52 in muxweb

a73x   2026-08-18 12:27

Commit message
feat: handle osc 52 in muxweb

web/index.html
Old New
@@ -27,6 +27,13 @@
27 border-bottom: 1px solid #262a33; user-select: none; 27 border-bottom: 1px solid #262a33; user-select: none;
28 } 28 }
29 .tile header .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 29 .tile header .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
30 .copy-request {
31 display: none; margin: 0 8px; padding: 0 6px; border: 1px solid #4a5261;
32 border-radius: 3px; background: transparent; color: var(--fg);
33 font: inherit; font-size: 11px; cursor: pointer;
34 }
35 .copy-request.on { display: inline-block; }
36 .copy-request.error { border-color: #8b3d3d; background: #3d1a1a; color: #e07a7a; }
30 .badge { padding: 0 6px; border-radius: 3px; font-size: 11px; } 37 .badge { padding: 0 6px; border-radius: 3px; font-size: 11px; }
31 .badge.connecting, .badge.reconnecting { background: #4a3b12; color: #e8c35a; } 38 .badge.connecting, .badge.reconnecting { background: #4a3b12; color: #e8c35a; }
32 .badge.up { background: #16351f; color: #6fce8a; } 39 .badge.up { background: #16351f; color: #6fce8a; }
web/mux.js
Old New
@@ -77,6 +77,22 @@ function colorOf(packed, dflt) {
77 return dflt; 77 return dflt;
78 } 78 }
79 79
80 // The shared Zig core has already authenticated OSC 52's target, size,
81 // and base64 alphabet. The browser adds its one platform constraint here:
82 // the decoded clipboard must be valid UTF-8 text.
83 function clipboardText(base64Bytes) {
84 try {
85 let ascii = '';
86 for (const byte of base64Bytes) ascii += String.fromCharCode(byte);
87 const binary = atob(ascii);
88 const decoded = new Uint8Array(binary.length);
89 for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
90 return new TextDecoder('utf-8', { fatal: true }).decode(decoded);
91 } catch (_) {
92 return null;
93 }
94 }
95
80 // One cell's font metrics, measured once against the real font. 96 // One cell's font metrics, measured once against the real font.
81 const METRICS = (() => { 97 const METRICS = (() => {
82 const c = document.createElement('canvas').getContext('2d'); 98 const c = document.createElement('canvas').getContext('2d');
@@ -111,12 +127,19 @@ class Tile {
111 // it back (see renderBadge). 127 // it back (see renderBadge).
112 this.status = 'connecting'; 128 this.status = 'connecting';
113 this.statusText = 'connecting'; 129 this.statusText = 'connecting';
130 this.pendingClipboard = null;
131 this.clipboardVersion = 0;
114 132
115 this.el = document.createElement('div'); 133 this.el = document.createElement('div');
116 this.el.className = 'tile'; 134 this.el.className = 'tile';
117 this.el.innerHTML = 135 this.el.innerHTML =
118 `<header><span class="label"></span><span class="badge connecting">connecting</span></header>`; 136 `<header><span class="label"></span><button class="copy-request" type="button">Copy</button><span class="badge connecting">connecting</span></header>`;
119 this.el.querySelector('.label').textContent = `${idx}: ${label}`; 137 this.el.querySelector('.label').textContent = `${idx}: ${label}`;
138 this.copyButton = this.el.querySelector('.copy-request');
139 this.copyButton.addEventListener('click', (ev) => {
140 ev.stopPropagation();
141 this.copyPendingClipboard();
142 });
120 this.canvas = document.createElement('canvas'); 143 this.canvas = document.createElement('canvas');
121 this.el.appendChild(this.canvas); 144 this.el.appendChild(this.canvas);
122 this.ctx = this.canvas.getContext('2d'); 145 this.ctx = this.canvas.getContext('2d');
@@ -428,7 +451,56 @@ class Tile {
428 } 451 }
429 } 452 }
430 453
431 onClipboardEffect() {} 454 onClipboardEffect() {
455 if (!this.zoomed) return;
456
457 // The semantic core's clipboard payload borrows the staging buffer.
458 // Copy it before calling or awaiting anything: either can move WASM
459 // memory or let the next frame replace the borrowed bytes.
460 const ptr = this.core.mux_clipboard_ptr();
461 const len = this.core.mux_clipboard_len();
462 const encoded = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len);
463 const text = clipboardText(encoded);
464 if (text === null) return;
465
466 const version = ++this.clipboardVersion;
467 this.pendingClipboard = text;
468 this.copyButton.className = 'copy-request';
469 this.copyButton.textContent = 'Copy';
470 return this.tryClipboardWrite(version, true);
471 }
472
473 async tryClipboardWrite(version, automatic) {
474 if (version !== this.clipboardVersion || this.pendingClipboard === null) return;
475 const text = this.pendingClipboard;
476 try {
477 const writeText = navigator.clipboard?.writeText;
478 if (typeof writeText !== 'function') throw new Error('clipboard unavailable');
479 await writeText.call(navigator.clipboard, text);
480 } catch (_) {
481 if (version !== this.clipboardVersion || this.pendingClipboard !== text) return;
482 this.copyButton.className = automatic
483 ? 'copy-request on'
484 : 'copy-request on error';
485 this.copyButton.textContent = automatic ? 'Copy' : 'Copy failed';
486 return;
487 }
488
489 if (version !== this.clipboardVersion || this.pendingClipboard !== text) return;
490 this.pendingClipboard = null;
491 this.copyButton.className = 'copy-request on';
492 this.copyButton.textContent = 'Copied';
493 setTimeout(() => {
494 if (version !== this.clipboardVersion || this.pendingClipboard !== null) return;
495 this.copyButton.className = 'copy-request';
496 this.copyButton.textContent = 'Copy';
497 }, 1200);
498 }
499
500 copyPendingClipboard() {
501 if (!this.zoomed || this.pendingClipboard === null) return;
502 return this.tryClipboardWrite(this.clipboardVersion, false);
503 }
432 504
433 // --- painting --- 505 // --- painting ---
434 // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it 506 // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it
@@ -647,6 +719,12 @@ function zoom(tile) {
647 function unzoom() { 719 function unzoom() {
648 if (!zoomedTile) return; 720 if (!zoomedTile) return;
649 const was = zoomedTile; 721 const was = zoomedTile;
722 // Invalidate every outstanding write and feedback timer before any
723 // exit-scroll or wall reflow work can run.
724 was.clipboardVersion++;
725 was.pendingClipboard = null;
726 was.copyButton.className = 'copy-request';
727 was.copyButton.textContent = 'Copy';
650 was.exitScroll(); 728 was.exitScroll();
651 was.zoomed = false; 729 was.zoomed = false;
652 was.el.classList.remove('zoomed'); 730 was.el.classList.remove('zoomed');
web/verify.js
Old New
@@ -295,6 +295,318 @@ function wasmCalls(source) {
295 )].sort(); 295 )].sort();
296 } 296 }
297 297
298 function deferred() {
299 let resolve, reject;
300 const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
301 return { promise, resolve, reject };
302 }
303
304 function browserShell(source) {
305 class FakeClassList {
306 constructor(owner) { this.owner = owner; }
307 add(...names) {
308 const values = new Set(this.owner.className.split(/\s+/).filter(Boolean));
309 for (const name of names) values.add(name);
310 this.owner.className = [...values].join(' ');
311 }
312 remove(...names) {
313 const gone = new Set(names);
314 this.owner.className = this.owner.className.split(/\s+/)
315 .filter((name) => name && !gone.has(name)).join(' ');
316 }
317 }
318
319 class FakeElement {
320 constructor(tagName) {
321 this.tagName = tagName.toUpperCase();
322 this.className = '';
323 this.classList = new FakeClassList(this);
324 this.children = [];
325 this.listeners = new Map();
326 this.style = {};
327 this.textContent = '';
328 this.value = '';
329 this.clientWidth = 640;
330 this.type = '';
331 }
332 set innerHTML(value) {
333 this._innerHTML = value;
334 this.children = [];
335 if (!value.includes('<header>')) return;
336 const header = new FakeElement('header');
337 const label = new FakeElement('span');
338 label.className = 'label';
339 const button = new FakeElement('button');
340 button.className = 'copy-request';
341 button.type = /<button[^>]*\btype="([^"]+)"/.exec(value)?.[1] ?? '';
342 button.textContent = /<button[^>]*>([^<]*)<\/button>/.exec(value)?.[1] ?? '';
343 const badge = new FakeElement('span');
344 badge.className = 'badge connecting';
345 badge.textContent = 'connecting';
346 header.appendChild(label);
347 if (value.includes('<button')) header.appendChild(button);
348 header.appendChild(badge);
349 this.appendChild(header);
350 }
351 get innerHTML() { return this._innerHTML ?? ''; }
352 appendChild(child) { this.children.push(child); return child; }
353 querySelector(selector) {
354 const match = (el) => selector.startsWith('.')
355 ? el.className.split(/\s+/).includes(selector.slice(1))
356 : el.tagName.toLowerCase() === selector.toLowerCase();
357 const visit = (el) => {
358 if (match(el)) return el;
359 for (const child of el.children) {
360 const found = visit(child);
361 if (found) return found;
362 }
363 return null;
364 };
365 for (const child of this.children) {
366 const found = visit(child);
367 if (found) return found;
368 }
369 return null;
370 }
371 addEventListener(type, fn) { this.listeners.set(type, fn); }
372 dispatchEvent(type, event) { return this.listeners.get(type)?.(event); }
373 getBoundingClientRect() { return { width: 640, height: 480 }; }
374 getContext() {
375 return {
376 measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }),
377 setTransform() {}, fillRect() {}, fillText() {},
378 };
379 }
380 focus() { this.focused = true; }
381 blur() { this.focused = false; }
382 }
383
384 const elements = {
385 shade: new FakeElement('div'),
386 ime: new FakeElement('input'),
387 wall: new FakeElement('div'),
388 };
389 const document = {
390 createElement: (tag) => new FakeElement(tag),
391 getElementById: (id) => elements[id],
392 addEventListener() {},
393 };
394 const timers = [];
395 const navigator = { clipboard: undefined };
396 const context = vm.createContext({
397 ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder,
398 Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document,
399 location: { host: 'verify.invalid' }, navigator,
400 setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
401 window: { addEventListener() {}, devicePixelRatio: 1 },
402 WebSocket: class { static OPEN = 1; },
403 atob: globalThis.atob,
404 });
405 const beforeBoot = source.split('// --- boot ---')[0];
406 new vm.Script(`${beforeBoot}\n;globalThis.__verify = {\n` +
407 'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' +
408 'unzoom, setZoomedTile(tile) { zoomedTile = tile; }\n' +
409 '};').runInContext(context);
410 return { ...context.__verify, context, document, elements, navigator, timers };
411 }
412
413 async function flushPromises() {
414 await Promise.resolve();
415 await Promise.resolve();
416 }
417
418 async function verifyClipboardShell(shell, html) {
419 const h = browserShell(shell);
420 const bytes = (text) => new Uint8Array(Buffer.from(text, 'ascii'));
421
422 const hasClipboardText = typeof h.clipboardText === 'function';
423 check('shell defines the strict clipboard decoder', hasClipboardText, true);
424 if (!hasClipboardText) return;
425
426 const unicode = 'snowman ☃ and 🌍';
427 check(
428 'clipboard helper decodes Unicode UTF-8',
429 h.clipboardText(bytes(Buffer.from(unicode, 'utf8').toString('base64'))),
430 unicode,
431 );
432 check('clipboard helper rejects invalid UTF-8', h.clipboardText(bytes('/w==')), null);
433 check('clipboard helper rejects malformed base64', h.clipboardText(bytes('%%%=')), null);
434
435 const tileFor = (text, zoomed = true) => {
436 const wall = h.document.createElement('div');
437 const tile = new h.Tile(7, '<unsafe-label>', wall, '<unsafe-session>');
438 const encoded = Buffer.from(text, 'utf8').toString('base64');
439 const memory = { buffer: new ArrayBuffer(128) };
440 new Uint8Array(memory.buffer).set(Buffer.from(encoded, 'ascii'), 17);
441 tile.core = {
442 memory,
443 mux_clipboard_ptr: () => 17,
444 mux_clipboard_len: () => encoded.length,
445 };
446 tile.zoomed = zoomed;
447 return { tile, memory, encoded };
448 };
449
450 const architecture = tileFor('architecture');
451 const hasClipboardState = architecture.tile.pendingClipboard === null
452 && architecture.tile.clipboardVersion === 0;
453 const hasCopyButton = architecture.tile.copyButton?.tagName === 'BUTTON';
454 check('tile initializes clipboard request state', hasClipboardState, true);
455 check('tile constructor stores a real copy button', hasCopyButton, true);
456 if (!hasClipboardState || !hasCopyButton) return;
457 const hasClipboardMethods = typeof architecture.tile.tryClipboardWrite === 'function'
458 && typeof architecture.tile.copyPendingClipboard === 'function';
459 check('tile exposes automatic and manual clipboard paths', hasClipboardMethods, true);
460 if (!hasClipboardMethods) return;
461
462 const ignored = tileFor('ignored', false);
463 h.navigator.clipboard = { writeText: () => { throw new Error('must not write'); } };
464 check('unzoomed clipboard effect returns nothing', ignored.tile.onClipboardEffect(), undefined);
465 check('unzoomed clipboard effect keeps no pending text', ignored.tile.pendingClipboard, null);
466 check('unzoomed clipboard effect does not advance version', ignored.tile.clipboardVersion, 0);
467
468 const invalidEffect = tileFor('valid seed');
469 new Uint8Array(invalidEffect.memory.buffer).set(Buffer.from('/w==', 'ascii'), 17);
470 invalidEffect.tile.core.mux_clipboard_len = () => 4;
471 h.navigator.clipboard = { writeText: () => { throw new Error('must not write'); } };
472 await invalidEffect.tile.onClipboardEffect();
473 check('invalid UTF-8 effect does not create pending UI', invalidEffect.tile.pendingClipboard, null);
474 check('invalid UTF-8 effect does not advance version', invalidEffect.tile.clipboardVersion, 0);
475 check('invalid UTF-8 effect leaves copy control reset', `${invalidEffect.tile.copyButton.className}|${invalidEffect.tile.copyButton.textContent}`, 'copy-request|Copy');
476
477 ignored.tile.pendingClipboard = 'must stay local';
478 h.navigator.clipboard = { writeText: () => { throw new Error('must not write'); } };
479 check('manual clipboard retry is ignored while unzoomed', ignored.tile.copyPendingClipboard(), undefined);
480 check('unzoomed manual retry leaves pending text untouched', ignored.tile.pendingClipboard, 'must stay local');
481
482 const snap = tileFor('snapshot ☃');
483 const snapshotWrite = deferred();
484 const snapshotCalls = [];
485 h.navigator.clipboard = { writeText: (text) => { snapshotCalls.push(text); return snapshotWrite.promise; } };
486 const snapshotRun = snap.tile.onClipboardEffect();
487 new Uint8Array(snap.memory.buffer).fill('A'.charCodeAt(0), 17, 17 + snap.encoded.length);
488 check('zoomed effect snapshots clipboard bytes before await', snap.tile.pendingClipboard, 'snapshot ☃');
489 check('zoomed effect starts automatic write', snapshotCalls.join('|'), 'snapshot ☃');
490 check('zoomed effect advances version', snap.tile.clipboardVersion, 1);
491 snapshotWrite.resolve();
492 await snapshotRun;
493 check('automatic success clears pending text', snap.tile.pendingClipboard, null);
494 check('automatic success shows Copied', snap.tile.copyButton.textContent, 'Copied');
495 check('automatic success makes feedback visible', snap.tile.copyButton.className, 'copy-request on');
496 check('automatic success schedules 1200ms hide', h.timers.at(-1)?.ms, 1200);
497
498 const fallback = tileFor('retry me');
499 h.navigator.clipboard = undefined;
500 await fallback.tile.onClipboardEffect();
501 check('automatic unavailable retains latest pending text', fallback.tile.pendingClipboard, 'retry me');
502 check('automatic unavailable shows Copy', fallback.tile.copyButton.textContent, 'Copy');
503 check('automatic unavailable is not an error', fallback.tile.copyButton.className, 'copy-request on');
504 h.navigator.clipboard = { writeText: () => Promise.reject(new Error('denied')) };
505 await fallback.tile.copyPendingClipboard();
506 check('manual rejection retains pending text', fallback.tile.pendingClipboard, 'retry me');
507 check('manual rejection says Copy failed', fallback.tile.copyButton.textContent, 'Copy failed');
508 check('manual rejection uses error styling', fallback.tile.copyButton.className, 'copy-request on error');
509 h.navigator.clipboard = { writeText: () => Promise.resolve() };
510 await fallback.tile.copyPendingClipboard();
511 check('manual success clears pending text', fallback.tile.pendingClipboard, null);
512 check('manual success shows Copied', fallback.tile.copyButton.textContent, 'Copied');
513
514 const rejected = tileFor('automatic rejection');
515 h.navigator.clipboard = { writeText: () => Promise.reject(new Error('denied')) };
516 await rejected.tile.onClipboardEffect();
517 check('automatic rejection retains pending text', rejected.tile.pendingClipboard, 'automatic rejection');
518 check('automatic rejection shows retry without error', rejected.tile.copyButton.className, 'copy-request on');
519
520 const latest = tileFor('old');
521 const first = deferred(), second = deferred();
522 const writes = [first, second];
523 h.navigator.clipboard = { writeText: () => writes.shift().promise };
524 const firstRun = latest.tile.onClipboardEffect();
525 const newest = Buffer.from('new', 'utf8').toString('base64');
526 new Uint8Array(latest.memory.buffer).fill(0);
527 new Uint8Array(latest.memory.buffer).set(Buffer.from(newest, 'ascii'), 17);
528 latest.tile.core.mux_clipboard_len = () => newest.length;
529 const secondRun = latest.tile.onClipboardEffect();
530 second.reject(new Error('new denied'));
531 await secondRun;
532 first.resolve();
533 await firstRun;
534 check('late old success cannot clear newer pending text', latest.tile.pendingClipboard, 'new');
535 check('late old success cannot overwrite newer UI', latest.tile.copyButton.textContent, 'Copy');
536
537 const lateFailure = tileFor('old failure');
538 const oldFailure = deferred(), newFailure = deferred();
539 const failureWrites = [oldFailure, newFailure];
540 h.navigator.clipboard = { writeText: () => failureWrites.shift().promise };
541 const oldFailureRun = lateFailure.tile.onClipboardEffect();
542 const later = Buffer.from('new failure', 'utf8').toString('base64');
543 new Uint8Array(lateFailure.memory.buffer).fill(0);
544 new Uint8Array(lateFailure.memory.buffer).set(Buffer.from(later, 'ascii'), 17);
545 lateFailure.tile.core.mux_clipboard_len = () => later.length;
546 const newFailureRun = lateFailure.tile.onClipboardEffect();
547 newFailure.reject(new Error('new denied'));
548 await newFailureRun;
549 oldFailure.reject(new Error('old denied'));
550 await oldFailureRun;
551 check('late old failure cannot replace newer pending text', lateFailure.tile.pendingClipboard, 'new failure');
552 check('late old failure cannot overwrite newer UI', lateFailure.tile.copyButton.textContent, 'Copy');
553
554 const timerRace = tileFor('copied first');
555 h.navigator.clipboard = { writeText: () => Promise.resolve() };
556 await timerRace.tile.onClipboardEffect();
557 const hide = h.timers.at(-1);
558 const timerNewest = Buffer.from('pending second', 'utf8').toString('base64');
559 new Uint8Array(timerRace.memory.buffer).fill(0);
560 new Uint8Array(timerRace.memory.buffer).set(Buffer.from(timerNewest, 'ascii'), 17);
561 timerRace.tile.core.mux_clipboard_len = () => timerNewest.length;
562 h.navigator.clipboard = undefined;
563 await timerRace.tile.onClipboardEffect();
564 hide.fn();
565 check('old success timer cannot clear newer pending text', timerRace.tile.pendingClipboard, 'pending second');
566 check('old success timer cannot hide newer retry', timerRace.tile.copyButton.className, 'copy-request on');
567 check('old success timer cannot overwrite newer label', timerRace.tile.copyButton.textContent, 'Copy');
568
569 const leaving = tileFor('leave');
570 const inFlight = deferred();
571 h.navigator.clipboard = { writeText: () => inFlight.promise };
572 const leavingRun = leaving.tile.onClipboardEffect();
573 let reflows = 0;
574 leaving.tile.exitScroll = () => {};
575 leaving.tile.reflow = () => { reflows++; };
576 h.setZoomedTile(leaving.tile);
577 h.unzoom();
578 check('unzoom invalidates clipboard work before reflow', leaving.tile.clipboardVersion, 2);
579 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');
581 check('unzoom still reflows once', reflows, 1);
582 inFlight.resolve();
583 await leavingRun;
584 check('in-flight success after unzoom remains invisible', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy');
585
586 const click = tileFor('click retry');
587 h.navigator.clipboard = undefined;
588 await click.tile.onClipboardEffect();
589 const clickWrites = [];
590 h.navigator.clipboard = { writeText: (text) => { clickWrites.push(text); return Promise.resolve(); } };
591 let stopped = false;
592 click.tile.copyButton.dispatchEvent('click', { stopPropagation: () => { stopped = true; } });
593 await flushPromises();
594 check('copy button click stops tile click propagation', stopped, true);
595 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);
597
598 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');
600 check('tile copy control is a real button', click.tile.copyButton.tagName, 'BUTTON');
601 check('tile copy control has button type', click.tile.copyButton.type, 'button');
602 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);
604
605 check('page styles copy control hidden by default', /\.copy-request\s*\{[^}]*display\s*:\s*none\s*;[^}]*\}/s.test(html), true);
606 check('page styles copy control visible on request', /\.copy-request\.on\s*\{[^}]*display\s*:\s*(?!none)[^;}]+\s*;[^}]*\}/s.test(html), true);
607 check('page gives failed copy a distinct style', /\.copy-request\.error\s*\{[^}]+\}/s.test(html), true);
608 }
609
298 // --- wire builders (layouts golden-pinned in protocol.zig) --- 610 // --- wire builders (layouts golden-pinned in protocol.zig) ---
299 function snapshotPayload({ seq, history, cols, rows, epoch }, state) { 611 function snapshotPayload({ seq, history, cols, rows, epoch }, state) {
300 const stateBytes = Buffer.from(state, 'utf8'); 612 const stateBytes = Buffer.from(state, 'utf8');
@@ -557,6 +869,8 @@ async function main() {
557 // export renamed on the Zig side is a TypeError in the browser and 869 // export renamed on the Zig side is a TypeError in the browser and
558 // nowhere else, and the wasm builds fine without it. 870 // nowhere else, and the wasm builds fine without it.
559 const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); 871 const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8');
872 const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
873 await verifyClipboardShell(shell, html);
560 874
561 // Mutation fixture for the source checks below. A correct-looking route 875 // Mutation fixture for the source checks below. A correct-looking route
562 // in either kind of comment must be invisible, while live literal 876 // in either kind of comment must be invisible, while live literal
@@ -749,7 +1063,7 @@ async function main() {
749 const clipboardMethods = balancedBodiesAfter(executableShell, /\bonClipboardEffect\s*\([^)]*\)\s*\{/g); 1063 const clipboardMethods = balancedBodiesAfter(executableShell, /\bonClipboardEffect\s*\([^)]*\)\s*\{/g);
750 const emptyClipboardMethods = clipboardMethods.filter((method) => method.body?.trim() === ''); 1064 const emptyClipboardMethods = clipboardMethods.filter((method) => method.body?.trim() === '');
751 check('shell has exactly one live clipboard effect method', clipboardMethods.length, 1); 1065 check('shell has exactly one live clipboard effect method', clipboardMethods.length, 1);
752 check('shell clipboard effect is the one empty placeholder', emptyClipboardMethods.length, 1); 1066 check('shell clipboard effect is implemented, not a placeholder', emptyClipboardMethods.length, 0);
753 1067
754 const sendPasteMethods = balancedBodiesAfter(executableShell, /\bsendPaste\s*\([^)]*\)\s*\{/g); 1068 const sendPasteMethods = balancedBodiesAfter(executableShell, /\bsendPaste\s*\([^)]*\)\s*\{/g);
755 const sendPasteBodies = balancedBodiesAfter(executableShell, /\bsendPaste\s*\(\s*text\s*\)\s*\{/g); 1069 const sendPasteBodies = balancedBodiesAfter(executableShell, /\bsendPaste\s*\(\s*text\s*\)\s*\{/g);