waybetter/packages/runtime/tests/pluginApi.test.ts
CRT b2a91ff7b3 Extract the wayBetter framework core from wayBetter for X
Site-agnostic parts of the X app, behind a small site adapter:

- crates/waybetter-host: webview-label security policy and tab rect
  clamp, account profiles with native-owned accounts (AccountRules),
  plugin store with bundled-plugin upgrades (PluginHost), signed
  data-only hotfix channel, update scheduling and crash handshake, tab
  payloads/link routing/tab-chrome validation, and (feature "tauri")
  webview helpers and the Windows system menu.
- packages/runtime: DOM hub, request hub, plugin API core, OKLab theme
  color model, split-view rail hiding, link routing and account
  reporting, each configured by a SiteAdapter.
- packages/shell: address bar, pane, link-drop, profile, palette and
  update-chip models, and window controls with the Tauri API injected.

Tests moved/adapted from the app: 45 Rust (headless), 40 TypeScript.
2026-09-28 03:01:33 +07:00

131 lines
5.2 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createCorePluginApi, isTypingTarget, type PluginApiSite } from '../src/pluginApi.ts';
type Listener = (event: unknown) => void;
class FakeTarget {
listeners = new Map<string, Set<Listener>>();
addEventListener(type: string, fn: Listener) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type)!.add(fn);
}
removeEventListener(type: string, fn: Listener) { this.listeners.get(type)?.delete(fn); }
emit(type: string, event: unknown = {}) { for (const fn of [...(this.listeners.get(type) ?? [])]) fn(event); }
count(type: string) { return this.listeners.get(type)?.size ?? 0; }
}
class FakeStorage {
map = new Map<string, string>();
get length() { return this.map.size; }
key(i: number) { return [...this.map.keys()][i] ?? null; }
getItem(k: string) { return this.map.get(k) ?? null; }
setItem(k: string, v: string) { this.map.set(k, v); }
removeItem(k: string) { this.map.delete(k); }
}
const SITE: PluginApiSite = {
name: 'Demo', product: 'wayBetter for Test', storagePrefix: 'demo-plugin:', styleDataKey: 'demoPlugin',
toastId: 'demo-toast', toastBackground: '#111', itemSelector: 'article.message', chordKeys: ['g'],
};
function fakeEnv() {
const styles: { dataset: Record<string, string>; textContent: string; isConnected: boolean; remove(): void }[] = [];
const doc = new FakeTarget() as FakeTarget & Record<string, unknown>;
Object.assign(doc, {
activeElement: null,
documentElement: {},
head: { appendChild(node: { isConnected: boolean }) { node.isConnected = true; } },
querySelectorAll: () => [],
createElement: () => {
const node = { dataset: {} as Record<string, string>, textContent: '', isConnected: false, remove() { node.isConnected = false; } };
styles.push(node);
return node;
},
});
const win = new FakeTarget() as FakeTarget & Record<string, unknown>;
const storage = new FakeStorage();
Object.assign(win, {
document: doc,
location: { href: 'https://site.test/home' },
localStorage: storage,
requestAnimationFrame: (fn: () => void) => { fn(); return 1; },
cancelAnimationFrame: () => {},
MutationObserver: class { observe() {} disconnect() {} },
});
const api = createCorePluginApi(win as unknown as Window & typeof globalThis, SITE);
return { api, win, doc, storage, styles };
}
test('storage is namespaced under the site prefix and never throws', () => {
const { api, storage } = fakeEnv();
const notes = api.storage('notes');
assert.equal(notes.set('a', { text: 'hi' }), true);
assert.equal(storage.getItem('demo-plugin:notes:a'), '{"text":"hi"}');
storage.setItem('demo-plugin:notes:broken', '{not json');
assert.deepEqual(notes.get('broken', 'fallback'), 'fallback');
assert.deepEqual(notes.keys().sort(), ['a', 'broken']);
assert.throws(() => api.storage('../escape'), /Demo\.storage: invalid namespace/);
});
test('injectCSS marks its style with the site data key and replaces in place', () => {
const { api, styles } = fakeEnv();
api.injectCSS('one', 'a{}');
api.injectCSS('one', 'b{}');
assert.equal(styles.length, 1);
assert.equal(styles[0]!.dataset.demoPlugin, 'one');
assert.equal(styles[0]!.textContent, 'b{}');
api.clearCSS('one');
assert.equal(styles[0]!.isConnected, false);
});
test('onKey skips typing, modifiers and the second key of site chords', () => {
const { api, doc } = fakeEnv();
const keys: string[] = [];
const off = api.onKey(event => keys.push(event.key));
const press = (key: string, extra: Record<string, unknown> = {}) => doc.emit('keydown', { key, target: null, ...extra });
press('j');
press('g'); press('h');
press('k', { ctrlKey: true });
press('l', { target: { nodeName: 'INPUT', closest: () => null } });
press('m');
assert.deepEqual(keys, ['j', 'm']);
off();
assert.equal(doc.count('keydown'), 0);
});
test('route listeners share one poll and stop with the last listener', () => {
const { api, win } = fakeEnv();
const offA = api.onRouteChange(() => {});
const offB = api.onRouteChange(() => {});
assert.equal(win.count('popstate'), 1);
offA();
assert.equal(win.count('popstate'), 1);
offB();
assert.equal(win.count('popstate'), 0);
});
test('scope disposes in reverse order and survives failing disposers', () => {
const { api } = fakeEnv();
const order: string[] = [];
const scope = api.scope();
const original = console.error;
console.error = () => {};
try {
scope.defer(() => order.push('first'));
scope.defer(() => { throw new Error('boom'); });
scope.defer(() => order.push('last'));
scope.dispose();
scope.dispose();
} finally {
console.error = original;
}
assert.deepEqual(order, ['last', 'first']);
});
test('isTypingTarget recognizes inputs and editable content', () => {
assert.equal(isTypingTarget(null), false);
assert.ok(isTypingTarget({ nodeName: 'TEXTAREA', closest: () => null } as unknown as EventTarget));
assert.ok(isTypingTarget({ nodeName: 'DIV', isContentEditable: true, closest: () => null } as unknown as EventTarget));
assert.equal(isTypingTarget({ nodeName: 'DIV', closest: () => null } as unknown as EventTarget), false);
});