The app skeleton's src/shell/main.tsx calls createShell with the site (pages, account rules, search, address chips, a Home link, a Plugins section) and src/shell/Panel.tsx (the app's own tab: the plugins, switched on or off live); app.css themes it with the --wb-c-* tokens. build.mjs bundles the shell with waybetter/build (JSX, CSS into dist/shell.css) and writes bootScript() as boot.js. desktop.rs keeps the frame's titlebar (custom_titlebar: true) and forwards shortcuts and link drags (BridgeOptions::ALL); the shell capability grants the frame's window calls. Native features the skeleton does not wire up (workspaces, sync, developer mode, plugin commands) are off in the site. Dependencies: react, react-dom, dockview, waybetter ^0.4.0. Tests: sources import only published subpaths (patterns included), main.tsx uses createShell, the shell capability has the resize permission, and the skeleton typechecks with the React types.
400 lines
24 KiB
JavaScript
400 lines
24 KiB
JavaScript
// 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, HOST_COMMANDS, SITE_PRESETS } from '../src/app.js';
|
|
import { normalizeReleaseConfig } from '../../waybetter/cli/release.mjs';
|
|
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);
|
|
/** A `waybetter/...` specifier is one of the package's exports (patterns like `./locales/*.json` included). */
|
|
const exported = specifier => {
|
|
const sub = `./${specifier.slice('waybetter/'.length)}`;
|
|
return EXPORTS.some(key => (key.includes('*') ? sub.startsWith(key.slice(0, key.indexOf('*'))) && sub.endsWith(key.slice(key.indexOf('*') + 1)) : key === sub));
|
|
};
|
|
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/<name> -> 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<Sans>' }), 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 == those + the host's.
|
|
const manifest = /manifest\(&\[([^\]]*)\]\)/.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, ...HOST_COMMANDS.shell, ...HOST_COMMANDS.tab].sort());
|
|
assert.deepEqual(caps[1].permissions, ['allow-tab-report', 'allow-tab-shortcut', 'allow-tab-link-drag'], 'any tab: only about itself');
|
|
for (const command of HOST_COMMANDS.shell) assert.ok(caps[0].permissions.includes(`allow-${command.replace(/_/g, '-')}`), `the shell has ${command}`);
|
|
assert.match(file('src-tauri', 'tests', 'security.rs'), /assert_host_commands\(\)/);
|
|
// The release config is one `waybetter release` accepts.
|
|
const release = normalizeReleaseConfig(JSON.parse(file('waybetter.release.json')), join(work, dir));
|
|
assert.equal(release.tauriDir, join(work, dir, 'src-tauri'));
|
|
assert.match(release.artifact, /^[A-Za-z0-9._-]+$/);
|
|
// The runtime is bundled by the framework.
|
|
assert.match(file('scripts', 'build.mjs'), /from 'waybetter\/build'/);
|
|
assert.ok(EXPORTS.includes('./build'));
|
|
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.tsx', 'src/shell/Panel.tsx', 'scripts/build.mjs'].map(path => file(...path.split('/'))).join('\n');
|
|
for (const [, specifier] of sources.matchAll(/from '(waybetter\/[^']+)'/g)) assert.ok(exported(specifier), `${specifier} is exported`);
|
|
// The shell is the framework's shell frame (createShell), not a copy; it needs the window calls.
|
|
assert.match(file('src', 'shell', 'main.tsx'), /createShell\(site, /);
|
|
assert.ok(caps[0].permissions.includes('core:window:allow-start-resize-dragging'), 'the frame\'s resize grips');
|
|
// 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: the host command list matches waybetter-host (commands.rs)', () => {
|
|
const source = readFileSync(join(HOST_CRATE, 'src', 'commands.rs'), 'utf8');
|
|
const list = name => new RegExp(`pub const ${name}: &\\[&str\\] = &\\[([^\\]]*)\\]`).exec(source)[1].match(/"([a-z0-9_]+)"/g).map(c => c.slice(1, -1));
|
|
assert.deepEqual(HOST_COMMANDS.shell, list('SHELL'));
|
|
assert.deepEqual(HOST_COMMANDS.tab, list('TAB'));
|
|
});
|
|
|
|
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);
|
|
// The shell's React types (the app's own dependencies; the framework's copies stand in).
|
|
link(dir, '@types/react', join(ROOT, 'node_modules/@types/react'));
|
|
link(dir, '@types/react-dom', join(ROOT, 'node_modules/@types/react-dom'));
|
|
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/app']);
|
|
});
|
|
|
|
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'), '<!doctype html><title>shell</title>\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)}`);
|
|
}
|
|
});
|