Files
mward4 1500f44bed v0.1.3: support the DSH 0.2.0 running indicator, keep 0.1.7/legacy working
DSH 0.2.0-rc.1 moved the live running label out of button[data-turn-process]
(which now carries only completed text) and into a new div[data-chat-running]
indicator rendered through TextShimmer.

- Discover the label host newest-layout-first: [data-chat-running], then
  [data-turn-process], then [role="status"] as a legacy-only fallback.
- Rewrite every matching text node in a host, not just the first: TextShimmer
  renders the label twice (visible layer + aria-hidden decorative overlay), and
  leaving one behind paints the stock wording back on top of the phrase.
- Skip nested live regions while scanning, so the screen-reader announcement
  survives now that it sits inside the label host.
- Extend hostOf() to the new hook so the per-second rewrite is still restored
  synchronously, before paint.

Tests cover all three layouts, including 0.2.0 precedence over a finished
process row and both shimmer layers staying in sync.
2026-09-29 00:30:22 -04:00

298 lines
12 KiB
JavaScript

// Headless harness for based-dsh-diving/client.js.
//
// Stubs just enough DOM to exercise all three supported layouts:
// - DSH >= 0.2.0: <div data-chat-running> holding a hidden [role="status"]
// announcement plus a TextShimmer whose label is rendered
// twice (visible layer + aria-hidden decorative overlay)
// - DSH >= 0.1.7: <button data-turn-process> > <span>label</span>
// plus a visually hidden [role="status"] announcement
// - DSH <= 0.1.5: the visible label is the [role="status"] text itself
import { readFileSync } from "node:fs";
// ---------------------------------------------------------------- loader stub
const capturedLoads = [];
globalThis.window = { __ModuleLoader__: { load: (def) => capturedLoads.push(def) } };
globalThis.Node = { TEXT_NODE: 3, ELEMENT_NODE: 1 };
let observerCallback = null;
globalThis.MutationObserver = class {
constructor(cb) { observerCallback = cb; }
observe() {}
disconnect() { observerCallback = null; }
};
await import(new URL("../client.js", import.meta.url));
if (capturedLoads.length !== 1) throw new Error(`expected 1 load, got ${capturedLoads.length}`);
const definition = capturedLoads[0];
if (definition.id !== "@based-dsh-diving/dsh-plugin") throw new Error(`bad id: ${definition.id}`);
// ------------------------------------------------------------------ DOM stub
function isConnected(node) {
for (let p = node.parent; p; p = p.parent) if (p.rooted) return true;
return false;
}
class TextNode {
constructor(value) { this.nodeType = 3; this.nodeValue = value; this.parent = null; this.nextSibling = null; }
get parentElement() { return this.parent; }
get isConnected() { return isConnected(this); }
}
class El {
constructor(tag, attrs = {}) {
this.nodeType = 1;
this.tagName = tag.toUpperCase();
this.attrs = attrs;
this.childNodes = [];
this.parent = null;
this.nextSibling = null;
this.rooted = false;
}
get parentElement() { return this.parent; }
get isConnected() { return this.rooted || isConnected(this); }
get firstChild() { return this.childNodes[0] ?? null; }
get firstElementChild() { return this.childNodes.find((n) => n.nodeType === 1) ?? null; }
getAttribute(name) { return this.attrs[name] === undefined ? null : String(this.attrs[name]); }
append(...nodes) {
for (const node of nodes) { node.parent = this; this.childNodes.push(node); }
this.childNodes.forEach((n, i) => { n.nextSibling = this.childNodes[i + 1] ?? null; });
return this;
}
contains(node) { for (let p = node; p; p = p.parent) if (p === this) return true; return false; }
closest(selector) {
for (let el = this; el; el = el.parent) if (matches(el, selector)) return el;
return null;
}
}
function matches(el, selector) {
if (el.nodeType !== 1) return false;
return selector.split(",").some((raw) => {
const part = raw.trim();
if (part === "[data-turn-process]") return el.attrs["data-turn-process"] !== undefined;
if (part === "[data-chat-running]") return el.attrs["data-chat-running"] !== undefined;
if (part === '[role="status"]') return el.attrs.role === "status";
return false;
});
}
function allElements(root) {
const out = [];
const walk = (node) => {
for (let c = node.firstChild; c; c = c.nextSibling) {
if (c.nodeType === 1) { out.push(c); walk(c); }
}
};
walk(root);
return out;
}
function useDom() {
const root = new El("div");
root.rooted = true;
globalThis.document = {
documentElement: root,
querySelectorAll: (selector) => allElements(root).filter((el) => matches(el, selector))
};
return root;
}
// ---------------------------------------------------------------- assertions
let failures = 0;
const ok = (name, condition, extra = "") => {
if (!condition) failures += 1;
console.log(`${condition ? "PASS" : "FAIL"} ${name}${extra ? " " + extra : ""}`);
};
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const start = () => {
const mod = definition.factory(() => { throw new Error("require not expected"); });
let dispose = null;
mod.apply({ effect: (fn) => { dispose = fn(); } });
return { dispose };
};
// "Deep diving" is itself one of the phrases, so compare against the stock text
// this layout shipped rather than pattern-matching the joke wording.
const isJoke = (value, stock) => /\.\.\.$/.test(value) && value !== stock;
const fire = (...nodes) => observerCallback?.(nodes.map((target) => ({ type: "characterData", target })));
/** Build DSH >= 0.2.0's RunningStatus subtree and return its label text nodes. */
function buildRunningStatus(root, labelText) {
const running = new El("div", { "data-chat-running": "true", class: "running" });
const hidden = new El("span", { role: "status", "aria-live": "polite", "aria-atomic": "true" });
const announcement = new TextNode("Deep diving...");
hidden.append(announcement);
const divider = new El("span", { "aria-hidden": "true" });
const content = new El("span", { class: "runningContent" });
const shimmer = new El("span", { "data-shimmer": "true" });
const visibleLayer = new El("span", { class: "content" });
const visible = new TextNode(labelText);
visibleLayer.append(visible);
const decoration = new El("span", { "aria-hidden": "true" });
const sweep = new El("span", { class: "sweep" });
const highlightLayer = new El("span", { class: "content highlight" });
const highlight = new TextNode(labelText);
highlightLayer.append(highlight);
sweep.append(highlightLayer);
decoration.append(sweep);
shimmer.append(visibleLayer, decoration);
content.append(shimmer);
running.append(hidden, divider, content);
root.append(running);
return { running, visible, highlight, announcement };
}
// -------------------------------------------- current layout (DSH >= 0.2.0)
{
const root = useDom();
const { visible, highlight, announcement } = buildRunningStatus(root, "Deep diving for 1s...");
const session = start();
const owned = visible.nodeValue;
ok("0.2.0: visible shimmer layer claimed", isJoke(owned, "Deep diving for 1s..."), JSON.stringify(owned));
ok("0.2.0: decorative overlay stays in sync", highlight.nodeValue === owned, JSON.stringify(highlight.nodeValue));
ok("0.2.0: screen-reader announcement untouched", announcement.nodeValue === "Deep diving...", JSON.stringify(announcement.nodeValue));
// React rewrites the live duration every second, in both layers at once.
visible.nodeValue = "Deep diving for 2s...";
highlight.nodeValue = "Deep diving for 2s...";
fire(visible, highlight);
ok("0.2.0: live-duration rewrite re-asserted (visible)", visible.nodeValue === owned, JSON.stringify(visible.nodeValue));
ok("0.2.0: live-duration rewrite re-asserted (overlay)", highlight.nodeValue === owned, JSON.stringify(highlight.nodeValue));
// Turn ends: the plugin must release, not fight the final text.
visible.nodeValue = "Worked for 12s";
highlight.nodeValue = "Worked for 12s";
fire(visible, highlight);
ok("0.2.0: released on turn end", visible.nodeValue === "Worked for 12s", JSON.stringify(visible.nodeValue));
const before = visible.nodeValue;
await wait(2400);
ok("0.2.0: stays released after a rotation tick", visible.nodeValue === before, JSON.stringify(visible.nodeValue));
session.dispose();
}
// ------------------------------- precedence: a stale completed process row
// On 0.2.0 the collapsible process row still exists for finished turns, so the
// running indicator must win and the finished row must be left alone.
{
const root = useDom();
const done = new El("button", { "data-turn-process": "t0" });
const doneLabel = new TextNode("Worked for 40s");
const doneSpan = new El("span", { class: "label" });
doneSpan.append(doneLabel);
done.append(doneSpan);
root.append(done);
const { visible } = buildRunningStatus(root, "Deep diving for 1s...");
const session = start();
ok("0.2.0: running indicator wins over finished row", isJoke(visible.nodeValue, "Deep diving for 1s..."), JSON.stringify(visible.nodeValue));
ok("0.2.0: finished process row untouched", doneLabel.nodeValue === "Worked for 40s", JSON.stringify(doneLabel.nodeValue));
// A finished row that React later rewrites must not be claimed either.
doneLabel.nodeValue = "Took 41s";
fire(doneLabel);
ok("0.2.0: finished row never claimed", doneLabel.nodeValue === "Took 41s", JSON.stringify(doneLabel.nodeValue));
session.dispose();
}
// ----------------------------------------------------------------- 0.1.7 row
{
const root = useDom();
const button = new El("button", { "data-turn-process": "t1" });
const labelSpan = new El("span", { class: "label" });
const label = new TextNode("Deep diving for 1s");
labelSpan.append(label);
button.append(labelSpan);
const hidden = new El("span", { role: "status", "aria-live": "polite", "aria-atomic": "true" });
const announcement = new TextNode("Deep diving...");
hidden.append(announcement);
root.append(hidden, button);
const session = start();
const owned = label.nodeValue;
ok("0.1.7: label claimed", isJoke(owned, "Deep diving for 1s"), JSON.stringify(owned));
ok("0.1.7: screen-reader announcement untouched", announcement.nodeValue === "Deep diving...", JSON.stringify(announcement.nodeValue));
label.nodeValue = "Deep diving for 2s";
fire(label);
ok("0.1.7: live-duration rewrite re-asserted", label.nodeValue === owned, JSON.stringify(label.nodeValue));
label.nodeValue = "Worked for 12s";
fire(label);
ok("0.1.7: released on turn end", label.nodeValue === "Worked for 12s", JSON.stringify(label.nodeValue));
const before = label.nodeValue;
await wait(2400);
ok("0.1.7: stays released after a rotation tick", label.nodeValue === before, JSON.stringify(label.nodeValue));
session.dispose();
}
// ---------------------------------------------------- legacy layout (<= 0.1.5)
{
const root = useDom();
const status = new El("div", { role: "status", "aria-live": "polite" });
const label = new TextNode("Deep diving...");
const clock = new El("span", { "aria-hidden": "true" });
const clockText = new TextNode("12s");
clock.append(clockText);
status.append(label, clock);
root.append(status);
const session = start();
const owned = label.nodeValue;
ok("legacy layout: label claimed", isJoke(owned, "Deep diving..."), JSON.stringify(owned));
ok("legacy layout: clock untouched", clockText.nodeValue === "12s", JSON.stringify(clockText.nodeValue));
await wait(2400);
ok("legacy layout: rotates on interval", label.nodeValue !== owned, JSON.stringify(label.nodeValue));
session.dispose();
}
// ------------------------------------------------- localized + misc assurance
{
const root = useDom();
const { visible } = buildRunningStatus(root, "深度求索中,用时 3s...");
const session = start();
ok("localized running label claimed", isJoke(visible.nodeValue, "深度求索中,用时 3s...") && !/深度求索中/.test(visible.nodeValue), JSON.stringify(visible.nodeValue));
const frozen = visible.nodeValue;
session.dispose();
await wait(2400);
ok("disposer stops rotation", visible.nodeValue === frozen, JSON.stringify(visible.nodeValue));
}
// ---------------------------------------------------------- static integrity
{
const source = readFileSync(new URL("../client.js", import.meta.url), "utf8");
const match = source.match(/var PHRASES = \[([\s\S]*?)\n \];/);
const phrases = eval(`[${match[1]}]`);
const dupes = phrases.filter((p, i) => phrases.indexOf(p) !== i);
ok("phrase list has no duplicates", dupes.length === 0, dupes.join(", "));
ok("phrase list is non-trivial", phrases.length >= 100, `${phrases.length} phrases`);
}
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILURE(S)`);
process.exit(failures === 0 ? 0 : 1);