clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / test / page.ts
page.ts 11.7K
1// A built page with the browser's own site/assets/ui.ts run on it, in Node:2// what a reader with JS sees on an owner page or /topics/. The DOM is3// htmlparser2's tree with the little ui.ts uses: attributes, classes,4// hidden, text, moving children, and selectors made of tags, classes and5// attributes, descendants and lists.6//7// node test/page.ts shelves <page.html> [#fragment] [click <selector> | go <#fragment>]...8// node test/page.ts activity <page.html> [#fragment] [click <selector> | go <#fragment>]...9//10// After loading the page at the fragment, then clicking each selector or11// going to each fragment on the page (hashchange) in turn, shelves prints12// the shelf panels that show, a line each: `<head strip>: <repo>13// <repo>...`, the repos as their rows show them (a panel without a head14// strip is named by the picked tab). activity prints the activity grid's15// head strip, its days with something on them (`*` marks the picked one;16// a still grid's days, a repo's insights, are never picked), then the17// shelves under it as above, with each repo's count for a picked day; and18// the address's fragment. fetch() reads files beside the page.19import fs from 'node:fs';20import vm from 'node:vm';21import path from 'node:path';22import { fileURLToPath } from 'node:url';23import { stripTypeScriptTypes } from 'node:module';24import { parseDocument } from 'htmlparser2';2526interface Raw {27 type: string;28 name?: string;29 data?: string;30 attribs?: Record<string, string>;31 children?: Raw[];32 parent?: Raw | null;33}3435// One compound selector: a tag, classes, attributes ([a] or [a="v"]).36interface Compound {37 tag: string | null;38 classes: string[];39 attrs: [string, string | null][];40}4142const compound = (s: string): Compound => {43 const m = /^([a-z][a-z0-9]*)?((?:\.[\w-]+|\[[\w-]+(?:="[^"]*")?\])*)$/i.exec(s);44 if (!m) throw new Error(`test/page.ts: can't read the selector ${s}`);45 const c: Compound = { tag: m[1]?.toLowerCase() ?? null, classes: [], attrs: [] };46 for (const p of m[2].match(/\.[\w-]+|\[[^\]]+\]/g) ?? []) {47 if (p[0] === '.') c.classes.push(p.slice(1));48 else {49 const a = /^\[([\w-]+)(?:="([^"]*)")?\]$/.exec(p)!;50 c.attrs.push([a[1], a[2] ?? null]);51 }52 }53 return c;54};5556const els = (r: Raw): Raw[] => (r.children ?? []).filter((c) => c.type === 'tag' || c.type === 'script' || c.type === 'style');5758const fits = (r: Raw, c: Compound): boolean => {59 if (r.type !== 'tag' && r.type !== 'script' && r.type !== 'style') return false;60 const at = r.attribs ?? {};61 if (c.tag && r.name !== c.tag) return false;62 const cls = (at.class ?? '').split(/\s+/);63 if (!c.classes.every((k) => cls.includes(k))) return false;64 return c.attrs.every(([k, v]) => k in at && (v == null || at[k] === v));65};6667// A list of descendant chains, matched right to left.68const matches = (r: Raw, selector: string): boolean =>69 selector.split(',').some((one) => {70 const chain = one.trim().split(/\s+/).map(compound);71 if (!fits(r, chain[chain.length - 1])) return false;72 let i = chain.length - 2;73 for (let up = r.parent; up && i >= 0; up = up.parent) if (fits(up, chain[i])) i--;74 return i < 0;75 });7677const all = (root: Raw, selector: string): Raw[] => {78 const out: Raw[] = [];79 const walk = (r: Raw): void => {80 for (const c of els(r)) {81 if (matches(c, selector)) out.push(c);82 walk(c);83 }84 };85 walk(root);86 return out;87};8889const text = (r: Raw): string => (r.type === 'text' ? r.data ?? '' : (r.children ?? []).map(text).join(''));9091const wrapped = new WeakMap<Raw, El>();92const el = (r: Raw): El => {93 let e = wrapped.get(r);94 if (!e) wrapped.set(r, (e = new El(r)));95 return e;96};9798class El {99 raw: Raw;100 scrollLeft = 0;101 constructor(raw: Raw) {102 this.raw = raw;103 }104 get attrs(): Record<string, string> {105 return (this.raw.attribs ??= {});106 }107 get children(): El[] {108 return els(this.raw).map(el);109 }110 getAttribute(k: string): string | null {111 return this.attrs[k] ?? null;112 }113 setAttribute(k: string, v: string): void {114 this.attrs[k] = String(v);115 }116 removeAttribute(k: string): void {117 delete this.attrs[k];118 }119 hasAttribute(k: string): boolean {120 return k in this.attrs;121 }122 get hidden(): boolean {123 return this.hasAttribute('hidden');124 }125 set hidden(on: boolean) {126 if (on) this.setAttribute('hidden', '');127 else this.removeAttribute('hidden');128 }129 get title(): string {130 return this.attrs.title ?? '';131 }132 set title(v: string) {133 this.attrs.title = v;134 }135 get dateTime(): string {136 return this.attrs.datetime ?? '';137 }138 get textContent(): string {139 return text(this.raw);140 }141 set innerHTML(v: string) {142 const frag = parseDocument(v) as unknown as Raw;143 this.raw.children = frag.children ?? [];144 for (const c of this.raw.children) c.parent = this.raw;145 }146 get parentElement(): El | null {147 return this.raw.parent?.type === 'tag' ? el(this.raw.parent) : null;148 }149 get offsetParent(): El | null {150 return this.closest('[hidden]') ? null : this;151 }152 tabIndex = 0;153 focus(): void {}154 set textContent(v: string) {155 this.raw.children = [{ type: 'text', data: v, parent: this.raw }];156 }157 get dataset(): Record<string, string | undefined> {158 const attrs = this.attrs;159 const name = (k: string): string => 'data-' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());160 return new Proxy({}, { get: (_, k) => attrs[name(String(k))], has: (_, k) => name(String(k)) in attrs });161 }162 get classList() {163 const get = (): string[] => (this.attrs.class ?? '').split(/\s+/).filter(Boolean);164 const set = (l: string[]): void => void (this.attrs.class = l.join(' '));165 return {166 contains: (c: string) => get().includes(c),167 add: (c: string) => set([...new Set([...get(), c])]),168 remove: (c: string) => set(get().filter((x) => x !== c)),169 toggle: (c: string, on?: boolean) => {170 const now = on ?? !get().includes(c);171 set(now ? [...new Set([...get(), c])] : get().filter((x) => x !== c));172 return now;173 },174 };175 }176 append(...kids: El[]): void {177 for (const k of kids) {178 const from = k.raw.parent;179 if (from?.children) from.children = from.children.filter((c) => c !== k.raw);180 k.raw.parent = this.raw;181 (this.raw.children ??= []).push(k.raw);182 }183 }184 matches(s: string): boolean {185 return matches(this.raw, s);186 }187 closest(s: string): El | null {188 for (let r: Raw | null | undefined = this.raw; r; r = r.parent) if (r.type === 'tag' && matches(r, s)) return el(r);189 return null;190 }191 querySelectorAll(s: string): El[] {192 return all(this.raw, s).map(el);193 }194 querySelector(s: string): El | null {195 return this.querySelectorAll(s)[0] ?? null;196 }197 getBoundingClientRect() {198 return { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 };199 }200 getClientRects(): unknown[] {201 return [];202 }203 addEventListener(): void {}204}205206// The page as ui.ts sees it, at a fragment; click() as a reader would.207function load(file: string, hash: string) {208 const location = { hash, pathname: '/', search: '' };209 const root = parseDocument(fs.readFileSync(file, 'utf8')) as unknown as Raw;210 const handlers: Record<string, ((e: object) => void)[]> = {};211 const html = all(root, 'html')[0] ?? root;212 const doc = Object.assign(el(html), {213 body: el(all(root, 'body')[0] ?? html),214 addEventListener: (type: string, f: (e: object) => void) => (handlers[type] ??= []).push(f),215 dispatchEvent: (e: { type: string }) => {216 for (const f of handlers[e.type] ?? []) f(e);217 return true;218 },219 });220 const noop = (): void => {};221 const page = vm.createContext({222 document: doc,223 window: { addEventListener: (type: string, f: (e: object) => void) => (handlers['window ' + type] ??= []).push(f) },224 location,225 history: { replaceState: (_: unknown, __: string, url: string) => void (location.hash = url.startsWith('#') ? url : '') },226 fetch: (url: string) => {227 const f = path.join(path.dirname(file), url);228 return Promise.resolve(fs.existsSync(f) ? { ok: true, json: async () => JSON.parse(fs.readFileSync(f, 'utf8')) } : { ok: false, json: async () => null });229 },230 localStorage: { getItem: () => null, setItem: noop },231 matchMedia: () => ({ matches: false }),232 setInterval: noop,233 setTimeout: noop,234 navigator: {},235 CSS: { escape: (s: string) => s.replace(/["\\]/g, '\\$&') },236 Event: class {237 type: string;238 constructor(type: string) {239 this.type = type;240 }241 },242 KeyboardEvent: class {},243 });244 const here = path.dirname(fileURLToPath(import.meta.url));245 vm.runInContext(stripTypeScriptTypes(fs.readFileSync(path.join(here, '../site/assets/ui.ts'), 'utf8')), page);246 const click = (selector: string): void => {247 const target = doc.querySelector(selector);248 if (!target) throw new Error(`test/page.ts: nothing on the page matches ${selector}`);249 for (const f of handlers.click ?? []) f({ type: 'click', target, preventDefault: noop });250 };251 const go = (to: string): void => {252 location.hash = to;253 for (const f of handlers['window hashchange'] ?? []) f({ type: 'hashchange' });254 };255 return { doc, click, go, location };256}257258const shown = (e: El): boolean => !e.closest('[hidden]');259const words = (s: string): string => s.replace(/\s+/g, ' ').trim();260261const [mode, file, ...rest] = process.argv.slice(2);262if ((mode !== 'shelves' && mode !== 'activity') || !file) {263 console.error('usage: node test/page.ts shelves|activity <page.html> [#fragment] [click <selector> | go <#fragment>]...');264 process.exit(2);265}266const hash = rest[0]?.startsWith('#') ? rest.shift()! : '';267const { doc, click, go, location } = load(file, hash);268// What the page's fetches bring, before and after each step.269const settle = async (): Promise<void> => {270 for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r));271};272await settle();273for (let i = 0; i < rest.length; i += 2) {274 if (rest[i] === 'click' && rest[i + 1]) click(rest[i + 1]);275 else if (rest[i] === 'go' && rest[i + 1]) go(rest[i + 1]);276 else throw new Error(`test/page.ts: expected click <selector> or go <#fragment>, got ${rest.slice(i).join(' ')}`);277 await settle();278}279const headOf = (h: El): string => h.children.filter(shown).map((c) => words(c.textContent)).filter(Boolean).join(' ');280if (mode === 'activity') {281 const g = doc.querySelector('[data-activity]');282 if (!g) throw new Error('test/page.ts: no activity grid on the page');283 console.log(headOf(g.closest('.panel')!.querySelector('.panel-head')!));284 for (const c of g.querySelectorAll('[data-day]')) {285 if (c.dataset.n !== '0' || c.getAttribute('aria-pressed') === 'true') console.log(`${c.getAttribute('aria-pressed') === 'true' ? '*' : ' '} ${c.getAttribute('aria-label')} l${(c.getAttribute('class') ?? '').match(/l(\d)/)?.[1]}`);286 }287}288for (const sec of doc.querySelectorAll('section.shelf').filter(shown)) {289 const names = sec.querySelectorAll('.card').filter(shown).map((c) => {290 const day = c.querySelector('.card-day');291 return words(c.querySelector('.card-name')!.textContent).replace(' / ', '/') + (day && shown(day) ? ` (${words(day.textContent)})` : '');292 });293 const head = sec.querySelectorAll('.panel-head').filter(shown)[0];294 const tab = doc.querySelector('[data-shelves] a.cur');295 const n = tab?.querySelector('.n');296 const name = head ? headOf(head) : tab ? words(`${tab.textContent.slice(0, tab.textContent.length - (n?.textContent.length ?? 0))} ${n?.textContent ?? ''}`) : 'all';297 const none = sec.querySelector('.day-empty');298 console.log(`${name}: ${names.join(' ')}${none && shown(none) ? words(none.textContent) : ''}`);299}300if (doc.querySelector('.shelves-empty') && shown(doc.querySelector('.shelves-empty')!)) console.log('empty: ' + words(doc.querySelector('.shelves-empty')!.textContent));301if (mode === 'activity') console.log(`address: ${location.hash}`);