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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
5.2 KiB
TypeScript
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);
|
|
});
|