feat: complete server-backed six-lesson app and trial tooling
Frontend CI / unit (test, type-check, build) (push) Successful in 1m11s
Frontend CI / browser E2E (frontend + pinned backend) (push) Failing after 2m48s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 14:40:37 +03:30
co-authored by Cursor
parent 653f1b0c5a
commit e3f0457dc9
73 changed files with 3792 additions and 395 deletions
+125
View File
@@ -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
+60
View File
@@ -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
}
+71
View File
@@ -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)
})
+19
View File
@@ -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 })
}
+28
View File
@@ -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)