test(e2e): Playwright photos-ready flow + visual evidence (#65)
Drive batch → pick calibration → Mask (Black) → Axis → Measure Calculate → Photos ready against set1-48x2 fixtures on local vite preview. Capture screenshots under tests/e2e/artifacts/; commit README + small evidence JPEGs.
This commit is contained in:
@@ -35,3 +35,11 @@ images/
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.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/
|
||||||
|
|||||||
Generated
+43
@@ -12,6 +12,7 @@
|
|||||||
"svelte": "^4.2.20"
|
"svelte": "^4.2.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||||
"svelte-preprocess": "^5.1.4",
|
"svelte-preprocess": "^5.1.4",
|
||||||
"tsx": "^4.23.13",
|
"tsx": "^4.23.13",
|
||||||
@@ -494,6 +495,21 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.63.2",
|
"version": "4.63.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz",
|
"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==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
|
|||||||
@@ -7,13 +7,16 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"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": {
|
"dependencies": {
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"svelte": "^4.2.20"
|
"svelte": "^4.2.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||||
"svelte-preprocess": "^5.1.4",
|
"svelte-preprocess": "^5.1.4",
|
||||||
"tsx": "^4.23.13",
|
"tsx": "^4.23.13",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
@@ -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.
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user