Files
Jyotisha/frontend/tests/react-client-lifecycle-test-support.ts
T
jesse-uxandGrok (agent loop) b7ec8e78ed
Independent Staging Quality Gate / validate (push) Failing after 9m20s
Independent Staging Quality Gate / publish (push) Skipped
星盘页换成组件库:页签、盘型条、可拖分栏和换盘动效(#107)。
四个页签用下划线,盘型条用分段并可以横滑,全部盘型从底部打开。桌面上盘和参数可以拖宽,窄屏上下排。行星表仍跨满整行。换盘型时星体移到新位置;减少动态效果只淡入。第一次进场不重播。

自测:tsc 退出码 0;frontend 下 npm run lint 0 error(122 警告);定向测试 188 通过、0 失败、0 取消;npm run build 后 / 仍是 Static;首屏 JS gzip-9 为 640131,相对 d25dfdbe 记下的 639127 多 0.16%。没有跑全量 npm test、Python 快速门和 test:db。

Co-Authored-By: Grok (agent loop)
2026-10-25 04:02:14 +08:00

178 lines
7.8 KiB
TypeScript

import { act, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
/** Minimal host DOM for React lifecycle tests, not a browser/layout simulator.
* Components may render div/section wrappers; no event dispatch, geometry or
* accessibility behavior is emulated. React's reconciler/effects are real.
*/
class HostElement {
readonly nodeType = 1;
readonly namespaceURI = "http://www.w3.org/1999/xhtml";
readonly childNodes: HostElement[] = [];
readonly style = {
setProperty() {},
getPropertyValue() { return ""; },
removeProperty() { return ""; },
};
open = false;
get disabled() { return this.hasAttribute("disabled"); }
isConnected = true;
getAttribute(name: string) { return this.attributes.get(name) ?? null; }
hasAttribute(name: string) { return this.attributes.has(name); }
focus() { this.ownerDocument.activeElement = this; }
showModal() { this.open = true; }
close() { this.open = false; }
scrollIntoView() {}
scrollTop = 0;
scrollHeight = 0;
clientHeight = 0;
clientWidth = 1440;
offsetWidth = 1440;
readonly scrollCalls: { top?: number }[] = [];
scrollTo(options: { top?: number }) { this.scrollCalls.push(options); }
/** No layout here: every box is the origin. Components that position from rects still run. */
getBoundingClientRect() { return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 }; }
/** Class selectors only (`.a` / `.a.b`); enough for components that find their scroller. */
closest(selector: string): HostElement | null {
const wanted = selector.split(".").filter(Boolean);
const classes = (this.getAttribute("class") ?? "").split(/\s+/);
if (wanted.every(name => classes.includes(name))) return this;
return this.parentNode?.closest(selector) ?? null;
}
get parentElement() { return this.parentNode; }
get children() { return this.childNodes.filter((child) => child.nodeType === 1); }
matches() { return false; }
querySelector() { return null; }
querySelectorAll() { return []; }
getRootNode() { return this.ownerDocument; }
contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); }
get text(): string { return this.textContent + this.childNodes.map(child => child.text).join(""); }
get props(): Record<string, unknown> { return (this as unknown as Record<string, Record<string, unknown>>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; }
readonly attributes = new Map<string, string>();
readonly dataset: Record<string, string> = {};
parentNode: HostElement | null = null;
textContent = "";
get nodeValue() { return this.textContent; }
set nodeValue(value: string) { this.textContent = value; }
constructor(readonly tagName: string, readonly ownerDocument: HostDocument) {}
get nodeName() { return this.tagName; }
get firstChild() { return this.childNodes[0] ?? null; }
addEventListener() {}
removeEventListener() {}
setAttribute(name: string, value: string) { this.attributes.set(name, String(value)); }
removeAttribute(name: string) { this.attributes.delete(name); }
appendChild(node: HostElement) {
this.childNodes.push(node);
node.parentNode = this;
return node;
}
insertBefore(node: HostElement, before: HostElement) {
this.childNodes.splice(this.childNodes.indexOf(before), 0, node);
node.parentNode = this;
return node;
}
removeChild(node: HostElement) {
this.childNodes.splice(this.childNodes.indexOf(node), 1);
node.parentNode = null;
return node;
}
}
class HostDocument {
readonly nodeType = 9;
readonly body: HostElement;
readonly head: HostElement;
readonly documentElement: HostElement;
activeElement: HostElement | null = null;
constructor() {
this.documentElement = new HostElement("HTML", this);
this.head = new HostElement("HEAD", this);
this.body = new HostElement("BODY", this);
this.documentElement.appendChild(this.head);
this.documentElement.appendChild(this.body);
}
createTextNode(text: string) { const node = new HostElement("#text", this); node.textContent = text; return node; }
getElementById() { return null; }
// React 19 hoists <style href precedence> onto the document. Base UI tabs
// emit that tag. Returning null lets React create the node and append it to head.
querySelector() { return null; }
querySelectorAll() { return []; }
getElementsByTagName() { return []; }
defaultView: unknown;
addEventListener() {}
removeEventListener() {}
createElement(tag: string) { return new HostElement(tag.toUpperCase(), this); }
createElementNS(_namespace: string, tag: string) { return this.createElement(tag); }
}
export function createClientLifecycleHarness() {
const document = new HostDocument();
const frames = new Map<number, ReturnType<typeof setTimeout>>();
let frameId = 0;
const requestAnimationFrame = (callback: () => void) => {
const id = ++frameId;
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
return id;
};
const cancelAnimationFrame = (id: number) => { clearTimeout(frames.get(id)); frames.delete(id); };
const window = {
document,
innerWidth: 1440,
setTimeout,
clearTimeout,
getComputedStyle: () => ({ direction: "ltr" }),
HTMLElement: HostElement,
Element: HostElement,
Node: HostElement,
HTMLIFrameElement: class {},
addEventListener() {},
removeEventListener() {},
requestAnimationFrame,
cancelAnimationFrame,
};
document.defaultView = window;
// tsx's ESM parser binds its runtime to global `self` when that binding
// exists. A partial window makes `cn` fail to parse. Next still calls
// `self.setTimeout`, so `self` is the real global, which already has it.
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, Node: HostElement, requestAnimationFrame, cancelAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true };
const originals = Object.fromEntries(Object.keys(globals).map((key) => [
key, Object.getOwnPropertyDescriptor(globalThis, key),
]));
for (const [key, value] of Object.entries(globals)) {
Object.defineProperty(globalThis, key, { value, writable: true, configurable: true });
}
const errors: unknown[] = [];
const container = document.createElement("div");
const root = createRoot(container as unknown as HTMLElement, {
onUncaughtError: (error) => { errors.push(error); },
});
function elements(node = container): HostElement[] { return [node, ...node.childNodes.flatMap(child => elements(child))]; }
return {
errors,
elements,
document,
container,
// Invoke the mounted host's current React handler inside act. This exercises
// real component state/effects, not browser bubbling, native dialog or layout.
async event(node: HostElement, name = "onClick", extra: Record<string, unknown> = {}) {
await act(async () => {
const handler = node.props[name] as ((event: unknown) => void) | undefined;
handler?.({ target: node, currentTarget: node, preventDefault() {}, stopPropagation() {}, nativeEvent: { preventDefault() {} }, ...extra });
});
},
async render(node: ReactNode) { await act(async () => { root.render(node); }); },
async update(action: () => void) { await act(async () => { action(); }); },
async idle() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); },
async close() {
try { await act(async () => { root.unmount(); }); }
finally {
for (const timer of frames.values()) clearTimeout(timer);
for (const [key, descriptor] of Object.entries(originals)) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
},
};
}