Persist image submissions with fixed idempotency keys, leased execution, provider identity, reference snapshots and staged results. Integrate DIY, assisted assembly and non-integrated white-background sessions without holding generation in a browser request. Recover ambiguous submissions through owner-scoped reads and stop a new eight-view batch when restoring an earlier request. Add the NativeImageGenerationJob migration and a dedicated worker to both release entry points. Protect job inputs/results from storage GC. Existing production configuration and business data are not changed by this commit. Validation: 256 focused Jest tests, isolated PostgreSQL migration/lease/transaction checks (8), deployment guard tests (9), type-check, production build and desktop/ mobile browser component scenarios. Live native 2K reference-image request worked; reusing its idempotency key returned the same task. Build uses local placeholders for otherwise absent Drawing System configuration. Three unrelated material tests also fail with the original mainline test file; they remain outside this change. Architecture/visual/root-cause review: passed with warnings. Independent review completed for the integration and shared input/storage paths; worker additionally reviewed by the primary agent. Production merge/deployment requires authorization.
193 lines
9.7 KiB
JavaScript
193 lines
9.7 KiB
JavaScript
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('<html><link rel="stylesheet" href="/_next/static/main.css"><script src="/_next/static/main.js"></script></html>')
|
|
}
|
|
})
|
|
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')
|
|
})
|