waybetter-for-x/apps/desktop/tests/make.test.ts
2026-10-01 06:01:13 +07:00

205 lines
12 KiB
TypeScript

// The "Make" section's examples are what people copy into their plugins folder, so each one is run
// here through a full lifecycle against the real X plugin API surface (members of createPluginApi,
// its scope and UI kit), its header is read with the plugin store's rules, the theme example goes
// through the app's own importer and the schema, and the bundled docs parse with working links.
// The section itself, the header rules and the Markdown reader are the framework's
// (waybetter/packages/shell/src/panel, tested in waybetter/packages/shell/tests/panel.test.ts).
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import { themeFromColors, themeVars } from '../../../waybetter/packages/runtime/src/themeApi.ts';
import { createPluginApi } from '../src/inject/pluginApi.ts';
import { parseThemeJson } from '../src/inject/themeSpec.ts';
import { anchorOf, inlineTokens, parseMarkdown, resolveLink } from '../../../waybetter/packages/shell/src/panel/markdown.ts';
import { KIT_DEMOS, cssVarOf, parsePluginHeader, pluginFileFor, pluginWarnings, validPluginFileName } from '../../../waybetter/packages/shell/src/panel/makeModel.ts';
import { PLUGIN_EXAMPLES, THEME_EXAMPLE } from '../src/ui/makeExamples.ts';
// @ts-expect-error: a plain .mjs helper of the framework's create-waybetter tests (no types).
import { validate } from '../../../waybetter/packages/create-waybetter/tests/jsonSchema.mjs';
const fakeWindow = { document: { documentElement: {} }, location: { href: 'https://x.com/home' } } as unknown as Window & typeof globalThis;
const real = createPluginApi(fakeWindow);
const API_KEYS = new Set(Object.keys(real));
const SCOPE_KEYS = new Set(Object.keys(real.scope()));
const UI_KEYS = new Set(Object.keys(real.ui));
type Any = Record<string, any>;
/** Throws on any member the real API does not have (a typo in an example fails here). */
function strict<T extends object>(label: string, keys: Set<string>, impl: T): T {
return new Proxy(impl, {
get(target, key) {
if (typeof key === 'symbol' || key === 'then') return (target as Any)[key as string];
if (!keys.has(key)) throw new Error(`${label}.${key} is not part of the wayBetter for X plugin API`);
if (!(key in target)) throw new Error(`test harness: ${label}.${key} is not stubbed`);
return (target as Any)[key];
},
});
}
function element(tag: string): Any {
return {
tag, className: '', textContent: '', children: [] as unknown[], attrs: new Map<string, string>(),
append(...nodes: unknown[]) { this.children.push(...nodes); },
replaceChildren(...nodes: unknown[]) { this.children = nodes; },
toggleAttribute(name: string, on: boolean) { if (on) this.attrs.set(name, ''); else this.attrs.delete(name); },
removeAttribute(name: string) { this.attrs.delete(name); },
};
}
function load(source: string) {
const calls: Array<[string, ...unknown[]]> = [];
const store = new Map<string, unknown>();
const keyHandlers: Array<(event: Any, item: unknown) => unknown> = [];
const themeHandlers: Array<(theme: unknown) => void> = [];
let disposed = false;
const ui = strict('ui', UI_KEYS, {
toast: (message: string, options?: Any) => { calls.push(['toast', message, options]); return { element: {}, close() {}, closed: Promise.resolve() }; },
dialog: (options: Any) => { calls.push(['dialog', options]); return { element: {}, body: element('div'), open: true, closed: Promise.resolve(), close() {}, setError() {}, setBusy() {} }; },
h: (tag: string, props: Any | null, ...children: unknown[]) => ({ ...element(tag), props: props ?? {}, checked: props?.checked, children: children.flat() }),
});
const scope = strict('scope', SCOPE_KEYS, {
css: (id: string, css: string) => calls.push(['css', id, css]),
onKey: (fn: (event: Any, item: unknown) => unknown) => keyHandlers.push(fn),
onThemeChange: (fn: (theme: unknown) => void) => themeHandlers.push(fn),
add: (node: unknown) => { calls.push(['add', node]); return node; },
defer: (fn: () => void) => calls.push(['defer', fn]),
ui,
dispose: () => { disposed = true; },
});
const theme = themeFromColors('#000000', '#e7e9ea', '#1d9bf0');
const api = strict('api', API_KEYS, {
injectCSS: (id: string, css: string) => calls.push(['injectCSS', id, css]),
clearCSS: (id: string) => calls.push(['clearCSS', id]),
scope: () => scope,
storage: (namespace: string) => ({
get: (key: string, fallback: unknown) => (store.has(`${namespace}:${key}`) ? store.get(`${namespace}:${key}`) : fallback),
set: (key: string, value: unknown) => { store.set(`${namespace}:${key}`, JSON.parse(JSON.stringify(value))); return true; },
remove: (key: string) => store.delete(`${namespace}:${key}`),
keys: () => [],
}),
tweet: () => ({ url: 'https://x.com/someone/status/1' }),
copyText: async (text: string) => { calls.push(['copyText', text]); return true; },
theme: { current: () => theme, onChange: () => () => {} },
ui,
});
const document = { documentElement: element('html'), body: element('body'), createElement: element };
const context = vm.createContext({ wayBetter: { api }, document, console });
context.window = context;
const module = { exports: {} as unknown };
vm.runInContext(`(function (module, exports) {\n${source}\n})`, context)(module, module.exports);
// As waybetter-host `start_script` instantiates it.
const P = (module.exports as Any)?.default ?? module.exports;
const instance = typeof P === 'function' ? (P.prototype && (P.prototype.start || P.prototype.stop) ? new P() : P()) : P;
return { instance, calls, store, keyHandlers, themeHandlers, document, theme, disposed: () => disposed };
}
test('every example plugin has a complete header and a file name the plugins folder accepts', () => {
assert.equal(PLUGIN_EXAMPLES.length, 4);
for (const example of PLUGIN_EXAMPLES) {
const meta = parsePluginHeader(example.file, example.source);
assert.ok(meta.header && meta.name && meta.description && meta.version && meta.author, example.id);
assert.ok(validPluginFileName(example.file), example.file);
assert.equal(pluginFileFor(meta.name), example.file);
assert.deepEqual(pluginWarnings(meta, example.source), [], example.id);
assert.doesNotMatch(example.source, /\bfetch\(|XMLHttpRequest|innerHTML|eval\(/, `${example.id}: no requests, no HTML strings`);
}
});
test('the smallest example injects and clears its CSS', () => {
const run = load(PLUGIN_EXAMPLES.find(e => e.id === 'smallest')!.source);
run.instance.start();
run.instance.stop();
assert.deepEqual(run.calls.map(c => c[0]), ['injectCSS', 'clearCSS']);
});
test('the scope example copies the focused post link on c and toasts', async () => {
const run = load(PLUGIN_EXAMPLES.find(e => e.id === 'scope')!.source);
run.instance.start();
await run.keyHandlers[0]!({ key: 'x' }, {});
await run.keyHandlers[0]!({ key: 'c' }, null);
assert.equal(run.calls.filter(c => c[0] === 'copyText').length, 0, 'nothing without a focused post');
await run.keyHandlers[0]!({ key: 'c' }, {});
assert.deepEqual(run.calls.find(c => c[0] === 'copyText'), ['copyText', 'https://x.com/someone/status/1']);
assert.equal(run.calls.find(c => c[0] === 'toast')?.[1], 'Link copied');
run.instance.stop();
assert.ok(run.disposed());
});
test('the settings example saves through a dialog and undoes from the toast', () => {
const run = load(PLUGIN_EXAMPLES.find(e => e.id === 'settings')!.source);
run.instance.start();
assert.ok(run.document.documentElement.attrs.has('data-hide-promoted'));
run.keyHandlers[0]!({ key: 'P' }, null);
const dialog = run.calls.find(c => c[0] === 'dialog')?.[1] as Any;
const body = element('div');
dialog.content(body);
const checkbox = body.children[0].children[0];
assert.equal(checkbox.checked, true);
checkbox.checked = false;
const save = dialog.actions.find((action: Any) => action.default);
assert.notEqual(save.onClick({ setError() {} }), false);
assert.equal(JSON.stringify(run.store.get('hide-promoted:settings')), '{"hide":false}');
assert.equal(run.document.documentElement.attrs.has('data-hide-promoted'), false);
const toast = run.calls.find(c => c[0] === 'toast') as [string, string, Any];
toast[2].action.onClick();
assert.equal(JSON.stringify(run.store.get('hide-promoted:settings')), '{"hide":true}');
assert.ok(run.document.documentElement.attrs.has('data-hide-promoted'));
run.instance.stop();
assert.ok(run.disposed());
});
test('the theme example draws the active theme and follows changes', () => {
const run = load(PLUGIN_EXAMPLES.find(e => e.id === 'theme')!.source);
run.instance.start();
const css = run.calls.find(c => c[0] === 'css')?.[2] as string;
assert.match(css, /var\(--wb-surface\)/);
const badge = run.document.body.children[0];
assert.equal(badge.className, 'theme-badge');
assert.equal(badge.children[0].textContent, run.theme.name);
run.themeHandlers[0]!({ ...run.theme, name: 'Midnight Mint', mode: 'dark', siteDefault: false });
assert.equal(badge.children[0].textContent, 'Midnight Mint');
assert.equal(badge.children[1], ' · dark');
run.instance.stop();
assert.ok(run.disposed());
});
test('the theme example imports cleanly and validates against theme.schema.json', () => {
const parsed = parseThemeJson(THEME_EXAMPLE);
assert.ok('theme' in parsed, 'error' in parsed ? parsed.error : '');
assert.deepEqual(parsed.warnings, []);
assert.equal(parsed.theme.name, 'Midnight Mint');
const schema = JSON.parse(readFileSync(new URL('../../../waybetter/packages/waybetter/theme.schema.json', import.meta.url), 'utf8'));
assert.deepEqual(validate(schema, JSON.parse(THEME_EXAMPLE)), []);
});
test('UI kit demos call real kit methods; color roles map to the --wb-* variables', () => {
for (const demo of KIT_DEMOS) {
assert.ok(UI_KEYS.has(demo.kind), demo.kind);
assert.ok(demo.code.includes(`ui.${demo.kind}(`), demo.id);
}
const theme = themeFromColors('#101010', '#f0f0f0', '#3ddc97');
const vars = themeVars(theme);
for (const role of Object.keys(theme.colors)) assert.ok(cssVarOf(role) in vars, role);
});
test('the bundled docs parse into headings, tables and code, and every link resolves', () => {
const docs = ['PLUGINS.md', 'THEMES.md', 'PLUGIN-IDEAS.md'];
for (const name of docs) {
const source = readFileSync(new URL(`../../../docs/${name}`, import.meta.url), 'utf8');
const blocks = parseMarkdown(source);
assert.ok(blocks.some(block => block.kind === 'heading'), name);
if (name !== 'PLUGIN-IDEAS.md') assert.ok(blocks.some(block => block.kind === 'code'), name);
for (const block of blocks) if (block.kind === 'table') for (const row of block.rows) assert.equal(row.length, block.head.length, `${name}: ${row.join(' | ')}`);
const anchors = new Set(blocks.filter(block => block.kind === 'heading').map(block => anchorOf((block as { text: string }).text)));
const texts = blocks.flatMap(block => block.kind === 'list' ? block.items : block.kind === 'table' ? [...block.head, ...block.rows.flat()] : 'text' in block && block.kind !== 'code' ? [block.text] : []);
for (const token of texts.flatMap(inlineTokens)) {
if (token.kind !== 'link') continue;
const link = resolveLink(token.href, docs);
assert.ok(link, `${name}: ${token.href} resolves`);
if (link.anchor && !link.doc) assert.ok(anchors.has(link.anchor), `${name}: #${link.anchor}`);
}
}
assert.equal(anchorOf('Matching the theme: `api.theme`'), 'matching-the-theme-apitheme');
assert.deepEqual(resolveLink('./PLUGINS.md#matching-the-theme-apitheme', docs), { doc: 'PLUGINS.md', anchor: 'matching-the-theme-apitheme' });
});