Files
based-dsh-diving/test/client.test.mjs
T
mward4 32381a6d00 v0.1.2: support the DSH 0.1.7-alpha.2 turn-status label
DSH 0.1.7-alpha.2 removed the visible [role="status"] turn status. The
running label now lives inside a <button data-turn-process> and reads
"Deep diving for 12s" (en) / "深度求索中,用时12s" (zh), rewritten every
second with the live duration. The only remaining [role="status"] is a
visually hidden screen-reader announcement, which the plugin previously
rewrote by mistake while the visible label went untouched.

- Locate the label via [data-turn-process], falling back to the legacy
  [role="status"] text node on DSH <= 0.1.5.
- Match running labels by locale-aware prefix so duration rewording and
  future locales keep working.
- Release the claim when the label stops meaning "a turn is running", so
  the final "Worked for 12s" is never clobbered.
- Restore the label synchronously from the MutationObserver fast path:
  observer callbacks run before paint, so React's per-second duration
  rewrite no longer flashes the stock text through.
- Never touch the visually hidden announcement; screen readers still get
  the real status.
- Wrap the effect in try/catch so an unexpected error disables the plugin
  instead of breaking the chat view.
- 29 new phrases (125 total).
- Test suite expanded to twelve checks covering both DOM layouts,
  turn-end release, and phrase-list integrity.
2026-09-23 19:15:50 -04:00

205 lines
7.3 KiB
JavaScript

// Headless harness for based-dsh-diving/client.js.
//
// Stubs just enough DOM to exercise both supported layouts:
// - 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; }
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;
if (selector === "[data-turn-process]") return el.attrs["data-turn-process"] !== undefined;
if (selector === '[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 };
};
const isJoke = (value) => /\.\.\.$/.test(value);
// ------------------------------------------------------- new layout (>= 0.1.7)
{
const root = useDom();
const button = new El("button", { "data-turn-process": "t1" });
const labelSpan = new El("span");
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("new layout: label claimed", isJoke(owned), JSON.stringify(owned));
ok("new layout: screen-reader announcement untouched", announcement.nodeValue === "Deep diving...", JSON.stringify(announcement.nodeValue));
// React rewrites the live duration every second; the observer must restore
// synchronously, before paint.
label.nodeValue = "Deep diving for 2s";
observerCallback?.([{ type: "characterData", target: label }]);
ok("new layout: live-duration rewrite re-asserted", label.nodeValue === owned, JSON.stringify(label.nodeValue));
// Turn ends: the plugin must release the label, not fight the final text.
label.nodeValue = "Worked for 12s";
observerCallback?.([{ type: "characterData", target: label }]);
ok("new layout: released on turn end", label.nodeValue === "Worked for 12s", JSON.stringify(label.nodeValue));
const before = label.nodeValue;
await wait(2400);
ok("new layout: 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), 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 button = new El("button", { "data-turn-process": "t2" });
const span = new El("span");
const label = new TextNode("深度求索中,用时3s");
span.append(label);
button.append(span);
root.append(button);
const session = start();
ok("localized running label claimed", isJoke(label.nodeValue) && !/深度求索中/.test(label.nodeValue), JSON.stringify(label.nodeValue));
const frozen = label.nodeValue;
session.dispose();
await wait(2400);
ok("disposer stops rotation", label.nodeValue === frozen, JSON.stringify(label.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);