import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; const script = fileURLToPath(new URL('./dev-backend.mjs', import.meta.url)); function fixture(t, source, options = {}) { const root = mkdtempSync(path.join(tmpdir(), 'creator-backend-test-')); mkdirSync(path.join(root, 'scripts')); mkdirSync(path.join(root, 'bin')); copyFileSync(script, path.join(root, 'scripts/dev-backend.mjs')); if (options.envFile) writeFileSync(path.join(root, '.env'), options.envFile); writeFileSync(path.join(root, 'bin/air'), `#!${process.execPath}\n${source}`, { mode: 0o755 }); const children = []; t.after(async () => { for (const child of children) { if (child.backendPID) { try { process.kill(child.backendPID, 'SIGTERM'); } catch (error) { if (error.code !== 'ESRCH') throw error; } } if (child.exitCode === null && child.signalCode === null) { const closed = once(child, 'close'); child.kill('SIGTERM'); await closed; } } rmSync(root, { recursive: true, force: true }); }); return () => { const child = spawn(process.execPath, [path.join(root, 'scripts/dev-backend.mjs')], { env: { ...process.env, PATH: options.noFlock ? path.join(root, 'bin') : `${path.join(root, 'bin')}:${process.env.PATH}` }, stdio: ['ignore', 'pipe', 'pipe'], }); children.push(child); child.errors = ''; child.output = ''; child.stdout.on('data', (chunk) => { child.output += chunk; const pid = /ready:(\d+)/.exec(chunk.toString()); if (pid) child.backendPID = Number(pid[1]); }); child.stderr.on('data', (chunk) => { child.errors += chunk; }); return child; }; } async function ready(child) { const [chunk] = await once(child.stdout, 'data', { signal: AbortSignal.timeout(1500) }); assert.match(chunk.toString(), /ready:/); } function exited(child) { return once(child, 'close', { signal: AbortSignal.timeout(1500) }); } const server = ` process.on('SIGTERM', () => process.exit(0)); process.on('SIGINT', () => process.exit(0)); console.log('ready:' + process.pid); setInterval(() => {}, 1000); `; test('rejects a second backend without disturbing the first', { timeout: 5000 }, async (t) => { const start = fixture(t, server); const first = start(); await ready(first); const second = start(); const [code] = await exited(second); assert.equal(code, 73); assert.match(second.errors, /后台已在运行/); assert.equal(first.exitCode, null); }); test('forwards termination and releases the lock for the next launch', { timeout: 5000 }, async (t) => { const start = fixture(t, server); const first = start(); await ready(first); const stopped = exited(first); first.kill('SIGTERM'); const [code] = await stopped; assert.equal(code, 0); const second = start(); await ready(second); const closed = exited(second); second.kill('SIGINT'); assert.equal((await closed)[0], 0); }); test('reports backend startup failure and allows a retry', { timeout: 5000 }, async (t) => { const start = fixture(t, "console.error('build failed'); process.exit(17);"); const first = start(); assert.equal((await exited(first))[0], 17); assert.match(first.errors, /build failed/); const second = start(); assert.equal((await exited(second))[0], 17); }); test('preserves environment configuration when launching through the lock', { timeout: 5000 }, async (t) => { const start = fixture(t, "console.log(process.env.LISTEN_ADDR); console.log(process.env.CREATORHUB_CREDENTIAL_MASTER_KEY);", { envFile: `\n# ignored comment\ninvalid line\nLISTEN_ADDR=:19091\nLISTEN_ADDR=:19092\nCREATORHUB_CREDENTIAL_MASTER_KEY=${Buffer.alloc(32, 1).toString('base64')}\nCONTROL_PLANE_USERNAME=test\nCONTROL_PLANE_PASSWORD=test\nDATABASE_URL=postgres://test\nWEB_DIR=/tmp/web\nCREATOR_COVER_DIR=/tmp/covers\nCREATORHUB_CREDENTIAL_STORE_DIR=/tmp/credentials\nLOG_LEVEL=info\nNATIVE_GATEWAY_ENDPOINT=http://127.0.0.1:28187\n`, }); const child = start(); assert.equal((await exited(child))[0], 0); assert.match(child.output, /:19091/); assert.match(child.output, /AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=/); }); test('preserves a signal-caused backend failure instead of reporting success', { timeout: 5000 }, async (t) => { const start = fixture(t, server); const child = start(); await ready(child); const closed = exited(child); process.kill(child.backendPID, 'SIGKILL'); assert.equal((await closed)[0], 137); }); test('reports a missing lock command instead of silently exiting successfully', { timeout: 5000 }, async (t) => { const start = fixture(t, server, { noFlock: true }); const child = start(); assert.equal((await exited(child))[0], 1); assert.match(child.errors, /启动失败.*ENOENT/); });