import { test, expect, type Page } from '@playwright/test'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import fs from 'node:fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FIXTURES = path.resolve(__dirname, '../fixtures/set1-48x2'); const ARTIFACTS = path.resolve(__dirname, 'artifacts'); const FACE_A = path.join(FIXTURES, 'face-a.jpeg'); const FACE_B = path.join(FIXTURES, 'face-b.jpeg'); const shot = async (page: Page, name: string) => { fs.mkdirSync(ARTIFACTS, { recursive: true }); await page.screenshot({ path: path.join(ARTIFACTS, name), fullPage: true, }); }; /** Discard any device draft so we always start on Batch. */ const ensureFreshBatch = async (page: Page) => { await page.goto('/'); // resumeProbe is async onMount — wait for either Resume or Batch. const resumeHeading = page.getByRole('heading', { name: /resume/i }); const batchHeading = page.getByRole('heading', { name: 'Add slab photos' }); await expect(resumeHeading.or(batchHeading)).toBeVisible({ timeout: 15_000 }); if (await resumeHeading.isVisible().catch(() => false)) { await page.getByRole('button', { name: /start fresh/i }).click(); } await expect(batchHeading).toBeVisible(); }; /** * Black-sheet fixtures: Auto may work, but UAT note says prefer Black + sensitivity * tweaks. Do not invent inference — only UI sheet/sensitivity controls. */ const prepareMaskForApprove = async (page: Page) => { await expect(page.getByRole('heading', { name: 'Mask' })).toBeVisible(); // Sheet change recomputes immediately (session.setSheet → runPass). await page.getByRole('button', { name: 'Black', exact: true }).click(); const approve = page.getByRole('button', { name: 'Approve mask' }); // First overlay after decode is debounced 2s; Black pass is immediate but // 4K fixtures still need decode + worker time. try { await expect(approve).toBeEnabled({ timeout: 45_000 }); return; } catch { /* try sensitivity ladder */ } // Higher sensitivity widens black sheet match; if still empty, try lower. for (const target of [60, 70, 40, 30, 80]) { const slider = page.locator('input[type="range"]'); await slider.fill(String(target)); // Sensitivity is debounced 2s — wait past debounce + worker. await page.waitForTimeout(2800); if (await approve.isEnabled()) return; } await expect(approve, 'Mask approve stayed disabled after Black + sensitivity tweaks').toBeEnabled({ timeout: 15_000, }); }; test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => { test('batch → pick → mask → measure (tape+fields) → photos ready + screenshots', async ({ page }) => { test.setTimeout(180_000); expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy(); expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy(); await ensureFreshBatch(page); // Gallery multi-input (not the capture=environment camera input). const gallery = page.locator('input[type="file"][multiple]'); await gallery.setInputFiles([FACE_A, FACE_B]); await expect(page.getByAltText('Photo 1')).toBeVisible({ timeout: 30_000 }); await expect(page.getByAltText('Photo 2')).toBeVisible(); await shot(page, '01-batch.png'); await page.getByRole('button', { name: 'Continue' }).click(); await expect(page.getByRole('heading', { name: 'Pick calibration photo' })).toBeVisible(); await shot(page, '02-pick-calibration.png'); await page.getByRole('button', { name: 'Use photo 1 as calibration' }).click(); await prepareMaskForApprove(page); await shot(page, '03-mask.png'); await page.getByRole('button', { name: 'Approve mask' }).click(); await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible(); await shot(page, '04-tape-measure.png'); await page.getByLabel('Length (in)').fill('48'); await page.getByLabel('Thickness').fill('2'); // blur/change commits eighths into session before Calculate. await page.getByLabel('Length (in)').blur(); await page.getByLabel('Thickness').blur(); await shot(page, '05-measure.png'); const calc = page.getByRole('button', { name: 'Calculate' }); await expect(calc).toBeEnabled(); await calc.click(); // Calculate encodes calibration PNG + overlay + sibling batch apply. await expect(page.getByRole('heading', { name: 'Photos ready' })).toBeVisible({ timeout: 120_000, }); await expect(page.getByLabel('Photos ready')).toBeVisible(); await expect(page.getByText('Reference photo')).toBeVisible(); await expect(page.getByText('Photo with measurements')).toBeVisible(); // Sibling face-b should appear as Photo 2 (capture order). await expect(page.getByText(/Photo 2|Photo 2 failed/)).toBeVisible(); await shot(page, '06-photos-ready.png'); // Write a short run log for parent AI visual review (not a substitute for eyes). const logPath = path.join(ARTIFACTS, 'run-log.txt'); const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-tape-measure.png', '05-measure.png', '06-photos-ready.png']; const lines = [ `M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`, `baseURL: ${page.url().split('/').slice(0, 3).join('/')}`, `finished: ${new Date().toISOString()}`, `artifacts:`, ...shots.map((s) => { const p = path.join(ARTIFACTS, s); const st = fs.existsSync(p) ? fs.statSync(p) : null; return ` - ${s} (${st ? `${st.size} bytes` : 'MISSING'})`; }), `assertions: Photos ready heading; Reference photo; Photo with measurements; batch sibling slot`, ]; fs.writeFileSync(logPath, lines.join('\n') + '\n', 'utf8'); }); });