178 lines
9.3 KiB
TypeScript
178 lines
9.3 KiB
TypeScript
// The CLI runs on Node 20+ (npm bundle) and Bun: cli/ reaches the runtime only through cli/lib/rt.ts, and rt behaves
|
|
// like the Bun APIs it replaced (spawn defaults and exit codes, which, Glob, type stripping of a config).
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, relative } from 'node:path';
|
|
import * as rt from '../cli/lib/rt';
|
|
|
|
const ROOT = join(import.meta.dir, '..');
|
|
|
|
function cliSources(dir = join(ROOT, 'cli')): string[] {
|
|
return readdirSync(dir, { withFileTypes: true }).flatMap(e => {
|
|
const p = join(dir, e.name);
|
|
if (e.isDirectory()) return cliSources(p);
|
|
return e.name.endsWith('.ts') && !e.name.endsWith('.test.ts') ? [p] : [];
|
|
});
|
|
}
|
|
|
|
describe('cli/ is runtime neutral', () => {
|
|
test('no Bun global, bun: module, import.meta.dir or text import outside cli/lib/rt.ts', () => {
|
|
const bad: string[] = [];
|
|
for (const f of cliSources()) {
|
|
const rel = relative(ROOT, f);
|
|
if (rel === join('cli', 'lib', 'rt.ts')) continue;
|
|
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
|
|
if (/\bBun\.[A-Za-z]|['"]bun:[a-z]|import\.meta\.(dir|dirname|path|file|filename|main)\b|with \{ type: 'text' \}/.test(line)) bad.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
|
|
});
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
test('no Bun-only runtime behaviour: console as a stdin iterator, prompt(), alert(), HTMLRewriter (use rt.readLine)', () => {
|
|
// `for await (const line of console)` works only on Bun; on Node (npx storebound) it throws
|
|
// "console is not async iterable" at the first prompt (owner's report on Windows, 0.8.2).
|
|
const bad: string[] = [];
|
|
for (const f of cliSources()) {
|
|
const rel = relative(ROOT, f);
|
|
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
|
|
if (/\bof\s+console\b|console\s*\[\s*Symbol\.asyncIterator|(^|[;{(=\s])(globalThis\.)?(prompt|alert)\s*\(\s*['"`]|\bnew\s+HTMLRewriter\b/.test(line.replace(/\/\/.*$/, ''))) bad.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
|
|
});
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
test('package.json: bin is the Node bundle, engines name Node 20, npm pack builds it', () => {
|
|
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { bin: Record<string, string>; engines: Record<string, string>; files: string[]; scripts: Record<string, string> };
|
|
expect(pkg.bin.storebound).toBe('dist/storebound.mjs');
|
|
expect(pkg.engines.node).toBe('>=20');
|
|
expect(pkg.files).toContain('dist/storebound.mjs');
|
|
expect(pkg.scripts.prepack).toContain('tools/build-node.ts');
|
|
});
|
|
});
|
|
|
|
describe('rt: processes', () => {
|
|
test('spawn: Bun defaults, exit code, 128 + signal, streams', async () => {
|
|
const p = rt.spawn(['sh', '-c', 'echo out; echo err >&2; exit 3'], { stderr: 'pipe' });
|
|
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
|
|
expect([out, err, code, p.exitCode]).toEqual(['out\n', 'err\n', 3, 3]);
|
|
const s = rt.spawn(['sleep', '10']);
|
|
s.kill();
|
|
expect(await s.exited).toBe(143);
|
|
expect(s.signalCode).toBe('SIGTERM');
|
|
const i = rt.spawn(['sleep', '10']);
|
|
i.kill('SIGINT');
|
|
expect(await i.exited).toBe(130);
|
|
});
|
|
|
|
test('a missing executable throws right away, like Bun', () => {
|
|
expect(() => rt.spawn(['storebound-no-such-tool'])).toThrow('Executable not found in $PATH: "storebound-no-such-tool"');
|
|
expect(() => rt.spawnSync(['storebound-no-such-tool'])).toThrow('Executable not found in $PATH');
|
|
});
|
|
|
|
test('spawnSync: stdin bytes, env, exit code, timeout without a throw', () => {
|
|
const r = rt.spawnSync(['sh', '-c', 'tr a-z A-Z; echo "$X" >&2; exit 2'], { stdin: new TextEncoder().encode('hi'), env: { ...process.env, X: 'x1' }, stdout: 'pipe', stderr: 'pipe' });
|
|
expect([r.stdout.toString(), r.stderr.toString(), r.exitCode, r.success]).toEqual(['HI', 'x1\n', 2, false]);
|
|
const t = rt.spawnSync(['sleep', '5'], { timeout: 100 });
|
|
expect([t.exitCode, t.signalCode, t.success]).toEqual([null, 'SIGTERM', false]);
|
|
});
|
|
|
|
test('which: PATH lookup with an explicit PATH, executables only', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-which-'));
|
|
try {
|
|
writeFileSync(join(dir, 'tool-x'), '#!/bin/sh\n');
|
|
chmodSync(join(dir, 'tool-x'), 0o755);
|
|
writeFileSync(join(dir, 'plain-y'), '');
|
|
expect(rt.which('tool-x', { PATH: dir })).toBe(join(dir, 'tool-x'));
|
|
expect(rt.which('plain-y', { PATH: dir })).toBeNull();
|
|
expect(rt.which('tool-x', { PATH: '' })).toBeNull();
|
|
expect(rt.which('sh')).toBe(Bun.which('sh'));
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('hasher and gunzip match Bun', () => {
|
|
expect(rt.hasher('sha256').update('a').update('b').digest('hex')).toBe(new Bun.CryptoHasher('sha256').update('ab').digest('hex'));
|
|
expect(new TextDecoder().decode(rt.gunzipSync(Bun.gzipSync(new TextEncoder().encode('zip'))))).toBe('zip');
|
|
});
|
|
});
|
|
|
|
describe('rt: Glob like Bun.Glob', () => {
|
|
const cases: [string, string][] = [
|
|
['apps/*', 'apps/a'], ['apps/*', 'apps/a/b'], ['apps/**', 'apps/a/b'], ['**/game', 'game'], ['**/game', 'x/y/game'],
|
|
['studio-*/app?', 'studio-1/app2'], ['studio-*/app?', 'studio-1/app22'], ['{a,b}/c', 'b/c'], ['{a,b}/c', 'd/c'],
|
|
['[ab]x', 'bx'], ['[!ab]x', 'cx'], ['[!ab]x', 'ax'], ['*.ts', 'a.ts'], ['*.ts', 'dir/a.ts'], ['a/**/z', 'a/z'], ['a/**/z', 'a/b/c/z'],
|
|
];
|
|
for (const [g, p] of cases) test(`${g} vs ${p}`, () => expect(new rt.Glob(g).match(p)).toBe(new Bun.Glob(g).match(p)));
|
|
|
|
test('scanSync finds the same folders, no dot entries', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-glob-'));
|
|
try {
|
|
for (const d of ['apps/one', 'apps/two/deep', 'apps/.hidden', 'other/three']) mkdirSync(join(dir, d), { recursive: true });
|
|
writeFileSync(join(dir, 'apps/file.txt'), '');
|
|
for (const g of ['apps/*', 'apps/**', '*/*', 'apps/t*']) {
|
|
const mine = [...new rt.Glob(g).scanSync({ cwd: dir, onlyFiles: false, dot: false })].sort();
|
|
const bun = [...new Bun.Glob(g).scanSync({ cwd: dir, onlyFiles: false, dot: false })].sort();
|
|
expect(mine, g).toEqual(bun);
|
|
}
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
});
|
|
|
|
describe('rt: configs on Node without type stripping', () => {
|
|
test('stripTypes turns every shipped config into JavaScript with the same value', async () => {
|
|
const files = ['templates/storebound.config.ts', ...readdirSync(join(ROOT, 'templates/profiles')).map(p => `templates/profiles/${p}/storebound.config.ts`), 'examples/demo-game/storebound.config.ts', 'examples/demo-app/storebound.config.ts']
|
|
.filter(f => { try { readFileSync(join(ROOT, f)); return true; } catch { return false; } });
|
|
expect(files.length).toBeGreaterThan(3);
|
|
for (const f of files) {
|
|
const js = rt.stripTypes(readFileSync(join(ROOT, f), 'utf8'));
|
|
const viaJs = (await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`)).default;
|
|
const viaTs = (await import(join(ROOT, f))).default;
|
|
expect(JSON.stringify(viaJs), f).toBe(JSON.stringify(viaTs));
|
|
}
|
|
});
|
|
|
|
test('stripTypes keeps strings and comments that look like types', () => {
|
|
const js = rt.stripTypes("import type { X } from 'x';\n// keep: a as B\nconst c: X = { s: 'a satisfies B', n: 1 as number } satisfies X;\nexport default c as X;\n");
|
|
expect(js).toContain("// keep: a as B");
|
|
expect(js).toContain("'a satisfies B'");
|
|
expect(js).not.toContain('import type');
|
|
expect(js).toContain('const c = {');
|
|
expect(js).toContain('n: 1 }');
|
|
expect(js).toContain('export default c;');
|
|
});
|
|
});
|
|
|
|
describe('rt: serve', () => {
|
|
test('fetch handler, file responses, 404', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-serve-'));
|
|
writeFileSync(join(dir, 'a.txt'), 'file body');
|
|
const s = await rt.serve({ port: 0, hostname: '127.0.0.1', fetch(req) {
|
|
const u = new URL(req.url);
|
|
if (u.pathname === '/json') return Response.json({ q: u.searchParams.get('q') });
|
|
return rt.fileResponse(join(dir, u.pathname.slice(1)), { 'content-type': 'text/plain' }) ?? new Response('no', { status: 404 });
|
|
} });
|
|
try {
|
|
const base = `http://127.0.0.1:${s.port}`;
|
|
expect(await (await fetch(`${base}/json?q=1`)).json()).toEqual({ q: '1' });
|
|
const f = await fetch(`${base}/a.txt`);
|
|
expect([f.status, f.headers.get('content-type'), await f.text()]).toEqual([200, 'text/plain', 'file body']);
|
|
expect((await fetch(`${base}/missing`)).status).toBe(404);
|
|
} finally { s.stop(true); rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('packageRoot is the storebound folder', () => {
|
|
expect(rt.packageRoot()).toBe(ROOT);
|
|
});
|
|
});
|
|
|
|
describe('rt: readLine', () => {
|
|
test('one line, CRLF trimmed, null at EOF, stream paused afterwards', async () => {
|
|
const { Readable } = await import('node:stream');
|
|
const a = Readable.from(['n\r\nmore\n']);
|
|
expect(await rt.readLine(a)).toBe('n');
|
|
expect(a.isPaused()).toBe(true);
|
|
expect(await rt.readLine(Readable.from(['\n']))).toBe('');
|
|
expect(await rt.readLine(Readable.from([]))).toBe(null);
|
|
expect(await rt.readLine(Readable.from(['yes']))).toBe('yes');
|
|
});
|
|
});
|