feat: complete server-backed six-lesson app and trial tooling
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* CI: clone backend repo at pinned ref (see e2e/backend.contract.json).
|
||||
* In CI (CI=true): E2E_BACKEND_REF is required — no fallback to main.
|
||||
*/
|
||||
import { readFileSync, existsSync, rmSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const frontendRoot = join(scriptDir, '..')
|
||||
const contractPath = join(frontendRoot, 'e2e', 'backend.contract.json')
|
||||
|
||||
const isCi = process.env.CI === 'true' || process.env.GITEA_ACTIONS === 'true'
|
||||
|
||||
if (!existsSync(contractPath)) {
|
||||
console.error('Missing e2e/backend.contract.json')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/** @type {{ repositoryUrl: string; checkoutPath: string; localRef?: string }} */
|
||||
const contract = JSON.parse(readFileSync(contractPath, 'utf8'))
|
||||
|
||||
const envRef = process.env.E2E_BACKEND_REF?.trim()
|
||||
let ref = envRef
|
||||
|
||||
if (isCi) {
|
||||
if (!ref) {
|
||||
console.error(
|
||||
'E2E backend ref is required in CI. Set repository variable E2E_BACKEND_REF to a full commit SHA (or immutable tag). Silent fallback to main is disabled.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
} else if (!ref) {
|
||||
ref = contract.localRef?.trim()
|
||||
if (!ref) {
|
||||
console.error(
|
||||
'Set E2E_BACKEND_REF to a backend commit SHA/tag, or localRef in e2e/backend.contract.json for clone-based local runs.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const repoUrl = process.env.E2E_BACKEND_REPOSITORY_URL?.trim() || contract.repositoryUrl
|
||||
const checkoutPath = resolve(
|
||||
frontendRoot,
|
||||
process.env.E2E_BACKEND_ROOT?.trim() || contract.checkoutPath,
|
||||
)
|
||||
|
||||
const token = process.env.E2E_BACKEND_CHECKOUT_TOKEN?.trim()
|
||||
let cloneUrl = repoUrl
|
||||
if (token) {
|
||||
const u = new URL(repoUrl.replace(/^git\+/, ''))
|
||||
u.username = token
|
||||
u.password = 'x-oauth-basic'
|
||||
cloneUrl = u.toString()
|
||||
}
|
||||
|
||||
if (existsSync(checkoutPath)) {
|
||||
rmSync(checkoutPath, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const git = 'git'
|
||||
const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
||||
const gitOpts = { stdio: 'inherit', env: gitEnv, shell: process.platform === 'win32' }
|
||||
|
||||
const isFullSha = /^[0-9a-f]{40}$/i.test(ref)
|
||||
|
||||
console.log(`Cloning backend ref=${ref} into ${checkoutPath}`)
|
||||
|
||||
let clone
|
||||
if (isFullSha) {
|
||||
clone = spawnSync(git, ['clone', '--depth', '1', '--no-checkout', cloneUrl, checkoutPath], gitOpts)
|
||||
if (clone.status !== 0) {
|
||||
console.error('Backend clone failed (init).')
|
||||
process.exit(clone.status ?? 1)
|
||||
}
|
||||
const fetch = spawnSync(
|
||||
git,
|
||||
['fetch', '--depth', '1', 'origin', ref],
|
||||
{ ...gitOpts, cwd: checkoutPath },
|
||||
)
|
||||
if (fetch.status !== 0) {
|
||||
console.error(`Backend fetch failed for SHA ${ref}.`)
|
||||
process.exit(fetch.status ?? 1)
|
||||
}
|
||||
const co = spawnSync(git, ['checkout', 'FETCH_HEAD'], { ...gitOpts, cwd: checkoutPath })
|
||||
if (co.status !== 0) {
|
||||
console.error('Backend checkout failed.')
|
||||
process.exit(co.status ?? 1)
|
||||
}
|
||||
} else {
|
||||
clone = spawnSync(
|
||||
git,
|
||||
['clone', '--depth', '1', '--branch', ref, cloneUrl, checkoutPath],
|
||||
gitOpts,
|
||||
)
|
||||
if (clone.status !== 0) {
|
||||
console.error(
|
||||
'Backend clone failed. For private repos set secret E2E_BACKEND_CHECKOUT_TOKEN (repo read).',
|
||||
)
|
||||
process.exit(clone.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const head = spawnSync(git, ['rev-parse', 'HEAD'], {
|
||||
cwd: checkoutPath,
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
const checkedOutSha = head.stdout?.trim()
|
||||
if (checkedOutSha) {
|
||||
console.log(`Backend checkout commit: ${checkedOutSha}`)
|
||||
if (isFullSha && checkedOutSha.toLowerCase() !== ref.toLowerCase()) {
|
||||
console.error(
|
||||
`E2E_BACKEND_REF (${ref}) does not match cloned HEAD (${checkedOutSha}).`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
console.error('Could not read cloned backend commit (git rev-parse HEAD failed).')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.env.E2E_BACKEND_ROOT = checkoutPath
|
||||
@@ -0,0 +1,60 @@
|
||||
# Ephemeral trial stack smoke test — does not use production volumes or .env.docker.
|
||||
param(
|
||||
[int]$Port = 18080
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$frontendRoot = Split-Path -Parent $PSScriptRoot
|
||||
$project = "cgltrial$(Get-Random)"
|
||||
$secret = ('trial-{0}' -f ('x' * 40))
|
||||
$cors = "http://127.0.0.1:$Port"
|
||||
$envFile = Join-Path $env:TEMP "cgl-trial-$project.env"
|
||||
@"
|
||||
JWT_SECRET=$secret
|
||||
CORS_ORIGIN=$cors
|
||||
VITE_API_URL=/api
|
||||
FRONTEND_PORT=$Port
|
||||
"@ | Set-Content -Path $envFile -Encoding utf8
|
||||
|
||||
Push-Location $frontendRoot
|
||||
try {
|
||||
docker compose -p $project --env-file $envFile config | Out-Null
|
||||
docker compose -p $project --env-file $envFile build
|
||||
docker compose -p $project --env-file $envFile up -d
|
||||
|
||||
$deadline = (Get-Date).AddMinutes(3)
|
||||
do {
|
||||
Start-Sleep -Seconds 3
|
||||
try {
|
||||
$h = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/health/ready" -UseBasicParsing
|
||||
if ($h.StatusCode -eq 200) { break }
|
||||
} catch { }
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
$ready = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/health/ready" -UseBasicParsing
|
||||
if ($ready.StatusCode -ne 200) { throw 'readiness failed' }
|
||||
|
||||
$user = "trial_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
||||
$body = @{ username = $user; password = 'pass1234'; name = 'Trial' } | ConvertTo-Json
|
||||
$reg = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/api/auth/register" -Method POST -Body $body -ContentType 'application/json; charset=utf-8' -UseBasicParsing
|
||||
$token = ($reg.Content | ConvertFrom-Json).token
|
||||
$me = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/api/me" -Headers @{ Authorization = "Bearer $token" } -UseBasicParsing
|
||||
if ($me.StatusCode -ne 200) { throw 'GET /api/me failed' }
|
||||
|
||||
$spa = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/map" -UseBasicParsing
|
||||
if ($spa.Content -notmatch 'html') { throw 'SPA route failed' }
|
||||
|
||||
$backendPorts = docker compose -p $project ps --format json | ConvertFrom-Json
|
||||
foreach ($svc in $backendPorts) {
|
||||
if ($svc.Service -eq 'backend' -and $svc.Publishers) {
|
||||
throw 'backend must not publish host ports'
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host 'docker-trial-smoke: OK'
|
||||
}
|
||||
finally {
|
||||
docker compose -p $project --env-file $envFile down -v --remove-orphans 2>$null
|
||||
Remove-Item $envFile -Force -ErrorAction SilentlyContinue
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* E2E-only backend: temp SQLite, configurable port/root. Used by Playwright webServer.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const frontendRoot = join(scriptDir, '..')
|
||||
const defaultBackend = join(frontendRoot, '..', 'backend-nest')
|
||||
const backendRoot = resolve(process.env.E2E_BACKEND_ROOT ?? defaultBackend)
|
||||
const backendPort = String(process.env.E2E_BACKEND_PORT ?? '3010')
|
||||
const frontendPort = String(process.env.E2E_FRONTEND_PORT ?? '5190')
|
||||
const corsOrigin = process.env.E2E_CORS_ORIGIN ?? `http://127.0.0.1:${frontendPort}`
|
||||
|
||||
const distMain = join(backendRoot, 'dist', 'main.js')
|
||||
if (!existsSync(distMain)) {
|
||||
console.error(`Missing ${distMain}. Run: npm run e2e:prepare (or npm run build in backend).`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const runtimeDir = join(frontendRoot, 'e2e', '.runtime')
|
||||
mkdirSync(runtimeDir, { recursive: true })
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'cgl-e2e-'))
|
||||
const dbPath = join(tempDir, 'app.db')
|
||||
writeFileSync(
|
||||
join(runtimeDir, 'state.json'),
|
||||
JSON.stringify({ tempDir, dbPath, backendPort: Number(backendPort) }),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const backend = spawn(process.execPath, ['dist/main.js'], {
|
||||
cwd: backendRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: backendPort,
|
||||
DB_PATH: dbPath,
|
||||
CORS_ORIGIN: corsOrigin,
|
||||
JWT_SECRET: process.env.E2E_JWT_SECRET ?? 'e2e-ci-jwt-not-for-production',
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
function cleanupTemp() {
|
||||
try {
|
||||
backend.kill()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function shutdown(code = 0) {
|
||||
cleanupTemp()
|
||||
process.exit(code)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown(0))
|
||||
process.on('SIGTERM', () => shutdown(0))
|
||||
backend.on('exit', (code, signal) => {
|
||||
if (signal) shutdown(0)
|
||||
else process.exit(code ?? 1)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync, rmSync, existsSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const frontendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const runtimeDir = join(frontendRoot, 'e2e', '.runtime')
|
||||
const statePath = join(runtimeDir, 'state.json')
|
||||
|
||||
if (existsSync(statePath)) {
|
||||
try {
|
||||
const { tempDir } = JSON.parse(readFileSync(statePath, 'utf8'))
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (existsSync(runtimeDir)) {
|
||||
rmSync(runtimeDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Build backend for E2E. Path from E2E_BACKEND_ROOT or sibling ../backend-nest.
|
||||
*/
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const frontendRoot = join(scriptDir, '..')
|
||||
const defaultBackend = join(frontendRoot, '..', 'backend-nest')
|
||||
const backendRoot = resolve(process.env.E2E_BACKEND_ROOT ?? defaultBackend)
|
||||
|
||||
if (!existsSync(join(backendRoot, 'package.json'))) {
|
||||
console.error(
|
||||
`E2E backend not found at ${backendRoot}. Set E2E_BACKEND_ROOT or clone per e2e/backend.contract.json.`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const result = spawnSync('npm', ['run', 'build'], {
|
||||
cwd: backendRoot,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
Reference in New Issue
Block a user