wayBetter for X
This commit is contained in:
commit
434f193217
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;
|
||||
}
|
||||
351
apps/desktop/tests/fixtures/x/chat/x.ts
vendored
Normal file
351
apps/desktop/tests/fixtures/x/chat/x.ts
vendored
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
// X's chat for tests/area-chat.test.ts, built from the live survey of /i/chat (structure only): the
|
||||
// test ids, the virtualized message list (a window of rows in the DOM out of a longer loaded
|
||||
// history), the record X's page keeps per row (obfuscated field names, a sequence object with a
|
||||
// bigint `num` whose class has `fromString`, a sender with `userId`), X's two ways to position on a
|
||||
// message (the `scrollToMessage` window event; router state on opening a conversation) and its
|
||||
// message menu (hover, "More", Reply). `FakeChat` plays X's part. Every name and every line of text
|
||||
// is invented.
|
||||
|
||||
import { El, Ev, h, mount, t } from './miniDom.ts';
|
||||
|
||||
const EPOCH = 1288834974657n;
|
||||
/** A sequence number for a message sent at `ms` (a snowflake: time in the high bits). */
|
||||
export const seqAt = (ms: number, n = 0) => (((BigInt(ms) - EPOCH) << 22n) + BigInt(n)).toString();
|
||||
|
||||
/** X's sequence class: text is not accepted where an instance is expected. */
|
||||
export class Seq {
|
||||
num: bigint;
|
||||
constructor(num: bigint) { this.num = num; }
|
||||
static fromString(text: string) { return new Seq(BigInt(text)); }
|
||||
}
|
||||
|
||||
export type Person = { id: string; name: string; handle?: string };
|
||||
export type Msg = { uuid: string; seq: string; from: Person; text: string; own?: boolean; clock?: string; replyTo?: Msg; media?: 'video' | 'image' };
|
||||
export type Conversation = { id: string; title: string; history: Msg[] };
|
||||
|
||||
export const ADA: Person = { id: '1001', name: 'Ada Quill', handle: 'adaquill' };
|
||||
export const TOMAS: Person = { id: '1002', name: 'Tomas Reed', handle: 'tomasreed' };
|
||||
export const MIRA: Person = { id: '1003', name: 'Mira Holt' };
|
||||
export const ME: Person = { id: '1000', name: 'Sam Birch', handle: 'sambirch' };
|
||||
|
||||
const LINES = [
|
||||
'Lunch at noon?', 'The ferry leaves at six', 'I found the blue notebook', 'Bring the spare cable', 'Rain again tomorrow', 'The demo went fine',
|
||||
'Call me after the match', 'Two tickets left', 'The bakery closes early', 'Send the map later', 'We moved the desk', 'Coffee is on me',
|
||||
];
|
||||
const uuid = (n: number) => `${String(n).padStart(8, '0')}-aaaa-4bbb-8ccc-${String(n).padStart(12, '0')}`;
|
||||
|
||||
/** `count` messages, one a minute from `start`, by `people` in turn. */
|
||||
export function history(count: number, people: Person[], start = Date.UTC(2026, 5, 1, 9, 0), offset = 0): Msg[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const from = people[index % people.length]!;
|
||||
const n = offset + index;
|
||||
return { uuid: uuid(n), seq: seqAt(start + index * 60000, n % 4096), from, own: from === ME, text: `${LINES[index % LINES.length]} (${n})`, clock: `09:${String(index % 60).padStart(2, '0')}` };
|
||||
});
|
||||
}
|
||||
|
||||
/** The record X's page keeps for a message, with build-specific field names. */
|
||||
export function record(msg: Msg, readableUser = true): Record<string, unknown> {
|
||||
return {
|
||||
aaa_1: msg.uuid,
|
||||
cfe_1: new Seq(BigInt(msg.seq)),
|
||||
pfd_1: { userId: msg.from.id, user: readableUser ? { displayName: msg.from.name, screenName: msg.from.handle } : { zz_1: msg.from.name } },
|
||||
bbb_1: { ccc_1: msg.text, ddd_1: 3 },
|
||||
...(msg.replyTo ? { replyPreview: { id: msg.replyTo.uuid, sequenceNumber: new Seq(BigInt(msg.replyTo.seq)), senderId: msg.replyTo.from.id, senderIsCurrentUser: !!msg.replyTo.own, senderDisplayName: msg.replyTo.from.name, previewText: msg.replyTo.text } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type Env = {
|
||||
doc: Record<string, unknown> & { emit(type: string, event?: unknown): void };
|
||||
win: Record<string, unknown> & { emit(type: string, event?: unknown): void; addEventListener(type: string, fn: (event: unknown) => void): void };
|
||||
mutate(): void;
|
||||
};
|
||||
|
||||
export type ChatOptions = {
|
||||
/** Rows X keeps in the DOM at once (default 8). */
|
||||
rows?: number;
|
||||
/** X's page exposes its loaded list in a prop above the rows (default true). */
|
||||
exposeList?: boolean;
|
||||
/** The sender's name and handle are readable in X's record (default true). */
|
||||
readableUser?: boolean;
|
||||
/** The menu's items carry icons (default true). */
|
||||
icons?: boolean;
|
||||
/** "More" opens on press only, not on a bare click (default false). */
|
||||
menuOnPress?: boolean;
|
||||
/** ms X needs to render after a jump (default 500). */
|
||||
delay?: number;
|
||||
/**
|
||||
* X's chat inside the open shadow root of its embed host (XChatEmbed, seen live 2026-10-01): the
|
||||
* page's own queries see only the host; everything else is in `host.shadowRoot` (default false).
|
||||
*/
|
||||
embed?: boolean;
|
||||
};
|
||||
|
||||
/** A clock for the page: `tick(ms)` runs the timers that come due, letting promises settle in between. */
|
||||
export function fakeClock(win: Record<string, unknown>) {
|
||||
let now = 0;
|
||||
let next = 1;
|
||||
const pending = new Map<number, { at: number; fn: () => void }>();
|
||||
win.setTimeout = (fn: () => void, ms = 0) => { const id = next++; pending.set(id, { at: now + ms, fn }); return id; };
|
||||
win.clearTimeout = (id: number) => { pending.delete(id); };
|
||||
const settle = () => new Promise<void>(resolve => setImmediate(resolve));
|
||||
return {
|
||||
pending: () => pending.size,
|
||||
async tick(ms: number) {
|
||||
const end = now + ms;
|
||||
for (;;) {
|
||||
await settle();
|
||||
const due = [...pending].filter(([, timer]) => timer.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!due) break;
|
||||
pending.delete(due[0]);
|
||||
now = due[1].at;
|
||||
due[1].fn();
|
||||
}
|
||||
now = end;
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class FakeChat {
|
||||
env: Env;
|
||||
options: Required<ChatOptions>;
|
||||
conversations = new Map<string, Conversation>();
|
||||
clock: ReturnType<typeof fakeClock>;
|
||||
body: El;
|
||||
inboxList: El;
|
||||
panelSlot: El;
|
||||
/** The open conversation and the window of its history that is in the DOM. */
|
||||
open: Conversation | null = null;
|
||||
first = 0;
|
||||
/** Index of the oldest message X has loaded (its local list). */
|
||||
loadedFrom = 0;
|
||||
/** What X's own UI did, to check what Chat+ caused. */
|
||||
replyingTo: string | null = null;
|
||||
sent = 0;
|
||||
deleted: string[] = [];
|
||||
forwarded: string[] = [];
|
||||
reactions = 0;
|
||||
jumps: string[] = [];
|
||||
opened: { id: string; at: string | null }[] = [];
|
||||
linkClicks: string[] = [];
|
||||
|
||||
constructor(env: Env, conversations: Conversation[], options: ChatOptions = {}) {
|
||||
this.env = env;
|
||||
this.options = { rows: 8, exposeList: true, readableUser: true, icons: true, menuOnPress: false, delay: 500, embed: false, ...options };
|
||||
for (const conversation of conversations) this.conversations.set(conversation.id, conversation);
|
||||
const { win } = env;
|
||||
this.clock = fakeClock(win);
|
||||
const location = win.location as { pathname: string; href: string };
|
||||
Object.assign(win, {
|
||||
innerWidth: 1280,
|
||||
CustomEvent: Ev, PopStateEvent: Ev, MouseEvent: Ev, PointerEvent: Ev, KeyboardEvent: Ev, WheelEvent: Ev,
|
||||
dispatchEvent: (event: Ev) => { win.emit(event.type, event); return true; },
|
||||
history: {
|
||||
state: null as unknown,
|
||||
pushState(state: unknown, _title: string, url: string) { this.state = state; location.pathname = url; location.href = `https://x.com${url}`; },
|
||||
},
|
||||
});
|
||||
this.inboxList = h('ul');
|
||||
this.panelSlot = h('div');
|
||||
const tree = t('dm-container',
|
||||
t('dm-inbox-panel',
|
||||
t('dm-inbox-header', t('dm-inbox-title', 'Chat'), h('button', { 'data-testid': 'dm-new-chat-button' })),
|
||||
h('div', null, h('div', { 'data-testid': 'dm-conversation-scroller', role: 'listbox' }, this.inboxList))),
|
||||
this.panelSlot);
|
||||
if (this.options.embed) {
|
||||
// The shadow root is not a child of its host: the page's querySelectorAll never reaches it.
|
||||
const shadow = h('div', { 'data-fake-shadow-root': '' }, tree);
|
||||
const host = h('div', { 'data-testid': 'xchatEmbedRoute' });
|
||||
(host as unknown as { shadowRoot: El }).shadowRoot = shadow;
|
||||
// Like a real shadow root: it knows its document, and composed events and focus reach the page.
|
||||
Object.assign(shadow, { ownerDocument: env.doc });
|
||||
shadow.doc = env.doc as unknown as El['doc'];
|
||||
mount(env.doc, host);
|
||||
this.body = shadow;
|
||||
} else this.body = mount(env.doc, tree);
|
||||
this.renderInbox();
|
||||
// X's router: a popstate reads the address and the state that came with it.
|
||||
win.addEventListener('popstate', () => {
|
||||
const match = /^\/i\/chat\/([^/]+)$/.exec(location.pathname);
|
||||
const state = (win.history as { state: { state?: { scrollToMessage?: string } } | null }).state;
|
||||
if (match) this.show(match[1]!, state?.state?.scrollToMessage ?? null);
|
||||
else this.close();
|
||||
});
|
||||
// X's message search: positions the open conversation on a sequence object of its own class.
|
||||
win.addEventListener('scrollToMessage', event => {
|
||||
const sequence = (event as { detail?: { sequenceNumber?: unknown } }).detail?.sequenceNumber;
|
||||
if (!this.open || !(sequence instanceof Seq)) return;
|
||||
this.jumps.push(sequence.num.toString());
|
||||
const at = this.open.history.findIndex(msg => msg.seq === sequence.num.toString());
|
||||
if (at >= 0) (win.setTimeout as (fn: () => void, ms: number) => unknown)(() => this.position(at), this.options.delay);
|
||||
});
|
||||
}
|
||||
|
||||
renderInbox() {
|
||||
this.inboxList.replaceChildren(...[...this.conversations.values()].map((conversation, index) => {
|
||||
const link = h('a', { href: `/i/chat/${conversation.id}` }, h('span', { dir: 'auto' }, conversation.title), h('span', { dir: 'auto' }, 'A preview line'));
|
||||
// X's own link: its router takes the address and tells the page.
|
||||
link.addEventListener('click', () => {
|
||||
this.linkClicks.push(conversation.id);
|
||||
(this.env.win.history as { pushState(state: unknown, title: string, url: string): void }).pushState(null, '', `/i/chat/${conversation.id}`);
|
||||
this.env.win.emit('popstate');
|
||||
});
|
||||
return h('li', { 'data-index': String(index) }, h('div', { role: 'option', 'data-testid': `dm-conversation-item-${conversation.id.replace('-', ':')}` }, link));
|
||||
}));
|
||||
}
|
||||
|
||||
/** X opens a conversation (from its list, or through its router), optionally positioned on a message. */
|
||||
show(id: string, at: string | null) {
|
||||
const conversation = this.conversations.get(id);
|
||||
if (!conversation) return;
|
||||
this.opened.push({ id, at });
|
||||
this.open = conversation;
|
||||
this.replyingTo = null;
|
||||
const last = Math.max(0, conversation.history.length - this.options.rows);
|
||||
this.loadedFrom = Math.max(0, conversation.history.length - this.options.rows * 3);
|
||||
const target = at ? conversation.history.findIndex(msg => msg.seq === at) : -1;
|
||||
this.panelSlot.replaceChildren(t('dm-conversation-panel', t('dm-conversation-content',
|
||||
t('dm-conversation-header', h('button', { 'data-testid': 'dm-conversation-back-button' }), t('dm-conversation-username', conversation.title), t('dm-conversation-header-item')),
|
||||
t('dm-message-list-container', h('div', { 'data-testid': 'dm-message-list', 'data-dm-message-list-scope': id },
|
||||
h('div', { 'data-testid': 'dm-message-scroller', 'data-virtualizer': 'tanstack', role: 'log' }))),
|
||||
t('dm-composer-container', h('form', { 'data-testid': 'dm-composer-form' },
|
||||
h('button', { 'data-testid': 'dm-composer-attachment-button' }),
|
||||
t('dm-composer-input-container', h('textarea', { 'data-testid': 'dm-composer-textarea', placeholder: 'Message' })),
|
||||
this.sendButton())))));
|
||||
const header = this.body.querySelector('[data-testid="dm-conversation-header"]')!;
|
||||
header.rect = { left: 400, top: 0, right: 1200, bottom: 56, width: 800, height: 56 };
|
||||
this.scroller.addEventListener('wheel', () => this.wheelUp());
|
||||
this.first = last;
|
||||
this.render();
|
||||
if (target >= 0) this.position(target);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.open = null;
|
||||
this.panelSlot.replaceChildren();
|
||||
this.env.mutate();
|
||||
}
|
||||
|
||||
private sendButton() {
|
||||
// X's Send carries no test id; nothing of Chat+ may ever click it.
|
||||
const button = h('button', { 'aria-label': 'Send' });
|
||||
button.addEventListener('click', () => { this.sent += 1; });
|
||||
return button;
|
||||
}
|
||||
|
||||
get scroller(): El { return this.body.querySelector('[data-testid="dm-message-scroller"]')!; }
|
||||
get composer(): El { return this.body.querySelector('[data-testid="dm-composer-container"]')!; }
|
||||
get textarea(): El { return this.body.querySelector('[data-testid="dm-composer-textarea"]')!; }
|
||||
row(id: string): El | null { return this.body.querySelector(`[data-dm-message-row="${id}"]`); }
|
||||
rendered(): string[] { return this.scroller.querySelectorAll('[data-dm-message-row]').map(row => row.getAttribute('data-dm-message-row')!); }
|
||||
|
||||
/** X positions the list on message `at`: it loads what lies between and renders the rows around it. */
|
||||
position(at: number) {
|
||||
if (!this.open) return;
|
||||
this.loadedFrom = Math.min(this.loadedFrom, Math.max(0, at - this.options.rows));
|
||||
this.first = Math.max(0, Math.min(at - Math.floor(this.options.rows / 2), this.open.history.length - this.options.rows));
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** A wheel step up: X loads a page of older history and shows older rows. */
|
||||
wheelUp() {
|
||||
if (!this.open) return;
|
||||
const before = this.loadedFrom;
|
||||
this.loadedFrom = Math.max(0, this.loadedFrom - this.options.rows);
|
||||
this.first = Math.max(0, this.first - this.options.rows);
|
||||
if (this.loadedFrom < before) this.scroller.scrollHeight += 1000;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** Renders the window: the virtualizer drops every row and makes new ones (nothing survives a scroll). */
|
||||
render() {
|
||||
const conversation = this.open!;
|
||||
const scroller = this.scroller;
|
||||
const group = conversation.id.startsWith('g');
|
||||
const loaded = conversation.history.slice(this.loadedFrom);
|
||||
const listFiber = { memoizedProps: this.options.exposeList ? { count: loaded.length, items: [{ kind: 'date' }, ...loaded.map(msg => ({ key: msg.uuid, messageItem: this.records.get(msg) ?? this.remember(msg) }))] } : { count: loaded.length }, return: null };
|
||||
scroller.scrollHeight ||= 4000;
|
||||
scroller.clientHeight = 800;
|
||||
scroller.replaceChildren(
|
||||
t('dm-message-list-spinner-slot'),
|
||||
h('div', { 'data-index': '0' }, h('span', null, 'Monday')),
|
||||
...conversation.history.slice(this.first, this.first + this.options.rows).map((msg, index) => this.rowOf(msg, index, group, listFiber)),
|
||||
t('dm-message-list-composer-spacer'));
|
||||
this.env.mutate();
|
||||
}
|
||||
private records = new Map<Msg, Record<string, unknown>>();
|
||||
private remember(msg: Msg) { const made = record(msg, this.options.readableUser); this.records.set(msg, made); return made; }
|
||||
|
||||
private rowOf(msg: Msg, index: number, group: boolean, listFiber: unknown): El {
|
||||
const id = `r${this.first + index}`;
|
||||
const row = h('div', { 'data-dm-message-row': msg.uuid, 'data-testid': `message-${msg.uuid}`, 'data-send-status': 'sent', class: msg.own ? 'flex w-full justify-end' : 'flex w-full' },
|
||||
group && !msg.own ? h('div', { 'aria-hidden': 'true' }, h('img', { src: 'https://pbs.twimg.com/profile_images/1/face_normal.jpg' })) : null,
|
||||
h('span', { id: `${id}-sender`, class: 'sr-only' }, msg.own ? 'You' : msg.from.name),
|
||||
h('span', { id: `${id}-time`, class: 'sr-only' }, msg.clock ?? '09:41'),
|
||||
h('div', { role: 'article', tabindex: '-1', 'data-dm-message-focus-target': `${msg.uuid}:content`, 'aria-labelledby': `${id}-sender ${id}-time`, 'aria-describedby': `${id}-content` },
|
||||
msg.replyTo ? h('div', null, h('svg', { 'data-icon': 'icon-reply-alt' }), h('div', null, msg.replyTo.text)) : null,
|
||||
msg.media === 'video' ? h('video', { poster: 'https://ton.x.com/i/ton/data/xchat_media/c1/m1.jpg', src: 'blob:https://x.com/1' }) : null,
|
||||
msg.media === 'image' ? h('img', { src: 'https://ton.x.com/i/ton/data/xchat_media/c1/p1.jpg' }) : null,
|
||||
msg.text ? h('div', { id: `${id}-content`, 'data-testid': `message-text-${msg.uuid}` }, h('span', { dir: 'auto' }, msg.text), h('span', null, msg.clock ?? '09:41')) : null));
|
||||
// React's handle on the row: its own props two components up, X's loaded list further up.
|
||||
(row as unknown as Record<string, unknown>)['__reactFiber$k3y'] = {
|
||||
memoizedProps: { className: 'flex' },
|
||||
return: { memoizedProps: {}, return: { memoizedProps: { messageItem: this.records.get(msg) ?? this.remember(msg), isOneToOne: !group }, return: { memoizedProps: {}, return: listFiber } } },
|
||||
};
|
||||
// X shows a row's buttons while the pointer is over it.
|
||||
row.addEventListener('mouseover', () => this.hover(msg, row));
|
||||
return h('div', { 'data-index': String(index + 1) }, h('div', null, h('div', null, row)));
|
||||
}
|
||||
|
||||
private hover(msg: Msg, row: El) {
|
||||
if (row.querySelector(`[data-testid="message-overflow-button-${msg.uuid}"]`)) return;
|
||||
const react = h('button', { 'data-testid': `message-reaction-button-${msg.uuid}`, 'aria-label': 'React' });
|
||||
react.addEventListener('click', () => { this.reactions += 1; });
|
||||
const more = h('button', { 'data-testid': `message-overflow-button-${msg.uuid}`, 'aria-label': 'More' });
|
||||
let pressed = false;
|
||||
more.addEventListener('pointerdown', () => { pressed = true; });
|
||||
more.addEventListener('mousedown', () => { if (this.options.menuOnPress && pressed) this.menu(msg); });
|
||||
more.addEventListener('click', () => { if (!this.options.menuOnPress) this.menu(msg); pressed = false; });
|
||||
row.append(h('div', { class: 'flex items-center' }, react, more));
|
||||
this.env.mutate();
|
||||
}
|
||||
|
||||
private menu(msg: Msg) {
|
||||
const item = (label: string, iconName: string, run: () => void) => {
|
||||
const node = h('div', { role: 'menuitem', 'data-slot': 'xds-menu-item' }, this.options.icons ? h('svg', { 'data-icon': iconName }) : null, label);
|
||||
node.addEventListener('click', () => { menu.remove(); run(); this.env.mutate(); });
|
||||
return node;
|
||||
};
|
||||
const menu = h('div', { role: 'menu', 'data-slot': 'xds-menu-content' },
|
||||
item('Reply', 'icon-reply', () => this.reply(msg)),
|
||||
item('Forward', 'icon-forward', () => { this.forwarded.push(msg.uuid); }),
|
||||
item('Copy message text', 'icon-copy', () => {}),
|
||||
item('Info', 'icon-info', () => {}),
|
||||
item('Delete for me', 'icon-trash', () => { this.deleted.push(msg.uuid); }));
|
||||
menu.addEventListener('keydown', event => { if (event.key === 'Escape') menu.remove(); });
|
||||
this.body.append(menu);
|
||||
this.env.mutate();
|
||||
}
|
||||
|
||||
private reply(msg: Msg) {
|
||||
this.replyingTo = msg.uuid;
|
||||
const cancel = h('button', { 'aria-label': 'Cancel reply' });
|
||||
const bar = h('div', null, h('span', null, msg.from.name), h('span', null, msg.text), cancel);
|
||||
cancel.addEventListener('click', () => { this.replyingTo = null; bar.remove(); });
|
||||
this.composer.insertBefore(bar, this.composer.firstElementChild);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whatever Chat+ added to the page: its nodes and its attributes on X's elements. */
|
||||
export function traces(body: El): string[] {
|
||||
const out: string[] = [];
|
||||
const walk = (el: El) => {
|
||||
for (const child of el.children) {
|
||||
if (/(^|\s)bt-chat-/.test(child.className)) out.push(`.${child.className}`);
|
||||
for (const name of ['data-bt-chat-starred', 'data-bt-chat-dim', 'data-bt-chat-kw', 'data-bt-chat-kw-label', 'data-bt-chat-flash', 'data-bt-chat-button']) if (child.hasAttribute(name)) out.push(`[${name}]`);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(body);
|
||||
return out;
|
||||
}
|
||||
61
apps/desktop/tests/fixtures/x/media/user-video-timeline.json
vendored
Normal file
61
apps/desktop/tests/fixtures/x/media/user-video-timeline.json
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"data": {
|
||||
"user": {
|
||||
"result": {
|
||||
"__typename": "User",
|
||||
"timeline": {
|
||||
"timeline": {
|
||||
"instructions": [
|
||||
{ "type": "TimelineClearCache" },
|
||||
{
|
||||
"type": "TimelineAddEntries",
|
||||
"entries": [
|
||||
{
|
||||
"entryId": "tweet-1000000000000000001",
|
||||
"content": {
|
||||
"entryType": "TimelineTimelineItem",
|
||||
"__typename": "TimelineTimelineItem",
|
||||
"itemContent": {
|
||||
"itemType": "TimelineTweet",
|
||||
"__typename": "TimelineTweet",
|
||||
"tweet_results": {
|
||||
"result": {
|
||||
"__typename": "Tweet",
|
||||
"rest_id": "1000000000000000001",
|
||||
"core": { "user_results": { "result": { "__typename": "User", "core": { "screen_name": "someone" } } } },
|
||||
"legacy": {
|
||||
"id_str": "1000000000000000001",
|
||||
"full_text": "An invented post with a video",
|
||||
"extended_entities": {
|
||||
"media": [
|
||||
{
|
||||
"id_str": "1000000000000000002",
|
||||
"media_key": "7_1000000000000000002",
|
||||
"type": "video",
|
||||
"media_url_https": "https://pbs.twimg.com/amplify_video_thumb/1000000000000000002/img/invented.jpg",
|
||||
"video_info": {
|
||||
"variants": [
|
||||
{ "content_type": "application/x-mpegURL", "url": "https://video.twimg.com/amplify_video/1000000000000000002/pl/invented.m3u8" },
|
||||
{ "bitrate": 832000, "content_type": "video/mp4", "url": "https://video.twimg.com/amplify_video/1000000000000000002/vid/avc1/640x360/low.mp4" },
|
||||
{ "bitrate": 2176000, "content_type": "video/mp4", "url": "https://video.twimg.com/amplify_video/1000000000000000002/vid/avc1/1280x720/high.mp4" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
224
apps/desktop/tests/fixtures/x/profile/miniDom.ts
vendored
Normal file
224
apps/desktop/tests/fixtures/x/profile/miniDom.ts
vendored
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// A small DOM for tests/area-profile.test.ts: enough of Element (attributes, children, text,
|
||||
// `closest`, `querySelectorAll` over the selectors Profile+ uses, listeners, `cloneNode`) to run the
|
||||
// Profile+ modules and the old User Notes plugin over X-shaped markup without a browser, plus the
|
||||
// pieces of X a profile feature reads (./x.ts builds them). Not a DOM library: it implements only
|
||||
// what these tests use, and a selector it does not understand throws.
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
type Listener = (event: Record<string, unknown>) => void;
|
||||
const kebab = (key: string) => `data-${key.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)}`;
|
||||
|
||||
export class El {
|
||||
tagName: string;
|
||||
parentElement: El | null = null;
|
||||
childNodes: (El | Txt)[] = [];
|
||||
tabIndex = -1;
|
||||
value = '';
|
||||
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; }
|
||||
get isConnected(): boolean { for (let up: El | null = this; up; up = up.parentElement) if (up.tagName === 'HTML') return true; return false; }
|
||||
|
||||
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); }
|
||||
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),
|
||||
add: (name: string) => { if (!names().includes(name)) this.className = [...names(), name].join(' '); },
|
||||
remove: (name: string) => { this.className = names().filter(item => item !== name).join(' '); },
|
||||
};
|
||||
}
|
||||
get dataset(): Record<string, string> {
|
||||
return new Proxy({}, {
|
||||
get: (_, key) => this.getAttribute(kebab(String(key))) ?? undefined,
|
||||
set: (_, key, value) => { this.setAttribute(kebab(String(key)), String(value)); return true; },
|
||||
}) as Record<string, string>;
|
||||
}
|
||||
|
||||
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; }
|
||||
after(...nodes: (El | Txt | string)[]) { const own = this.adopt(nodes); this.parentElement!.insert(this.parentElement!.childNodes.indexOf(this) + 1, own); }
|
||||
before(...nodes: (El | Txt | string)[]) { const own = this.adopt(nodes); this.parentElement!.insert(this.parentElement!.childNodes.indexOf(this), own); }
|
||||
remove() {
|
||||
const parent = this.parentElement;
|
||||
if (!parent) return;
|
||||
parent.childNodes.splice(parent.childNodes.indexOf(this), 1);
|
||||
this.parentElement = null;
|
||||
}
|
||||
cloneNode(deep = false): El {
|
||||
const copy = new El(this.tagName);
|
||||
for (const [name, value] of this.attributes) copy.setAttribute(name, value);
|
||||
if (deep) copy.append(...this.childNodes.map(node => (node instanceof El ? node.cloneNode(true) : node.text)));
|
||||
return copy;
|
||||
}
|
||||
|
||||
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)); }
|
||||
/** Calls this element's own listeners (no bubbling). */
|
||||
emit(type: string, event: Record<string, unknown> = {}) {
|
||||
for (const listener of [...(this.listeners.get(type) ?? [])]) listener({ type, target: this, preventDefault() {}, stopPropagation() {}, ...event });
|
||||
}
|
||||
click() { this.emit('click'); }
|
||||
|
||||
/** The markup, for comparing two trees. */
|
||||
get outerHTML(): string {
|
||||
const attrs = [...this.attributes].map(([name, value]) => ` ${name}="${value.replace(/"/g, '"')}"`).join('');
|
||||
const tag = this.tagName.toLowerCase();
|
||||
return `<${tag}${attrs}>${this.childNodes.map(node => (node instanceof El ? node.outerHTML : node.text)).join('')}</${tag}>`;
|
||||
}
|
||||
}
|
||||
|
||||
type Child = El | string | null | false | undefined;
|
||||
|
||||
/** `h('div', { 'data-testid': 'UserName' }, 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('UserName', ...)` is `<div data-testid="UserName">`. */
|
||||
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). 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);
|
||||
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)),
|
||||
});
|
||||
return body;
|
||||
}
|
||||
73
apps/desktop/tests/fixtures/x/profile/x.ts
vendored
Normal file
73
apps/desktop/tests/fixtures/x/profile/x.ts
vendored
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// The parts of X a Profile+ feature reads, shaped like X's own markup (the same nesting and test
|
||||
// ids as scripts/visual-bugs/x-fixture.mjs; class names left out): a profile header, a post in a
|
||||
// timeline, an opened post, a quoted post, a hover card and a profile's ··· menu. Invented
|
||||
// accounts only. Not a live X test.
|
||||
|
||||
import { h, t, type El } from './miniDom.ts';
|
||||
|
||||
export type Account = { name: string; handle: string; bio?: string; joined?: string };
|
||||
|
||||
const words = (value: string) => h('div', { dir: 'ltr' }, h('span', null, value));
|
||||
const nameText = (name: string) => h('div', { dir: 'ltr' }, h('span', null, h('span', null, name)));
|
||||
|
||||
/** `<div data-testid="primaryColumn">` with a profile header: name row, handle row, bio, "Joined …", the ··· button. */
|
||||
export function profile({ name, handle, bio = 'Invented bio.', joined = 'Joined June 2015' }: Account): El {
|
||||
return t('primaryColumn',
|
||||
h('div', null,
|
||||
h('button', { 'data-testid': 'userActions', 'aria-label': 'More' }),
|
||||
h('div', null,
|
||||
t('UserName', h('div', null, h('div', null,
|
||||
h('div', { class: 'name-row' }, nameText(name)),
|
||||
h('div', { class: 'handle-row' }, h('div', null, words(`@${handle}`)), t('userFollowIndicator', h('span', null, 'Follows you'))),
|
||||
))),
|
||||
bio ? t('UserDescription', h('span', null, bio)) : null,
|
||||
t('UserProfileHeader_Items', joined ? h('span', { 'data-testid': 'UserJoinDate' }, h('span', null, joined)) : null),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A post's name block in a timeline: the name and the "@handle · time" in one row. */
|
||||
export function userName({ name, handle }: Account): El {
|
||||
return t('User-Name',
|
||||
h('div', { class: 'name-row' }, h('div', null, h('a', { href: `/${handle}`, role: 'link' }, h('div', null, nameText(name))))),
|
||||
h('div', { class: 'handle-row' }, h('div', null,
|
||||
h('div', null, h('a', { href: `/${handle}`, role: 'link', tabindex: '-1' }, words(`@${handle}`))),
|
||||
h('div', { dir: 'ltr', 'aria-hidden': 'true' }, h('span', null, '·')),
|
||||
h('div', null, h('a', { href: `/${handle}/status/1` }, h('time', { datetime: '2026-09-28T10:00:00.000Z' }, '2h'))),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
export function post(account: Account, body = 'Invented post.', quoted?: Account): El {
|
||||
return t('cellInnerDiv', h('article', { 'data-testid': 'tweet', role: 'article' },
|
||||
userName(account),
|
||||
t('tweetText', h('span', null, body)),
|
||||
quoted ? h('div', { role: 'link', tabindex: '0' },
|
||||
// A quoted post names its author without links.
|
||||
t('User-Name', h('div', { class: 'name-row' }, nameText(quoted.name)), h('div', { class: 'handle-row' }, h('div', null, h('div', null, words(`@${quoted.handle}`))))),
|
||||
) : null,
|
||||
));
|
||||
}
|
||||
|
||||
/** X's hover card as it is in `#layers`: avatar, name link, handle link, bio, counts. */
|
||||
export function hoverCard({ name, handle, bio = 'Invented bio.' }: Account): El {
|
||||
return t('hoverCardParent', t('HoverCard',
|
||||
t(`UserAvatar-Container-${handle}`),
|
||||
h('div', null,
|
||||
h('div', { class: 'name-row' }, h('a', { href: `/${handle}`, role: 'link' }, h('div', null, nameText(name)))),
|
||||
h('div', { class: 'handle-row' }, h('a', { href: `/${handle}`, role: 'link', tabindex: '-1' }, words(`@${handle}`))),
|
||||
),
|
||||
bio ? t('UserDescription', h('span', null, bio)) : null,
|
||||
h('div', null, h('a', { href: `/${handle}/following` }, h('span', null, '30'), h('span', null, ' Following'))),
|
||||
));
|
||||
}
|
||||
|
||||
/** The menu X opens for a profile's ··· button. */
|
||||
export function profileMenu(handle: string): El[] {
|
||||
return [
|
||||
t('mask'),
|
||||
h('div', { role: 'menu' }, h('div', { role: 'menuitem', 'data-testid': 'block', tabindex: '0' },
|
||||
h('svg', null, h('path', { d: 'M0 0' }), h('path', { d: 'M1 1' })), h('div', null, h('span', null, `Block @${handle}`)))),
|
||||
];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue