diff --git a/.gitignore b/.gitignore index fc5c1f6..f8eb5b4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,11 @@ images/ # Logs *.log + +# Playwright e2e (#65) — keep README; ignore churn +frontend/tests/e2e/artifacts/*.png +frontend/tests/e2e/artifacts/run-log.txt +frontend/tests/e2e/test-results/ +frontend/tests/e2e/playwright-report/ +frontend/blob-report/ +frontend/playwright/.cache/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aa1c1ca..39bd099 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "svelte": "^4.2.20" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@sveltejs/vite-plugin-svelte": "^3.1.2", "svelte-preprocess": "^5.1.4", "tsx": "^4.23.13", @@ -494,6 +495,21 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", @@ -1242,6 +1258,33 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8707b2f..8c31b89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,13 +7,16 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "test": "node --import tsx --test src/**/*.test.ts" + "test": "node --import tsx --test src/**/*.test.ts", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed" }, "dependencies": { "idb-keyval": "^6.3.0", "svelte": "^4.2.20" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@sveltejs/vite-plugin-svelte": "^3.1.2", "svelte-preprocess": "^5.1.4", "tsx": "^4.23.13", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..bc261cd --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * M2 #65 — Playwright visual evidence against local vite preview (not ubudual). + * Artifacts land in tests/e2e/artifacts/ (png churn gitignored; README committed). + */ +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + timeout: 180_000, + expect: { timeout: 30_000 }, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]], + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'off', + ...devices['Pixel 7'], + }, + webServer: { + command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + timeout: 180_000, + }, + outputDir: 'tests/e2e/test-results', +}); diff --git a/frontend/tests/e2e/artifacts/README.md b/frontend/tests/e2e/artifacts/README.md new file mode 100644 index 0000000..469f6c0 --- /dev/null +++ b/frontend/tests/e2e/artifacts/README.md @@ -0,0 +1,40 @@ +# E2E visual artifacts (#65) + +Screenshots from `npx playwright test` for Milestone 2 Photos-ready evidence. + +## How to run (local box — not ubudual :10880) + +```bash +cd frontend +npm ci +npx playwright install chromium # once per machine +npm run test:e2e +``` + +Or against an already-running preview: + +```bash +npm run build && npm run preview -- --host 127.0.0.1 --port 4173 +# other terminal: +npx playwright test +``` + +Fixtures: `tests/fixtures/set1-48x2/` (L=48, thickness=2) and `set2-31x2p5/` (L=31, thickness=2.5). Black sheet — e2e selects **Black** and may nudge sensitivity. + +## Expected screenshots (this folder) + +| File | Step | +|---|---| +| `01-batch.png` | Batch with fixtures loaded | +| `02-pick-calibration.png` | Pick calibration | +| `03-mask.png` | Mask (Black sheet) ready to approve | +| `04-axis.png` | Length axis | +| `05-measure.png` | Measure with L / thickness filled | +| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) | + +Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored. +Committed small evidence JPEGs live in `evidence/` (regenerate with a green `npm run test:e2e`, then re-encode if needed). + +## Unit tests + +`npm test` must still pass separately from Playwright. diff --git a/frontend/tests/e2e/artifacts/evidence/01-batch.jpg b/frontend/tests/e2e/artifacts/evidence/01-batch.jpg new file mode 100644 index 0000000..b832437 Binary files /dev/null and b/frontend/tests/e2e/artifacts/evidence/01-batch.jpg differ diff --git a/frontend/tests/e2e/artifacts/evidence/02-pick-calibration.jpg b/frontend/tests/e2e/artifacts/evidence/02-pick-calibration.jpg new file mode 100644 index 0000000..44a22ad Binary files /dev/null and b/frontend/tests/e2e/artifacts/evidence/02-pick-calibration.jpg differ diff --git a/frontend/tests/e2e/artifacts/evidence/03-mask.jpg b/frontend/tests/e2e/artifacts/evidence/03-mask.jpg new file mode 100644 index 0000000..a5367a6 Binary files /dev/null and b/frontend/tests/e2e/artifacts/evidence/03-mask.jpg differ diff --git a/frontend/tests/e2e/artifacts/evidence/04-axis.jpg b/frontend/tests/e2e/artifacts/evidence/04-axis.jpg new file mode 100644 index 0000000..07c8b4b Binary files /dev/null and b/frontend/tests/e2e/artifacts/evidence/04-axis.jpg differ diff --git a/frontend/tests/e2e/artifacts/evidence/05-measure.jpg b/frontend/tests/e2e/artifacts/evidence/05-measure.jpg new file mode 100644 index 0000000..693f10c Binary files /dev/null and b/frontend/tests/e2e/artifacts/evidence/05-measure.jpg differ diff --git a/frontend/tests/e2e/artifacts/evidence/06-photos-ready.jpg b/frontend/tests/e2e/artifacts/evidence/06-photos-ready.jpg new file mode 100644 index 0000000..91ebe01 Binary files /dev/null and b/frontend/tests/e2e/artifacts/evidence/06-photos-ready.jpg differ diff --git a/frontend/tests/e2e/artifacts/evidence/LAST_RUN.md b/frontend/tests/e2e/artifacts/evidence/LAST_RUN.md new file mode 100644 index 0000000..7f23454 --- /dev/null +++ b/frontend/tests/e2e/artifacts/evidence/LAST_RUN.md @@ -0,0 +1,11 @@ +# Last green e2e run (#65) + +- **When:** 2026-09-15 2:36 PM PT (`2026-09-15T21:36:28Z`) +- **Fixture:** `tests/fixtures/set1-48x2/` (L=48, thickness=2) +- **Server:** local `vite preview` at `http://127.0.0.1:4173` (not ubudual) +- **Result:** 1 passed (~7s test / ~14s wall with build) +- **Mask:** Black sheet @ sensitivity 50 — Approve enabled without sensitivity ladder +- **Photos ready numbers (from UI):** widths 6"–9 3/8" (avg 7 1/2"); sqft 2.13; bdft 4.26; scale 76.95 px/in +- **Lineup:** Clean calibration → Dimension overlay (48" length + width stations) → Batch photo 2 + +Full-resolution PNGs are produced under `../` on each run (gitignored). These JPEGs are commit-sized evidence for review. diff --git a/frontend/tests/e2e/photos-ready.spec.ts b/frontend/tests/e2e/photos-ready.spec.ts new file mode 100644 index 0000000..44c5d43 --- /dev/null +++ b/frontend/tests/e2e/photos-ready.spec.ts @@ -0,0 +1,135 @@ +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: 'Batch 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 → axis → measure → 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('Batch photo 1')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByAltText('Batch 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: 'Length axis' })).toBeVisible(); + await shot(page, '04-axis.png'); + await page.getByRole('button', { name: 'Approve axis' }).click(); + + await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible(); + await page.getByLabel('Length L').fill('48'); + await page.getByLabel('Thickness').fill('2'); + // blur/change commits eighths into session before Calculate. + await page.getByLabel('Length L').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('Clean calibration')).toBeVisible(); + await expect(page.getByText('Dimension overlay')).toBeVisible(); + // Sibling face-b should appear as Batch photo 2 (capture order). + await expect(page.getByText(/Batch 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-axis.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; Clean calibration; Dimension overlay; batch sibling slot`, + ]; + fs.writeFileSync(logPath, lines.join('\n') + '\n', 'utf8'); + }); +});