wayBetter for X
This commit is contained in:
commit
df5dff619e
720 changed files with 147465 additions and 0 deletions
252
apps/desktop/tests/fixtures/x/chat/miniDom.ts
vendored
Normal file
252
apps/desktop/tests/fixtures/x/chat/miniDom.ts
vendored
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// A small DOM for tests/area-chat.test.ts: enough of Element (attributes, children, text, `closest`,
|
||||
// `querySelectorAll` over the selectors Chat+ uses, listeners with bubbling, focus, style) to run
|
||||
// the Chat+ modules over X-shaped chat markup without a browser (./x.ts builds that markup and plays
|
||||
// X's part). Not a DOM library: it implements only what these tests use, and a selector it does not
|
||||
// understand throws. The selector engine is the one of ../profile/miniDom.ts.
|
||||
|
||||
type Simple = { tag?: string; id?: string; classes: string[]; attrs: { name: string; op?: string; value?: string }[]; nots: Simple[] };
|
||||
type Step = { simple: Simple; child: boolean };
|
||||
|
||||
/** Splits at top-level separators (not inside brackets, parentheses or quotes). */
|
||||
function split(source: string, separators: string): { part: string; by: string }[] {
|
||||
const out: { part: string; by: string }[] = [];
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let part = '';
|
||||
let by = '';
|
||||
for (const char of source) {
|
||||
if (quote) { part += char; if (char === quote) quote = ''; continue; }
|
||||
if (char === '"' || char === "'") { quote = char; part += char; continue; }
|
||||
if (char === '[' || char === '(') depth += 1;
|
||||
if (char === ']' || char === ')') depth -= 1;
|
||||
if (depth === 0 && separators.includes(char)) {
|
||||
if (part) { out.push({ part, by }); part = ''; by = ''; }
|
||||
if (char !== ' ') by = char;
|
||||
continue;
|
||||
}
|
||||
part += char;
|
||||
}
|
||||
if (part) out.push({ part, by });
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseSimple(source: string): Simple {
|
||||
const simple: Simple = { classes: [], attrs: [], nots: [] };
|
||||
let rest = source;
|
||||
const take = (pattern: RegExp) => { const match = pattern.exec(rest); if (match) rest = rest.slice(match[0].length); return match; };
|
||||
let match = take(/^[a-zA-Z][\w-]*/);
|
||||
if (match) simple.tag = match[0].toLowerCase();
|
||||
while (rest) {
|
||||
if ((match = take(/^#([\w-]+)/))) simple.id = match[1];
|
||||
else if ((match = take(/^\.([\w-]+)/))) simple.classes.push(match[1]!);
|
||||
else if ((match = take(/^\[([\w-]+)(?:([\^$*]?=)"([^"]*)")?\]/))) simple.attrs.push({ name: match[1]!, op: match[2], value: match[3] });
|
||||
else if ((match = take(/^:not\(([^()]*)\)/))) simple.nots.push(parseSimple(match[1]!));
|
||||
else throw new Error(`miniDom: selector not supported: ${source}`);
|
||||
}
|
||||
return simple;
|
||||
}
|
||||
|
||||
const parsed = new Map<string, Step[][]>();
|
||||
function parse(selector: string): Step[][] {
|
||||
let list = parsed.get(selector);
|
||||
if (!list) {
|
||||
list = split(selector, ',').map(({ part }) => split(part.trim(), ' >').map(step => ({ simple: parseSimple(step.part), child: step.by === '>' })));
|
||||
parsed.set(selector, list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function matchesSimple(el: El, simple: Simple): boolean {
|
||||
if (simple.tag && el.tagName.toLowerCase() !== simple.tag) return false;
|
||||
if (simple.id && el.getAttribute('id') !== simple.id) return false;
|
||||
for (const name of simple.classes) if (!el.classList.contains(name)) return false;
|
||||
for (const { name, op, value } of simple.attrs) {
|
||||
const actual = el.getAttribute(name);
|
||||
if (actual === null) return false;
|
||||
if (op === '=' && actual !== value) return false;
|
||||
if (op === '^=' && !actual.startsWith(value!)) return false;
|
||||
if (op === '$=' && !actual.endsWith(value!)) return false;
|
||||
if (op === '*=' && !actual.includes(value!)) return false;
|
||||
}
|
||||
return !simple.nots.some(not => matchesSimple(el, not));
|
||||
}
|
||||
|
||||
function matchesSteps(el: El, steps: Step[], index = steps.length - 1): boolean {
|
||||
const step = steps[index]!;
|
||||
if (!matchesSimple(el, step.simple)) return false;
|
||||
if (index === 0) return true;
|
||||
if (step.child) return !!el.parentElement && matchesSteps(el.parentElement, steps, index - 1);
|
||||
for (let up = el.parentElement; up; up = up.parentElement) if (matchesSteps(up, steps, index - 1)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export class Txt {
|
||||
text: string;
|
||||
parentElement: El | null = null;
|
||||
constructor(text: string) { this.text = text; }
|
||||
}
|
||||
|
||||
/** An event as the code under test makes them (`new win.MouseEvent(type, init)`) and as X's part fires them. */
|
||||
export class Ev {
|
||||
type: string;
|
||||
target: El | null = null;
|
||||
currentTarget: unknown = null;
|
||||
defaultPrevented = false;
|
||||
stopped = false;
|
||||
[key: string]: unknown;
|
||||
constructor(type: string, init: Record<string, unknown> = {}) { this.type = type; Object.assign(this, init); }
|
||||
preventDefault() { this.defaultPrevented = true; }
|
||||
stopPropagation() { this.stopped = true; }
|
||||
}
|
||||
|
||||
type Listener = (event: Ev) => void;
|
||||
type DocLike = { activeElement: unknown; emit(type: string, event: unknown): void };
|
||||
|
||||
class Style {
|
||||
[key: string]: unknown;
|
||||
setProperty(name: string, value: string) { this[name] = value; }
|
||||
getPropertyValue(name: string) { return String(this[name] ?? ''); }
|
||||
}
|
||||
|
||||
export class El {
|
||||
tagName: string;
|
||||
parentElement: El | null = null;
|
||||
childNodes: (El | Txt)[] = [];
|
||||
style = new Style();
|
||||
value = '';
|
||||
title = '';
|
||||
type = '';
|
||||
disabled = false;
|
||||
scrollTop = 0;
|
||||
scrollHeight = 0;
|
||||
clientHeight = 0;
|
||||
/** Set by `mount` on `<html>`: where an event goes after the tree, and who knows the focus. */
|
||||
doc: DocLike | null = null;
|
||||
rect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
|
||||
private attributes = new Map<string, string>();
|
||||
private listeners = new Map<string, Listener[]>();
|
||||
|
||||
constructor(tag: string) { this.tagName = tag.toUpperCase(); }
|
||||
|
||||
get nodeName() { return this.tagName; }
|
||||
get children(): El[] { return this.childNodes.filter((node): node is El => node instanceof El); }
|
||||
get firstElementChild(): El | null { return this.children[0] ?? null; }
|
||||
get previousElementSibling(): El | null { const all = this.parentElement?.children ?? []; return all[all.indexOf(this) - 1] ?? null; }
|
||||
get nextElementSibling(): El | null { const all = this.parentElement?.children ?? []; return all[all.indexOf(this) + 1] ?? null; }
|
||||
private get root(): El { let up: El = this; while (up.parentElement) up = up.parentElement; return up; }
|
||||
get isConnected(): boolean { return this.root.tagName === 'HTML'; }
|
||||
|
||||
getAttribute(name: string) { return this.attributes.get(name) ?? null; }
|
||||
hasAttribute(name: string) { return this.attributes.has(name); }
|
||||
setAttribute(name: string, value: string) { this.attributes.set(name, String(value)); }
|
||||
removeAttribute(name: string) { this.attributes.delete(name); }
|
||||
toggleAttribute(name: string, on = !this.attributes.has(name)) { if (on) this.attributes.set(name, ''); else this.attributes.delete(name); return on; }
|
||||
get id() { return this.getAttribute('id') ?? ''; }
|
||||
get className() { return this.getAttribute('class') ?? ''; }
|
||||
set className(value: string) { this.setAttribute('class', value); }
|
||||
get classList() {
|
||||
const names = () => this.className.split(/\s+/).filter(Boolean);
|
||||
return { contains: (name: string) => names().includes(name) };
|
||||
}
|
||||
|
||||
get textContent(): string { return this.childNodes.map(node => (node instanceof El ? node.textContent : node.text)).join(''); }
|
||||
set textContent(value: string) {
|
||||
for (const node of this.childNodes) node.parentElement = null;
|
||||
this.childNodes = [];
|
||||
if (value !== '') this.append(String(value));
|
||||
}
|
||||
|
||||
private adopt(nodes: (El | Txt | string)[]): (El | Txt)[] {
|
||||
return nodes.map(node => {
|
||||
const own = typeof node === 'string' ? new Txt(node) : node;
|
||||
if (own instanceof El) own.remove();
|
||||
return own;
|
||||
});
|
||||
}
|
||||
private insert(at: number, nodes: (El | Txt)[]) {
|
||||
this.childNodes.splice(at, 0, ...nodes);
|
||||
for (const node of nodes) node.parentElement = this;
|
||||
}
|
||||
append(...nodes: (El | Txt | string)[]) { this.insert(this.childNodes.length, this.adopt(nodes)); }
|
||||
appendChild<T extends El>(node: T): T { this.append(node); return node; }
|
||||
insertBefore<T extends El>(node: T, before: El | null): T {
|
||||
const own = this.adopt([node]);
|
||||
this.insert(before ? this.childNodes.indexOf(before) : this.childNodes.length, own);
|
||||
return node;
|
||||
}
|
||||
replaceChildren(...nodes: (El | Txt | string)[]) { this.textContent = ''; this.append(...nodes); }
|
||||
remove() {
|
||||
const parent = this.parentElement;
|
||||
if (!parent) return;
|
||||
parent.childNodes.splice(parent.childNodes.indexOf(this), 1);
|
||||
this.parentElement = null;
|
||||
}
|
||||
contains(other: El | null): boolean { for (let up = other; up; up = up.parentElement) if (up === this) return true; return false; }
|
||||
|
||||
matches(selector: string): boolean { return parse(selector).some(steps => matchesSteps(this, steps)); }
|
||||
closest(selector: string): El | null {
|
||||
for (let up: El | null = this; up; up = up.parentElement) if (up.matches(selector)) return up;
|
||||
return null;
|
||||
}
|
||||
querySelectorAll(selector: string): El[] {
|
||||
const out: El[] = [];
|
||||
const walk = (el: El) => { for (const child of el.children) { if (child.matches(selector)) out.push(child); walk(child); } };
|
||||
walk(this);
|
||||
return out;
|
||||
}
|
||||
querySelector(selector: string): El | null { return this.querySelectorAll(selector)[0] ?? null; }
|
||||
|
||||
addEventListener(type: string, listener: Listener) { (this.listeners.get(type) ?? this.listeners.set(type, []).get(type)!).push(listener); }
|
||||
removeEventListener(type: string, listener: Listener) { this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener)); }
|
||||
/** Runs the listeners of this element and, for an event that bubbles, of its ancestors and the document. */
|
||||
dispatchEvent(event: Ev): boolean {
|
||||
event.target ??= this;
|
||||
const root = this.root;
|
||||
for (let at: El | null = this; at && !event.stopped; at = event.bubbles === false ? null : at.parentElement) {
|
||||
event.currentTarget = at;
|
||||
for (const listener of [...(at.listeners.get(event.type) ?? [])]) listener(event);
|
||||
}
|
||||
if (!event.stopped && event.bubbles !== false) root.doc?.emit(event.type, event);
|
||||
return !event.defaultPrevented;
|
||||
}
|
||||
click() { this.dispatchEvent(new Ev('click', { bubbles: true })); }
|
||||
focus() { const doc = this.root.doc; if (doc) doc.activeElement = this; }
|
||||
select() {}
|
||||
scrollIntoView() {}
|
||||
scrollBy(options: { top: number }) { this.scrollTop = Math.max(0, this.scrollTop + options.top); }
|
||||
getBoundingClientRect() { return this.rect; }
|
||||
}
|
||||
|
||||
type Child = El | string | null | false | undefined;
|
||||
|
||||
/** `h('div', { 'data-testid': 'dm-container' }, h('span', null, 'Name'))`. */
|
||||
export function h(tag: string, attrs?: Record<string, string> | null, ...children: Child[]): El {
|
||||
const el = new El(tag);
|
||||
for (const [name, value] of Object.entries(attrs ?? {})) el.setAttribute(name, value);
|
||||
el.append(...children.filter((child): child is El | string => !!child));
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Shorthand for X's test ids: `t('dm-inbox-panel', ...)` is `<div data-testid="dm-inbox-panel">`. */
|
||||
export const t = (testid: string, ...children: Child[]) => h('div', { 'data-testid': testid }, ...children);
|
||||
|
||||
/**
|
||||
* Puts `<html><body>…children</body></html>` behind an `areaPage` document: its queries and the
|
||||
* elements it creates are this DOM's (a `<style>` stays the page's own, so `env.live()` still
|
||||
* lists the stylesheets), and an event that bubbles out of the tree reaches the document's
|
||||
* listeners. Returns the body.
|
||||
*/
|
||||
export function mount(doc: Record<string, unknown>, ...children: Child[]): El {
|
||||
const body = h('body', null, ...children);
|
||||
const html = h('html', { lang: 'en' }, h('head'), body);
|
||||
html.doc = doc as unknown as DocLike;
|
||||
const createStyle = (doc.createElement as (tag: string) => unknown).bind(doc);
|
||||
Object.assign(doc, {
|
||||
body,
|
||||
querySelectorAll: (selector: string) => html.querySelectorAll(selector),
|
||||
querySelector: (selector: string) => html.querySelector(selector),
|
||||
createElement: (tag: string) => (tag === 'style' ? createStyle(tag) : new El(tag)),
|
||||
createElementNS: (_: string, tag: string) => new El(tag),
|
||||
});
|
||||
return body;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue