import { test, expect, type Page, type Route } from '@playwright/test'; import { seedMeasureDraft } from './helpers/draft'; /** * M3 Review→Publish E2E (issue #77, QUALITY-50/105): drives the one-column * create→review→publish flow through the UI against a mocked API * (`page.route` on the whole /api/v1 namespace; no live backend under e2e). * * The capture→measure path needs real camera/canvas/Vision workers, so the * deterministic driver is the Resume path: seed an in-progress measure draft * (helpers/draft.ts) → Resume → Continue → mocked createSlab/uploadPhoto → * Review → Publish. All ids are synthetic fixtures (QUALITY-105 / METADATA-5). */ // --- Fixture taxonomy: fixture ids only, parented like the live Woo tree --- const SLAB_ID = '5f3c9a2e-0000-4000-8000-0000000000aa'; const SPECIES_GROUper_ID = 10; // "Species" grouper (parent_id null) const SPECIES_ID = 11; // Cedar (parent = species grouper) const WCAT_ID = 20; // "Wood category" grouper (parent null) const LIVEEDGE_ID = 21; // Live edge (parent = wcat) const BOWL_ID = 22; // Bowl blanks (parent = wcat) const FIG_TAG_ID = 101; // fig-burl product tag const FEAT_TAG_ID = 201; // feat-mineral-streaks product tag const TAXONOMY = { categories: [ { id: SPECIES_GROUper_ID, name: 'Species', slug: 'species', parent_id: null }, { id: SPECIES_ID, name: 'Cedar', slug: 'cedar', parent_id: SPECIES_GROUper_ID }, { id: 12, name: 'Maple', slug: 'maple', parent_id: SPECIES_GROUper_ID }, { id: WCAT_ID, name: 'Wood category', slug: 'wcat', parent_id: null }, { id: LIVEEDGE_ID, name: 'Live edge', slug: 'liveedge', parent_id: WCAT_ID }, { id: BOWL_ID, name: 'Bowl blanks', slug: 'bowlblanks', parent_id: WCAT_ID }, { id: 99, name: 'Chairs', slug: 'chairs', parent_id: null }, ], attributes: [ { id: 30, name: 'Edge Type', slug: 'edge_type', terms: [ { id: 31, name: 'Live edge', slug: 'live-edge' }, { id: 32, name: 'Square edge', slug: 'square-edge' }, ], }, { id: 33, name: 'Figure', slug: 'figure', terms: [ { id: 34, name: 'Burl', slug: 'burl' }, { id: 35, name: 'Birdseye', slug: 'birdseye' }, ], }, { id: 36, name: 'Grade', slug: 'grade', terms: [{ id: 37, name: 'A grade', slug: 'a-grade' }], }, { id: 38, name: 'Thickness', slug: 'thickness', // Real live band names: the Review screen only *displays* the derived band. terms: [ { id: 347, name: 'Under 1/2"', slug: 'xthin' }, { id: 348, name: '1/2" - 3/4"', slug: '2-3qtr' }, { id: 349, name: '3/4" - 1"', slug: '3-4qtr' }, { id: 350, name: '1"- 1 1/2"', slug: '4-6qtr' }, { id: 351, name: '1 1/2" - 2"', slug: '6-8qtr' }, { id: 352, name: '2" - 3"', slug: '8-12qtr' }, { id: 353, name: 'Over 3"', slug: '12qtr' }, ], }, { id: 40, name: 'Moisture', slug: 'moisture', terms: [{ id: 41, name: 'Kiln dried', slug: 'kiln-dried' }], }, ], tags: [ { id: 100, name: 'fig', slug: 'fig' }, { id: FIG_TAG_ID, name: 'fig-burl', slug: 'fig-burl' }, { id: 102, name: 'fig-birdseye', slug: 'fig-birdseye' }, { id: FEAT_TAG_ID, name: 'feat-mineral-streaks', slug: 'feat-mineral-streaks' }, { id: 202, name: 'feat-knots', slug: 'feat-knots' }, ], taxonomy_anchor: 'fixture', taxonomy_last_sync_at: '2026-01-01T00:00:00', }; function settingsView(over: Record = {}) { return { woo_base_url: 'https://example.invalid', woo_wp_username: 'wpuser', woo_app_password: { configured: true }, inference_base_url: { configured: false }, inference_api_key: { configured: false }, inference_model: null, inference_enabled: false, content_llm_enabled: false, brand_voice: null, geo_context: null, woo_create_status: 'draft', taxonomy_last_sync_at: '2026-01-01T00:00:00', taxonomy_anchor: 'fixture', ...over, }; } function baseSlab(): Record { return { id: SLAB_ID, sku: '', status: 'draft', price: null, price_source: 'recommendation', length_in: 48, thickness_in: 2, sqft: 6.25, bdft: 12.5, width_min_in: 10, width_max_in: 12, width_avg_in: 11, species_id: null, wood_category_ids: [], edge_type_term_id: null, figure_term_ids: [], grade_term_id: null, thickness_term_id: 352, moisture_term_id: null, fig_tag_ids: [], feat_tag_ids: [], title: null, short_description: null, description: null, content_source: 'manual', metadata_locked: false, species_confidence: null, figure_confidence: null, grade_confidence: null, inference_status: null, calibration_photo_id: null, woo_product_id: null, server_rev: 0, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }; } async function json(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); } interface ApiHooks { settings?: Record; onPublish?: (route: Route) => Promise; onCall1?: (route: Route) => Promise; onCall2?: (route: Route) => Promise; /** Counts inference endpoints hit (default: record to an array via closure). */ calls?: { call1: number; call2: number }; } /** Mock the full API for the flow. Slab PUTs are folded into a live row; the * review endpoint recomputes `ready` from the folded row and echoes the primary. */ async function mockApi(page: Page, hooks: ApiHooks = {}) { const calls = hooks.calls ?? { call1: 0, call2: 0 }; const slab = baseSlab(); let primary = null as number | null; const reviewPrimaries: number[] = []; const merge = (payload: Record) => { // Respect optimistic concurrency: reject stale client_rev (like the real backend). if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) { return false; // stale — do NOT merge } for (const k of [ 'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids', 'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids', 'title', 'short_description', 'description', 'content_source', 'price', 'price_source', 'metadata_locked', ]) { if (k in payload) (slab as Record)[k] = payload[k]; } if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1; }; // #89: deterministic copy is built only on an explicit lock. Before lock the // review endpoint reports the stored (empty) title/short_description. const buildCopy = () => { const speciesName = TAXONOMY.categories.find((c) => c.id === slab.species_id)?.name ?? 'Slab'; const fig = Array.isArray(slab.figure_term_ids) && slab.figure_term_ids.length ? 'Figured' : ''; return `${fig} ${speciesName} Live Edge Slab`; }; const reviewResult = (payload: Record) => { if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id; if (typeof payload.price === 'number') slab.price = payload.price; if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku; // Explicit lock (#89): persist the deterministic copy + lock flag. if (payload.action === 'lock') { const title = (typeof payload.title === 'string' && payload.title) ? (payload.title as string) : buildCopy(); (slab as Record).title = title; (slab as Record).short_description = `${title}, 4ft 0in x 11in x 2in, 12.5 bdft`; (slab as Record).metadata_locked = true; } const locked = (slab.metadata_locked as boolean) === true; const missing: string[] = []; if (!slab.sku) missing.push('sku'); if (!slab.species_id) missing.push('species'); if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories'); if (!slab.edge_type_term_id) missing.push('edge type'); if (!slab.title) missing.push('title'); if (!slab.short_description) missing.push('short description'); if (slab.price == null) missing.push('price'); return { ready: missing.length === 0, missing, title: (slab.title as string) ?? null, short_description: (slab.short_description as string) ?? null, price: (slab.price as number) ?? null, price_suggestion: 120, primary_wood_category_id: primary, locked, }; }; await page.route('**/api/v1/**', async (route) => { const req = route.request(); const url = new URL(req.url()); const path = url.pathname; const method = req.method(); const payload = (() => { try { return req.postDataJSON() as Record; } catch { return {}; } })(); if (path === '/api/v1/settings' && method === 'GET') { await json(route, settingsView(hooks.settings)); return; } if (path === '/api/v1/admin/taxonomy' && method === 'GET') { await json(route, TAXONOMY); return; } if (path === '/api/v1/slabs' && method === 'POST') { // The client generates the slab id (crypto.randomUUID, APP-20); adopt it so // the subsequent photo/review/publish paths (keyed by this id) resolve. slab.id = String(payload.id ?? SLAB_ID); merge(payload); // created with capture math; client fields empty await json(route, slab, 201); return; } if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') { if (merge(payload) === false) { await json(route, { error: { code: 'revision_conflict', message: 'client_rev mismatch', detail: { current_server_rev: slab.server_rev } } }, 409); return; } await json(route, slab); return; } if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') { await json(route, slab); return; } if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') { // Photo uploads are multipart; `payload` is {} — the client's photo_id is // in the form body, not JSON, so echo a stable synthetic id. await json(route, { id: 'photo-1', slab_id: slab.id, seq: 1, is_calibration: 1, original_retained: false, processed_retained: true, }, 201); return; } if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') { const result = reviewResult(payload); if (typeof result.primary_wood_category_id === 'number') { reviewPrimaries.push(result.primary_wood_category_id); } await json(route, result); return; } if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') { if (hooks.onPublish) { await hooks.onPublish(route); return; } await json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true }); return; } if (path === `/api/v1/slabs/${slab.id}/inference/call1` && method === 'POST') { calls.call1 += 1; if (hooks.onCall1) { await hooks.onCall1(route); return; } await json(route, { ok: false, suggestions: {} }); return; } if (path === `/api/v1/slabs/${slab.id}/inference/call2` && method === 'POST') { calls.call2 += 1; if (hooks.onCall2) { await hooks.onCall2(route); return; } await json(route, { ok: false, description: '' }); return; } await json(route, { error: { code: 'not_found', message: path } }, 404); }); return { calls, slab, reviewPrimaries }; } // Seed a measure draft (L=48in, T=2in, 6.25 sqft, 12.5 bdft, 11in avg width) and // drive Resume → Continue to land in Review. async function reachReview(page: Page) { // Phase 1: boot with ?noload so the app mounts (real origin for IndexedDB) // WITHOUT probing for a draft. Then seed the measure draft into IDB. await page.goto('/?noload'); await seedMeasureDraft(page, { sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11, }); // Phase 2: reload WITHOUT ?noload so onMount probes IDB and shows Resume. await page.goto('/'); // Resume prompt → Continue (restores measure). The mask re-derivation runs in // a worker; wait for the Photos-ready Continue control. await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 }); await page.getByRole('button', { name: 'Continue' }).click(); await expect(page.getByTestId('continue-to-review')).toBeVisible({ timeout: 20000 }); // Continue → mocked createSlab + uploadPhoto → Review screen. await page.getByTestId('continue-to-review').click(); await expect(page.getByTestId('review-screen')).toBeVisible({ timeout: 20000 }); } /** Fill every ready-gating field to make the slab publishable. */ async function completeForReady(page: Page) { await page.getByTestId('species').selectOption(String(SPECIES_ID)); // Wood categories: open, select Live edge, Done. await page.getByTestId('wood-cat-btn').click(); await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click(); await page.getByTestId('wood-cat-done').click(); await page.getByTestId('edge-type').selectOption('31'); await page.getByTestId('price').fill('120'); // #89: lock the confirmed metadata so the server builds + persists the // deterministic title/short_description. Do this BEFORE the SKU blur so a // subsequent plain review can't clobber the locked-note banner. await expect(page.getByTestId('lock-metadata')).toBeEnabled(); await page.getByTestId('lock-metadata').click(); // Wait for the lock to complete (title field populated = lock persisted copy). await expect(page.getByTestId('title')).not.toHaveValue(''); await page.getByTestId('sku').fill('SLAB-0001'); // SKU commits on blur — press Tab to move focus off it (deterministic, single // commit; a body click can re-focus the SKU and re-fire blur). await page.getByTestId('sku').press('Tab'); await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 }); } test.describe('M3 Review→Publish flow (#77)', () => { test('golden path: capture→review→lock→ready→publish→done (#77, #89)', async ({ page }) => { const { slab } = await mockApi(page); await reachReview(page); // Not ready until fields are set; copy is empty until an explicit lock. await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/); await expect(page.getByTestId('title')).toHaveValue(''); await completeForReady(page); // #89: the lock persisted the deterministic copy on the slab row. expect(slab.metadata_locked).toBe(true); expect(slab.title).toBeTruthy(); expect(slab.short_description).toBeTruthy(); // Continue to Publish is enabled and hands off. await expect(page.getByTestId('continue-publish')).toBeEnabled(); await page.getByTestId('continue-publish').click(); await expect(page.getByTestId('publish-screen')).toBeVisible(); // Publish → success → Done panel (draft in UAT store). await page.getByTestId('publish-btn').click(); await expect(page.getByText('Published')).toBeVisible({ timeout: 15000 }); await expect(page.getByText(/Woo product #4242/)).toBeVisible(); await expect(page.getByTestId('new-slab')).toBeVisible(); }); test('wood categories: closed summary shows names; primary radio among selected (#90, REVIEW-15)', async ({ page }) => { const { reviewPrimaries } = await mockApi(page); await reachReview(page); // Open wood MultiSelect, pick Live edge + Bowl blanks, Done. await page.getByTestId('wood-cat-btn').click(); await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click(); await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click(); await page.getByTestId('wood-cat-done').click(); // Closed summary shows selected names (not only a count). await expect(page.getByTestId('wood-cat-summary')).toContainText(/Live edge/i); await expect(page.getByTestId('wood-cat-summary')).toContainText(/Bowl blanks/i); // Primary set appears with both selected; default primary = first selected. const primarySet = page.getByTestId('primary-wood-set'); await expect(primarySet).toBeVisible(); await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'true'); await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'false'); // Owner switches primary to Bowl blanks; review payload carries it. await page.getByTestId(`primary-wood-${BOWL_ID}`).click(); await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'true'); await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'false'); await expect.poll(() => reviewPrimaries.at(-1)).toBe(BOWL_ID); }); test('thickness band is read-only and shows the derived band (#90, METADATA-15)', async ({ page }) => { await mockApi(page); await reachReview(page); // The band is derived server-side, so Review displays it as data. const band = page.getByTestId('thickness-band'); await expect(band).toBeVisible(); await expect(band).toContainText('2" - 3"'); // 2 in rounds into the live band // There is no owner control for it anywhere on Review — not a select, not an input. await expect(page.locator('#thickness, select[data-testid="thickness-band"]')).toHaveCount(0); expect(await band.evaluate((el) => el.tagName)).toBe('P'); }); /** * #90 served-surface render check: the changed Review surface (wood-category * MultiSelect + primary-radio set + read-only thickness band) is rendered at * desktop/tablet/mobile widths, measured, and captured to * tests/e2e/artifacts/ so layout and interaction can be inspected as evidence. */ test('Review surface renders at 1440/768/375 with no overflow (#90)', async ({ page }) => { const widths = [ { w: 1440, h: 900, name: 'desktop' }, { w: 768, h: 1024, name: 'tablet' }, { w: 375, h: 812, name: 'mobile' }, ]; const report: string[] = []; for (const vp of widths) { await page.setViewportSize({ width: vp.w, height: vp.h }); await mockApi(page); await reachReview(page); // Put the surface in its real state: two wood categories selected, primary set. await page.getByTestId('wood-cat-btn').click(); await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click(); await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click(); await page.getByTestId('wood-cat-done').click(); await expect(page.getByTestId('primary-wood-set')).toBeVisible(); const metrics = await page.evaluate(() => { const de = document.documentElement; const q = (sel: string) => document.querySelector(sel) as HTMLElement | null; const box = (sel: string) => { const el = q(sel); if (!el) return null; const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return { w: Math.round(r.width), h: Math.round(r.height), fontSize: cs.fontSize, overflowX: el.scrollWidth > el.clientWidth + 1, text: (el.textContent || '').trim().slice(0, 80), }; }; const primaryRow = q('[data-testid^="primary-wood-"]'); const primaryRowBox = primaryRow ? primaryRow.getBoundingClientRect() : null; // Anything whose box pokes outside the viewport, with enough identity to fix it. const offenders: { tag: string; cls: string; id: string; left: number; right: number }[] = []; document.querySelectorAll('body *').forEach((el) => { const r = el.getBoundingClientRect(); if (r.width === 0 && r.height === 0) return; if (r.right > de.clientWidth + 1 || r.left < -1) { offenders.push({ tag: el.tagName.toLowerCase(), cls: String(el.className || '').slice(0, 48), id: el.id, left: Math.round(r.left), right: Math.round(r.right), }); } }); return { docScrollW: de.scrollWidth, docClientW: de.clientWidth, pageHasHScroll: de.scrollWidth > de.clientWidth + 1, offenders: offenders.slice(0, 8), summary: box('[data-testid="wood-cat-summary"]'), count: box('[data-testid="wood-cat-count"]'), band: box('[data-testid="thickness-band"]'), primarySet: box('[data-testid="primary-wood-set"]'), primaryRowHeight: primaryRowBox ? Math.round(primaryRowBox.height) : null, lockBtn: box('[data-testid="lock-metadata"]'), continueBtn: box('[data-testid="continue-publish"]'), }; }); const shot = `tests/e2e/artifacts/review-90-${vp.name}-${vp.w}.png`; await page.screenshot({ path: shot, fullPage: true }); report.push(`${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`); // Print before asserting so the measurement survives a failing gate. console.log(`RENDER ${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`); // Layout gates: nothing spills sideways, the closed summary names both picks // (CSS ellipsis may shorten the paint, but the text must be there), and the // primary rows stay a comfortable tap target on mobile. expect(metrics.pageHasHScroll, `${vp.name} horizontal overflow: ${metrics.docScrollW} > ${metrics.docClientW}`).toBe(false); expect(metrics.summary?.text).toMatch(/Live edge/i); expect(metrics.summary?.text).toMatch(/Bowl blanks/i); expect(metrics.count?.text).toContain('2 selected'); expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w); expect(metrics.band?.text).toContain('2" - 3"'); expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32); } console.log('RENDER REPORT\n' + report.join('\n')); }); });