feat: prepare six-lesson trial backend with reliable rewards and operations
Backend CI / type-check, build, tests (push) Successful in 1m0s
Backend CI / type-check, build, tests (push) Successful in 1m0s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
# Starts Child Gold Land backend + frontend for local dev.
|
||||
# Manages only processes started by this script. No npm install / rebuild on each run.
|
||||
#
|
||||
# Usage:
|
||||
# pwsh -File .\scripts\dev-local-stack.ps1
|
||||
# pwsh -File .\scripts\dev-local-stack.ps1 -UseTestPorts -SmokeTest # alt ports + health/login probe then exit
|
||||
#
|
||||
# Default ports: backend 3000, frontend 5174. Test ports: 3010 / 5184 (when 3000/5174 are busy).
|
||||
|
||||
param(
|
||||
[switch]$UseTestPorts,
|
||||
[switch]$SmokeTest
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$BackendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$WorkspaceRoot = (Resolve-Path (Join-Path $BackendRoot '..')).Path
|
||||
$FrontendRoot = Join-Path $WorkspaceRoot 'frontend'
|
||||
|
||||
if ($UseTestPorts) {
|
||||
$BackendPort = 3010
|
||||
$FrontendPort = 5190
|
||||
} else {
|
||||
$BackendPort = 3000
|
||||
$FrontendPort = 5174
|
||||
}
|
||||
|
||||
$HealthUrl = "http://127.0.0.1:${BackendPort}/health"
|
||||
$FrontendUrl = "http://localhost:${FrontendPort}/"
|
||||
$LoginProxyUrl = "http://localhost:${FrontendPort}/api/auth/login"
|
||||
|
||||
$script:ManagedProcesses = @()
|
||||
$script:TempDbDir = $null
|
||||
|
||||
function Write-Info([string]$Message) { Write-Host $Message -ForegroundColor Cyan }
|
||||
function Write-Warn([string]$Message) { Write-Host $Message -ForegroundColor Yellow }
|
||||
function Write-Err([string]$Message) { Write-Host $Message -ForegroundColor Red }
|
||||
|
||||
function Test-NodeEngines {
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
throw 'Node.js not found in PATH. Install Node ^22.18.0 or >=24.12.0 (see .nvmrc).'
|
||||
}
|
||||
$raw = (node -v).TrimStart('v')
|
||||
$ver = [version]$raw
|
||||
$ok = ($ver.Major -eq 22 -and $ver -ge [version]'22.18.0') -or ($ver.Major -ge 24 -and $ver -ge [version]'24.12.0')
|
||||
if (-not $ok) {
|
||||
throw "Node v$raw is not supported. Required: ^22.18.0 or >=24.12.0. Run: nvm install 22.18.0 && nvm use 22.18.0"
|
||||
}
|
||||
Write-Info "Node $(node -v) at $((Get-Command node).Source)"
|
||||
}
|
||||
|
||||
function Test-RepoLayout {
|
||||
if (-not (Test-Path (Join-Path $BackendRoot 'package.json'))) {
|
||||
throw "Backend package.json not found at $BackendRoot"
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $FrontendRoot 'package.json'))) {
|
||||
throw "Frontend not found at `"$FrontendRoot`" (expected sibling of backend-nest)."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DependenciesPresent {
|
||||
if (-not (Test-Path (Join-Path $BackendRoot 'node_modules'))) {
|
||||
throw "Backend node_modules missing. Run once: cd `"$BackendRoot`"; npm install"
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $FrontendRoot 'node_modules'))) {
|
||||
throw "Frontend node_modules missing. Run once: cd `"$FrontendRoot`"; npm install"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ListenProcessId([int]$Port) {
|
||||
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($conn) { return [int]$conn.OwningProcess }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Assert-PortFree([int]$Port, [string]$Label) {
|
||||
$ownerPid = Get-ListenProcessId $Port
|
||||
if ($null -ne $ownerPid) {
|
||||
$name = (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue).ProcessName
|
||||
throw "${Label} port $Port is in use (PID $ownerPid, $name). Use -UseTestPorts or free the port."
|
||||
}
|
||||
}
|
||||
|
||||
function New-BackendProcessEnvironment {
|
||||
$envMap = @{}
|
||||
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
|
||||
$envMap[$entry.Key] = [string]$entry.Value
|
||||
}
|
||||
$envMap['PORT'] = [string]$BackendPort
|
||||
$envMap['HOST'] = '0.0.0.0'
|
||||
$envMap['NODE_ENV'] = 'development'
|
||||
$envMap['CORS_ORIGIN'] = "http://localhost:${FrontendPort}"
|
||||
if ($UseTestPorts) {
|
||||
$script:TempDbDir = Join-Path ([System.IO.Path]::GetTempPath()) ("cgl-devstack-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $script:TempDbDir -Force | Out-Null
|
||||
$envMap['DB_PATH'] = Join-Path $script:TempDbDir 'app.db'
|
||||
Write-Info "Test backend DB: $($envMap['DB_PATH'])"
|
||||
}
|
||||
return $envMap
|
||||
}
|
||||
|
||||
function Start-ProcessWithEnv {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$ArgumentList,
|
||||
[string]$WorkingDirectory,
|
||||
[hashtable]$Environment
|
||||
)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $FilePath
|
||||
$psi.WorkingDirectory = $WorkingDirectory
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
if ($ArgumentList -and $ArgumentList.Count -gt 0) {
|
||||
$quoted = $ArgumentList | ForEach-Object {
|
||||
if ($_ -match '\s') { "`"$_`"" } else { $_ }
|
||||
}
|
||||
$psi.Arguments = $quoted -join ' '
|
||||
}
|
||||
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
|
||||
$psi.EnvironmentVariables[[string]$entry.Key] = [string]$entry.Value
|
||||
}
|
||||
foreach ($key in $Environment.Keys) {
|
||||
$psi.EnvironmentVariables[$key] = [string]$Environment[$key]
|
||||
}
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
if (-not $proc) {
|
||||
throw "Failed to start process: $FilePath"
|
||||
}
|
||||
return $proc
|
||||
}
|
||||
|
||||
function Start-Backend {
|
||||
$mainJs = Join-Path $BackendRoot 'dist\main.js'
|
||||
if (-not (Test-Path $mainJs)) {
|
||||
throw "Missing dist/main.js. Run once: cd `"$BackendRoot`"; npm run build"
|
||||
}
|
||||
$node = (Get-Command node).Source
|
||||
$envMap = New-BackendProcessEnvironment
|
||||
Write-Info "Starting backend on port $BackendPort ..."
|
||||
$p = Start-ProcessWithEnv -FilePath $node -ArgumentList @($mainJs) -WorkingDirectory $BackendRoot -Environment $envMap
|
||||
$script:ManagedProcesses += $p
|
||||
Start-Sleep -Milliseconds 500
|
||||
if ($p.HasExited) {
|
||||
throw "Backend exited immediately (exit $($p.ExitCode)). Build with current Node and check dist/main.js."
|
||||
}
|
||||
return $p
|
||||
}
|
||||
|
||||
function Start-Frontend {
|
||||
$npm = (Get-Command npm.cmd -ErrorAction SilentlyContinue).Source
|
||||
if (-not $npm) {
|
||||
$npm = (Get-Command npm -ErrorAction SilentlyContinue).Source
|
||||
}
|
||||
if (-not $npm) {
|
||||
throw 'npm not found in PATH.'
|
||||
}
|
||||
$envMap = @{}
|
||||
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
|
||||
$envMap[$entry.Key] = [string]$entry.Value
|
||||
}
|
||||
$envMap['VITE_DEV_PORT'] = [string]$FrontendPort
|
||||
$envMap['VITE_DEV_API_TARGET'] = "http://127.0.0.1:${BackendPort}"
|
||||
Write-Info "Starting frontend on port $FrontendPort (proxy -> $($envMap['VITE_DEV_API_TARGET'])) ..."
|
||||
$p = Start-ProcessWithEnv -FilePath $npm -ArgumentList @('run', 'dev', '--', '--port', [string]$FrontendPort) `
|
||||
-WorkingDirectory $FrontendRoot -Environment $envMap
|
||||
$script:ManagedProcesses += $p
|
||||
return $p
|
||||
}
|
||||
|
||||
function Wait-BackendHealth {
|
||||
param([int]$TimeoutSeconds = 90)
|
||||
Write-Info "Waiting for backend health (max ${TimeoutSeconds}s) ..."
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
foreach ($p in $script:ManagedProcesses) {
|
||||
if ($p.HasExited) {
|
||||
throw "Managed process exited early (PID $($p.Id), exit $($p.ExitCode))."
|
||||
}
|
||||
}
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 3
|
||||
if ($r.StatusCode -eq 200) {
|
||||
Write-Info "Backend health OK: $HealthUrl"
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
throw "Backend health timed out: $HealthUrl"
|
||||
}
|
||||
|
||||
function Wait-FrontendHttp {
|
||||
param([int]$TimeoutSeconds = 120)
|
||||
Write-Info "Waiting for frontend (max ${TimeoutSeconds}s) ..."
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $FrontendUrl -UseBasicParsing -TimeoutSec 3
|
||||
if ($r.StatusCode -eq 200) {
|
||||
Write-Info "Frontend OK: $FrontendUrl"
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
throw "Frontend HTTP timed out: $FrontendUrl"
|
||||
}
|
||||
|
||||
function Test-ProxyLogin {
|
||||
$body = '{"username":"demo","password":"123456"}'
|
||||
$r = Invoke-WebRequest -Uri $LoginProxyUrl -Method POST -Body $body -ContentType 'application/json' -UseBasicParsing
|
||||
if ($r.StatusCode -ne 200) {
|
||||
throw "Proxy login failed: HTTP $($r.StatusCode)"
|
||||
}
|
||||
Write-Info "Proxy login OK: $LoginProxyUrl"
|
||||
}
|
||||
|
||||
function Stop-ManagedProcesses {
|
||||
foreach ($p in $script:ManagedProcesses) {
|
||||
if ($p -and -not $p.HasExited) {
|
||||
Write-Info "Stopping PID $($p.Id) ..."
|
||||
try {
|
||||
& taskkill.exe /PID $p.Id /T /F 2>$null | Out-Null
|
||||
} catch {
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
$script:ManagedProcesses = @()
|
||||
if ($script:TempDbDir -and (Test-Path $script:TempDbDir)) {
|
||||
Remove-Item -Recurse -Force $script:TempDbDir -ErrorAction SilentlyContinue
|
||||
$script:TempDbDir = $null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Info 'Child Gold Land — local dev stack'
|
||||
if ($UseTestPorts) { Write-Warn 'Using test ports 3010 / 5190 (temp SQLite when backend starts fresh DB).' }
|
||||
Test-NodeEngines
|
||||
Test-RepoLayout
|
||||
Test-DependenciesPresent
|
||||
Assert-PortFree -Port $BackendPort -Label 'Backend'
|
||||
Assert-PortFree -Port $FrontendPort -Label 'Frontend'
|
||||
|
||||
Start-Backend | Out-Null
|
||||
Start-Frontend | Out-Null
|
||||
Wait-BackendHealth
|
||||
if ($SmokeTest) {
|
||||
Wait-FrontendHttp
|
||||
Test-ProxyLogin
|
||||
Write-Info 'SmokeTest passed.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Info "Frontend: $FrontendUrl"
|
||||
Write-Info "Backend health: $HealthUrl"
|
||||
Write-Info 'Demo login: demo / 123456'
|
||||
Write-Warn 'Press Ctrl+C to stop only processes started by this script.'
|
||||
Write-Host ''
|
||||
|
||||
while ($true) {
|
||||
Start-Sleep -Seconds 2
|
||||
foreach ($p in $script:ManagedProcesses) {
|
||||
if ($p.HasExited) {
|
||||
Write-Err "Process PID $($p.Id) exited unexpectedly (code $($p.ExitCode))."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Err $_.Exception.Message
|
||||
exit 1
|
||||
} finally {
|
||||
Stop-ManagedProcesses
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Validates that a SQLite file matches Child Gold Land backend schema expectations.
|
||||
*/
|
||||
import Database from 'better-sqlite3'
|
||||
|
||||
export const EXPECTED_TABLES = [
|
||||
'users',
|
||||
'user_level_progress',
|
||||
'reward_ledger',
|
||||
'auth_accounts',
|
||||
]
|
||||
|
||||
/** Keep in sync with src/database/migrations/index.ts */
|
||||
export const EXPECTED_MIGRATION_IDS = ['001_initial_schema']
|
||||
|
||||
/**
|
||||
* @param {string} dbPath
|
||||
* @returns {{ ok: true } | { ok: false; reason: string }}
|
||||
*/
|
||||
export function validateAppSqliteFile(dbPath) {
|
||||
let db
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
} catch (error) {
|
||||
return { ok: false, reason: `cannot open database: ${error instanceof Error ? error.message : error}` }
|
||||
}
|
||||
|
||||
try {
|
||||
const integrity = db.pragma('integrity_check', { simple: true })
|
||||
if (integrity !== 'ok') {
|
||||
return { ok: false, reason: `integrity_check failed: ${integrity}` }
|
||||
}
|
||||
|
||||
const tables = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'",
|
||||
)
|
||||
.all()
|
||||
.map((r) => r.name)
|
||||
|
||||
for (const name of EXPECTED_TABLES) {
|
||||
if (!tables.includes(name)) {
|
||||
return { ok: false, reason: `missing table: ${name}` }
|
||||
}
|
||||
}
|
||||
|
||||
const migTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
|
||||
.get()
|
||||
if (!migTable) {
|
||||
return { ok: false, reason: 'missing schema_migrations table' }
|
||||
}
|
||||
|
||||
const applied = db.prepare('SELECT id FROM schema_migrations ORDER BY id').all().map((r) => r.id)
|
||||
for (const id of EXPECTED_MIGRATION_IDS) {
|
||||
if (!applied.includes(id)) {
|
||||
return { ok: false, reason: `missing migration: ${id}` }
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* WAL-safe SQLite backup via better-sqlite3 backup API (not a raw file copy).
|
||||
* Usage: DB_PATH=./data/app.db BACKUP_DIR=./backups node scripts/sqlite-backup.mjs
|
||||
*/
|
||||
import Database from 'better-sqlite3'
|
||||
import { mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const srcPath = process.env.DB_PATH ?? join(process.cwd(), 'data', 'app.db')
|
||||
const backupDir = process.env.BACKUP_DIR ?? join(process.cwd(), 'backups')
|
||||
const retentionDays = Number(process.env.BACKUP_RETENTION_DAYS ?? '0')
|
||||
|
||||
if (!existsSync(srcPath)) {
|
||||
console.error(`Source database not found: ${srcPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
mkdirSync(backupDir, { recursive: true })
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const destPath = join(backupDir, `app-${stamp}.db`)
|
||||
|
||||
const src = new Database(srcPath, { readonly: true })
|
||||
try {
|
||||
await src.backup(destPath)
|
||||
} finally {
|
||||
src.close()
|
||||
}
|
||||
|
||||
const verify = new Database(destPath, { readonly: true })
|
||||
try {
|
||||
const integrity = verify.pragma('integrity_check', { simple: true })
|
||||
if (integrity !== 'ok') {
|
||||
console.error(`Backup integrity check failed: ${integrity}`)
|
||||
process.exit(1)
|
||||
}
|
||||
} finally {
|
||||
verify.close()
|
||||
}
|
||||
|
||||
console.log(`Backup written: ${destPath}`)
|
||||
|
||||
if (retentionDays > 0) {
|
||||
const cutoff = Date.now() - retentionDays * 86_400_000
|
||||
for (const name of readdirSync(backupDir)) {
|
||||
if (!name.startsWith('app-') || !name.endsWith('.db')) continue
|
||||
const full = join(backupDir, name)
|
||||
if (statSync(full).mtimeMs < cutoff) {
|
||||
unlinkSync(full)
|
||||
console.log(`Removed old backup: ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Restore backup to a target path (service must be stopped).
|
||||
* Usage: node scripts/sqlite-restore.mjs <backup.db> <target.db>
|
||||
*/
|
||||
import Database from 'better-sqlite3'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { validateAppSqliteFile } from './sqlite-app-schema.mjs'
|
||||
|
||||
const backupPath = resolve(process.argv[2] ?? '')
|
||||
const targetPath = resolve(process.argv[3] ?? '')
|
||||
|
||||
if (!backupPath || !targetPath) {
|
||||
console.error('Usage: node scripts/sqlite-restore.mjs <backup.db> <target.db>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!existsSync(backupPath)) {
|
||||
console.error(`Backup not found: ${backupPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const activeDb = resolve(process.env.DB_PATH ?? join(process.cwd(), 'data', 'app.db'))
|
||||
const targetExists = existsSync(targetPath)
|
||||
|
||||
if (targetPath === activeDb) {
|
||||
console.error('Restore onto active DB_PATH is not allowed while it is configured as the live database.')
|
||||
console.error('Stop the backend service, point DB_PATH elsewhere, or restore to a temp file first.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (targetExists && process.env.RESTORE_ACK_STOPPED !== 'YES') {
|
||||
console.error(
|
||||
'Target database file already exists. Stop the backend service and set RESTORE_ACK_STOPPED=YES to continue.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const schema = validateAppSqliteFile(backupPath)
|
||||
if (!schema.ok) {
|
||||
console.error(`Backup rejected: ${schema.reason}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const staging = `${targetPath}.restore-staging`
|
||||
const stagingWal = `${staging}-wal`
|
||||
const stagingShm = `${staging}-shm`
|
||||
|
||||
function cleanupStaging() {
|
||||
for (const p of [staging, stagingWal, stagingShm]) {
|
||||
try {
|
||||
if (existsSync(p)) rmSync(p)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanupStaging()
|
||||
|
||||
try {
|
||||
copyFileSync(backupPath, staging)
|
||||
const stagedCheck = validateAppSqliteFile(staging)
|
||||
if (!stagedCheck.ok) {
|
||||
console.error(`Staging validation failed: ${stagedCheck.reason}`)
|
||||
cleanupStaging()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const verify = new Database(staging, { readonly: true })
|
||||
try {
|
||||
const integrity = verify.pragma('integrity_check', { simple: true })
|
||||
if (integrity !== 'ok') {
|
||||
console.error(`Staging integrity check failed: ${integrity}`)
|
||||
cleanupStaging()
|
||||
process.exit(1)
|
||||
}
|
||||
} finally {
|
||||
verify.close()
|
||||
}
|
||||
|
||||
for (const sidecar of [`${targetPath}-wal`, `${targetPath}-shm`]) {
|
||||
try {
|
||||
if (existsSync(sidecar)) rmSync(sidecar)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (targetExists) {
|
||||
const backupOfTarget = `${targetPath}.pre-restore-${Date.now()}.bak`
|
||||
renameSync(targetPath, backupOfTarget)
|
||||
console.log(`Existing target moved to ${backupOfTarget}`)
|
||||
}
|
||||
|
||||
renameSync(staging, targetPath)
|
||||
console.log(`Restored ${backupPath} -> ${targetPath}`)
|
||||
} catch (error) {
|
||||
cleanupStaging()
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Smoke-test production env on a temp DB (no deploy).
|
||||
* Requires: npm run build first.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const backendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
if (!existsSync(join(backendRoot, 'dist', 'main.js'))) {
|
||||
console.error('Run npm run build before trial-production-smoke.mjs')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'cgl-prod-smoke-'))
|
||||
const dbPath = join(tempDir, 'app.db')
|
||||
const port = String(3020 + Math.floor(Math.random() * 100))
|
||||
const secret = `trial-smoke-${'x'.repeat(40)}`
|
||||
|
||||
function fail(msg) {
|
||||
console.error(msg)
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const bad = spawnSync(process.execPath, ['dist/main.js'], {
|
||||
cwd: backendRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
PORT: port,
|
||||
DB_PATH: dbPath,
|
||||
JWT_SECRET: 'gold-land-dev-secret-change-in-production',
|
||||
CORS_ORIGIN: 'http://127.0.0.1:8080',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf8',
|
||||
timeout: 8000,
|
||||
})
|
||||
|
||||
if (bad.status === 0) {
|
||||
fail('Expected startup failure with dev JWT placeholder in production')
|
||||
}
|
||||
|
||||
async function waitForReady() {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['dist/main.js'], {
|
||||
cwd: backendRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
PORT: port,
|
||||
DB_PATH: dbPath,
|
||||
JWT_SECRET: secret,
|
||||
CORS_ORIGIN: 'http://127.0.0.1:8080',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill()
|
||||
reject(new Error('startup timeout'))
|
||||
}, 20_000)
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const live = await fetch(`http://127.0.0.1:${port}/health`)
|
||||
const ready = await fetch(`http://127.0.0.1:${port}/health/ready`)
|
||||
if (live.ok && ready.ok) {
|
||||
clearTimeout(timer)
|
||||
child.kill()
|
||||
resolve(undefined)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
setTimeout(poll, 300)
|
||||
}
|
||||
setTimeout(poll, 500)
|
||||
})
|
||||
}
|
||||
|
||||
await waitForReady().catch((e) => fail(String(e)))
|
||||
|
||||
const demoCheck = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`const D=require('better-sqlite3');const d=new D(${JSON.stringify(dbPath)});const row=d.prepare("SELECT COUNT(*) as c FROM auth_accounts WHERE username='demo'").get();console.log(row.c);d.close();`,
|
||||
],
|
||||
{ cwd: backendRoot, encoding: 'utf8' },
|
||||
)
|
||||
const demoCount = Number(demoCheck.stdout.trim())
|
||||
if (demoCount !== 0) {
|
||||
fail(`Expected no demo account in production DB, found ${demoCount}`)
|
||||
}
|
||||
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
console.log('trial-production-smoke passed')
|
||||
Reference in New Issue
Block a user