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 <cursoragent@cursor.com>
This commit is contained in:
Даня Архипов
2026-07-15 17:14:01 +00:00
parent 985f7c327d
commit b5985f33bb
34 changed files with 2076 additions and 71 deletions

View File

@@ -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

38
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

8
.gitignore vendored
View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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 <path>]` | 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/<run-id>` on branch `agent/<run-id>/<task-slug>`
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/<run-id>.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/<run-id> show HEAD
# Accept into your feature branch yourself (example)
git cherry-pick <agent-commit-sha>
# Or discard
git worktree remove --force .agent/worktrees/<run-id>
git branch -D agent/<run-id>/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)

View File

@@ -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

View File

@@ -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
`}

View File

@@ -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:<release>`), порт `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-<release>/`.
## Smoke (tunnel)
Подтверждено: CASE 1/10, Jump to case 1–10, Previous/Next, Present, Play Demo.

38
e2e/controls.spec.ts Normal file
View File

@@ -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();
});
});

19
e2e/routes.spec.ts Normal file
View File

@@ -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 });
});
});

43
e2e/safety.spec.ts Normal file
View File

@@ -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);
});
});

31
e2e/smoke.spec.ts Normal file
View File

@@ -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([]);
});
});

20
e2e/visual.spec.ts Normal file
View File

@@ -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,
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

64
package-lock.json generated
View File

@@ -19,6 +19,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",
@@ -124,6 +125,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=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",

View File

@@ -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",

40
playwright.config.ts Normal file
View File

@@ -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,
});

View File

@@ -0,0 +1 @@
index-ncgt6PBL.js

View File

@@ -0,0 +1 @@
20260715-1712

6
releases/README.md Normal file
View File

@@ -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`

5
scripts/agent-implement.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
exec node agent/cli.mjs implement "$@"

View File

@@ -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));

59
scripts/deploy-production.sh Executable file
View File

@@ -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"

View File

@@ -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 */}
<color attach="background" args={['#0a1520']} />
{/* Shared industrial palette — dark engineering projection of the same system */}
<color attach="background" args={[INDUSTRIAL_PALETTE.backgroundDark]} />
{/* Improved lighting for better visibility */}
<ambientLight intensity={0.7} />

View File

@@ -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<string, { color: string; roughness: number; metalness?: number }> = {
@@ -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 (
<div className="digital-twin-wrap continuous-twin">
<div className="digital-twin-canvas continuous-canvas">
<Canvas
camera={{ position: [4.5, 3.5, 5.0], fov: 45 }}
dpr={simplified ? [1, 1.25] : [1, 1.5]}
shadows={false}
gl={{ antialias: !simplified, powerPreference: 'high-performance' }}
dpr={[1, quality.dprMax]}
shadows={quality.shadows}
gl={{ antialias: quality.antialias && !useSimplified, powerPreference: 'high-performance' }}
onCreated={({ gl }) => {
const canvas = gl.domElement;
const handleLost = (event: Event) => {
@@ -1466,7 +1473,7 @@ export default function SorterDigitalTwinContinuous({
<Suspense fallback={null}>
<ContinuousScene
playback={playback}
simplified={simplified}
simplified={useSimplified}
autoCameraEnabled={autoCameraEnabled}
viewportType={viewportType}
/>

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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);
});
});

View File

@@ -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 };

View File

@@ -41,7 +41,7 @@ const PRESETS: Record<QualityMode, QualitySettings> = {
mode: 'high',
dprMax: 1.5,
antialias: true,
shadows: false,
shadows: true,
maxVisibleItems: 6,
effectsEnabled: false,
rollerDetail: 'full',
@@ -51,7 +51,7 @@ const PRESETS: Record<QualityMode, QualitySettings> = {
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',

View File

@@ -156,6 +156,7 @@ export default function MainPage({
onContextLost={() => setContextLost(true)}
autoCameraEnabled={autoCameraEnabled}
viewportType={viewportType}
qualityMode={qualityMode}
/>
</Suspense>
</ThreeErrorBoundary>
@@ -175,29 +176,35 @@ export default function MainPage({
)}
{!presentationMode && (
<div className="main-hud">
<div className="main-hud" data-testid="demo-hud">
<div className="hud-row">
<span className="hud-label">Item</span>
<span className="hud-value">{currentCase.title}</span>
</div>
<div className="hud-row">
<span className="hud-label">Status</span>
<span className={`hud-value status-${playback.status} ${faultActive ? 'status-fault' : ''}`}>
<span
className={`hud-value status-${playback.status} ${faultActive ? 'status-fault' : ''}`}
data-testid="demo-status"
>
{isFinished ? 'FINISHED' : phaseConfig.label}
</span>
</div>
<div className="hud-row">
<span className="hud-label">Category</span>
<span className={`hud-value ${category ? `category-${category}` : ''}`}>
<span
className={`hud-value ${category ? `category-${category}` : ''}`}
data-testid="demo-category"
>
{category ?? '—'}
</span>
</div>
<div className="hud-row">
<span className="hud-label">Command</span>
<span className="hud-value">{command}</span>
<span className="hud-value" data-testid="demo-command">{command}</span>
</div>
{playback.classification && (
<div className="hud-row hud-row-small">
<div className="hud-row hud-row-small" data-testid="demo-proof">
<span className="hud-value proof-text">
{playback.classification.dimensionsPass ? 'DIM✓' : 'DIM✗'} · K=
{resolveItem(currentCase.itemId).roundness.toFixed(2)} · {playback.classification.reason}
@@ -216,7 +223,9 @@ export default function MainPage({
<div className="hud-divider" />
<div className="hud-row">
<span className="hud-label">Case</span>
<span className="hud-value">{playback.currentCaseIndex + 1}/{PLAYLIST_LENGTH}</span>
<span className="hud-value" data-testid="demo-case-label">
{playback.currentCaseIndex + 1}/{PLAYLIST_LENGTH}
</span>
</div>
<div className="hud-row hud-row-small">
<span className="hud-value">{currentCase.description}</span>
@@ -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}`}
/>
))}
</div>
)}
<div className="main-controls">
<button type="button" className="ctrl-nav" onClick={onSeekPrev} aria-label="Previous case" title="Prev (← / B)">
<button
type="button"
className="ctrl-nav"
onClick={onSeekPrev}
aria-label="Previous case"
title="Prev (← / B)"
data-testid="demo-prev"
>
⏮
</button>
<button
@@ -255,6 +272,7 @@ export default function MainPage({
className="play-button"
onClick={handlePlayPause}
aria-label={isRunning ? 'Pause demo' : 'Play demo'}
data-testid={isRunning ? 'demo-pause' : 'demo-play'}
>
{isRunning ? (
<span className="play-icon">⏸</span>
@@ -265,12 +283,26 @@ export default function MainPage({
{isRunning ? 'Pause' : isPaused ? 'Resume' : isFinished ? 'Replay' : 'Play Demo'}
</span>
</button>
<button type="button" className="ctrl-nav" onClick={onSeekNext} aria-label="Next case" title="Next (→ / N)">
<button
type="button"
className="ctrl-nav"
onClick={onSeekNext}
aria-label="Next case"
title="Next (→ / N)"
data-testid="demo-next"
>
⏭
</button>
{(isRunning || isPaused) && (
<button type="button" className="stop-button" onClick={onStop} aria-label="Stop demo" title="Reset (R)">
<button
type="button"
className="stop-button"
onClick={onStop}
aria-label="Stop demo"
title="Reset (R)"
data-testid="demo-stop"
>
⏹
</button>
)}
@@ -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}×
</button>
@@ -292,7 +325,7 @@ export default function MainPage({
</div>
{isFinished && (
<div className="main-finished-overlay">
<div className="main-finished-overlay" data-testid="demo-finished">
<div className="finished-content">
<h2>Demo Complete</h2>
<p>All {PLAYLIST_LENGTH} cases demonstrated — including safety scenarios</p>
@@ -304,7 +337,7 @@ export default function MainPage({
)}
{showEventLog && !presentationMode && (
<div className="main-event-log" aria-live="polite">
<div className="main-event-log" aria-live="polite" data-testid="demo-event-log">
<div className="event-log-header">Event journal</div>
<ul>
{playback.events.slice(0, 8).map((ev) => (
@@ -331,7 +364,13 @@ export default function MainPage({
{!presentationMode && (
<>
<button type="button" className="presentation-toggle" onClick={enterFullscreen} title="Presentation (P / F)">
<button
type="button"
className="presentation-toggle"
onClick={enterFullscreen}
title="Presentation (P / F)"
data-testid="demo-presentation"
>
Present
</button>
<Link to="/details" className="details-link">

View File

@@ -6,5 +6,5 @@
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "playwright.config.ts"]
}

9
vitest.config.ts Normal file
View File

@@ -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/**'],
},
});