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.
205 lines
7.3 KiB
JavaScript
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);
|