diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 13849ee..e2fcacf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,12 @@ "name": "slabuploader-frontend", "version": "0.0.0", "devDependencies": { + "@playwright/test": "^1.63.0", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.3", "pngjs": "^7.0.0", + "sirv-cli": "^3.0.1", "svelte": "^5.0.0", "svelte-check": "^4.1.1", "typescript": "^5.6.3", @@ -496,6 +498,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/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1128,6 +1145,15 @@ "node": ">=6" } }, + "node_modules/console-clear": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/console-clear/-/console-clear-1.1.1.tgz", + "integrity": "sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -1297,6 +1323,18 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1315,6 +1353,15 @@ "node": ">=6" } }, + "node_modules/local-access": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/local-access/-/local-access-1.1.0.tgz", + "integrity": "sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -1411,6 +1458,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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/pngjs": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", @@ -1518,6 +1592,15 @@ "node": ">=6" } }, + "node_modules/semiver": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/semiver/-/semiver-1.1.0.tgz", + "integrity": "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/set-cookie-parser": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", @@ -1544,6 +1627,28 @@ "node": ">=18" } }, + "node_modules/sirv-cli": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-3.0.1.tgz", + "integrity": "sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==", + "dev": true, + "dependencies": { + "console-clear": "^1.1.0", + "get-port": "^5.1.1", + "kleur": "^4.1.4", + "local-access": "^1.0.1", + "sade": "^1.6.0", + "semiver": "^1.0.0", + "sirv": "^3.0.0", + "tinydate": "^1.0.0" + }, + "bin": { + "sirv": "bin.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1621,6 +1726,15 @@ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true }, + "node_modules/tinydate": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinydate/-/tinydate-1.3.0.tgz", + "integrity": "sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6c1abda..83ece0e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,10 +11,12 @@ "test:watch": "vitest" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.3", "pngjs": "^7.0.0", + "sirv-cli": "^3.0.1", "svelte": "^5.0.0", "svelte-check": "^4.1.1", "typescript": "^5.6.3", diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 93d14a9..6f7c043 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -453,8 +453,8 @@

Capture Photos

Select 1–5 photos of the slab. Supported: JPG, PNG, WEBP.

- handleFiles(e.target.files)} class="file-input" /> -
{/each} - {/if} @@ -484,7 +484,7 @@
- { const knobs = getMaskKnobs(); applyMaskKnobs({ ...knobs, sheet: e.target.value as KnobSettings['sheet'] }); maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!); @@ -497,7 +497,7 @@
{ + oninput={(e) => { const knobs = getMaskKnobs(); applyMaskKnobs({ ...knobs, sensitivity: parseFloat(e.target.value) }); maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!); @@ -506,7 +506,7 @@
{ + oninput={(e) => { const knobs = getMaskKnobs(); applyMaskKnobs({ ...knobs, edgeOffset: parseInt(e.target.value) }); maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!); @@ -515,7 +515,7 @@
{ + oninput={(e) => { const knobs = getMaskKnobs(); applyMaskKnobs({ ...knobs, feather: parseInt(e.target.value) }); maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!); @@ -523,7 +523,7 @@
- { state.currentPhotoIndex = parseInt(e.target.value); maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!); }}> @@ -535,8 +535,8 @@
- - + +
{/if} @@ -551,8 +551,8 @@
- - + +
@@ -561,7 +561,7 @@
- { state.currentPhotoIndex = parseInt(e.target.value); axisCanvas && drawAxisOverlay(axisCanvas, currentPhoto()!); }}> @@ -573,8 +573,8 @@
- - + +
{/if} @@ -587,19 +587,19 @@
updateLengthIn(e.target.value)} /> + oninput={(e) => updateLengthIn(e.target.value)} /> 1/8" steps
updateThicknessIn(e.target.value)} /> + oninput={(e) => updateThicknessIn(e.target.value)} /> 1/8" steps
updateSku(e.target.value)} + oninput={(e) => updateSku(e.target.value)} class:invalid={!state.skuValidation.valid} /> {state.skuValidation.valid ? '✓ Valid' : state.skuValidation.errors.join(', ')}
@@ -633,8 +633,8 @@
⚠️ Source image is too small for 1600px minimum output. Consider retaking photos.
{/if}
- - + @@ -652,13 +652,13 @@

✓ Upload Successful

Slab ID: {state.reviewData?.id}

Server Rev: {state.reviewData?.serverRev}

- +
{:else if state.uploadError}

✗ Upload Failed

{state.uploadError}

- +
{:else}
@@ -681,8 +681,8 @@
- - +
diff --git a/frontend/test/bvt/playwright.config.ts b/frontend/test/bvt/playwright.config.ts new file mode 100644 index 0000000..652ea3a --- /dev/null +++ b/frontend/test/bvt/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import * as path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + testDir: '.', + fullyParallel: true, + forbidOnly: false, + retries: 1, + workers: 1, + use: { + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + webServer: { + command: 'npx sirv build --port 18080', + port: 18080, + reuseExistingServer: true, + cwd: path.resolve(__dirname, '../../'), + stdout: 'ignore', + stderr: 'pipe', + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], +}); diff --git a/frontend/test/bvt/select-photos.spec.ts b/frontend/test/bvt/select-photos.spec.ts new file mode 100644 index 0000000..f587ed7 --- /dev/null +++ b/frontend/test/bvt/select-photos.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEST_IMAGE = resolve(__dirname, 'test-slab.png'); + +test.describe('Select Photos BVT', () => { + test('label "Select Photos" has for attribute pointing to the file input id', async ({ page }) => { + await page.goto('/'); + + const label = page.locator('label.file-label'); + await expect(label).toBeVisible(); + const forAttr = await label.getAttribute('for'); + expect(forAttr).toBe('photo-file-input'); + + const fileInput = page.locator('#photo-file-input'); + await expect(fileInput).toHaveAttribute('type', 'file'); + }); + + test('file selection advances to mask step', async ({ page }) => { + await page.goto('/'); + + const fileInput = page.locator('#photo-file-input'); + await fileInput.setInputFiles(TEST_IMAGE); + + // handleFiles runs → state.step becomes 'mask' + await expect(page.locator('h2')).toContainText('Mask & Knobs'); + await expect(page.locator('.knobs-panel')).toBeVisible(); + + // Photo selector should have one option + const photoSelect = page.locator('.photo-selector select'); + await expect(photoSelect).toBeVisible(); + const optionCount = await photoSelect.locator('option').count(); + expect(optionCount).toBe(1); + }); + + test('Process Photos button is enabled after file selection', async ({ page }) => { + await page.goto('/'); + + const fileInput = page.locator('#photo-file-input'); + await fileInput.setInputFiles(TEST_IMAGE); + + // After processing, the mask step appears with Confirm Mask button + await expect(page.locator('h2')).toContainText('Mask & Knobs'); + const confirmBtn = page.locator('button:has-text("Confirm Mask")'); + await expect(confirmBtn).toBeEnabled(); + }); + + test('full flow: select photo → mask step appears → knobs can be adjusted', async ({ page }) => { + await page.goto('/'); + + const fileInput = page.locator('#photo-file-input'); + await fileInput.setInputFiles(TEST_IMAGE); + + // Should advance to mask step + await expect(page.locator('h2')).toContainText('Mask & Knobs'); + await expect(page.locator('.knobs-panel')).toBeVisible(); + + // Verify knobs are present and adjustable + const sensitivitySlider = page.locator('input[type="range"]'); + await expect(sensitivitySlider.first()).toBeVisible(); + }); +}); diff --git a/frontend/test/bvt/test-slab.png b/frontend/test/bvt/test-slab.png new file mode 100644 index 0000000..50ac406 Binary files /dev/null and b/frontend/test/bvt/test-slab.png differ