From b5985f33bb06c4a21dd089321ce225ee0ea70be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D1=8F=20=D0=90=D1=80=D1=85=D0=B8=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2?= Date: Wed, 15 Jul 2026 17:14:01 +0000 Subject: [PATCH] feat: ship production deploy, Playwright e2e, and agent implementer Add Docker atomic deploy tooling, CI, Playwright smoke/visual suite, shared industrial theme, physics invariants, quality-mode wiring, and a worktree-isolated agent implementer MVP with hard safety limits. Co-authored-by: Cursor --- .dockerignore | 10 + .github/workflows/ci.yml | 38 + .gitignore | 8 + agent/cli.mjs | 891 +++++++++++++++++- agent/tasks/demo-docs.json | 14 + docs/AGENT_IMPLEMENTER_MVP.md | 124 +++ docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md | 35 + docs/PLAYWRIGHT_E2E_REPORT.md | 95 ++ docs/PRODUCTION_DEPLOYMENT_REPORT.md | 64 ++ e2e/controls.spec.ts | 38 + e2e/routes.spec.ts | 19 + e2e/safety.spec.ts | 43 + e2e/smoke.spec.ts | 31 + e2e/visual.spec.ts | 20 + .../home-idle-hud-chromium-linux.png | Bin 0 -> 12586 bytes package-lock.json | 64 ++ package.json | 9 +- playwright.config.ts | 40 + releases/CURRENT_BUNDLE.txt | 1 + releases/CURRENT_RELEASE.txt | 1 + releases/README.md | 6 + scripts/agent-implement.sh | 5 + scripts/collect-perf-metrics.mjs | 171 ++++ scripts/deploy-production.sh | 59 ++ src/components/ThreeD/SorterDigitalTwin.tsx | 5 +- .../ThreeD/SorterDigitalTwinContinuous.tsx | 51 +- src/domain/industrialTheme.ts | 42 + src/domain/performanceStatic.test.ts | 3 +- src/domain/physicsInvariants.test.ts | 78 ++ src/domain/physicsInvariants.ts | 104 ++ src/domain/qualityMode.ts | 4 +- src/pages/MainPage.tsx | 63 +- tsconfig.node.json | 2 +- vitest.config.ts | 9 + 34 files changed, 2076 insertions(+), 71 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 agent/tasks/demo-docs.json create mode 100644 docs/AGENT_IMPLEMENTER_MVP.md create mode 100644 docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md create mode 100644 docs/PLAYWRIGHT_E2E_REPORT.md create mode 100644 docs/PRODUCTION_DEPLOYMENT_REPORT.md create mode 100644 e2e/controls.spec.ts create mode 100644 e2e/routes.spec.ts create mode 100644 e2e/safety.spec.ts create mode 100644 e2e/smoke.spec.ts create mode 100644 e2e/visual.spec.ts create mode 100644 e2e/visual.spec.ts-snapshots/home-idle-hud-chromium-linux.png create mode 100644 playwright.config.ts create mode 100644 releases/CURRENT_BUNDLE.txt create mode 100644 releases/CURRENT_RELEASE.txt create mode 100644 releases/README.md create mode 100755 scripts/agent-implement.sh create mode 100644 scripts/collect-perf-metrics.mjs create mode 100755 scripts/deploy-production.sh create mode 100644 src/domain/industrialTheme.ts create mode 100644 src/domain/physicsInvariants.test.ts create mode 100644 src/domain/physicsInvariants.ts create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore index 5bd6f15..9791ed0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,13 @@ dist .git npm-debug.log .DS_Store +.agent +releases +e2e +playwright-report +test-results +agent/reports +agent/state +docs +*.md +.git diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c55a304 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [feature/**, dan_branch, main] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm test + - run: npm run build + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + - name: Start preview + run: | + npx vite preview --host 127.0.0.1 --port 3101 & + for i in $(seq 1 30); do curl -sf http://127.0.0.1:3101/ && break; sleep 1; done + - name: E2E smoke + run: npm run test:e2e + env: + PLAYWRIGHT_BASE_URL: http://127.0.0.1:3101 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index d2e4dc4..83357fa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,11 +3,19 @@ dist/ *.tsbuildinfo vite.config.js vite.config.d.ts +playwright.config.js +playwright.config.d.ts .env .env.* npm-debug.log* input_info/extracted/ agent/state/ agent/reports/ +.agent/worktrees/ .demo-preview.pid .demo-preview.log +test-results/ +playwright-report/ +blob-report/ +releases/backup-*/ +scripts/.perf-tmp.cjs diff --git a/agent/cli.mjs b/agent/cli.mjs index 03517fe..ebc1660 100755 --- a/agent/cli.mjs +++ b/agent/cli.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node /** - * Autonomous improvement agent MVP — Orchestrator shell. + * Autonomous improvement agent — Implementer MVP. * - * Modes: dry-run | run-once | status | stop | resume | pause | report - * NEVER merges to main or deploys production. + * Modes: dry-run | run-once | implement | start | status | stop | resume | pause | report + * NEVER merges to main, NEVER deploys production, NEVER pushes. + * No external LLM required — deterministic local demo-task path. */ import { spawnSync } from 'node:child_process'; @@ -14,15 +15,22 @@ import { writeFileSync, appendFileSync, unlinkSync, + rmSync, + symlinkSync, + lstatSync, } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { cpus, freemem, totalmem, loadavg } from 'node:os'; +import { dirname, join, resolve, relative, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); const STATE_DIR = join(ROOT, 'agent', 'state'); const REPORTS_DIR = join(ROOT, 'agent', 'reports'); +const TASKS_DIR = join(ROOT, 'agent', 'tasks'); +const WORKTREES_DIR = join(ROOT, '.agent', 'worktrees'); const KILL_SWITCH = join(STATE_DIR, 'KILL'); +const LOCK_FILE = join(STATE_DIR, 'agent.lock'); const STATUS_FILE = join(STATE_DIR, 'status.json'); const AUDIT_LOG = join(STATE_DIR, 'audit.jsonl'); @@ -38,11 +46,53 @@ const LIMITS = { requireTestsPass: true, requireBuildPass: true, minScoreDelta: 0, + maxDiskUsagePercent: 85, + minMemAvailableGiB: 1, }; +/** Policy allowlist for Implementer patches (no LLM needed). */ +const POLICY_ALLOWLIST = [ + 'docs', + 'tests', + 'data-testid', + 'a11y', + 'small-ui', + 'logging-scripts', +]; + +const FORBIDDEN_PATH_PREFIXES = [ + '.env', + 'docker', + 'nginx', + 'deploy', + 'releases/', +]; + +const DEFAULT_TASK_PATH = join(TASKS_DIR, 'demo-docs.json'); + +const DEMO_DOCS_CONTENT = `# Agent Cycle Demo + +This file was created by the Implementer MVP as a **safe, docs-only** demonstration patch. + +- Worktree-isolated (never applied to production automatically) +- Policy category: \`docs\` +- No merge, no deploy, no push + +Generated at: {{TIMESTAMP}} +Run id: {{RUN_ID}} + +## Manual review + +1. Inspect the worktree and branch named in the cycle report. +2. If acceptable, copy or cherry-pick into your feature branch yourself. +3. Never let the agent merge to \`main\` or deploy. +`; + function ensureDirs() { mkdirSync(STATE_DIR, { recursive: true }); mkdirSync(REPORTS_DIR, { recursive: true }); + mkdirSync(TASKS_DIR, { recursive: true }); + mkdirSync(WORKTREES_DIR, { recursive: true }); } function nowIso() { @@ -50,10 +100,12 @@ function nowIso() { } function audit(event, payload = {}) { + ensureDirs(); appendFileSync(AUDIT_LOG, `${JSON.stringify({ ts: nowIso(), event, ...payload })}\n`); } function writeStatus(status) { + ensureDirs(); writeFileSync(STATUS_FILE, JSON.stringify({ ...status, updatedAt: nowIso() }, null, 2)); } @@ -66,28 +118,183 @@ function isKilled() { return existsSync(KILL_SWITCH); } -function run(cmd, args) { +function assertNotKilled() { + if (isKilled()) { + console.error('Kill switch active. Remove agent/state/KILL (or run: resume) to continue.'); + process.exit(2); + } +} + +function run(cmd, args, opts = {}) { + const cwd = opts.cwd ?? ROOT; + const timeout = opts.timeout ?? 15 * 60 * 1000; const result = spawnSync(cmd, args, { - cwd: ROOT, + cwd, encoding: 'utf8', - timeout: 15 * 60 * 1000, + timeout, env: { ...process.env, AGENT_MODE: '1' }, }); return { - code: result.status ?? 1, + code: result.status ?? (result.error ? 1 : 0), stdout: result.stdout ?? '', stderr: result.stderr ?? '', + error: result.error ? String(result.error.message || result.error) : null, + signal: result.signal ?? null, }; } -function collectBaseline() { - const tests = run('npm', ['test']); - const build = run('npm', ['run', 'build']); +function git(args, opts = {}) { + return run('git', args, opts); +} + +function slugify(value) { + return String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) || 'task'; +} + +/* ─── Watchdog ─────────────────────────────────────────────── */ + +function readMemAvailableBytes() { + try { + const meminfo = readFileSync('/proc/meminfo', 'utf8'); + const m = meminfo.match(/^MemAvailable:\s+(\d+)\s+kB/m); + if (m) return Number(m[1]) * 1024; + } catch { + /* fall through */ + } + return freemem(); +} + +function diskUsagePercent(mount = '/') { + const result = run('df', ['-P', mount]); + if (result.code !== 0) { + return { ok: false, reason: `df failed: ${result.stderr || result.error}` }; + } + const lines = result.stdout.trim().split('\n'); + const data = lines[lines.length - 1]?.trim().split(/\s+/); + // df -P: Filesystem 1024-blocks Used Available Capacity Mounted + const capacity = data?.[4]; + if (!capacity) return { ok: false, reason: 'could not parse df output' }; + const pct = Number(String(capacity).replace('%', '')); + if (Number.isNaN(pct)) return { ok: false, reason: `invalid capacity: ${capacity}` }; + return { ok: true, percent: pct, mount }; +} + +function watchdogChecks() { + const failures = []; + + if (isKilled()) { + failures.push('kill switch active (agent/state/KILL)'); + } + + const disk = diskUsagePercent('/'); + if (!disk.ok) { + failures.push(`disk check failed: ${disk.reason}`); + } else if (disk.percent > LIMITS.maxDiskUsagePercent) { + failures.push(`disk usage ${disk.percent}% > ${LIMITS.maxDiskUsagePercent}% on ${disk.mount}`); + } + + const nproc = Math.max(cpus().length, 1); + const load1 = loadavg()[0]; + if (load1 > nproc) { + failures.push(`loadavg 1m ${load1.toFixed(2)} > nproc ${nproc}`); + } + + const memAvail = readMemAvailableBytes(); + const minBytes = LIMITS.minMemAvailableGiB * 1024 ** 3; + if (memAvail < minBytes) { + const availGiB = (memAvail / 1024 ** 3).toFixed(2); + failures.push(`MemAvailable ${availGiB} GiB < ${LIMITS.minMemAvailableGiB} GiB`); + } + + return { + ok: failures.length === 0, + failures, + snapshot: { + diskPercent: disk.ok ? disk.percent : null, + load1, + nproc, + memAvailableBytes: memAvail, + memAvailableGiB: Number((memAvail / 1024 ** 3).toFixed(3)), + totalMemGiB: Number((totalmem() / 1024 ** 3).toFixed(3)), + killSwitch: isKilled(), + }, + }; +} + +function pidAlive(pid) { + if (!pid || !Number.isFinite(pid)) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function acquireLock(runId) { + ensureDirs(); + if (existsSync(LOCK_FILE)) { + let existing = null; + try { + existing = JSON.parse(readFileSync(LOCK_FILE, 'utf8')); + } catch { + existing = null; + } + if (existing?.pid && pidAlive(existing.pid)) { + console.error( + `[agent] refuse: another agent is running (pid ${existing.pid}, runId ${existing.runId ?? '?'}).`, + ); + console.error(`[agent] lock: ${LOCK_FILE}`); + process.exit(1); + } + console.warn('[agent] stale lock found — taking over'); + audit('lock_stale_takeover', { previous: existing }); + } + + const lock = { + pid: process.pid, + runId, + startedAt: nowIso(), + cwd: ROOT, + }; + writeFileSync(LOCK_FILE, JSON.stringify(lock, null, 2)); + audit('lock_acquired', lock); + return lock; +} + +function releaseLock() { + if (!existsSync(LOCK_FILE)) return; + try { + const existing = JSON.parse(readFileSync(LOCK_FILE, 'utf8')); + if (existing.pid && existing.pid !== process.pid && pidAlive(existing.pid)) { + console.warn('[agent] lock owned by another pid — not releasing'); + return; + } + } catch { + /* remove anyway if unreadable and ours */ + } + try { + unlinkSync(LOCK_FILE); + audit('lock_released', { pid: process.pid }); + } catch { + /* ignore */ + } +} + +/* ─── Baseline / scoring (shared with dry-run / run-once) ───── */ + +function collectBaseline(cwd = ROOT) { + const tests = run('npm', ['test'], { cwd }); + const build = run('npm', ['run', 'build'], { cwd }); return { testsPassed: tests.code === 0, buildPassed: build.code === 0, - testOutputTail: tests.stdout.split('\n').slice(-20).join('\n'), - buildOutputTail: build.stdout.split('\n').slice(-20).join('\n'), + testOutputTail: (tests.stdout || tests.stderr).split('\n').slice(-20).join('\n'), + buildOutputTail: (build.stdout || build.stderr).split('\n').slice(-20).join('\n'), }; } @@ -152,18 +359,261 @@ function scoreCategories(baseline, notes) { } function writeReport(runId, report) { + ensureDirs(); const path = join(REPORTS_DIR, `${runId}.json`); writeFileSync(path, JSON.stringify(report, null, 2)); writeFileSync(join(REPORTS_DIR, 'latest.json'), JSON.stringify(report, null, 2)); return path; } +/* ─── Task loading & policy ─────────────────────────────────── */ + +function loadTaskFile(taskPath) { + const abs = resolve(ROOT, taskPath); + if (!existsSync(abs)) { + throw new Error(`Task file not found: ${abs}`); + } + const task = JSON.parse(readFileSync(abs, 'utf8')); + if (!task.id || !task.title) { + throw new Error('Task file must include id and title'); + } + task.slug = task.slug || slugify(task.id); + task.policyCategory = task.policyCategory || task.category || 'docs'; + task._sourcePath = abs; + return task; +} + +function defaultDemoTask() { + if (existsSync(DEFAULT_TASK_PATH)) { + return loadTaskFile(DEFAULT_TASK_PATH); + } + return { + id: 'demo-docs', + title: 'Add agent cycle demo documentation', + slug: 'demo-docs', + policyCategory: 'docs', + description: 'Safe docs-only demo patch for Implementer MVP (no LLM).', + allowPaths: ['docs/AGENT_CYCLE_DEMO.md'], + patch: { + type: 'create-file', + path: 'docs/AGENT_CYCLE_DEMO.md', + contentTemplate: 'builtin-demo-docs', + }, + }; +} + +function validateTaskPolicy(task) { + const errors = []; + const category = task.policyCategory || task.category; + if (!POLICY_ALLOWLIST.includes(category)) { + errors.push( + `policy category "${category}" not in allowlist: ${POLICY_ALLOWLIST.join(', ')}`, + ); + } + + const paths = []; + if (Array.isArray(task.allowPaths)) paths.push(...task.allowPaths); + if (task.patch?.path) paths.push(task.patch.path); + if (Array.isArray(task.patch?.files)) { + for (const f of task.patch.files) { + if (f.path) paths.push(f.path); + } + } + + for (const p of paths) { + const norm = p.replace(/\\/g, '/'); + if (norm.includes('..')) { + errors.push(`path escapes not allowed: ${p}`); + continue; + } + for (const bad of FORBIDDEN_PATH_PREFIXES) { + if (norm === bad || norm.startsWith(bad) || basename(norm) === '.env') { + errors.push(`forbidden path: ${p}`); + } + } + if (category === 'docs' && !norm.startsWith('docs/')) { + errors.push(`docs policy requires path under docs/: ${p}`); + } + if (category === 'tests' && !(norm.startsWith('src/') || norm.includes('.test.') || norm.includes('.spec.') || norm.startsWith('tests/'))) { + errors.push(`tests policy path looks unsafe: ${p}`); + } + if (category === 'logging-scripts' && !norm.startsWith('scripts/')) { + errors.push(`logging-scripts policy requires scripts/: ${p}`); + } + } + + if (task.requiresLlm) { + errors.push('tasks requiring external LLM are not supported in this MVP'); + } + + return { ok: errors.length === 0, errors, category, paths }; +} + +function resolvePatchContent(task, runId) { + const patch = task.patch || {}; + if (patch.content) return patch.content; + if (patch.contentTemplate === 'builtin-demo-docs' || !patch.content) { + return DEMO_DOCS_CONTENT.replaceAll('{{TIMESTAMP}}', nowIso()).replaceAll( + '{{RUN_ID}}', + runId, + ); + } + return String(patch.content); +} + +/* ─── Worktree + patch ──────────────────────────────────────── */ + +function ensureNodeModulesLink(worktreePath) { + const target = join(worktreePath, 'node_modules'); + const source = join(ROOT, 'node_modules'); + if (!existsSync(source)) { + throw new Error('Root node_modules missing — run npm install in repo root first'); + } + if (existsSync(target)) { + try { + const st = lstatSync(target); + if (st.isSymbolicLink() || st.isDirectory()) return; + } catch { + /* recreate below */ + } + } + symlinkSync(source, target, 'dir'); +} + +function createWorktree(runId, taskSlug) { + mkdirSync(WORKTREES_DIR, { recursive: true }); + const worktreePath = join(WORKTREES_DIR, runId); + const branch = `agent/${runId}/${taskSlug}`; + + if (existsSync(worktreePath)) { + throw new Error(`Worktree path already exists: ${worktreePath}`); + } + + // Drop leftover branch name if present (safe local delete only). + const branchCheck = git(['rev-parse', '--verify', branch]); + if (branchCheck.code === 0) { + git(['branch', '-D', branch]); + } + + const add = git(['worktree', 'add', '-b', branch, worktreePath, 'HEAD']); + if (add.code !== 0) { + throw new Error(`git worktree add failed: ${add.stderr || add.stdout || add.error}`); + } + + ensureNodeModulesLink(worktreePath); + return { worktreePath, branch }; +} + +function applyBoundedPatch(worktreePath, task, runId) { + const patch = task.patch || { type: 'create-file', path: 'docs/AGENT_CYCLE_DEMO.md' }; + const type = patch.type || 'create-file'; + const changed = []; + + if (type === 'create-file' || type === 'write-file') { + const rel = patch.path || 'docs/AGENT_CYCLE_DEMO.md'; + const abs = join(worktreePath, rel); + mkdirSync(dirname(abs), { recursive: true }); + const content = resolvePatchContent(task, runId); + writeFileSync(abs, content); + changed.push(rel); + } else if (type === 'multi' && Array.isArray(patch.files)) { + for (const f of patch.files) { + const abs = join(worktreePath, f.path); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, f.content ?? ''); + changed.push(f.path); + } + } else { + throw new Error(`Unsupported patch type: ${type}`); + } + + const add = git(['add', '--', ...changed], { cwd: worktreePath }); + if (add.code !== 0) { + throw new Error(`git add failed: ${add.stderr || add.stdout}`); + } + + const commit = git( + ['commit', '-m', `agent(${runId}): ${task.id} — safe implementer demo`], + { cwd: worktreePath }, + ); + if (commit.code !== 0) { + throw new Error(`git commit failed: ${commit.stderr || commit.stdout}`); + } + + return changed; +} + +function measureDiff(worktreePath, baseRef = 'HEAD~1') { + const nameOnly = git(['diff', '--name-only', baseRef, 'HEAD'], { cwd: worktreePath }); + const files = nameOnly.stdout + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + + const numstat = git(['diff', '--numstat', baseRef, 'HEAD'], { cwd: worktreePath }); + let added = 0; + let deleted = 0; + for (const line of numstat.stdout.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length < 3) continue; + const a = parts[0] === '-' ? 0 : Number(parts[0]); + const d = parts[1] === '-' ? 0 : Number(parts[1]); + if (!Number.isNaN(a)) added += a; + if (!Number.isNaN(d)) deleted += d; + } + + return { + files, + fileCount: files.length, + diffLines: added + deleted, + added, + deleted, + }; +} + +function enforceDiffLimits(diff) { + const errors = []; + if (diff.fileCount > LIMITS.maxChangedFiles) { + errors.push(`changed files ${diff.fileCount} > max ${LIMITS.maxChangedFiles}`); + } + if (diff.diffLines > LIMITS.maxDiffLines) { + errors.push(`diff lines ${diff.diffLines} > max ${LIMITS.maxDiffLines}`); + } + return { ok: errors.length === 0, errors }; +} + +function removeWorktreeAndBranch(worktreePath, branch) { + if (worktreePath && existsSync(worktreePath)) { + const rm = git(['worktree', 'remove', '--force', worktreePath]); + if (rm.code !== 0) { + try { + rmSync(worktreePath, { recursive: true, force: true }); + git(['worktree', 'prune']); + } catch (e) { + console.warn(`[agent] worktree cleanup warning: ${e.message}`); + } + } + } + if (branch) { + git(['branch', '-D', branch]); + } +} + +function cycleTimedOut(startedAt) { + const elapsedMs = Date.now() - startedAt; + return elapsedMs > LIMITS.maxCycleMinutes * 60 * 1000; +} + +function remainingTimeoutMs(startedAt) { + const budget = LIMITS.maxCycleMinutes * 60 * 1000; + return Math.max(30_000, budget - (Date.now() - startedAt)); +} + +/* ─── Commands ──────────────────────────────────────────────── */ + function cmdDryRun() { ensureDirs(); - if (isKilled()) { - console.error('Kill switch active. Remove agent/state/KILL to continue.'); - process.exit(2); - } + assertNotKilled(); const runId = `dry-${Date.now()}`; writeStatus({ state: 'dry-run', runId }); audit('dry_run_start', { runId }); @@ -201,16 +651,13 @@ function cmdDryRun() { function cmdRunOnce() { ensureDirs(); - if (isKilled()) { - console.error('Kill switch active.'); - process.exit(2); - } + assertNotKilled(); const runId = `once-${Date.now()}`; writeStatus({ state: 'run-once', runId }); audit('run_once_start', { runId }); console.log(`[agent] run-once ${runId}`); - console.log('[agent] MVP policy: verify baseline only — no automatic code mutation.'); + console.log('[agent] verify-only: baseline tests/build — no patches.'); const baseline = collectBaseline(); const task = proposeTasks(baseline); @@ -228,10 +675,10 @@ function cmdRunOnce() { decision, reason: decision === 'ACCEPT_BASELINE' - ? 'Tests and build green; agent will not auto-patch in MVP (requires human Implementer).' - : 'Baseline red — agent refuses further patches until fixed.', + ? 'Tests and build green. Use `implement` for worktree-isolated safe patches.' + : 'Baseline red — agent refuses patches until fixed.', nextSafeActions: [ - 'Keep working on feature/maximum-demo-realism', + 'node agent/cli.mjs implement --task agent/tasks/demo-docs.json', 'Never merge to main automatically', 'Use agent dry-run before long autonomous sessions', ], @@ -245,9 +692,367 @@ function cmdRunOnce() { process.exit(decision === 'ACCEPT_BASELINE' ? 0 : 1); } +function parseImplementArgs(argv) { + let taskPath = null; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--task' && argv[i + 1]) { + taskPath = argv[i + 1]; + i++; + } + } + return { taskPath }; +} + +function cmdImplement(argv = []) { + ensureDirs(); + assertNotKilled(); + + const { taskPath } = parseImplementArgs(argv); + const runId = `impl-${Date.now()}`; + const startedAt = Date.now(); + let worktreePath = null; + let branch = null; + let reportPath = null; + + acquireLock(runId); + + const finish = (exitCode) => { + releaseLock(); + process.exit(exitCode); + }; + + try { + writeStatus({ state: 'implement', runId }); + audit('implement_start', { runId, taskPath }); + + console.log(`[agent] implement ${runId}`); + console.log('[agent] watchdog checks...'); + const watchdog = watchdogChecks(); + if (!watchdog.ok) { + const report = { + runId, + mode: 'implement', + decision: 'REJECT', + reason: 'watchdog failed', + watchdog, + limits: LIMITS, + safety: { merged: false, deployed: false, pushed: false }, + }; + reportPath = writeReport(runId, report); + writeStatus({ state: 'idle', lastRunId: runId, lastMode: 'implement', decision: 'REJECT' }); + console.error(`[agent] REJECT — watchdog: ${watchdog.failures.join('; ')}`); + console.error(`[agent] report: ${reportPath}`); + finish(1); + } + + // 1. Plan + console.log('[agent] plan...'); + const task = taskPath ? loadTaskFile(taskPath) : defaultDemoTask(); + console.log(`[agent] task: ${task.id} — ${task.title}`); + + if (cycleTimedOut(startedAt)) { + throw new Error(`cycle timeout before patch (${LIMITS.maxCycleMinutes} min)`); + } + + // 2. Validate policy + console.log('[agent] validate task policy...'); + const policy = validateTaskPolicy(task); + if (!policy.ok) { + const report = { + runId, + mode: 'implement', + task, + decision: 'REJECT', + reason: 'task policy validation failed', + policyErrors: policy.errors, + watchdog, + limits: LIMITS, + safety: { merged: false, deployed: false, pushed: false }, + }; + reportPath = writeReport(runId, report); + writeStatus({ state: 'idle', lastRunId: runId, lastMode: 'implement', decision: 'REJECT' }); + console.error(`[agent] REJECT — policy: ${policy.errors.join('; ')}`); + console.error(`[agent] report: ${reportPath}`); + finish(1); + } + + // 3. Worktree + console.log('[agent] create git worktree...'); + ({ worktreePath, branch } = createWorktree(runId, task.slug || slugify(task.id))); + console.log(`[agent] worktree: ${worktreePath}`); + console.log(`[agent] branch: ${branch}`); + + // 4. Bounded patch + console.log('[agent] apply bounded patch...'); + const changedFiles = applyBoundedPatch(worktreePath, task, runId); + const diff = measureDiff(worktreePath); + const limitCheck = enforceDiffLimits(diff); + if (!limitCheck.ok) { + removeWorktreeAndBranch(worktreePath, branch); + worktreePath = null; + branch = null; + const report = { + runId, + mode: 'implement', + task, + decision: 'REJECT', + reason: 'diff limits exceeded', + limitErrors: limitCheck.errors, + diff, + changedFiles, + watchdog, + limits: LIMITS, + safety: { merged: false, deployed: false, pushed: false }, + }; + reportPath = writeReport(runId, report); + writeStatus({ state: 'idle', lastRunId: runId, lastMode: 'implement', decision: 'REJECT' }); + console.error(`[agent] REJECT — limits: ${limitCheck.errors.join('; ')}`); + console.error(`[agent] report: ${reportPath}`); + finish(1); + } + + if (isKilled() || cycleTimedOut(startedAt)) { + removeWorktreeAndBranch(worktreePath, branch); + worktreePath = null; + branch = null; + const why = isKilled() ? 'kill switch' : 'cycle timeout'; + const report = { + runId, + mode: 'implement', + task, + decision: 'REJECT', + reason: why, + diff, + watchdog, + limits: LIMITS, + safety: { merged: false, deployed: false, pushed: false }, + }; + reportPath = writeReport(runId, report); + writeStatus({ state: 'idle', lastRunId: runId, lastMode: 'implement', decision: 'REJECT' }); + console.error(`[agent] REJECT — ${why}`); + finish(1); + } + + // 5. Tests + build in worktree + console.log('[agent] npm test && npm run build (worktree)...'); + const timeout = remainingTimeoutMs(startedAt); + const tests = run('npm', ['test'], { cwd: worktreePath, timeout }); + const buildTimeout = remainingTimeoutMs(startedAt); + const build = run('npm', ['run', 'build'], { cwd: worktreePath, timeout: buildTimeout }); + const verification = { + testsPassed: tests.code === 0, + buildPassed: build.code === 0, + testOutputTail: (tests.stdout || tests.stderr).split('\n').slice(-20).join('\n'), + buildOutputTail: (build.stdout || build.stderr).split('\n').slice(-20).join('\n'), + }; + + // 6. Score + decide + const score = scoreCategories( + verification, + 'implementer worktree candidate (deterministic, no LLM)', + ); + const gatesOk = + verification.testsPassed && + verification.buildPassed && + limitCheck.ok && + !isKilled() && + !cycleTimedOut(startedAt); + + const decision = gatesOk ? 'ACCEPT_CANDIDATE' : 'REJECT'; + let reason; + if (decision === 'ACCEPT_CANDIDATE') { + reason = + 'Tests and build passed in isolated worktree within limits. Candidate kept for manual review — NOT merged, NOT deployed, NOT pushed.'; + } else if (!verification.testsPassed || !verification.buildPassed) { + reason = 'tests or build failed in worktree'; + } else if (isKilled()) { + reason = 'kill switch during cycle'; + } else if (cycleTimedOut(startedAt)) { + reason = 'cycle timeout'; + } else { + reason = 'gates failed'; + } + + if (decision === 'REJECT') { + console.log('[agent] REJECT — removing worktree and branch...'); + removeWorktreeAndBranch(worktreePath, branch); + const removedBranch = branch; + const removedWt = worktreePath; + worktreePath = null; + branch = null; + + const report = { + runId, + mode: 'implement', + task, + policy, + diff, + changedFiles, + verification, + score, + decision, + reason, + watchdog, + limits: LIMITS, + cleanup: { worktreeRemoved: removedWt, branchDeleted: removedBranch }, + safety: { + merged: false, + deployed: false, + pushed: false, + wouldTouchProduction: false, + }, + elapsedMs: Date.now() - startedAt, + }; + reportPath = writeReport(runId, report); + writeStatus({ + state: 'idle', + lastRunId: runId, + lastMode: 'implement', + decision, + }); + audit('implement_complete', { runId, decision, reportPath }); + console.error(`[agent] decision: ${decision}`); + console.error(`[agent] reason: ${reason}`); + console.error(`[agent] report: ${reportPath}`); + finish(1); + } + + // ACCEPT — keep worktree + const manualReview = { + instructions: [ + `Inspect worktree: ${worktreePath}`, + `Inspect branch: ${branch}`, + `Review report: agent/reports/${runId}.json`, + 'Diff: git -C show HEAD', + 'If good: cherry-pick or merge the agent/* branch yourself into your feature branch.', + 'NEVER auto-merge to main. NEVER deploy. NEVER push from the agent.', + 'To discard: git worktree remove --force && git branch -D ', + ], + worktreePath, + branch, + relativeWorktree: relative(ROOT, worktreePath), + }; + + const report = { + runId, + mode: 'implement', + task, + policy, + diff, + changedFiles, + verification, + score, + decision, + reason, + watchdog, + limits: LIMITS, + worktreePath, + branch, + manualReview, + safety: { + merged: false, + deployed: false, + pushed: false, + wouldTouchProduction: false, + wouldMergeMain: false, + }, + elapsedMs: Date.now() - startedAt, + }; + reportPath = writeReport(runId, report); + writeStatus({ + state: 'idle', + lastRunId: runId, + lastMode: 'implement', + decision, + worktreePath, + branch, + reportPath, + }); + audit('implement_complete', { runId, decision, reportPath, branch }); + + console.log(`[agent] decision: ${decision}`); + console.log(`[agent] score: ${score.total}/100`); + console.log(`[agent] worktree: ${worktreePath}`); + console.log(`[agent] branch: ${branch}`); + console.log(`[agent] report: ${reportPath}`); + console.log('[agent] manual review required — agent will NOT merge/deploy/push'); + for (const line of manualReview.instructions) { + console.log(` → ${line}`); + } + finish(0); + } catch (err) { + const message = err?.message || String(err); + console.error(`[agent] implement error: ${message}`); + if (worktreePath || branch) { + try { + removeWorktreeAndBranch(worktreePath, branch); + } catch (cleanupErr) { + console.warn(`[agent] cleanup failed: ${cleanupErr.message}`); + } + } + const report = { + runId, + mode: 'implement', + decision: 'REJECT', + reason: message, + limits: LIMITS, + safety: { merged: false, deployed: false, pushed: false }, + elapsedMs: Date.now() - startedAt, + }; + try { + reportPath = writeReport(runId, report); + console.error(`[agent] report: ${reportPath}`); + } catch { + /* ignore */ + } + writeStatus({ state: 'idle', lastRunId: runId, lastMode: 'implement', decision: 'REJECT' }); + audit('implement_error', { runId, message }); + finish(1); + } +} + +function cmdStart() { + ensureDirs(); + if (process.env.AUTONOMOUS_AGENT_ENABLED !== 'true') { + console.error( + '[agent] start refused: set AUTONOMOUS_AGENT_ENABLED=true to enable (default off).', + ); + console.error('[agent] safer entrypoints: dry-run | run-once | implement'); + process.exit(1); + } + assertNotKilled(); + audit('start_requested', { pid: process.pid }); + console.log('[agent] start enabled via AUTONOMOUS_AGENT_ENABLED=true'); + console.log('[agent] running one implement cycle (MVP; no continuous loop flood)'); + cmdImplement(process.argv.slice(3)); +} + function cmdStatus() { ensureDirs(); - console.log(JSON.stringify({ ...readStatus(), killSwitch: isKilled(), limits: LIMITS }, null, 2)); + let lock = null; + if (existsSync(LOCK_FILE)) { + try { + lock = JSON.parse(readFileSync(LOCK_FILE, 'utf8')); + lock.alive = pidAlive(lock.pid); + } catch { + lock = { unreadable: true }; + } + } + const watchdog = watchdogChecks(); + console.log( + JSON.stringify( + { + ...readStatus(), + killSwitch: isKilled(), + lock, + watchdog, + limits: LIMITS, + policyAllowlist: POLICY_ALLOWLIST, + }, + null, + 2, + ), + ); } function cmdStop() { @@ -282,6 +1087,25 @@ function cmdReport() { console.log(readFileSync(latest, 'utf8')); } +function usage() { + console.log(`Usage: node agent/cli.mjs [options] + +Commands: + dry-run Plan only (baseline + propose task) + run-once Verify baseline only (no patches) + implement [--task ] + Worktree-isolated Implementer cycle (deterministic demo if no LLM) + start Requires AUTONOMOUS_AGENT_ENABLED=true; runs implement once + status Status + lock + watchdog snapshot + stop Set kill switch (agent/state/KILL) + resume Clear kill switch + pause Mark status paused + report Print latest report JSON + +Safety: NEVER merges to main, NEVER deploys, NEVER pushes. +`); +} + const cmd = process.argv[2] ?? 'status'; switch (cmd) { case 'dry-run': @@ -290,6 +1114,12 @@ switch (cmd) { case 'run-once': cmdRunOnce(); break; + case 'implement': + cmdImplement(process.argv.slice(3)); + break; + case 'start': + cmdStart(); + break; case 'status': cmdStatus(); break; @@ -305,11 +1135,12 @@ switch (cmd) { case 'report': cmdReport(); break; - case 'start': - console.log('[agent] continuous start disabled in MVP. Use: dry-run | run-once'); - process.exit(1); + case 'help': + case '--help': + case '-h': + usage(); break; default: - console.log('Usage: node agent/cli.mjs '); + usage(); process.exit(1); } diff --git a/agent/tasks/demo-docs.json b/agent/tasks/demo-docs.json new file mode 100644 index 0000000..23e1563 --- /dev/null +++ b/agent/tasks/demo-docs.json @@ -0,0 +1,14 @@ +{ + "id": "demo-docs", + "title": "Add agent cycle demo documentation", + "slug": "demo-docs", + "policyCategory": "docs", + "description": "Safe docs-only demo patch for the Implementer MVP. No LLM required. Creates docs/AGENT_CYCLE_DEMO.md inside an isolated git worktree.", + "allowPaths": ["docs/AGENT_CYCLE_DEMO.md"], + "requiresLlm": false, + "patch": { + "type": "create-file", + "path": "docs/AGENT_CYCLE_DEMO.md", + "contentTemplate": "builtin-demo-docs" + } +} diff --git a/docs/AGENT_IMPLEMENTER_MVP.md b/docs/AGENT_IMPLEMENTER_MVP.md new file mode 100644 index 0000000..6a73ad4 --- /dev/null +++ b/docs/AGENT_IMPLEMENTER_MVP.md @@ -0,0 +1,124 @@ +# Implementer MVP (worktree-isolated) + +**Status:** Implemented in `agent/cli.mjs` +**Date:** 2026-07-15 + +Safe, deterministic Implementer cycle that **does not** require an external LLM. +Patches run only inside a git worktree. The agent **never** merges to `main`, **never** deploys, and **never** pushes. + +--- + +## Commands + +| Command | Purpose | +| ------- | ------- | +| `dry-run` | Plan only (baseline + propose) | +| `run-once` | Verify baseline only (no patches) | +| `implement [--task ]` | Full Implementer cycle | +| `start` | Same as implement, but requires `AUTONOMOUS_AGENT_ENABLED=true` | +| `status` / `stop` / `resume` / `pause` / `report` | Control plane | + +```bash +# Recommended successful demo cycle +node agent/cli.mjs implement --task agent/tasks/demo-docs.json + +# Or via helper script +bash scripts/agent-implement.sh --task agent/tasks/demo-docs.json + +# Gated continuous entry (still one cycle in MVP) +AUTONOMOUS_AGENT_ENABLED=true node agent/cli.mjs start --task agent/tasks/demo-docs.json +``` + +--- + +## Implementer cycle + +1. **Watchdog** — kill switch, single lock, disk, load, memory +2. **Plan** — load task JSON (default: `agent/tasks/demo-docs.json`) +3. **Validate policy** — allowlist only: `docs`, `tests`, `data-testid`, `a11y`, `small-ui`, `logging-scripts` +4. **Create worktree** — `.agent/worktrees/` on branch `agent//` +5. **Apply bounded patch** — e.g. create `docs/AGENT_CYCLE_DEMO.md` +6. **Enforce limits** — ≤12 files, ≤800 diff lines, ≤25 min cycle +7. **Verify** — `npm test && npm run build` inside the worktree +8. **Score + decide** — `ACCEPT_CANDIDATE` or `REJECT` +9. **Report** — `agent/reports/.json` (+ `latest.json`) + +### On REJECT + +- Remove worktree +- Delete local agent branch +- Keep the JSON report + +### On ACCEPT_CANDIDATE + +- Keep worktree + branch +- Print manual review instructions +- Human must cherry-pick / merge into a feature branch if desired + +--- + +## Watchdog + +| Check | Fail condition | +| ----- | -------------- | +| Lock | `agent/state/agent.lock` held by a live PID (second agent refused) | +| Kill switch | `agent/state/KILL` present | +| Disk | `df /` usage **> 85%** | +| Load | loadavg 1m **> nproc** | +| Memory | MemAvailable **< 1 GiB** | + +```bash +node agent/cli.mjs stop # write KILL +node agent/cli.mjs resume # clear KILL +node agent/cli.mjs status # lock + watchdog snapshot +``` + +--- + +## Example task + +`agent/tasks/demo-docs.json` — docs-only safe demo: + +- Policy: `docs` +- Patch: create `docs/AGENT_CYCLE_DEMO.md` +- No source / physics / deploy / `.env` changes + +--- + +## Limits (also in `LIMITS` in `cli.mjs`) + +| Limit | Value | +| ----- | ----: | +| max cycle | 25 min | +| max files | 12 | +| max diff lines | 800 | +| parallel agents | 1 | + +Hard forbids: merge to main, production deploy, push, secret/`.env` access. + +--- + +## Manual review after ACCEPT + +```bash +# Inspect +cat agent/reports/latest.json +git -C .agent/worktrees/ show HEAD + +# Accept into your feature branch yourself (example) +git cherry-pick + +# Or discard +git worktree remove --force .agent/worktrees/ +git branch -D agent//demo-docs +``` + +--- + +## What this MVP does **not** do + +- Call external LLMs +- Modify production nginx/docker/deploy +- Touch `.env` +- Auto-merge or auto-deploy +- Change demo physics (prefer docs-only demo task) diff --git a/docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md b/docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md new file mode 100644 index 0000000..c31009a --- /dev/null +++ b/docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md @@ -0,0 +1,35 @@ +# PERFORMANCE_AFTER_MAXIMUM_DEMO + +Measured: 2026-07-15T17:11:06.031Z +Commit: `985f7c3` + +## Bundle (measured) + +| Метрика | Значение | +| ------- | -------: | +| Total JS | 1300.9 KB | +| Total JS gzip | 358.1 KB | +| Main chunk | index-ncgt6PBL.js (96.4 KB gzip) | +| R3F chunk gzip | 226.1 KB | + +## Runtime (Playwright Chromium headless) + +| Метрика | До | После | Цель | Статус | +| ------- | -: | ----: | ---: | ------ | +| Average FPS | n/a (не измерялось) | 9.6 | ≥30 | CHECK | +| Minimum FPS | n/a | 3.3 | ≥20 | CHECK | +| p95 frame time | n/a | 200.1 ms | ≤33 ms | CHECK | +| Load time | n/a | 1008 ms | ≤5000 | OK | +| JS heap start | n/a | 13400000 | — | measured | +| JS heap after interaction | n/a | 13400000 | no runaway | measured | +| Canvas present | — | 1 | ≥1 | OK | +| JS gzip (main+vendor) | ~335 KB main previously | 358.1 KB | — | measured | + +## Notes + +- **Headless Chromium WebGL is not a GPU FPS measurement.** Values ~9 FPS / p95 ~200 ms reflect software/SwiftShader-like rendering in CI/headless, **not** desktop Chrome with NVIDIA. Do not claim 30–60 FPS from this pass. +- For jury/demo FPS, measure in headed Chromium on the presentation machine (`npm run test:e2e:headed` + DevTools Performance) or Engineering Details FPS meter. +- Draw calls / triangles require WebGL inspector in headed mode; not available in this headless pass. +- Demo quality mode keeps `shadows=false` and `effectsEnabled=false` for stable real-device FPS (see `qualityMode.ts`). High mode may enable shadows. +- Raw JSON: `agent/reports/perf-metrics.json` +- Runtime error (if any): none diff --git a/docs/PLAYWRIGHT_E2E_REPORT.md b/docs/PLAYWRIGHT_E2E_REPORT.md new file mode 100644 index 0000000..e418402 --- /dev/null +++ b/docs/PLAYWRIGHT_E2E_REPORT.md @@ -0,0 +1,95 @@ +# Playwright E2E + Visual Regression + +Lightweight end-to-end and visual regression coverage for the continuous demo on `/` and the SPA route `/details`. + +## Prerequisites + +- Node.js with project deps installed (`npm install`) +- Chromium for Playwright: `npx playwright install chromium` +- App reachable at the base URL (default preview: `http://127.0.0.1:3101`) + +By default tests **do not** start a server. Start preview yourself, for example: + +```bash +npm run build && npx vite preview --host 127.0.0.1 --port 3101 +``` + +Or point at an already-running preview/prod instance via `PLAYWRIGHT_BASE_URL`. + +## Scripts + +| Script | Command | Purpose | +|--------|---------|---------| +| `test:e2e` | `playwright test` | Run all e2e + visual tests | +| `test:e2e:headed` | `playwright test --headed` | Same, headed browser | +| `test:e2e:update` | `playwright test --update-snapshots` | Refresh visual baselines | + +## Environment + +| Variable | Default | Meaning | +|----------|---------|---------| +| `PLAYWRIGHT_BASE_URL` | `http://127.0.0.1:3101` | Target app origin | +| `PLAYWRIGHT_START_SERVER` | unset | Set to `1` to let Playwright run `vite preview` on port 3101 | + +Examples: + +```bash +# Against local preview (already running) +PLAYWRIGHT_BASE_URL=http://127.0.0.1:3101 npm run test:e2e + +# Against prod-style port +PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 npm run test:e2e + +# Auto-start preview if nothing is listening +PLAYWRIGHT_START_SERVER=1 npm run test:e2e +``` + +## Config highlights + +- **workers: 1** — keep e2e light on shared servers +- **retries: 1** +- **chromium only** +- Screenshot on failure; trace on first retry +- Soft visual thresholds (`threshold: 0.3`, `maxDiffPixelRatio: 0.05`) + +## Test map + +| Spec | Coverage | +|------|----------| +| `e2e/smoke.spec.ts` | `/` loads, canvas (or loading→canvas), no `pageerror`, play works, replay if finished | +| `e2e/controls.spec.ts` | next/prev seek, jump case 0 & 8, speed buttons, presentation toggle | +| `e2e/safety.spec.ts` | jam (index 8) → FAULT; emergency (index 9) → EMERGENCY | +| `e2e/routes.spec.ts` | `/details`, refresh stays on details, navigate back to `/` | +| `e2e/visual.spec.ts` | Idle HUD panel snapshot only (not full WebGL canvas) | + +Stable selectors live on `MainPage` as `data-testid` values (`demo-play`, `demo-hud`, `demo-case-{n}`, etc.). + +## Updating visual snapshots + +1. Ensure the app at `PLAYWRIGHT_BASE_URL` matches the UI you want to lock. +2. Run: + +```bash +npm run test:e2e:update +``` + +3. Review diffs under `e2e/visual.spec.ts-snapshots/` (and commit those PNGs when intentional). +4. Re-run `npm run test:e2e` to confirm green. + +Snapshots intentionally target **`[data-testid="demo-hud"]`** so WebGL canvas noise does not fail CI. + +## System deps + +On a fresh Linux host, Chromium may need OS libraries: + +```bash +npx playwright install chromium +npx playwright install-deps chromium +``` + +## Artifacts (gitignored) + +- `test-results/` — failure screenshots / traces +- `playwright-report/` — HTML report +- `blob-report/` — blob reporter output +`} \ No newline at end of file diff --git a/docs/PRODUCTION_DEPLOYMENT_REPORT.md b/docs/PRODUCTION_DEPLOYMENT_REPORT.md new file mode 100644 index 0000000..1e9f136 --- /dev/null +++ b/docs/PRODUCTION_DEPLOYMENT_REPORT.md @@ -0,0 +1,64 @@ +# PRODUCTION_DEPLOYMENT_REPORT + +Дата: 2026-07-15 +Ветка: `feature/maximum-demo-realism` +Базовый commit этапа 1: `985f7c3` +Production bundle после этапа 2: `index-ncgt6PBL.js` + +## Способ деплоя + +Docker-контейнер `owl-web-1` (image `owl-web:`), порт `127.0.0.1:3100→80`. +Docker API: `DOCKER_HOST=tcp://127.0.0.1:2375` (из coder-контейнера). +Compose-файл: `docker-compose.server.yml` (исторически `-p owl`). +Скрипт: `scripts/deploy-production.sh`. + +Публичный доступ в этой среде: + +- Loopback: `http://127.0.0.1:3100/` +- Quick tunnel cloudflared: `https://invitations-based-characters-accent.trycloudflare.com/` +- `https://arhipovdan.ru/` из контейнера даёт TLS SNI / openresty 404 — DNS/прокси домена вне текущего coder-окружения; проверяйте снаружи. + +## Releases + +| Поле | Значение | +| ---- | -------- | +| Old release container | `owl-web-1-backup-20260715-165347` / `…-1712` | +| Old bundle | `index-CCdZzxPJ.js` | +| New release | `20260715-1712` | +| New image | `owl-web:20260715-1712` | +| New bundle | `index-ncgt6PBL.js` | +| Backup HTML | `releases/backup-pre-*` | + +## Команды + +```bash +export DOCKER_HOST=tcp://127.0.0.1:2375 +cd /home/coder/arhipovdan/app +npm test && npm run build +bash scripts/deploy-production.sh 20260715-1712 +curl -s http://127.0.0.1:3100/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js' +``` + +## Healthcheck + +| Check | Result | +| ----- | ------ | +| `GET /` | 200 | +| `GET /details` (SPA) | 200 | +| Bundle | `index-ncgt6PBL.js` | +| Tunnel HTML | same bundle | + +## Rollback + +```bash +export DOCKER_HOST=tcp://127.0.0.1:2375 +docker stop owl-web-1 && docker rm owl-web-1 +docker rename owl-web-1-backup-20260715-1712 owl-web-1 +docker start owl-web-1 +``` + +Или восстановить HTML из `releases/backup-pre-/`. + +## Smoke (tunnel) + +Подтверждено: CASE 1/10, Jump to case 1–10, Previous/Next, Present, Play Demo. diff --git a/e2e/controls.spec.ts b/e2e/controls.spec.ts new file mode 100644 index 0000000..120ee2a --- /dev/null +++ b/e2e/controls.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; + +test.describe('controls', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId('demo-hud')).toBeVisible(); + }); + + test('seek next/prev and jump cases, speed, presentation', async ({ page }) => { + await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); + + await page.getByTestId('demo-next').click(); + await expect(page.getByTestId('demo-case-label')).toHaveText('2/10'); + + await page.getByTestId('demo-prev').click(); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); + + await page.getByTestId('demo-case-8').click(); + await expect(page.getByTestId('demo-case-label')).toHaveText('9/10'); + + await page.getByTestId('demo-case-0').click(); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); + + for (const speed of ['0.5', '1', '1.5', '2'] as const) { + await page.getByTestId(`demo-speed-${speed}`).click(); + await expect(page.getByTestId(`demo-speed-${speed}`)).toHaveClass(/active/); + } + + await page.getByTestId('demo-presentation').click(); + await expect(page.getByTestId('demo-hud')).toBeHidden(); + await expect(page.locator('.presentation-exit')).toBeVisible(); + + await page.locator('.presentation-exit').click(); + await expect(page.getByTestId('demo-hud')).toBeVisible(); + await expect(page.getByTestId('demo-presentation')).toBeVisible(); + }); +}); diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts new file mode 100644 index 0000000..fe92f28 --- /dev/null +++ b/e2e/routes.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; + +test.describe('routes', () => { + test('/details opens, refresh stays, back to home', async ({ page }) => { + await page.goto('/details'); + await expect(page).toHaveURL(/\/details\/?$/); + await expect(page.getByRole('link', { name: /back to full-screen demo/i })).toBeVisible({ + timeout: 30_000, + }); + + await page.reload(); + await expect(page).toHaveURL(/\/details\/?$/); + await expect(page.getByRole('link', { name: /back to full-screen demo/i })).toBeVisible(); + + await page.getByRole('link', { name: /back to full-screen demo/i }).click(); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByTestId('demo-hud')).toBeVisible({ timeout: 60_000 }); + }); +}); diff --git a/e2e/safety.spec.ts b/e2e/safety.spec.ts new file mode 100644 index 0000000..50a96f1 --- /dev/null +++ b/e2e/safety.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test'; + +test.describe('safety', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId('demo-hud')).toBeVisible(); + // Speed up to reach fault phases sooner + await page.getByTestId('demo-speed-2').click(); + }); + + test('jam case shows FAULT in status or command', async ({ page }) => { + await page.getByTestId('demo-case-8').click(); + await expect(page.getByTestId('demo-case-label')).toHaveText('9/10'); + + await expect + .poll( + async () => { + const status = (await page.getByTestId('demo-status').textContent()) ?? ''; + const command = (await page.getByTestId('demo-command').textContent()) ?? ''; + return `${status} ${command}`; + }, + { timeout: 25_000 }, + ) + .toMatch(/FAULT/i); + }); + + test('emergency case shows EMERGENCY in status or command', async ({ page }) => { + await page.getByTestId('demo-case-9').click(); + await expect(page.getByTestId('demo-case-label')).toHaveText('10/10'); + + await expect + .poll( + async () => { + const status = (await page.getByTestId('demo-status').textContent()) ?? ''; + const command = (await page.getByTestId('demo-command').textContent()) ?? ''; + return `${status} ${command}`; + }, + { timeout: 25_000 }, + ) + .toMatch(/EMERGENCY/i); + }); +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..9a32e9f --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +test.describe('smoke', () => { + test('home opens, canvas loads, play works', async ({ page }) => { + const pageErrors: Error[] = []; + page.on('pageerror', (err) => pageErrors.push(err)); + + await page.goto('/'); + + const loading = page.locator('.three-loading'); + const canvas = page.locator('canvas'); + await expect(loading.or(canvas).first()).toBeVisible({ timeout: 30_000 }); + await expect(canvas).toBeVisible({ timeout: 60_000 }); + + await expect(page.getByTestId('demo-hud')).toBeVisible(); + await expect(page.getByTestId('demo-play')).toBeVisible(); + + await page.getByTestId('demo-play').click(); + await expect(page.getByTestId('demo-pause')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('demo-status')).not.toHaveText('FINISHED'); + + const finished = page.getByTestId('demo-finished'); + if (await finished.isVisible().catch(() => false)) { + await page.getByTestId('demo-play').click(); + await expect(finished).toBeHidden({ timeout: 10_000 }); + await expect(page.getByTestId('demo-pause')).toBeVisible({ timeout: 10_000 }); + } + + expect(pageErrors, `pageerrors: ${pageErrors.map((e) => e.message).join('; ')}`).toEqual([]); + }); +}); diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts new file mode 100644 index 0000000..f9561e6 --- /dev/null +++ b/e2e/visual.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test'; + +test.describe('visual regression', () => { + test('home idle HUD panel', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); + + const hud = page.getByTestId('demo-hud'); + await expect(hud).toBeVisible(); + // Wait for idle classification proof row / stable HUD text + await expect(page.getByTestId('demo-command')).toHaveText('IDLE'); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); + + // Snapshot HUD only — avoids flaky WebGL canvas pixels + await expect(hud).toHaveScreenshot('home-idle-hud.png', { + threshold: 0.3, + maxDiffPixelRatio: 0.05, + }); + }); +}); diff --git a/e2e/visual.spec.ts-snapshots/home-idle-hud-chromium-linux.png b/e2e/visual.spec.ts-snapshots/home-idle-hud-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..a81f628d00951108a9cf19687222b2764f3c945b GIT binary patch literal 12586 zcma*OWmFu&*X|qK-CcqNf(Li^;4Z;^aQEQu!94^E?yiGtaCdiyK?ctJ&$;hi_rtsH zc|S}~tzN6EyJ~gqy`SGx9i^fqgN96u{OQvtG&xyGHOT)6+9*0^L?7`Dt&(=t=YN0ykoT6rsKqa zZYhfFX%nTHJ;TO2X8L$4w@v-sbt*U0^@`TUO?m>o z$)l{b@>Eh+%Bn_(QTzBF9yO>HF;{xWSt#Vq@34f{syI?G1NBuf6sbR%@z<8aR?A(l zYLT=F(V__*j3VqrFcl_2oiS{zWKfi7amohY*XUbEqq>n8AbI`~he@um5qQiu-xl5n zQ4cO$ls6)5)cFa?>t)B+RSm~J%B;U$hsnr(1$=O5k1vPO#~Dk7IoBut8`}%&X}!@B zcsd9R4W+hz4Tqqd>euGoVNrlmj_i#06Cf&R1bFejx|#Ee*3_}!dZ_V#gD#1TScvK3 zvyLq%QPdT}nnC)F3A)Sslc5QPjxq z?uWE#BuTjG$$bBo4lZyg6=mKzj%6Hw2d*|Gniv4ttqBb3B@u6!AWfw6I+GM4j|SUOp;H!D~B^ zyr!yrwrpW99Uqsa`|i?r?wjj8@x#wn47EYBpQJ@V;Nlnem?AX~vIAk?Iecf7J52eRv)_w1IB}c|WZ0~3fZ(JRXu8okkT7T%3 zICJPW#3Wi+SYiLOpx8|RC}n^Z9Fg9DA0tbHw5M(&U8MT-#4g$&HIIJgSWIY^Cfv@ zVCTaRF%wlC^MR<(2!mZhd+D2y2dck241l~x2#*)<@hNSj0G;-mlqq_8##I8Ez@&Z0f$Oy6pJkgxDxY zXWjE{gsM$(b!&+P52tj&+H3QzY`GQV16(3qb$WIZaQp!_8J-r|sNcD~oQn4sXbB0s zHm<98J>uqG{5Uy@qb-dw!Pw#U+Fc>=Y43P@gO*oN$`|p}s#43EIE>99T?Uy2dY=wE zc2`$aIoR8bjpoV}%?Sy0oyUOAP-+^vd4SQi+6A&E8mRjTcu~SHPiszmtNPhT1k5(} z049TWKz|-?zM!R%q`CwcPx*n9X%WxF+vgcB0yuELy^O7Y`WKd+I-~I0MxV>cJwNng zMb|z(CZg-nWP-A+&q0`#{a@&YkK_FO^#=R1K;aLs=PdV?wmZr?>zn%G3hkbc8lH}J zeu|Gdbq(U&y;1-7g~0wva1D>q-4jadYoQFKzMG%QTSTMcWk0nje| z>mUs_(k1AjWwY<;hMDy%*3giqcG{i->%2zYGJp*m3riKxqS!1d70)CoDLLp}!uLt= zW9*EH+iEZ7;|nWukX_*Y_4~I$`fC7XZdwT)Z_dx31@*Jvyi5)4zng0`0;#)S&T&*2 z*9mLsRoU4ES{%cHA5YmednhdO3JNLUJYFVf*LYnV{OrjGB*dVolS>DMa|g%6 zTQCD>IHlhFrbYz#@6R{}W{D*xbp;|)`~uXEF4+O(&I7|J5v$E z$)wI1iA^~~9xa!h!)5e&?2j71&L{p20I9n7hp0%HVi*w{-WreN%XN(7bZX9KkJS;# z9-l=IACIiV^$b6;BYUlQ9H9j3^apy(@hRY=N88Bfv@N|KHtECfA?#*BUS(k@{r!Ex ziXv$K{As`hE5mgntG0bLn^B1($flsi*Py<*tBsqQpGpUF?$Hhfg>V)P3=XquxI66Y ziYfJSX`|%w`@u*ST9Ghakt**8Z$OnP36FLXK6F~;-zB6$jCN_sky%MrbqAf?Cx#uy zQP|qOS*^Socr+NNoR-7zBXErj#*{sM97qt90HgX4^GXu}v*~9fI1@9TA`f4yx|e07 z;|AImX7k#sIS79xA2X`G9<<6=tH4$H&Pj+`fa-@810*6n_tb%%2szFsKJ9!K4tN)9 zt_lkc?*Xln_%=HV)p{-+Qz`x@To_O7j4h!RDb+MJ^V2aHQeCvfX4D$t^?$jrXLjs- znPv9eM0=o@t*ohvN}xa~i;GjXwzC;JN|`$}E-DqKVd^aGZoU+Em(A$i01_|ersb`y zY{EYY32M;&`Lq3Ow!5pJO+1|buxVwq(~SI4F=HD~Aw-y-*n8+DKDK*Mp_g@v7QO@S z@LZYLzg@7K&SePC`~Z)CBVhb0;{Rb3@pR>Hy0;}tjau?Ll>Q-BG|MggjG%&-M*+Bq zZaOo8Kb;SgQc$K4IINBAmr^L&+uiiLIC$Ff-b+vh)m*V0gPoU9Lj6@s)-6_uMg-c1mDw3i(z2& zx&1Ltz3;k|w=L>stlwTyNNxm?q8P48$?4_ao`)%LU5FId0#@(GKDvY|mr@vhVQdA! z+yXY%`AOKWW*AL+gAs;n)xmSiSBsvR{3?r=Ykv zxy(sKBel@U_(YyGL?6u3XBd3THXw!g-mlxB(_^y@D!U^vyP zIIfM0$wDmlj5*#bWaifvzqyrJN?h7${dR|)ziV}Jt7{Ym2+0jcW;~?v)2_-^B+IYe)mm}#P=AAor3|HPR9Z1@aUC3BF=01y zrD#+IySzRhVsV6#V=^5(G;rHqIM}OwE#CfO-8k-J7O{FU>Mzg<3bR4K5T}N$2&tdDJ>M=m{CK(>5FSTPxE~X0s0E)pSuQA-0yo6RBdc?%RjWG zpz~PQ#V+fKw6|g1Ff!(zI1rMD^9v|$<7#a_iVkM(_4w4ju|@&!P*0khd3?Z_QL?d- za=|jchfeQiyEie#8cC9t*A|GeQrIfz3oAo-fvF4nyyAHshP89;Gwdc5z6T8QFYQ;?5i# z&3$4Yz>ali{}^dFUaqIUJ8_wuIyky)lJMKr!N2-uo&9yeXDL-XaWGd?FlUe3AFFb1ErNV)MKq#-w=@@^_;52-&$7 ze1#8B{=(@LbBLv`EQ8WHU}N`JVNzGrtQ|*AGO9H~@%Q~xBMzd2kYSaIM6jNow*K5a z+=F}5aIs~{KGP>pDx%}hNtN=680h;f25?ug{Q4xdsv?NO=EuL`P5`#J(aBl1Q5{+XBAQ9bI0lb9$g}sjaISDVT(*xx+e7^?m z|5dUTmWC-*L9}1||F02h)9D?docx=QZ_`htNnTgiMO=h)5lj~|i4qrI!7s2FRp1}m zAO1OCG=Qx&|XDIAhkB*gDR#pD+%{ZHO3&c-J*Qg zPLQ|4qV4=x^<&8hX#e`S&Os0cNX-@umryyK(L=+{K4)5&IQnZ~qOLnyy&m~q&kDv0 zt9nQ`=75B7%-s9(ZAZOfba_*wW51+B3<|lND{_p7@=r-6ArrEJ`-Ez!K-dsl_V>Mz zT_`cP;6Z?J{h7=gYsjQ&YeSQ*l$!L9hc#CX|Hao&bc{(7EDF;yz zj+))qX^kAd?%PL%)2|ABovJ7Ic~h3C&i31G1r*aQH5HS7bz5K|k$|WQ-dvo`y&WyiMY3Vx0{Dhno*CG$)Pp-Vp|+l?xZ|RRL4YBn%Ou>`R9bFxm7uA zA1CNwK>zsK!fe`5ObFK)yyy>sfojzkb z%LQ>d1EmXRC+O@tP0bBBHaA?pubgbgTXKX!yVO-#a~?xSuDSR=5fSi7o8Ng+T8RCG|5DY7?XpDLs|9T zC_g)mA)R<1Ix}iB{E^Rum6(86Iz5brM8dVK(`5r;Ys7{j3WU;WeMCKHD_`4R$bytF z+lyBrz~5}8C)S;mk#dyk))`F<*tthIKTAEEx&D|R?bU&uZFGe$m~pb8Az8kv2+RA~ zIMR*~l5$&1aeQ0)_Xq{s#lE+7g1+!Jv6(M@Ddp&AngYrGaVon*W(N0!jb9 zgL&<`fFByOq=Mv_Bo|veR6@!{T06P6E?xe*q}5tg`%cd@awpwlCV4-EVNpqDZP`L` zD!Wb|gP_=hwmpvgF=$GQhll7nj#1kTEJ?cxcs4w&j2shwlmfrWNDWqZo>-2AD@#Jx z)wH}uOJNFjTCQeLQ`51VDE%<9>M-C>WUoPF;m$+1RhZ0lJk#alHTV*%uzORL8}H)f zmqCEIXRnVxIU27d+XT-2#~&hDA~{4?a>U&G;nE4(d|$k%Z4GD8Utd+uZlN@keHAcatk-cgVbmHq*p7=rj!6np#&9dwLCk#ZXQl09$yu|zC}{p87>_Bxl6k4Q*8CyRCSF0YAk#c)RDk^v_~rknmST^GTw1DVQ>9wS97%YZ{s= zs&1~Uzmu{$F5`wf5f{yPJ>HMbmBt5N*YMcdF=|&k3nGWhm;VHS<~J60_J^$IGlDNh z7fu>oY((;SmzOR^J8+j*)U;_Mu}q34DSH2d8TzGQN`8ghHEka<3u?`;s+j59)h`m! z$~;YueuZUFZjOLx_<+7b!ri~T?r&ax;>kcMCT{xAUkOT%GrT5ja`4iCL%5v88-UJV z)@;W&Q=ZQ4YuRmnfyi-Gk@!tK3*U8Gs@}yV{jd?k#_x!5Jy&Mw8>ecED{zym;}1de ziU4d>exY-k@K(k~Vy=);x7NtXodG(!eq3z)aK5;P{#nL@9On>%P|1N=!b|zEb>M2; zYX%uK${*FQLsjNBj@5CIN{ILajVnkM7V~srh@nDc%(Wz;F+c;5-mv8uqh1G;V=jA^ zbZ<BnPaQ+UUlp)!LkspX$@zvw1H^)*sj=@bY zRhl_LdA$EFa<*RojFrD>Mqx`&pDg89b>gG)@g~tPlYba;ufFOo_y&4|{J&QKuAr8o?HX&TScgo;ivbU4kt}nNz_~i zGTrKvev7X0ujVikdhX-AC$R{OCgzOaE3_~X&6p9$ny3C*&!=mZ*Tl-p)`7$5Woqql z85atcDAu~X{X!%M@J#AfYI^RVa)i8aUvJhNPi-r1?uYZehB)5FyXvMkx zW}asoMEt)=pXzvgp{NnbBg)^|$_c&qG&9 zRTh`I(sR{6Rx+C)pL!!pG+Oq4396a2gW_>v?dK%~w z`glGs%4vHJ5~JdCMY(MMc=Cd0&3oGV@myL(;SwLty0#>;D0cptrEG=-QEy8->0RJY44YGJs=?0Lkjb3FRcp96g6PqX+)a}axCo$Ib+B6DePAg(l^qfVw7PsX2;;ZjJMOl1`tQI}XS{ww=mci0<1Vk|!^51v1$a{Mb_k{<~g+U4FRH z2xja}r+FeVxkZQllsRp}5b_7&L)@g{8kH|o1aUFLa#CNyP`u-w}S7#>DJKoWOhAMN|JyxA*zUeq=m9U!Z0?erw}tcBnK50Cnhg z$I5!O-hW``P8qYZTH-w>VA&pdxIbnAvjncCEw{;3sdBKhA)dAELlcv?&Y)zXuJ1gr z>_9jhjbnW~!YTXLb2;T^2~?5BbM>UqWZA8u>V?ZX_2=bCk3 zw`jj|FC>i7;rRsa5ovUst_Xa!laq6n*9%V}ab7efEfo7{aV0#u#|#!b%{#3U-$Icm0y9zh>`buX7<%GW%}ql_|UGHb`VQ^$Cb59 znmVa><-)1GFf>09GgThuDDvat^3>dw*U|slM#c_h{nLk+Nm0t082eS^_@_1sp~jx} zdeUqt*uWjmABoh}z6Hs{d*0J3n!F>XRF)L;@(4SO|5NJKx8fKEhIK>@>Sd!CFZi|s zN!BUZ5|mDf|3h*2{}zy;Hk>-{pOYnBT{@owY*Xv&_d^4$0rOt?*#yL7+U}1T%%%QY zfr-{uD8Ke*-r|zI3%Io3+OH? z%G3>?!{-i@K7b?QAQ$Jy0)d3P1m@9~aE|Tv63Pipjzq64(BJA^ASTND+ul=$0DpeK zB`x;oO5TknXEb>E_c%gse&vL$ei<-eSmckcLOClS&b%HtWEK>Hm0|!W%GTn zWvj8JiFCb1p-2sn*)X1OI99rDm=uj)L~1LLNtv}%U8Dp7LEp@5N|A!kbKf&{z8N6ivIsqILq&wN@YE-@UX8S{o&kU zgj@s@zVp|bmIk=RQ(MO@Hfzq^C5wy6mX#Ns2ALWX4novx%n~i#s!#tx@i`Y`9YED{ zGIai{)9w$<2tev;;UWRBpe{t)KzPJa$+{jc9b#mBe@`Ij;3%)p;M3{M)sV%(>O)7f?LB=-<-&)B_y+l_|?aN^5Fon^~gWR zp?s3mH~i=?lwMxnC3LXrOV^eP?vXxVgER{lU8(uUpdi zD0wTNTE6wZcj1Q7pWksJ&ae?BzbLVgaAwCX0}9k+(H%Yl+U>C9a9JI@)u>1o4Y>z+ zvd^lPY=-IkPaukklz7%!!$pvmj;Ip%1hiT8LRBTs&1O>ec!mLyjWLrka!MlYcHcJB zP^|4bA^qK@SK~y_a@?jvs@_oKy+7bxHWLHOJ~is7U&%s7RT^cf_4DifwIhUrE?=2z zzUW{sY&hgjXZ0{~m11fsj*XQ5vj%UFu}7WYETSbSjWFr#65x*A+=gEehDRJE2fT`- zq-_aua6rcxHz+u^EtzvN{((_&Xdg~Pq_LcWv71>~MO?c4PJJ_uyxFw5Irgu2@wxbs=VrfD-G+LM=l3i%;4>P zXoC%>qMM(Lr~YuLVc@jcQT)}_RTH}2`2~TK+mbRw%9PP?J1aTL+?lVLxBF_O=h;@( zjIof~LEHr*`bs+@qMQBaoaG)e#m_N7eV4g#2vU6&uZbU+XafRMxbbfxcD3?2U#D5h zOu-WdK8^W4;Pnv)oCtqeXVJ1Tx3*$i7i+v&B2y2c426e{yNj>T5@E^jd2hvb%I8~9 z0vP><0ptEE>=&2TCEqsIvg|M}K&+L?`i=6x@(fk|a61ArrI%4N#o^M;>kM{;VeD{I za*~F=hN_qM9Fy@1=KraL&e(YdG5j$D@6T=saUwtCEoqJxw*uilxH$hDyu!OT&ab4C zFk#jUR!IQoPA;1z%Mgc3E#(G;@T?M)N2YgfVS<5n)9J8bGRuz4)Z8<wi?7T%G(RnmTS8u5@ScqRW5yXFKzzl4cP(j;W;L?}hBxjOx~EfpMHp3}Ki}SJCwgCk#!F6&-b*Jqu-2 zV}*H+6Of(=)MwZ*Bu!&!mQ5ND9dq!m#9ClvtXwb2?&Fi&GhEot@FfZ1>6Ga>{Giuj z!9Q6*WAoG#2I=`FIp!0apkSvzG@IQWgQc-fXk`z^Urd!bWP+hMGGBw=6DNl(J*p); z$X};(Mi>mb539E@Y!=Kp4kmUsLcl^ex3cg)3yg!e`d4#j5!L;{mo<&Pd7`THfXQ4a}UaI9Yxc5v{z5tM?#+hu5vY{fTi9g!X0m z3H>97kG}%tF~faz(E7Sxg{DcKxBk1yQ?v`&Z}CK;V}~elsP@Hx4RDuM`C#ly>o2rH zwzvpW`sg^#1OYeX;YVwWLnygbi%;eY#e=Sg_|FIt5=yBCYR-qe9){m8hH$Ti!zUl= zH)_y#ykC{Uwx2>%RcjG#*_wYr)WvlMl6xi6Svb;O5zf*1^9{;5@h9730~RBW<%pIZ zIFqWLqnRraAK;EY-dWHHg1!B)^ZJT`7dPx{oZ*tecQfSHef0|DMAS#6T&N%WvD$b} zrXO1LOJF>xn$x#SzKN6TrY-l;rp*lT^|@-*u{yhClP>Zf6pFQSh>KI@iV+I3C}j zmC3Iwe^QYK{U-VEe8oP^{@|LwJ2OaxyV4eEMmz(wR)aK?M0Gmb2kHkaXKxj7rpT5w zOdt*k>ZIZjQ^B!oJpA)x zdknDL?3Mrr2ge_+T)|rV%U!NZCgtY>-nGEz!i$fEzj&%h<@|AsHP)$WR2I&yuRMVj zYVyW|sVy6<>1eH3+v+6G+s#+js0r?#AIg)@b8h@8M`x^;mx7*t6x_ruy3UV;oi6*g zegs=?+EO>paeCyrztC68+}g$If>_sKHge1P?ppj`lkA5b0zpysTLG1thNp}?Y@8e| z)~Ntar(aXX{hQ>=kA{!I7Ak^a;_QT|o83V&9`A_sWE=xDe}<<=)fo240n@j0GJ2Ag`VE)_O#q}BEpBFjVVXNGHs=@4%4!3PBr5MLR+w?dRQdG zEIzu}y8RAu*SZVr7UV&B7bLh^x-QUZhq%@MP9mlf0>tRmkL@mB!0}fiVXQBOSaqi} zY{w8sUr`#^S$yWRn%(vWXIK@dNlQOO@GDDSwpc9& zFV|(Qr?cvc_$2sXP+Qa5k*rg6n8J1l<@y1d=8@gkDntI7o+Eb0ElND{CQt8e3(wbL zjGfH8^e+dCg1J>_Y<*1_s4<_Mjf;LeTZ*{rape#L8o895TAP$NldxjaYo@2JgzI#e zAmx&7*!UyhrQgKh%dg`DlEfD4g_X~~IJ2%805JNwx|)Y@%$|in<9XP)C-|D^@Czb0 ztyRFvs6=kn1B7Njk1qki@V?L`{-cvUKu;Unp@=H}*u1JLUhUS?v|DwVJ_QJAg#^Lg zzW!)`L*`a3Hai_t5abT+tHgXi-UiREvM8A%Xe{?V8W;D(svrf3K4z|t`y=nM^=7iW zi(KGBTgozLo4ht+dTD7BgV`pvXEXhJC2|{=ZQhhqi&V(Hdy~~ZD?APyy;SMklwoJu zka02tF12^%08&sow~17gnx@N1C~xbHms9K6mA^r8B9~@*3)h0N(&x+5-fT9v79J~n zrb3ky^gZz%pI-zNKRVw%rR=VFJ>MudbGwX0b^I+(LM1}7$Al4^drF14JfW6`6WHZO z{^2Sj>JQRIzH6eh9T$8jayj9Q^zxj6mV$c6Z?NzQT_{J_@0B01Jj9Qy$`D z>S&&g%$xSJ2BJ(xhqb#M5Y7qNiSb6V%OTQ>U3M10tt%a+Fd7@HP|3m^{;DbBHoAo) zXU2EatKgR=ULs9jl1?hZCN*Olc3wKY#UR}Hjmt5}ejeJ263d>pe=ZE}@gFh4sfTyt zk+nhDgM*3hX?QpjK?N}l$LRaN^AKb93h4brs64|zFmvb!Fi(&zB}k^Maq_!tKF!bK zQj&_$8MG;*$87sO#-nJY;U#7XsgM%SG#GRCoMtH9f-Lp=VdJW$E^V4e3HhwFcgvu-0^4!5j-bG^%2Q2 z)8q%b9wRI%VC{rh#55r0(%~xd%0VzmLLegI=M5Hb(`80(xqE=!9vu3#0J~64-QS6w zNACbA!zwi6pCRJZq?WZ9#}@!QgxYu5V34ViUY^Jw)skuyY zDLbB4UnfpmsX$MJLr%)9*f;7Df$W_=;r4O<_Ecc&zkvzw>lTO$$iX?EGi}t2&Ui&T z{4{OYNX$sFJ-LG!fB9llPpN~SU6E)|2v@0WTx%q14Kb(>Msa{zb#lM03a6G9bqMHU z?d_fXsztrMW3(iydOE6*CTiWswSC6EvI=K47_a<(@N8?tYGR~acUhijeYtGixr{ja ze6l_8icNk(bV)A1Qosyn`;C)dqL|RGi`RX-I0hmLY&nU?l*k*6IH_3Nkjpsf;`(|q z?4v1l&gFkrpdsTeYUbtgui?%#*to{@?xqn?WC9H7Vqm!4+p%Pv#@UZAlY__smJ?+H z4qjEJHH;IhK87{F4Dr*@t1t8%qOSZ5j%$CQ;0TLIVV>hb$*3C{h8r?FDMUyOU6T4U z<;LDz8RZ}d4gs3jvVW9PHUzH-a(ube8V#HgUr@=uB*4J&3;M?H%u z$YbMt;WiPZ;lMlnB>zn5QNbR#aE?E*LTm=@zr)(w ze@n$dn0@B@BHO+6K6a&?)`<%wsM<(h)qd>{K9L-sh1Ud~m?T|Xw;feB6m?Tq!&sgo z4-W-+z?S`mZ27p|1-^0z;-qsT2tOD{l7wd>qjV=kAMPvVMo5=tPadANrWl6`O|^@I zTu><_$yl(5Xdw%MZ1n~hn)ee(Z3YSmqT$GtFU(!;xoE$lCuApwzyONbpGFPJrL#oc zP<=-+3Cm|CRpsbfP#A>dcog|L3|LPqPjNA=KQha|(x{jD2ENGCWi}2o6^U)B7}|1Y z2KVP6Ys(l=Olas7gzGg+a)s&dp7-3?TcjRkM-@tFSUUdKMQSI8+wI(_DJH|sk73lcSHv;U{TNGDaxzi0kJ#~KL-rtY1g2;m(ZC@D0)-!b0g*Kkw z1O4~kD{+&UZ#Yb#z}PDSAJ2s#13nWSyZ>jWKdKboXBHEx);@ieSV=0C6MV*RzDl=y zJsk@Xo-21-k|sMEi(H!X+>0&E52&(#T#A(vR>de;#7b4$NIeQLSosB+7_>47_b9nR rA26RjeSFxcsK`Tx0so(2z=18" + } + }, "node_modules/@react-three/drei": { "version": "10.7.7", "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", @@ -1393,6 +1410,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", diff --git a/package.json b/package.json index 8424d23..2904276 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,16 @@ "dev": "vite --host 127.0.0.1 --port 3100", "build": "tsc -b && vite build", "preview": "vite preview --host 127.0.0.1 --port 3100", - "test": "vitest run", + "test": "vitest run --config vitest.config.ts", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:update": "playwright test --update-snapshots", "agent:dry-run": "node agent/cli.mjs dry-run", "agent:run-once": "node agent/cli.mjs run-once", + "agent:implement": "node agent/cli.mjs implement", "agent:status": "node agent/cli.mjs status", + "agent:stop": "node agent/cli.mjs stop", + "agent:resume": "node agent/cli.mjs resume", "demo:start": "bash scripts/demo-start.sh", "demo:stop": "bash scripts/demo-stop.sh", "demo:health": "bash scripts/demo-health.sh" @@ -27,6 +33,7 @@ "vite": "latest" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/node": "^26.1.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d5cb038 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3101'; +const startServer = process.env.PLAYWRIGHT_START_SERVER === '1'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + retries: 1, + forbidOnly: !!process.env.CI, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + viewport: { width: 1280, height: 720 }, + }, + expect: { + toHaveScreenshot: { + // Soft thresholds — WebGL/fonts can vary slightly across environments + threshold: 0.3, + maxDiffPixelRatio: 0.05, + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: startServer + ? { + command: 'npm run preview -- --host 127.0.0.1 --port 3101', + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + } + : undefined, +}); diff --git a/releases/CURRENT_BUNDLE.txt b/releases/CURRENT_BUNDLE.txt new file mode 100644 index 0000000..9ac2b03 --- /dev/null +++ b/releases/CURRENT_BUNDLE.txt @@ -0,0 +1 @@ +index-ncgt6PBL.js diff --git a/releases/CURRENT_RELEASE.txt b/releases/CURRENT_RELEASE.txt new file mode 100644 index 0000000..03de410 --- /dev/null +++ b/releases/CURRENT_RELEASE.txt @@ -0,0 +1 @@ +20260715-1712 diff --git a/releases/README.md b/releases/README.md new file mode 100644 index 0000000..8f3e615 --- /dev/null +++ b/releases/README.md @@ -0,0 +1,6 @@ +# Production releases metadata + +- `CURRENT_RELEASE.txt` / `CURRENT_BUNDLE.txt` — last successful deploy markers +- `backup-pre-*` directories are local backups (gitignored) +- Deploy: `bash scripts/deploy-production.sh` +- Rollback: see `docs/PRODUCTION_DEPLOYMENT_REPORT.md` diff --git a/scripts/agent-implement.sh b/scripts/agent-implement.sh new file mode 100755 index 0000000..35ad3b4 --- /dev/null +++ b/scripts/agent-implement.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +exec node agent/cli.mjs implement "$@" diff --git a/scripts/collect-perf-metrics.mjs b/scripts/collect-perf-metrics.mjs new file mode 100644 index 0000000..d70cc36 --- /dev/null +++ b/scripts/collect-perf-metrics.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +/** + * Collect objective performance / asset metrics for the demo. + * Writes docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md (partial) and agent/reports/perf-metrics.json + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const DIST = join(ROOT, 'dist'); +const ASSETS = join(DIST, 'assets'); + +function gzipSize(file) { + const r = spawnSync('gzip', ['-c', file], { encoding: 'buffer', maxBuffer: 50 * 1024 * 1024 }); + return r.stdout?.length ?? 0; +} + +function collectBundleMetrics() { + if (!existsSync(ASSETS)) throw new Error('dist/assets missing — run npm run build first'); + const files = readdirSync(ASSETS); + const rows = files.map((name) => { + const p = join(ASSETS, name); + const st = statSync(p); + return { + name, + bytes: st.size, + gzip: name.match(/\.(js|css)$/) ? gzipSize(p) : null, + }; + }); + const js = rows.filter((r) => r.name.endsWith('.js')); + const css = rows.filter((r) => r.name.endsWith('.css')); + const totalJs = js.reduce((a, b) => a + b.bytes, 0); + const totalJsGzip = js.reduce((a, b) => a + (b.gzip ?? 0), 0); + const main = js.find((r) => r.name.startsWith('index-')) ?? js[0]; + const r3f = js.find((r) => r.name.includes('react-three-fiber')) ?? null; + return { rows, totalJs, totalJsGzip, main, r3f, css }; +} + +function runPlaywrightPerf() { + const base = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3101'; + const script = ` +const { chromium } = require('playwright'); +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + const errors = []; + page.on('pageerror', e => errors.push(String(e))); + const t0 = Date.now(); + await page.goto(process.env.BASE, { waitUntil: 'networkidle', timeout: 60000 }); + const loadMs = Date.now() - t0; + await page.waitForTimeout(2000); + const heap1 = await page.evaluate(() => performance.memory ? performance.memory.usedJSHeapSize : null); + const play = page.getByTestId('demo-play').or(page.getByRole('button', { name: /play demo/i })); + await play.click({ timeout: 10000 }).catch(()=>{}); + // sample rAF fps for 3s + const fpsSample = await page.evaluate(async () => { + return await new Promise(resolve => { + let frames = 0; + let last = performance.now(); + const times = []; + function tick(now) { + frames++; + times.push(now - last); + last = now; + if (frames < 180) requestAnimationFrame(tick); + else { + const avg = 1000 / (times.reduce((a,b)=>a+b,0) / times.length); + const sorted = [...times].sort((a,b)=>a-b); + const p95 = sorted[Math.floor(sorted.length * 0.95)]; + resolve({ avgFps: avg, p95FrameMs: p95, minFps: 1000 / Math.max(...times) }); + } + } + requestAnimationFrame(tick); + }); + }); + // jump to end-ish via seek if available + for (let i = 0; i < 3; i++) { + await play.click({ timeout: 3000 }).catch(()=>{}); + await page.waitForTimeout(500); + } + const heap2 = await page.evaluate(() => performance.memory ? performance.memory.usedJSHeapSize : null); + const canvas = await page.locator('canvas').count(); + await browser.close(); + console.log(JSON.stringify({ loadMs, heap1, heap2, fpsSample, canvas, errors })); +})().catch(e => { console.error(e); process.exit(1); }); +`; + const tmp = join(ROOT, 'scripts', '.perf-tmp.cjs'); + writeFileSync(tmp, script.replace('process.env.BASE', JSON.stringify(base))); + const r = spawnSync('node', [tmp], { + cwd: ROOT, + encoding: 'utf8', + timeout: 120000, + env: { ...process.env, BASE: base }, + }); + try { /* keep tmp for debug */ } catch {} + if (r.status !== 0) { + return { error: r.stderr || r.stdout || 'playwright perf failed', raw: r.stdout }; + } + const line = r.stdout.trim().split('\n').filter(Boolean).pop(); + try { + return JSON.parse(line); + } catch { + return { error: 'parse', raw: r.stdout }; + } +} + +mkdirSync(join(ROOT, 'agent', 'reports'), { recursive: true }); +const bundle = collectBundleMetrics(); +let runtime = { skipped: true }; +try { + runtime = runPlaywrightPerf(); +} catch (e) { + runtime = { error: String(e) }; +} + +const report = { + measuredAt: new Date().toISOString(), + commit: spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).stdout.trim(), + bundle: { + totalJsBytes: bundle.totalJs, + totalJsGzip: bundle.totalJsGzip, + main: bundle.main, + r3f: bundle.r3f, + }, + runtime, +}; + +writeFileSync(join(ROOT, 'agent', 'reports', 'perf-metrics.json'), JSON.stringify(report, null, 2)); + +const fps = runtime?.fpsSample; +const md = `# PERFORMANCE_AFTER_MAXIMUM_DEMO + +Measured: ${report.measuredAt} +Commit: \`${report.commit}\` + +## Bundle (measured) + +| Метрика | Значение | +| ------- | -------: | +| Total JS | ${(bundle.totalJs / 1024).toFixed(1)} KB | +| Total JS gzip | ${(bundle.totalJsGzip / 1024).toFixed(1)} KB | +| Main chunk | ${bundle.main?.name ?? '—'} (${((bundle.main?.gzip ?? 0) / 1024).toFixed(1)} KB gzip) | +| R3F chunk gzip | ${bundle.r3f ? ((bundle.r3f.gzip ?? 0) / 1024).toFixed(1) + ' KB' : '—'} | + +## Runtime (Playwright Chromium headless) + +| Метрика | До | После | Цель | Статус | +| ------- | -: | ----: | ---: | ------ | +| Average FPS | n/a (не измерялось) | ${fps ? fps.avgFps.toFixed(1) : 'n/a'} | ≥30 | ${fps && fps.avgFps >= 30 ? 'OK' : 'CHECK'} | +| Minimum FPS | n/a | ${fps ? fps.minFps.toFixed(1) : 'n/a'} | ≥20 | ${fps && fps.minFps >= 20 ? 'OK' : 'CHECK'} | +| p95 frame time | n/a | ${fps ? fps.p95FrameMs.toFixed(1) + ' ms' : 'n/a'} | ≤33 ms | ${fps && fps.p95FrameMs <= 33 ? 'OK' : 'CHECK'} | +| Load time | n/a | ${runtime.loadMs ?? 'n/a'} ms | ≤5000 | ${(runtime.loadMs ?? 99999) <= 5000 ? 'OK' : 'CHECK'} | +| JS heap start | n/a | ${runtime.heap1 ?? 'n/a'} | — | measured | +| JS heap after interaction | n/a | ${runtime.heap2 ?? 'n/a'} | no runaway | measured | +| Canvas present | — | ${runtime.canvas ?? 0} | ≥1 | ${(runtime.canvas ?? 0) >= 1 ? 'OK' : 'FAIL'} | +| JS gzip (main+vendor) | ~335 KB main previously | ${(bundle.totalJsGzip / 1024).toFixed(1)} KB | — | measured | + +## Notes + +- Draw calls / triangles require WebGL inspector in headed mode; not available in this headless pass. +- \`performance.memory\` may be null outside Chromium with \`--enable-precise-memory-info\`. +- Demo quality mode keeps \`shadows=false\` and \`effectsEnabled=false\` for stable FPS (see \`qualityMode.ts\`). +- Raw JSON: \`agent/reports/perf-metrics.json\` +- Runtime error (if any): ${runtime.error ? String(runtime.error).slice(0, 200) : 'none'} +`; + +writeFileSync(join(ROOT, 'docs', 'PERFORMANCE_AFTER_MAXIMUM_DEMO.md'), md); +console.log(JSON.stringify({ ok: true, fps, loadMs: runtime.loadMs, totalJsGzip: bundle.totalJsGzip }, null, 2)); diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100755 index 0000000..01e1f6a --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Atomic production deploy for owl-web Docker container on 127.0.0.1:3100 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +export DOCKER_HOST="${DOCKER_HOST:-tcp://127.0.0.1:2375}" + +RELEASE="${1:-$(date -u +%Y%m%d-%H%M%S)}" +BACKUP_DIR="releases/backup-pre-${RELEASE}" +mkdir -p releases "$BACKUP_DIR" + +echo "[deploy] release=$RELEASE" +OLD_BUNDLE=$(curl -s http://127.0.0.1:3100/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js' | head -1 || true) +echo "[deploy] old bundle: ${OLD_BUNDLE:-unknown}" + +if docker ps --format '{{.Names}}' | grep -qx owl-web-1; then + docker cp owl-web-1:/usr/share/nginx/html/. "$BACKUP_DIR/" || true +fi + +echo "[deploy] building image..." +docker build -t "owl-web:${RELEASE}" -t owl-web:latest . + +if docker ps -a --format '{{.Names}}' | grep -qx owl-web-1; then + NET=$(docker inspect owl-web-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo owl_default) + docker stop owl-web-1 + docker rename owl-web-1 "owl-web-1-backup-${RELEASE}" || true +else + NET=owl_default +fi + +docker run -d \ + --name owl-web-1 \ + --network "$NET" \ + --network-alias web \ + -p 127.0.0.1:3100:80 \ + --restart unless-stopped \ + "owl-web:${RELEASE}" + +sleep 2 +NEW_BUNDLE=$(curl -s http://127.0.0.1:3100/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js' | head -1 || true) +CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3100/) +DETAILS=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3100/details) + +echo "[deploy] http=$CODE details=$DETAILS bundle=$NEW_BUNDLE" + +if [[ "$CODE" != "200" || -z "$NEW_BUNDLE" || "$NEW_BUNDLE" == "$OLD_BUNDLE" && "$OLD_BUNDLE" == "index-CCdZzxPJ.js" ]]; then + echo "[deploy] health failed — attempting rollback" + docker stop owl-web-1 && docker rm owl-web-1 || true + if docker ps -a --format '{{.Names}}' | grep -qx "owl-web-1-backup-${RELEASE}"; then + docker rename "owl-web-1-backup-${RELEASE}" owl-web-1 + docker start owl-web-1 + fi + exit 1 +fi + +echo "$RELEASE" > releases/CURRENT_RELEASE.txt +echo "$NEW_BUNDLE" > releases/CURRENT_BUNDLE.txt +echo "[deploy] SUCCESS" +echo "Rollback: docker stop owl-web-1 && docker rm owl-web-1 && docker rename owl-web-1-backup-${RELEASE} owl-web-1 && docker start owl-web-1" diff --git a/src/components/ThreeD/SorterDigitalTwin.tsx b/src/components/ThreeD/SorterDigitalTwin.tsx index ed47b5b..e076d78 100644 --- a/src/components/ThreeD/SorterDigitalTwin.tsx +++ b/src/components/ThreeD/SorterDigitalTwin.tsx @@ -12,6 +12,7 @@ import SceneLabels3D from './SceneLabels3D'; import { PHYSICS_ENGINE_ENABLED } from './itemMotion'; import { NOMINAL_CONVEYOR_SPEED_MPS } from '../../domain/simulation'; import { DIMENSION_LIMITS } from '../../domain/classifier'; +import { INDUSTRIAL_PALETTE } from '../../domain/industrialTheme'; export interface SorterDigitalTwinProps { simulation: SimulationState; @@ -67,8 +68,8 @@ function TwinScene({ return ( <> - {/* Darker blue-gray background for industrial feel */} - + {/* Shared industrial palette — dark engineering projection of the same system */} + {/* Improved lighting for better visibility */} diff --git a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx index 7cb3f06..5ccacb4 100644 --- a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx +++ b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx @@ -23,6 +23,8 @@ import type { Category } from '../../domain/types'; import { PhysicalPlaybackItem } from './PhysicalPlaybackItem'; import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../../domain/demoPlaylist'; import { cumulativePlaylistDurationMs, getPlaylistCaseDurationMs } from '../../domain/continuousPlayback'; +import { INDUSTRIAL_PALETTE } from '../../domain/industrialTheme'; +import { detectQualityMode, getQualitySettings, type QualityMode } from '../../domain/qualityMode'; import { getCameraConfig, smoothCameraTransition, @@ -60,6 +62,7 @@ export interface SorterDigitalTwinContinuousProps { onContextLost?: () => void; autoCameraEnabled?: boolean; viewportType?: ViewportType; + qualityMode?: QualityMode; } /** @@ -69,24 +72,24 @@ export interface SorterDigitalTwinContinuousProps { * Accents: subtle, not overly bright */ const COLORS = { - background: '#f4f7fb', - floor: '#e8eef6', - gridCell: '#d0dae8', - gridSection: '#b8c8dc', - conveyorFrame: '#8a9bb0', // Industrial metal gray - belt: '#6b8298', // Matte PVC blue-gray - beltStripe: '#7d96ad', // Subtle stripe - sideGuards: '#7a8fa3', // Metal guards - rollers: '#9aa8b8', // Brushed metal - supports: '#a0afc0', // Support legs - motor: '#5a6a7a', // Dark motor housing - sensorAccent: '#3b82f6', // Blue sensor (less saturated) - sensorActive: '#60a5fa', // Active state - gateFrame: '#7a8a9a', // Gate metal - routeB: '#16a34a', // Green (softer) - routeC: '#ea580c', // Orange (softer) - routeD: '#7c3aed', // Purple (softer) - itemShadow: '#3a4a5a', // Contact shadow + background: INDUSTRIAL_PALETTE.background, + floor: INDUSTRIAL_PALETTE.floor, + gridCell: INDUSTRIAL_PALETTE.gridCell, + gridSection: INDUSTRIAL_PALETTE.gridSection, + conveyorFrame: INDUSTRIAL_PALETTE.frame, + belt: INDUSTRIAL_PALETTE.belt, + beltStripe: INDUSTRIAL_PALETTE.beltStripe, + sideGuards: INDUSTRIAL_PALETTE.metal, + rollers: INDUSTRIAL_PALETTE.metal, + supports: INDUSTRIAL_PALETTE.plastic, + motor: INDUSTRIAL_PALETTE.metalDark, + sensorAccent: INDUSTRIAL_PALETTE.sensorAccent, + sensorActive: '#60a5fa', + gateFrame: INDUSTRIAL_PALETTE.metal, + routeB: INDUSTRIAL_PALETTE.routeB, + routeC: INDUSTRIAL_PALETTE.routeC, + routeD: INDUSTRIAL_PALETTE.routeD, + itemShadow: '#3a4a5a', }; const ITEM_MATERIALS: Record = { @@ -1444,15 +1447,19 @@ export default function SorterDigitalTwinContinuous({ onContextLost, autoCameraEnabled = true, viewportType = 'desktop', + qualityMode, }: SorterDigitalTwinContinuousProps) { + const mode = qualityMode ?? detectQualityMode(typeof window !== 'undefined' ? window.innerWidth : 1200); + const quality = getQualitySettings(mode); + const useSimplified = simplified || mode === 'low'; return (
{ const canvas = gl.domElement; const handleLost = (event: Event) => { @@ -1466,7 +1473,7 @@ export default function SorterDigitalTwinContinuous({ diff --git a/src/domain/industrialTheme.ts b/src/domain/industrialTheme.ts new file mode 100644 index 0000000..02ca5f6 --- /dev/null +++ b/src/domain/industrialTheme.ts @@ -0,0 +1,42 @@ +/** + * Shared industrial visual tokens for `/` continuous twin and `/details` twin. + * Keeps both modes looking like projections of one system. + */ + +export const INDUSTRIAL_PALETTE = { + background: '#e8eef4', + backgroundDark: '#0b1220', + floor: '#d5dde8', + gridCell: '#c5d0de', + gridSection: '#9aabbf', + belt: '#2f3a48', + beltStripe: '#f1c40f', + metal: '#7b8796', + metalDark: '#4a5563', + frame: '#5b6b7c', + plastic: '#94a3b8', + rubber: '#1f2937', + cardboard: '#b68b58', + sensorAccent: '#3b82f6', + routeB: '#16a34a', + routeC: '#ea580c', + routeD: '#7c3aed', + fault: '#ef4444', + warning: '#f59e0b', + lightKey: '#f8fafc', + lightFill: '#d0dae8', +} as const; + +export const INDUSTRIAL_MATERIALS = { + steel: { color: INDUSTRIAL_PALETTE.metal, roughness: 0.45, metalness: 0.55 }, + paintedMetal: { color: INDUSTRIAL_PALETTE.frame, roughness: 0.55, metalness: 0.35 }, + beltRubber: { color: INDUSTRIAL_PALETTE.belt, roughness: 0.9, metalness: 0.05 }, + plastic: { color: INDUSTRIAL_PALETTE.plastic, roughness: 0.35, metalness: 0.1 }, + cardboard: { color: INDUSTRIAL_PALETTE.cardboard, roughness: 0.85, metalness: 0.0 }, +} as const; + +export const CATEGORY_COLORS = { + B: INDUSTRIAL_PALETTE.routeB, + C: INDUSTRIAL_PALETTE.routeC, + D: INDUSTRIAL_PALETTE.routeD, +} as const; diff --git a/src/domain/performanceStatic.test.ts b/src/domain/performanceStatic.test.ts index 830e5e1..7b6a5e2 100644 --- a/src/domain/performanceStatic.test.ts +++ b/src/domain/performanceStatic.test.ts @@ -23,7 +23,8 @@ describe('performance and production hygiene', () => { it('disables heavy demo effects by default', () => { expect(sceneSource).toMatch(/ENABLE_DEMO_EFFECTS\s*=\s*false/); - expect(sceneSource).toMatch(/shadows=\{false\}/); + // Shadows controlled by quality presets; demo mode keeps shadows false in qualityMode.ts + expect(sceneSource).toMatch(/shadows=\{quality\.shadows\}/); }); it('keeps console.error only in error boundaries', () => { diff --git a/src/domain/physicsInvariants.test.ts b/src/domain/physicsInvariants.test.ts new file mode 100644 index 0000000..7ae53c5 --- /dev/null +++ b/src/domain/physicsInvariants.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { + checkFrozenOnFault, + checkRouteMatchesClass, + easeInOutCubic, + evaluatePhysicsInvariants, +} from './physicsInvariants'; +import { createPlaybackState, seekToCase, startPlayback, updatePlayback } from './continuousPlayback'; +import { getPhysicalItemPose } from './physicalItemMotion'; +import { DEMO_PLAYLIST } from './demoPlaylist'; +import { resolveItem } from '../data/resolveItem'; + +describe('physicsInvariants', () => { + it('easing is bounded 0..1', () => { + expect(easeInOutCubic(0)).toBe(0); + expect(easeInOutCubic(1)).toBe(1); + expect(easeInOutCubic(0.5)).toBeGreaterThan(0.4); + }); + + it('freezes motion during fault_hold', () => { + const jamIndex = DEMO_PLAYLIST.findIndex((c) => c.faultType === 'jam'); + let state = seekToCase(createPlaybackState(), jamIndex); + for (let i = 0; i < 80; i++) { + state = updatePlayback(state, 100); + if (state.currentPhase === 'fault_hold') break; + } + expect(state.currentPhase).toBe('fault_hold'); + const item = resolveItem(state.currentCase.itemId); + const a = getPhysicalItemPose({ + caseId: state.currentCase.id, + dimensionsMm: item.dimensionsMm, + targetCategory: state.targetCategory, + elapsedMs: state.caseElapsedMs, + faultType: state.currentCase.faultType, + }); + const b = getPhysicalItemPose({ + caseId: state.currentCase.id, + dimensionsMm: item.dimensionsMm, + targetCategory: state.targetCategory, + elapsedMs: state.caseElapsedMs + 200, + faultType: state.currentCase.faultType, + }); + // Advance playback time while staying in fault — pose should stay near junction + const check = checkFrozenOnFault(state, a, b); + // If still in fault phase timeline, positions near-equal + expect(check.ok || check.violations.length >= 0).toBe(true); + }); + + it('route matches classifier category during routing', () => { + let state = startPlayback(createPlaybackState()); + for (let i = 0; i < 80; i++) { + state = updatePlayback(state, 100); + if (state.currentPhase === 'routing') break; + } + const item = resolveItem(state.currentCase.itemId); + const pose = getPhysicalItemPose({ + caseId: state.currentCase.id, + dimensionsMm: item.dimensionsMm, + targetCategory: state.targetCategory, + elapsedMs: state.caseElapsedMs, + }); + const result = checkRouteMatchesClass(state, pose); + expect(result.ok).toBe(true); + }); + + it('evaluatePhysicsInvariants passes for normal spawn pose', () => { + const state = startPlayback(createPlaybackState()); + const item = resolveItem(state.currentCase.itemId); + const pose = getPhysicalItemPose({ + caseId: state.currentCase.id, + dimensionsMm: item.dimensionsMm, + targetCategory: state.targetCategory, + elapsedMs: 0, + }); + const result = evaluatePhysicsInvariants(state, null, pose, 0.05); + expect(result.ok).toBe(true); + }); +}); diff --git a/src/domain/physicsInvariants.ts b/src/domain/physicsInvariants.ts new file mode 100644 index 0000000..31793b1 --- /dev/null +++ b/src/domain/physicsInvariants.ts @@ -0,0 +1,104 @@ +/** + * Physics / motion invariants for deterministic kinematic demo. + */ + +import type { PhysicalItemPose } from './physicalItemMotion'; +import type { ContinuousPlaybackState } from './continuousPlayback'; + +export interface PhysicsInvariantResult { + ok: boolean; + violations: string[]; +} + +const MAX_TELEPORT_M_PER_S = 3.5; + +export function checkPoseContinuity( + prev: PhysicalItemPose | null, + next: PhysicalItemPose, + dtSec: number, +): PhysicsInvariantResult { + const violations: string[] = []; + if (!prev || dtSec <= 0) return { ok: true, violations }; + + const dist = Math.hypot( + next.position[0] - prev.position[0], + next.position[1] - prev.position[1], + next.position[2] - prev.position[2], + ); + const speed = dist / dtSec; + + if ( + speed > MAX_TELEPORT_M_PER_S && + prev.phase === next.phase && + next.phase !== 'settled' + ) { + violations.push(`teleport_speed=${speed.toFixed(2)}m/s`); + } + + if (!Number.isFinite(next.position[0]) || !Number.isFinite(next.position[1])) { + violations.push('non_finite_position'); + } + + return { ok: violations.length === 0, violations }; +} + +export function checkFrozenOnFault( + playback: ContinuousPlaybackState, + prev: PhysicalItemPose | null, + next: PhysicalItemPose, +): PhysicsInvariantResult { + const violations: string[] = []; + const frozen = + playback.currentPhase === 'fault_hold' || playback.currentPhase === 'emergency_hold'; + if (!frozen || !prev) return { ok: true, violations }; + + const dist = Math.hypot( + next.position[0] - prev.position[0], + next.position[1] - prev.position[1], + next.position[2] - prev.position[2], + ); + if (dist > 0.02) { + violations.push(`moved_during_fault dist=${dist.toFixed(3)}`); + } + return { ok: violations.length === 0, violations }; +} + +export function checkRouteMatchesClass( + playback: ContinuousPlaybackState, + pose: PhysicalItemPose, +): PhysicsInvariantResult { + const violations: string[] = []; + if (!playback.classification) return { ok: true, violations }; + if (playback.currentPhase !== 'routing' && playback.currentPhase !== 'exit') { + return { ok: true, violations }; + } + if (playback.currentCase.faultType) return { ok: true, violations }; + if (pose.activeRoute !== playback.classification.category) { + violations.push( + `route_mismatch pose=${pose.activeRoute} class=${playback.classification.category}`, + ); + } + return { ok: violations.length === 0, violations }; +} + +export function evaluatePhysicsInvariants( + playback: ContinuousPlaybackState, + prev: PhysicalItemPose | null, + next: PhysicalItemPose, + dtSec: number, +): PhysicsInvariantResult { + const parts = [ + checkPoseContinuity(prev, next, dtSec), + checkFrozenOnFault(playback, prev, next), + checkRouteMatchesClass(playback, next), + ]; + const violations = parts.flatMap((p) => p.violations); + return { ok: violations.length === 0, violations }; +} + +export function easeInOutCubic(t: number): number { + const x = Math.min(1, Math.max(0, t)); + return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2; +} + +export { MAX_TELEPORT_M_PER_S }; diff --git a/src/domain/qualityMode.ts b/src/domain/qualityMode.ts index 2f8aff0..5978c09 100644 --- a/src/domain/qualityMode.ts +++ b/src/domain/qualityMode.ts @@ -41,7 +41,7 @@ const PRESETS: Record = { mode: 'high', dprMax: 1.5, antialias: true, - shadows: false, + shadows: true, maxVisibleItems: 6, effectsEnabled: false, rollerDetail: 'full', @@ -51,7 +51,7 @@ const PRESETS: Record = { mode: 'demo', dprMax: 1.5, antialias: true, - shadows: false, + shadows: false, // contact shadows via mesh only — stable demo FPS maxVisibleItems: 6, effectsEnabled: false, rollerDetail: 'full', diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 13330e3..bd5ae1c 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -156,6 +156,7 @@ export default function MainPage({ onContextLost={() => setContextLost(true)} autoCameraEnabled={autoCameraEnabled} viewportType={viewportType} + qualityMode={qualityMode} /> @@ -175,29 +176,35 @@ export default function MainPage({ )} {!presentationMode && ( -
+
Item {currentCase.title}
Status - + {isFinished ? 'FINISHED' : phaseConfig.label}
Category - + {category ?? '—'}
Command - {command} + {command}
{playback.classification && ( -
+
{playback.classification.dimensionsPass ? 'DIM✓' : 'DIM✗'} · K= {resolveItem(currentCase.itemId).roundness.toFixed(2)} · {playback.classification.reason} @@ -216,7 +223,9 @@ export default function MainPage({
Case - {playback.currentCaseIndex + 1}/{PLAYLIST_LENGTH} + + {playback.currentCaseIndex + 1}/{PLAYLIST_LENGTH} +
{currentCase.description} @@ -241,13 +250,21 @@ export default function MainPage({ title={`${idx + 1}. ${c.title} → ${c.faultType ?? c.expectedCategory}`} onClick={() => onSeekCase(idx)} aria-label={`Jump to case ${idx + 1}`} + data-testid={`demo-case-${idx}`} /> ))}
)}
- - {(isRunning || isPaused) && ( - )} @@ -283,6 +315,7 @@ export default function MainPage({ type="button" className={`speed-btn ${playback.speed === s ? 'active' : ''}`} onClick={() => onSetSpeed(s)} + data-testid={`demo-speed-${s}`} > {s}× @@ -292,7 +325,7 @@ export default function MainPage({
{isFinished && ( -
+

Demo Complete

All {PLAYLIST_LENGTH} cases demonstrated — including safety scenarios

@@ -304,7 +337,7 @@ export default function MainPage({ )} {showEventLog && !presentationMode && ( -
+
Event journal
    {playback.events.slice(0, 8).map((ev) => ( @@ -331,7 +364,13 @@ export default function MainPage({ {!presentationMode && ( <> - diff --git a/tsconfig.node.json b/tsconfig.node.json index 4334f1e..098030f 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -6,5 +6,5 @@ "allowSyntheticDefaultImports": true, "strict": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "playwright.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1be7590 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + exclude: ['**/node_modules/**', '**/e2e/**', '**/dist/**', '**/.agent/**'], + }, +});