- src/shell/Panel.tsx is createPanel (waybetter/shell/panel) with the app's home page: Home, Plugins with custom CSS, Accounts and Settings, growing with what the app serves. - src/runtime/main.ts is composed with waybetter/runtime/siteRuntime (feature flags, page strings, plugin API views per @permissions, the floating panel button). - Native: the host serves the plugin, custom CSS, feature flag and settings commands (AppConfig::new(&SITE, title, &plugins::HOST), app::page_scripts on a loaded page); the app keeps tab_report and site_ready. site-tab grants the host's open_panel on the site only, checked with assert_site_only. - HOST_COMMANDS follows commands.rs again (shell_ready, tab_still_ready and the new ones; SITE).
413 lines
25 KiB
JavaScript
413 lines
25 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, ...HOST_COMMANDS.site].sort());
|
|
// A new app has no shell command of its own: the host serves plugins, custom CSS, tweaks and settings.
|
|
assert.deepEqual(manifest, ['site_ready', 'tab_report']);
|
|
assert.deepEqual(caps[2].permissions, ['allow-site-ready', 'allow-open-panel'], 'site tabs: the app\'s site command and the host\'s');
|
|
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']);
|
|
// The Windows installer is the framework's template, a file the package ships.
|
|
const nsis = JSON.parse(file('src-tauri', 'tauri.conf.json')).bundle.windows.nsis;
|
|
assert.deepEqual(nsis, { installMode: 'currentUser', template: '../node_modules/waybetter/windows/installer.nsi' });
|
|
assert.ok(existsSync(join(PACKAGE, 'windows', 'installer.nsi')), 'the package has the installer template');
|
|
assert.ok(JSON.parse(readFileSync(join(PACKAGE, 'package.json'), 'utf8')).files.includes('windows'), 'and publishes it');
|
|
// 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, /);
|
|
// The app's tab is the framework's panel (createPanel); the page runtime is composed by the framework.
|
|
assert.match(file('src', 'shell', 'Panel.tsx'), /export const Panel = createPanel\(\{/);
|
|
assert.match(file('src', 'runtime', 'main.ts'), /installSiteRuntime\(\{/);
|
|
assert.match(file('src-tauri', 'src', 'desktop.rs'), /AppConfig::new\(&SITE, SITE\.product, &plugins::HOST\)/);
|
|
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'));
|
|
assert.deepEqual(HOST_COMMANDS.site, list('SITE'));
|
|
});
|
|
|
|
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)}`);
|
|
}
|
|
});
|