mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
bb9684f505
document-level mouseleave delivery on window exit is flaky in some engines, stranding the floating button until the next in-page pointer event; the <html> element receives the leave event reliably.
1365 lines
50 KiB
Python
1365 lines
50 KiB
Python
"""Behavior tests for ``turnstone/shared_static/copy_actions.js``.
|
|
|
|
Same node + browser-shim approach as ``test_renderer_js.py``: the modules are
|
|
demodulized and evaluated with script semantics against stub DOM elements.
|
|
The harness records delegated listeners by type, so the tests drive the REAL
|
|
interaction layer — the mouseover/keydown delegation, the show/hide
|
|
lifecycle, and the pane-level busy gate — not just the exported resolvers.
|
|
Placement geometry (clamps, collision, scroll-follow coordinates) is
|
|
deliberately NOT asserted here: the stub rects are uniform; the livepass copy
|
|
harness (scripts/livepass.py, COPY-READY / COPY-KBD-READY verdicts) owns
|
|
rendered placement.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from tests._js_harness_helpers import demodulize, node_skip
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
_UTILS_JS = _REPO_ROOT / "turnstone/shared_static/utils.js"
|
|
_TOAST_JS = _REPO_ROOT / "turnstone/shared_static/toast.js"
|
|
_COPY_ACTIONS_JS = _REPO_ROOT / "turnstone/shared_static/copy_actions.js"
|
|
|
|
|
|
pytestmark = node_skip
|
|
|
|
|
|
# The three interpolated sources never change between tests — read,
|
|
# demodulize, and JSON-encode them once so each test costs exactly one
|
|
# node spawn.
|
|
_UTILS_SRC = json.dumps(demodulize(_UTILS_JS))
|
|
_TOAST_SRC = json.dumps(demodulize(_TOAST_JS))
|
|
_COPY_ACTIONS_SRC = json.dumps(demodulize(_COPY_ACTIONS_JS))
|
|
|
|
|
|
# The stub element models what the tested seams touch: class/attr/parent
|
|
# plumbing, a selector subset for closest()/matches (type, .class, [attr] and
|
|
# [attr="value"], comma lists — everything the module's selectors use),
|
|
# listener capture with a synthetic .click(), per-element rects, and a focus
|
|
# spy that tracks document.activeElement. document.addEventListener records
|
|
# handlers by type so tests can fire delegated events (fireDoc merges extra
|
|
# event fields, e.g. {key} for keydown); the flash timer is real (node
|
|
# setTimeout) and the template exits the process after each body so the 1.4s
|
|
# revert never holds it open.
|
|
_HARNESS_TEMPLATE = """
|
|
const vm = require('vm');
|
|
|
|
class StubEl {}
|
|
global.Element = StubEl;
|
|
|
|
function hasClass(el, c) {
|
|
return (
|
|
(el._classes && el._classes.has(c)) ||
|
|
(el.className || '').split(/\\s+/).includes(c)
|
|
);
|
|
}
|
|
|
|
function matchesSel(el, sel) {
|
|
return sel.split(',').some((s) => {
|
|
s = s.trim();
|
|
if (s.startsWith('.')) return hasClass(el, s.slice(1));
|
|
if (s.startsWith('[')) {
|
|
const body = s.slice(1, -1);
|
|
const eq = body.indexOf('=');
|
|
if (eq === -1) return el.getAttribute(body) !== null;
|
|
let want = body.slice(eq + 1);
|
|
if (want.startsWith('"') || want.startsWith("'")) want = want.slice(1, -1);
|
|
return el.getAttribute(body.slice(0, eq)) === want;
|
|
}
|
|
return el.tagName === s.toUpperCase();
|
|
});
|
|
}
|
|
|
|
function makeEl(tag) {
|
|
const el = new StubEl();
|
|
Object.assign(el, {
|
|
tagName: String(tag || 'div').toUpperCase(),
|
|
children: [],
|
|
attrs: {},
|
|
style: {},
|
|
listeners: {},
|
|
_classes: new Set(),
|
|
textContent: '',
|
|
value: '',
|
|
title: '',
|
|
className: '',
|
|
type: '',
|
|
isConnected: true,
|
|
parentNode: null,
|
|
parentElement: null,
|
|
});
|
|
el.setAttribute = (n, v) => { el.attrs[n] = String(v); };
|
|
el.getAttribute = (n) => (n in el.attrs ? el.attrs[n] : null);
|
|
el.removeAttribute = (n) => { delete el.attrs[n]; };
|
|
el.appendChild = (c) => {
|
|
if (c.parentNode) {
|
|
const i = c.parentNode.children.indexOf(c);
|
|
if (i !== -1) c.parentNode.children.splice(i, 1);
|
|
}
|
|
el.children.push(c);
|
|
c.parentNode = el;
|
|
c.parentElement = el;
|
|
return c;
|
|
};
|
|
el.remove = () => { el._removed = true; };
|
|
el.select = () => {};
|
|
// Focus succeeds only for focusable elements — mirroring the browser is
|
|
// what lets tests catch focus plumbing that silently no-ops on a
|
|
// non-focusable target (form controls, tabindex attr, or a programmatic
|
|
// tabIndex assignment).
|
|
el.tabIndex = undefined;
|
|
el.focus = (opts) => {
|
|
const focusable =
|
|
el.tabIndex !== undefined ||
|
|
'tabindex' in el.attrs ||
|
|
['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT'].includes(el.tagName);
|
|
if (!focusable) return;
|
|
el._focused = (el._focused || 0) + 1;
|
|
el._focusOpts = opts || null;
|
|
global.document.activeElement = el;
|
|
};
|
|
el.hasAttribute = (n) => n in el.attrs;
|
|
el.addEventListener = (t, fn) => {
|
|
(el.listeners[t] = el.listeners[t] || []).push(fn);
|
|
};
|
|
el.click = (evt) =>
|
|
(el.listeners.click || []).forEach((fn) =>
|
|
fn(evt || { stopPropagation() {} }));
|
|
el.querySelector = () => null;
|
|
el.querySelectorAll = (sel) => {
|
|
const out = [];
|
|
const walk = (n) => {
|
|
for (const c of n.children) {
|
|
if (matchesSel(c, sel)) out.push(c);
|
|
walk(c);
|
|
}
|
|
};
|
|
walk(el);
|
|
return out;
|
|
};
|
|
el.closest = (sel) => {
|
|
for (let n = el; n; n = n.parentElement) {
|
|
if (matchesSel(n, sel)) return n;
|
|
}
|
|
return null;
|
|
};
|
|
el.contains = (x) => {
|
|
for (let n = x; n; n = n.parentNode) if (n === el) return true;
|
|
return false;
|
|
};
|
|
el.getBoundingClientRect = () =>
|
|
el._rect || { top: 10, bottom: 110, left: 0, right: 200, width: 200, height: 100 };
|
|
el.classList = {
|
|
add: (...cs) => cs.forEach((c) => el._classes.add(c)),
|
|
remove: (...cs) => cs.forEach((c) => el._classes.delete(c)),
|
|
contains: (c) => hasClass(el, c),
|
|
};
|
|
return el;
|
|
}
|
|
|
|
const createdEls = [];
|
|
const toastEl = makeEl('div');
|
|
const docListeners = {};
|
|
// Ranges clone like the real Range: a restored clone must itself be
|
|
// cloneable, or the SECOND copy's save pass crashes on it.
|
|
function makeRange(marker, clone) {
|
|
return {
|
|
marker,
|
|
clone: !!clone,
|
|
cloneRange() { return makeRange(this.marker, true); },
|
|
};
|
|
}
|
|
const prevRange = makeRange('prev', false);
|
|
const selection = {
|
|
_ranges: [prevRange],
|
|
get rangeCount() { return this._ranges.length; },
|
|
getRangeAt(i) { return this._ranges[i]; },
|
|
removeAllRanges() { this._ranges = []; },
|
|
addRange(r) { this._ranges.push(r); },
|
|
};
|
|
global.__execResult = true;
|
|
global.document = {
|
|
createElement: (tag) => {
|
|
const el = makeEl(tag);
|
|
createdEls.push(el);
|
|
return el;
|
|
},
|
|
body: makeEl('body'),
|
|
// The window-exit dismissal listens on the <html> element (document-
|
|
// level mouseleave delivery is flaky on window exit), so the stub
|
|
// carries a documentElement with its own listener map.
|
|
documentElement: makeEl('html'),
|
|
addEventListener: (t, fn) => {
|
|
(docListeners[t] = docListeners[t] || []).push(fn);
|
|
},
|
|
getSelection: () => selection,
|
|
getElementById: (id) => (id === 'toast' ? toastEl : null),
|
|
querySelector: () => null,
|
|
execCommand: () => global.__execResult,
|
|
activeElement: undefined,
|
|
};
|
|
global.window = global;
|
|
global.innerWidth = 800;
|
|
global.innerHeight = 600;
|
|
global.getComputedStyle = () => ({ overflowY: 'visible' });
|
|
global.requestAnimationFrame = (fn) => { fn(); return 0; };
|
|
// Node >= 21 ships globalThis.navigator as a built-in accessor with no
|
|
// setter, so a plain `global.navigator = ...` silently no-ops and the
|
|
// secure-context branch would never see the stub clipboard. Install by
|
|
// property definition instead.
|
|
function setNavigator(nav) {
|
|
Object.defineProperty(global, 'navigator', {
|
|
value: nav,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
}
|
|
setNavigator({});
|
|
global.isSecureContext = false;
|
|
|
|
const fireDoc = (type, target, props) =>
|
|
(docListeners[type] || []).forEach((fn) =>
|
|
fn(Object.assign({ target }, props || {})));
|
|
const fabEl = () =>
|
|
createdEls.find((e) => hasClass(e, 'block-copy-btn')) || null;
|
|
// The announce idiom is clear-then-set on a short timer; outcome
|
|
// announcements are only observable after it fires.
|
|
const settle = (ms) => new Promise((r) => setTimeout(r, ms == null ? 40 : ms));
|
|
|
|
vm.runInThisContext(%(utils_src)s);
|
|
vm.runInThisContext(%(toast_src)s);
|
|
vm.runInThisContext(%(copy_actions_src)s);
|
|
// The module's aria-live region is the first sr-only appended to body.
|
|
const liveRegion = document.body.children.find((c) => hasClass(c, 'sr-only'));
|
|
|
|
// A bubble fixture: .msg > (.msg-body > table.table-wrap) + bar with a
|
|
// copy button — the DOM shape both chat clients build, with the REAL
|
|
// layout's geometry: the bar hugs the bubble's top-right corner and the
|
|
// block sits below it (chat.css .msg-actions top/right 4px). Overlapping
|
|
// default rects would make every reveal read as an unresolvable bar
|
|
// collision.
|
|
function makeBubble(sourceText) {
|
|
const msg = makeEl('div');
|
|
msg.className = 'msg assistant';
|
|
msg._rect = { top: 0, bottom: 160, left: 0, right: 200, width: 200, height: 160 };
|
|
const body = makeEl('div');
|
|
body.className = 'msg-body';
|
|
const table = makeEl('div');
|
|
table._classes.add('table-wrap');
|
|
table.setAttribute('data-md-source', sourceText || '| a |');
|
|
table.setAttribute('tabindex', '0');
|
|
table._rect = { top: 40, bottom: 150, left: 0, right: 200, width: 200, height: 110 };
|
|
const bar = makeEl('div');
|
|
bar.className = 'msg-actions';
|
|
bar._rect = { top: 4, bottom: 28, left: 150, right: 196, width: 46, height: 24 };
|
|
const barBtn = makeEl('button');
|
|
barBtn.className = 'msg-action-btn msg-copy-btn';
|
|
document.body.appendChild(msg);
|
|
msg.appendChild(body);
|
|
body.appendChild(table);
|
|
msg.appendChild(bar);
|
|
bar.appendChild(barBtn);
|
|
return { msg, body, table, bar, barBtn };
|
|
}
|
|
|
|
// Wrap a bubble in a messages container carrying the pane-level busy
|
|
// stamp — the DOM fact both chat clients maintain (data-busy="true"
|
|
// while a turn is in flight, "false" at idle).
|
|
function makeBusyWrap(bubble, busy) {
|
|
const wrap = makeEl('div');
|
|
wrap.className = 'messages';
|
|
wrap.setAttribute('data-busy', busy === false ? 'false' : 'true');
|
|
document.body.appendChild(wrap);
|
|
wrap.appendChild(bubble.msg);
|
|
return wrap;
|
|
}
|
|
|
|
(async () => {
|
|
const out = await (async () => { %(body)s })();
|
|
process.stdout.write(JSON.stringify(out));
|
|
process.exit(0);
|
|
})().catch((e) => {
|
|
console.error((e && e.stack) || e);
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
|
|
|
|
def _run(body: str) -> dict[str, object]:
|
|
harness = _HARNESS_TEMPLATE % {
|
|
"utils_src": _UTILS_SRC,
|
|
"toast_src": _TOAST_SRC,
|
|
"copy_actions_src": _COPY_ACTIONS_SRC,
|
|
"body": body,
|
|
}
|
|
result = subprocess.run(
|
|
["node", "-e", harness],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=True,
|
|
)
|
|
parsed: dict[str, object] = json.loads(result.stdout)
|
|
return parsed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Source resolution — what a copy click actually lifts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_block_copy_source_resolves_each_block_kind() -> None:
|
|
"""Each rendered block kind resolves to its SOURCE: mermaid containers
|
|
via ``data-mermaid-source`` (the source the diagram was rendered from —
|
|
the pre it replaced is gone from the DOM), tables via the render-time
|
|
``data-md-source`` stash, and code fences via ``code.textContent``
|
|
(escapeHtml round-trips; hljs spans don't change text content)."""
|
|
out = _run(
|
|
"""
|
|
const mermaid = makeEl('div');
|
|
mermaid._classes.add('mermaid-container');
|
|
mermaid.attrs['data-mermaid-source'] = 'graph TD;\\n A-->B';
|
|
const table = makeEl('div');
|
|
table._classes.add('table-wrap');
|
|
table.attrs['data-md-source'] = '| a |\\n|---|\\n| 1 |';
|
|
const code = makeEl('code');
|
|
code.textContent = 'x = 1\\nprint(x)';
|
|
const pre = makeEl('pre');
|
|
pre.querySelector = (sel) => (sel === 'code' ? code : null);
|
|
const barePre = makeEl('pre');
|
|
barePre.textContent = 'no code child';
|
|
return {
|
|
mermaid: blockCopySource(mermaid),
|
|
table: blockCopySource(table),
|
|
pre: blockCopySource(pre),
|
|
barePre: blockCopySource(barePre),
|
|
missing: blockCopySource(null),
|
|
};
|
|
"""
|
|
)
|
|
assert out["mermaid"] == "graph TD;\n A-->B"
|
|
assert out["table"] == "| a |\n|---|\n| 1 |"
|
|
assert out["pre"] == "x = 1\nprint(x)"
|
|
assert out["barePre"] == "no code child"
|
|
assert out["missing"] == ""
|
|
|
|
|
|
def test_msg_copy_source_priority_chain() -> None:
|
|
"""A bubble copies the raw markdown the render pipeline stashed on its
|
|
``.msg-body`` (``_copySource`` — set unconditionally per applied
|
|
frame, including frames whose markdown render threw and painted as
|
|
plain text). The visible textContent is the honest last resort for a
|
|
body that never went through the render pipeline. The stash is
|
|
whole-source by contract: it may carry syntax the render does not
|
|
display."""
|
|
out = _run(
|
|
"""
|
|
const body = makeEl('div');
|
|
body._copySource = 'full **source**';
|
|
body.textContent = 'rendered';
|
|
const msg = makeEl('div');
|
|
msg.querySelector = (sel) => (sel === '.msg-body' ? body : null);
|
|
const bareBody = makeEl('div');
|
|
bareBody.textContent = 'plain rendered text';
|
|
const bare = makeEl('div');
|
|
bare.querySelector = (sel) => (sel === '.msg-body' ? bareBody : null);
|
|
return {
|
|
copySourceWins: msgCopySource(msg),
|
|
textFallback: msgCopySource(bare),
|
|
noBody: msgCopySource(makeEl('div')),
|
|
};
|
|
"""
|
|
)
|
|
assert out["copySourceWins"] == "full **source**"
|
|
assert out["textFallback"] == "plain rendered text"
|
|
assert out["noBody"] == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Clipboard transport — secure-context API vs plain-HTTP fallback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_copy_text_fallback_restores_selection_and_focus() -> None:
|
|
"""Plain-HTTP LAN nodes have no ``navigator.clipboard``; the legacy
|
|
hidden-textarea + execCommand path must carry the copy, surface the
|
|
command's verdict, and leave the user's world as it found it: the
|
|
prior selection re-added and the previously focused element
|
|
re-focused (execCommand copies the textarea's selection, so both are
|
|
disturbed mid-flight). The textarea itself is marked as the
|
|
clipboard shim so delegated UI listeners can ignore it."""
|
|
out = _run(
|
|
"""
|
|
const prevFocusEl = makeEl('input');
|
|
document.activeElement = prevFocusEl;
|
|
const okResult = await copyTextToClipboard('fence content');
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
global.__execResult = false;
|
|
const failResult = await copyTextToClipboard('x');
|
|
return {
|
|
okResult,
|
|
failResult,
|
|
taValue: ta ? ta.value : null,
|
|
taRemoved: ta ? !!ta._removed : null,
|
|
taShimMarked: ta ? ta.getAttribute('data-clipboard-shim') !== null : null,
|
|
rangesAfter: selection._ranges.map(
|
|
(r) => r.marker + (r.clone ? ':clone' : '')),
|
|
refocusCount: prevFocusEl._focused || 0,
|
|
refocusPreventScroll: prevFocusEl._focusOpts
|
|
? prevFocusEl._focusOpts.preventScroll === true
|
|
: false,
|
|
};
|
|
"""
|
|
)
|
|
assert out["okResult"] is True
|
|
assert out["failResult"] is False
|
|
assert out["taValue"] == "fence content"
|
|
assert out["taRemoved"] is True
|
|
assert out["taShimMarked"] is True
|
|
# Restore invariants: after both copies the selection holds exactly one
|
|
# range carrying the original's content, and it is a CLONE — getRangeAt
|
|
# returns live ranges the shim's select() can collapse in place, so a
|
|
# live ref re-added here would "restore" the collapsed range. No
|
|
# accumulation across copies, and the previously focused element got
|
|
# focus({preventScroll}) once per copy.
|
|
assert out["rangesAfter"] == ["prev:clone"]
|
|
assert out["refocusCount"] == 2
|
|
assert out["refocusPreventScroll"] is True
|
|
|
|
|
|
def test_copy_text_uses_async_clipboard_in_secure_context() -> None:
|
|
"""On HTTPS / localhost the async Clipboard API is the transport; the
|
|
legacy path must not run at all (no stray textarea, no execCommand)."""
|
|
out = _run(
|
|
"""
|
|
let wrote = null;
|
|
let execCalled = false;
|
|
global.isSecureContext = true;
|
|
setNavigator({
|
|
clipboard: {
|
|
writeText: (v) => { wrote = v; return Promise.resolve(); },
|
|
},
|
|
});
|
|
global.document.execCommand = () => { execCalled = true; return true; };
|
|
const ok = await copyTextToClipboard('secret token');
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return { ok, wrote, execCalled, madeTextarea: !!ta };
|
|
"""
|
|
)
|
|
assert out["ok"] is True
|
|
assert out["wrote"] == "secret token"
|
|
assert out["execCalled"] is False
|
|
assert out["madeTextarea"] is False
|
|
|
|
|
|
def test_copy_text_clipboard_rejection_falls_through_to_legacy() -> None:
|
|
"""A secure-context permission rejection is not a dead end — the legacy
|
|
path still gets its shot, and its verdict is the caller's answer."""
|
|
out = _run(
|
|
"""
|
|
global.isSecureContext = true;
|
|
setNavigator({
|
|
clipboard: { writeText: () => Promise.reject(new Error('denied')) },
|
|
});
|
|
const ok = await copyTextToClipboard('salvaged');
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return { ok, taValue: ta ? ta.value : null };
|
|
"""
|
|
)
|
|
assert out["ok"] is True
|
|
assert out["taValue"] == "salvaged"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The bubble copy button — shape, click behavior, busy gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_msg_copy_button_click_copies_and_flashes_copied() -> None:
|
|
"""A click lifts the stashed markdown through the clipboard helper and
|
|
flashes the ✓ state (class + plain-language title); the button carries
|
|
the toolbar chrome the .msg-actions bars expect, and the outcome is
|
|
announced via the live region (clear-then-set, so it lands a beat
|
|
after the flash)."""
|
|
out = _run(
|
|
"""
|
|
const body = makeEl('div');
|
|
body._copySource = 'raw **markdown**';
|
|
const msg = makeEl('div');
|
|
msg.querySelector = (sel) => (sel === '.msg-body' ? body : null);
|
|
const btn = buildMsgCopyButton(msg);
|
|
btn.click();
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
className: btn.className,
|
|
type: btn.type,
|
|
aria: btn.attrs['aria-label'],
|
|
iconClass: btn.children[0].className,
|
|
flash: [...btn._classes],
|
|
title: btn.title,
|
|
copied: ta ? ta.value : null,
|
|
announced: liveRegion ? liveRegion.textContent : null,
|
|
toast: toastEl.textContent,
|
|
};
|
|
"""
|
|
)
|
|
assert out["className"] == "msg-action-btn msg-copy-btn"
|
|
assert out["type"] == "button"
|
|
assert out["aria"] == "Copy message"
|
|
assert out["iconClass"] == "icon-copy"
|
|
assert out["flash"] == ["is-copied"]
|
|
assert out["title"] == "Copied"
|
|
assert out["copied"] == "raw **markdown**"
|
|
assert out["announced"] == "Copied"
|
|
assert out["toast"] == "", "success must not raise a toast"
|
|
|
|
|
|
def test_empty_source_copies_empty_string_with_honest_outcome() -> None:
|
|
"""A source that RESOLVES empty is copied like any other: the write is
|
|
attempted (the clipboard genuinely ends up holding the empty string)
|
|
and the flash reports the transport's verdict — never a manufactured
|
|
failure whose "select the text" hint points at nothing."""
|
|
out = _run(
|
|
"""
|
|
const btn = buildMsgCopyButton(makeEl('div'));
|
|
btn.click();
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
flash: [...btn._classes],
|
|
title: btn.title,
|
|
madeTextarea: !!ta,
|
|
taValue: ta ? ta.value : null,
|
|
announced: liveRegion ? liveRegion.textContent : null,
|
|
toast: toastEl.textContent,
|
|
liveRegionEager: !!liveRegion,
|
|
};
|
|
"""
|
|
)
|
|
assert out["flash"] == ["is-copied"]
|
|
assert out["title"] == "Copied"
|
|
assert out["madeTextarea"] is True
|
|
assert out["taValue"] == ""
|
|
# Outcome surfaces at the button only — flash, title, and ONE
|
|
# aria-live announcement. No toast: identical behavior on all three
|
|
# pages beats louder surfacing (the empty stub host is what makes
|
|
# this a real assertion).
|
|
assert out["toast"] == ""
|
|
assert out["announced"] == "Copied"
|
|
# The region must predate the first announcement — AT only announces
|
|
# changes to a region already in the accessibility tree.
|
|
assert out["liveRegionEager"] is True
|
|
|
|
|
|
def test_failed_copy_flashes_failure_state() -> None:
|
|
"""A transport failure (execCommand false on a plain-HTTP node) flashes
|
|
the ✗ state with the manual-copy hint — never a false ✓."""
|
|
out = _run(
|
|
"""
|
|
global.__execResult = false;
|
|
const body = makeEl('div');
|
|
body._copySource = 'content';
|
|
const msg = makeEl('div');
|
|
msg.querySelector = (sel) => (sel === '.msg-body' ? body : null);
|
|
const btn = buildMsgCopyButton(msg);
|
|
btn.click();
|
|
await settle();
|
|
return { flash: [...btn._classes], title: btn.title };
|
|
"""
|
|
)
|
|
assert out["flash"] == ["is-copy-failed"]
|
|
assert out["title"] == "Copy failed — select the text and copy manually"
|
|
|
|
|
|
def test_busy_pane_refuses_bubble_copy_click() -> None:
|
|
"""Every copy affordance is idle-only. The bubble button's click path
|
|
must refuse in JS while the messages container carries
|
|
data-busy="true" — the chat.css pointer-events gate alone cannot stop
|
|
Enter on a button that already holds focus. No clipboard write, but
|
|
the refusal ANSWERS: ✗ flash plus a busy-specific announcement — a
|
|
keyboard user must be able to tell "refused while streaming" from
|
|
"keystroke lost". The same click works once the container returns
|
|
to idle."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| a |');
|
|
const wrap = makeBusyWrap(a, true);
|
|
const btn = buildMsgCopyButton(a.msg);
|
|
a.bar.appendChild(btn);
|
|
a.body._copySource = 'the reply';
|
|
a.msg.querySelector = (sel) => (sel === '.msg-body' ? a.body : null);
|
|
btn.click();
|
|
await settle();
|
|
const taBusy = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
const busyState = {
|
|
madeTextarea: !!taBusy,
|
|
flash: [...btn._classes],
|
|
busyTitle: btn.title,
|
|
announced: liveRegion.textContent,
|
|
};
|
|
wrap.setAttribute('data-busy', 'false');
|
|
btn.click();
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
...busyState,
|
|
idleCopied: ta ? ta.value : null,
|
|
idleFlash: [...btn._classes],
|
|
};
|
|
"""
|
|
)
|
|
assert out["madeTextarea"] is False
|
|
assert out["flash"] == ["is-copy-failed"]
|
|
assert out["busyTitle"] == "Copy is available when the reply finishes"
|
|
assert out["announced"] == "Copy is available when the reply finishes"
|
|
assert out["idleCopied"] == "the reply"
|
|
assert out["idleFlash"] == ["is-copied"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The action bar helpers — the shared ARIA contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ensure_msg_actions_bar_contract_and_reuse() -> None:
|
|
"""One helper owns the bar's ARIA contract for every adder in both
|
|
clients: role=toolbar + accessible name, direct-child placement, and
|
|
reuse of an existing bar instead of stacking a second one."""
|
|
out = _run(
|
|
"""
|
|
const msg = makeEl('div');
|
|
msg.className = 'msg';
|
|
const bar1 = ensureMsgActionsBar(msg);
|
|
const bar2 = ensureMsgActionsBar(msg);
|
|
return {
|
|
sameBar: bar1 === bar2,
|
|
cls: bar1.className,
|
|
role: bar1.attrs['role'],
|
|
label: bar1.attrs['aria-label'],
|
|
isDirectChild: msg.children.includes(bar1),
|
|
found: findMsgActionsBar(msg) === bar1,
|
|
foundOnBare: findMsgActionsBar(makeEl('div')),
|
|
};
|
|
"""
|
|
)
|
|
assert out["sameBar"] is True
|
|
assert out["cls"] == "msg-actions"
|
|
assert out["role"] == "toolbar"
|
|
assert out["label"] == "Message actions"
|
|
assert out["isDirectChild"] is True
|
|
assert out["found"] is True
|
|
assert out["foundOnBare"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The floating button — pointer-only reveal, unconditional dismissal
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_hover_reveals_and_click_copies_block_source() -> None:
|
|
"""The pointer path end to end: hovering a block reveals the floating
|
|
button (mounted once in <body>, permanently out of the tab order —
|
|
keyboard has its own Enter path), and a click lands the block's
|
|
SOURCE on the clipboard with a ✓ flash."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| a |\\n|---|\\n| 1 |');
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
fab.click();
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
visible: fab.classList.contains('is-visible'),
|
|
inBody: fab.parentNode === document.body,
|
|
tabindex: fab.getAttribute('tabindex'),
|
|
copied: ta ? ta.value : null,
|
|
flash: [...fab._classes].filter((c) => c.startsWith('is-cop')),
|
|
announced: liveRegion.textContent,
|
|
};
|
|
"""
|
|
)
|
|
assert out["visible"] is True
|
|
assert out["inBody"] is True
|
|
assert out["tabindex"] == "-1"
|
|
assert out["copied"] == "| a |\n|---|\n| 1 |"
|
|
assert out["flash"] == ["is-copied"]
|
|
assert out["announced"] == "Copied"
|
|
|
|
|
|
def test_pointer_dismissal_is_unconditional() -> None:
|
|
"""Dismissal has no focus or keyboard gating left: a mouseover outside
|
|
any block hides the button, mouseleave (pointer leaving the window)
|
|
hides it, a scroll in its context hides it — even while an element
|
|
inside the block-and-button world holds focus — and a fresh hover
|
|
re-reveals after every dismissal. Without these pins, deleting a
|
|
dismissal listener ships green while the button strands painted over
|
|
unrelated content."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| a |');
|
|
const outsider = makeEl('div');
|
|
document.body.appendChild(outsider);
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
const shown = fab.classList.contains('is-visible');
|
|
fireDoc('mouseover', outsider);
|
|
const hiddenByMouseover = !fab.classList.contains('is-visible');
|
|
fireDoc('mouseover', a.table);
|
|
const reshown = fab.classList.contains('is-visible');
|
|
(document.documentElement.listeners.mouseleave || []).forEach((fn) =>
|
|
fn({ target: outsider }));
|
|
const hiddenByMouseleave = !fab.classList.contains('is-visible');
|
|
fireDoc('mouseover', a.table);
|
|
fireDoc('scroll', document);
|
|
const hiddenByScroll = !fab.classList.contains('is-visible');
|
|
// Focus anywhere in the old "keyboard-owned" world must not spare
|
|
// the reveal: pointer dismissal is unconditional now.
|
|
fireDoc('mouseover', a.table);
|
|
document.activeElement = a.table;
|
|
fireDoc('mouseover', outsider);
|
|
const hiddenDespiteBlockFocus = !fab.classList.contains('is-visible');
|
|
fireDoc('mouseover', a.table);
|
|
document.activeElement = fab;
|
|
fireDoc('mouseover', outsider);
|
|
const hiddenDespiteFabFocus = !fab.classList.contains('is-visible');
|
|
return {
|
|
shown,
|
|
hiddenByMouseover,
|
|
reshown,
|
|
hiddenByMouseleave,
|
|
hiddenByScroll,
|
|
hiddenDespiteBlockFocus,
|
|
hiddenDespiteFabFocus,
|
|
};
|
|
"""
|
|
)
|
|
assert out["shown"] is True
|
|
assert out["hiddenByMouseover"] is True
|
|
assert out["reshown"] is True
|
|
assert out["hiddenByMouseleave"] is True
|
|
assert out["hiddenByScroll"] is True
|
|
assert out["hiddenDespiteBlockFocus"] is True
|
|
assert out["hiddenDespiteFabFocus"] is True
|
|
|
|
|
|
def test_clipboard_shim_hover_does_not_dismiss_button() -> None:
|
|
"""The legacy copy path's off-screen textarea sits at the viewport
|
|
origin; a pointer event targeting it mid-copy must not read as "the
|
|
pointer left the block" and dismiss the button whose outcome flash is
|
|
about to land. The shim marker (set in utils.js) exempts it."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| a |');
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
const shim = makeEl('textarea');
|
|
shim.setAttribute('data-clipboard-shim', '');
|
|
document.body.appendChild(shim);
|
|
fireDoc('mouseover', shim);
|
|
return { afterShimHover: fab.classList.contains('is-visible') };
|
|
"""
|
|
)
|
|
assert out["afterShimHover"] is True
|
|
|
|
|
|
def test_disconnected_target_click_flashes_failure() -> None:
|
|
"""The click guard is target-liveness only: when the revealed block has
|
|
left the DOM by click time (transcript wipe under the pointer), the
|
|
click fails with an honest ✗ — it never silently copies a stale node
|
|
and never retargets to whatever now occupies the space, even when a
|
|
same-kind replacement exists."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| gone |');
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
a.body.children.length = 0;
|
|
a.table.isConnected = false;
|
|
a.table.parentNode = null;
|
|
a.table.parentElement = null;
|
|
const fresh = makeEl('div');
|
|
fresh._classes.add('table-wrap');
|
|
fresh.setAttribute('data-md-source', '| fresh |');
|
|
a.body.appendChild(fresh);
|
|
fab.click();
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
flash: [...fab._classes].filter((c) => c.startsWith('is-cop')),
|
|
madeTextarea: !!ta,
|
|
toast: toastEl.textContent,
|
|
};
|
|
"""
|
|
)
|
|
assert out["flash"] == ["is-copy-failed"]
|
|
assert out["madeTextarea"] is False
|
|
assert out["toast"] == ""
|
|
|
|
|
|
def test_nested_error_pre_lifts_to_container() -> None:
|
|
"""The mermaid ERROR state draws its message + source as a <pre> INSIDE
|
|
the .mermaid-container. That pre is a rendering detail, not a copy
|
|
target: hovering it must reveal the button FOR THE CONTAINER (whose
|
|
data-mermaid-source is the honest source), and an Enter keydown
|
|
reaching the nested pre must copy the container too — both paths lift
|
|
through the same helper."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| t |');
|
|
const mermaid = makeEl('div');
|
|
mermaid._classes.add('mermaid-container');
|
|
mermaid.setAttribute('data-mermaid-source', 'graph TD');
|
|
mermaid.setAttribute('tabindex', '0');
|
|
const errPre = makeEl('pre');
|
|
errPre.textContent = 'mermaid error source';
|
|
a.body.appendChild(mermaid);
|
|
mermaid.appendChild(errPre);
|
|
fireDoc('mouseover', errPre);
|
|
const hoverTarget = blockCopySource(_fabTarget);
|
|
fireDoc('keydown', errPre, { key: 'Enter' });
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
hoverTarget,
|
|
kbdCopied: ta ? ta.value : null,
|
|
flashOnContainer: mermaid.classList.contains('is-copied'),
|
|
flashOnErrPre: errPre.classList.contains('is-copied'),
|
|
};
|
|
"""
|
|
)
|
|
assert out["hoverTarget"] == "graph TD"
|
|
assert out["kbdCopied"] == "graph TD"
|
|
assert out["flashOnContainer"] is True
|
|
assert out["flashOnErrPre"] is False
|
|
|
|
|
|
def test_busy_pane_gates_fab_reveal_and_click() -> None:
|
|
"""The floating button must never REVEAL while the pane is busy (the
|
|
turn is mutating the transcript under it), and a click on a button
|
|
revealed at idle whose pane went busy before the click refuses with
|
|
an honest ✗ and the busy-specific announcement instead of copying —
|
|
or silently swallowing — the gesture. Idle again, the same hover
|
|
reveals normally."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| a |');
|
|
const wrap = makeBusyWrap(a, true);
|
|
fireDoc('mouseover', a.table);
|
|
const revealedWhileBusy = !!fabEl() && fabEl().classList.contains('is-visible');
|
|
wrap.setAttribute('data-busy', 'false');
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
const revealedAtIdle = fab.classList.contains('is-visible');
|
|
wrap.setAttribute('data-busy', 'true');
|
|
fab.click();
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
revealedWhileBusy,
|
|
revealedAtIdle,
|
|
clickFlash: [...fab._classes].filter((c) => c.startsWith('is-cop')),
|
|
announced: liveRegion.textContent,
|
|
madeTextarea: !!ta,
|
|
};
|
|
"""
|
|
)
|
|
assert out["revealedWhileBusy"] is False
|
|
assert out["revealedAtIdle"] is True
|
|
assert out["clickFlash"] == ["is-copy-failed"]
|
|
assert out["announced"] == "Copy is available when the reply finishes"
|
|
assert out["madeTextarea"] is False
|
|
|
|
|
|
def test_within_block_move_dismisses_fab_when_turn_starts() -> None:
|
|
"""The mouseover fast path (pointer moving WITHIN the already-targeted
|
|
block) must still honor the busy gate: a turn starting under a shown
|
|
button dismisses it on the NEXT pointer move — not only once the
|
|
pointer eventually leaves the block."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| a |');
|
|
const wrap = makeBusyWrap(a, false);
|
|
const cell = makeEl('span');
|
|
a.table.appendChild(cell);
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
const revealedAtIdle = fab.classList.contains('is-visible');
|
|
wrap.setAttribute('data-busy', 'true');
|
|
fireDoc('mouseover', cell);
|
|
return {
|
|
revealedAtIdle,
|
|
visibleAfterBusyMove: fab.classList.contains('is-visible'),
|
|
};
|
|
"""
|
|
)
|
|
assert out["revealedAtIdle"] is True
|
|
assert out["visibleAfterBusyMove"] is False
|
|
|
|
|
|
def test_scroll_dismissal_follows_containment() -> None:
|
|
"""Scroll hides the button exactly when the scrolled thing MOVES the
|
|
block: the document, or any scrollable ancestor holding the block,
|
|
dismisses; a pan INSIDE the block (a fence's internal horizontal
|
|
scroll) and an unrelated scroller (a sidebar) keep it — regardless
|
|
of whether the surface has an overflow container at all."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| a |');
|
|
const wrap = makeBusyWrap(a, false);
|
|
const sidebar = makeEl('div');
|
|
document.body.appendChild(sidebar);
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
const shown = fab.classList.contains('is-visible');
|
|
fireDoc('scroll', a.table);
|
|
const keptOnInternalPan = fab.classList.contains('is-visible');
|
|
fireDoc('scroll', sidebar);
|
|
const keptOnSidebar = fab.classList.contains('is-visible');
|
|
fireDoc('scroll', wrap);
|
|
const hiddenOnAncestor = !fab.classList.contains('is-visible');
|
|
fireDoc('mouseover', a.table);
|
|
fireDoc('scroll', document);
|
|
const hiddenOnDocument = !fab.classList.contains('is-visible');
|
|
return {
|
|
shown,
|
|
keptOnInternalPan,
|
|
keptOnSidebar,
|
|
hiddenOnAncestor,
|
|
hiddenOnDocument,
|
|
};
|
|
"""
|
|
)
|
|
assert out["shown"] is True
|
|
assert out["keptOnInternalPan"] is True
|
|
assert out["keptOnSidebar"] is True
|
|
assert out["hiddenOnAncestor"] is True
|
|
assert out["hiddenOnDocument"] is True
|
|
|
|
|
|
def test_inflight_copy_outcome_is_orphaned_by_retarget() -> None:
|
|
"""The clipboard write settles asynchronously; a re-target inside that
|
|
gap must orphan the pending outcome — otherwise the ✓ and its
|
|
announcement land on a block the user never copied."""
|
|
out = _run(
|
|
"""
|
|
global.isSecureContext = true;
|
|
let resolveWrite = null;
|
|
setNavigator({
|
|
clipboard: {
|
|
writeText: () => new Promise((r) => { resolveWrite = r; }),
|
|
},
|
|
});
|
|
const a = makeBubble('| one |');
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
fab.click();
|
|
const b = makeBubble('| two |');
|
|
fireDoc('mouseover', b.table);
|
|
resolveWrite();
|
|
await settle();
|
|
return {
|
|
flash: [...fab._classes].filter((c) => c.startsWith('is-cop')),
|
|
announced: liveRegion.textContent,
|
|
};
|
|
"""
|
|
)
|
|
assert out["flash"] == []
|
|
assert out["announced"] == ""
|
|
|
|
|
|
def test_second_copy_during_flash_window_keeps_its_outcome() -> None:
|
|
"""A copy started while the previous outcome flash is still showing
|
|
claims the element's outcome slot: the earlier flash's revert timer
|
|
is cancelled at copy start. Left armed, it fires mid-write, bumps
|
|
the generation, and the settling write paints nothing and announces
|
|
nothing — a silent copy, or worse a silent FAILURE the user reads as
|
|
success. The second test that waits out the real 1.4s timer."""
|
|
out = _run(
|
|
"""
|
|
global.isSecureContext = true;
|
|
let resolveWrite = null;
|
|
let calls = 0;
|
|
setNavigator({
|
|
clipboard: {
|
|
writeText: () => {
|
|
calls += 1;
|
|
if (calls === 1) return Promise.resolve();
|
|
return new Promise((r) => { resolveWrite = r; });
|
|
},
|
|
},
|
|
});
|
|
const a = makeBubble('| a |');
|
|
const btn = buildMsgCopyButton(a.msg);
|
|
a.bar.appendChild(btn);
|
|
a.body._copySource = 'reply';
|
|
a.msg.querySelector = (sel) => (sel === '.msg-body' ? a.body : null);
|
|
btn.click();
|
|
await settle();
|
|
const firstFlash = [...btn._classes];
|
|
btn.click();
|
|
// Outlive the FIRST flash's 1400ms revert while the second write
|
|
// is still in flight — with the timer cancelled, nothing fires.
|
|
await settle(1600);
|
|
const inflightClasses = [...btn._classes];
|
|
liveRegion.textContent = '';
|
|
resolveWrite();
|
|
await settle();
|
|
return {
|
|
firstFlash,
|
|
inflightClasses,
|
|
finalFlash: [...btn._classes],
|
|
announced: liveRegion.textContent,
|
|
};
|
|
"""
|
|
)
|
|
assert out["firstFlash"] == ["is-copied"]
|
|
# In flight, the element sits at idle (the new copy cleared the old
|
|
# flash); the orphaning revert never fires.
|
|
assert out["inflightClasses"] == []
|
|
assert out["finalFlash"] == ["is-copied"]
|
|
assert out["announced"] == "Copied"
|
|
|
|
|
|
def test_aborted_reveal_on_sliver_block_does_not_strand_state() -> None:
|
|
"""_showFabFor assigns the target refs BEFORE its sliver check; when
|
|
the check aborts a reveal that started with the button hidden, the
|
|
refs must still be cleared — stranded refs make the hover fast-path
|
|
treat the block as already handled, permanently killing its copy
|
|
affordance."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| a |');
|
|
// The block peeks 12px into the viewport: under the 32px sliver
|
|
// threshold, so the reveal aborts while the fab is still hidden.
|
|
a.table._rect = { top: 588, bottom: 700, left: 0, right: 200, width: 200, height: 112 };
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
const hiddenAfterSliver = !fab || !fab.classList.contains('is-visible');
|
|
// The block scrolls fully into view; the SAME interaction must now
|
|
// reveal the button.
|
|
a.table._rect = { top: 100, bottom: 220, left: 0, right: 200, width: 200, height: 120 };
|
|
fireDoc('mouseover', a.table);
|
|
const revealsAfterHover = fabEl().classList.contains('is-visible');
|
|
return { hiddenAfterSliver, revealsAfterHover };
|
|
"""
|
|
)
|
|
assert out["hiddenAfterSliver"] is True
|
|
assert out["revealsAfterHover"] is True
|
|
|
|
|
|
def test_retarget_clears_previous_outcome_flash() -> None:
|
|
"""The singleton button must never carry one block's ✓/✗ onto another —
|
|
a fresh reveal starts idle, even inside the 1.4s flash window."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| one |');
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
fab.click();
|
|
await settle();
|
|
const flashedAfterCopy = fab.classList.contains('is-copied');
|
|
const b = makeBubble('| two |');
|
|
fireDoc('mouseover', b.table);
|
|
return {
|
|
flashedAfterCopy,
|
|
carried: [...fab._classes].filter((c) => c.startsWith('is-cop')),
|
|
title: fab.title,
|
|
};
|
|
"""
|
|
)
|
|
assert out["flashedAfterCopy"] is True
|
|
assert out["carried"] == []
|
|
assert out["title"] == "Copy block"
|
|
|
|
|
|
def test_unresolvable_bar_collision_hides_instead_of_stacking() -> None:
|
|
"""When the band clamp leaves nowhere below the action bar, the
|
|
floating button must hide rather than paint on top of the bubble's own
|
|
copy button — two stacked identical glyphs make a click that targets
|
|
one silently hit the other."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| a |');
|
|
// The block hugs the bottom of the viewport band while the bar sits
|
|
// just above the only admissible strip: the collision push has no
|
|
// room below the bar.
|
|
a.table._rect = { top: 560, bottom: 700, left: 0, right: 200, width: 200, height: 140 };
|
|
a.bar._rect = { top: 555, bottom: 579, left: 150, right: 200, width: 50, height: 24 };
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
return { visible: !!fab && fab.classList.contains('is-visible') };
|
|
"""
|
|
)
|
|
assert out["visible"] is False
|
|
|
|
|
|
def test_failure_flash_orphans_prior_inflight_copy() -> None:
|
|
"""A failure flash is a NEW outcome: a still-pending copy from an
|
|
earlier click must not settle afterwards and flip the ✗ (with its
|
|
recovery title) back to an unearned ✓."""
|
|
out = _run(
|
|
"""
|
|
global.isSecureContext = true;
|
|
let resolveWrite = null;
|
|
setNavigator({
|
|
clipboard: {
|
|
writeText: () => new Promise((r) => { resolveWrite = r; }),
|
|
},
|
|
});
|
|
const a = makeBubble('| slow |');
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
fab.click();
|
|
// The bubble empties (no replacement), so the second click finds a
|
|
// disconnected target and flashes the failure.
|
|
a.body.children.length = 0;
|
|
a.table.isConnected = false;
|
|
a.table.parentNode = null;
|
|
a.table.parentElement = null;
|
|
fab.click();
|
|
const failedShown = fab.classList.contains('is-copy-failed');
|
|
// The first click's slow write now settles successfully.
|
|
resolveWrite(true);
|
|
await settle();
|
|
return {
|
|
failedShown,
|
|
stillFailed: fab.classList.contains('is-copy-failed'),
|
|
notFlippedToCopied: !fab.classList.contains('is-copied'),
|
|
title: fab.title,
|
|
};
|
|
"""
|
|
)
|
|
assert out["failedShown"] is True
|
|
assert out["stillFailed"] is True
|
|
assert out["notFlippedToCopied"] is True
|
|
assert out["title"] == "Copy failed — select the text and copy manually"
|
|
|
|
|
|
def test_click_survives_post_reveal_sliver_without_hiding() -> None:
|
|
"""A click on a still-connected target must not re-run placement — a
|
|
reflow may have slivered the block since the reveal, and a reposition
|
|
would hide the button while the copy proceeds, making its outcome
|
|
flash invisible. The click path copies, flashes, and leaves the
|
|
button exactly where the user pressed it."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| here |');
|
|
fireDoc('mouseover', a.table);
|
|
const fab = fabEl();
|
|
// The block slivers AFTER the reveal (late reflow).
|
|
a.table._rect = { top: 588, bottom: 700, left: 0, right: 200, width: 200, height: 112 };
|
|
fab.click();
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
copied: ta ? ta.value : null,
|
|
stillVisible: fab.classList.contains('is-visible'),
|
|
flashed: fab.classList.contains('is-copied'),
|
|
};
|
|
"""
|
|
)
|
|
assert out["copied"] == "| here |"
|
|
assert out["stillVisible"] is True
|
|
assert out["flashed"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The keyboard path — Enter on a focused block
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_enter_on_block_copies_source_and_flashes_the_block() -> None:
|
|
"""Enter on a focused block copies that block's source directly: no
|
|
floating button involved (none is created, let alone revealed), the
|
|
outcome flashes on the BLOCK (is-copied), and the live region
|
|
announces it. Enter reaching the listener from a descendant of the
|
|
block (a focusable child owns its own Enter semantics) and non-Enter
|
|
keys are ignored."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| kbd |\\n|---|\\n| 1 |');
|
|
const cell = makeEl('a');
|
|
a.table.appendChild(cell);
|
|
fireDoc('keydown', cell, { key: 'Enter' });
|
|
fireDoc('keydown', a.table, { key: 'a' });
|
|
await settle();
|
|
const taEarly = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
const ignored = {
|
|
madeTextarea: !!taEarly,
|
|
flash: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
|
|
};
|
|
fireDoc('keydown', a.table, { key: 'Enter' });
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
...ignored,
|
|
copied: ta ? ta.value : null,
|
|
blockFlash: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
|
|
blockTitle: a.table.title,
|
|
announced: liveRegion.textContent,
|
|
fabExists: !!fabEl(),
|
|
};
|
|
"""
|
|
)
|
|
assert out["madeTextarea"] is False
|
|
assert out["flash"] == []
|
|
assert out["copied"] == "| kbd |\n|---|\n| 1 |"
|
|
assert out["blockFlash"] == ["is-copied"]
|
|
assert out["blockTitle"] == "Copied"
|
|
assert out["announced"] == "Copied"
|
|
assert out["fabExists"] is False
|
|
|
|
|
|
def test_enter_on_block_refuses_while_busy() -> None:
|
|
"""The keyboard path shares the pane-level busy gate: Enter on a
|
|
focused block inside a data-busy="true" container copies nothing but
|
|
ANSWERS — the block flashes the ✗ ring and the live region carries
|
|
the busy-specific explanation, because a silent refusal is
|
|
indistinguishable from a lost keystroke. The same Enter works once
|
|
the pane is idle."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| kbd |');
|
|
const wrap = makeBusyWrap(a, true);
|
|
fireDoc('keydown', a.table, { key: 'Enter' });
|
|
await settle();
|
|
const taBusy = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
const busyState = {
|
|
madeTextarea: !!taBusy,
|
|
flash: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
|
|
announced: liveRegion.textContent,
|
|
};
|
|
wrap.setAttribute('data-busy', 'false');
|
|
fireDoc('keydown', a.table, { key: 'Enter' });
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
...busyState,
|
|
idleCopied: ta ? ta.value : null,
|
|
idleFlash: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
|
|
};
|
|
"""
|
|
)
|
|
assert out["madeTextarea"] is False
|
|
assert out["flash"] == ["is-copy-failed"]
|
|
assert out["announced"] == "Copy is available when the reply finishes"
|
|
assert out["idleCopied"] == "| kbd |"
|
|
assert out["idleFlash"] == ["is-copied"]
|
|
|
|
|
|
def test_enter_chords_and_repeat_do_not_copy() -> None:
|
|
"""Modifier-Enter chords belong to the browser / OS, and key repeat is
|
|
never a deliberate copy — a copy overwrites the user's clipboard, so
|
|
only a plain, single Enter fires."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| kbd |');
|
|
for (const props of [
|
|
{ key: 'Enter', ctrlKey: true },
|
|
{ key: 'Enter', altKey: true },
|
|
{ key: 'Enter', metaKey: true },
|
|
{ key: 'Enter', shiftKey: true },
|
|
{ key: 'Enter', repeat: true },
|
|
]) fireDoc('keydown', a.table, props);
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
const chordState = {
|
|
madeTextarea: !!ta,
|
|
flash: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
|
|
};
|
|
fireDoc('keydown', a.table, { key: 'Enter' });
|
|
await settle();
|
|
const ta2 = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return { ...chordState, plainCopied: ta2 ? ta2.value : null };
|
|
"""
|
|
)
|
|
assert out["madeTextarea"] is False
|
|
assert out["flash"] == []
|
|
assert out["plainCopied"] == "| kbd |"
|
|
|
|
|
|
def test_enter_with_empty_source_copies_empty_string() -> None:
|
|
"""A focused block whose source resolves empty (a mermaid container
|
|
stripped of its stash) still attempts the write: the block flashes the
|
|
transport's verdict — never a manufactured failure whose "select the
|
|
text" hint points at a block with nothing to select."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| t |');
|
|
const mermaid = makeEl('div');
|
|
mermaid._classes.add('mermaid-container');
|
|
mermaid.setAttribute('tabindex', '0');
|
|
a.body.appendChild(mermaid);
|
|
fireDoc('keydown', mermaid, { key: 'Enter' });
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
madeTextarea: !!ta,
|
|
taValue: ta ? ta.value : null,
|
|
flash: [...mermaid._classes].filter((c) => c.startsWith('is-cop')),
|
|
announced: liveRegion.textContent,
|
|
};
|
|
"""
|
|
)
|
|
assert out["madeTextarea"] is True
|
|
assert out["taValue"] == ""
|
|
assert out["flash"] == ["is-copied"]
|
|
assert out["announced"] == "Copied"
|
|
|
|
|
|
def test_block_flash_reverts_clean_after_flash_ms() -> None:
|
|
"""The outcome flash on a keyboard-copied BLOCK is transient: after
|
|
FLASH_MS the state class reverts and the title carries no residue — a
|
|
block (which unlike the buttons has no idle tooltip of its own)
|
|
returns to an empty title, never a literal "undefined". The one test
|
|
that waits out the real 1.4s revert timer."""
|
|
out = _run(
|
|
"""
|
|
const a = makeBubble('| kbd |');
|
|
fireDoc('keydown', a.table, { key: 'Enter' });
|
|
await settle();
|
|
const flashTitle = a.table.title;
|
|
const flashed = [...a.table._classes].filter((c) => c.startsWith('is-cop'));
|
|
await settle(1500);
|
|
return {
|
|
flashed,
|
|
flashTitle,
|
|
idleClasses: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
|
|
idleTitle: a.table.title,
|
|
};
|
|
"""
|
|
)
|
|
assert out["flashed"] == ["is-copied"]
|
|
assert out["flashTitle"] == "Copied"
|
|
assert out["idleClasses"] == []
|
|
assert out["idleTitle"] == ""
|
|
|
|
|
|
def test_enter_outside_msg_body_is_ignored() -> None:
|
|
"""The keyboard path is scoped to blocks inside rendered chat bodies —
|
|
a focusable pre on some other surface (admin panes, previews) keeps
|
|
its own Enter semantics."""
|
|
out = _run(
|
|
"""
|
|
const stray = makeEl('pre');
|
|
stray.setAttribute('tabindex', '0');
|
|
stray.textContent = 'not transcript content';
|
|
document.body.appendChild(stray);
|
|
fireDoc('keydown', stray, { key: 'Enter' });
|
|
await settle();
|
|
const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
|
|
return {
|
|
madeTextarea: !!ta,
|
|
flash: [...stray._classes].filter((c) => c.startsWith('is-cop')),
|
|
};
|
|
"""
|
|
)
|
|
assert out["madeTextarea"] is False
|
|
assert out["flash"] == []
|