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.
298 lines
12 KiB
JavaScript
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);
|