import assert from 'node:assert/strict' import { execFile, execFileSync } from 'node:child_process' import { createServer } from 'node:http' import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import test from 'node:test' const exec = promisify(execFile) const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') const readText = async file => (await readFile(file, 'utf8')).replaceAll('\r\n', '\n') const guards = await readText(join(root, 'scripts/yydd-prod-release-guards.sh')) const bash = process.env.DEPLOY_TEST_BASH || (process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash') const python = process.env.DEPLOY_TEST_PYTHON || 'python3' const scratch = await mkdtemp(join(process.env.DEPLOY_TEST_TMP || tmpdir(), 'release-guards-')) const quote = value => `'${value.replaceAll("'", "'\\''")}'` const posix = value => value.replaceAll('\\', '/') const run = command => exec(bash, ['-c', `set -euo pipefail\n${guards}\n${command}`]) test('missing server and empty static output are rejected before copying', async () => { const app = join(scratch, 'incomplete') await mkdir(join(app, '.next/static'), { recursive: true }) await mkdir(join(app, '.next/standalone'), { recursive: true }) await assert.rejects(run(`prepare_standalone_runtime ${quote(posix(app))}`)) await writeFile(join(app, '.next/standalone/server.js'), 'server') await assert.rejects(run(`prepare_standalone_runtime ${quote(posix(app))}`)) await writeFile(join(app, '.next/static/empty.js'), '') await assert.rejects(run(`prepare_standalone_runtime ${quote(posix(app))}`)) }) test('complete output is copied and can be checked without recopying', async () => { const app = join(scratch, 'complete') await mkdir(join(app, '.next/static'), { recursive: true }) await mkdir(join(app, '.next/standalone'), { recursive: true }) await mkdir(join(app, 'public'), { recursive: true }) await writeFile(join(app, '.next/standalone/server.js'), 'server') await writeFile(join(app, '.next/static/app.js'), 'script') await writeFile(join(app, 'public/logo.png'), 'asset') await run(`prepare_standalone_runtime ${quote(posix(app))}\nassert_release_ready ${quote(posix(app))}`) assert.equal(await readFile(join(app, '.next/standalone/.next/static/app.js'), 'utf8'), 'script') assert.equal(await readFile(join(app, '.next/standalone/public/logo.png'), 'utf8'), 'asset') }) test('both generated deployment paths prepare before switching and retain failure evidence', async () => { for (const file of ['deploy-yydd-prod-from-git.ps1', 'create-yydd-prod-deploy.ps1']) { const source = await readText(join(root, 'scripts', file)) const shell = source.match(/\$shellScript = @'\r?\n([\s\S]*?)\r?\n'@/)[1] .replace('__RELEASE_GUARDS__', () => guards) execFileSync(bash, ['-n'], { input: shell }) assert(shell.includes('acquire_deploy_lock\n')) assert(shell.includes('assert_release_ready "$app_root" || return 1')) const switchAt = shell.lastIndexOf('ln -sfn "$RELEASE_DIR" "$CURRENT"') assert(shell.indexOf('prepare_standalone_runtime "$APP_DIR"') < switchAt) assert(shell.indexOf('assert_release_ready "$APP_DIR"') < switchAt) assert(shell.indexOf('verify_release_http "http://127.0.0.1:$APP_PORT"') > switchAt) assert(shell.includes('verify_release_http "https://cp.jiansun.vip"')) assert(shell.includes('ln -sfn "$PREVIOUS_RELEASE" "$CURRENT"')) assert(!shell.includes('rm -rf -- "$RELEASE_DIR"')) assert(!shell.includes('rm -f -- "$HEALTHCHECK_FILE"')) } }) test('HTTP verification parses pages and rejects missing or disguised static resources', async () => { const script = guards.match(/<<'PY'\n([\s\S]*?)\nPY/)[1] const scriptPath = join(scratch, 'verify.py') await writeFile(scriptPath, script) let failure = null const server = createServer((req, res) => { if (req.url === '/api/health') { res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify({ success: true, data: { services: { database: 'connected', api: 'running' } } })) } else if (req.url.startsWith('/_next/static/')) { res.statusCode = failure === 'missing' ? 404 : 200 res.setHeader('Content-Type', failure === 'html' ? 'text/html' : req.url.endsWith('.css') ? 'text/css' : 'application/javascript') res.end('test-content') } else { res.setHeader('Content-Type', 'text/html') res.end('') } }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) try { const url = `http://127.0.0.1:${server.address().port}` const result = await exec(python, [scriptPath, url]) assert(result.stdout.includes('Health and 2 static assets verified')) failure = 'missing' await assert.rejects(exec(python, [scriptPath, url])) failure = 'html' await assert.rejects(exec(python, [scriptPath, url])) } finally { await new Promise(resolve => server.close(resolve)) } }) test('a started remote deployment cannot silently retry via archive fallback', async () => { const source = await readText(join(root, 'scripts/deploy-yydd-prod-from-git.ps1')) assert(source.includes('$remoteDeployStarted = $true')) assert(source.includes('if ($remoteDeployStarted) {\n throw\n }')) assert(source.includes('$Command = $guard + "`n" + $Command')) }) test('failed post-switch validation restores only the previous release and retains evidence', async () => { for (const file of ['deploy-yydd-prod-from-git.ps1', 'create-yydd-prod-deploy.ps1']) { const source = await readText(join(root, 'scripts', file)) const cleanup = source.match(/cleanup_failed_release\(\) \{[\s\S]*?\n\}/)[0] const base = join(scratch, file.replace('.ps1', '')) await mkdir(join(base, 'previous'), { recursive: true }) await mkdir(join(base, 'new'), { recursive: true }) await writeFile(join(base, 'check.log'), 'static 404') await assert.rejects(run(` BASE=${quote(posix(base))} CURRENT="$BASE/current" PREVIOUS_RELEASE="$BASE/previous" RELEASE_DIR="$BASE/new" DEPLOY_SUCCEEDED=0 HEALTHCHECK_FILE="$BASE/check.log" # Model symlink operations so the test also runs without Windows symlink privileges. ln() { printf '%s\\n' "$2" > "$3"; } readlink() { cat "$2"; } ln -sfn "$RELEASE_DIR" "$CURRENT" start_pm2_release() { readlink -f "$1" > "$BASE/restarted"; } ${cleanup} trap 'cleanup_failed_release "$?"' EXIT exit 1 `)) const restarted = await readFile(join(base, 'restarted'), 'utf8') assert(restarted.trim().endsWith('/previous')) assert.equal(await readFile(join(base, 'check.log'), 'utf8'), 'static 404') } }) test('image worker gets one persistent secret and starts only its own PM2 process', async () => { const app = join(scratch, 'worker-release') const shared = join(scratch, 'worker-shared') await mkdir(join(app, 'scripts'), { recursive: true }) await mkdir(shared, { recursive: true }) await writeFile(join(app, 'scripts/run-ai-image-worker.mjs'), '// worker fixture') await writeFile(join(shared, '.env'), 'EXISTING_SETTING=keep\n') await run(` SHARED=${quote(posix(shared))} PM2_NAME=furniture-test APP_PORT=3001 pm2() { printf '%s\\n' "$*" >> "$SHARED/pm2-calls"; } ensure_ai_image_worker_secret ${quote(posix(app))} ensure_ai_image_worker_secret ${quote(posix(app))} start_ai_image_worker ${quote(posix(app))} `) const env = await readText(join(shared, '.env')) assert(env.includes('EXISTING_SETTING=keep\n')) assert.equal(env.match(/^AI_IMAGE_WORKER_SECRET=/gm)?.length, 1) assert.match(env, /^AI_IMAGE_WORKER_SECRET=[a-f0-9]{64}$/m) const calls = await readText(join(shared, 'pm2-calls')) assert(calls.includes('delete furniture-test-ai-image-worker\n')) assert(calls.includes('start /bin/bash --name furniture-test-ai-image-worker -- -lc ')) assert(calls.includes('exec env PORT=3001 node scripts/run-ai-image-worker.mjs')) assert(!calls.includes('delete all')) await run(`SHARED=${quote(posix(shared))}\nensure_ai_image_worker_secret ${quote(posix(app))}`) assert.equal(await readText(join(shared, '.env')), env) }) test('rolling back to a release without the worker removes only the worker and does not rotate credentials', async () => { const app = join(scratch, 'old-worker-release') const shared = join(scratch, 'old-worker-shared') await mkdir(app, { recursive: true }) await mkdir(shared, { recursive: true }) await writeFile(join(shared, '.env'), 'EXISTING_SETTING=keep\nAI_IMAGE_WORKER_SECRET=unchanged-test-secret\n') const before = await readText(join(shared, '.env')) await run(` SHARED=${quote(posix(shared))} PM2_NAME=furniture-test APP_PORT=3001 pm2() { printf '%s\\n' "$*" >> "$SHARED/pm2-calls"; } ensure_ai_image_worker_secret ${quote(posix(app))} start_ai_image_worker ${quote(posix(app))} `) assert.equal(await readText(join(shared, '.env')), before) assert.equal(await readText(join(shared, 'pm2-calls')), 'delete furniture-test-ai-image-worker\n') }) test('a failed secret generator rejects startup preparation without saving an empty credential', async () => { const app = join(scratch, 'failed-secret-release') const shared = join(scratch, 'failed-secret-shared') await mkdir(join(app, 'scripts'), { recursive: true }) await mkdir(shared, { recursive: true }) await writeFile(join(app, 'scripts/run-ai-image-worker.mjs'), '// worker fixture') await writeFile(join(shared, '.env'), 'EXISTING_SETTING=keep\n') await assert.rejects(run(`SHARED=${quote(posix(shared))}\nnode() { return 1; }\nensure_ai_image_worker_secret ${quote(posix(app))}`)) assert.equal(await readText(join(shared, '.env')), 'EXISTING_SETTING=keep\n') })