feat: finish Stage 2B continuous sorter realism and local deploy

Unify CAD conveyor on / and /details, add RealSense D435i + contact paddle
physics, keep items moving through scan, lock 7×10 drop validation, and
ship Stage 2 artifacts with production on :3100 (permanent HTTPS still blocked).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Даня Архипов
2026-07-30 12:00:09 +00:00
parent 40e9b18e8d
commit acc59ec912
81 changed files with 2530 additions and 878 deletions

View File

@@ -1,26 +1,31 @@
import { test, expect } from '@playwright/test';
import { dismissFinished, ensurePaused, ensureRunning, openPausedCase } from './helpers';
test.describe('controls', () => {
test.setTimeout(90_000);
test.beforeEach(async ({ page }) => {
await page.goto('/');
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
await expect(page.getByTestId('demo-hud')).toBeVisible();
await openPausedCase(page, 0, '1');
});
test('seek next/prev and jump cases, speed, presentation', async ({ page }) => {
await expect(page.getByTestId('demo-case-label')).toHaveText('1/10');
await expect(page.getByTestId('demo-case-label')).toHaveText('1/11');
await page.getByTestId('demo-next').click();
await expect(page.getByTestId('demo-case-label')).toHaveText('2/10');
await ensurePaused(page);
await expect(page.getByTestId('demo-case-label')).toHaveText('2/11');
await page.getByTestId('demo-prev').click();
await expect(page.getByTestId('demo-case-label')).toHaveText('1/10');
await ensurePaused(page);
await expect(page.getByTestId('demo-case-label')).toHaveText('1/11');
await page.getByTestId('demo-case-8').click();
await expect(page.getByTestId('demo-case-label')).toHaveText('9/10');
await ensurePaused(page);
await expect(page.getByTestId('demo-case-label')).toHaveText('9/11');
await page.getByTestId('demo-case-0').click();
await expect(page.getByTestId('demo-case-label')).toHaveText('1/10');
await ensurePaused(page);
await expect(page.getByTestId('demo-case-label')).toHaveText('1/11');
for (const speed of ['0.5', '1', '1.5', '2'] as const) {
await page.getByTestId(`demo-speed-${speed}`).click();
@@ -34,5 +39,8 @@ test.describe('controls', () => {
await page.locator('.presentation-exit').click();
await expect(page.getByTestId('demo-hud')).toBeVisible();
await expect(page.getByTestId('demo-presentation')).toBeVisible();
await page.goto('/?debug=1');
await ensureRunning(page);
});
});

55
e2e/helpers.ts Normal file
View File

@@ -0,0 +1,55 @@
/** Shared e2e helpers — Stage 2B autostart means / opens already running. */
import { expect, type Page } from '@playwright/test';
/** Close the Demo Complete overlay if it is covering the controls. */
export async function dismissFinished(page: Page) {
const finished = page.getByTestId('demo-finished');
if (await finished.isVisible().catch(() => false)) {
await finished.getByRole('button', { name: /Replay/i }).click({ force: true });
await expect(finished).toHaveCount(0, { timeout: 10_000 });
}
}
/** Ensure playback is running (noop if already on Pause button). */
export async function ensureRunning(page: Page) {
await dismissFinished(page);
const pause = page.getByTestId('demo-pause');
if (await pause.isVisible().catch(() => false)) return;
const play = page.getByTestId('demo-play');
await expect(play.or(pause)).toBeVisible({ timeout: 15_000 });
if (await play.isVisible().catch(() => false)) {
await play.click({ force: true });
}
await expect(page.getByTestId('demo-pause')).toBeVisible({ timeout: 10_000 });
}
export async function ensurePaused(page: Page) {
await dismissFinished(page);
const play = page.getByTestId('demo-play');
if (await play.isVisible().catch(() => false)) return;
await page.getByTestId('demo-pause').click({ force: true });
await expect(page.getByTestId('demo-play')).toBeVisible({ timeout: 10_000 });
}
/** Assert either Play or Pause control is present (autostart-safe). */
export async function expectPlaybackControl(page: Page) {
await expect(
page.getByTestId('demo-play').or(page.getByTestId('demo-pause')),
).toBeVisible({ timeout: 15_000 });
}
/**
* Open debug demo, pause, then seek to a case WHILE paused (seek preserves
* paused status — unlike seek from idle which auto-starts).
*/
export async function openPausedCase(page: Page, caseIndex: number, speed: '0.5' | '1' | '1.5' | '2' = '1') {
await page.goto('/?debug=1');
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
await expect(page.getByTestId('demo-hud')).toBeVisible();
await ensurePaused(page);
await page.getByTestId(`demo-speed-${speed}`).click();
await page.getByTestId(`demo-case-${caseIndex}`).click();
await dismissFinished(page);
await ensurePaused(page);
await expect(page.getByTestId('demo-case-label')).toHaveText(`${caseIndex + 1}/11`);
}

View File

@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { ensureRunning } from './helpers';
/**
* Stage 0 — mobile fallback & WebGL context resilience.
@@ -67,9 +68,9 @@ test.describe('mobile fallback (390x844)', () => {
// Auto-camera toggle must not claim hidden 3D works
await expect(page.locator('.auto-camera-toggle')).toHaveCount(0);
// Play/pause drive the visible SVG scene (same playback state)
await page.getByTestId('demo-play').click();
await expect(page.getByTestId('demo-pause')).toBeVisible({ timeout: 10_000 });
// Play/pause drive the visible SVG scene (same playback state).
// Stage 2B autostart may already be running — ensureRunning is idempotent.
await ensureRunning(page);
await expect(page.getByTestId('demo-status')).not.toHaveText('FINISHED');
await page.getByTestId('demo-pause').click();
await expect(page.getByTestId('demo-play')).toBeVisible({ timeout: 10_000 });
@@ -185,7 +186,7 @@ test.describe('stage2 mobile capability policy', () => {
// Mobile Low mounts the real 3D canvas at low quality
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
await expect(page.getByTestId('main-svg-fallback')).toHaveCount(0);
await page.getByTestId('demo-play').click();
await ensureRunning(page);
await page.waitForTimeout(4000);
expect(pageErrors, `pageerrors: ${pageErrors.join('; ')}`).toEqual([]);
});
@@ -227,7 +228,7 @@ test.describe('stage0 prototype mode', () => {
await toggle.click();
await expect(toggle).toContainText('Auto Cam: ON');
await page.getByTestId('demo-play').click();
await ensureRunning(page);
await page.waitForTimeout(4000);
expect(pageErrors, `pageerrors: ${pageErrors.join('; ')}`).toEqual([]);
expect(consoleErrors, `console errors: ${consoleErrors.join('; ')}`).toEqual([]);

View File

@@ -17,7 +17,7 @@ test.describe('production smoke @production', () => {
}
});
await page.goto('/');
await page.goto('/?debug=1');
const html = await page.content();
const bundle = html.match(/index-[A-Za-z0-9_-]+\.js/)?.[0] ?? '';
expect(bundle).toMatch(/^index-/);
@@ -50,7 +50,7 @@ test.describe('production smoke @production', () => {
expect(webgl.ok).toBe(true);
await expect(page.getByTestId('demo-play').or(page.getByTestId('demo-pause'))).toBeVisible();
await expect(page.getByTestId('demo-case-9')).toBeVisible();
await expect(page.getByTestId('demo-case-10')).toBeVisible();
// Play / seek
const pauseBtn = page.getByTestId('demo-pause');
@@ -59,11 +59,11 @@ test.describe('production smoke @production', () => {
}
await page.waitForTimeout(600);
await page.getByTestId('demo-case-0').click();
await expect(page.getByTestId('demo-case-label')).toHaveText('1/10');
await expect(page.getByTestId('demo-case-label')).toHaveText('1/11');
// Jam
await page.getByTestId('demo-speed-2').click();
await page.getByTestId('demo-case-8').click();
await page.getByTestId('demo-case-9').click();
await expect
.poll(async () => {
const status = (await page.getByTestId('demo-status').textContent()) ?? '';
@@ -73,7 +73,7 @@ test.describe('production smoke @production', () => {
.toMatch(/FAULT|JAM/i);
// E-stop
await page.getByTestId('demo-case-9').click();
await page.getByTestId('demo-case-10').click();
await expect
.poll(async () => {
const status = (await page.getByTestId('demo-status').textContent()) ?? '';
@@ -88,7 +88,7 @@ test.describe('production smoke @production', () => {
await stop.click({ force: true });
}
await page.getByTestId('demo-case-0').click({ force: true });
await expect(page.getByTestId('demo-case-label')).toHaveText('1/10');
await expect(page.getByTestId('demo-case-label')).toHaveText('1/11');
await page.goto('/details');
await expect(page.locator('#root')).toBeVisible();
@@ -96,7 +96,7 @@ test.describe('production smoke @production', () => {
await expect(page.locator('#root')).toBeVisible();
// Back home — version still matches
await page.goto('/');
await page.goto('/?debug=1');
const version2 = await page.evaluate(async () => {
const r = await fetch('/version.json', { cache: 'no-store' });
return r.ok ? r.json() : null;

View File

@@ -10,6 +10,7 @@
* - no console errors throughout
*/
import { test, expect, type Page } from '@playwright/test';
import { ensureRunning } from './helpers';
const MODEL_RE = /\/models\/.*\.stl$/;
@@ -32,7 +33,7 @@ test.describe('Stage 1 real models', () => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('canvas', { timeout: 30000 });
await page.locator('[data-testid="demo-play"]').first().click();
await ensureRunning(page);
// Playlist starts with SKU-001 (box-300.stl, preloaded); let playback settle.
await page.waitForTimeout(9000);
@@ -49,7 +50,7 @@ test.describe('Stage 1 real models', () => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('canvas', { timeout: 30000 });
await page.locator('[data-testid="demo-play"]').first().click();
await ensureRunning(page);
// Play through several cases incl. the bottle case; scene must stay alive.
await page.waitForTimeout(15000);
@@ -120,7 +121,7 @@ test.describe('Stage 1 real models', () => {
const errors = watchConsole(page);
await page.goto('/?stage1=1&verify=real-models&sku=SKU-001', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('canvas', { timeout: 30000 });
await page.locator('[data-testid="demo-play"]').first().click();
await ensureRunning(page);
await page.waitForTimeout(8000);
const card = page.locator('text=STAGE1 VERIFY · SKU-001');
@@ -135,7 +136,7 @@ test.describe('Stage 1 real models', () => {
const errors = watchConsole(page);
await page.goto('/?stage1=1&verify=real-models&sku=SKU-011', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('canvas', { timeout: 30000 });
await page.locator('[data-testid="demo-play"]').first().click();
await ensureRunning(page);
// SKU-011 is the 7th playlist case — seek forward deterministically.
for (let i = 0; i < 6; i++) {

View File

@@ -1,17 +1,13 @@
import { test, expect } from '@playwright/test';
import { ensureRunning, openPausedCase } from './helpers';
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.setTimeout(90_000);
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 openPausedCase(page, 9, '1');
await expect(page.getByTestId('demo-case-label')).toHaveText('10/11');
await ensureRunning(page);
await expect
.poll(
@@ -20,14 +16,15 @@ test.describe('safety', () => {
const command = (await page.getByTestId('demo-command').textContent()) ?? '';
return `${status} ${command}`;
},
{ timeout: 25_000 },
{ timeout: 20_000, intervals: [50, 100, 100] },
)
.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 openPausedCase(page, 10, '1');
await expect(page.getByTestId('demo-case-label')).toHaveText('11/11');
await ensureRunning(page);
await expect
.poll(
@@ -36,7 +33,7 @@ test.describe('safety', () => {
const command = (await page.getByTestId('demo-command').textContent()) ?? '';
return `${status} ${command}`;
},
{ timeout: 25_000 },
{ timeout: 20_000, intervals: [50, 100, 100] },
)
.toMatch(/EMERGENCY/i);
});

View File

@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { ensureRunning, expectPlaybackControl } from './helpers';
test.describe('smoke', () => {
test('home opens, canvas loads, play works', async ({ page }) => {
@@ -13,10 +14,9 @@ test.describe('smoke', () => {
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 });
// Stage 2B: demo autostarts — Pause is present immediately; Play after pause.
await expectPlaybackControl(page);
await ensureRunning(page);
await expect(page.getByTestId('demo-status')).not.toHaveText('FINISHED');
const finished = page.getByTestId('demo-finished');

View File

@@ -3,11 +3,14 @@ import { test, expect, type Page } from '@playwright/test';
test.setTimeout(90_000);
async function waitScene(page: Page) {
await page.goto('/');
await page.goto('/?debug=1&playback=paused');
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
await page.evaluate(() => document.fonts.ready);
await expect(page.getByTestId('demo-hud')).toBeVisible();
// Demo auto-starts as running — pin speed early
// seek pins case 0 and starts running — pause again for deterministic HUD snaps
await page.getByTestId('demo-case-0').click();
const pause = page.getByTestId('demo-pause');
if (await pause.isVisible().catch(() => false)) await pause.click();
await page.getByTestId('demo-speed-2').click();
}
@@ -22,7 +25,7 @@ async function dismissFinishedIfNeeded(page: Page) {
async function jumpCase(page: Page, index: number) {
await dismissFinishedIfNeeded(page);
await page.getByTestId(`demo-case-${index}`).click();
await expect(page.getByTestId('demo-case-label')).toHaveText(`${index + 1}/10`, {
await expect(page.getByTestId('demo-case-label')).toHaveText(`${index + 1}/11`, {
timeout: 10_000,
});
}
@@ -52,7 +55,7 @@ test.describe('visual regression', () => {
if (await pause.isVisible().catch(() => false)) {
await pause.click();
}
await expect(page.getByTestId('demo-case-label')).toHaveText('1/10');
await expect(page.getByTestId('demo-case-label')).toHaveText('1/11');
await snapHud(page, '01-home-idle-hud.png');
});
@@ -85,7 +88,7 @@ test.describe('visual regression', () => {
test('05 jam fault HUD', async ({ page }) => {
await waitScene(page);
await jumpCase(page, 8);
await jumpCase(page, 9);
await ensureRunning(page);
await expect
.poll(async () => {
@@ -100,7 +103,7 @@ test.describe('visual regression', () => {
test('06 emergency stop HUD', async ({ page }) => {
await waitScene(page);
await jumpCase(page, 9);
await jumpCase(page, 10);
await ensureRunning(page);
await expect
.poll(async () => {
@@ -139,7 +142,7 @@ test.describe('visual regression', () => {
test('09 recovery after jam stop', async ({ page }) => {
await waitScene(page);
await jumpCase(page, 8);
await jumpCase(page, 9);
await ensureRunning(page);
await expect
.poll(async () => {