// create-waybetter: runs the CLI non-interactively into temp directories and checks what it wrote // against the real contracts: plugin headers with the plugin store's own parser (Rust // `parse_meta` via the waybetter-host `plugin_meta` example), plugin code against the real plugin // API surface (createCorePluginApi), themes against theme.schema.json, app skeletons with tsc // against the built `waybetter` package, and `cargo metadata`, `cargo check` (desktop, Tauri) and // `cargo test` (headless) against the waybetter-host crate. The cargo check shares a target // directory in the OS temp folder: about a minute cold, seconds after that; it needs the crates in // the local registry cache (offline). WAYBETTER_SKIP_CARGO_CHECK=1 skips it. import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import vm from 'node:vm'; import { createCorePluginApi } from '../../runtime/src/pluginApi.ts'; import { themeFromColors } from '../../runtime/src/themeApi.ts'; import { APP_OUTPUTS, SITE_PRESETS } from '../src/app.js'; import { main } from '../src/cli.js'; import { UsageError, headerValue, pascalCase, slugify } from '../src/names.js'; import { normalizeThemeOptions, THEME_PRESETS } from '../src/theme.js'; import { validate } from './jsonSchema.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const HOST_CRATE = join(ROOT, 'crates/waybetter-host'); const PACKAGE = join(ROOT, 'packages/waybetter'); const TSC = join(ROOT, 'node_modules/typescript/bin/tsc'); const SCHEMA = JSON.parse(readFileSync(join(PACKAGE, 'theme.schema.json'), 'utf8')); const EXPORTS = Object.keys(JSON.parse(readFileSync(join(PACKAGE, 'package.json'), 'utf8')).exports); const BIN = join(ROOT, 'packages/create-waybetter/bin/create-waybetter.js'); const work = mkdtempSync(join(tmpdir(), 'create-waybetter-')); test.after(() => rmSync(work, { recursive: true, force: true })); const hasCargo = spawnSync('cargo', ['--version'], { encoding: 'utf8', shell: process.platform === 'win32' }).status === 0; const packageBuilt = existsSync(join(PACKAGE, 'dist/runtime/pluginApi.d.ts')); /** Runs the CLI in-process in `work`; returns { code, out, err }. */ async function create(...args) { let out = ''; let err = ''; const code = await main(args, { cwd: work, stdout: { write: text => { out += text; } }, stderr: { write: text => { err += text; } }, stdin: /** @type {any} */ ({ isTTY: false }), }); return { code, out, err }; } const read = (...parts) => readFileSync(join(work, ...parts), 'utf8'); /** The plugin store's own header parser (Rust), one result per file. */ function pluginMeta(...files) { const output = execFileSync('cargo', ['run', '--quiet', '--locked', '--manifest-path', join(HOST_CRATE, 'Cargo.toml'), '--example', 'plugin_meta', '--', ...files], { encoding: 'utf8' }); return output.trim().split('\n').map(line => JSON.parse(line)); } /** node_modules/ -> target, as a junction (no admin rights needed on Windows). */ function link(dir, name, target) { const path = join(dir, 'node_modules', name); mkdirSync(dirname(path), { recursive: true }); if (!existsSync(path)) symlinkSync(target, path, 'junction'); } function tsc(project) { const result = spawnSync(process.execPath, [TSC, '-p', project], { encoding: 'utf8' }); assert.equal(result.status, 0, `tsc -p ${project}\n${result.stdout}${result.stderr}`); } // --------------------------------------------------------------------------------------------- // The real plugin API surface, from the runtime itself. const SITE = { name: 'Test', product: 'Test', storagePrefix: 't:', styleDataKey: 't', toastId: 't-ui', toastBackground: '#1d9bf0', itemSelector: 'article', chordKeys: [] }; const core = createCorePluginApi(/** @type {any} */ ({ document: {}, location: { href: 'https://x.com/' } }), SITE); const API_KEYS = new Set(Object.keys(core)); const SCOPE_KEYS = new Set(Object.keys(core.scope())); const UI_KEYS = new Set(Object.keys(core.ui)); /** An object that throws on any member the real API does not have. */ function strict(label, keys, impl) { return new Proxy(impl, { get(target, key) { if (typeof key === 'symbol' || key === 'then') return target[key]; if (!keys.has(key)) throw new Error(`${label}.${key} is not part of the wayBetter plugin API`); if (!(key in target)) throw new Error(`test harness: ${label}.${key} is not stubbed`); return target[key]; }, }); } function element(tag) { return { tag, className: '', textContent: '', children: [], attrs: new Map(), append(...nodes) { this.children.push(...nodes); }, querySelector: () => null, toggleAttribute(name, on) { if (on) this.attrs.set(name, ''); else this.attrs.delete(name); }, removeAttribute(name) { this.attrs.delete(name); }, }; } /** Loads a plugin file the way waybetter-host `start_script` does and drives it through a lifecycle. */ function runPlugin(source, { extras = {} } = {}) { const calls = []; const store = new Map(); const observers = []; const keyHandlers = []; let disposed = false; const ui = strict('ui', UI_KEYS, { toast: (message, options) => { calls.push(['toast', message, options]); return { element: {}, close() {}, closed: Promise.resolve() }; }, dialog: options => { calls.push(['dialog', options]); return { element: {}, body: element('div'), open: true, closed: Promise.resolve(), close() {}, setError() {}, setBusy() {} }; }, h: (tag, props, ...children) => ({ ...element(tag), props: props ?? {}, checked: props?.checked, children: children.flat() }), }); const scope = strict('scope', SCOPE_KEYS, { css: (id, css) => calls.push(['css', id, css]), observe: (selector, callback) => observers.push([selector, callback]), onKey: callback => keyHandlers.push(callback), add: node => { calls.push(['add', node]); return node; }, defer: fn => calls.push(['defer', fn]), ui, dispose: () => { disposed = true; }, }); const api = strict('api', new Set([...API_KEYS, ...Object.keys(extras)]), { scope: () => scope, storage: namespace => ({ get: (key, fallback) => (store.has(`${namespace}:${key}`) ? store.get(`${namespace}:${key}`) : fallback), set: (key, value) => { store.set(`${namespace}:${key}`, value); return true; }, remove: key => store.delete(`${namespace}:${key}`), keys: () => [], }), theme: { current: () => themeFromColors('#000000', '#e7e9ea', '#1d9bf0'), onChange: () => () => {} }, ui, ...extras, }); const document = { documentElement: element('html'), createElement: element }; const context = vm.createContext({ window: { wayBetter: { api } }, document, console }); const module = { exports: {} }; vm.runInContext(`(function (module, exports) {\n${source}\n})`, context)(module, module.exports); const P = module.exports && module.exports.default ? module.exports.default : module.exports; const instance = typeof P === 'function' ? (P.prototype && (P.prototype.start || P.prototype.stop) ? new P() : P()) : P; return { instance, calls, store, observers, keyHandlers, document, disposed: () => disposed }; } /** start, one item, the settings dialog (Save), stop. */ function exercisePlugin(source, { selector, extras, words }) { const run = runPlugin(source, { extras }); run.instance.start(); assert.ok(run.calls.some(([kind, id, css]) => kind === 'css' && typeof id === 'string' && css.includes('var(--wb-')), 'themed CSS through the scope'); assert.equal(run.observers[0]?.[0], selector); const item = { ...element('article'), textContent: 'one two' }; run.observers[0][1](item); assert.equal(item.children[0]?.textContent, `${words} words`, 'badge on the item'); const welcome = run.calls.find(([kind]) => kind === 'toast'); assert.equal(typeof welcome?.[2]?.action?.onClick, 'function', 'toast with an action'); run.keyHandlers[0]({ key: 'W' }); const dialog = run.calls.find(([kind]) => kind === 'dialog')?.[1]; assert.ok(dialog?.title && Array.isArray(dialog.actions), 'settings dialog'); const body = element('div'); dialog.content(body); assert.equal(body.children[0].props.class, 'wb-check'); const save = dialog.actions.find(action => action.default); assert.notEqual(save.onClick({ setError() {} }), false); // Values come from the plugin's own realm (vm context): compare as JSON. assert.equal(JSON.stringify([...run.store.entries()].find(([key]) => key.endsWith(':settings'))?.[1]), '{"badges":true}'); run.instance.stop(); assert.ok(run.disposed(), 'stop() disposes the scope'); } // --------------------------------------------------------------------------------------------- test('names: slugs, class names and header values', () => { assert.equal(slugify('Hide Trends!'), 'hide-trends'); assert.equal(slugify('Çok Güzel Tema'), 'cok-guzel-tema'); assert.equal(pascalCase('2nd best plugin'), 'Plugin2ndBestPlugin'); assert.equal(pascalCase('hide trends'), 'HideTrends'); assert.throws(() => headerValue('Name', 'a */ b'), UsageError); assert.throws(() => headerValue('Name', 'two\nlines'), UsageError); assert.throws(() => headerValue('Name', '', { required: true }), UsageError); }); test('cli: help, version, usage errors and dry runs', async () => { assert.equal((await create('--help')).code, 0); assert.match((await create('--version')).out, /^\d+\.\d+\.\d+/); const missing = await create('--name', 'x'); assert.equal(missing.code, 1); assert.match(missing.err, /--type is required/); assert.equal((await create('--type', 'nope', '--yes')).code, 1); assert.equal((await create('--bogus')).code, 1); const dry = await create('plugin', 'dry', '--name', 'Dry Run', '--dry-run'); assert.equal(dry.code, 0); assert.match(dry.out, /dry-run\.plugin\.js/); assert.equal(existsSync(join(work, 'dry')), false); mkdirSync(join(work, 'busy')); writeFileSync(join(work, 'busy', 'keep.txt'), 'x'); const busy = await create('theme', 'busy', '--name', 'Busy', '--yes'); assert.equal(busy.code, 1); assert.match(busy.err, /not empty/); assert.equal((await create('theme', 'busy', '--name', 'Busy', '--yes', '--force')).code, 0); }); test('cli: the bin runs as a program and exits non-zero on errors', () => { const ok = spawnSync(process.execPath, [BIN, 'plugin', 'bin-plugin', '--name', 'Bin Plugin', '--yes'], { cwd: work, encoding: 'utf8' }); assert.equal(ok.status, 0, ok.stderr); assert.ok(existsSync(join(work, 'bin-plugin', 'bin-plugin.plugin.js'))); const bad = spawnSync(process.execPath, [BIN, '--type', 'theme', '--name', 'Bad', '--accent', 'blue', '--yes'], { cwd: work, encoding: 'utf8' }); assert.equal(bad.status, 1); assert.match(bad.stderr, /hex color/); }); test('plugin (JavaScript, X): header, file name and a full lifecycle on the real API', async () => { const result = await create('plugin', 'js-plugin', '--name', 'Word Badges', '--description', 'Counts words.', '--author', 'Ada', '--plugin-version', '1.2.3', '--yes'); assert.equal(result.code, 0, result.err); assert.deepEqual(readdirSync(join(work, 'js-plugin')).sort(), ['README.md', 'word-badges.plugin.js']); const source = read('js-plugin', 'word-badges.plugin.js'); assert.ok(source.startsWith('/**\n * @name Word Badges\n')); exercisePlugin(source, { selector: 'article[data-testid="tweet"]', extras: { tweet: () => ({ text: 'three words here' }) }, words: 3, }); }); test('plugin (JavaScript, any app): generic item selector, core API only', async () => { assert.equal((await create('plugin', 'any-plugin', '--name', 'Any Badges', '--app', 'any', '--yes')).code, 0); const source = read('any-plugin', 'any-badges.plugin.js'); assert.doesNotMatch(source, /api\.tweet/); exercisePlugin(source, { selector: '[data-example-item]', extras: {}, words: 2 }); }); test('plugin headers parse with the plugin store\'s own rules (waybetter-host parse_meta)', { skip: !hasCargo && 'cargo not found' }, async () => { assert.equal((await create('plugin', 'meta-plugin', '--name', 'Meta: Ünïcode & Co.', '--description', 'Emoji ✨ ok, "quotes" too', '--author', 'A. Person', '--yes')).code, 0); const files = [join(work, 'js-plugin', 'word-badges.plugin.js'), join(work, 'meta-plugin', readdirSync(join(work, 'meta-plugin')).find(f => f.endsWith('.plugin.js')))]; const [first, second] = pluginMeta(...files); assert.deepEqual(first, { file: 'word-badges.plugin.js', name: 'Word Badges', description: 'Counts words.', version: '1.2.3', author: 'Ada', validFileName: true }); assert.equal(second.name, 'Meta: Ünïcode & Co.'); assert.equal(second.description, 'Emoji ✨ ok, "quotes" too'); assert.equal(second.file, 'meta-unicode-co.plugin.js'); assert.equal(second.validFileName, true); }); test('plugin (TypeScript): typechecks against the waybetter package and builds a loadable file', { skip: !packageBuilt && 'run `bun run build:packages` first' }, async () => { assert.equal((await create('plugin', 'ts-plugin', '--name', 'Typed Badges', '--ts', '--yes')).code, 0); const dir = join(work, 'ts-plugin'); const pkg = JSON.parse(read('ts-plugin', 'package.json')); assert.ok(pkg.devDependencies.waybetter && pkg.devDependencies.typescript); link(dir, 'waybetter', PACKAGE); link(dir, 'typescript', join(ROOT, 'node_modules/typescript')); tsc(join(dir, 'tsconfig.json')); execFileSync(process.execPath, ['build.mjs'], { cwd: dir }); const built = read('ts-plugin', 'dist', 'typed-badges.plugin.js'); assert.ok(built.startsWith('/**\n * @name Typed Badges\n'), 'header first in the built file'); exercisePlugin(built, { selector: 'article[data-testid="tweet"]', extras: { tweet: () => ({ text: 'a b c d' }) }, words: 4 }); if (hasCargo) assert.equal(pluginMeta(join(dir, 'dist', 'typed-badges.plugin.js'))[0].name, 'Typed Badges'); }); test('theme: every preset and option validates against theme.schema.json', async () => { const cases = [ ['theme-default', '--name', 'Midnight Mint'], ...Object.keys(THEME_PRESETS).map(preset => [`theme-${preset}`, '--name', `Preset ${preset}`, '--preset', preset]), ['theme-custom', '--name', 'Custom Look', '--mode', 'auto', '--bg', '#fff', '--text', '111111', '--accent', '#C2410C', '--font', 'Geist, system-ui', '--post-font', '"Georgia", serif', '--radius', '0', '--schema', 'local'], ]; for (const [dir, ...args] of cases) { const result = await create('theme', dir, ...args, '--yes'); assert.equal(result.code, 0, result.err); const file = readdirSync(join(work, dir)).find(name => name.endsWith('.theme.json')); const theme = JSON.parse(read(dir, file)); assert.deepEqual(validate(SCHEMA, theme), [], `${dir}: ${file}`); assert.ok(theme.$schema.endsWith('theme.schema.json')); assert.ok(theme.font.family && theme.font.tweet, 'font roles'); for (const role of ['bg', 'surface', 'elevated', 'border', 'text', 'muted', 'accent', 'inverse', 'onInverse', 'page']) assert.match(theme.colors[role], /^#[0-9a-f]{6}$/); } const custom = JSON.parse(read('theme-custom', 'custom-look.theme.json')); assert.equal(custom.$schema, './node_modules/waybetter/theme.schema.json'); assert.deepEqual(custom.font.tweet, ['Georgia', 'serif']); assert.equal(custom.colors.bg, '#ffffff'); }); test('theme: bad input is refused before anything is written', () => { assert.throws(() => normalizeThemeOptions({ name: 'X', accent: 'red' }), UsageError); assert.throws(() => normalizeThemeOptions({ name: 'X', font: 'Comic' }), UsageError); assert.throws(() => normalizeThemeOptions({ name: 'X', radius: 40 }), UsageError); assert.throws(() => normalizeThemeOptions({ name: 'X', mode: 'sepia' }), UsageError); assert.equal(normalizeThemeOptions({ name: 'Low', bg: '#777777', text: '#888888' }).warnings.length > 0, true); }); const APPS = [...Object.keys(SITE_PRESETS), 'example.org']; test('app: every site skeleton is consistent across its native and page halves', async () => { for (const site of APPS) { const dir = `app-${site.replace(/\W/g, '-')}`; const result = await create('app', dir, '--site', site, '--host-source', 'path', '--host-path', ROOT, '--yes'); assert.equal(result.code, 0, result.err); const file = (...parts) => read(dir, ...parts); const siteRs = file('src-tauri', 'src', 'site.rs'); const siteTs = file('src', 'site.ts'); // Same hosts and runtime global on both sides. const rsHosts = /hosts: &\[([^\]]*)\]/.exec(siteRs)[1]; const tsHosts = /hosts: \[([^\]]*)\]/.exec(siteTs)[1]; assert.equal(rsHosts, tsHosts); assert.equal(/runtime_global: "([^"]+)"/.exec(siteRs)[1], /runtimeGlobal: "([^"]+)"/.exec(siteTs)[1]); // Commands: build.rs manifest == generate_handler == capability permissions. const manifest = /commands\(&\[([^\]]*)\]\)/.exec(file('src-tauri', 'build.rs'))[1].match(/"([a-z_]+)"/g).map(c => c.slice(1, -1)).sort(); const handler = /generate_handler!\[([^\]]*)\]/.exec(file('src-tauri', 'src', 'desktop.rs'))[1].split(',').map(c => c.trim()).sort(); assert.deepEqual(handler, manifest); const caps = ['shell', 'tab-chrome', 'site-tab'].map(name => JSON.parse(file('src-tauri', 'capabilities', `${name}.json`))); const granted = caps.flatMap(cap => cap.permissions).filter(p => p.startsWith('allow-')).map(p => p.slice(6).replace(/-/g, '_')).sort(); assert.deepEqual(granted, manifest); assert.deepEqual(caps[1].permissions, ['allow-tab-report'], 'any tab: only about itself'); assert.deepEqual(caps[2].remote.urls, JSON.parse(`[${tsHosts}]`).map(host => `https://${host}/*`)); assert.equal(caps[0].local, true); assert.deepEqual(JSON.parse(file('src-tauri', 'tauri.conf.json')).app.security.capabilities.sort(), ['shell', 'site-tab', 'tab-chrome']); // Every `waybetter/...` import is a published subpath of the package. const sources = ['src/site.ts', 'src/address.ts', 'src/runtime/main.ts', 'src/shell/main.ts'].map(path => file(...path.split('/'))).join('\n'); for (const [, specifier] of sources.matchAll(/from '(waybetter\/[^']+)'/g)) assert.ok(EXPORTS.includes(`./${specifier.slice('waybetter/'.length)}`), `${specifier} is exported`); // Icons Tauri decodes at compile time. const png = readFileSync(join(work, dir, 'src-tauri', 'icons', 'icon.png')); assert.equal(png.subarray(1, 4).toString(), 'PNG'); assert.equal(png[25], 6, 'RGBA'); const ico = readFileSync(join(work, dir, 'src-tauri', 'icons', 'icon.ico')); assert.deepEqual([...ico.subarray(0, 6)], [0, 0, 1, 0, 1, 0]); assert.ok(existsSync(join(work, dir, APP_OUTPUTS.runtime)), 'runtime placeholder for the first cargo check'); } const kick = read('app-kick-com', 'src-tauri', 'src', 'site.rs'); assert.match(kick, /hosts: &\["kick\.com"\]/); assert.match(read('app-youtube-com', 'src-tauri', 'Cargo.toml'), /waybetter-host = \{ path = ".*\/crates\/waybetter-host" \}/); }); test('app: host crate sources (submodule default, git)', async () => { assert.equal((await create('app', 'app-sub', '--site', 'twitch.tv', '--yes')).code, 0); assert.match(read('app-sub', 'src-tauri', 'Cargo.toml'), /waybetter-host = \{ path = "\.\.\/waybetter\/crates\/waybetter-host" \}/); assert.match(read('app-sub', 'README.md'), /git submodule add/); assert.equal((await create('app', 'app-git', '--site', 'twitch.tv', '--host-source', 'git', '--yes')).code, 0); assert.match(read('app-git', 'src-tauri', 'Cargo.toml'), /waybetter-host = \{ git = "https:\/\/git\.crt\.fyi\/CRT\/waybetter\.git" \}/); assert.equal((await create('app', 'app-bad', '--site', 'not a host', '--yes')).code, 1); assert.equal((await create('app', 'app-bad2', '--site', 'kick.com', '--global', 'wayBetter', '--yes')).code, 1); }); test('app: the TypeScript typechecks against the waybetter package', { skip: !packageBuilt && 'run `bun run build:packages` first' }, () => { for (const site of APPS) { const dir = join(work, `app-${site.replace(/\W/g, '-')}`); link(dir, 'waybetter', PACKAGE); tsc(join(dir, 'tsconfig.json')); } }); test('app: the Rust resolves against waybetter-host (cargo metadata)', { skip: !hasCargo && 'cargo not found' }, () => { const manifest = join(work, 'app-kick-com', 'src-tauri', 'Cargo.toml'); const offline = spawnSync('cargo', ['metadata', '--offline', '--format-version', '1', '--manifest-path', manifest], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); // Without a warm registry cache, fall back to the manifest alone. const result = offline.status === 0 ? offline : spawnSync('cargo', ['metadata', '--no-deps', '--format-version', '1', '--manifest-path', manifest], { encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr); const metadata = JSON.parse(result.stdout); const app = metadata.packages.find(pkg => pkg.name === 'waybetter-for-kick'); assert.ok(app, 'the app crate'); const host = app.dependencies.find(dep => dep.name === 'waybetter-host'); assert.ok(host?.path, 'waybetter-host as a path dependency'); assert.deepEqual(app.features.desktop.sort(), ['dep:tauri', 'dep:tauri-build', 'waybetter-host/tauri']); }); test('app: cargo check (desktop) and cargo test (headless) of the generated Rust', { skip: (!hasCargo && 'cargo not found') || (process.env.WAYBETTER_SKIP_CARGO_CHECK === '1' && 'WAYBETTER_SKIP_CARGO_CHECK=1') }, () => { const dir = join(work, 'app-kick-com'); mkdirSync(join(dir, 'dist'), { recursive: true }); writeFileSync(join(dir, 'dist', 'index.html'), 'shell\n'); const env = { ...process.env, CARGO_TARGET_DIR: process.env.CARGO_TARGET_DIR || join(tmpdir(), 'create-waybetter-cargo-target') }; const manifest = join(dir, 'src-tauri', 'Cargo.toml'); for (const args of [['check', '--offline'], ['test', '--offline', '--no-default-features']]) { const result = spawnSync('cargo', [...args, '--manifest-path', manifest], { encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 }); assert.equal(result.status, 0, `cargo ${args.join(' ')}\n${result.stderr.slice(-4000)}`); } });