Files
sapl/frontend/sapl-scraper-v2-report-check.cjs

1062 lines
63 KiB
JavaScript

// Scraper v2 report UI check — #1446 (brief-form site picker) + #1453 (report
// cards → Library garment sheet + real Save-to-Library) + #1569 (masonry
// spill regression) + #1524 (search → shareable board: report links the run's
// auto-board; multi-select → Add to board via the portaled picker) + #1544
// (structured brief filters: Category + Gender/segment + Design DNA pickers
// beside the brief, fetched live from GET /api/scraper/brief-filters and
// omitted from the run body when unset), against LOCAL PREVIEW.
//
// Mock-interception harness (repo idiom): every API route is fulfilled from
// fixtures, localStorage auth injection, one fresh chromium per viewport.
// The report page is reached by an IN-APP navigation (past-search card) so no
// mid-session reload is needed. Service workers are blocked (preview harness
// convention — mocked routes must win deterministically).
//
// Covers, at 1440x900 AND 390x844:
// /scraper (brief form, #1446 + #1460 + type-ahead picker + #1544 filters)
// - the type-ahead combobox renders the live registry from
// GET /api/scraper/sources (a mock-only label proves no hardcoded list)
// - filtering as you type narrows the list; selecting hides the option
// - page blurb names the LIVE labels
// - NO buyer-name input; run body carries the auto-label `sites · season`
// - run with NO selection → POST /api/scraper/run body OMITS `sources`
// - run with 2 picks → body.sources === ['mango','mockgrove']
// - #1544: three brief-filter pickers render with "Any …" defaults; the
// category menu lists the LIVE API vocabulary (a mock-only category
// proves no hardcoded list) and no invented value appears; picking a
// filter updates the trigger, clearing it back to "Any …" updates it
// again; an unfiltered run body OMITS category/gender/design_dna
// (backward-compatible), a filtered run body carries all three, and a
// CLEARED filter is omitted again
// - no native <select> anywhere; no horizontal overflow (incl. with a
// brief-filter menu open)
// /scraper/rep-1 (report, #1453 + #1464 Pinterest-clean + #1466 masonry)
// - NO header/stats/full-report/signals/vs-previous sections render
// - cards carry ONLY the View-on-source link below the image
// - masonry: exactly N flex columns per breakpoint (2/5 at 390/1440px),
// pins render natural aspects (no crop)
// - card tap → GET /api/scraper/items/item1 → EXACT Library sheet opens
// - shot carousel: /scraped/ image resolution + 2 thumbs + thumb swap
// - unsaved item = read-only sheet (no Edit/Delete); saved item refetches
// the garment and shows Edit (Library behaviour); the style-number read
// waits for the #1482 flight to land (innerText is '' while the panel is
// still visibility:hidden mid-glide)
// - save chip → Save-to-Board: portaled board picker → add-to-board POST
// for THAT item → toast; no garment is minted from a card (the retired
// /save route records + 404s — asserted never called)
// - modal stacking (centre / bottom / close-button hit-tests) + no overflow
//
// #1569 masonry-spill regression (JS-distributed columns), at 1440x900 AND
// 390x844:
// - /scraper/rep-14: 14 tall cards (~370px at 1440 — the geometry that
// spilled 4 cards into implicit 6th/7th columns under CSS multicol):
// exactly N columns, round-robin distribution (item i → column i%N),
// all 14 cards visible, overflowX == 0, no card beyond the grid, 16px
// gaps, zero console errors
// - 1/4/15-card shapes: N equal-width columns, all cards rendered, no
// overflow (no visual regression on the other shapes)
//
// Run: cd frontend && npm run build && (npm run preview -- --port 4173 &)
// node sapl-scraper-v2-report-check.cjs
const { chromium } = require('/root/.npm/_npx/e41f203b7505f1fb/node_modules/playwright');
const fs = require('fs');
const path = require('path');
const chromiumDirs = fs.readdirSync('/root/.cache/ms-playwright').filter((d) => d.startsWith('chromium') && !d.includes('headless_shell'));
const CHROME = path.join('/root/.cache/ms-playwright', chromiumDirs[chromiumDirs.length - 1], 'chrome-linux64', 'chrome');
const BASE = 'http://localhost:4173';
const OUT = path.resolve(__dirname, '..', '.qa-screens');
fs.mkdirSync(OUT, { recursive: true });
// 1x1 transparent PNG (mocked image bodies)
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
const USER = {
id: 'u1',
email: 'pratik@sonalapparels.in',
name: 'Pratik Sabat',
role: 'Admin',
scope: 'all',
is_active: true,
department: { id: 'dept-1', name: 'Design' },
permissions: {
library: 'manage', boards: 'manage', moodboard: 'manage', trends: 'manage',
analytics: 'view', review: 'manage', ingest: 'manage', inbox: 'view',
tna: 'view', users: 'none', data: 'none',
},
};
// ── Fixtures ─────────────────────────────────────────────────────────────────
const SOURCES = {
sources: [
{ key: 'zara', label: 'Zara', genders: ['women', 'men'] },
{ key: 'mango', label: 'Mango', genders: ['women'] },
{ key: 'next', label: 'Next', genders: ['women', 'men'] },
// A label that exists NOWHERE in the code — proves the blurb/chips read the
// live registry instead of a hardcoded brand list.
{ key: 'mockgrove', label: 'Mockgrove', genders: ['women'] },
],
};
// ── #1544 structured brief filters ───────────────────────────────────────────
// The vocabulary GET /api/scraper/brief-filters serves to the three pickers
// (Category / Gender-segment / Design DNA). 'Mockwear' exists NOWHERE in the
// app — it proves the picker renders the LIVE API vocabulary instead of a
// hardcoded frontend list (the Mockgrove idiom). Labels mirror the backend:
// the house §1.3 taxonomy canonicals, the registry's genders with the
// Library's Womens/Mens/Kids labels, the five design-DNAs.
const BRIEF_FILTERS = {
categories: [
{ value: 'Shirt', label: 'Shirt' },
{ value: 'T-Shirt', label: 'T-Shirt' },
{ value: 'Sweatshirt', label: 'Sweatshirt' },
{ value: 'Hoodie', label: 'Hoodie' },
{ value: 'Trousers', label: 'Trousers' },
{ value: 'Jeans', label: 'Jeans' },
{ value: 'Shorts', label: 'Shorts' },
{ value: 'Skirt', label: 'Skirt' },
{ value: 'Dress', label: 'Dress' },
{ value: 'Kurta', label: 'Kurta' },
{ value: 'Outerwear', label: 'Outerwear' },
{ value: 'Mockwear', label: 'Mockwear' },
],
genders: [
{ value: 'women', label: 'Womens' },
{ value: 'men', label: 'Mens' },
{ value: 'kids', label: 'Kids' },
],
design_dna: [
{ value: 'romantic_feminine', label: 'Romantic feminine' },
{ value: 'heritage_check_plaid', label: 'Heritage check plaid' },
{ value: 'animal_elevated', label: 'Animal elevated' },
{ value: 'classic_minimal', label: 'Classic minimal' },
{ value: 'mixed', label: 'Mixed' },
],
};
const SEARCH_REP1 = {
id: 'rep-1', buyer_name: 'Primark', season: 'AW26', requirements: 'relaxed linen',
status: 'complete', items_count: '3',
started_at: '2026-10-10T09:00:00', completed_at: '2026-10-10T09:05:00',
agent_thinking: [], report_text: 'Report body — linen layering.', trend_signals: ['Linen layering'],
// Non-empty on purpose: the Pinterest-clean report (#1464) must NOT render it.
comparison_notes: 'Mock-only comparison note', routing_info: null,
// #1524 — the run's auto-board (the report links to /boards/<id>).
board_id: 'board-auto-1',
};
const SEARCH_REP9 = {
...SEARCH_REP1, id: 'rep-9', buyer_name: 'Testbuyer', status: 'failed',
items_count: '0', error_message: 'Mock agent failure', board_id: null,
};
const ITEMS = {
items: [
{
id: 'item1', source: 'zara', brand: 'Zara', title: 'Ivory linen dress',
image_url: 'https://example.com/item1.jpg', image_path: 'item1_hero.jpg',
page_url: 'https://example.com/p/1', tags: { category: 'dress', design_dna: 'Scraped test dress' },
trend_signal: 'hero', trend_keywords: ['linen'], sapl_relevance: 'HIGH',
analysis_notes: 'notes', saved_to_library: false, scraped_at: '2026-10-10 09:00:00',
},
{
id: 'item2', source: 'mango', brand: 'Mango', title: 'Printed maxi',
image_url: 'https://example.com/item2.jpg', image_path: 'item2_hero.jpg',
page_url: 'https://example.com/p/2', tags: { category: 'dress' },
trend_signal: 'trend', trend_keywords: [], sapl_relevance: 'MEDIUM',
analysis_notes: '', saved_to_library: false, scraped_at: '2026-10-10 09:05:00',
},
{
id: 'item3', source: 'next', brand: 'Next', title: 'Saved knit top',
image_url: 'https://example.com/item3.jpg', image_path: 'item3_hero.jpg',
page_url: 'https://example.com/p/3', tags: { category: 'top', design_dna: 'Saved knit top' },
trend_signal: 'basic', trend_keywords: [], sapl_relevance: 'LOW',
analysis_notes: '', saved_to_library: true, scraped_at: '2026-10-10 09:10:00',
},
],
};
// ── #1569 masonry fixtures ───────────────────────────────────────────────────
// The regression geometry: 14 cards, each one TALL (~370px at 1440 desktop
// 5-column) — the exact content shape under which Chrome's `column-fill:
// balance` balancer spilled 4 indivisible break-inside-avoid cards into
// implicit 6th/7th columns (501px page overflow). pin-mid.jpg is 400x533
// (h/w 1.333) → ~350px image + ~22px link line ≈ 372px cards.
const pinFixture = (id, pin) => ({
id, source: 'zara', brand: 'Zara', title: `Masonry pin ${id}`,
image_url: `https://example.com/${id}.jpg`, image_path: `${pin}.jpg`,
page_url: `https://example.com/m/${id}`, tags: { category: 'dress' },
trend_signal: 'basic', trend_keywords: [], sapl_relevance: 'LOW',
analysis_notes: '', saved_to_library: false, scraped_at: '2026-10-10 09:30:00',
});
const TALL_ITEMS = { items: Array.from({ length: 14 }, (_, i) => pinFixture(`tall${i + 1}`, 'pin-mid')) };
const SHAPE1_ITEMS = { items: [pinFixture('shape1_1', 'pin-square')] };
const SHAPE4_ITEMS = { items: ['pin-tall', 'pin-mid', 'pin-square', 'pin-tall'].map((p, i) => pinFixture(`shape4_${i + 1}`, p)) };
const SHAPE15_ITEMS = { items: Array.from({ length: 15 }, (_, i) => pinFixture(`shape15_${i + 1}`, ['pin-tall', 'pin-mid', 'pin-square'][i % 3])) };
const SEARCH_REP14 = { ...SEARCH_REP1, id: 'rep-14', buyer_name: 'Overflow14 · AW26', items_count: '14' };
const SEARCH_REP1C = { ...SEARCH_REP1, id: 'rep-1c', buyer_name: 'Shape1 · AW26', items_count: '1' };
const SEARCH_REP4C = { ...SEARCH_REP1, id: 'rep-4c', buyer_name: 'Shape4 · AW26', items_count: '4' };
const SEARCH_REP15C = { ...SEARCH_REP1, id: 'rep-15c', buyer_name: 'Shape15 · AW26', items_count: '15' };
// 43 keys — exactly the unsaved sidecar the backend synthesizes (#1453):
// synthesized garment dict + all_tags, shot files as /scraped/<file> URLs.
const SIDECAR_UNSAVED = {
id: 'item1', style_number: null, series_code: null, season: 'AW26', collection_name: null,
description: 'Scraped test dress', image_path: '/scraped/item1_hero.jpg',
fabric_composition: '100% linen', design_team_city: null, is_co_ord_set: false,
review_status: 'pending_human_review', status: 'prototype', origin: 'scraped',
origin_source: 'zara', sample_available: false, sample_location: null, shelf_style_code: null,
group: null, macro_family: null, construction_details: [], weave_knit_structure: null,
fabric_family: null, segment: 'new_dev', category: null, colourway_code: null,
pattern_number: null, counters: null, fabric_quality: null, is_draft: false,
draft_reason: null, is_deleted: false, deleted_at: null,
shots: ['front', 'back'], shot_count: 2, capture_mode: 'scraped',
shot_details: [{ role: 'front', role_confidence: 92 }, { role: 'back', role_confidence: 81 }],
capture_degraded: false, qr_generated_at: null,
top_tags: {
category: 'dress', design_dna: 'Scraped test dress',
colour: [{ colour_name: 'Ivory', hex: '#f3efe6' }],
sleeve: 'sleeveless', coloration_method: 'printed', value_addition: 'embroidery',
},
created_at: '2026-10-10T09:00:00', updated_at: '2026-10-10T09:00:00',
shot_images: [
{ role: 'front', role_confidence: 92, image_path: '/scraped/item1_hero.jpg' },
{ role: 'back', role_confidence: 81, image_path: '/scraped/item1_back.jpg' },
],
all_tags: [
{ field_name: 'category', field_value: 'dress', confidence: 0.5, confidence_label: 'medium', source: 'gpt5_vision' },
{ field_name: 'design_dna', field_value: 'Scraped test dress', confidence: 0.5, confidence_label: 'medium', source: 'gpt5_vision' },
{ field_name: 'colour', field_value: '[{"colour_name":"Ivory","hex":"#f3efe6"}]', confidence: 0.5, confidence_label: 'medium', source: 'gpt5_vision' },
{ field_name: 'sleeve', field_value: 'sleeveless', confidence: 0.5, confidence_label: 'medium', source: 'gpt5_vision' },
{ field_name: 'coloration_method', field_value: 'printed', confidence: 0.5, confidence_label: 'medium', source: 'gpt5_vision' },
{ field_name: 'value_addition', field_value: 'embroidery', confidence: 0.5, confidence_label: 'medium', source: 'gpt5_vision' },
],
};
// The promoted garment's own sidecar — what GET /api/garments/g3 and the saved
// item sidecar (GARMENT3 + garment_id) both serve. Bare filenames → /images/.
const GARMENT3 = {
...SIDECAR_UNSAVED,
id: 'g3', style_number: 'SCRAPED-FIX3', series_code: 'unknown',
description: 'Saved knit top', image_path: 'garment3.jpg', origin_source: 'next',
shots: ['front', 'detail_fabric'],
shot_details: [{ role: 'front', role_confidence: 90 }, { role: 'detail_fabric', role_confidence: 77 }],
top_tags: {
category: 'top', design_dna: 'Saved knit top',
colour: [{ colour_name: 'Navy', hex: '#1f2a44' }], sleeve: 'long',
},
shot_images: [
{ role: 'front', role_confidence: 90, image_path: 'garment3.jpg' },
{ role: 'detail_fabric', role_confidence: 77, image_path: 'garment3_1.jpg' },
],
all_tags: [
{ field_name: 'category', field_value: 'top', confidence: 0.5, confidence_label: 'medium', source: 'gpt5_vision' },
{ field_name: 'design_dna', field_value: 'Saved knit top', confidence: 0.5, confidence_label: 'medium', source: 'gpt5_vision' },
],
};
const SIDECAR_SAVED = { ...GARMENT3, garment_id: 'g3' };
const SEASONS = {
seasons: [
{ id: 's1', code: 'AW26', sort_order: 2, is_active: true },
{ id: 's2', code: 'SS26', sort_order: 1, is_active: true },
],
newest_active_code: 'AW26',
};
// ── #1524 board fixtures ─────────────────────────────────────────────────────
// The run's auto-board (what the report links to) + a second board for the
// picker's "existing boards" list.
const BOARD_INDEX = {
boards: [
{
id: 'board-auto-1', name: 'Primark · AW26 · linen', kind: 'board',
owner_id: 'u1', owner_city: 'unknown', buyer_name: 'Primark', season: 'AW26',
visibility: 'org', is_deleted: false, item_count: 3,
cover_image_path: null, preview_image_paths: [],
created_at: '2026-10-10T09:05:00', updated_at: '2026-10-10T09:05:00',
},
{
id: 'board-picks-2', name: 'Picks wall', kind: 'board',
owner_id: 'u1', owner_city: 'unknown', buyer_name: null, season: null,
visibility: 'org', is_deleted: false, item_count: 0,
cover_image_path: null, preview_image_paths: [],
created_at: '2026-10-11T10:00:00', updated_at: '2026-10-11T10:00:00',
},
],
};
const BOARD_DETAIL = { board: BOARD_INDEX.boards[0], items: [] };
/** The add-to-board response for a captured request body (per-item results).
* #1453 re-scope: RAW links — no `saved`/`promoted` keys, item garment_id null
* unless the row had already graduated. */
function addToBoardResult(body) {
const ids = body.scraped_item_ids || [];
const newBoard = !!body.new_board_name;
return {
board_id: newBoard ? 'board-new-1' : body.board_id,
board_name: newBoard ? body.new_board_name : 'Picks wall',
created_board: newBoard,
added: ids.length,
already_in_board: 0,
failed: 0,
items: ids.map((id) => ({
item_id: id, status: 'added', garment_id: null, style_number: null,
})),
};
}
let failures = [];
function check(name, ok, detail = '') {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
if (!ok) failures.push(name);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Node-side poll (for values the page can't see, e.g. captured request bodies). */
async function waitFor(fn, timeout = 10000) {
const t0 = Date.now();
while (!fn()) {
if (Date.now() - t0 > timeout) return false;
await sleep(100);
}
return true;
}
async function setupRoutes(page, stats) {
// Broad logger first; exact-URL routes below take precedence (routes run in
// reverse registration order) and every request they don't cover continues
// to the preview server and is reported here.
await page.route('**/api/**', (r) => {
console.log(` ROUTE other: ${r.request().method()} ${r.request().url()}`);
return r.continue();
});
await page.route('**/api/notifications/unread-count*', (r) => r.fulfill({ json: { count: 0 } }));
await page.route('**/api/notifications*', (r) => r.fulfill({ json: [] }));
await page.route('**/api/auth/me*', (r) => r.fulfill({ json: { user: USER } }));
await page.route('**/api/auth/version*', (r) => r.fulfill({ json: { revision: 1 } }));
await page.route('**/api/releases/latest*', (r) => r.fulfill({ status: 404, json: { detail: 'NO_RELEASE' } }));
await page.route('**/api/seasons*', (r) => r.fulfill({ json: SEASONS }));
await page.route('**/api/feed/share*', (r) => r.fulfill({ json: { shared: false } }));
// Static media (backend mounts) — the adapted /scraped/ URLs and garment
// /images/ filenames must both resolve. Grid pins serve aspect-distinct
// JPEGs (#1466 masonry proof); everything else gets the 1px PNG.
const PINS = {
item1_hero: fs.readFileSync(path.join(__dirname, 'public', 'test-pins', 'pin-tall.jpg')),
item2_hero: fs.readFileSync(path.join(__dirname, 'public', 'test-pins', 'pin-mid.jpg')),
item3_hero: fs.readFileSync(path.join(__dirname, 'public', 'test-pins', 'pin-square.jpg')),
// #1569 fixtures address the pins by bare filename.
'pin-tall': fs.readFileSync(path.join(__dirname, 'public', 'test-pins', 'pin-tall.jpg')),
'pin-mid': fs.readFileSync(path.join(__dirname, 'public', 'test-pins', 'pin-mid.jpg')),
'pin-square': fs.readFileSync(path.join(__dirname, 'public', 'test-pins', 'pin-square.jpg')),
};
await page.route('**/scraped/**', (r) => {
const name = (r.request().url().split('/').pop() || '').replace(/\.[^.]+$/, '');
const pin = PINS[name];
return pin
? r.fulfill({ status: 200, contentType: 'image/jpeg', body: pin })
: r.fulfill({ status: 200, contentType: 'image/png', body: PNG });
});
await page.route('**/images/**', (r) => r.fulfill({ status: 200, contentType: 'image/png', body: PNG }));
// ── #1446 site picker + run ───────────────────────────────────────────────
await page.route('**/api/scraper/sources', (r) => { stats.sources++; return r.fulfill({ json: SOURCES }); });
// #1544 — the structured brief-filter vocabulary (category / gender / DNA).
await page.route('**/api/scraper/brief-filters', (r) => { stats.briefFilters++; return r.fulfill({ json: BRIEF_FILTERS }); });
await page.route('**/api/scraper/run', (r) => {
stats.runBodies.push(r.request().postDataJSON());
return r.fulfill({ json: { search_id: 'rep-9', status: 'running' } });
});
await page.route('**/api/scraper/searches', (r) => r.fulfill({ json: { searches: [SEARCH_REP1] } }));
await page.route('**/api/scraper/searches/rep-9', (r) => r.fulfill({ json: SEARCH_REP9 }));
await page.route('**/api/scraper/searches/rep-1', (r) => r.fulfill({ json: SEARCH_REP1 }));
await page.route('**/api/scraper/searches/rep-1/items', (r) => r.fulfill({ json: ITEMS }));
// ── #1569 masonry fixtures: 14-tall regression + 1/4/15-card shapes ───────
await page.route('**/api/scraper/searches/rep-14', (r) => r.fulfill({ json: SEARCH_REP14 }));
await page.route('**/api/scraper/searches/rep-14/items', (r) => r.fulfill({ json: TALL_ITEMS }));
await page.route('**/api/scraper/searches/rep-1c', (r) => r.fulfill({ json: SEARCH_REP1C }));
await page.route('**/api/scraper/searches/rep-1c/items', (r) => r.fulfill({ json: SHAPE1_ITEMS }));
await page.route('**/api/scraper/searches/rep-4c', (r) => r.fulfill({ json: SEARCH_REP4C }));
await page.route('**/api/scraper/searches/rep-4c/items', (r) => r.fulfill({ json: SHAPE4_ITEMS }));
await page.route('**/api/scraper/searches/rep-15c', (r) => r.fulfill({ json: SEARCH_REP15C }));
await page.route('**/api/scraper/searches/rep-15c/items', (r) => r.fulfill({ json: SHAPE15_ITEMS }));
// ── #1453 item sidecar + Save-to-Board (#1453 re-scope) ──────────────────
await page.route('**/api/scraper/items/item1', async (r) => {
stats.itemGets++;
await sleep(350); // long enough to observe the card's busy overlay
return r.fulfill({ json: SIDECAR_UNSAVED });
});
await page.route('**/api/scraper/items/item3', (r) => {
stats.itemGets++;
return r.fulfill({ json: SIDECAR_SAVED });
});
// The RETIRED promote-to-garment save route: records + 404s. Under the
// re-scope a card must NEVER call it (the tick means graduated; the chip
// saves to a board) — the harness asserts the record stays empty.
await page.route('**/api/scraper/items/*/save', async (r) => {
stats.saveBodies.push(r.request().postDataJSON());
return r.fulfill({ status: 404, json: { detail: 'Item not found' } });
});
// The saved sheet's own refetches (Library behaviour — g.id is a garment id).
await page.route('**/api/garments/g3', (r) => r.fulfill({ json: GARMENT3 }));
await page.route('**/api/garments/g3/similar', (r) => { stats.similar++; return r.fulfill({ json: { similar: [] } }); });
await page.route('**/api/techpacks*', (r) => r.fulfill({ json: { total: 0, items: [] } }));
// ── #1524 board flow ──────────────────────────────────────────────────────
// The picker's board list (GET /api/collections) — the bare index URL only,
// so the ad-hoc POSTs of the library flow are not swallowed.
await page.route('**/api/collections', (r) => {
stats.boardIndex++;
return r.fulfill({ json: BOARD_INDEX });
});
// The multi-select action: save-to-library + link each item.
await page.route('**/api/scraper/items/add-to-board', (r) => {
const body = r.request().postDataJSON();
stats.addToBoardBodies.push(body);
return r.fulfill({ json: addToBoardResult(body) });
});
// The auto-board link target (BoardDetail) + its comment thread.
await page.route('**/api/collections/board-auto-1', (r) => r.fulfill({ json: BOARD_DETAIL }));
await page.route('**/api/boards/board-auto-1/comments*', (r) => r.fulfill({ json: { comments: [] } }));
await page.route('**/api/presentations/by-board/*', (r) => r.fulfill({ status: 404, json: { detail: 'NO_ARTIFACT' } }));
}
async function injectAuth(page) {
await page.evaluate((u) => {
localStorage.setItem('sapl_token', 'test-token');
localStorage.setItem('sapl_user', JSON.stringify(u));
}, USER);
}
const noOverflow = () => document.documentElement.scrollWidth <= window.innerWidth + 1;
async function runScenario(label, viewport) {
const browser = await chromium.launch({ executablePath: CHROME });
try {
const ctx = await browser.newContext({ viewport, serviceWorkers: 'block' });
const page = await ctx.newPage();
const stats = { sources: 0, briefFilters: 0, runBodies: [], itemGets: 0, saveBodies: [], similar: 0, boardIndex: 0, addToBoardBodies: [] };
const pageErrors = [];
page.on('pageerror', (e) => pageErrors.push(String(e)));
await setupRoutes(page, stats);
// ── Part A: brief form + site picker (#1446) ────────────────────────────
await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' });
await injectAuth(page);
await page.goto(BASE + '/scraper', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => /Trend Scraper/.test(document.body.innerText), null, { timeout: 30000 });
await page.locator('[data-testid=scraper-sites]').waitFor({ state: 'visible', timeout: 30000 });
await sleep(600);
check(`${label}: sources endpoint hit once`, stats.sources === 1, `sources=${stats.sources}`);
// Type-ahead combobox (replaces the old site chips): the registry's labels
// render as options, filtered as you type; picking one hides it.
const sitesInput = page.locator('[data-testid=scraper-sites-input]');
await sitesInput.click();
await page.locator('[data-testid=scraper-sites-list]').waitFor({ state: 'visible', timeout: 8000 });
await page.waitForFunction(
(n) => document.querySelectorAll('[data-testid=scraper-site-option]').length === n, 4,
{ timeout: 5000 }).catch(() => {});
const optionTexts = await page.locator('[data-testid=scraper-site-option]').allInnerTexts();
check(`${label}: combobox offers the 4 live registry sites`,
optionTexts.length === 4 && optionTexts.some((t) => t.includes('Mockgrove')) &&
optionTexts.some((t) => t.includes('Mango')),
JSON.stringify(optionTexts));
await sitesInput.fill('man');
await sleep(200);
const filteredOptionTexts = await page.locator('[data-testid=scraper-site-option]').allInnerTexts();
check(`${label}: typing filters the site list`, filteredOptionTexts.length === 1 &&
filteredOptionTexts[0].includes('Mango'), JSON.stringify(filteredOptionTexts));
await sitesInput.fill('');
await page.keyboard.press('Escape');
await sleep(200);
const allSitesNote = await page.evaluate(() => document.body.innerText.includes('All enabled sites'));
check(`${label}: empty selection = All sites default`, allSitesNote === true, `note=${allSitesNote}`);
const blurb = await page.evaluate(() => {
const p = Array.from(document.querySelectorAll('p')).find((el) => (el.className || '').includes('max-w-lg'));
return p ? p.textContent || '' : null;
});
check(`${label}: page blurb names the LIVE registry (mock-only label present)`,
!!blurb && blurb.includes('Mockgrove') && blurb.includes('research'),
JSON.stringify(blurb));
check(`${label}: hardcoded brand blurb (Zara, Mango, and Pinterest) is gone`,
!!blurb && !blurb.includes('Pinterest') && !/Zara, Mango, and/.test(blurb), JSON.stringify(blurb));
await page.screenshot({ path: path.join(OUT, `scraper-v2-form-${label}.png`) });
// #1460: no buyer-name field — the run is auto-labelled `sites · season`.
check(`${label}: buyer-name input is gone`, (await page.locator('input[placeholder="e.g. Primark"]').count()) === 0);
// ── #1544 brief filters: Category + Gender/segment (+ Design DNA) ──────
// The three portaled pickers sit next to the brief; their vocabularies come
// LIVE from GET /api/scraper/brief-filters (the mock-only 'Mockwear' proves
// no hardcoded frontend list).
check(`${label}: brief-filter vocabulary fetched once`, stats.briefFilters === 1, `hit=${stats.briefFilters}`);
const catTrigger = page.locator('[data-testid=brief-category-picker]');
const genderTrigger = page.locator('[data-testid=brief-gender-picker]');
const dnaTrigger = page.locator('[data-testid=brief-design-dna-picker]');
check(`${label}: three brief-filter pickers render with Any defaults`,
(await catTrigger.innerText()).includes('Any category') &&
(await genderTrigger.innerText()).includes('Any gender') &&
(await dnaTrigger.innerText()).includes('Any design DNA'),
JSON.stringify([await catTrigger.innerText(), await genderTrigger.innerText(), await dnaTrigger.innerText()]));
const optionRegex = (text) => new RegExp(`^${text}$`);
const pickFromOpenMenu = async (text) => {
await page.locator('[role=listbox] [role=option]', { hasText: optionRegex(text) })
.click({ timeout: 8000 });
await sleep(250);
};
await catTrigger.click();
await page.locator('[role=listbox] [role=option]').first().waitFor({ state: 'visible', timeout: 8000 });
await sleep(400); // let the item stagger settle before reading/clicking
check(`${label}: no horizontal overflow with the category menu open`, await page.evaluate(noOverflow));
const catOptions = await page.locator('[role=listbox] [role=option]').allInnerTexts();
check(`${label}: category picker lists the LIVE taxonomy vocabulary`,
catOptions.length === BRIEF_FILTERS.categories.length + 1 &&
catOptions[0] === 'Any category' &&
['Shirt', 'T-Shirt', 'Sweatshirt', 'Hoodie', 'Trousers', 'Jeans', 'Shorts',
'Skirt', 'Dress', 'Kurta', 'Outerwear'].every((v) => catOptions.includes(v)) &&
catOptions.includes('Mockwear') && !catOptions.includes('Sneakers'),
JSON.stringify(catOptions));
await pickFromOpenMenu('Sweatshirt');
check(`${label}: picking a category updates the trigger`,
(await catTrigger.innerText()).includes('Sweatshirt'), await catTrigger.innerText());
await catTrigger.click();
await pickFromOpenMenu('Any category');
check(`${label}: clearing the category returns the picker to Any`,
(await catTrigger.innerText()).includes('Any category'), await catTrigger.innerText());
await genderTrigger.click();
await sleep(400);
const genderOptions = await page.locator('[role=listbox] [role=option]').allInnerTexts();
check(`${label}: gender picker lists Womens/Mens/Kids (Any first)`,
JSON.stringify(genderOptions) === JSON.stringify(['Any gender', 'Womens', 'Mens', 'Kids']),
JSON.stringify(genderOptions));
await pickFromOpenMenu('Mens');
check(`${label}: picking a gender updates the trigger`,
(await genderTrigger.innerText()).includes('Mens'), await genderTrigger.innerText());
await genderTrigger.click();
await pickFromOpenMenu('Any gender');
check(`${label}: clearing the gender returns the picker to Any`,
(await genderTrigger.innerText()).includes('Any gender'), await genderTrigger.innerText());
await dnaTrigger.click();
await sleep(400);
const dnaOptions = await page.locator('[role=listbox] [role=option]').allInnerTexts();
check(`${label}: design-DNA picker lists the five house DNAs (Any first)`,
dnaOptions.length === 6 && dnaOptions[0] === 'Any design DNA' &&
dnaOptions.includes('Heritage check plaid') && dnaOptions.includes('Mixed'),
JSON.stringify(dnaOptions));
await dnaTrigger.click(); // close without picking (toggle)
await sleep(200);
// Run 1 — no site selected, NO filters: the body must OMIT `sources` (all
// enabled sites) AND every structured filter key (the pre-#1544 contract),
// carrying the auto-label `All sites · AW26`.
await page.locator('button', { hasText: 'Run Agent' }).click();
const r1 = await waitFor(() => stats.runBodies.length >= 1);
check(`${label}: run #1 body omits \`sources\` (backward-compatible default)`,
r1 && !('sources' in stats.runBodies[0]) && stats.runBodies[0].buyer_name === 'All sites · AW26',
JSON.stringify(stats.runBodies[0]));
check(`${label}: run #1 body OMITS the unfiltered brief filters (free-text-only contract)`,
r1 && !('category' in stats.runBodies[0]) && !('gender' in stats.runBodies[0]) &&
!('design_dna' in stats.runBodies[0]),
JSON.stringify(stats.runBodies[0]));
await page.waitForFunction(() => /search failed/i.test(document.body.innerText), null, { timeout: 15000 });
// Reset via the failed panel, then Run 2 — two combobox picks + the three
// structured filters.
await page.locator('button', { hasText: 'New search' }).click();
await sitesInput.click();
await page.locator('[data-testid=scraper-sites-list]').waitFor({ state: 'visible', timeout: 8000 });
await page.locator('[data-testid=scraper-site-option][data-site-key=mango]').click();
await page.locator('[data-testid=scraper-site-option][data-site-key=mockgrove]').click();
const pillCount = await page.locator('[data-testid=scraper-site-pill]').count();
check(`${label}: picks render as removable pills and leave the list`,
pillCount === 2 &&
(await page.locator('[data-testid=scraper-site-pill][data-site-key=mango]').count()) === 1 &&
(await page.locator('[data-testid=scraper-site-option][data-site-key=mango]').count()) === 0,
`pills=${pillCount}`);
await page.keyboard.press('Escape');
await catTrigger.click();
await pickFromOpenMenu('T-Shirt');
await genderTrigger.click();
await pickFromOpenMenu('Mens');
await dnaTrigger.click();
await pickFromOpenMenu('Heritage check plaid');
await page.screenshot({ path: path.join(OUT, `scraper-v2-form-selected-${label}.png`) });
await page.locator('button', { hasText: 'Run Agent' }).click();
const r2 = await waitFor(() => stats.runBodies.length >= 2);
check(`${label}: run #2 body carries the picked keys + auto-label`,
r2 && JSON.stringify(stats.runBodies[1].sources) === JSON.stringify(['mango', 'mockgrove']) &&
stats.runBodies[1].buyer_name === 'Mango, Mockgrove · AW26',
JSON.stringify(stats.runBodies[1]));
check(`${label}: run #2 body carries the picked brief filters`,
r2 && stats.runBodies[1].category === 'T-Shirt' &&
stats.runBodies[1].gender === 'men' &&
stats.runBodies[1].design_dna === 'heritage_check_plaid',
JSON.stringify(stats.runBodies[1]));
await page.waitForFunction(() => /search failed/i.test(document.body.innerText), null, { timeout: 15000 });
// Run 3 — pick a filter, then CLEAR it back to "Any …": the body must omit
// the key again (clearing is the pre-#1544 contract, not an empty value).
await page.locator('button', { hasText: 'New search' }).click();
await catTrigger.click();
await pickFromOpenMenu('Mockwear');
await catTrigger.click();
await pickFromOpenMenu('Any category');
await page.locator('button', { hasText: 'Run Agent' }).click();
const r3 = await waitFor(() => stats.runBodies.length >= 3);
check(`${label}: cleared filters are omitted from the run body`,
r3 && !('category' in stats.runBodies[2]) && !('gender' in stats.runBodies[2]) &&
!('design_dna' in stats.runBodies[2]),
JSON.stringify(stats.runBodies[2]));
await page.waitForFunction(() => /search failed/i.test(document.body.innerText), null, { timeout: 15000 });
check(`${label}: no native <select> on /scraper`, (await page.locator('select').count()) === 0);
check(`${label}: /scraper no horizontal overflow`, await page.evaluate(noOverflow));
// ── Part B: report page — in-app navigation via the past-search card ────
await page.locator('button', { hasText: 'Primark' }).first().click();
await page.waitForFunction(() => location.pathname === '/scraper/rep-1', null, { timeout: 15000 });
await page.locator('[data-testid=scraped-card-item1]').waitFor({ state: 'visible', timeout: 30000 });
check(`${label}: report renders 3 scraped cards`, (await page.locator('[data-testid^=scraped-card-]').count()) === 3);
// #1464 Pinterest-clean: insight sections are gone (fixtures carry the data,
// so absence proves removal, not empty fixtures).
const reportText = await page.evaluate(() => document.body.innerText);
check(`${label}: no report header`, !/trend report/i.test(reportText) && (await page.locator('h1').count()) === 0);
check(`${label}: no stat blocks`, !/items collected/i.test(reportText));
check(`${label}: no full-report reader`, !/read full report/i.test(reportText) && !/report body — linen layering/i.test(reportText));
check(`${label}: no confirmed-signals sidebar`, !/confirmed trend signals/i.test(reportText) && !/linen layering/i.test(reportText));
check(`${label}: no vs-previous block`, !/vs previous search/i.test(reportText) && !/mock-only comparison note/i.test(reportText));
const card1Text = await page.locator('[data-testid=scraped-card-item1]').innerText();
check(`${label}: card shows ONLY the source link (no title/notes/badges)`,
!card1Text.includes('Ivory linen dress') && /view on zara/i.test(card1Text) && !/HIGH|MED|LOW/.test(card1Text),
JSON.stringify(card1Text));
// #1569 masonry: JS-distributed flex columns — exactly N column divs per
// breakpoint (no CSS multicol left to spill into implicit columns).
const gridShape = await page.locator('[data-testid=scraped-masonry]').evaluate((el) => ({
display: getComputedStyle(el).display,
columns: [...el.children].map((c) => getComputedStyle(c).display + '/' + getComputedStyle(c).flexDirection),
}));
const expectCols = label === '390x844' ? 2 : 5;
check(`${label}: masonry renders ${expectCols} flex columns`,
gridShape.display === 'flex' && gridShape.columns.length === expectCols &&
gridShape.columns.every((c) => c === 'flex/column'),
`display=${gridShape.display} columns=${gridShape.columns.length}`);
await page.waitForFunction(() => {
const imgs = [...document.querySelectorAll('[data-testid^=scraped-card-] img')];
return imgs.length === 3 && imgs.every((i) => i.complete && i.naturalHeight > 1);
}, null, { timeout: 15000 });
const pinAspects = await page.locator('[data-testid^=scraped-card-]').evaluateAll((cards) =>
cards.map((c) => {
const img = c.querySelector('img');
const r = img.getBoundingClientRect();
return {
id: c.getAttribute('data-testid'),
natural: +(img.naturalHeight / img.naturalWidth).toFixed(3),
rendered: +(r.height / r.width).toFixed(3),
h: Math.round(r.height),
};
}));
check(`${label}: pins render natural aspects (no crop)`,
pinAspects.every((p) => Math.abs(p.natural - p.rendered) < 0.02),
JSON.stringify(pinAspects.map((p) => `${p.id}:${p.rendered}~${p.natural}`)));
check(`${label}: pin heights stay staggered (3 distinct aspects)`,
new Set(pinAspects.map((p) => p.h)).size === 3,
`heights=${JSON.stringify(pinAspects.map((p) => p.h))}`);
// Unsaved card → the EXACT Library sheet (adapted /scraped/ shots).
await page.locator('[data-testid=scraped-card-item1]').click();
await page.locator('[data-testid=scraped-opening-item1]').waitFor({ state: 'visible', timeout: 3000 });
check(`${label}: card shows busy overlay while the item fetch is in flight`, true);
await page.locator('[data-testid=garment-detail-modal]').waitFor({ state: 'visible', timeout: 15000 });
await page.locator('[data-testid=shot-gallery-main] img').waitFor({ state: 'visible', timeout: 10000 });
const mainSrc = await page.evaluate(() => document.querySelector('[data-testid=shot-gallery-main] img')?.getAttribute('src'));
check(`${label}: unsaved hero resolves via /scraped/ (adapted URL)`,
mainSrc === `${BASE}/scraped/item1_hero.jpg`, String(mainSrc));
const stripCount = await page.locator('[data-testid=shot-gallery-strip]').getAttribute('data-shot-count');
const thumbs = await page.locator('[data-testid^=shot-thumb-]').count();
check(`${label}: shot carousel — 2 thumbs`, stripCount === '2' && thumbs === 2, `count=${stripCount} thumbs=${thumbs}`);
const label1 = (await page.locator('[data-testid=shot-gallery-label]').innerText()).trim();
check(`${label}: carousel starts on 1/2`, /^1\/2/.test(label1), label1);
await page.locator('[data-testid=shot-thumb-1]').click();
const label2 = (await page.locator('[data-testid=shot-gallery-label]').innerText()).trim();
const mainSrc2 = await page.evaluate(() => document.querySelector('[data-testid=shot-gallery-main] img')?.getAttribute('src'));
check(`${label}: thumb swap → 2/2 + back shot`,
/^2\/2/.test(label2) && mainSrc2 === `${BASE}/scraped/item1_back.jpg`, `${label2} · ${mainSrc2}`);
const modalText = await page.locator('[data-testid=garment-detail-modal]').innerText();
check(`${label}: sheet renders the sidecar (title + composition + review chip)`,
modalText.includes('Scraped test dress') && /100% Linen/.test(modalText) && /ai tags · in review/i.test(modalText),
modalText.slice(0, 160).replace(/\n/g, ' | '));
check(`${label}: captured-shots section present (Scraped)`, /captured shots/i.test(modalText) && /scraped/i.test(modalText));
check(`${label}: unsaved item = read-only sheet (no Edit/Delete)`,
(await page.locator('[data-testid=garment-detail-modal] button', { hasText: /^Edit$/ }).count()) === 0 &&
(await page.locator('[data-testid=garment-detail-modal] button', { hasText: /^Delete$/ }).count()) === 0);
const stacking = await page.evaluate(() => {
const modal = document.querySelector('[data-testid="garment-detail-modal"]');
if (!modal) return { ok: false, why: 'no modal' };
const cx = Math.floor(window.innerWidth / 2);
const centre = document.elementFromPoint(cx, Math.floor(window.innerHeight / 2));
const bottom = document.elementFromPoint(cx, window.innerHeight - 40);
const closeBtn = Array.from(modal.querySelectorAll('button')).find((b) => (b.getAttribute('title') || '').startsWith('Close'));
let closeClickable = false;
if (closeBtn) {
const r = closeBtn.getBoundingClientRect();
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
closeClickable = !!(hit && (hit === closeBtn || closeBtn.contains(hit)));
}
return {
ok: !!(centre && centre.closest('[data-testid="garment-detail-modal"]')) &&
!!(bottom && bottom.closest('[data-testid="garment-detail-modal"]')) && closeClickable,
centre: centre ? centre.tagName : null, closeClickable,
};
});
check(`${label}: sheet stacking — centre, bottom edge and close button all hit inside`, stacking.ok, JSON.stringify(stacking));
check(`${label}: no horizontal overflow with the sheet open`, await page.evaluate(noOverflow));
await page.screenshot({ path: path.join(OUT, `scraper-v2-modal-unsaved-${label}.png`) });
await page.keyboard.press('Escape');
await page.locator('[data-testid=garment-detail-modal]').waitFor({ state: 'detached', timeout: 8000 });
// Saved card → Library behaviour (garment refetch → Edit enabled, /images/).
await page.locator('[data-testid=scraped-card-item3]').click();
await page.locator('[data-testid=garment-detail-modal]').waitFor({ state: 'visible', timeout: 15000 });
await page.waitForFunction(() => {
const b = Array.from(document.querySelectorAll('[data-testid="garment-detail-modal"] button')).find((x) => (x.textContent || '').trim() === 'Edit');
return !!b && !b.disabled;
}, null, { timeout: 15000 });
const savedSrc = await page.evaluate(() => document.querySelector('[data-testid=shot-gallery-main] img')?.getAttribute('src'));
check(`${label}: saved item refetches the garment (Edit enabled, /images/ resolution)`,
stats.similar === 1 && /\/images\/garment3\.jpg$/.test(String(savedSrc)), `similar=${stats.similar} src=${savedSrc}`);
// #1482: the sheet opens through the flight choreography — its panel is
// `visibility: hidden` until the glide lands (`data-phase` → 'open') and
// the text cascades in after that, so `innerText` is '' until the sheet
// settles. Wait for the landing before reading the rendered text.
await page.waitForFunction(() => {
const f = document.querySelector('[data-testid=sheet-flight]');
if (f && f.getAttribute('data-phase') !== 'open') return false;
const m = document.querySelector('[data-testid="garment-detail-modal"]');
return !!m && (m.innerText || '').trim().length > 0;
}, null, { timeout: 10000 }).catch(() => {});
const savedModalText = await page.locator('[data-testid=garment-detail-modal]').innerText();
check(`${label}: saved sheet shows the garment style number`,
savedModalText.includes('SCRAPED-FIX3'), savedModalText.slice(0, 160).replace(/\n/g, ' | '));
await page.screenshot({ path: path.join(OUT, `scraper-v2-modal-saved-${label}.png`) });
await page.keyboard.press('Escape');
await page.locator('[data-testid=garment-detail-modal]').waitFor({ state: 'detached', timeout: 8000 });
// Save-to-Board (#1453 re-scope): the chip opens the portaled board picker
// for THAT item; picking a board links it RAW. No garment is minted from a
// card — the retired /save route must never be called.
// Click the chip's core (24x24 at the button's top-left) — the hover-revealed
// 48x48 hit-box's exact centre sits on the chip/box boundary.
await page.locator('[data-testid=scraped-save-item1]').click({ position: { x: 12, y: 12 } });
await page.locator('[data-testid=board-picker]').waitFor({ state: 'visible', timeout: 10000 });
check(`${label}: card chip opens the board picker for that item`,
/add 1 to board/i.test(await page.locator('[data-testid=board-picker]').innerText()),
(await page.locator('[data-testid=board-picker]').innerText()).slice(0, 80).replace(/\n/g, ' | '));
check(`${label}: chip did NOT open the sheet + no saved tick appeared`,
(await page.locator('[data-testid=garment-detail-modal]').count()) === 0 &&
(await page.locator('[data-testid=scraped-saved-item1]').count()) === 0);
await page.locator('[data-testid=board-picker-option-board-picks-2]').click();
const chipAdd = await waitFor(() => stats.addToBoardBodies.length >= 1);
check(`${label}: chip save-to-board POST carries just item1`,
chipAdd && JSON.stringify(stats.addToBoardBodies[0]) ===
JSON.stringify({ scraped_item_ids: ['item1'], board_id: 'board-picks-2' }),
JSON.stringify(stats.addToBoardBodies[0]));
const chipToast = await (async () => {
const t0 = Date.now();
let txt = '';
while (Date.now() - t0 < 8000) {
txt = (await page.locator('.toast-item').allInnerTexts()).join(' | ');
if (/added to “picks wall”/i.test(txt)) break;
await sleep(150);
}
return txt;
})();
check(`${label}: chip toast reports the board add (no library save)`,
/added to “picks wall”/i.test(chipToast) && /1 added/.test(chipToast) &&
!/saved to library/i.test(chipToast), JSON.stringify(chipToast));
check(`${label}: retired /save route was never called`,
stats.saveBodies.length === 0, JSON.stringify(stats.saveBodies));
await page.screenshot({ path: path.join(OUT, `scraper-v2-save-to-board-${label}.png`) });
// ── #1524: search → shareable board (auto-board link + selective add) ───
/** Poll the toast stack (the add toast lands a beat after the click). */
const waitToast = async (re, timeout = 8000) => {
const t0 = Date.now();
let txt = '';
while (Date.now() - t0 < timeout) {
txt = (await page.locator('.toast-item').allInnerTexts()).join(' | ');
if (re.test(txt)) return txt;
await sleep(150);
}
return txt;
};
check(`${label}: report shows the run's auto-board link`,
(await page.locator('[data-testid=scraped-open-board]').count()) === 1);
check(`${label}: no select controls before select mode`,
(await page.locator('input[data-testid^=scraped-select-]').count()) === 0);
await page.locator('[data-testid=scraped-select-toggle]').click();
check(`${label}: select mode renders a checkbox per card`,
(await page.locator('input[data-testid^=scraped-select-]').count()) === 3);
// Card tap toggles selection — it must NOT open the sheet in select mode.
await page.locator('[data-testid=scraped-card-item1]').click();
await page.locator('[data-testid=scraped-select-item2]').click();
check(`${label}: picks tracked (card tap + checkbox), no sheet in select mode`,
(await page.locator('[data-testid=scraped-select-count]').innerText()).trim().toLowerCase() === '2 selected' &&
(await page.locator('[data-testid=garment-detail-modal]').count()) === 0 &&
(await page.locator('[data-testid=scraped-select-item1]').isChecked()) &&
(await page.locator('[data-testid=scraped-select-item2]').isChecked()));
await page.locator('[data-testid=scraped-select-all]').click();
const allCount = (await page.locator('[data-testid=scraped-select-count]').innerText()).trim().toLowerCase();
await page.locator('[data-testid=scraped-select-none]').click();
const noneCount = (await page.locator('[data-testid=scraped-select-count]').innerText()).trim().toLowerCase();
check(`${label}: select-all then clear (none)`, allCount === '3 selected' && noneCount === '0 selected',
`all=${allCount} none=${noneCount}`);
check(`${label}: Add to board disabled with nothing selected`,
await page.locator('[data-testid=scraped-add-to-board]').isDisabled());
// Re-pick two → picker → add to the existing board.
await page.locator('[data-testid=scraped-select-item1]').click();
await page.locator('[data-testid=scraped-select-item2]').click();
await page.locator('[data-testid=scraped-add-to-board]').click();
await page.locator('[data-testid=board-picker]').waitFor({ state: 'visible', timeout: 8000 });
check(`${label}: picker lists the caller's boards (no native <select>)`,
stats.boardIndex >= 1 &&
(await page.locator('[data-testid=board-picker-option-board-picks-2]').count()) === 1 &&
(await page.locator('[data-testid=board-picker-option-board-auto-1]').count()) === 1 &&
(await page.locator('[data-testid=board-picker] select').count()) === 0);
check(`${label}: no horizontal overflow with the picker open`, await page.evaluate(noOverflow));
await page.screenshot({ path: path.join(OUT, `scraper-v2-board-picker-${label}.png`) });
await page.locator('[data-testid=board-picker-search]').fill('picks');
await page.waitForFunction(() => document.querySelectorAll('[data-testid^=board-picker-option-]').length === 1, null, { timeout: 5000 }).catch(() => {});
check(`${label}: typing filters the board list`,
(await page.locator('[data-testid^=board-picker-option-]').count()) === 1);
await page.locator('[data-testid=board-picker-search]').fill('');
await page.locator('[data-testid=board-picker-option-board-picks-2]').click();
const add1 = await waitFor(() => stats.addToBoardBodies.length >= 2);
check(`${label}: add-to-board POST body carries the picked items + board`,
add1 && JSON.stringify(stats.addToBoardBodies[1]) ===
JSON.stringify({ scraped_item_ids: ['item1', 'item2'], board_id: 'board-picks-2' }),
JSON.stringify(stats.addToBoardBodies[1]));
const addToast = await waitToast(/2 added/i);
check(`${label}: toast reports the board add (counts only — no library save)`,
/added to “picks wall”/i.test(addToast) && /2 added/.test(addToast) && !/saved to library/i.test(addToast),
JSON.stringify(addToast));
check(`${label}: picker closed + selection cleared + select mode exited`,
(await page.locator('[data-testid=board-picker]').count()) === 0 &&
(await page.locator('[data-testid=scraped-select-toggle]').count()) === 1 &&
(await page.locator('[data-testid=scraped-select-count]').count()) === 0);
// Create-new inline: one item → a brand-new board (no confirm dialog).
await page.locator('[data-testid=scraped-select-toggle]').click();
await page.locator('[data-testid=scraped-select-item3]').click();
await page.locator('[data-testid=scraped-add-to-board]').click();
await page.locator('[data-testid=board-picker]').waitFor({ state: 'visible', timeout: 8000 });
// The panel (and its trailing Create control) must sit fully inside the
// viewport — the control-row trigger sits at the right edge, so an
// un-clamped panel would push the Create button off-screen.
const pickerBox = await page.locator('[data-testid=board-picker]').evaluate((el) => {
const r = el.getBoundingClientRect();
const create = document.querySelector('[data-testid=board-picker-create]').getBoundingClientRect();
return {
top: Math.round(r.top), bottom: Math.round(r.bottom),
left: Math.round(r.left), right: Math.round(r.right),
createLeft: Math.round(create.left), createRight: Math.round(create.right),
innerW: window.innerWidth, innerH: window.innerHeight,
};
});
check(`${label}: picker panel + its Create control stay inside the viewport`,
pickerBox.top >= 0 && pickerBox.bottom <= pickerBox.innerH &&
pickerBox.left >= 0 && pickerBox.right <= pickerBox.innerW &&
pickerBox.createRight <= pickerBox.innerW, JSON.stringify(pickerBox));
await page.locator('[data-testid=board-picker-new-name]').fill('AW26 wall');
await page.locator('[data-testid=board-picker-create]').click();
const add2 = await waitFor(() => stats.addToBoardBodies.length >= 3);
check(`${label}: create-new sends new_board_name`,
add2 && JSON.stringify(stats.addToBoardBodies[2]) ===
JSON.stringify({ scraped_item_ids: ['item3'], new_board_name: 'AW26 wall' }),
JSON.stringify(stats.addToBoardBodies[2]));
const createToast = await waitToast(/board “aw26 wall” created/i);
check(`${label}: toast reports the created board`, /board “aw26 wall” created/i.test(createToast), JSON.stringify(createToast));
check(`${label}: no horizontal overflow after the board flow`, await page.evaluate(noOverflow));
await page.screenshot({ path: path.join(OUT, `scraper-v2-board-created-${label}.png`) });
// The auto-board link navigates to the board detail.
await page.locator('[data-testid=scraped-open-board]').click();
await page.waitForFunction(() => location.pathname === '/boards/board-auto-1', null, { timeout: 15000 });
await page.waitForFunction(() => document.body.innerText.includes('Primark · AW26 · linen'), null, { timeout: 15000 });
check(`${label}: Open board → /boards/<run board> renders the board`,
await page.evaluate(() => location.pathname === '/boards/board-auto-1' &&
document.body.innerText.includes('Primark · AW26 · linen')));
await page.screenshot({ path: path.join(OUT, `scraper-v2-board-open-${label}.png`) });
check(`${label}: no JS page errors`, pageErrors.length === 0, pageErrors.slice(0, 3).join(' | '));
await ctx.close();
} finally {
await browser.close();
}
}
/** #1569 masonry-spill regression (JS-distributed columns): the report must
* render exactly N flex columns — spilling beyond them is structurally
* impossible. Exercises the 14-tall-card failing geometry (~370px cards,
* the shape that spilled 4 cards into implicit 6th/7th columns under CSS
* multicol) plus the 1/4/15-card shapes for visual parity. */
async function runMasonryCases(label, viewport) {
const browser = await chromium.launch({ executablePath: CHROME });
try {
const ctx = await browser.newContext({ viewport, serviceWorkers: 'block' });
const page = await ctx.newPage();
const stats = { sources: 0, briefFilters: 0, runBodies: [], itemGets: 0, saveBodies: [], similar: 0, boardIndex: 0, addToBoardBodies: [] };
const pageErrors = [];
const consoleErrors = [];
page.on('pageerror', (e) => pageErrors.push(String(e)));
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push({ text: m.text(), url: m.location()?.url || '' }); });
await setupRoutes(page, stats);
const expectCols = viewport.width >= 1280 ? 5 : viewport.width >= 1024 ? 4 : viewport.width >= 640 ? 3 : 2;
const openReport = async (id, waitCard) => {
await page.goto(BASE + '/scraper/' + id, { waitUntil: 'domcontentloaded' });
await page.locator(`[data-testid=scraped-card-${waitCard}]`).waitFor({ state: 'visible', timeout: 30000 });
// Pins are loading="lazy" — scroll through so every card fetches its
// image (the tall geometry only exists once images are decoded), then
// settle back at the top before measuring.
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForFunction(() => {
const imgs = [...document.querySelectorAll('[data-testid^=scraped-card-] img')];
return imgs.length > 0 && imgs.every((i) => i.complete && i.naturalHeight > 1);
}, null, { timeout: 20000 });
await page.evaluate(() => window.scrollTo(0, 0));
await sleep(250);
};
// ── The 14-tall-card regression (the failing geometry) ──────────────────
await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' });
await injectAuth(page);
await openReport('rep-14', 'tall14');
const grid = await page.evaluate(() => {
const el = document.querySelector('[data-testid=scraped-masonry]');
const cols = [...el.children];
const cards = [...document.querySelectorAll('[data-testid^=scraped-card-]')];
const col0Cards = [...cols[0].querySelectorAll('[data-testid^=scraped-card-]')];
return {
display: getComputedStyle(el).display,
colCount: cols.length,
colDisplays: cols.map((c) => getComputedStyle(c).display + '/' + getComputedStyle(c).flexDirection),
widths: cols.map((c) => Math.round(c.getBoundingClientRect().width)),
cardsByCol: cols.map((c) => [...c.querySelectorAll('[data-testid^=scraped-card-]')]
.map((x) => x.getAttribute('data-testid').replace('scraped-card-', ''))),
cardCount: cards.length,
allVisible: cards.every((c) => { const r = c.getBoundingClientRect(); return r.width > 0 && r.height > 0; }),
maxCardRight: Math.round(Math.max(...cards.map((c) => c.getBoundingClientRect().right))),
gridRight: Math.round(el.getBoundingClientRect().right),
overflow: document.documentElement.scrollWidth - window.innerWidth,
cardH: Math.round(cards[0].getBoundingClientRect().height),
colGap: cols.length > 1 ? Math.round(cols[1].getBoundingClientRect().left - cols[0].getBoundingClientRect().right) : null,
vGap: col0Cards.length > 1
? Math.round(col0Cards[1].getBoundingClientRect().top - col0Cards[0].getBoundingClientRect().bottom)
: null,
};
});
check(`${label}: 14-card grid is flex with exactly ${expectCols} columns`,
grid.display === 'flex' && grid.colCount === expectCols && grid.colDisplays.every((d) => d === 'flex/column'),
`display=${grid.display} cols=${grid.colCount} widths=${JSON.stringify(grid.widths)}`);
const expectedByCol = Array.from({ length: expectCols }, (_, c) =>
TALL_ITEMS.items.filter((_, i) => i % expectCols === c).map((it) => it.id));
check(`${label}: cards distributed round-robin (item i → column i%N)`,
JSON.stringify(grid.cardsByCol) === JSON.stringify(expectedByCol),
JSON.stringify(grid.cardsByCol));
check(`${label}: all 14 cards visible`, grid.cardCount === 14 && grid.allVisible,
`cards=${grid.cardCount} allVisible=${grid.allVisible}`);
check(`${label}: overflowX == 0 (the spill is gone)`, grid.overflow <= 0,
`overflow=${grid.overflow}px (was 501 desktop / 163 mobile)`);
check(`${label}: no card beyond the grid's last column`, grid.maxCardRight <= grid.gridRight + 1,
`maxCardRight=${grid.maxCardRight} gridRight=${grid.gridRight}`);
check(`${label}: 16px column gap (multicol parity)`, grid.colGap === 16, `colGap=${grid.colGap}`);
check(`${label}: 16px card gap inside a column (mb-4 parity)`, grid.vGap === 16, `vGap=${grid.vGap}`);
check(`${label}: tall-card geometry matches the failing case (~370px at 1440)`,
viewport.width >= 1280 ? grid.cardH >= 330 && grid.cardH <= 430 : grid.cardH > 150,
`cardHeight=${grid.cardH}px`);
await page.screenshot({ path: path.join(OUT, `scraper-v2-masonry-14-${label}.png`) });
// ── 1/4/15-card shapes: no visual regression, no spill ──────────────────
for (const shape of [
{ id: 'rep-1c', count: 1, wait: 'shape1_1' },
{ id: 'rep-4c', count: 4, wait: 'shape4_4' },
{ id: 'rep-15c', count: 15, wait: 'shape15_15' },
]) {
await openReport(shape.id, shape.wait);
const m = await page.evaluate(() => {
const el = document.querySelector('[data-testid=scraped-masonry]');
const cols = [...el.children];
const widths = cols.map((c) => Math.round(c.getBoundingClientRect().width));
const cards = [...document.querySelectorAll('[data-testid^=scraped-card-]')];
return {
colCount: cols.length,
equalWidths: Math.max(...widths) - Math.min(...widths) <= 1,
widths,
rendered: cards.length,
allRendered: cards.every((c) => { const r = c.getBoundingClientRect(); return r.width > 0 && r.height > 0; }),
overflow: document.documentElement.scrollWidth - window.innerWidth,
maxCardRight: Math.round(Math.max(...cards.map((c) => c.getBoundingClientRect().right))),
gridRight: Math.round(el.getBoundingClientRect().right),
};
});
check(`${label}: ${shape.count}-card shape — ${expectCols} equal columns, all cards rendered, no spill`,
m.colCount === expectCols && m.equalWidths && m.rendered === shape.count && m.allRendered &&
m.overflow <= 0 && m.maxCardRight <= m.gridRight + 1,
`cols=${m.colCount} widths=${JSON.stringify(m.widths)} cards=${m.rendered} overflow=${m.overflow}`);
await page.screenshot({ path: path.join(OUT, `scraper-v2-masonry-${shape.count}card-${label}.png`) });
}
// The harness's OWN `/api/releases/latest` mock deliberately fulfills 404
// (the NO_RELEASE path) — Chromium logs a console error per boot for it.
// Exclude that known-deliberate noise; every other console error is real.
const consoleNoise = consoleErrors.filter((e) => !/\/api\/releases\/latest/.test(e.url));
check(`${label}: masonry cases — zero console errors (excl. deliberate releases/latest 404)`,
consoleNoise.length === 0, consoleNoise.slice(0, 3).map((e) => `${e.text} @ ${e.url}`).join(' | '));
check(`${label}: masonry cases — zero JS page errors`, pageErrors.length === 0,
pageErrors.slice(0, 3).join(' | '));
await ctx.close();
} finally {
await browser.close();
}
}
(async () => {
const before = failures.length;
await runScenario('1440x900', { width: 1440, height: 900 });
await runScenario('390x844', { width: 390, height: 844 });
await runMasonryCases('1440x900', { width: 1440, height: 900 });
await runMasonryCases('390x844', { width: 390, height: 844 });
if (failures.length > before) {
console.log(`\n${failures.length - before} FAILURES: ${failures.slice(before).join(' | ')}`);
process.exit(1);
}
console.log('\nALL PASS');
process.exit(0);
})();