610b0883fe
Species is chosen on the merged Measure screen; the Review select was a duplicate. Keep species_id on the slab from createSlab (Capture→draft→Woo) and gate Create title from the hydrated value without offering edit.
489 lines
21 KiB
TypeScript
489 lines
21 KiB
TypeScript
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<string, unknown> = {}) {
|
|
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<string, unknown> {
|
|
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',
|
|
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<string, unknown>;
|
|
onPublish?: (route: Route) => Promise<void>;
|
|
onCall1?: (route: Route) => Promise<void>;
|
|
onCall2?: (route: Route) => Promise<void>;
|
|
/** 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<string, unknown>) => {
|
|
// 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',
|
|
]) {
|
|
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
|
|
}
|
|
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
|
|
};
|
|
|
|
const reviewResult = (payload: Record<string, unknown>) => {
|
|
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;
|
|
|
|
// #89: the review ALWAYS returns the deterministic title/short_description built
|
|
// from the current metadata (the Create button's source of truth). The server
|
|
// does NOT persist title/short_description from the review payload.
|
|
const hasSpecies = !!slab.species_id;
|
|
const hasWoodCat = Array.isArray(slab.wood_category_ids) && slab.wood_category_ids.length > 0;
|
|
const deterministicTitle =
|
|
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab' : null;
|
|
const deterministicShort =
|
|
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab, 4ft 0in x 11in x 2in, 12.5 bdft' : null;
|
|
|
|
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.price == null) missing.push('price');
|
|
return {
|
|
ready: missing.length === 0,
|
|
missing,
|
|
title: deterministicTitle,
|
|
short_description: deterministicShort,
|
|
price: (slab.price as number) ?? null,
|
|
price_suggestion: 120,
|
|
primary_wood_category_id: primary,
|
|
};
|
|
};
|
|
|
|
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<string, unknown>; } 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,
|
|
speciesId: SPECIES_ID,
|
|
});
|
|
// 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) {
|
|
// Species comes from Measure/createSlab (seeded on the draft); no Review picker.
|
|
await expect(page.getByTestId('species')).toHaveCount(0);
|
|
// 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: fire the deterministic build so the title/short_description populate.
|
|
// Do this BEFORE the SKU blur so a subsequent plain review doesn't race.
|
|
await expect(page.getByTestId('create-title')).toBeEnabled();
|
|
await page.getByTestId('create-title').click();
|
|
// Wait for the build to complete (title field populated = deterministic 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→create→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 the Create build fires.
|
|
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
|
|
await expect(page.getByTestId('title')).toHaveValue('');
|
|
await completeForReady(page);
|
|
// #89: the Create build populated the deterministic copy on the slab row.
|
|
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 server-derived and NOT shown on Review (#90, METADATA-15)', async ({ page }) => {
|
|
await mockApi(page);
|
|
await reachReview(page);
|
|
|
|
// METADATA-15 / QUALITY-95: the band is derived server-side from the measured
|
|
// thickness against the live synced Woo bands. It is deliberately ABSENT from
|
|
// this screen — no owner control, no read-only echo. The only thickness the
|
|
// owner sees is the measured value in the summary. (Commit 204bc5e dropped the
|
|
// read-only echo; this test asserts the omission.)
|
|
await expect(page.getByTestId('thickness-band')).toHaveCount(0);
|
|
await expect(page.locator('#thickness, select[data-testid="thickness-band"]')).toHaveCount(0);
|
|
});
|
|
|
|
/**
|
|
* #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,
|
|
createBtn: box('[data-testid="create-title"]'),
|
|
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);
|
|
// Band is deliberately absent from Review (METADATA-15) — no element to measure.
|
|
expect(metrics.band).toBeNull();
|
|
expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32);
|
|
}
|
|
console.log('RENDER REPORT\n' + report.join('\n'));
|
|
});
|
|
});
|