web/verify.js
Ref: Size: 189.9 KiB History
#!/usr/bin/env node
// Fast non-browser smoke for the wasm core (M-web Task 4): drives
// mux_core.wasm through the page's real call sequence — attach payload,
// snapshot frame, delta frame, flat viewport readout, key encoding —
// and asserts styled cells, damage rows, and resume coordinates.
//
// THE DISCIPLINE THIS FILE DEMONSTRATES: every wasm call can grow linear
// memory, and growth detaches every cached ArrayBuffer view. Views are
// re-read from exports.memory.buffer at every access, never cached.
//
// Usage: node web/verify.js [path/to/mux_core.wasm]
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const wasmPath = process.argv[2] ||
path.join(__dirname, '..', 'zig-out', 'bin', 'mux_core.wasm');
let passed = 0, failed = 0;
const clientAction = Object.freeze({
ignored: 0,
terminalModes: 1,
clipboard: 2,
bell: 3,
selection: 4,
});
function check(name, got, want) {
const ok = Object.is(got, want); // SameValue compares BigInts by value
if (ok) { passed++; }
else { failed++; console.error(`FAIL ${name}: got ${got}, want ${want}`); }
}
// Reduce JavaScript to executable tokens: comments and literal contents are
// spaces, while punctuation, identifiers, numbers, and newlines stay put.
// Template expressions recurse back into code. This is intentionally a
// lexer, not a comment-shaped regex: the verifier must not mistake a route
// written in a string/regex for behavior, nor `//` inside a regex for a
// comment. The context tracking covers every form used by mux.js and the
// control-flow/division mutation fixtures below.
function executableJsTokens(source) {
const out = source.split('');
let i = 0;
const mask = (start, end) => {
for (let j = start; j < end; j++) {
if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
}
};
const skipQuoted = (quote) => {
const contentStart = ++i; // preserve the opening quote
while (i < source.length) {
if (source[i] === '\\') { i += 2; continue; }
const c = source[i];
if (c === quote) {
mask(contentStart, i);
i++; // preserve the closing quote
return;
}
if (c === '\n' || c === '\r') {
mask(contentStart, i);
return;
}
i++;
}
mask(contentStart, i);
};
const skipRegex = () => {
const contentStart = ++i; // preserve the opening slash
let inClass = false;
while (i < source.length) {
if (source[i] === '\\') { i += 2; continue; }
const c = source[i];
if (c === '[') inClass = true;
else if (c === ']') inClass = false;
else if (c === '/' && !inClass) {
mask(contentStart, i);
i++; // preserve the closing slash
const flagsStart = i;
while (i < source.length && /[a-z]/i.test(source[i])) i++;
mask(flagsStart, i);
return;
} else if (c === '\n' || c === '\r') {
mask(contentStart, i);
return;
}
i++;
}
mask(contentStart, i);
};
const expressionKeywords = new Set([
'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new',
'of', 'return', 'throw', 'typeof', 'void', 'yield',
]);
const controlKeywords = new Set(['catch', 'for', 'if', 'switch', 'while', 'with']);
const statementKeywords = new Set(['do', 'else', 'finally', 'try']);
let scanCode;
const skipTemplate = () => {
i++; // preserve the opening backtick
let rawStart = i;
while (i < source.length) {
if (source[i] === '\\') { i += 2; continue; }
if (source[i] === '`') {
mask(rawStart, i);
i++; // preserve the closing backtick
return;
}
if (source[i] === '$' && source[i + 1] === '{') {
mask(rawStart, i);
i += 2;
scanCode(true);
rawStart = i;
continue;
}
i++;
}
mask(rawStart, i);
};
scanCode = (templateExpression) => {
let expressionAllowed = true;
let statementExpected = !templateExpression;
let pendingControl = false;
let previousToken = null;
const parenKinds = [];
const braceKinds = [];
while (i < source.length) {
const c = source[i];
const next = source[i + 1];
if (/\s/.test(c)) { i++; continue; }
if (templateExpression && c === '}' && braceKinds.length === 0) { i++; return; }
if (c === '/' && next === '/') {
const start = i;
i += 2;
while (i < source.length && source[i] !== '\n' && source[i] !== '\r') i++;
mask(start, i);
continue;
}
if (c === '/' && next === '*') {
const start = i;
i += 2;
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++;
if (i < source.length) i += 2;
mask(start, i);
continue;
}
if (c === '"' || c === "'") {
skipQuoted(c);
expressionAllowed = false;
statementExpected = false;
previousToken = 'literal';
continue;
}
if (c === '`') {
skipTemplate();
expressionAllowed = false;
statementExpected = false;
previousToken = 'literal';
continue;
}
if (c === '/' && expressionAllowed) {
skipRegex();
expressionAllowed = false;
statementExpected = false;
previousToken = 'literal';
continue;
}
if (/[A-Za-z_$]/.test(c)) {
const start = i++;
while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) i++;
const word = source.slice(start, i);
pendingControl = controlKeywords.has(word);
expressionAllowed = expressionKeywords.has(word);
statementExpected = statementKeywords.has(word);
previousToken = word;
continue;
}
if (/[0-9]/.test(c)) {
i++;
while (i < source.length && /[A-Za-z0-9_.]/.test(source[i])) i++;
expressionAllowed = false;
statementExpected = false;
previousToken = 'number';
continue;
}
if (c === '(') {
parenKinds.push(pendingControl ? 'control' : 'expression');
pendingControl = false;
i++;
expressionAllowed = true;
statementExpected = false;
previousToken = '(';
} else if (c === ')') {
const kind = parenKinds.pop() ?? 'expression';
i++;
expressionAllowed = kind === 'control';
statementExpected = kind === 'control';
previousToken = kind === 'control' ? 'control-close' : ')';
} else if (c === '{') {
const kind = statementExpected || !expressionAllowed || previousToken === '=>'
? 'block'
: 'object';
braceKinds.push(kind);
i++;
expressionAllowed = true;
statementExpected = kind === 'block';
previousToken = '{';
} else if (c === '}') {
const kind = braceKinds.pop() ?? 'block';
i++;
expressionAllowed = kind === 'block';
statementExpected = kind === 'block';
previousToken = kind === 'block' ? 'block-close' : 'object-close';
} else if (c === '[') {
i++;
expressionAllowed = true;
statementExpected = false;
previousToken = '[';
} else if (c === ']') {
i++;
expressionAllowed = false;
statementExpected = false;
previousToken = ']';
} else if (c === '.' || ((c === '+' || c === '-') && next === c)) {
i += next === c ? 2 : 1;
expressionAllowed = false;
statementExpected = false;
previousToken = c === '.' ? '.' : c + next;
} else if (c === '/') {
i += next === '=' ? 2 : 1;
expressionAllowed = true;
statementExpected = false;
previousToken = '/';
} else if (c === ';') {
i++;
expressionAllowed = true;
statementExpected = true;
previousToken = ';';
} else if (c === ',') {
i++;
expressionAllowed = true;
statementExpected = false;
previousToken = ',';
} else if (c === ':' || c === '?') {
i++;
expressionAllowed = true;
statementExpected = c === ':' && braceKinds.at(-1) === 'block';
previousToken = c;
} else if (c === '=' && next === '>') {
i += 2;
expressionAllowed = true;
statementExpected = true;
previousToken = '=>';
} else {
i++;
expressionAllowed = true;
statementExpected = false;
previousToken = c;
}
}
};
scanCode(false);
return out.join('');
}
function balancedBodiesAfter(source, headerPattern) {
return [...source.matchAll(headerPattern)].map((match) => {
const open = match.index + match[0].lastIndexOf('{');
let depth = 1;
for (let at = open + 1; at < source.length; at++) {
if (source[at] === '{') depth++;
else if (source[at] === '}' && --depth === 0) {
return { body: source.slice(open + 1, at), start: open, end: at + 1 };
}
}
return { body: null, start: open, end: source.length };
});
}
function wasmCalls(source) {
const executable = executableJsTokens(source);
return [...new Set(
[...executable.matchAll(/\bcore\.(mux_[a-z_0-9]+)/g)].map((m) => m[1]),
)].sort();
}
function deferred() {
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
function executableCss(source) {
return source.replace(/\/\*[\s\S]*?\*\//g, (comment) =>
comment.replace(/[^\n\r]/g, ' '));
}
// opts.storage models what a browser is allowed to do with localStorage:
// a Map is a working store (prefill it to test what survives a reload),
// 'throws' is Chrome with cookies blocked — which throws on ACCESS, not
// just on write — and 'none' is no localStorage on window at all.
function browserShell(source, opts = {}) {
let activeElement = null;
class FakeClassList {
constructor(owner) { this.owner = owner; }
add(...names) {
const values = new Set(this.owner.className.split(/\s+/).filter(Boolean));
for (const name of names) values.add(name);
this.owner.className = [...values].join(' ');
}
remove(...names) {
const gone = new Set(names);
this.owner.className = this.owner.className.split(/\s+/)
.filter((name) => name && !gone.has(name)).join(' ');
}
contains(name) { return this.owner.className.split(/\s+/).includes(name); }
}
class FakeElement {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.className = '';
this.classList = new FakeClassList(this);
this.children = [];
this.listeners = new Map();
this.style = {};
this.textContent = '';
this.value = '';
this.clientWidth = 640;
this.type = '';
this.rect = { left: 0, top: 0, width: 640, height: 480 };
this.capturedPointers = new Set();
// Real DOM nodes carry one; Tile stamps its id here so reorder can
// read the wall's order back out of the document.
this.dataset = {};
}
set innerHTML(value) {
this._innerHTML = value;
this.children = [];
if (!value.includes('<header>')) return;
const header = new FakeElement('header');
const label = new FakeElement('span');
label.className = 'label';
const badge = new FakeElement('span');
badge.className = 'badge connecting';
badge.textContent = 'connecting';
header.appendChild(label);
// Every button in the markup, classes read from the markup: a
// hardcoded single .copy-request is how the .close/.spawn buttons
// arrived unconstructable and Tile broke only under this harness.
for (const m of value.matchAll(/<button([^>]*)>([^<]*)<\/button>/g)) {
const button = new FakeElement('button');
button.className = /\bclass="([^"]+)"/.exec(m[1])?.[1] ?? '';
button.type = /\btype="([^"]+)"/.exec(m[1])?.[1] ?? '';
button.textContent = m[2];
header.appendChild(button);
}
header.appendChild(badge);
this.appendChild(header);
}
get innerHTML() { return this._innerHTML ?? ''; }
appendChild(child) { this.children.push(child); return child; }
// Real nodes contain themselves, and the page asks this of the add
// tile to decide whether a key belongs to a form field or a terminal.
contains(node) {
if (node === this) return true;
return this.children.some((child) => child.contains(node));
}
removeAttribute() {}
querySelector(selector) {
const match = (el) => selector.startsWith('.')
? el.className.split(/\s+/).includes(selector.slice(1))
: el.tagName.toLowerCase() === selector.toLowerCase();
const visit = (el) => {
if (match(el)) return el;
for (const child of el.children) {
const found = visit(child);
if (found) return found;
}
return null;
};
for (const child of this.children) {
const found = visit(child);
if (found) return found;
}
return null;
}
// Keyed by type, ONE listener each: the page registers exactly one per
// (element, type) today, and silently keeping only the last would mean
// a future double-registration was tested in name only.
addEventListener(type, fn) {
if (this.listeners.has(type))
throw new Error(`FakeElement: second ${type} listener on <${this.tagName}>`);
this.listeners.set(type, fn);
}
dispatchEvent(type, event) { return this.listeners.get(type)?.(event); }
getBoundingClientRect() { return this.rect; }
setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); }
hasPointerCapture(pointerId) { return this.capturedPointers.has(pointerId); }
releasePointerCapture(pointerId) {
this.capturedPointers.delete(pointerId);
this.dispatchEvent('lostpointercapture', { pointerId });
}
getContext() {
// measureText answers the font it was ASKED about: a fixed 8/11/3
// made every font assertion vacuous, since the page's whole font
// feature is "the cell changed size". Scaled from the 14px default
// so the geometry every other test pins stays exactly 8/11/3.
const ctx = {
font: '',
measureText() {
const px = parseFloat(this.font) || 14;
return {
width: 8 * px / 14,
fontBoundingBoxAscent: 11 * px / 14,
fontBoundingBoxDescent: 3 * px / 14,
};
},
setTransform() {}, clearRect() {}, fillRect() {}, fillText() {},
};
return ctx;
}
focus() { activeElement = this; }
blur() { if (activeElement === this) activeElement = null; }
}
const elements = {
shade: new FakeElement('div'),
ime: new FakeElement('input'),
wall: new FakeElement('div'),
};
const documentElement = new FakeElement('html');
// A CSS custom property is set through the style object, not assigned:
// the page publishes terminal ground this way and the fake has to see it.
documentElement.style.setProperty = (name, value) => { documentElement.style[name] = value; };
const documentListeners = new Map();
const document = {
createElement: (tag) => new FakeElement(tag),
getElementById: (id) => elements[id],
documentElement,
get activeElement() { return activeElement; },
addEventListener(type, fn) { documentListeners.set(type, fn); },
dispatchEvent(type, event) { return documentListeners.get(type)?.(event); },
};
const timers = [];
const intervals = [];
const navigator = { clipboard: undefined };
const windowFake = { addEventListener() {}, devicePixelRatio: 1 };
if (opts.storage !== 'none') {
const store = opts.storage instanceof Map ? opts.storage : new Map();
const localStorage = {
getItem: (key) => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
};
if (opts.storage === 'throws') {
Object.defineProperty(windowFake, 'localStorage', {
get() { throw new Error('SecurityError: storage is denied'); },
});
} else {
windowFake.localStorage = localStorage;
}
}
const context = vm.createContext({
ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder,
Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document,
location: { host: 'verify.invalid' }, navigator,
setTimeout: (fn, ms) => { timers.push({ fn, ms, cleared: false }); return timers.length; },
clearTimeout: (id) => { if (timers[id - 1]) timers[id - 1].cleared = true; },
setInterval: (fn, ms) => { intervals.push({ fn, ms, cleared: false }); return intervals.length; },
clearInterval: (id) => { if (intervals[id - 1]) intervals[id - 1].cleared = true; },
window: windowFake,
WebSocket: class { static OPEN = 1; },
atob: globalThis.atob,
});
const beforeBoot = source.split('// --- boot ---')[0];
new vm.Script(`${beforeBoot}\n;globalThis.__verify = {\n` +
'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' +
'unzoom, setZoomedTile(tile) { zoomedTile = tile; }, ' +
'setSettingsTile(el) { settingsTileEl = el; }, ' +
'spawnPollDelay: typeof spawnPollDelay === "function" ? spawnPollDelay : undefined, ' +
'SPAWN_POLL_MS: typeof SPAWN_POLL_MS === "number" ? SPAWN_POLL_MS : undefined, ' +
'parseGhosttyTheme: typeof parseGhosttyTheme === "function" ? parseGhosttyTheme : undefined, ' +
'applySettings: typeof applySettings === "function" ? applySettings : undefined, ' +
'loadSettings: typeof loadSettings === "function" ? loadSettings : undefined, ' +
'saveSettings: typeof saveSettings === "function" ? saveSettings : undefined, ' +
'THEME_BYTES_MAX: typeof THEME_BYTES_MAX === "number" ? THEME_BYTES_MAX : undefined, ' +
// The render globals are re-bound by applySettings, so they are read
// through a call: a captured copy would pin the value at boot and
// pass however badly the settings failed to land.
'settingsState: () => ({ FONT, METRICS, PALETTE, DEFAULT_FG, DEFAULT_BG, CURSOR_FILL, SELECTION_FILL })\n' +
'};').runInContext(context);
return {
...context.__verify, context, document, documentElement, elements, navigator, timers, intervals,
// A tile the settings tests can paint through: not registered in the
// page's own map, so applySettings' reflow loop never reaches it and
// each assertion paints exactly when it says it does.
makeSettingsTile() {
const tile = new context.__verify.Tile(9, 'settings fixture', document.createElement('div'), '');
tile.core = {
mux_cols: () => 10,
mux_rows: () => 5,
mux_history_rows: () => 0,
mux_cursor_x: () => 1,
mux_cursor_y: () => 2,
mux_mark_all_dirty() {},
};
tile.scrollPages = 0;
return tile;
},
};
}
async function flushPromises() {
// Some clipboard fakes deliberately use then/finally to model the
// external side effect and active-call accounting. Drain enough turns
// for the Tile's await continuation and a serialized follow-up to run.
for (let i = 0; i < 8; i++) await Promise.resolve();
}
async function verifyClipboardShell(shell, html) {
const h = browserShell(shell);
const bytes = (text) => new Uint8Array(Buffer.from(text, 'ascii'));
const hasClipboardText = typeof h.clipboardText === 'function';
check('shell defines the strict clipboard decoder', hasClipboardText, true);
if (!hasClipboardText) return;
const unicode = 'snowman ☃ and 🌍';
check(
'clipboard helper decodes Unicode UTF-8',
h.clipboardText(bytes(Buffer.from(unicode, 'utf8').toString('base64'))),
unicode,
);
check('clipboard helper rejects invalid UTF-8', h.clipboardText(bytes('/w==')), null);
check('clipboard helper rejects malformed base64', h.clipboardText(bytes('%%%=')), null);
const tileFor = (text, zoomed = true) => {
const wall = h.document.createElement('div');
const tile = new h.Tile(7, '<unsafe-label>', wall, '<unsafe-session>');
const encoded = Buffer.from(text, 'utf8').toString('base64');
const memory = { buffer: new ArrayBuffer(128) };
new Uint8Array(memory.buffer).set(Buffer.from(encoded, 'ascii'), 17);
tile.core = {
memory,
mux_clipboard_target: () => 'c'.charCodeAt(0),
mux_clipboard_ptr: () => 17,
mux_clipboard_len: () => encoded.length,
};
tile.zoomed = zoomed;
return { tile, memory, encoded };
};
const architecture = tileFor('architecture');
const hasClipboardState = architecture.tile.pendingClipboard === null
&& architecture.tile.clipboardVersion === 0
&& architecture.tile.clipboardWriteActive === false;
const hasCopyButton = architecture.tile.copyButton?.tagName === 'BUTTON';
check('tile initializes clipboard request state', hasClipboardState, true);
check('tile constructor stores a real copy button', hasCopyButton, true);
if (!hasClipboardState || !hasCopyButton) return;
const hasClipboardMethods = typeof architecture.tile.tryClipboardWrite === 'function'
&& typeof architecture.tile.copyPendingClipboard === 'function';
check('tile exposes automatic and manual clipboard paths', hasClipboardMethods, true);
if (!hasClipboardMethods) return;
const ignored = tileFor('ignored', false);
h.navigator.clipboard = { writeText: () => { throw new Error('must not write'); } };
check('unzoomed clipboard effect returns nothing', ignored.tile.onClipboardEffect(), undefined);
check('unzoomed clipboard effect keeps no pending text', ignored.tile.pendingClipboard, null);
check('unzoomed clipboard effect does not advance version', ignored.tile.clipboardVersion, 0);
const invalidEffect = tileFor('valid seed');
new Uint8Array(invalidEffect.memory.buffer).set(Buffer.from('/w==', 'ascii'), 17);
invalidEffect.tile.core.mux_clipboard_len = () => 4;
h.navigator.clipboard = { writeText: () => { throw new Error('must not write'); } };
await invalidEffect.tile.onClipboardEffect();
check('invalid UTF-8 effect does not create pending UI', invalidEffect.tile.pendingClipboard, null);
check('invalid UTF-8 effect does not advance version', invalidEffect.tile.clipboardVersion, 0);
check('invalid UTF-8 effect leaves copy control reset', `${invalidEffect.tile.copyButton.className}|${invalidEffect.tile.copyButton.textContent}`, 'copy-request|Copy');
// The protocol accepts every Pc the native client can route, but this
// page has exactly one destination. `p` is what an ordinary X11 mouse
// drag writes, and proxying it here would clobber the browser user's
// clipboard from a program that only ever asked for PRIMARY.
const primaryTarget = tileFor('PRIMARY, not the clipboard');
primaryTarget.tile.core.mux_clipboard_target = () => 'p'.charCodeAt(0);
h.navigator.clipboard = { writeText: () => { throw new Error('must not write'); } };
await primaryTarget.tile.onClipboardEffect();
check('non-c OSC 52 target creates no pending clipboard text', primaryTarget.tile.pendingClipboard, null);
check('non-c OSC 52 target does not advance the clipboard version', primaryTarget.tile.clipboardVersion, 0);
check('non-c OSC 52 target leaves the copy control reset', `${primaryTarget.tile.copyButton.className}|${primaryTarget.tile.copyButton.textContent}`, 'copy-request|Copy');
const clipboardTarget = tileFor('the real clipboard');
const clipboardTargetWrites = [];
h.navigator.clipboard = { writeText: (text) => { clipboardTargetWrites.push(text); return Promise.resolve(); } };
await clipboardTarget.tile.onClipboardEffect();
check('target c OSC 52 still reaches the system clipboard', clipboardTargetWrites.join('|'), 'the real clipboard');
ignored.tile.pendingClipboard = 'must stay local';
h.navigator.clipboard = { writeText: () => { throw new Error('must not write'); } };
check('manual clipboard retry is ignored while unzoomed', ignored.tile.copyPendingClipboard(), undefined);
check('unzoomed manual retry leaves pending text untouched', ignored.tile.pendingClipboard, 'must stay local');
const snap = tileFor('snapshot ☃');
const snapshotWrite = deferred();
const snapshotCalls = [];
h.navigator.clipboard = { writeText: (text) => { snapshotCalls.push(text); return snapshotWrite.promise; } };
const snapshotRun = snap.tile.onClipboardEffect();
// The staging bytes are reused the instant that call returns, and the
// pending text below is already the decoded value: the DECODE landed
// before the first await. That is the whole of what this can pin. It is
// deliberately NOT a claim about the `.slice` that takes the bytes —
// nothing runs between the copy and `clipboardText`, so swapping in
// `.subarray` is invisible to every check in this file (measured). The
// copy is still correct, because a view is only safe while nothing can
// grow linear memory; it is simply not a property a test can hold up.
new Uint8Array(snap.memory.buffer).fill('A'.charCodeAt(0), 17, 17 + snap.encoded.length);
check('zoomed effect decodes clipboard text before any await', snap.tile.pendingClipboard, 'snapshot ☃');
check('zoomed effect starts automatic write', snapshotCalls.join('|'), 'snapshot ☃');
check('zoomed effect advances version', snap.tile.clipboardVersion, 1);
snapshotWrite.resolve();
await snapshotRun;
check('automatic success clears pending text', snap.tile.pendingClipboard, null);
check('automatic success shows Copied', snap.tile.copyButton.textContent, 'Copied');
check('automatic success makes feedback visible', snap.tile.copyButton.className, 'copy-request on');
check('automatic success schedules 1200ms hide', h.timers.at(-1)?.ms, 1200);
// The Tab affordance makes the copy control focusable, and the 1200ms
// timer then hides it. Focus would land on <body>, the hidden IME would
// stop seeing compositionend, and IME input would be dead until a click.
const hiddenUnderFocus = tileFor('hidden while focused');
h.navigator.clipboard = { writeText: () => Promise.resolve() };
await hiddenUnderFocus.tile.onClipboardEffect();
const hiddenUnderFocusTimer = h.timers.at(-1);
hiddenUnderFocus.tile.copyButton.focus();
hiddenUnderFocusTimer.fn();
check('hiding the focused copy control returns focus to the IME', h.document.activeElement, h.elements.ime);
const fallback = tileFor('retry me');
h.navigator.clipboard = undefined;
await fallback.tile.onClipboardEffect();
check('automatic unavailable retains latest pending text', fallback.tile.pendingClipboard, 'retry me');
check('automatic unavailable shows Copy', fallback.tile.copyButton.textContent, 'Copy');
check('automatic unavailable is not an error', fallback.tile.copyButton.className, 'copy-request on');
h.navigator.clipboard = { writeText: () => Promise.reject(new Error('denied')) };
await fallback.tile.copyPendingClipboard();
check('manual rejection retains pending text', fallback.tile.pendingClipboard, 'retry me');
check('manual rejection says Copy failed', fallback.tile.copyButton.textContent, 'Copy failed');
check('manual rejection uses error styling', fallback.tile.copyButton.className, 'copy-request on error');
h.navigator.clipboard = { writeText: () => Promise.resolve() };
await fallback.tile.copyPendingClipboard();
check('manual success clears pending text', fallback.tile.pendingClipboard, null);
check('manual success shows Copied', fallback.tile.copyButton.textContent, 'Copied');
const rejected = tileFor('automatic rejection');
h.navigator.clipboard = { writeText: () => Promise.reject(new Error('denied')) };
await rejected.tile.onClipboardEffect();
check('automatic rejection retains pending text', rejected.tile.pendingClipboard, 'automatic rejection');
check('automatic rejection shows retry without error', rejected.tile.copyButton.className, 'copy-request on');
const latest = tileFor('old');
const serial = {
active: 0, maxActive: 0, system: null, calls: [],
writeText(text) {
const done = deferred();
this.calls.push({ text, done });
this.active++;
this.maxActive = Math.max(this.maxActive, this.active);
return done.promise.then(() => { this.system = text; }).finally(() => { this.active--; });
},
};
h.navigator.clipboard = { writeText: serial.writeText.bind(serial) };
const latestRun = latest.tile.onClipboardEffect();
for (const value of ['middle one', 'middle two', 'newest']) {
const encoded = Buffer.from(value, 'utf8').toString('base64');
new Uint8Array(latest.memory.buffer).fill(0);
new Uint8Array(latest.memory.buffer).set(Buffer.from(encoded, 'ascii'), 17);
latest.tile.core.mux_clipboard_len = () => encoded.length;
latest.tile.onClipboardEffect();
}
check('unresolved clipboard writes have one external call', serial.calls.length, 1);
check('unresolved clipboard writes retain only latest pending text', latest.tile.pendingClipboard, 'newest');
check('clipboard writes never overlap before settlement', serial.maxActive, 1);
serial.calls[0].done.resolve();
await flushPromises();
check('settled old write starts exactly one coalesced follow-up', serial.calls.length, 2);
check('coalesced follow-up skips intermediate clipboard values', serial.calls[1]?.text, 'newest');
check('old success cannot overwrite newer pending UI', `${latest.tile.copyButton.className}|${latest.tile.copyButton.textContent}`, 'copy-request|Copy');
serial.calls[1].done.resolve();
await latestRun;
check('serialized clipboard writes have maximum concurrency one', serial.maxActive, 1);
check('serialized external clipboard finishes with newest value', serial.system, 'newest');
check('newest serialized success clears pending text', latest.tile.pendingClipboard, null);
const afterFailure = tileFor('old failure');
const failedSerial = { calls: [] };
h.navigator.clipboard = { writeText: (text) => {
const done = deferred();
failedSerial.calls.push({ text, done });
return done.promise;
} };
const afterFailureRun = afterFailure.tile.onClipboardEffect();
const afterFailureText = Buffer.from('latest after failure', 'utf8').toString('base64');
new Uint8Array(afterFailure.memory.buffer).fill(0);
new Uint8Array(afterFailure.memory.buffer).set(Buffer.from(afterFailureText, 'ascii'), 17);
afterFailure.tile.core.mux_clipboard_len = () => afterFailureText.length;
afterFailure.tile.onClipboardEffect();
check('new value queues behind unresolved old write', failedSerial.calls.length, 1);
failedSerial.calls[0].done.reject(new Error('old denied'));
await flushPromises();
check('old failure starts only latest queued value', failedSerial.calls[1]?.text, 'latest after failure');
check('stale old failure does not show fallback UI', `${afterFailure.tile.copyButton.className}|${afterFailure.tile.copyButton.textContent}`, 'copy-request|Copy');
failedSerial.calls[1].done.reject(new Error('latest denied'));
await afterFailureRun;
check('latest failure retains latest pending request', afterFailure.tile.pendingClipboard, 'latest after failure');
check('latest automatic failure shows fallback UI', `${afterFailure.tile.copyButton.className}|${afterFailure.tile.copyButton.textContent}`, 'copy-request on|Copy');
const timerRace = tileFor('copied first');
h.navigator.clipboard = { writeText: () => Promise.resolve() };
await timerRace.tile.onClipboardEffect();
const hide = h.timers.at(-1);
const timerNewest = Buffer.from('pending second', 'utf8').toString('base64');
new Uint8Array(timerRace.memory.buffer).fill(0);
new Uint8Array(timerRace.memory.buffer).set(Buffer.from(timerNewest, 'ascii'), 17);
timerRace.tile.core.mux_clipboard_len = () => timerNewest.length;
h.navigator.clipboard = undefined;
await timerRace.tile.onClipboardEffect();
hide.fn();
check('old success timer cannot clear newer pending text', timerRace.tile.pendingClipboard, 'pending second');
check('old success timer cannot hide newer retry', timerRace.tile.copyButton.className, 'copy-request on');
check('old success timer cannot overwrite newer label', timerRace.tile.copyButton.textContent, 'Copy');
const leaving = tileFor('leave');
const inFlight = deferred();
const leavingCalls = [];
h.navigator.clipboard = { writeText: (text) => { leavingCalls.push(text); return inFlight.promise; } };
const leavingRun = leaving.tile.onClipboardEffect();
const queuedAfterLeave = Buffer.from('queued then left', 'utf8').toString('base64');
new Uint8Array(leaving.memory.buffer).fill(0);
new Uint8Array(leaving.memory.buffer).set(Buffer.from(queuedAfterLeave, 'ascii'), 17);
leaving.tile.core.mux_clipboard_len = () => queuedAfterLeave.length;
leaving.tile.onClipboardEffect();
check('unresolved write keeps queued latest out of clipboard API', leavingCalls.length, 1);
let reflows = 0;
leaving.tile.exitScroll = () => {};
leaving.tile.reflow = () => { reflows++; };
h.setZoomedTile(leaving.tile);
h.unzoom();
check('unzoom invalidates clipboard work before reflow', leaving.tile.clipboardVersion, 3);
check('unzoom clears pending clipboard text', leaving.tile.pendingClipboard, null);
check('unzoom releases shared copy-control ownership', leaving.tile.copyUiOwner, null);
check('unzoom hides and resets copy button', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy');
check('unzoom still reflows once', reflows, 1);
inFlight.resolve();
await leavingRun;
check('unzoom prevents queued clipboard follow-up', leavingCalls.length, 1);
check('in-flight success after unzoom remains invisible', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy');
const click = tileFor('click retry');
h.navigator.clipboard = undefined;
await click.tile.onClipboardEffect();
h.setZoomedTile(click.tile);
const keyCalls = [];
click.tile.sendKey = (...args) => { keyCalls.push(args.join(',')); };
let tabPrevented = false;
h.elements.ime.focus();
h.document.dispatchEvent('keydown', {
key: 'Tab', target: h.elements.ime,
preventDefault: () => { tabPrevented = true; },
});
check('visible retry Tab is captured for button focus', tabPrevented, true);
check('visible retry Tab focuses copy button', h.document.activeElement, click.tile.copyButton);
check('visible retry Tab emits no terminal key', keyCalls.length, 0);
for (const key of ['Enter', ' ', 'Tab']) {
let prevented = false;
h.document.dispatchEvent('keydown', {
key, target: click.tile.copyButton,
preventDefault: () => { prevented = true; },
});
check(`copy button ${JSON.stringify(key)} keeps native behavior`, prevented, false);
}
check('copy button native keys emit no terminal bytes', keyCalls.length, 0);
const clickWrites = [];
h.navigator.clipboard = { writeText: (text) => { clickWrites.push(text); return Promise.resolve(); } };
let stopped = false;
click.tile.copyButton.focus();
click.tile.copyButton.dispatchEvent('click', { stopPropagation: () => { stopped = true; } });
await flushPromises();
check('copy button click stops tile click propagation', stopped, true);
check('copy button click takes manual clipboard path', clickWrites.join('|'), 'click retry');
check('copy button manual path clears pending on success', click.tile.pendingClipboard, null);
check('copy button interaction restores IME focus', h.document.activeElement, h.elements.ime);
const terminalTab = tileFor('terminal tab');
h.setZoomedTile(terminalTab.tile);
const terminalKeys = [];
terminalTab.tile.sendKey = (...args) => { terminalKeys.push(args.join(',')); };
let terminalTabPrevented = false;
h.elements.ime.focus();
h.document.dispatchEvent('keydown', {
key: 'Tab', target: h.elements.ime,
preventDefault: () => { terminalTabPrevented = true; },
});
check('hidden fallback preserves terminal Tab prevention', terminalTabPrevented, true);
check('hidden fallback preserves terminal Tab encoding', terminalKeys.join('|'), '2,0,0');
const header = click.tile.el.querySelector('header');
check('tile header places copy button between label and badge', header.children.map((el) => el.className).join('|'), 'label|copy-request on|spawn|badge connecting');
check('tile copy control is a real button', click.tile.copyButton.tagName, 'BUTTON');
check('tile copy control has button type', click.tile.copyButton.type, 'button');
check('tile header does not interpolate label into innerHTML', click.tile.el.innerHTML.includes('<unsafe-label>'), false);
check('tile header does not interpolate session into innerHTML', click.tile.el.innerHTML.includes('<unsafe-session>'), false);
const hiddenRule = /\.copy-request\s*\{[^}]*display\s*:\s*none\s*;[^}]*\}/s;
const visibleRule = /\.copy-request\.on\s*\{[^}]*display\s*:\s*(?!none)[^;}]+\s*;[^}]*\}/s;
const errorRule = /\.copy-request\.error\s*\{[^}]+\}/s;
const commentedRules = executableCss(`/*
.copy-request { display: none; }
.copy-request.on { display: inline-block; }
.copy-request.error { color: red; }
*/`);
check('CSS masker rejects commented hidden rule decoy', hiddenRule.test(commentedRules), false);
check('CSS masker rejects commented visible rule decoy', visibleRule.test(commentedRules), false);
check('CSS masker rejects commented error rule decoy', errorRule.test(commentedRules), false);
const executableHtmlCss = executableCss(html);
check('page styles copy control hidden by default', hiddenRule.test(executableHtmlCss), true);
check('page styles copy control visible on request', visibleRule.test(executableHtmlCss), true);
check('page gives failed copy a distinct style', errorRule.test(executableHtmlCss), true);
}
async function verifySelectionShell(shell, html) {
const h = browserShell(shell);
const wall = h.document.createElement('div');
const tile = new h.Tile(3, 'selection', wall, '');
const initialState = tile.viewStartRow === 0
&& tile.selection === null
&& tile.nextSelectionId === 0
&& tile.drag === null
&& tile.lastPointerX === null
&& tile.lastPointerY === null
&& tile.selectionScrollTimer === null
&& tile.scrollRequest === null
&& tile.scrollRequestTimer === null
&& tile.selectionRequestTimer === null
&& tile.selectionRequestVersion === 0
&& tile.activeSelectionRequest === null
&& tile.selectionFeedbackTimer === null
&& tile.selectionFeedbackVersion === 0
&& tile.selectionCopyVersion === 0;
check('tile initializes retained selection state', initialState, true);
check('tile initializes shared copy-control ownership', tile.copyUiOwner, null);
check(
'tile binds all pointer selection events',
['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'lostpointercapture']
.every((name) => tile.canvas.listeners.has(name)),
true,
);
const executableHtmlCss = executableCss(html);
const selectionCanvasRule = /\.tile\.zoomed\s+canvas\s*\{(?=[^}]*\bcursor\s*:\s*text\s*;)(?=[^}]*\btouch-action\s*:\s*none\s*;)[^}]*\}/s;
const commentedSelectionRule = executableCss(`/*
.tile.zoomed canvas { cursor: text; touch-action: none; }
*/`);
check('CSS masker rejects commented selection-canvas decoy', selectionCanvasRule.test(commentedSelectionRule), false);
check('zoomed canvas uses a text cursor and owns touch dragging', selectionCanvasRule.test(executableHtmlCss), true);
const selectionMethods = [
'cellAtPointer', 'orderedSelection', 'clearSelection', 'paintSelection',
'beginSelection', 'moveSelection', 'endSelection', 'onSelectionReply',
'changeScrollPages', 'autoScrollSelection', 'writeClipboardText', 'copySelection',
];
check(
'tile exposes retained-selection behavior',
selectionMethods.every((name) => typeof tile[name] === 'function'),
true,
);
if (!initialState || !selectionMethods.every((name) => typeof tile[name] === 'function')) return;
const makeTile = () => {
const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), '');
const memory = { buffer: new ArrayBuffer(1024) };
let requestResult = 37;
let scrollFeedResult = 0;
let result = { id: 0, status: 3, historyRows: 30, ptr: 96, len: 0 };
const requestCalls = [];
selected.core = {
memory,
mux_cols: () => 10,
mux_rows: () => 5,
mux_history_rows: () => 30,
mux_mark_all_dirty() {},
mux_read_viewport: () => 0,
mux_read_scroll_viewport: () => 5,
mux_dirty_row: (i) => i,
mux_cursor_x: () => 0,
mux_cursor_y: () => 0,
mux_viewport_ptr: () => 512,
mux_input_cap: () => 1024,
mux_input_ptr: () => 0,
mux_output_ptr: () => 256,
mux_output_len: () => requestResult === 37 ? 37 : 0,
mux_scroll_start: (pages, rows) => 30 - pages * rows,
mux_scroll_feed: () => scrollFeedResult,
mux_init: () => 0,
mux_attach_payload: () => 20,
mux_client_frame: (type) => type === 0x90 ? 4 : 0,
mux_clipboard_target: () => 'c'.charCodeAt(0),
mux_selection_id: () => result.id | 0,
mux_selection_status: () => result.status,
mux_selection_history_rows: () => result.historyRows | 0,
mux_selection_ptr: () => result.ptr,
mux_selection_len: () => result.len,
mux_selection_request: (id, ar, ac, br, bc) => {
requestCalls.push([id >>> 0, ar, ac, br, bc]);
if (requestResult !== 37) return requestResult;
const view = new DataView(memory.buffer, 256, 37);
view.setUint32(0, id, true);
view.setUint32(4, ar, true);
view.setUint16(8, ac, true);
view.setUint32(10, br, true);
view.setUint16(14, bc, true);
return 37;
},
};
selected.zoomed = true;
selected.drawScale = 1;
selected.canvas.rect = { left: 10, top: 20, width: 80, height: 70 };
const sent = [];
selected.sendFrame = (type, payload) => {
sent.push({ type, payload: Uint8Array.from(payload) });
return true;
};
let reflows = 0;
selected.reflow = () => { reflows++; };
return {
tile: selected, memory, requestCalls, sent,
setRequestResult(value) { requestResult = value; },
setScrollFeedResult(value) { scrollFeedResult = value; },
// The default history reading matches mux_history_rows above, so a
// test that does not care about the watermark never trips it.
setResult(id, status, textBytes, historyRows = 30) {
const bytes = Uint8Array.from(textBytes);
new Uint8Array(memory.buffer).set(bytes, 96);
result = { id, status, historyRows, ptr: 96, len: bytes.length };
},
reflows: () => reflows,
};
};
const pointer = (pointerId, col, viewRow, button = 0) => {
let prevented = false;
return {
pointerId, button,
clientX: 10 + col * 8 + 1,
clientY: 20 + viewRow * 14 + 1,
preventDefault() { prevented = true; },
wasPrevented: () => prevented,
};
};
const scrollEnvelope = (start, count = 5, body = [1, 2, 3]) => {
const chunk = new Uint8Array(6 + body.length);
new DataView(chunk.buffer).setUint32(0, start, true);
new DataView(chunk.buffer).setUint16(4, count, true);
chunk.set(body, 6);
const envelope = new Uint8Array(6 + chunk.length);
envelope[0] = 0;
envelope[1] = 0x97;
new DataView(envelope.buffer).setUint32(2, chunk.length, true);
envelope.set(chunk, 6);
return envelope;
};
const frameEnvelope = (type, payload) => {
const envelope = new Uint8Array(6 + payload.length);
envelope[0] = 0;
envelope[1] = type;
new DataView(envelope.buffer).setUint32(2, payload.length, true);
envelope.set(payload, 6);
return envelope;
};
const authorizeSelectionReply = (fixture) =>
fixture.tile.activateSelectionRequest(fixture.tile.selection);
const transport = makeTile();
transport.tile.sendFrame = h.Tile.prototype.sendFrame.bind(transport.tile);
const transportWrites = [];
transport.tile.ws = { readyState: 0, send: (bytes) => transportWrites.push(bytes) };
check('sendFrame reports a closed-socket drop', transport.tile.sendFrame(0x05, new Uint8Array()), false);
transport.tile.ws.readyState = 1;
check('sendFrame reports an open-socket send', transport.tile.sendFrame(0x05, new Uint8Array()), true);
check('open sendFrame writes exactly one envelope', transportWrites.length, 1);
const droppedScroll = makeTile();
droppedScroll.tile.sendFrame = h.Tile.prototype.sendFrame.bind(droppedScroll.tile);
droppedScroll.tile.ws = { readyState: 0, send() { throw new Error('closed socket must not send'); } };
droppedScroll.tile.viewStartRow = 30;
check('closed-socket page request reports no movement', droppedScroll.tile.changeScrollPages(1), false);
check('closed-socket page request immediately rolls page intent back', droppedScroll.tile.scrollPages, 0);
check('closed-socket page request retains no pending request', droppedScroll.tile.scrollRequest, null);
check('closed-socket page request starts no response timeout', droppedScroll.tile.scrollRequestTimer, null);
const timeoutScroll = makeTile();
timeoutScroll.tile.viewStartRow = 30;
timeoutScroll.tile.canvas.dispatchEvent('pointerdown', pointer(25, 2, 2));
timeoutScroll.tile.canvas.dispatchEvent('pointermove', {
...pointer(25, 4, 0), clientY: 10,
});
const timeoutAutoTimer = h.intervals[timeoutScroll.tile.selectionScrollTimer - 1];
timeoutAutoTimer.fn();
const responseTimeout = h.timers[timeoutScroll.tile.scrollRequestTimer - 1];
check('scroll request timeout is bounded to 2000ms', responseTimeout?.ms, 2000);
timeoutAutoTimer.fn();
check('120ms auto-scroll timer does not retry before response timeout', timeoutScroll.sent.length, 1);
responseTimeout.fn();
check('silent scroll server timeout rolls page intent back', timeoutScroll.tile.scrollPages, 0);
check('silent scroll server timeout releases pending request', timeoutScroll.tile.scrollRequest, null);
check('silent scroll server timeout clears timer identity', timeoutScroll.tile.scrollRequestTimer, null);
timeoutAutoTimer.fn();
check('auto-scroll retries once after bounded response timeout', timeoutScroll.sent.length, 2);
timeoutScroll.tile.canvas.dispatchEvent('pointercancel', { pointerId: 25 });
const staleTimeout = makeTile();
staleTimeout.tile.viewStartRow = 30;
staleTimeout.tile.paintScroll = () => {};
staleTimeout.tile.changeScrollPages(1);
const firstTimeout = h.timers[staleTimeout.tile.scrollRequestTimer - 1];
staleTimeout.tile.onMessage(scrollEnvelope(25));
check('successful scroll reply clears its response timeout', firstTimeout?.cleared, true);
staleTimeout.tile.changeScrollPages(1);
const secondRequest = staleTimeout.tile.scrollRequest;
firstTimeout.fn();
check('stale response timeout cannot roll back a later request', staleTimeout.tile.scrollRequest, secondRequest);
check('stale response timeout cannot change later requested page', staleTimeout.tile.scrollPages, 2);
const lifecycleScroll = makeTile();
lifecycleScroll.tile.viewStartRow = 30;
lifecycleScroll.tile.connect();
lifecycleScroll.tile.changeScrollPages(1);
const closeTimeout = h.timers[lifecycleScroll.tile.scrollRequestTimer - 1];
lifecycleScroll.tile.ws.onclose();
check('socket close rolls pending scroll request back', lifecycleScroll.tile.scrollRequest, null);
check('socket close clears pending scroll timeout', closeTimeout?.cleared, true);
lifecycleScroll.tile.changeScrollPages(1);
const openTimeout = h.timers[lifecycleScroll.tile.scrollRequestTimer - 1];
lifecycleScroll.tile.ws.onopen();
check('socket open invalidates pre-open scroll request', lifecycleScroll.tile.scrollRequest, null);
check('socket open clears pre-open scroll timeout', openTimeout?.cleared, true);
const attachScroll = makeTile();
attachScroll.tile.viewStartRow = 30;
attachScroll.tile.changeScrollPages(1);
const attachTimeout = h.timers[attachScroll.tile.scrollRequestTimer - 1];
attachScroll.tile.sendAttach(false);
check('reattach rolls pending scroll request back', attachScroll.tile.scrollRequest, null);
check('reattach clears pending scroll timeout', attachTimeout?.cleared, true);
const controlLifecycle = makeTile();
controlLifecycle.tile.viewStartRow = 30;
controlLifecycle.tile.changeScrollPages(1);
const controlTimeout = h.timers[controlLifecycle.tile.scrollRequestTimer - 1];
const reconnectingControl = Uint8Array.from([
1, ...Buffer.from(JSON.stringify({ state: 'reconnecting' }), 'utf8'),
]);
controlLifecycle.tile.onMessage(reconnectingControl);
check('daemon reconnect lifecycle rolls pending scroll request back', controlLifecycle.tile.scrollRequest, null);
check('daemon reconnect lifecycle clears pending scroll timeout', controlTimeout?.cleared, true);
const exitLifecycle = makeTile();
exitLifecycle.tile.viewStartRow = 30;
exitLifecycle.tile.changeScrollPages(1);
const exitTimeout = h.timers[exitLifecycle.tile.scrollRequestTimer - 1];
exitLifecycle.tile.onMessage(Uint8Array.from([0, 0x82, 1, 0, 0, 0, 0]));
check('session exit rolls pending scroll request back', exitLifecycle.tile.scrollRequest, null);
check('session exit clears pending scroll timeout', exitTimeout?.cleared, true);
const controlClearsSelection = makeTile();
controlClearsSelection.tile.selection = {
anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 61, text: 'daemon leg dropped',
};
controlClearsSelection.tile.onMessage(reconnectingControl);
check('daemon reconnect lifecycle clears the retained selection', controlClearsSelection.tile.selection, null);
const exitClearsSelection = makeTile();
exitClearsSelection.tile.selection = {
anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 62, text: 'outlived the shell',
};
exitClearsSelection.tile.onMessage(Uint8Array.from([0, 0x82, 1, 0, 0, 0, 0]));
check('session exit clears the retained selection', exitClearsSelection.tile.selection, null);
const countMismatch = makeTile();
countMismatch.tile.viewStartRow = 30;
let countPaints = 0;
countMismatch.tile.paintScroll = () => { countPaints++; };
countMismatch.tile.changeScrollPages(1);
countMismatch.tile.onMessage(scrollEnvelope(25, 4));
check('matching start with wrong echoed count is not painted', countPaints, 0);
check('wrong echoed count leaves exact request pending', countMismatch.tile.scrollRequest !== null, true);
countMismatch.tile.onMessage(scrollEnvelope(25, 5));
check('matching start and count paints requested viewport', countPaints, 1);
const resizeMismatch = makeTile();
resizeMismatch.tile.viewStartRow = 30;
resizeMismatch.tile.changeScrollPages(1);
const resizeTimeout = h.timers[resizeMismatch.tile.scrollRequestTimer - 1];
resizeMismatch.tile.zoomCols = () => 11;
resizeMismatch.tile.zoomRows = () => 6;
resizeMismatch.tile.sendResizeIfDiffers();
check('grid resize invalidates old-count scroll request', resizeMismatch.tile.scrollRequest, null);
check('grid resize clears old-count scroll timeout', resizeTimeout?.cleared, true);
const snapshotHistory = makeTile();
let snapshotHistoryRows = 30, snapshotRows = 5;
snapshotHistory.tile.core.mux_history_rows = () => snapshotHistoryRows;
snapshotHistory.tile.core.mux_rows = () => snapshotRows;
snapshotHistory.tile.core.mux_apply_frame = () => {
snapshotHistoryRows = 40;
snapshotRows = 6;
return 0;
};
snapshotHistory.tile.scrollPages = 2;
snapshotHistory.tile.viewStartRow = 20;
snapshotHistory.tile.selection = {
anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 90, text: 'old grid',
};
snapshotHistory.tile.startSelectionRequestTimeout(
snapshotHistory.tile.activateSelectionRequest(snapshotHistory.tile.selection),
);
const snapshotSelectionHandle = snapshotHistory.tile.selectionRequestTimer;
const snapshotSelectionTimer = snapshotSelectionHandle === null
? undefined : h.timers[snapshotSelectionHandle - 1];
snapshotHistory.tile.sizeCanvas = () => {};
let snapshotLivePaints = 0, snapshotScrollPaints = 0;
const realSnapshotLivePaint = h.Tile.prototype.paintLive.bind(snapshotHistory.tile);
snapshotHistory.tile.paintLive = () => { snapshotLivePaints++; realSnapshotLivePaint(); };
snapshotHistory.tile.paintScroll = () => { snapshotScrollPaints++; };
snapshotHistory.tile.onMessage(frameEnvelope(0x95, Uint8Array.from([1, 2, 3])));
check('authoritative snapshot exits settled history mode', snapshotHistory.tile.scrollPages, 0);
check('authoritative snapshot clears old-grid selection', snapshotHistory.tile.selection, null);
check('authoritative snapshot repaints new live geometry exactly once', snapshotLivePaints, 1);
check('authoritative snapshot never repaints stale history scratch', snapshotScrollPaints, 0);
check('snapshot live repaint adopts new history start', snapshotHistory.tile.viewStartRow, 40);
check('snapshot leaves no scroll request timer', snapshotHistory.tile.scrollRequestTimer, null);
check('snapshot clears old-grid selection response timeout', snapshotSelectionTimer?.cleared, true);
check('snapshot leaves no selection response timer identity', snapshotHistory.tile.selectionRequestTimer, null);
check('snapshot lifts history badge after successful apply', snapshotHistory.tile.el.querySelector('.badge').classList.contains('scroll'), false);
check(
'post-snapshot pointer coordinates use new live geometry',
snapshotHistory.tile.cellAtPointer(pointer(30, 4, 5)).row,
45,
);
const failedSnapshot = makeTile();
failedSnapshot.tile.scrollPages = 2;
failedSnapshot.tile.viewStartRow = 20;
failedSnapshot.tile.selection = {
anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 91, text: 'failed old grid',
};
failedSnapshot.tile.core.mux_apply_frame = () => -3;
failedSnapshot.tile.onMessage(frameEnvelope(0x95, Uint8Array.from([9])));
check('failed authoritative snapshot follows reset to live mode', failedSnapshot.tile.scrollPages, 0);
check('failed authoritative snapshot clears stale selection', failedSnapshot.tile.selection, null);
check('failed authoritative snapshot repaints through reset path once', failedSnapshot.reflows(), 1);
const coordinate = makeTile();
coordinate.tile.viewStartRow = 40;
check(
'pointer coordinates include the retained viewport start',
JSON.stringify(coordinate.tile.cellAtPointer(pointer(1, 3, 2))),
JSON.stringify({ col: 3, viewRow: 2, row: 42 }),
);
check(
'pointer coordinates clamp outside the visible grid',
JSON.stringify(coordinate.tile.cellAtPointer({ clientX: -50, clientY: 999 })),
JSON.stringify({ col: 0, viewRow: 4, row: 44 }),
);
coordinate.tile.drawScale = 0.5;
h.context.window.devicePixelRatio = 2;
check(
'fractional canvas scale maps CSS pixels without applying DPR',
JSON.stringify(coordinate.tile.cellAtPointer({ clientX: 23, clientY: 36 })),
JSON.stringify({ col: 3, viewRow: 2, row: 42 }),
);
h.context.window.devicePixelRatio = 1;
const withoutScaleDivisor = shell
.replace('/ this.drawScale / METRICS.w', '/ METRICS.w')
.replace('/ this.drawScale / METRICS.h', '/ METRICS.h');
check(
'fractional-scale mutation removes both scale divisors',
shell.includes('/ this.drawScale / METRICS.w')
&& shell.includes('/ this.drawScale / METRICS.h')
&& !withoutScaleDivisor.includes('/ this.drawScale / METRICS.w')
&& !withoutScaleDivisor.includes('/ this.drawScale / METRICS.h'),
true,
);
const mutatedScale = browserShell(withoutScaleDivisor);
const mutatedTile = new mutatedScale.Tile(
5, 'scale mutation', mutatedScale.document.createElement('div'), '',
);
mutatedTile.core = { mux_cols: () => 10, mux_rows: () => 5 };
mutatedTile.drawScale = 0.5;
mutatedTile.viewStartRow = 40;
mutatedTile.canvas.rect = { left: 10, top: 20, width: 80, height: 70 };
mutatedScale.context.window.devicePixelRatio = 2;
check(
'removing the draw-scale divisor changes real pointer mapping',
JSON.stringify(mutatedTile.cellAtPointer({ clientX: 23, clientY: 36 }))
!== JSON.stringify({ col: 3, viewRow: 2, row: 42 }),
true,
);
const ignored = makeTile();
ignored.tile.zoomed = false;
ignored.tile.canvas.dispatchEvent('pointerdown', pointer(1, 2, 2));
ignored.tile.zoomed = true;
ignored.tile.canvas.dispatchEvent('pointerdown', pointer(2, 2, 2, 1));
check('unzoomed and non-left pointer downs are ignored', ignored.tile.selection, null);
check('ignored pointer downs capture nothing', ignored.tile.canvas.capturedPointers.size, 0);
const forward = makeTile();
forward.tile.viewStartRow = 30;
const down = pointer(7, 2, 1);
const move = pointer(7, 5, 3);
const up = pointer(7, 5, 3);
forward.tile.canvas.dispatchEvent('pointerdown', down);
check('left drag start prevents native canvas selection', down.wasPrevented(), true);
check('left drag captures its pointer', forward.tile.canvas.hasPointerCapture(7), true);
check('left drag starts the 120ms scroll timer', h.intervals.at(-1)?.ms, 120);
forward.tile.canvas.dispatchEvent('pointermove', move);
check('drag move prevents native pointer behavior', move.wasPrevented(), true);
check(
'forward drag retains absolute anchor and active geometry',
JSON.stringify(forward.tile.orderedSelection()),
JSON.stringify([{ col: 2, viewRow: 1, row: 31 }, { col: 5, viewRow: 3, row: 33 }]),
);
forward.tile.canvas.dispatchEvent('pointerup', up);
check('drag release prevents native pointer behavior', up.wasPrevented(), true);
check('drag release gives up pointer capture', forward.tile.canvas.hasPointerCapture(7), false);
check('normal capture release preserves completed selection', forward.tile.selection !== null, true);
check('drag release clears the scroll timer', forward.tile.selectionScrollTimer, null);
check('drag release sends exactly one frame', forward.sent.length, 1);
check('drag release uses selection-request wire type', forward.sent[0]?.type, 0x0b);
check(
'drag release passes forward daemon coordinates once',
JSON.stringify(forward.requestCalls),
JSON.stringify([[1, 31, 2, 33, 5]]),
);
check(
'drag release sends the exact 37-byte request',
Buffer.from(forward.sent[0]?.payload ?? []).toString('hex'),
'010000001f0000000200210000000500000000000000000000000000000000000000000000',
);
const reverse = makeTile();
reverse.tile.viewStartRow = 50;
reverse.tile.canvas.dispatchEvent('pointerdown', pointer(8, 7, 4));
reverse.tile.canvas.dispatchEvent('pointermove', pointer(8, 1, 0));
check(
'reverse drag normalizes retained geometry for painting',
JSON.stringify(reverse.tile.orderedSelection()),
JSON.stringify([{ col: 1, viewRow: 0, row: 50 }, { col: 7, viewRow: 4, row: 54 }]),
);
reverse.tile.canvas.dispatchEvent('pointerup', pointer(8, 1, 0));
check(
'reverse request preserves anchor and active direction for daemon extraction',
JSON.stringify(reverse.requestCalls),
JSON.stringify([[1, 54, 7, 50, 1]]),
);
const clickOnly = makeTile();
clickOnly.tile.selection = { anchor: { row: 1, col: 1 }, active: { row: 2, col: 2 }, requestId: 9, text: 'old' };
clickOnly.tile.canvas.dispatchEvent('pointerdown', pointer(9, 4, 2));
clickOnly.tile.canvas.dispatchEvent('pointerup', pointer(9, 4, 2));
check('click without movement clears retained selection', clickOnly.tile.selection, null);
check('click without movement sends no request', clickOnly.sent.length, 0);
const cancelled = makeTile();
cancelled.tile.canvas.dispatchEvent('pointerdown', pointer(10, 2, 1));
const cancelTimer = cancelled.tile.selectionScrollTimer;
cancelled.tile.canvas.dispatchEvent('pointercancel', { pointerId: 10 });
check('pointer cancel clears retained selection', cancelled.tile.selection, null);
check('pointer cancel clears drag state', cancelled.tile.drag, null);
check('pointer cancel stops the scroll timer', h.intervals[cancelTimer - 1]?.cleared, true);
check('pointer cancel sends no request', cancelled.sent.length, 0);
const lost = makeTile();
lost.tile.canvas.dispatchEvent('pointerdown', pointer(12, 2, 1));
const lostTimer = lost.tile.selectionScrollTimer;
lost.tile.canvas.capturedPointers.delete(12);
lost.tile.canvas.dispatchEvent('lostpointercapture', { pointerId: 12 });
check('unexpected lost pointer capture clears selection', lost.tile.selection, null);
check('unexpected lost pointer capture clears drag state', lost.tile.drag, null);
check('unexpected lost pointer capture clears pointer position', lost.tile.lastPointerY, null);
check('unexpected lost pointer capture stops the timer', h.intervals[lostTimer - 1]?.cleared, true);
check('unexpected lost pointer capture sends no request', lost.sent.length, 0);
const refused = makeTile();
refused.setRequestResult(-3);
refused.tile.canvas.dispatchEvent('pointerdown', pointer(11, 1, 1));
refused.tile.canvas.dispatchEvent('pointermove', pointer(11, 2, 2));
refused.tile.canvas.dispatchEvent('pointerup', pointer(11, 2, 2));
check('failed selection request clears retained selection', refused.tile.selection, null);
check('failed selection request sends no frame', refused.sent.length, 0);
const scrolling = makeTile();
scrolling.tile.scrollPages = 0;
scrolling.tile.viewStartRow = 30;
scrolling.tile.onWheel({ deltaY: -1 });
check('wheel request preserves the currently painted start row', scrolling.tile.viewStartRow, 30);
check('wheel sends scrollback fetch', scrolling.sent[0]?.type, 0x05);
scrolling.tile.canvas.dispatchEvent('pointerdown', pointer(13, 2, 2));
check('quick drag before history reply maps against painted rows', scrolling.tile.selection.anchor.row, 32);
scrolling.tile.canvas.dispatchEvent('pointercancel', { pointerId: 13 });
let paintedScroll = 0;
scrolling.tile.paintScroll = () => { paintedScroll++; };
const chunk = new Uint8Array(6 + 3);
new DataView(chunk.buffer).setUint32(0, 25, true);
new DataView(chunk.buffer).setUint16(4, 5, true);
chunk.set([1, 2, 3], 6);
const envelope = new Uint8Array(6 + chunk.length);
envelope[0] = 0;
envelope[1] = 0x97;
new DataView(envelope.buffer).setUint32(2, chunk.length, true);
envelope.set(chunk, 6);
scrolling.tile.onMessage(envelope);
check('matching scrollback echo becomes the displayed start row', scrolling.tile.viewStartRow, 25);
check('scrollback echo paints the history viewport', paintedScroll, 1);
// A wheel tick arriving while a fetch is outstanding must not replace
// scrollRequest and scrollRequestTimer without clearing the old timer:
// that leaks one setTimeout per tick and leaves a rollback armed for a
// request nobody is waiting on any more.
const wheelDuringFetch = makeTile();
wheelDuringFetch.tile.viewStartRow = 30;
wheelDuringFetch.tile.changeScrollPages(1);
const inFlightScroll = wheelDuringFetch.tile.scrollRequest;
const inFlightScrollTimer = wheelDuringFetch.tile.scrollRequestTimer;
wheelDuringFetch.tile.onWheel({ deltaY: -1 });
check('a wheel tick during a pending fetch sends nothing', wheelDuringFetch.sent.length, 1);
check('a wheel tick during a pending fetch keeps the one request', wheelDuringFetch.tile.scrollRequest, inFlightScroll);
check('a wheel tick during a pending fetch keeps its one timer', wheelDuringFetch.tile.scrollRequestTimer, inFlightScrollTimer);
check('a wheel tick during a pending fetch does not move page intent', wheelDuringFetch.tile.scrollPages, 1);
const failedStage = makeTile();
failedStage.tile.scrollPages = 1;
failedStage.tile.viewStartRow = 30;
failedStage.tile.stage = () => false;
failedStage.tile.onMessage(envelope);
check('failed scrollback staging preserves painted start row', failedStage.tile.viewStartRow, 30);
const failedFeed = makeTile();
failedFeed.tile.scrollPages = 1;
failedFeed.tile.viewStartRow = 30;
failedFeed.setScrollFeedResult(-3);
failedFeed.tile.onMessage(envelope);
check('failed scrollback decode preserves painted start row', failedFeed.tile.viewStartRow, 30);
const auto = makeTile();
auto.tile.viewStartRow = 30;
let autoPaints = 0;
auto.tile.paintScroll = () => { autoPaints++; };
auto.tile.canvas.dispatchEvent('pointerdown', pointer(18, 2, 2));
auto.tile.canvas.dispatchEvent('pointermove', {
...pointer(18, 4, 0), clientY: 10,
});
const autoTimer = h.intervals[auto.tile.selectionScrollTimer - 1];
autoTimer.fn();
check('outside drag timer requests one older page without pointermove', auto.sent.length, 1);
check('auto-scroll uses fetch-scrollback wire type', auto.sent[0]?.type, 0x05);
check('auto-scroll keeps coordinates tied to painted page while pending', auto.tile.selection.active.row, 30);
check('auto-scroll keeps painted start unchanged while pending', auto.tile.viewStartRow, 30);
autoTimer.fn();
check('auto-scroll does not stack requests while a paint is pending', auto.sent.length, 1);
auto.tile.onMessage(scrollEnvelope(20));
check('out-of-order history reply cannot move the painted viewport', auto.tile.viewStartRow, 30);
check('out-of-order history reply cannot move the drag endpoint', auto.tile.selection.active.row, 30);
check('out-of-order history reply does not paint', autoPaints, 0);
auto.tile.onMessage(scrollEnvelope(25));
check('matching successful history reply advances painted viewport', auto.tile.viewStartRow, 25);
check('matching successful history reply moves drag to painted boundary', auto.tile.selection.active.row, 25);
check('matching successful history reply remaps latest pointer column', auto.tile.selection.active.col, 4);
check('matching successful history reply retains boundary view row', auto.tile.selection.active.viewRow, 0);
check('matching successful history reply paints once', autoPaints, 1);
autoTimer.fn();
check('timer continues to the next page while pointer remains outside', auto.sent.length, 2);
check(
'continued auto-scroll requests exact next viewport',
Buffer.from(auto.sent[1]?.payload ?? []).toString('hex'),
'140000000500',
);
auto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 18 });
const insideDrift = makeTile();
insideDrift.tile.viewStartRow = 30;
insideDrift.tile.paintScroll = () => {};
insideDrift.tile.canvas.dispatchEvent('pointerdown', pointer(26, 2, 2));
insideDrift.tile.canvas.dispatchEvent('pointermove', {
...pointer(26, 4, 0), clientY: 10,
});
h.intervals[insideDrift.tile.selectionScrollTimer - 1].fn();
insideDrift.tile.canvas.dispatchEvent('pointermove', pointer(26, 7, 3));
check('pointer returning inside pending page still names old painted row', insideDrift.tile.selection.active.row, 33);
insideDrift.tile.onMessage(scrollEnvelope(25));
check('matching paint remaps inside pointer to new painted row', insideDrift.tile.selection.active.row, 28);
check('matching paint remaps latest inside pointer X', insideDrift.tile.selection.active.col, 7);
insideDrift.tile.canvas.dispatchEvent('pointercancel', { pointerId: 26 });
const outsideXDrift = makeTile();
outsideXDrift.tile.viewStartRow = 30;
outsideXDrift.tile.paintScroll = () => {};
outsideXDrift.tile.canvas.dispatchEvent('pointerdown', pointer(27, 2, 2));
outsideXDrift.tile.canvas.dispatchEvent('pointermove', {
...pointer(27, 4, 0), clientY: 10,
});
h.intervals[outsideXDrift.tile.selectionScrollTimer - 1].fn();
outsideXDrift.tile.canvas.dispatchEvent('pointermove', {
...pointer(27, 8, 0), clientY: 10,
});
outsideXDrift.tile.onMessage(scrollEnvelope(25));
check('matching paint remaps changed outside pointer X', outsideXDrift.tile.selection.active.col, 8);
check('matching paint clamps changed outside pointer to painted boundary', outsideXDrift.tile.selection.active.row, 25);
outsideXDrift.tile.canvas.dispatchEvent('pointercancel', { pointerId: 27 });
const failedAuto = makeTile();
failedAuto.tile.viewStartRow = 30;
failedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(19, 2, 2));
failedAuto.tile.canvas.dispatchEvent('pointermove', {
...pointer(19, 4, 0), clientY: 10,
});
const failedAutoTimer = h.intervals[failedAuto.tile.selectionScrollTimer - 1];
failedAutoTimer.fn();
failedAuto.setScrollFeedResult(-3);
const failedDecodeTimeout = h.timers[failedAuto.tile.scrollRequestTimer - 1];
failedAuto.tile.onMessage(scrollEnvelope(25));
check('failed auto-scroll reply preserves painted viewport', failedAuto.tile.viewStartRow, 30);
check('failed auto-scroll reply preserves displayed endpoint', failedAuto.tile.selection.active.row, 30);
check('failed auto-scroll reply rolls requested page back', failedAuto.tile.scrollPages, 0);
check('failed auto-scroll reply clears response timeout', failedDecodeTimeout?.cleared, true);
failedAuto.setScrollFeedResult(0);
failedAutoTimer.fn();
check('auto-scroll retries the same page after a failed decode', failedAuto.sent.length, 2);
check(
'auto-scroll retry keeps exact failed viewport coordinates',
Buffer.from(failedAuto.sent[1]?.payload ?? []).toString('hex'),
'190000000500',
);
failedAuto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 19 });
const failedAutoStage = makeTile();
failedAutoStage.tile.viewStartRow = 30;
failedAutoStage.tile.canvas.dispatchEvent('pointerdown', pointer(22, 2, 2));
failedAutoStage.tile.canvas.dispatchEvent('pointermove', {
...pointer(22, 4, 0), clientY: 10,
});
const failedStageTimer = h.intervals[failedAutoStage.tile.selectionScrollTimer - 1];
failedStageTimer.fn();
failedAutoStage.tile.stage = () => false;
const failedStageTimeout = h.timers[failedAutoStage.tile.scrollRequestTimer - 1];
failedAutoStage.tile.onMessage(scrollEnvelope(25));
check('failed auto-scroll staging rolls requested page back', failedAutoStage.tile.scrollPages, 0);
check('failed auto-scroll staging preserves painted endpoint', failedAutoStage.tile.selection.active.row, 30);
check('failed auto-scroll staging clears response timeout', failedStageTimeout?.cleared, true);
failedAutoStage.tile.stage = h.Tile.prototype.stage.bind(failedAutoStage.tile);
failedStageTimer.fn();
check('auto-scroll retries after failed staging', failedAutoStage.sent.length, 2);
failedAutoStage.tile.canvas.dispatchEvent('pointercancel', { pointerId: 22 });
const malformedAuto = makeTile();
malformedAuto.tile.viewStartRow = 30;
malformedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(24, 2, 2));
malformedAuto.tile.canvas.dispatchEvent('pointermove', {
...pointer(24, 4, 0), clientY: 10,
});
h.intervals[malformedAuto.tile.selectionScrollTimer - 1].fn();
const malformedScroll = new Uint8Array([0, 0x97, 1, 0, 0, 0, 0]);
malformedAuto.tile.onMessage(malformedScroll);
check('malformed matching scroll lane releases pending page intent', malformedAuto.tile.scrollPages, 0);
check('malformed matching scroll lane preserves painted start', malformedAuto.tile.viewStartRow, 30);
malformedAuto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 24 });
const edgeAuto = makeTile();
edgeAuto.tile.scrollPages = 6;
edgeAuto.tile.viewStartRow = 0;
edgeAuto.tile.canvas.dispatchEvent('pointerdown', pointer(20, 3, 0));
edgeAuto.tile.canvas.dispatchEvent('pointermove', {
...pointer(20, 3, 0), clientY: 10,
});
h.intervals[edgeAuto.tile.selectionScrollTimer - 1].fn();
check('auto-scroll is bounded at oldest history page', edgeAuto.sent.length, 0);
check('bounded auto-scroll retains painted endpoint', edgeAuto.tile.selection.active.row, 0);
edgeAuto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 20 });
// A drag that leaves the canvas at the very top never changes cell —
// cellAtPointer clamps to view row 0 — so `moved` can only become true
// when auto-scroll remaps the endpoint onto a genuinely different
// absolute row. Without that remap setting the flag, pointerup takes the
// plain-click branch and throws a multi-page selection away.
const clampedDrag = makeTile();
clampedDrag.tile.viewStartRow = 30;
clampedDrag.tile.paintScroll = () => {};
clampedDrag.tile.canvas.dispatchEvent('pointerdown', pointer(44, 2, 0));
clampedDrag.tile.canvas.dispatchEvent('pointermove', {
...pointer(44, 2, 0), clientY: 10,
});
check('a drag clamped at the top edge has not moved a cell yet', clampedDrag.tile.selection.active.row, 30);
h.intervals[clampedDrag.tile.selectionScrollTimer - 1].fn();
clampedDrag.tile.onMessage(scrollEnvelope(25));
check('the painted older page remaps the clamped endpoint', clampedDrag.tile.selection.active.row, 25);
clampedDrag.tile.canvas.dispatchEvent('pointerup', {
...pointer(44, 2, 0), clientY: 10,
});
check('a page-crossing drag is not discarded as a plain click', clampedDrag.tile.selection !== null, true);
check(
'a page-crossing drag requests the rows it actually spanned',
JSON.stringify(clampedDrag.requestCalls),
JSON.stringify([[1, 30, 2, 25, 2]]),
);
const releaseRace = makeTile();
releaseRace.tile.viewStartRow = 30;
let releasePaints = 0;
releaseRace.tile.paintScroll = () => { releasePaints++; };
releaseRace.tile.canvas.dispatchEvent('pointerdown', pointer(21, 2, 2));
const outside = { ...pointer(21, 4, 0), clientY: 10 };
releaseRace.tile.canvas.dispatchEvent('pointermove', outside);
h.intervals[releaseRace.tile.selectionScrollTimer - 1].fn();
releaseRace.tile.canvas.dispatchEvent('pointerup', outside);
check(
'release before scroll reply requests only displayed coordinates',
JSON.stringify(releaseRace.requestCalls),
JSON.stringify([[1, 32, 2, 30, 4]]),
);
const finalizedActive = JSON.stringify(releaseRace.tile.selection.active);
releaseRace.tile.onMessage(scrollEnvelope(25));
check('late scroll reply after release may paint requested viewport', releasePaints, 1);
check('late scroll reply after release cannot change finalized selection', JSON.stringify(releaseRace.tile.selection.active), finalizedActive);
check('late scroll reply after release sends no second selection request', releaseRace.requestCalls.length, 1);
const finalUp = makeTile();
finalUp.tile.viewStartRow = 30;
finalUp.tile.canvas.dispatchEvent('pointerdown', pointer(28, 2, 1));
finalUp.tile.canvas.dispatchEvent('pointerup', pointer(28, 6, 4));
check(
'pointerup samples final coordinates even without a last pointermove',
JSON.stringify(finalUp.requestCalls),
JSON.stringify([[1, 31, 2, 34, 6]]),
);
check('pointerup-only endpoint movement repaints retained overlay once', finalUp.reflows(), 2);
const finalPendingUp = makeTile();
finalPendingUp.tile.viewStartRow = 30;
finalPendingUp.tile.paintScroll = () => {};
finalPendingUp.tile.canvas.dispatchEvent('pointerdown', pointer(29, 2, 2));
finalPendingUp.tile.canvas.dispatchEvent('pointermove', {
...pointer(29, 4, 0), clientY: 10,
});
h.intervals[finalPendingUp.tile.selectionScrollTimer - 1].fn();
finalPendingUp.tile.canvas.dispatchEvent('pointerup', pointer(29, 7, 3));
check(
'pointerup before page reply samples final point on displayed viewport',
JSON.stringify(finalPendingUp.requestCalls),
JSON.stringify([[1, 32, 2, 33, 7]]),
);
check('changed pointerup after move adds one final overlay repaint', finalPendingUp.reflows(), 3);
const finalPendingPoint = JSON.stringify(finalPendingUp.tile.selection.active);
finalPendingUp.tile.onMessage(scrollEnvelope(25));
check('late painted page cannot remap final pointerup point', JSON.stringify(finalPendingUp.tile.selection.active), finalPendingPoint);
const droppedSelection = makeTile();
let droppedSelectionSends = 0;
droppedSelection.tile.sendFrame = () => { droppedSelectionSends++; return false; };
droppedSelection.tile.canvas.dispatchEvent('pointerdown', pointer(31, 2, 1));
droppedSelection.tile.canvas.dispatchEvent('pointerup', pointer(31, 5, 3));
check('dropped selection request attempts one transport send', droppedSelectionSends, 1);
check('dropped selection request clears retained pending geometry', droppedSelection.tile.selection, null);
check('dropped selection request starts no response timeout', droppedSelection.tile.selectionRequestTimer, null);
const selectionTimeout = makeTile();
selectionTimeout.tile.canvas.dispatchEvent('pointerdown', pointer(32, 1, 1));
selectionTimeout.tile.canvas.dispatchEvent('pointerup', pointer(32, 4, 2));
const selectionTimeoutHandle = selectionTimeout.tile.selectionRequestTimer;
const selectionTimeoutTask = selectionTimeoutHandle === null
? undefined : h.timers[selectionTimeoutHandle - 1];
check('selection response timeout is bounded to 5000ms', selectionTimeoutTask?.ms, 5000);
selectionTimeoutTask?.fn();
check('selection response timeout clears timer identity', selectionTimeout.tile.selectionRequestTimer, null);
check('selection response timeout retains no authoritative text', selectionTimeout.tile.selection?.text, null);
check('selection response timeout reports transient unavailability', `${selectionTimeout.tile.copyButton.className}|${selectionTimeout.tile.copyButton.textContent}`, 'copy-request on error|Selection unavailable');
check('selection response timeout revokes active request capability', selectionTimeout.tile.activeSelectionRequest, null);
const lateAfterTimeout = makeTile();
lateAfterTimeout.tile.pendingClipboard = 'OSC fallback after timeout';
lateAfterTimeout.tile.clipboardVersion = 1;
lateAfterTimeout.tile.copyUiOwner = 'clipboard';
lateAfterTimeout.tile.canvas.dispatchEvent('pointerdown', pointer(41, 1, 1));
lateAfterTimeout.tile.canvas.dispatchEvent('pointerup', pointer(41, 4, 2));
const lateLeaseHandle = lateAfterTimeout.tile.selectionRequestTimer;
const lateLeaseTimer = lateLeaseHandle === null
? undefined : h.timers[lateLeaseHandle - 1];
lateLeaseTimer?.fn();
const lateFeedbackHandle = lateAfterTimeout.tile.selectionFeedbackTimer;
const lateFeedbackTimer = lateFeedbackHandle === null
? undefined : h.timers[lateFeedbackHandle - 1];
lateFeedbackTimer?.fn();
check('expired selection feedback restores OSC fallback before late reply', lateAfterTimeout.tile.copyUiOwner, 'clipboard');
lateAfterTimeout.setResult(1, 0, Buffer.from('must stay expired'));
lateAfterTimeout.tile.onSelectionReply();
check('late same-id success cannot restore expired authoritative text', lateAfterTimeout.tile.selection?.text, null);
check('late same-id success cannot reclaim shared copy UI', lateAfterTimeout.tile.copyUiOwner, 'clipboard');
lateAfterTimeout.setResult(1, 2, []);
lateAfterTimeout.tile.onSelectionReply();
check('late same-id failure cannot reclaim shared copy UI', lateAfterTimeout.tile.copyUiOwner, 'clipboard');
check('late same-id failure cannot start new unavailable feedback', lateAfterTimeout.tile.selectionFeedbackTimer, null);
const replyBeforeTimeout = makeTile();
replyBeforeTimeout.tile.canvas.dispatchEvent('pointerdown', pointer(33, 1, 1));
replyBeforeTimeout.tile.canvas.dispatchEvent('pointerup', pointer(33, 4, 2));
const replyTimeoutHandle = replyBeforeTimeout.tile.selectionRequestTimer;
const replyTimeoutTask = replyTimeoutHandle === null
? undefined : h.timers[replyTimeoutHandle - 1];
replyBeforeTimeout.setResult(1, 0, Buffer.from('reply before timeout'));
replyBeforeTimeout.tile.onSelectionReply();
check('matching selection reply clears response timeout', replyTimeoutTask?.cleared, true);
check('matching selection reply clears timeout identity', replyBeforeTimeout.tile.selectionRequestTimer, null);
check('matching selection reply retains authoritative text', replyBeforeTimeout.tile.selection?.text, 'reply before timeout');
check('matching selection reply consumes active request capability', replyBeforeTimeout.tile.activeSelectionRequest, null);
const synchronousReply = makeTile();
synchronousReply.tile.sendFrame = () => {
synchronousReply.setResult(1, 0, Buffer.from('synchronous host reply'));
synchronousReply.tile.onSelectionReply();
return true;
};
synchronousReply.tile.canvas.dispatchEvent('pointerdown', pointer(40, 1, 1));
synchronousReply.tile.canvas.dispatchEvent('pointerup', pointer(40, 4, 2));
check('synchronous host reply retains authoritative selection', synchronousReply.tile.selection?.text, 'synchronous host reply');
check('synchronous host reply cannot acquire a stale response timeout', synchronousReply.tile.selectionRequestTimer, null);
check('synchronous host reply consumes pre-send active capability', synchronousReply.tile.activeSelectionRequest, null);
const failureBeforeTimeout = makeTile();
failureBeforeTimeout.tile.canvas.dispatchEvent('pointerdown', pointer(34, 1, 1));
failureBeforeTimeout.tile.canvas.dispatchEvent('pointerup', pointer(34, 4, 2));
const failureTimeoutHandle = failureBeforeTimeout.tile.selectionRequestTimer;
const failureTimeoutTask = failureTimeoutHandle === null
? undefined : h.timers[failureTimeoutHandle - 1];
failureBeforeTimeout.setResult(1, 2, []);
failureBeforeTimeout.tile.onSelectionReply();
check('matching failed selection reply clears response timeout', failureTimeoutTask?.cleared, true);
check('matching failed selection reply clears timeout identity', failureBeforeTimeout.tile.selectionRequestTimer, null);
const staleSelectionTimeout = makeTile();
staleSelectionTimeout.tile.canvas.dispatchEvent('pointerdown', pointer(35, 1, 1));
staleSelectionTimeout.tile.canvas.dispatchEvent('pointerup', pointer(35, 4, 2));
const staleSelectionHandle = staleSelectionTimeout.tile.selectionRequestTimer;
const staleSelectionTask = staleSelectionHandle === null
? undefined : h.timers[staleSelectionHandle - 1];
staleSelectionTimeout.tile.canvas.dispatchEvent('pointerdown', pointer(36, 2, 2));
staleSelectionTimeout.tile.canvas.dispatchEvent('pointerup', pointer(36, 5, 3));
const newerSelection = staleSelectionTimeout.tile.selection;
const newerSelectionTimer = staleSelectionTimeout.tile.selectionRequestTimer;
staleSelectionTask?.fn();
check('stale selection timeout cannot clear newer retained geometry', staleSelectionTimeout.tile.selection, newerSelection);
check('stale selection timeout cannot clear newer response timer', staleSelectionTimeout.tile.selectionRequestTimer, newerSelectionTimer);
check('stale selection timeout cannot show unavailable for newer request', staleSelectionTimeout.tile.copyUiOwner, null);
const staleSelectionReply = makeTile();
staleSelectionReply.tile.canvas.dispatchEvent('pointerdown', pointer(42, 1, 1));
staleSelectionReply.tile.canvas.dispatchEvent('pointerup', pointer(42, 4, 2));
staleSelectionReply.tile.canvas.dispatchEvent('pointerdown', pointer(43, 2, 2));
staleSelectionReply.tile.canvas.dispatchEvent('pointerup', pointer(43, 5, 3));
const activeNewerRequest = staleSelectionReply.tile.activeSelectionRequest;
check('newer selection establishes active pre-send capability', activeNewerRequest !== null, true);
check('newer selection active capability carries request id', activeNewerRequest?.id, 2);
staleSelectionReply.setResult(1, 0, Buffer.from('stale older reply'));
staleSelectionReply.tile.onSelectionReply();
check('older reply cannot consume newer active request capability', staleSelectionReply.tile.activeSelectionRequest, activeNewerRequest);
check('older reply cannot bind text to newer selection', staleSelectionReply.tile.selection?.text, null);
staleSelectionReply.setResult(2, 0, Buffer.from('matching newer reply'));
staleSelectionReply.tile.onSelectionReply();
check('matching newer reply binds after stale older reply', staleSelectionReply.tile.selection?.text, 'matching newer reply');
check('matching newer reply consumes active capability', staleSelectionReply.tile.activeSelectionRequest, null);
const selectionLifecycle = makeTile();
selectionLifecycle.tile.canvas.dispatchEvent('pointerdown', pointer(37, 1, 1));
selectionLifecycle.tile.canvas.dispatchEvent('pointerup', pointer(37, 4, 2));
const lifecycleSelectionHandle = selectionLifecycle.tile.selectionRequestTimer;
const lifecycleSelectionTask = lifecycleSelectionHandle === null
? undefined : h.timers[lifecycleSelectionHandle - 1];
selectionLifecycle.tile.sendAttach(false);
check('selection lifecycle reset clears pending response timer', lifecycleSelectionTask?.cleared, true);
check('selection lifecycle reset clears response timer identity', selectionLifecycle.tile.selectionRequestTimer, null);
check('selection lifecycle reset clears pending geometry', selectionLifecycle.tile.selection, null);
const selectionUnzoom = makeTile();
selectionUnzoom.tile.canvas.dispatchEvent('pointerdown', pointer(38, 1, 1));
selectionUnzoom.tile.canvas.dispatchEvent('pointerup', pointer(38, 4, 2));
const unzoomSelectionHandle = selectionUnzoom.tile.selectionRequestTimer;
const unzoomSelectionTask = unzoomSelectionHandle === null
? undefined : h.timers[unzoomSelectionHandle - 1];
h.setZoomedTile(selectionUnzoom.tile);
h.unzoom();
check('unzoom clears pending selection response timeout', unzoomSelectionTask?.cleared, true);
check('unzoom clears pending selection response identity', selectionUnzoom.tile.selectionRequestTimer, null);
check('unzoom clears pending selection geometry', selectionUnzoom.tile.selection, null);
const selectionReset = makeTile();
selectionReset.tile.canvas.dispatchEvent('pointerdown', pointer(39, 1, 1));
selectionReset.tile.canvas.dispatchEvent('pointerup', pointer(39, 4, 2));
const resetSelectionHandle = selectionReset.tile.selectionRequestTimer;
const resetSelectionTask = resetSelectionHandle === null
? undefined : h.timers[resetSelectionHandle - 1];
selectionReset.tile.resetCore('pending selection timeout');
check('core reset clears pending selection response timeout', resetSelectionTask?.cleared, true);
check('core reset clears pending selection response identity', selectionReset.tile.selectionRequestTimer, null);
check('core reset clears pending selection geometry', selectionReset.tile.selection, null);
const painted = makeTile();
const order = [];
painted.tile.sizeCanvas = () => {};
painted.tile.paintRow = (row) => { order.push(`row${row}`); };
painted.tile.paintCursor = () => { order.push('cursor'); };
painted.tile.paintSelection = () => { order.push('selection'); };
painted.tile.core.mux_read_viewport = () => 2;
painted.tile.paintLive();
check('live paint follows current history start', painted.tile.viewStartRow, 30);
check('live selection overlay paints after cells and cursor', order.join('|'), 'row0|row1|cursor|selection');
order.length = 0;
painted.tile.viewStartRow = 12;
painted.tile.paintScroll();
check('history paint retains echoed start row', painted.tile.viewStartRow, 12);
check('history selection overlay paints after cells', order.join('|'), 'row0|row1|row2|row3|row4|selection');
const partial = makeTile();
partial.tile.sizeCanvas = () => {};
partial.tile.selection = {
anchor: { row: 31, col: 2 }, active: { row: 33, col: 4 }, requestId: 1, text: null,
};
let dirtyRows = [0];
partial.tile.core.mux_read_viewport = () => dirtyRows.length;
partial.tile.core.mux_dirty_row = (i) => dirtyRows[i];
const partialOps = [];
partial.tile.paintRow = (row) => { partialOps.push(`row${row}`); };
partial.tile.paintCursor = () => { partialOps.push('cursor'); };
partial.tile.paintSelection = () => { partialOps.push('overlay'); };
partial.tile.paintLive();
check(
'partial live paint restores every selected row before translucent overlay',
partialOps.join('|'),
'row0|row1|row2|row3|cursor|overlay',
);
dirtyRows = [2, 4];
partialOps.length = 0;
partial.tile.paintLive();
check(
'successive partial live paint restores selected rows without duplicates',
partialOps.join('|'),
'row2|row4|row1|row3|cursor|overlay',
);
const overlay = makeTile();
const fills = [];
overlay.tile.ctx = {
fillStyle: '',
fillRect(x, y, width, height) { fills.push([x, y, width, height, this.fillStyle]); },
};
overlay.tile.viewStartRow = 100;
overlay.tile.selection = {
anchor: { row: 103, col: 4 }, active: { row: 101, col: 2 }, requestId: 1, text: null,
};
overlay.tile.paintSelection();
check(
'retained overlay paints reverse multi-row geometry in history',
JSON.stringify(fills),
JSON.stringify([
[16, 14, 64, 14, '#6ab0e055'],
[0, 28, 80, 14, '#6ab0e055'],
[0, 42, 40, 14, '#6ab0e055'],
]),
);
const replies = makeTile();
const highId = 0xfedcba98;
replies.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: highId, text: null,
};
const authoritative = Buffer.from('daemon ☃', 'utf8');
authorizeSelectionReply(replies);
replies.setResult(highId, 0, authoritative);
replies.tile.onSelectionReply();
new Uint8Array(replies.memory.buffer).fill(0, 96, 96 + authoritative.length);
check('matching high-bit reply binds with unsigned ID comparison', replies.tile.selection.text, 'daemon ☃');
replies.tile.selection.text = 'keep';
replies.setResult(123, 0, Buffer.from('stale'));
replies.tile.onSelectionReply();
check('stale semantic reply cannot replace retained text', replies.tile.selection.text, 'keep');
authorizeSelectionReply(replies);
replies.setResult(highId, 2, []);
replies.tile.onSelectionReply();
check('failed matching reply clears authoritative text', replies.tile.selection.text, null);
check('failed matching reply reports selection unavailable', `${replies.tile.copyButton.className}|${replies.tile.copyButton.textContent}`, 'copy-request on error|Selection unavailable');
replies.tile.selection.text = 'previous';
authorizeSelectionReply(replies);
replies.setResult(highId, 0, [0xff]);
replies.tile.onSelectionReply();
check('browser rejects invalid UTF-8 selection text defensively', replies.tile.selection.text, null);
const invalidUtf8FeedbackHandle = replies.tile.selectionFeedbackTimer;
const invalidUtf8Feedback = invalidUtf8FeedbackHandle === null
? undefined : h.timers[invalidUtf8FeedbackHandle - 1];
check('invalid UTF-8 selection status is bounded to 1200ms', invalidUtf8Feedback?.ms, 1200);
invalidUtf8Feedback?.fn();
check('invalid UTF-8 selection status releases shared copy UI', `${replies.tile.copyUiOwner}|${replies.tile.copyButton.className}|${replies.tile.copyButton.textContent}`, 'null|copy-request|Copy');
const routed = makeTile();
routed.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 77, text: null,
};
authorizeSelectionReply(routed);
routed.setResult(77, 0, Buffer.from('routed'));
const replyPayload = Uint8Array.from([77, 0, 0, 0, 0, 30, 0, 0, 0, ...Buffer.from('routed')]);
const replyEnvelope = new Uint8Array(6 + replyPayload.length);
replyEnvelope[0] = 0;
replyEnvelope[1] = 0x90;
new DataView(replyEnvelope.buffer).setUint32(2, replyPayload.length, true);
replyEnvelope.set(replyPayload, 6);
routed.tile.onMessage(replyEnvelope);
check('selection-reply frame routes through semantic client core', routed.tile.selection.text, 'routed');
const evicted = makeTile();
evicted.tile.selection = {
anchor: { row: 30, col: 0 }, active: { row: 31, col: 2 }, requestId: 82, text: null,
};
const evictedRequest = authorizeSelectionReply(evicted);
check('selection request samples the history base its rows are counted from', evictedRequest.historyBase, 30);
evicted.setResult(82, 0, Buffer.from('rows that moved under the request'), 25);
evicted.tile.onSelectionReply();
check('reply below the sampled history base retains no text', evicted.tile.selection.text, null);
check('reply below the sampled history base reports unavailable', `${evicted.tile.copyButton.className}|${evicted.tile.copyButton.textContent}`, 'copy-request on error|Selection unavailable');
check('reply below the sampled history base consumes the request', evicted.tile.activeSelectionRequest, null);
// The other half of the same rule, and the one a stricter comparison
// would break: output APPENDS rows below the retained ones, so a higher
// reading names the same lines the drag did.
const grown = makeTile();
grown.tile.selection = {
anchor: { row: 30, col: 0 }, active: { row: 31, col: 2 }, requestId: 83, text: null,
};
authorizeSelectionReply(grown);
grown.setResult(83, 0, Buffer.from('same rows, more below them'), 900);
grown.tile.onSelectionReply();
check('a risen history base leaves the reply usable', grown.tile.selection.text, 'same rows, more below them');
const blockedCopy = makeTile();
blockedCopy.tile.pendingClipboard = 'unrelated OSC 52 text';
blockedCopy.tile.clipboardVersion = 1;
blockedCopy.tile.copyUiOwner = 'clipboard';
blockedCopy.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 71, text: null,
};
authorizeSelectionReply(blockedCopy);
blockedCopy.setResult(71, 3, []);
blockedCopy.tile.onSelectionReply();
const unavailableFeedbackHandle = blockedCopy.tile.selectionFeedbackTimer;
const unavailableFeedback = unavailableFeedbackHandle === null
? undefined : h.timers[unavailableFeedbackHandle - 1];
const blockedWrites = [];
h.navigator.clipboard = { writeText: (text) => { blockedWrites.push(text); return Promise.resolve(); } };
blockedCopy.tile.copyButton.dispatchEvent('click', { stopPropagation() {} });
await flushPromises();
check('selection failure owns the shared copy control', blockedCopy.tile.copyUiOwner, 'selection');
check('selection failure button cannot copy unrelated OSC 52 text', blockedWrites.length, 0);
check('selection failure leaves unrelated OSC 52 text pending', blockedCopy.tile.pendingClipboard, 'unrelated OSC 52 text');
check('selection unavailable feedback is bounded to 1200ms', unavailableFeedback?.ms, 1200);
unavailableFeedback?.fn();
check('expired selection unavailable restores pending OSC52 owner', blockedCopy.tile.copyUiOwner, 'clipboard');
check('expired selection unavailable restores pending OSC52 action', `${blockedCopy.tile.copyButton.className}|${blockedCopy.tile.copyButton.textContent}`, 'copy-request on|Copy');
const ownerChangedFeedback = makeTile();
ownerChangedFeedback.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 78, text: null,
};
authorizeSelectionReply(ownerChangedFeedback);
ownerChangedFeedback.setResult(78, 3, []);
ownerChangedFeedback.tile.onSelectionReply();
const supersededFeedbackHandle = ownerChangedFeedback.tile.selectionFeedbackTimer;
const supersededFeedback = supersededFeedbackHandle === null
? undefined : h.timers[supersededFeedbackHandle - 1];
const ownerOsc = Buffer.from('new OSC owner', 'utf8').toString('base64');
new Uint8Array(ownerChangedFeedback.memory.buffer).set(Buffer.from(ownerOsc, 'ascii'), 96);
ownerChangedFeedback.tile.core.mux_clipboard_ptr = () => 96;
ownerChangedFeedback.tile.core.mux_clipboard_len = () => ownerOsc.length;
h.navigator.clipboard = undefined;
await ownerChangedFeedback.tile.onClipboardEffect();
check('new OSC52 owner clears transient selection feedback timer', supersededFeedback?.cleared, true);
supersededFeedback?.fn();
check('stale unavailable feedback cannot overwrite new OSC52 owner', ownerChangedFeedback.tile.copyUiOwner, 'clipboard');
check('stale unavailable feedback cannot hide new OSC52 retry', `${ownerChangedFeedback.tile.copyButton.className}|${ownerChangedFeedback.tile.copyButton.textContent}`, 'copy-request on|Copy');
const copySupersedesFeedback = makeTile();
copySupersedesFeedback.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 79, text: null,
};
authorizeSelectionReply(copySupersedesFeedback);
copySupersedesFeedback.setResult(79, 3, []);
copySupersedesFeedback.tile.onSelectionReply();
const preCopyFeedbackHandle = copySupersedesFeedback.tile.selectionFeedbackTimer;
const preCopyFeedback = preCopyFeedbackHandle === null
? undefined : h.timers[preCopyFeedbackHandle - 1];
copySupersedesFeedback.tile.selection.text = 'late authoritative text';
h.navigator.clipboard = { writeText: () => Promise.resolve() };
await copySupersedesFeedback.tile.copySelection();
check('authoritative copy clears prior unavailable feedback timer', preCopyFeedback?.cleared, true);
preCopyFeedback?.fn();
check('stale unavailable timer cannot overwrite copy success', `${copySupersedesFeedback.tile.copyButton.className}|${copySupersedesFeedback.tile.copyButton.textContent}`, 'copy-request on|Copied');
const unzoomFeedback = makeTile();
unzoomFeedback.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 81, text: null,
};
authorizeSelectionReply(unzoomFeedback);
unzoomFeedback.setResult(81, 3, []);
unzoomFeedback.tile.onSelectionReply();
const unzoomFeedbackHandle = unzoomFeedback.tile.selectionFeedbackTimer;
const unzoomFeedbackTask = unzoomFeedbackHandle === null
? undefined : h.timers[unzoomFeedbackHandle - 1];
h.setZoomedTile(unzoomFeedback.tile);
h.unzoom();
check('unzoom clears transient unavailable feedback timer', unzoomFeedbackTask?.cleared, true);
unzoomFeedbackTask?.fn();
check('stale unavailable timer remains invisible after unzoom', `${unzoomFeedback.tile.copyUiOwner}|${unzoomFeedback.tile.copyButton.className}|${unzoomFeedback.tile.copyButton.textContent}`, 'null|copy-request|Copy');
// The selection lane's two timer-driven hides, same hazard.
const unavailableFocus = makeTile();
unavailableFocus.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 84, text: null,
};
authorizeSelectionReply(unavailableFocus);
unavailableFocus.setResult(84, 3, []);
unavailableFocus.tile.onSelectionReply();
const unavailableFocusTimer = h.timers[unavailableFocus.tile.selectionFeedbackTimer - 1];
unavailableFocus.tile.copyButton.focus();
unavailableFocusTimer?.fn();
check('expiring selection feedback under focus returns focus to the IME', h.document.activeElement, h.elements.ime);
// The other side of the guard: retiring selection feedback onto a pending
// OSC 52 leaves the control VISIBLE and actionable, so focus must stay
// where the user put it.
const visibleAfterFeedback = makeTile();
visibleAfterFeedback.tile.pendingClipboard = 'OSC52 still waiting';
visibleAfterFeedback.tile.clipboardVersion = 1;
visibleAfterFeedback.tile.copyUiOwner = 'clipboard';
visibleAfterFeedback.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 86, text: null,
};
authorizeSelectionReply(visibleAfterFeedback);
visibleAfterFeedback.setResult(86, 3, []);
visibleAfterFeedback.tile.onSelectionReply();
const visibleFeedbackTimer = h.timers[visibleAfterFeedback.tile.selectionFeedbackTimer - 1];
visibleAfterFeedback.tile.copyButton.focus();
visibleFeedbackTimer?.fn();
check('feedback expiring onto a still-visible retry keeps its focus', h.document.activeElement, visibleAfterFeedback.tile.copyButton);
check('feedback expiring onto a still-visible retry stays actionable', `${visibleAfterFeedback.tile.copyButton.className}|${visibleAfterFeedback.tile.copyButton.textContent}`, 'copy-request on|Copy');
const copiedFocus = makeTile();
copiedFocus.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 85, text: 'copied then hidden',
};
h.navigator.clipboard = { writeText: () => Promise.resolve() };
await copiedFocus.tile.copySelection();
const copiedFocusTimer = h.timers.at(-1);
copiedFocus.tile.copyButton.focus();
copiedFocusTimer.fn();
check('hiding the selection copy control under focus returns focus to the IME', h.document.activeElement, h.elements.ime);
const inFlightUi = makeTile();
inFlightUi.tile.pendingClipboard = 'in flight';
inFlightUi.tile.clipboardVersion = 1;
inFlightUi.tile.copyUiOwner = 'clipboard';
const inFlightWrite = deferred();
h.navigator.clipboard = { writeText: () => inFlightWrite.promise };
const inFlightRun = inFlightUi.tile.tryClipboardWrite(1, true);
inFlightUi.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 72, text: null,
};
authorizeSelectionReply(inFlightUi);
inFlightUi.setResult(72, 2, []);
inFlightUi.tile.onSelectionReply();
inFlightWrite.resolve();
await inFlightRun;
check(
'settled in-flight OSC 52 write cannot overwrite selection failure status',
`${inFlightUi.tile.copyButton.className}|${inFlightUi.tile.copyButton.textContent}`,
'copy-request on error|Selection unavailable',
);
const timerUi = makeTile();
timerUi.tile.pendingClipboard = 'copied before selection failure';
timerUi.tile.clipboardVersion = 1;
timerUi.tile.copyUiOwner = 'clipboard';
h.navigator.clipboard = { writeText: () => Promise.resolve() };
await timerUi.tile.tryClipboardWrite(1, true);
const oldClipboardTimer = h.timers.at(-1);
timerUi.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 73, text: null,
};
authorizeSelectionReply(timerUi);
timerUi.setResult(73, 1, []);
timerUi.tile.onSelectionReply();
oldClipboardTimer.fn();
check(
'old OSC 52 feedback timer cannot overwrite selection failure status',
`${timerUi.tile.copyButton.className}|${timerUi.tile.copyButton.textContent}`,
'copy-request on error|Selection unavailable',
);
const clearedUi = makeTile();
clearedUi.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 74, text: null,
};
authorizeSelectionReply(clearedUi);
clearedUi.setResult(74, 3, []);
clearedUi.tile.onSelectionReply();
clearedUi.tile.clearSelection(false);
check('clearing failed selection removes stale error UI', `${clearedUi.tile.copyButton.className}|${clearedUi.tile.copyButton.textContent}`, 'copy-request|Copy');
const successfulUi = makeTile();
successfulUi.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 75, text: null,
};
authorizeSelectionReply(successfulUi);
successfulUi.setResult(75, 3, []);
successfulUi.tile.onSelectionReply();
authorizeSelectionReply(successfulUi);
successfulUi.setResult(75, 0, Buffer.from('available'));
successfulUi.tile.onSelectionReply();
check('successful selection removes stale unavailable UI', `${successfulUi.tile.copyButton.className}|${successfulUi.tile.copyButton.textContent}`, 'copy-request|Copy');
const keyEvent = (overrides = {}) => {
let prevented = false;
return {
key: '', target: h.elements.ime, isComposing: false,
shiftKey: false, altKey: false, ctrlKey: false, metaKey: false,
preventDefault() { prevented = true; },
wasPrevented: () => prevented,
...overrides,
};
};
const authoritativeSelection = (text) => ({
anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 80, text,
});
for (const [name, modifiers] of [
['Ctrl+Shift+C', { ctrlKey: true, shiftKey: true }],
['Ctrl+C', { ctrlKey: true }],
['Meta+C', { metaKey: true }],
]) {
const chord = makeTile();
chord.tile.selection = authoritativeSelection(`exact ${name}`);
h.setZoomedTile(chord.tile);
const writes = [];
h.navigator.clipboard = { writeText(text) { writes.push(text); return Promise.resolve(); } };
const ev = keyEvent({ key: 'c', ...modifiers });
h.document.dispatchEvent('keydown', ev);
check(`${name} with authoritative selection prevents browser default`, ev.wasPrevented(), true);
check(`${name} invokes clipboard in the keydown user gesture`, writes.join('|'), `exact ${name}`);
check(`${name} with authoritative selection sends no PTY frame`, chord.sent.length, 0);
await flushPromises();
check(`${name} retains the selection after copy`, chord.tile.selection?.text, `exact ${name}`);
}
const emptyCopy = makeTile();
emptyCopy.tile.selection = authoritativeSelection('');
h.setZoomedTile(emptyCopy.tile);
const emptyWrites = [];
h.navigator.clipboard = { writeText(text) { emptyWrites.push(text); return Promise.resolve(); } };
const emptyEvent = keyEvent({ key: 'c', ctrlKey: true });
h.document.dispatchEvent('keydown', emptyEvent);
check('empty authoritative selection is still copyable', emptyEvent.wasPrevented(), true);
check('empty authoritative selection writes exact empty text', JSON.stringify(emptyWrites), '[""]');
await flushPromises();
check('empty selection copy retains authoritative empty text', emptyCopy.tile.selection?.text, '');
const nativeChords = makeTile();
h.setZoomedTile(nativeChords.tile);
for (const [name, modifiers] of [
['Ctrl+Shift+C', { ctrlKey: true, shiftKey: true }],
['Meta+C', { metaKey: true }],
]) {
const ev = keyEvent({ key: 'c', ...modifiers });
h.document.dispatchEvent('keydown', ev);
check(`${name} without selection remains browser-native`, ev.wasPrevented(), false);
}
check('browser-native copy chords without selection send no PTY frame', nativeChords.sent.length, 0);
const terminalCopy = makeTile();
const encodedKeys = [];
terminalCopy.tile.core.mux_key_encode = (...args) => {
encodedKeys.push(args);
new Uint8Array(terminalCopy.memory.buffer)[256] = 3;
return 1;
};
terminalCopy.tile.core.mux_output_len = () => 1;
h.setZoomedTile(terminalCopy.tile);
const ctrlC = keyEvent({ key: 'c', ctrlKey: true });
h.document.dispatchEvent('keydown', ctrlC);
check('Ctrl+C without selection prevents browser default for terminal input', ctrlC.wasPrevented(), true);
check('Ctrl+C without selection reaches terminal key encoding', JSON.stringify(encodedKeys), '[[0,99,4]]');
check('Ctrl+C without selection sends one PTY frame', terminalCopy.sent[0]?.type, 0x02);
check('Ctrl+C fake encoder emits ETX byte', terminalCopy.sent[0]?.payload[0], 3);
// The retained selection exists but its reply has not decoded (or came
// back non-ok), so copySelection would return at its own text === null
// guard. Treating "a selection object exists" as "there is something to
// copy" swallows the interrupt for up to the whole response timeout.
const undecodedCopy = makeTile();
undecodedCopy.tile.selection = authoritativeSelection(null);
undecodedCopy.tile.core.mux_key_encode = () => 1;
undecodedCopy.tile.core.mux_output_len = () => 1;
h.setZoomedTile(undecodedCopy.tile);
const undecodedWrites = [];
h.navigator.clipboard = { writeText: (text) => { undecodedWrites.push(text); return Promise.resolve(); } };
const undecodedCtrlC = keyEvent({ key: 'c', ctrlKey: true });
h.document.dispatchEvent('keydown', undecodedCtrlC);
await flushPromises();
check('Ctrl+C with an undecoded selection still reaches the shell', undecodedCopy.sent[0]?.type, 0x02);
check('Ctrl+C with an undecoded selection sends exactly one frame', undecodedCopy.sent.length, 1);
check('Ctrl+C with an undecoded selection writes no clipboard', undecodedWrites.length, 0);
const altCopy = makeTile();
const altKeys = [];
altCopy.tile.selection = authoritativeSelection('must not copy');
altCopy.tile.core.mux_key_encode = (...args) => { altKeys.push(args); return 1; };
altCopy.tile.core.mux_output_len = () => 1;
h.setZoomedTile(altCopy.tile);
let altWrites = 0;
h.navigator.clipboard = { writeText() { altWrites++; return Promise.resolve(); } };
const ctrlAltC = keyEvent({ key: 'c', ctrlKey: true, altKey: true });
h.document.dispatchEvent('keydown', ctrlAltC);
check('Ctrl+Alt+C is terminal input, not selection copy', altWrites, 0);
check('Ctrl+Alt+C includes Alt and Ctrl in terminal modifiers', JSON.stringify(altKeys), '[[0,99,6]]');
check('Ctrl+Alt+C clears retained selection through sendKey', altCopy.tile.selection, null);
const nativePaste = makeTile();
nativePaste.tile.selection = authoritativeSelection('keep during browser paste chord');
h.setZoomedTile(nativePaste.tile);
const ctrlShiftV = keyEvent({ key: 'v', ctrlKey: true, shiftKey: true });
h.document.dispatchEvent('keydown', ctrlShiftV);
check('Ctrl+Shift+V remains browser-native', ctrlShiftV.wasPrevented(), false);
check('Ctrl+Shift+V sends no PTY frame', nativePaste.sent.length, 0);
check('Ctrl+Shift+V keydown alone does not clear selection', nativePaste.tile.selection?.text, 'keep during browser paste chord');
const composingCopy = makeTile();
composingCopy.tile.selection = authoritativeSelection('IME owns this key');
h.setZoomedTile(composingCopy.tile);
let composingWrites = 0;
h.navigator.clipboard = { writeText() { composingWrites++; return Promise.resolve(); } };
const composingEvent = keyEvent({ key: 'c', ctrlKey: true, isComposing: true });
h.document.dispatchEvent('keydown', composingEvent);
check('composition handling precedes selection copy chords', composingWrites, 0);
check('composing copy chord remains unprevented', composingEvent.wasPrevented(), false);
const selectionRetryTab = makeTile();
selectionRetryTab.tile.selection = authoritativeSelection('retry by keyboard');
selectionRetryTab.tile.renderCopyUi('selection', 'copy-request on error', 'Copy failed');
h.setZoomedTile(selectionRetryTab.tile);
h.elements.ime.focus();
const selectionTab = keyEvent({ key: 'Tab' });
h.document.dispatchEvent('keydown', selectionTab);
check('plain Tab reaches actionable selection-copy failure', selectionTab.wasPrevented(), true);
check('selection-copy retry receives keyboard focus', h.document.activeElement, selectionRetryTab.tile.copyButton);
check('selection-copy retry Tab sends no PTY bytes', selectionRetryTab.sent.length, 0);
const unavailableTab = makeTile();
unavailableTab.tile.selection = authoritativeSelection(null);
unavailableTab.tile.pendingClipboard = 'unrelated hidden OSC52';
unavailableTab.tile.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable');
unavailableTab.tile.core.mux_key_encode = () => 1;
unavailableTab.tile.core.mux_output_len = () => 1;
h.setZoomedTile(unavailableTab.tile);
h.elements.ime.focus();
const unavailableSelectionTab = keyEvent({ key: 'Tab' });
h.document.dispatchEvent('keydown', unavailableSelectionTab);
check('unavailable selection does not advertise an actionable retry', h.document.activeElement, h.elements.ime);
check('unavailable selection Tab keeps terminal key handling', unavailableSelectionTab.wasPrevented(), true);
check('unavailable selection Tab reaches PTY once', unavailableTab.sent.length, 1);
const scrollExitKey = makeTile();
scrollExitKey.tile.scrollPages = 1;
scrollExitKey.tile.viewStartRow = 25;
scrollExitKey.tile.selection = authoritativeSelection('history selection');
h.setZoomedTile(scrollExitKey.tile);
const swallowedHistoryKey = keyEvent({ key: 'x' });
h.document.dispatchEvent('keydown', swallowedHistoryKey);
check('key swallowed to leave history still clears retained selection', scrollExitKey.tile.selection, null);
check('history-exit key remains swallowed without PTY bytes', scrollExitKey.sent.length, 0);
const inputClears = makeTile();
inputClears.tile.core.mux_key_encode = () => 1;
inputClears.tile.core.mux_text_encode = (len) => len;
inputClears.tile.core.mux_output_len = () => 1;
inputClears.tile.selection = authoritativeSelection('typed over');
inputClears.tile.sendKey(0, 120, 0);
check('sendKey clears retained selection before terminal output', inputClears.tile.selection, null);
check('sendKey clear does not duplicate terminal output', inputClears.sent.length, 1);
inputClears.tile.selection = authoritativeSelection('IME over');
inputClears.tile.sendText('x');
check('sendText clears retained selection before terminal output', inputClears.tile.selection, null);
check('sendText clear does not duplicate terminal output', inputClears.sent.length, 2);
const pasteClears = makeTile();
pasteClears.tile.core.mux_paste_begin = () => 0;
pasteClears.tile.core.mux_paste_end = () => 0;
pasteClears.tile.core.mux_text_encode = (len) => len;
pasteClears.tile.core.mux_output_len = () => 1;
pasteClears.tile.selection = authoritativeSelection('paste over');
h.setZoomedTile(pasteClears.tile);
let pastePrevented = false;
h.document.dispatchEvent('paste', {
preventDefault() { pastePrevented = true; },
clipboardData: { getData: () => 'p' },
});
check('real paste handler prevents browser insertion', pastePrevented, true);
check('real paste handler clears retained selection through sendText', pasteClears.tile.selection, null);
check('real paste handler emits one unwrapped text frame', pasteClears.sent.length, 1);
const pasteScroll = makeTile();
pasteScroll.tile.scrollPages = 1;
pasteScroll.tile.viewStartRow = 25;
pasteScroll.tile.core.mux_paste_begin = () => 0;
pasteScroll.tile.core.mux_paste_end = () => 0;
pasteScroll.tile.core.mux_text_encode = (len) => len;
pasteScroll.tile.core.mux_output_len = () => 1;
h.setZoomedTile(pasteScroll.tile);
h.document.dispatchEvent('paste', {
preventDefault() {},
clipboardData: { getData: () => 'pasted while paged back' },
});
check('paste returns the tile to live like every other input path', pasteScroll.tile.scrollPages, 0);
check('paste out of history mode still delivers its content', pasteScroll.sent.length, 1);
const compositionClears = makeTile();
compositionClears.tile.core.mux_text_encode = (len) => len;
compositionClears.tile.core.mux_output_len = () => 1;
compositionClears.tile.selection = authoritativeSelection('composition over');
h.setZoomedTile(compositionClears.tile);
h.elements.ime.value = 'stale';
h.elements.ime.dispatchEvent('compositionend', { data: '漢' });
check('real composition handler clears retained selection through sendText', compositionClears.tile.selection, null);
check('real composition handler emits one terminal text frame', compositionClears.sent.length, 1);
check('composition handler still clears hidden IME value', h.elements.ime.value, '');
const sharedWriter = makeTile();
const sharedCalls = [];
sharedWriter.tile.writeClipboardText = (text) => { sharedCalls.push(text); return Promise.resolve(); };
const encodedOsc = Buffer.from('shared OSC writer', 'utf8').toString('base64');
new Uint8Array(sharedWriter.memory.buffer).set(Buffer.from(encodedOsc, 'ascii'), 96);
sharedWriter.tile.core.mux_clipboard_ptr = () => 96;
sharedWriter.tile.core.mux_clipboard_len = () => encodedOsc.length;
await sharedWriter.tile.onClipboardEffect();
sharedWriter.tile.selection = authoritativeSelection('shared selection writer');
await sharedWriter.tile.copySelection();
check('OSC52 and explicit selection share the low-level writer', sharedCalls.join('|'), 'shared OSC writer|shared selection writer');
const clipboardRace = makeTile();
clipboardRace.tile.pendingClipboard = 'older OSC52';
clipboardRace.tile.clipboardVersion = 1;
clipboardRace.tile.copyUiOwner = 'clipboard';
const oldOsc = deferred(), explicitCopy = deferred();
const raceCalls = [];
h.navigator.clipboard = { writeText(text) {
raceCalls.push(text);
return text === 'older OSC52' ? oldOsc.promise : explicitCopy.promise;
} };
const oldOscRun = clipboardRace.tile.tryClipboardWrite(1, true);
clipboardRace.tile.selection = authoritativeSelection('new explicit selection');
h.setZoomedTile(clipboardRace.tile);
const raceEvent = keyEvent({ key: 'c', ctrlKey: true });
h.document.dispatchEvent('keydown', raceEvent);
check('explicit selection copy starts immediately while OSC52 is active', raceCalls.join('|'), 'older OSC52|new explicit selection');
check('selection copy owns shared UI while writes overlap', clipboardRace.tile.copyUiOwner, 'selection');
explicitCopy.resolve();
await flushPromises();
check('explicit selection success reports Copied', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied');
oldOsc.resolve();
await oldOscRun;
check('older OSC52 settlement cannot overwrite selection-copy success UI', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied');
check('older OSC52 still completes its serialized pending state', clipboardRace.tile.pendingClipboard, null);
// The re-dispatch at the end of tryClipboardWrite is ours to start, and
// an explicit user copy is the newest statement of intent: a queued OSC 52
// must not reach the system clipboard after the gesture that asked for
// something else.
const revoked = makeTile();
revoked.tile.pendingClipboard = 'first OSC52';
revoked.tile.clipboardVersion = 1;
revoked.tile.copyUiOwner = 'clipboard';
const firstOsc = deferred();
const revokedCalls = [];
h.navigator.clipboard = { writeText(text) {
revokedCalls.push(text);
return text === 'first OSC52' ? firstOsc.promise : Promise.resolve();
} };
const revokedRun = revoked.tile.tryClipboardWrite(1, true);
// A second OSC 52 lands while the first write is still with the browser,
// so it is queued in the one pending slot rather than started.
revoked.tile.pendingClipboard = 'second OSC52';
revoked.tile.clipboardVersion++;
check('queued OSC52 is not yet handed to the browser', revokedCalls.join('|'), 'first OSC52');
revoked.tile.selection = authoritativeSelection('what the user asked for');
h.setZoomedTile(revoked.tile);
h.document.dispatchEvent('keydown', keyEvent({ key: 'c', ctrlKey: true }));
await flushPromises();
firstOsc.resolve();
await revokedRun;
await flushPromises();
check('explicit copy revokes the queued OSC52 write', revokedCalls.join('|'), 'first OSC52|what the user asked for');
check('explicit copy drops the queued OSC52 text', revoked.tile.pendingClipboard, null);
check('revoked OSC52 leaves the copy control to the selection', revoked.tile.copyUiOwner, 'selection');
const oldTimerRace = makeTile();
oldTimerRace.tile.pendingClipboard = 'prior copied OSC52';
oldTimerRace.tile.clipboardVersion = 1;
oldTimerRace.tile.copyUiOwner = 'clipboard';
h.navigator.clipboard = { writeText: () => Promise.resolve() };
await oldTimerRace.tile.tryClipboardWrite(1, true);
const priorOscTimer = h.timers.at(-1);
oldTimerRace.tile.selection = authoritativeSelection('selection after OSC timer');
const selectionTimerWrite = deferred();
h.navigator.clipboard = { writeText: () => selectionTimerWrite.promise };
const selectionTimerRun = oldTimerRace.tile.copySelection();
selectionTimerWrite.reject(new Error('selection denied'));
await selectionTimerRun;
priorOscTimer.fn();
check('old OSC52 timer cannot overwrite selection-copy failure', `${oldTimerRace.tile.copyButton.className}|${oldTimerRace.tile.copyButton.textContent}`, 'copy-request on error|Copy failed');
const copyFailure = makeTile();
copyFailure.tile.selection = authoritativeSelection('retry exact selection');
h.setZoomedTile(copyFailure.tile);
h.navigator.clipboard = { writeText: () => Promise.reject(new Error('denied')) };
const failedCopyEvent = keyEvent({ key: 'c', ctrlKey: true });
h.document.dispatchEvent('keydown', failedCopyEvent);
await flushPromises();
check('selection clipboard rejection is handled as visible failure', `${copyFailure.tile.copyButton.className}|${copyFailure.tile.copyButton.textContent}`, 'copy-request on error|Copy failed');
check('selection clipboard rejection retains exact text for retry', copyFailure.tile.selection?.text, 'retry exact selection');
check('copy failure with valid text is durable, not transient unavailable feedback', copyFailure.tile.selectionFeedbackTimer, null);
const retryWrites = [];
h.navigator.clipboard = { writeText: (text) => { retryWrites.push(text); return Promise.resolve(); } };
copyFailure.tile.copyButton.dispatchEvent('click', { stopPropagation() {} });
await flushPromises();
check('selection failure button retries the retained selection only', retryWrites.join('|'), 'retry exact selection');
check('selection retry click restores hidden IME focus', h.document.activeElement, h.elements.ime);
// The same zoom gate onClipboardEffect and copyPendingClipboard carry:
// only the tile the human is looking at may reach the system clipboard.
const unzoomedSelectionCopy = makeTile();
unzoomedSelectionCopy.tile.zoomed = false;
unzoomedSelectionCopy.tile.selection = authoritativeSelection('wall tiles do not copy');
const unzoomedSelectionWrites = [];
h.navigator.clipboard = { writeText: (text) => { unzoomedSelectionWrites.push(text); return Promise.resolve(); } };
await unzoomedSelectionCopy.tile.copySelection();
check('an unzoomed tile writes no clipboard for its selection', unzoomedSelectionWrites.length, 0);
check('an unzoomed selection copy claims no copy control', unzoomedSelectionCopy.tile.copyUiOwner, null);
check('an unzoomed selection copy retains its text', unzoomedSelectionCopy.tile.selection?.text, 'wall tiles do not copy');
const invalidatedCopy = makeTile();
invalidatedCopy.tile.selection = authoritativeSelection('old selection');
h.setZoomedTile(invalidatedCopy.tile);
const pendingSelectionCopy = deferred();
h.navigator.clipboard = { writeText: () => pendingSelectionCopy.promise };
const invalidatedRun = invalidatedCopy.tile.copySelection();
invalidatedCopy.tile.clearSelection(false);
invalidatedCopy.tile.selection = authoritativeSelection('new selection');
pendingSelectionCopy.resolve();
await invalidatedRun;
check('old copy promise cannot overwrite a new selection UI', `${invalidatedCopy.tile.copyButton.className}|${invalidatedCopy.tile.copyButton.textContent}`, 'copy-request|Copy');
const oldSelectionTimer = makeTile();
oldSelectionTimer.tile.selection = authoritativeSelection('copied old selection');
h.navigator.clipboard = { writeText: () => Promise.resolve() };
await oldSelectionTimer.tile.copySelection();
const oldSelectionHide = h.timers.at(-1);
oldSelectionTimer.tile.canvas.dispatchEvent('pointerdown', pointer(23, 3, 2));
oldSelectionTimer.tile.renderCopyUi('selection', 'copy-request on error', 'New selection pending');
oldSelectionHide.fn();
check('old selection success timer cannot overwrite new selection UI', `${oldSelectionTimer.tile.copyButton.className}|${oldSelectionTimer.tile.copyButton.textContent}`, 'copy-request on error|New selection pending');
oldSelectionTimer.tile.canvas.dispatchEvent('pointercancel', { pointerId: 23 });
const unzoomCopy = makeTile();
unzoomCopy.tile.selection = authoritativeSelection('leave during copy');
unzoomCopy.tile.exitScroll = () => {};
unzoomCopy.tile.reflow = () => {};
h.setZoomedTile(unzoomCopy.tile);
const afterUnzoom = deferred();
h.navigator.clipboard = { writeText: () => afterUnzoom.promise };
const unzoomCopyRun = unzoomCopy.tile.copySelection();
h.unzoom();
check('unzoom clears retained selection during copy', unzoomCopy.tile.selection, null);
afterUnzoom.reject(new Error('late denial'));
await unzoomCopyRun;
check('late selection-copy failure after unzoom remains invisible', `${unzoomCopy.tile.copyButton.className}|${unzoomCopy.tile.copyButton.textContent}`, 'copy-request|Copy');
const unzoomScroll = makeTile();
unzoomScroll.tile.viewStartRow = 30;
unzoomScroll.tile.changeScrollPages(1);
const unzoomScrollTimeout = h.timers[unzoomScroll.tile.scrollRequestTimer - 1];
h.setZoomedTile(unzoomScroll.tile);
h.unzoom();
check('unzoom invalidates pending scroll request', unzoomScroll.tile.scrollRequest, null);
check('unzoom clears pending scroll timeout', unzoomScrollTimeout?.cleared, true);
const newSelectionUi = makeTile();
newSelectionUi.tile.selection = {
anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 76, text: null,
};
authorizeSelectionReply(newSelectionUi);
newSelectionUi.setResult(76, 3, []);
newSelectionUi.tile.onSelectionReply();
const oldUnavailableHandle = newSelectionUi.tile.selectionFeedbackTimer;
const oldUnavailableTimer = oldUnavailableHandle === null
? undefined : h.timers[oldUnavailableHandle - 1];
newSelectionUi.tile.canvas.dispatchEvent('pointerdown', pointer(17, 1, 1));
check('starting a new selection removes stale unavailable UI', `${newSelectionUi.tile.copyButton.className}|${newSelectionUi.tile.copyButton.textContent}`, 'copy-request|Copy');
check('starting a new selection clears unavailable feedback timer', oldUnavailableTimer?.cleared, true);
oldUnavailableTimer?.fn();
check('stale unavailable timer cannot alter new selection UI', `${newSelectionUi.tile.copyButton.className}|${newSelectionUi.tile.copyButton.textContent}`, 'copy-request|Copy');
newSelectionUi.tile.canvas.dispatchEvent('pointercancel', { pointerId: 17 });
const wrap = makeTile();
wrap.tile.nextSelectionId = 0xffffffff;
wrap.tile.canvas.dispatchEvent('pointerdown', pointer(14, 1, 1));
wrap.tile.canvas.dispatchEvent('pointermove', pointer(14, 2, 1));
wrap.tile.canvas.dispatchEvent('pointerup', pointer(14, 2, 1));
check('selection request ID naturally wraps from max u32 to zero', wrap.requestCalls[0]?.[0], 0);
check('wrapped zero remains the retained correlation ID', wrap.tile.selection.requestId, 0);
wrap.setResult(0, 0, Buffer.from('wrapped'));
wrap.tile.onSelectionReply();
check('wrapped zero reply correlates normally', wrap.tile.selection.text, 'wrapped');
const reset = makeTile();
reset.tile.selection = {
anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 7, text: 'resolved old session',
};
reset.tile.drag = { pointerId: 15, moved: true };
reset.tile.lastPointerX = 20;
reset.tile.lastPointerY = 30;
reset.tile.viewStartRow = 30;
reset.tile.changeScrollPages(1);
const resetScrollTimeout = h.timers[reset.tile.scrollRequestTimer - 1];
reset.tile.canvas.setPointerCapture(15);
reset.tile.selectionScrollTimer = h.context.setInterval(() => {}, 120);
const resetTimer = reset.tile.selectionScrollTimer;
reset.tile.resetCore('selection test');
check('destructive core reset clears resolved selection', reset.tile.selection, null);
check('destructive core reset clears drag state', reset.tile.drag, null);
check('destructive core reset clears pointer X', reset.tile.lastPointerX, null);
check('destructive core reset clears pointer Y', reset.tile.lastPointerY, null);
check('destructive core reset stops selection timer', h.intervals[resetTimer - 1]?.cleared, true);
check('destructive core reset clears orphaned scroll request', reset.tile.scrollRequest, null);
check('destructive core reset clears scroll timeout', resetScrollTimeout?.cleared, true);
const reconnect = makeTile();
reconnect.tile.canvas.dispatchEvent('pointerdown', pointer(16, 1, 1));
const reconnectTimer = reconnect.tile.selectionScrollTimer;
reconnect.tile.selection.requestId = 44;
reconnect.tile.connect();
reconnect.tile.ws.onopen();
check('new socket clears pending selection from prior connection', reconnect.tile.selection, null);
check('new socket clears prior drag state', reconnect.tile.drag, null);
check('new socket clears prior pointer X', reconnect.tile.lastPointerX, null);
check('new socket clears prior pointer Y', reconnect.tile.lastPointerY, null);
check('new socket stops prior selection timer', h.intervals[reconnectTimer - 1]?.cleared, true);
const reattach = makeTile();
reattach.tile.selection = {
anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 45, text: 'old attach',
};
reattach.tile.sendAttach(false);
check('reattach clears selection text from prior session', reattach.tile.selection, null);
const openPaint = makeTile();
delete openPaint.tile.reflow;
const openOps = [];
openPaint.tile.core.mux_mark_all_dirty = () => { openOps.push('mark'); };
openPaint.tile.paintLive = () => { openOps.push('live'); };
openPaint.tile.paintScroll = () => { openOps.push('scroll'); };
openPaint.tile.selection = {
anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 50, text: 'painted',
};
openPaint.tile.connect();
openPaint.tile.ws.onopen();
check('socket open erases retained live selection pixels once', openOps.join('|'), 'mark|live');
const attachPaint = makeTile();
delete attachPaint.tile.reflow;
const attachOps = [];
attachPaint.tile.scrollPages = 2;
attachPaint.tile.core.mux_mark_all_dirty = () => { attachOps.push('mark'); };
attachPaint.tile.paintLive = () => { attachOps.push('live'); };
attachPaint.tile.paintScroll = () => { attachOps.push('scroll'); };
attachPaint.tile.selection = {
anchor: { row: 20, col: 1 }, active: { row: 21, col: 2 }, requestId: 51, text: 'history pixels',
};
attachPaint.tile.sendAttach(false);
check('reattach erases retained history selection pixels once', attachOps.join('|'), 'mark|scroll');
const resetPaint = makeTile();
delete resetPaint.tile.reflow;
const resetOps = [];
resetPaint.tile.scrollPages = 2;
resetPaint.tile.core.mux_mark_all_dirty = () => { resetOps.push('mark'); };
resetPaint.tile.paintLive = () => { resetOps.push('live'); };
resetPaint.tile.paintScroll = () => { resetOps.push('scroll'); };
resetPaint.tile.selection = {
anchor: { row: 20, col: 1 }, active: { row: 21, col: 2 }, requestId: 52, text: 'old core',
};
resetPaint.tile.resetCore('paint safety test');
check('successful destructive reset repaints only the new live core', resetOps.join('|'), 'mark|live');
const failedResetPaint = makeTile();
delete failedResetPaint.tile.reflow;
const failedResetOps = [];
failedResetPaint.tile.canvas.width = 640;
failedResetPaint.tile.canvas.height = 480;
failedResetPaint.tile.ctx.setTransform = (...args) => { failedResetOps.push(`transform:${args.join(',')}`); };
failedResetPaint.tile.ctx.clearRect = (...args) => { failedResetOps.push(`clear:${args.join(',')}`); };
failedResetPaint.tile.core.mux_init = () => -1;
failedResetPaint.tile.core.mux_mark_all_dirty = () => { throw new Error('invalid old core read'); };
failedResetPaint.tile.paintLive = () => { throw new Error('invalid old live paint'); };
failedResetPaint.tile.paintScroll = () => { throw new Error('invalid old scroll paint'); };
failedResetPaint.tile.selection = {
anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 53, text: 'must disappear',
};
failedResetPaint.tile.scrollRequest = { fromPages: 1, start: 20 };
failedResetPaint.tile.resetCore('failed paint safety test');
check(
'failed destructive reset clears backing pixels without reading invalid core',
failedResetOps.join('|'),
'transform:1,0,0,1,0,0|clear:0,0,640,480',
);
check('failed destructive reset also clears orphaned scroll request', failedResetPaint.tile.scrollRequest, null);
}
// The terminal-status region: which badge stops a tile re-attaching, and
// the two words the hub and the page agree on. Nothing on the wire can
// see any of it — wsclient attaches 0x0 and renders no badge — so this
// shell is the gate, and a real browser is the gate for pixels and keys.
async function verifyStatusShell(shell) {
const h = browserShell(shell);
const wall = h.document.createElement('div');
// ENV_FRAME/ENV_CONTROL and the exit_status opcode, spelled as the
// browser receives them: mux.js reads these bytes off a WebSocket, so a
// fixture that called its constants instead would pin nothing.
const exitStatus = (code) => Uint8Array.from([0x00, 0x82, 1, 0, 0, 0, code]);
const control = (state) =>
Uint8Array.from([0x01, ...new TextEncoder().encode(JSON.stringify({ state }))]);
const makeTile = () => {
const tile = new h.Tile(9, 'status fixture', wall, '');
tile.core = {
memory: { buffer: new ArrayBuffer(64) },
mux_attach_payload: () => 20,
mux_output_ptr: () => 0,
mux_output_len: () => 20,
};
const sent = [];
tile.sendFrame = (type) => { sent.push(type); return true; };
tile.reflow = () => {};
return { tile, sent };
};
// --- the badge vocabulary, which webhub.zig's restore rule reads back ---
const refusal = makeTile();
refusal.tile.onMessage(exitStatus(0));
check('a refusal before any grid badges refused', refusal.tile.status, 'refused');
check('the refusal badge names no reason the daemon never gave', refusal.tile.statusText, 'refused');
const ending = makeTile();
ending.tile.gotState = true;
ending.tile.onMessage(exitStatus(3));
check('an exit after a grid badges exited', ending.tile.status, 'exited');
check('the exit badge carries the code', ending.tile.statusText, 'exited 3');
// --- what each badge does to the next attach ---
const exited = makeTile();
exited.tile.gotState = true;
exited.tile.onMessage(exitStatus(0));
exited.tile.zoomed = true; // a sized attach CREATES: the dangerous one
exited.tile.sendAttach(false);
check('a session the user ended is not re-attached', exited.sent.length, 0);
// The restore heal IS a refusal followed by a re-attach: the hub births
// the session and re-dials, and this attach is what lands on it. Gate
// the whole TERMINAL set and the browser wall stays dead after a daemon
// restart.
const refused = makeTile();
refused.tile.onMessage(exitStatus(0));
refused.tile.sendAttach(false);
check('a refused tile keeps re-attaching', refused.sent.length, 1);
// --- the precedence the gate rests on ---
const narrated = makeTile();
narrated.tile.gotState = true;
narrated.tile.onMessage(exitStatus(0));
narrated.tile.onMessage(control('up'));
check('link news cannot clear exited', narrated.tile.status, 'exited');
check('and the up it rode in on sends no attach', narrated.sent.length, 0);
}
// --- wire builders (layouts golden-pinned in protocol.zig) ---
//
// A row on the wire is a CellRow: a u16 cell count, then runs of cells. A run
// is a u16 count, a u8 mask of the style fields that CHANGED since the run
// before it in the same row, those fields, and then the cells. Each cell is a
// head byte of `wide << 6 | text_len` followed by its UTF-8, except in an
// all-ASCII run (mask bit 7), where every cell is one bare byte.
//
// Colours pack as 0 none, (1 << 24) | index palette, (2 << 24) | rgb; on the
// wire a present colour is a tag byte, 0 none, 1 palette + one byte, 2 rgb +
// three. Style flags are ghostty's bit order: bold 0, italic 1, faint 2,
// blink 3, inverse 4, invisible 5, strikethrough 6, overline 7.
const MASK_FLAGS = 1 << 0, MASK_FG = 1 << 1, MASK_BG = 1 << 2, MASK_UL = 1 << 3;
const MASK_ASCII = 1 << 7;
function colorBytes(packed) {
const tag = packed >>> 24;
if (tag === 0) return [0];
if (tag === 1) return [1, packed & 0xff];
return [2, (packed >>> 16) & 0xff, (packed >>> 8) & 0xff, packed & 0xff];
}
/// One run of cells sharing a style. `cells` are {text, wide} — wide 0 narrow,
/// 1 wide, 2 spacer_tail, 3 spacer_head.
function runBytes(style, cells, prev) {
const out = [];
out.push(cells.length & 0xff, (cells.length >> 8) & 0xff);
const ascii = cells.every((c) => c.wide === 0 && c.text.length === 1 &&
c.text.charCodeAt(0) >= 0x20 && c.text.charCodeAt(0) <= 0x7e);
let mask = ascii ? MASK_ASCII : 0;
const fields = [];
if (style.flags !== prev.flags) {
mask |= MASK_FLAGS;
fields.push(style.flags & 0xff, (style.flags >> 8) & 0xff);
}
if (style.fg !== prev.fg) { mask |= MASK_FG; fields.push(...colorBytes(style.fg)); }
if (style.bg !== prev.bg) { mask |= MASK_BG; fields.push(...colorBytes(style.bg)); }
if (style.ul !== prev.ul) { mask |= MASK_UL; fields.push(...colorBytes(style.ul)); }
out.push(mask, ...fields);
for (const c of cells) {
const bytes = [...Buffer.from(c.text, 'utf8')];
if (ascii) out.push(bytes[0]);
else out.push((c.wide << 6) | bytes.length, ...bytes);
}
return out;
}
/// A CellRow from a list of runs, each {style, cells}. Styles default to
/// plain, so a caller states only what it means to exercise.
function cellRow(runs) {
const plain = { fg: 0, bg: 0, ul: 0, flags: 0 };
let ncells = 0;
for (const r of runs) ncells += r.cells.length;
const out = [ncells & 0xff, (ncells >> 8) & 0xff];
let prev = plain;
for (const r of runs) {
const style = { ...plain, ...(r.style || {}) };
out.push(...runBytes(style, r.cells, prev));
prev = style;
}
return Buffer.from(out);
}
/// A CellRow of plain narrow ASCII: the common case, one run.
function textRow(text) {
if (text.length === 0) return Buffer.from([0, 0]);
return cellRow([{ cells: [...text].map((ch) => ({ text: ch, wide: 0 })) }]);
}
function snapshotPayload({ seq, history, cols, rows, epoch, cx = 0, cy = 0 }, rowList) {
const head = Buffer.alloc(28);
head.writeBigUInt64LE(BigInt(seq), 0);
head.writeUInt32LE(history, 8);
head.writeUInt16LE(cols, 12);
head.writeUInt16LE(rows, 14);
head.writeBigUInt64LE(BigInt(epoch), 16);
head.writeUInt16LE(cx, 24);
head.writeUInt16LE(cy, 26);
const body = [];
for (let y = 0; y < rows; y++) {
const r = rowList[y];
body.push(r === undefined ? textRow('') : (Buffer.isBuffer(r) ? r : textRow(r)));
}
return Buffer.concat([head, ...body]);
}
function deltaPayload({ seq, history, cx, cy }, rowEntries) {
const parts = [];
const hdr = Buffer.alloc(18);
hdr.writeBigUInt64LE(BigInt(seq), 0);
hdr.writeUInt32LE(history, 8);
hdr.writeUInt16LE(cx, 12);
hdr.writeUInt16LE(cy, 14);
hdr.writeUInt16LE(rowEntries.length, 16);
parts.push(hdr);
for (const [row, content] of rowEntries) {
const bytes = Buffer.isBuffer(content) ? content : textRow(content);
const rh = Buffer.alloc(6);
rh.writeUInt16LE(row, 0);
rh.writeUInt32LE(bytes.length, 2);
parts.push(rh, bytes);
}
return Buffer.concat(parts);
}
/// A scrollback_chunk payload: the echoed start and count, then the rows.
function scrollChunk(start, rowList) {
const head = Buffer.alloc(6);
head.writeUInt32LE(start, 0);
head.writeUInt16LE(rowList.length, 4);
return Buffer.concat([head, ...rowList.map((r) => (Buffer.isBuffer(r) ? r : textRow(r)))]);
}
// Settings: the browser's own theme and font. Neither is a protocol
// concern — colour is invented at paint time from a palette INDEX, and
// the daemon has no notion of a font — with one exception this file
// pins: font size moves the cell, so a ZOOMED tile's grid moves with it.
async function verifySettingsShell(shell, html) {
const h = browserShell(shell);
const parse = h.parseGhosttyTheme;
check('shell exposes a ghostty theme parser', typeof parse, 'function');
check('shell exposes settings application', typeof h.applySettings, 'function');
check('shell exposes persisted settings loading', typeof h.loadSettings, 'function');
if (typeof parse !== 'function' || typeof h.applySettings !== 'function') return;
// --- the parser, against what a real theme file contains ---
const mocha = [
'palette = 0=#45475a',
'palette = 15=#bac2de',
'background = #1e1e2e',
'foreground = #cdd6f4',
'cursor-color = #f5e0dc',
'cursor-text = #1e1e2e',
'selection-background = #585b70',
'selection-foreground = #cdd6f4',
].join('\n');
const m = parse(mocha);
// The value of a palette line CONTAINS an '=' — splitting on all of
// them is the bug this asserts against.
check('palette line splits on the first = only', m?.theme.palette[0], '#45475a');
check('palette line reads the last ANSI slot', m?.theme.palette[15], '#bac2de');
check('theme reads background', m?.theme.bg, '#1e1e2e');
check('theme reads foreground', m?.theme.fg, '#cdd6f4');
check('theme reads cursor colour', m?.theme.cursor, '#f5e0dc');
check('theme reads selection background', m?.theme.selection, '#585b70');
// A translucent overlay cannot honour a text colour: naming them is not
// the same as applying them, so they count as ignored, not applied.
check('theme counts keys it cannot honour as ignored', m?.ignored, 2);
check('parser tolerates missing spaces', parse('palette=8=#585b70')?.theme.palette[8], '#585b70');
check('parser tolerates CRLF', parse('background = #112233\r\nforeground = #445566\r\n')?.theme.bg, '#112233');
check('parser strips a BOM', parse('background = #112233')?.theme.bg, '#112233');
check('parser ignores comments', parse('# background = #ffffff\nbackground = #112233')?.theme.bg, '#112233');
check('parser accepts bare hex', parse('background = 1e1e2e')?.theme.bg, '#1e1e2e');
check('parser expands short hex', parse('background = #abc')?.theme.bg, '#aabbcc');
check('parser takes the last of duplicate keys', parse('background = #111111\nbackground = #222222')?.theme.bg, '#222222');
check('parser is case-insensitive about hex', parse('background = #AABBCC')?.theme.bg, '#aabbcc');
// 16-255 are an xterm formula, not a theme's to set: taking one would
// leave the cube incoherent with the sixteen below it.
const beyond = parse('palette = 16=#ffffff\npalette = 255=#ffffff\nbackground = #111111');
check('parser refuses palette indices above 15', beyond?.theme.palette.length, 16);
check('parser counts an out-of-range palette line as ignored', beyond?.ignored, 2);
check('parser refuses a negative palette index', parse('palette = -1=#ffffff'), null);
check('parser refuses a non-numeric palette index', parse('palette = x=#ffffff'), null);
check('parser refuses a malformed colour', parse('background = #12345'), null);
check('parser refuses a colour name', parse('background = rebeccapurple'), null);
// A file that is not a theme must be REFUSED, not silently applied as
// an empty one: a PNG and a whole ghostty config both land here.
check('parser refuses a file with no colours', parse('PNG\r\n\n binary'), null);
check('parser refuses an empty file', parse(''), null);
const config = 'font-family = JetBrains Mono\nkeybind = ctrl+a=new_tab\nbackground = #101010\n';
const fromConfig = parse(config);
check('a whole ghostty config still yields its colours', fromConfig?.theme.bg, '#101010');
check('a whole ghostty config reports what it skipped', fromConfig?.ignored, 2);
// keybind's value contains '=' too, so a first-= split must not let it
// through as a colour.
check('a keybind line is never mistaken for a colour', fromConfig?.applied, 1);
// --- applying a theme ---
const before = h.settingsState();
check('the untouched palette is the full xterm 256', before.PALETTE.length, 256);
const cube = before.PALETTE.slice(16).join('|');
h.applySettings({ theme: parse(mocha).theme, fontFamily: null, fontSize: null });
const themed = h.settingsState();
check('applying a theme replaces the ANSI slots', themed.PALETTE[0], '#45475a');
check('applying a theme leaves the 240-colour cube computed', themed.PALETTE.slice(16).join('|'), cube);
check('applying a theme keeps the palette 256 long', themed.PALETTE.length, 256);
// A theme names 0 and 15 here and nothing between: the unnamed slots
// keep the built-in ANSI colour rather than becoming undefined.
check('a partial theme keeps built-in colours for unnamed slots', themed.PALETTE[1], before.PALETTE[1]);
check('applying a theme sets the default foreground', themed.DEFAULT_FG, '#cdd6f4');
check('applying a theme sets the default background', themed.DEFAULT_BG, '#1e1e2e');
// The overlays are translucent so the glyph under them survives: theme
// colour plus an alpha byte, never a second hardcoded constant.
check('the cursor overlay derives from the theme', themed.CURSOR_FILL, '#f5e0dc88');
check('the selection overlay derives from the theme', themed.SELECTION_FILL, '#585b7055');
// The canvas element's own background is terminal ground: it shows
// before the first frame and through sizeCanvas's rounding edge, so a
// light theme with a black canvas flashes black.
check('applying a theme republishes terminal ground', h.documentElement.style['--term-bg'], '#1e1e2e');
const noCursor = parse('background = #101010\nforeground = #e0e0e0');
h.applySettings({ theme: noCursor.theme, fontFamily: null, fontSize: null });
const fallback = h.settingsState();
check('a theme without a cursor colour falls back to its foreground', fallback.CURSOR_FILL, '#e0e0e088');
check('a theme without a selection colour keeps the built-in blend', fallback.SELECTION_FILL, '#6ab0e055');
h.applySettings({ theme: null, fontFamily: null, fontSize: null });
const reset = h.settingsState();
check('clearing the theme restores the built-in foreground', reset.DEFAULT_FG, before.DEFAULT_FG);
check('clearing the theme restores the built-in palette', reset.PALETTE.join('|'), before.PALETTE.join('|'));
// --- the overlays actually paint what the theme said ---
const painted = h.makeSettingsTile();
h.applySettings({ theme: parse(mocha).theme, fontFamily: null, fontSize: null });
const fills = [];
painted.ctx = {
fillStyle: '',
fillRect(x, y, width, height) { fills.push([x, y, width, height, this.fillStyle]); },
};
painted.paintCursor();
check('the cursor paints the themed colour', fills.at(-1)?.[4], '#f5e0dc88');
painted.viewStartRow = 0;
painted.selection = { anchor: { row: 0, col: 0 }, active: { row: 0, col: 2 }, requestId: 1, text: null };
painted.paintSelection();
check('the selection paints the themed colour', fills.at(-1)?.[4], '#585b7055');
// A repaint after a settings change must CLEAR first. A wall tile paints
// through a scaled transform, so each cell fill lands on fractional
// device pixels and its antialiased seam keeps a blend of whatever was
// underneath — a light theme painted straight over a dark one keeps the
// old colour in a lattice along every cell boundary.
const stale = h.makeSettingsTile();
const order = [];
stale.clearBackingCanvas = () => order.push('clear');
stale.reflow = () => order.push('reflow');
stale.repaintForSettings();
check('a settings repaint clears the stale palette before painting', order.join('|'), 'clear|reflow');
const coreless = h.makeSettingsTile();
coreless.core = null;
coreless.clearBackingCanvas = () => order.push('clear-coreless');
coreless.repaintForSettings();
check('a tile with no core is left alone by a settings repaint', order.join('|'), 'clear|reflow');
// --- font: the one setting that reaches the daemon ---
h.applySettings({ theme: null, fontFamily: null, fontSize: null });
const baseMetrics = h.settingsState().METRICS;
check('the default cell is measured from the default font', `${baseMetrics.w}x${baseMetrics.h}`, '8x14');
const zoomed = h.makeSettingsTile();
zoomed.zoomed = true;
const baseCols = zoomed.zoomCols();
h.applySettings({ theme: null, fontFamily: null, fontSize: 28 });
const bigger = h.settingsState().METRICS;
check('a larger font measures a larger cell', `${bigger.w}x${bigger.h}`, '16x28');
check('a larger font fits fewer columns in the same box', zoomed.zoomCols() < baseCols, true);
check('the font string keeps the size-then-family shape', h.settingsState().FONT, '28px ui-monospace, monospace');
check('a font family is applied verbatim', (h.applySettings({ theme: null, fontFamily: 'JetBrains Mono', fontSize: 14 }), h.settingsState().FONT), '14px JetBrains Mono');
// A zero size would divide the zoom box by a zero-width cell: zoomCols
// becomes Infinity, setUint16 writes 0, and the daemon is told to
// resize the session to no columns at all.
h.applySettings({ theme: null, fontFamily: null, fontSize: 0 });
const clamped = h.settingsState();
check('a zero font size is clamped to something measurable', clamped.METRICS.w >= 1, true);
check('a zero font size never yields an infinite column count', Number.isFinite(zoomed.zoomCols()), true);
h.applySettings({ theme: null, fontFamily: null, fontSize: 4000 });
check('an absurd font size is clamped too', h.settingsState().METRICS.w < 200, true);
check('a non-numeric font size falls back to the default', (h.applySettings({ theme: null, fontFamily: null, fontSize: 'huge' }), h.settingsState().FONT), '14px ui-monospace, monospace');
// The resize is the SAME claim the window-resize handler makes: only
// the zoomed tile sends it, and only when the grid really moved.
const sent = [];
const claiming = h.makeSettingsTile();
claiming.sendFrame = (type, payload) => sent.push([type, payload]);
claiming.zoomed = true;
h.setZoomedTile(claiming);
h.applySettings({ theme: null, fontFamily: null, fontSize: 28 });
check('a font change claims the grid for the zoomed tile', sent.length, 1);
const claimedCols = sent.length ? new DataView(sent[0][1].buffer, sent[0][1].byteOffset).getUint16(0, true) : 0;
check('the claimed grid is the one the new cell fits', claimedCols, claiming.zoomCols());
check('the claimed grid is never zero columns', claimedCols > 0, true);
// Theme is paint-local: with the grid already agreeing, applying one
// must put nothing on the wire at all.
claiming.core.mux_cols = () => claiming.zoomCols();
claiming.core.mux_rows = () => claiming.zoomRows();
sent.length = 0;
h.applySettings({ theme: parse(mocha).theme, fontFamily: null, fontSize: 28 });
check('a theme change sends nothing to the daemon', sent.length, 0);
h.setZoomedTile(null);
// An unzoomed tile claims nothing, so its font is paint-only — the
// wall stays passive however the settings move.
const passive = h.makeSettingsTile();
const passiveSent = [];
passive.sendFrame = (type, payload) => passiveSent.push([type, payload]);
h.applySettings({ theme: null, fontFamily: null, fontSize: 20 });
check('an unzoomed tile sends nothing when the font changes', passiveSent.length, 0);
// --- the panel must not hold the keyboard the terminal owns ---
// Zoom hides the settings tile behind the shade, so the panel is
// unreachable by pointer — but Tab still reaches it, and a key answered
// by both the focused field and the session types the user's font size
// into their shell. Caught in a real browser, not here, so it is pinned.
const keyed = h.makeSettingsTile();
const typed = [];
keyed.sendKey = () => typed.push('key');
keyed.zoomed = true;
h.setZoomedTile(keyed);
const fakeSettingsTile = h.document.createElement('div');
const fakePanelField = fakeSettingsTile.appendChild(h.document.createElement('input'));
const fakeSizeField = fakeSettingsTile.appendChild(h.document.createElement('input'));
h.setSettingsTile(fakeSettingsTile);
const key = (target) => {
target.focus();
h.document.dispatchEvent('keydown', {
key: '2', target, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false,
preventDefault() {}, isComposing: false,
});
};
key(fakePanelField);
check('a focused settings field keeps its keys out of the session', typed.length, 0);
key(fakeSizeField);
check('a second settings field keeps its keys out too', typed.length, 0);
// The guard is the settings tile, not "any input": the terminal still
// has to receive keys when focus is anywhere else.
const elsewhere = h.document.createElement('input');
key(elsewhere);
check('a key with focus outside the settings tile still reaches the session', typed.length > 0, true);
h.setZoomedTile(null);
h.setSettingsTile(null);
// --- persistence, where the browser is allowed to say no ---
check('settings load to defaults with no stored value', h.loadSettings().fontSize, 14);
const stored = browserShell(shell, { storage: new Map([['mux.settings.v1', JSON.stringify({ v: 1, fontSize: 20 })]]) });
check('stored settings are read back', stored.loadSettings().fontSize, 20);
const corrupt = browserShell(shell, { storage: new Map([['mux.settings.v1', '{not json']]) });
check('corrupt stored settings fall back to defaults', corrupt.loadSettings().fontSize, 14);
const older = browserShell(shell, { storage: new Map([['mux.settings.v1', JSON.stringify({ fontSize: 20 })]]) });
check('settings without the current version are discarded', older.loadSettings().fontSize, 14);
// Chrome with cookies blocked throws on ACCESSING localStorage, not
// just on write, and the page must still boot.
const denied = browserShell(shell, { storage: 'throws' });
// Caught rather than allowed to propagate: an unguarded storage read
// throws out of here, and a stack trace kills the whole run instead of
// naming the one assertion that broke.
let deniedSize = null;
try { deniedSize = denied.loadSettings().fontSize; } catch (_) { deniedSize = 'threw'; }
check('a browser that refuses storage still loads settings', deniedSize, 14);
let saveThrew = false;
try { denied.saveSettings({ theme: null, fontFamily: null, fontSize: 20 }); } catch (_) { saveThrew = true; }
check('a browser that refuses storage still saves without throwing', saveThrew, false);
const absent = browserShell(shell, { storage: 'none' });
check('a browser with no storage at all still loads settings', absent.loadSettings().fontSize, 14);
// --- the file the user hands us ---
check('the theme upload is capped before it is read', typeof h.THEME_BYTES_MAX, 'number');
check('the cap is smaller than any plausible paste of a log', h.THEME_BYTES_MAX <= 1 << 20, true);
// --- the wall keeps its one standing control ---
const executableHtmlCss = executableCss(html);
check(
'terminal ground is a themeable variable, not a hardcoded black',
/\.tile\s+canvas\s*\{[^}]*background\s*:\s*var\(--term-bg/.test(executableHtmlCss),
true,
);
check(
'the settings panel lives inside the settings tile',
/\.tile\.settings-tile\s+\.settings/.test(executableHtmlCss),
true,
);
// The browser authors nothing: the wall is the hosts file, so a page
// that grew an add box or an `x` back would be showing a control whose
// route the hub answers 405 to.
check(
'the page has no add box and no per-tile close',
/addTileEl|removeTile|putOrder|draggable/.test(shell),
false,
);
// --- the `+` must not out-race the hub it just asked ---
// `Hub.spawn` stores a poke and answers; the poller feels it at its next
// 50 ms slice and only then dials. Six unspaced fetches finish in ~10 ms,
// so an unspaced loop finds nothing, falls through, and the session the
// user asked for arrives unzoomed a second later. The delay is a named
// function precisely so this check can reach it — the network path around
// it is not executable here.
check('the spawn poll delay is a function', typeof h.spawnPollDelay, 'function');
check('the spawn poll delay clears the hub 50ms poke slice', h.SPAWN_POLL_MS >= 100, true);
const timersBefore = h.timers.length;
h.spawnPollDelay();
const spawnTimer = h.timers.at(-1);
check('the spawn poll delay schedules exactly one timer', h.timers.length - timersBefore, 1);
check('the spawn poll delay sleeps SPAWN_POLL_MS', spawnTimer?.ms, h.SPAWN_POLL_MS);
// ...and `spawnHere` actually awaits it. Read off the EXECUTABLE tokens,
// so a delay that survives only in a comment does not pass.
const spawnBodies = balancedBodiesAfter(
executableJsTokens(shell),
/\basync function spawnHere\s*\([^)]*\)\s*\{/g,
);
check('shell has exactly one spawnHere', spawnBodies.length, 1);
const spawnBody = spawnBodies.length === 1 ? (spawnBodies[0].body ?? '') : '';
check(
'spawnHere waits between its polls',
/await\s+spawnPollDelay\s*\(\s*\)/.test(spawnBody),
true,
);
// The wait is INSIDE the retry loop, not once before it: a single sleep
// would cover the poke slice and then burn the remaining five polls in
// 10 ms against a daemon that answered slowly.
const spawnLoops = balancedBodiesAfter(spawnBody, /\bfor\s*\([^)]*SPAWN_POLLS[^)]*\)\s*\{/g);
check('spawnHere has one bounded retry loop', spawnLoops.length, 1);
check(
'the wait is inside the retry loop',
/await\s+spawnPollDelay\s*\(\s*\)/.test(spawnLoops.length === 1 ? (spawnLoops[0].body ?? '') : ''),
true,
);
}
async function main() {
const bin = fs.readFileSync(wasmPath);
const { instance } = await WebAssembly.instantiate(bin, {});
const e = instance.exports;
const mem = () => Buffer.from(e.memory.buffer); // ALWAYS fresh
const inputPtr = e.mux_input_ptr();
const stage = (buf) => {
if (buf.length > e.mux_input_cap()) throw new Error('over cap');
buf.copy(mem(), e.mux_input_ptr());
return buf.length;
};
// Exercise consumers without asking for the staging pointer again. This
// catches invalidation at the consumer boundary as well as mux_input_ptr.
const stageThroughKnownPtr = (buf) => {
if (buf.length > e.mux_input_cap()) throw new Error('over cap');
buf.copy(mem(), inputPtr);
return buf.length;
};
const outBytes = () =>
Buffer.from(mem().subarray(e.mux_output_ptr(), e.mux_output_ptr() + e.mux_output_len()));
const cell = (x, y) => {
const cols = e.mux_cols();
const base = e.mux_viewport_ptr() + (y * cols + x) * 16;
const m = mem();
return {
cp: m.readUInt32LE(base),
fg: m.readUInt32LE(base + 4),
bg: m.readUInt32LE(base + 8),
flags: m.readUInt32LE(base + 12),
};
};
// The resume coordinates as the only reader of them has them: the
// attach payload. There are no scalar accessors for seq/epoch — the
// shell never wanted them (it cannot hand-assemble a u64 anyway), so
// the payload IS the interface, and what asserts on it is this.
const resumeArgs = () => {
e.mux_attach_payload(0, 0, 0);
const b = outBytes();
return { seq: b.readBigUInt64LE(4), epoch: b.readBigUInt64LE(12) };
};
// --- init ---
check('init', e.mux_init(80, 24), 0);
check('cols', e.mux_cols(), 80);
check('rows', e.mux_rows(), 24);
// --- shared client semantics ABI ---
// Pasting is raw until the daemon explicitly samples mode 2004 as on.
stage(Buffer.from('seed'));
check('seed output before disabled begin', e.mux_text_encode(4), 4);
check('paste begin disabled', e.mux_paste_begin(), 0);
check('paste begin disabled clears output', e.mux_output_len(), 0);
stage(Buffer.from('again'));
check('seed output before disabled end', e.mux_text_encode(5), 5);
check('paste end disabled', e.mux_paste_end(), 0);
check('paste end disabled clears output', e.mux_output_len(), 0);
const modesOn = Buffer.from([1, 0, 0, 0]);
const modesOff = Buffer.from([0, 0, 0, 0]);
check('client modes on action', e.mux_client_frame(0x8d, stage(modesOn)), clientAction.terminalModes);
check('bracketed paste on', e.mux_bracketed_paste(), 1);
check('client repeated modes action', e.mux_client_frame(0x8d, stage(modesOn)), clientAction.terminalModes);
check('client modes off action', e.mux_client_frame(0x8d, stage(modesOff)), clientAction.terminalModes);
check('bracketed paste off', e.mux_bracketed_paste(), 0);
check('client malformed modes ignored', e.mux_client_frame(0x8d, stage(Buffer.from([1, 0]))), clientAction.ignored);
check('malformed modes preserve state', e.mux_bracketed_paste(), 0);
const clipboard = Buffer.from([0, 'c'.charCodeAt(0), ...Buffer.from('aGk=', 'ascii')]);
check('client clipboard action', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard);
check('clipboard target', e.mux_clipboard_target(), 'c'.charCodeAt(0));
check('clipboard len', e.mux_clipboard_len(), 4);
check(
'clipboard borrowed bytes',
Buffer.from(mem().subarray(e.mux_clipboard_ptr(), e.mux_clipboard_ptr() + e.mux_clipboard_len())).toString('ascii'),
'aGk=',
);
check('client bell action', e.mux_client_frame(0x8f, stage(Buffer.from([1]))), clientAction.bell);
check('bell clears clipboard getter', e.mux_clipboard_len(), 0);
check('client unsafe clipboard ignored', e.mux_client_frame(0x8f, stage(Buffer.from([0, 'X'.charCodeAt(0), 'A'.charCodeAt(0)]))), clientAction.ignored);
check('ignored event clears clipboard getter', e.mux_clipboard_len(), 0);
check('client unknown u8 type ignored', e.mux_client_frame(0x40, 0), clientAction.ignored);
check('clipboard before oversize guard', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard);
check('client oversize length', e.mux_client_frame(0x8f, e.mux_input_cap() + 1), -2);
check('oversize clears clipboard len', e.mux_clipboard_len(), 0);
check('oversize clears clipboard target', e.mux_clipboard_target(), 0);
check('clipboard before wide-type guard', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard);
check('client wide type ignored', e.mux_client_frame(0x100, 0), clientAction.ignored);
check('wide type clears clipboard len', e.mux_clipboard_len(), 0);
check('wide type clears clipboard target', e.mux_clipboard_target(), 0);
const selectionReply = (id, status, text = '', historyRows = 0) => {
const body = Buffer.from(text, 'utf8');
const reply = Buffer.alloc(41 + body.length);
reply.writeUInt32LE(id, 0);
reply[4] = status;
reply.writeUInt32LE(historyRows, 5);
body.copy(reply, 41);
return reply;
};
const selectionText = () => Buffer.from(mem().subarray(
e.mux_selection_ptr(),
e.mux_selection_ptr() + e.mux_selection_len(),
));
const firstSelectionId = 0x78563412;
check('selection request len', e.mux_selection_request(firstSelectionId, 0x44332211, 0x6655, 0xaa998877, 0xccbb), 37);
check('selection request output len', e.mux_output_len(), 37);
check(
'selection request golden bytes',
outBytes().toString('hex'),
'12345678112233445566778899aabbcc000000000000000000000000000000000000000000',
);
check('selection invalid anchor col', e.mux_selection_request(30, 1, 65536, 2, 3), -3);
check('selection invalid anchor col clears output', e.mux_output_len(), 0);
check('selection invalid active col', e.mux_selection_request(30, 1, 2, 3, 65536), -3);
check('selection invalid active col clears output', e.mux_output_len(), 0);
check(
'selection invalid columns preserve pending id',
e.mux_client_frame(0x90, stage(selectionReply(firstSelectionId, 0, 'preserved'))),
clientAction.selection,
);
const supersededSelectionId = 0x01020304;
check('selection superseded request', e.mux_selection_request(supersededSelectionId, 5, 6, 7, 8), 37);
const latestSelectionId = 0x10203040;
check('selection latest request replaces pending', e.mux_selection_request(latestSelectionId, 9, 10, 11, 12), 37);
check(
'selection request survives enlarged input staging capacity',
outBytes().toString('hex'),
'40302010090000000a000b0000000c00000000000000000000000000000000000000000000',
);
check(
'selection stale reply ignored',
e.mux_client_frame(0x90, stage(selectionReply(supersededSelectionId, 0, 'stale'))),
clientAction.ignored,
);
check('selection stale reply leaves getters empty', e.mux_selection_len(), 0);
check(
'selection malformed matching reply ignored',
e.mux_client_frame(0x90, stage(selectionReply(latestSelectionId, 1, 'bad'))),
clientAction.ignored,
);
check(
'selection valid after malformed matching reply',
e.mux_client_frame(0x90, stage(selectionReply(latestSelectionId, 0, 'selected 漢', 0xdeadbeef))),
clientAction.selection,
);
check('selection getter id', e.mux_selection_id(), latestSelectionId);
check('selection getter status', e.mux_selection_status(), 0);
check('selection getter history rows', e.mux_selection_history_rows() >>> 0, 0xdeadbeef);
check('selection getter len', e.mux_selection_len(), Buffer.byteLength('selected 漢'));
check('selection getter borrowed text', selectionText().toString('utf8'), 'selected 漢');
check(
'selection repeated reply after consume ignored',
e.mux_client_frame(0x90, stage(selectionReply(latestSelectionId, 0, 'again'))),
clientAction.ignored,
);
check('selection repeated reply clears getter', e.mux_selection_len(), 0);
const highSelectionId = 0xfedcba98;
check('high-bit selection request', e.mux_selection_request(highSelectionId, 0, 0, 0, 0), 37);
check(
'high-bit selection reply action',
e.mux_client_frame(0x90, stage(selectionReply(highSelectionId, 0, 'high'))),
clientAction.selection,
);
// WebAssembly exposes u32 results to JS as signed i32. Every browser
// consumer must normalize this getter with >>> 0 before comparing IDs.
check('high-bit selection id uses unsigned JS consumer contract', e.mux_selection_id() >>> 0, highSelectionId);
for (const [name, status] of [['invalid', 1], ['too large', 2], ['unavailable', 3]]) {
const id = 100 + status;
check(`selection ${name} request`, e.mux_selection_request(id, 0, 0, 0, 0), 37);
check(
`selection ${name} action`,
e.mux_client_frame(0x90, stage(selectionReply(id, status))),
clientAction.selection,
);
check(`selection ${name} id`, e.mux_selection_id(), id);
check(`selection ${name} status`, e.mux_selection_status(), status);
check(`selection ${name} empty text`, e.mux_selection_len(), 0);
}
const populateSelection = (id) => {
check(`selection ${id} request`, e.mux_selection_request(id, 0, 0, 0, 0), 37);
check(
`selection ${id} reply`,
e.mux_client_frame(0x90, stage(selectionReply(id, 0, 'x'))),
clientAction.selection,
);
};
populateSelection(201);
check('bell after selection', e.mux_client_frame(0x8f, stage(Buffer.from([1]))), clientAction.bell);
check('bell clears selection getter', e.mux_selection_len(), 0);
check('empty selection pointer is input buffer', e.mux_selection_ptr(), e.mux_input_ptr());
populateSelection(202);
check('ignored after selection', e.mux_client_frame(0x40, 0), clientAction.ignored);
check('ignored clears selection getter', e.mux_selection_len(), 0);
populateSelection(203);
check('oversize after selection', e.mux_client_frame(0x90, e.mux_input_cap() + 1), -2);
check('oversize clears selection getter', e.mux_selection_len(), 0);
populateSelection(204);
check('wide type after selection', e.mux_client_frame(0x100, 0), clientAction.ignored);
check('wide type clears selection getter', e.mux_selection_len(), 0);
populateSelection(205);
check('valid new request after selection', e.mux_selection_request(206, 1, 2, 3, 4), 37);
check('valid new request clears selection getter', e.mux_selection_len(), 0);
populateSelection(207);
e.mux_input_ptr();
check('asking for staging pointer clears selection id', e.mux_selection_id(), 0);
check('asking for staging pointer clears selection status', e.mux_selection_status(), 3);
check('asking for staging pointer clears selection len', e.mux_selection_len(), 0);
check('asking for staging pointer resets selection ptr', e.mux_selection_ptr(), inputPtr);
check('clipboard before staging pointer invalidation', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard);
e.mux_input_ptr();
check('staging pointer clears borrowed clipboard len', e.mux_clipboard_len(), 0);
check('staging pointer clears borrowed clipboard target', e.mux_clipboard_target(), 0);
populateSelection(208);
check('text encode guard after selection', e.mux_text_encode(e.mux_input_cap() + 1), -2);
check('text encode guard clears selection', e.mux_selection_len(), 0);
populateSelection(209);
check('apply frame guard after selection', e.mux_apply_frame(0x95, e.mux_input_cap() + 1), -2);
check('apply frame guard clears selection', e.mux_selection_len(), 0);
populateSelection(210);
check('scroll feed guard after selection', e.mux_scroll_feed(e.mux_input_cap() + 1), -2);
check('scroll feed guard clears selection', e.mux_selection_len(), 0);
populateSelection(211);
check('invalid selection request after result', e.mux_selection_request(212, 0, 65536, 0, 0), -3);
check('invalid selection request clears exposed id', e.mux_selection_id(), 0);
check('invalid selection request clears exposed status', e.mux_selection_status(), 3);
check('invalid selection request clears exposed len', e.mux_selection_len(), 0);
check('pending selection request before invalid request', e.mux_selection_request(213, 1, 2, 3, 4), 37);
check('invalid selection request preserves pending correlation', e.mux_selection_request(214, 1, 2, 3, 65536), -3);
check(
'matching reply after invalid request is accepted',
e.mux_client_frame(0x90, stage(selectionReply(213, 0, 'still pending'))),
clientAction.selection,
);
// --- attach payload before any state: quotes (0,0); wall spelling 0x0 ---
check('attach len', e.mux_attach_payload(0, 0, 0), 20);
let att = outBytes();
check('attach cols', att.readUInt16LE(0), 0);
check('attach rows', att.readUInt16LE(2), 0);
check('attach seq0', att.readBigUInt64LE(4), 0n);
check('attach epoch0', att.readBigUInt64LE(12), 0n);
// --- snapshot: styled text, adoption, full damage ---
// `hello` bold on palette red, then a plain ` world`: two runs in one row,
// so the second run's mask says what it CHANGED and nothing else.
const snap = snapshotPayload(
{ seq: 7, history: 3, cols: 80, rows: 24, epoch: 0xabcdn, cx: 11, cy: 0 },
[cellRow([
{
style: { flags: 1 << 0, fg: (1 << 24) | 1 },
cells: [...'hello'].map((ch) => ({ text: ch, wide: 0 })),
},
{ cells: [...' world'].map((ch) => ({ text: ch, wide: 0 })) },
])],
);
populateSelection(215);
check('apply snapshot', e.mux_apply_frame(0x95, stageThroughKnownPtr(snap)), 0);
check('apply snapshot clears borrowed selection', e.mux_selection_len(), 0);
check('seq adopted', resumeArgs().seq, 7n);
check('epoch adopted', resumeArgs().epoch, 0xabcdn);
check('history', e.mux_history_rows(), 3);
check('snapshot dirties all', e.mux_read_viewport(), 24);
check('cell h', cell(0, 0).cp, 'h'.codePointAt(0));
check('cell h bold', cell(0, 0).flags & 1, 1);
check('cell h fg palette red', cell(0, 0).fg, (1 << 24) | 1);
check('cell w plain', cell(6, 0).flags & 0xffff, 0);
check('cell w fg none', cell(6, 0).fg, 0);
check('cursor x', e.mux_cursor_x(), 11); // where the snapshot's own cursor says
// --- delta: rows 0 and 2 repainted, damage = their rows + cursor rows ---
const delta = deltaPayload(
{ seq: 8, history: 4, cx: 2, cy: 2 },
[
[0, cellRow([{ style: { flags: 1 << 4 }, cells: [...'yo'].map((ch) => ({ text: ch, wide: 0 })) }])],
[2, 'row two'],
],
);
check('apply delta', e.mux_apply_frame(0x96, stage(delta)), 0);
check('seq advanced', resumeArgs().seq, 8n);
check('history follows', e.mux_history_rows(), 4);
const ndirty = e.mux_read_viewport();
const dirtyRows = [];
for (let i = 0; i < ndirty; i++) dirtyRows.push(e.mux_dirty_row(i));
// rows 0 (content+old cursor row 0) and 2 (content+new cursor) — sorted.
check('delta dirty rows', dirtyRows.join(','), '0,2');
check('cell yo inverse', cell(0, 0).flags & (1 << 4), 1 << 4);
check('cell row2', cell(0, 2).cp, 'r'.codePointAt(0));
check('cursor y', e.mux_cursor_y(), 2);
// --- attach after state: quotes what we hold; fresh=1 re-quotes zero ---
e.mux_attach_payload(80, 24, 0);
att = outBytes();
// The size really is encoded and not merely left zero — which the wall
// spelling above, being 0x0, can no longer tell you on its own.
check('attach cols held', att.readUInt16LE(0), 80);
check('attach rows held', att.readUInt16LE(2), 24);
check('attach seq held', att.readBigUInt64LE(4), 8n);
check('attach epoch held', att.readBigUInt64LE(12), 0xabcdn);
e.mux_attach_payload(80, 24, 1);
att = outBytes();
check('attach fresh seq', att.readBigUInt64LE(4), 0n);
// --- resync path: header lies about row count ---
const bad = deltaPayload({ seq: 9, history: 4, cx: 0, cy: 0 }, [[0, 'x']]);
bad.writeUInt16LE(2, 16); // row_count claims 2
check('resync', e.mux_apply_frame(0x96, stage(bad)), 1);
check('resync holds seq', resumeArgs().seq, 8n);
// --- bad frames ---
check('not replay frame', e.mux_apply_frame(0x88, 1), -3);
check('unknown type', e.mux_apply_frame(0x40, 0), -3);
check('short snapshot', e.mux_apply_frame(0x95, 10), -3);
// --- grid move via snapshot: readout follows, all dirty ---
const wide = snapshotPayload(
{ seq: 10, history: 0, cols: 100, rows: 30, epoch: 0xabcdn },
['wide'],
);
check('apply wide', e.mux_apply_frame(0x95, stage(wide)), 0);
check('cols follow', e.mux_cols(), 100);
check('rows follow', e.mux_rows(), 30);
check('grid move dirties all', e.mux_read_viewport(), 30);
check('cell after move', cell(0, 0).cp, 'w'.codePointAt(0));
// --- wide CJK: wide flag + spacer ---
// A wide glyph, its spacer, then a second wide glyph: the head byte's
// `wide` field is the only thing that says which is which.
const cjk = snapshotPayload(
{ seq: 11, history: 0, cols: 100, rows: 30, epoch: 0xabcdn },
[cellRow([{ cells: [
{ text: '漢', wide: 1 },
{ text: '', wide: 2 },
{ text: '字', wide: 1 },
{ text: '', wide: 2 },
] }])],
);
e.mux_apply_frame(0x95, stage(cjk));
e.mux_read_viewport();
check('cjk cp', cell(0, 0).cp, 0x6f22);
check('cjk wide', cell(0, 0).flags & (1 << 16), 1 << 16);
check('cjk spacer', cell(1, 0).flags & (1 << 17), 1 << 17);
check('cjk second', cell(2, 0).cp, 0x5b57);
// --- key encoding round-trips two table rows ---
check('key a', e.mux_key_encode(0, 'a'.codePointAt(0), 0), 1);
check('key a byte', outBytes().toString('latin1'), 'a');
check('key ctrl-up', e.mux_key_encode(5, 0, 4), 6);
check('key ctrl-up seq', outBytes().toString('latin1'), '\x1b[1;5A');
check('key unknown', e.mux_key_encode(99, 0, 0), -3);
// --- text vs paste: the markers are separate exports on purpose ---
// Composed IME text is TYPING and goes out raw; a paste is the same
// bytes with ONE wrap around the whole of it, however many chunks it
// takes. Reassembling here is what proves a chunked paste carries
// exactly one begin and one end.
populateSelection(216);
stageThroughKnownPtr(Buffer.from('two\nlines'));
check('text len', e.mux_text_encode(9), 9);
check('text encode clears borrowed selection', e.mux_selection_len(), 0);
check('text bytes: no wrap', outBytes().toString('latin1'), 'two\nlines');
// Fresh sample: this assertion must not inherit mode state from the
// client-ABI checks above.
check('paste section modes on', e.mux_client_frame(0x8d, stage(modesOn)), clientAction.terminalModes);
check('paste begin len', e.mux_paste_begin(), 6);
const pasteWire = [outBytes().toString('latin1')];
for (const chunk of ['two\n', 'lines']) {
stage(Buffer.from(chunk));
check(`paste chunk ${chunk.length}`, e.mux_text_encode(chunk.length), chunk.length);
pasteWire.push(outBytes().toString('latin1'));
}
check('paste end len', e.mux_paste_end(), 6);
pasteWire.push(outBytes().toString('latin1'));
check('paste wire', pasteWire.join(''), '\x1b[200~two\nlines\x1b[201~');
// The markers themselves, so a drift in input.zig's spelling is caught
// here and not by an application quietly re-indenting.
check('paste begin bytes', pasteWire[0], '\x1b[200~');
check('paste end bytes', pasteWire[pasteWire.length - 1], '\x1b[201~');
// The staging cap is the refusal, not a truncation.
check('text over cap', e.mux_text_encode(e.mux_input_cap() + 1), -2);
// --- scroll scratch: never touches the live replica ---
check('scroll start', e.mux_scroll_start(1, 30), 0); // history 0: saturates
populateSelection(217);
const historyChunk = scrollChunk(0, ['old history line']);
stageThroughKnownPtr(historyChunk);
check('scroll feed', e.mux_scroll_feed(historyChunk.length), 0);
check('scroll feed clears borrowed selection', e.mux_selection_len(), 0);
check('scroll read', e.mux_read_scroll_viewport(), 30);
check('scroll cell', cell(0, 0).cp, 'o'.codePointAt(0));
check('live untouched: full repaint queued', e.mux_read_viewport(), 30);
check('live cell back', cell(0, 0).cp, 0x6f22);
// --- diagnostics: the dump is the referee ---
e.mux_dump_plain();
check('dump starts', outBytes().toString('utf8').startsWith('漢字'), true);
// --- a real-sized snapshot fits the staging buffer ---
// The staging cap is pinned to the largest selection reply: its nine-byte
// correlation/status/history prefix plus the protocol's one-MiB text
// maximum.
check('input cap', e.mux_input_cap(), 1024 * 1024 + 41);
// --- the shell's ACTUAL call list, read out of mux.js ---
// Everything above pins exports this file happens to name. This pins
// the ones the page names, which is the pair that has to agree: an
// export renamed on the Zig side is a TypeError in the browser and
// nowhere else, and the wasm builds fine without it.
const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8');
const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
await verifyClipboardShell(shell, html);
await verifySelectionShell(shell, html);
await verifySettingsShell(shell, html);
await verifyStatusShell(shell);
// Mutation fixture for the source checks below. A correct-looking route
// in either kind of comment must be invisible, while live literal
// boundaries remain valid after their non-executable contents are masked.
const lexicalFixture = [
'// const MSG = { term_modes: 0x8d, term_event: 0x8f };',
'/* const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 }; */',
'/* case MSG.term_modes:',
'case MSG.term_event: { const action = this.core.mux_client_frame(type, payload.length); }',
'case MSG.pty_mode: */',
'// sendPaste(text) { try { this.sendText(text); } finally {} }',
'/* onClipboardEffect() {} */',
'// this.core.mux_comment_decoy(1);',
'const stringLiteral = "https://host/*literal*/";',
'const templateLiteral = `// literal /* template */`;',
"const regexLiteral = /[\"']https?:\\/\\/host/; // remove this tail",
].join('\n');
const executableFixture = executableJsTokens(lexicalFixture);
check('source masker rejects line-comment declaration decoy', /\bconst MSG\b/.test(executableFixture), false);
check('source masker rejects block-comment declaration decoy', /\bconst CLIENT_ACTION\b/.test(executableFixture), false);
check('source masker rejects commented semantic-route decoy', /\bcase MSG\.term_modes\b/.test(executableFixture), false);
check('source masker rejects commented sendPaste decoy', /\bsendPaste\s*\(/.test(executableFixture), false);
check('source masker rejects commented placeholder decoy', /\bonClipboardEffect\s*\(/.test(executableFixture), false);
check('WASM-call scan rejects commented decoy', wasmCalls(lexicalFixture).includes('mux_comment_decoy'), false);
check('source masker removes a regex trailing comment', /remove this tail/.test(executableFixture), false);
check('source masker excludes string literal contents', executableFixture.includes('https://host'), false);
check('source masker excludes template raw contents', executableFixture.includes('literal /* template */'), false);
check('source masker excludes regex body contents', executableFixture.includes('https?:\\/\\/host'), false);
let lexicalFixtureCompiles = true;
try { new vm.Script(executableFixture); } catch (_) { lexicalFixtureCompiles = false; }
check('source masker preserves literal syntactic boundaries', lexicalFixtureCompiles, true);
const completeDecoy = [
'const MSG = { term_modes: 0x8d, term_event: 0x8f, selection_reply: 0x90 };',
'const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3, selection: 4 };',
'case MSG.term_modes: case MSG.term_event: case MSG.selection_reply: { if (!this.stage(payload)) return;',
'const action = this.core.mux_client_frame(type, payload.length);',
'if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect();',
'else if (action === CLIENT_ACTION.selection) this.onSelectionReply(); return; }',
'sendPaste(text) { if (this.core.mux_paste_begin() > 0) this.sendText(text);',
'try { this.sendText(text); } finally { this.core.mux_paste_end(); } }',
'onClipboardEffect() {} this.core.mux_client_frame(type, payload.length);',
].join(' ');
const literalDecoys = [
`const stringDecoy = '${completeDecoy}';`,
`const templateDecoy = \`${completeDecoy}\`;`,
`const regexDecoy = /${completeDecoy}/;`,
].join('\n');
let literalDecoysCompile = true;
try { new vm.Script(literalDecoys); } catch (_) { literalDecoysCompile = false; }
check('complete literal decoy attack is valid JavaScript', literalDecoysCompile, true);
const executableLiteralDecoys = executableJsTokens(literalDecoys);
check('literal decoys add no MSG declaration', (executableLiteralDecoys.match(/\b(?:const|let|var)\s+MSG\b/g) ?? []).length, 0);
check('literal decoys add no CLIENT_ACTION declaration', (executableLiteralDecoys.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []).length, 0);
check(
'literal decoys add no semantic route body',
balancedBodiesAfter(executableLiteralDecoys, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*case\s+MSG\.selection_reply\s*:\s*\{/g).length,
0,
);
check(
'literal decoys add no paste method body',
balancedBodiesAfter(executableLiteralDecoys, /\bsendPaste\s*\([^)]*\)\s*\{/g).length,
0,
);
check(
'literal decoys add no placeholder body',
balancedBodiesAfter(executableLiteralDecoys, /\bonClipboardEffect\s*\([^)]*\)\s*\{/g).length,
0,
);
check('WASM-call scan rejects all literal decoys', wasmCalls(literalDecoys).includes('mux_client_frame'), false);
const templateExpressionFixture = [
'const nested = `raw mux_raw_decoy ${',
'this.core.mux_live_call("mux_string_decoy" /* mux_comment_decoy */)',
'}`;',
'const flagged = /mux_regex_decoy/gim;',
].join(' ');
const executableTemplateExpression = executableJsTokens(templateExpressionFixture);
check('template raw text is not searchable', /mux_raw_decoy/.test(executableTemplateExpression), false);
check('template expression code remains live', /\.mux_live_call\s*\(/.test(executableTemplateExpression), true);
check('nested string content is not searchable', /mux_string_decoy/.test(executableTemplateExpression), false);
check('nested comment content is not searchable', /mux_comment_decoy/.test(executableTemplateExpression), false);
check('regex body is not searchable', /mux_regex_decoy/.test(executableTemplateExpression), false);
check('regex flags are not searchable', /\bgim\b/.test(executableTemplateExpression), false);
const slashFixture = [
'let ok = true, x = "/", left = 6, right = 3;',
'if (ok) /[//]/.test(x);',
'if (ok) {} /[/*]/.test(x);',
'const quotient = left / right;',
'const objectQuotient = {} / 2;',
].join('\n');
let rawSlashFixtureCompiles = true;
try { new vm.Script(slashFixture); } catch (_) { rawSlashFixtureCompiles = false; }
check('regex/control and division attack is valid JavaScript', rawSlashFixtureCompiles, true);
const executableSlashes = executableJsTokens(slashFixture);
let slashFixtureCompiles = true;
try { new vm.Script(executableSlashes); } catch (_) { slashFixtureCompiles = false; }
check('masked regex/control and division fixture remains valid JS', slashFixtureCompiles, true);
check('regex after control and block remains live', (executableSlashes.match(/\.test\s*\(/g) ?? []).length, 2);
check('division after a value remains live', /left\s*\/\s*right/.test(executableSlashes), true);
check('division after an object expression remains live', /\{\}\s*\/\s*2/.test(executableSlashes), true);
const neighborFixture = executableJsTokens([
'case MSG.term_modes:',
'case MSG.term_event:',
'case MSG.selection_reply: { if (ready) { route(); } }',
'case MSG.unrelated: { return; }',
'case MSG.pty_mode: return;',
'sendPaste(text) { try { if (text) { this.sendText(text); } } finally { finish(); } }',
'unrelatedMethod() { return 1; }',
'sendResizeIfDiffers() {}',
].join('\n'));
check(
'semantic extraction ignores a later unrelated case',
balancedBodiesAfter(neighborFixture, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*case\s+MSG\.selection_reply\s*:\s*\{/g).length,
1,
);
check(
'paste extraction ignores a later unrelated method',
balancedBodiesAfter(neighborFixture, /\bsendPaste\s*\(\s*text\s*\)\s*\{/g).length,
1,
);
const executableShell = executableJsTokens(shell);
let executableShellCompiles = true;
try { new vm.Script(executableShell); } catch (_) { executableShellCompiles = false; }
check('real shell executable-token representation remains valid JS', executableShellCompiles, true);
// The browser must route sampled terminal state and host effects through
// the same semantic core as the CLI. Pin the real page source here: this
// verifier otherwise exercises only the WASM half of that handshake.
const msgBindings = executableShell.match(/\b(?:const|let|var)\s+MSG\b/g) ?? [];
const msgObjects = balancedBodiesAfter(executableShell, /\bconst MSG\s*=\s*\{/g);
check('shell has exactly one live MSG declaration', msgBindings.length, 1);
check('shell MSG declaration has the expected object shape', msgObjects.length, 1);
const msgDecl = msgObjects.length === 1 ? (msgObjects[0].body ?? '') : '';
check('shell has one term_modes property', (msgDecl.match(/\bterm_modes\s*:/g) ?? []).length, 1);
check('shell has one term_event property', (msgDecl.match(/\bterm_event\s*:/g) ?? []).length, 1);
check('shell has one selection_req property', (msgDecl.match(/\bselection_req\s*:/g) ?? []).length, 1);
check('shell has one selection_reply property', (msgDecl.match(/\bselection_reply\s*:/g) ?? []).length, 1);
check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true);
check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true);
check('shell declares selection wire codes', /\bselection_req\s*:\s*0x0b\b/.test(msgDecl)
&& /\bselection_reply\s*:\s*0x90\b/.test(msgDecl), true);
const actionBindings = executableShell.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? [];
const actionObjects = balancedBodiesAfter(executableShell, /\bconst CLIENT_ACTION\s*=\s*\{/g);
check('shell has exactly one live CLIENT_ACTION declaration', actionBindings.length, 1);
check('shell CLIENT_ACTION declaration has the expected object shape', actionObjects.length, 1);
const actionDecl = actionObjects.length === 1 ? (actionObjects[0].body ?? '') : '';
for (const name of ['ignored', 'terminalModes', 'clipboard', 'bell', 'selection']) {
check(`shell has one ${name} client action`, (actionDecl.match(new RegExp(`\\b${name}\\s*:`, 'g')) ?? []).length, 1);
}
check(
'shell pins shared client action ABI',
/\bignored\s*:\s*0\b/.test(actionDecl)
&& /\bterminalModes\s*:\s*1\b/.test(actionDecl)
&& /\bclipboard\s*:\s*2\b/.test(actionDecl)
&& /\bbell\s*:\s*3\b/.test(actionDecl)
&& /\bselection\s*:\s*4\b/.test(actionDecl),
true,
);
const semanticRoutes = balancedBodiesAfter(
executableShell,
/case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*case\s+MSG\.selection_reply\s*:\s*\{/g,
);
check('shell has one live term_modes case', (executableShell.match(/case\s+MSG\.term_modes\s*:/g) ?? []).length, 1);
check('shell has one live term_event case', (executableShell.match(/case\s+MSG\.term_event\s*:/g) ?? []).length, 1);
check('shell has one live selection_reply case', (executableShell.match(/case\s+MSG\.selection_reply\s*:/g) ?? []).length, 1);
check('shell shares exactly one semantic frame route', semanticRoutes.length, 1);
const semanticCases = semanticRoutes.length === 1 ? (semanticRoutes[0].body ?? '') : '';
check('shell stages a semantic payload exactly once', (semanticCases.match(/this\.stage\(payload\)/g) ?? []).length, 1);
check(
'shell stages semantic payload before WASM',
/if\s*\(\s*!this\.stage\(payload\)\s*\)\s*return\s*;/.test(semanticCases),
true,
);
check(
'shell passes semantic type and length to WASM',
/(?:const|let)\s+action\s*=\s*this\.core\.mux_client_frame\(type,\s*payload\.length\)\s*;/.test(semanticCases),
true,
);
check('shell calls the semantic WASM entry exactly once', (semanticCases.match(/\.mux_client_frame\s*\(/g) ?? []).length, 1);
check(
'shell dispatches clipboard and selection semantic actions',
/if\s*\(\s*action\s*===\s*CLIENT_ACTION\.clipboard\s*\)\s*this\.onClipboardEffect\(\)\s*;/.test(semanticCases)
&& /else\s+if\s*\(\s*action\s*===\s*CLIENT_ACTION\.selection\s*\)\s*this\.onSelectionReply\(\)\s*;/.test(semanticCases)
&& (semanticCases.match(/this\.onClipboardEffect\(\)/g) ?? []).length === 1
&& (semanticCases.match(/this\.onSelectionReply\(\)/g) ?? []).length === 1,
true,
);
check('shell leaves semantic payload parsing to WASM', /DataView|TextDecoder|payload\s*\[/.test(semanticCases), false);
check(
'shell has no duplicate bracketed-paste state',
/this\.(?:bracketedPaste|bracketed_paste)\s*=/.test(executableShell),
false,
);
const clipboardMethods = balancedBodiesAfter(executableShell, /\bonClipboardEffect\s*\([^)]*\)\s*\{/g);
const emptyClipboardMethods = clipboardMethods.filter((method) => method.body?.trim() === '');
check('shell has exactly one live clipboard effect method', clipboardMethods.length, 1);
check('shell clipboard effect is implemented, not a placeholder', emptyClipboardMethods.length, 0);
const sendPasteMethods = balancedBodiesAfter(executableShell, /\bsendPaste\s*\([^)]*\)\s*\{/g);
const sendPasteBodies = balancedBodiesAfter(executableShell, /\bsendPaste\s*\(\s*text\s*\)\s*\{/g);
check('shell has exactly one live sendPaste method', sendPasteMethods.length, 1);
check('shell sendPaste has the expected text contract', sendPasteBodies.length, 1);
const sendPaste = sendPasteBodies.length === 1 ? (sendPasteBodies[0].body ?? '') : '';
check('shell paste has one begin call', (sendPaste.match(/mux_paste_begin\(\)/g) ?? []).length, 1);
check('shell paste has one end call', (sendPaste.match(/mux_paste_end\(\)/g) ?? []).length, 1);
check(
'shell paste wraps the whole sendText with optional markers',
/if\s*\(this\.core\.mux_paste_begin\(\)\s*>\s*0\)[\s\S]*?try\s*\{\s*this\.sendText\(text\)\s*;\s*\}\s*finally\s*\{\s*if\s*\(this\.core\.mux_paste_end\(\)\s*>\s*0\)/.test(sendPaste),
true,
);
const called = wasmCalls(shell);
check('shell calls something', called.length > 0, true);
const absent = called.filter((n) => typeof e[n] !== 'function');
check(`shell calls only real exports (${called.length} of them)`, absent.join(','), '');
// --- the SHAPE mux.js depends on ---
// WebAssembly.instantiate returns {module, instance} for bytes but the
// bare Instance for an already-compiled Module. mux.js compiles first
// (compileStreaming) and so takes the second shape; this file passes
// bytes and takes the first. Destructuring the wrong one is silently
// undefined, which is precisely how the page shipped unable to start
// at all — every export above passing, and nothing running. Pin the
// shape the shell relies on, since no other assertion here can see it.
const mod = await WebAssembly.compile(bin);
const fromModule = await WebAssembly.instantiate(mod, {});
check('instantiate(Module) is an Instance', fromModule instanceof WebAssembly.Instance, true);
check('instantiate(Module) has no .instance to destructure', fromModule.instance, undefined);
check('instantiate(Module).exports is the ABI', typeof fromModule.exports.mux_init, 'function');
const fromBytes = await WebAssembly.instantiate(bin, {});
check('instantiate(bytes) is the OTHER shape', fromBytes.instance instanceof WebAssembly.Instance, true);
// --- deinit / re-init ---
check('clipboard before lifecycle reset', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard);
check('clipboard populated before lifecycle reset', e.mux_clipboard_len(), 4);
check('modes populated before lifecycle reset', e.mux_bracketed_paste(), 1);
check('selection before lifecycle reset request', e.mux_selection_request(301, 0, 0, 0, 0), 37);
check('selection before lifecycle reset reply', e.mux_client_frame(0x90, stage(selectionReply(301, 0, 'reset'))), clientAction.selection);
check('selection populated before lifecycle reset', e.mux_selection_len(), 5);
e.mux_deinit();
check('apply after deinit', e.mux_apply_frame(0x95, 0), -1);
check('client frame after deinit', e.mux_client_frame(0x8d, 0), -1);
check('selection id after deinit', e.mux_selection_id(), 0);
check('selection status after deinit', e.mux_selection_status(), 3);
check('selection len after deinit', e.mux_selection_len(), 0);
check('selection ptr after deinit is input buffer', e.mux_selection_ptr(), e.mux_input_ptr());
check('selection request after deinit', e.mux_selection_request(302, 0, 0, 0, 0), -1);
check('selection request error clears output', e.mux_output_len(), 0);
check('re-init', e.mux_init(80, 24), 0);
check('re-init resets bracketed paste', e.mux_bracketed_paste(), 0);
check('re-init resets clipboard', e.mux_clipboard_len(), 0);
check('deinit and re-init reset selection id', e.mux_selection_id(), 0);
check('deinit and re-init reset selection status', e.mux_selection_status(), 3);
check('deinit and re-init reset selection len', e.mux_selection_len(), 0);
e.mux_deinit();
console.log(`verify: ${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((err) => { console.error(err); process.exit(1); });