243 lines
14 KiB
JavaScript
243 lines
14 KiB
JavaScript
// Garment-library trend-parity check (#1488) — against LOCAL PREVIEW.
|
|
//
|
|
// Mock-interception harness (repo idiom): the Made library must render as
|
|
// Trend Library pins — same masonry ladder, same pin chrome, same flight —
|
|
// and the sheet must carry the shot strip (front/back/flat/detail). At
|
|
// 1440x900 AND 390x844:
|
|
// - staggered skeleton pins while loading
|
|
// - masonry columnCount: 5 @1440, 2 @390
|
|
// - hero-only pins (one img per card), natural stagger, rounded-2xl,
|
|
// always-visible captions (title + meta + dots)
|
|
// - card tap → shell + clone glide as one unit; modal rides panel-hidden
|
|
// - sheet lands: shot strip with 4 thumbs, label "1/4 · Front"; tapping a
|
|
// thumb switches the shot ("2/4 · Back"); arrow seats on the right
|
|
// - Next browses without replaying the flight; Escape closes
|
|
// - select mode still toggles + shows the selected ring
|
|
// - no native <select>; no horizontal overflow; no JS errors
|
|
//
|
|
// Run: cd frontend && npm run build && (npm run preview -- --port 4173 &)
|
|
// node sapl-library-trend-parity-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 });
|
|
|
|
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',
|
|
},
|
|
};
|
|
|
|
const COLOURS = [{ colour_name: 'Ivory', hex: '#f3ecdd' }, { colour_name: 'Indigo', hex: '#2c3e6b' }];
|
|
const G = (id, pin, style, dna) => ({
|
|
id, style_number: style, series_code: null, season: 'AW26', origin: 'made',
|
|
description: `${dna} — mock garment for the parity harness.`,
|
|
collection_name: null, fabric_family: 'Cotton', weave_knit_structure: null,
|
|
fabric_composition: '100% cotton', design_team_city: 'Mumbai',
|
|
sample_available: true, review_status: 'human_verified', image_path: `${pin}.jpg`,
|
|
top_tags: { category: 'shirts', design_dna: dna, colour: COLOURS },
|
|
});
|
|
const GARMENTS = [
|
|
G('lib1', 'pin-tall', 'M001', 'Heritage Check'),
|
|
G('lib2', 'pin-mid', 'M002', 'Indigo Workwear'),
|
|
G('lib3', 'pin-square', 'M003', 'Bone Poplin'),
|
|
G('lib4', 'pin-tall', 'M004', 'Madras Patch'),
|
|
G('lib5', 'pin-mid', 'M005', 'Night Twill'),
|
|
G('lib6', 'pin-square', 'M006', 'Ecru Waffle'),
|
|
];
|
|
const SHOTS = [
|
|
{ role: 'front', role_confidence: 98, image_path: 'pin-tall.jpg' },
|
|
{ role: 'back', role_confidence: 96, image_path: 'pin-mid.jpg' },
|
|
{ role: 'flat', role_confidence: null, image_path: 'pin-square.jpg' },
|
|
{ role: 'detail_fabric', role_confidence: null, image_path: 'pin-mid.jpg' },
|
|
];
|
|
const detailFor = (id) => {
|
|
const g = GARMENTS.find((x) => x.id === id) || GARMENTS[0];
|
|
return { ...g, shots: SHOTS.map((s) => s.role), shot_count: 4, shot_images: SHOTS };
|
|
};
|
|
|
|
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));
|
|
|
|
async function setupRoutes(page, stats) {
|
|
await page.route('**/api/**', (r) => 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/feed/share*', (r) => r.fulfill({ json: { shared: false } }));
|
|
await page.route('**/api/collections*', (r) => r.fulfill({ json: [] }));
|
|
|
|
const PINS = {
|
|
'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('**/images/**', (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 });
|
|
});
|
|
|
|
// Single garments route (no ordering ambiguity): drafts, detail, list.
|
|
// The first list call is delayed so the skeleton stagger can be captured.
|
|
await page.route('**/api/garments**', async (r) => {
|
|
const url = r.request().url();
|
|
if (url.includes('/garments/drafts')) return r.fulfill({ json: { data: [] } });
|
|
const m = url.match(/\/garments\/([^\/?#]+)/);
|
|
if (m) return r.fulfill({ json: detailFor(m[1]) });
|
|
stats.listCalls++;
|
|
if (stats.listCalls === 1) await sleep(1200);
|
|
return r.fulfill({ json: { total: GARMENTS.length, data: GARMENTS } });
|
|
});
|
|
}
|
|
|
|
async function runScenario(label, viewport, expectCols) {
|
|
const browser = await chromium.launch({ executablePath: CHROME });
|
|
try {
|
|
const ctx = await browser.newContext({ viewport, serviceWorkers: 'block' });
|
|
const page = await ctx.newPage();
|
|
const stats = { listCalls: 0 };
|
|
const pageErrors = [];
|
|
page.on('pageerror', (e) => pageErrors.push(String(e)));
|
|
await setupRoutes(page, stats);
|
|
|
|
await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate((u) => {
|
|
localStorage.setItem('sapl_token', 'test-token');
|
|
localStorage.setItem('sapl_user', JSON.stringify(u));
|
|
}, USER);
|
|
await page.goto(BASE + '/library', { waitUntil: 'domcontentloaded' });
|
|
|
|
// ── Skeleton stagger ────────────────────────────────────────────────
|
|
await page.locator('[data-testid=library-skeleton]').first().waitFor({ state: 'visible', timeout: 15000 });
|
|
const skelBoxes = await page.locator('[data-testid=library-skeleton]').evaluateAll((els) =>
|
|
els.slice(0, 6).map((el) => Math.round(el.getBoundingClientRect().height)));
|
|
check(`${label}: skeleton pins stagger`, new Set(skelBoxes).size >= 2, `heights=${skelBoxes.join(',')}`);
|
|
|
|
// ── Masonry + pins ──────────────────────────────────────────────────
|
|
await page.locator('[data-testid=garment-card-lib1]').waitFor({ state: 'visible', timeout: 15000 });
|
|
await sleep(1800); // entrance cascade settles
|
|
const cardCount = () => page.locator('[data-testid^=garment-card-]').count();
|
|
check(`${label}: 6 pins render`, (await cardCount()) === 6, `cards=${await cardCount()}`);
|
|
const cols = await page.locator('[data-testid=library-masonry]').evaluate(
|
|
(el) => getComputedStyle(el).columnCount);
|
|
check(`${label}: masonry has ${expectCols} columns`, String(cols) === String(expectCols), `columnCount=${cols}`);
|
|
const imgHeights = await page.locator('[data-testid^=garment-card-] img').evaluateAll((els) =>
|
|
els.map((el) => Math.round(el.getBoundingClientRect().height)));
|
|
check(`${label}: hero-only pins stagger at natural aspect`, new Set(imgHeights).size >= 2,
|
|
`imgHeights=${imgHeights.join(',')}`);
|
|
// #1492: the grid NEVER CSS-crops (no passport squares) — pins render
|
|
// their native aspect exactly like the trend library's TrendImageCard.
|
|
const cropped = await page.locator('[data-testid^=garment-card-] img.object-cover').count();
|
|
check(`${label}: zero CSS-cropped pins`, cropped === 0, `object-cover=${cropped}`);
|
|
const radius = await page.locator('[data-testid=garment-card-lib1] > div').first().evaluate(
|
|
(el) => getComputedStyle(el).borderRadius);
|
|
check(`${label}: pin is rounded-2xl`, radius.startsWith('16px'), `radius=${radius}`);
|
|
const cardText = await page.locator('[data-testid=garment-card-lib2]').innerText();
|
|
check(`${label}: caption shows title + meta + dots`,
|
|
cardText.includes('Indigo Workwear') && cardText.includes('M002') && cardText.includes('AW26'),
|
|
JSON.stringify(cardText.slice(0, 80)));
|
|
await page.screenshot({ path: path.join(OUT, `lib-grid-${label}.png`) });
|
|
|
|
// ── Flight + shot strip ─────────────────────────────────────────────
|
|
await page.locator('[data-testid=garment-card-lib1]').click();
|
|
const clone = page.locator('[data-testid=sheet-flight-clone]');
|
|
const shell = page.locator('[data-testid=sheet-flight-shell]');
|
|
const modal = page.locator('[data-testid=garment-detail-modal]');
|
|
await clone.waitFor({ state: 'visible', timeout: 8000 });
|
|
check(`${label}: flight clone lifts off`, true);
|
|
check(`${label}: shell glides as one unit`, await shell.isVisible());
|
|
const phase = await page.locator('[data-testid=sheet-flight]').getAttribute('data-phase');
|
|
const dialogVis = await modal.locator('[role=dialog]').evaluate((el) => getComputedStyle(el).visibility);
|
|
check(`${label}: sheet rides panel-hidden`, phase === 'fly' && dialogVis === 'hidden',
|
|
`phase=${phase} visibility=${dialogVis}`);
|
|
await sleep(250);
|
|
await page.screenshot({ path: path.join(OUT, `lib-mid-${label}.png`) });
|
|
|
|
const arrow = page.locator('[data-testid=sheet-flight-close]');
|
|
await arrow.waitFor({ state: 'visible', timeout: 8000 });
|
|
check(`${label}: sheet lands`, (await modal.count()) === 1);
|
|
// Shot strip: 4 thumbs incl. the new flat role; switching updates the label.
|
|
const strip = modal.locator('[data-testid=shot-gallery-strip]');
|
|
await strip.waitFor({ state: 'visible', timeout: 8000 });
|
|
check(`${label}: shot strip carries 4 shots`, (await strip.locator('[data-testid^=shot-thumb-]').count()) === 4);
|
|
const label1 = await modal.locator('[data-testid=shot-gallery-label]').innerText();
|
|
check(`${label}: gallery opens on 1/4 Front`, label1.includes('1/4') && label1.toLowerCase().includes('front'), label1);
|
|
await modal.locator('[data-testid=shot-thumb-1]').click();
|
|
const label2 = await modal.locator('[data-testid=shot-gallery-label]').innerText();
|
|
check(`${label}: thumb switches to 2/4 Back`, label2.includes('2/4') && label2.toLowerCase().includes('back'), label2);
|
|
await modal.locator('[data-testid=shot-thumb-2]').click();
|
|
const label3 = await modal.locator('[data-testid=shot-gallery-label]').innerText();
|
|
check(`${label}: flat-lay shot reads Flat lay`, label3.toLowerCase().includes('flat lay'), label3);
|
|
// Arrow seats on the right (same geometry as the scraper flight).
|
|
await sleep(400);
|
|
const arrowBox = await arrow.boundingBox();
|
|
const dialogBox = await modal.locator('[role=dialog]').boundingBox();
|
|
const imgBox = await modal.locator('[role=dialog] > div').first().boundingBox();
|
|
const panelRight = (dialogBox?.x ?? 0) + (dialogBox?.width ?? 0);
|
|
const expX = (viewport.width - panelRight >= 100) ? panelRight + 12 : panelRight - 76;
|
|
const expY = (imgBox?.y ?? 0) + (imgBox?.height ?? 0) / 2 - 32;
|
|
check(`${label}: arrow seats on the right`,
|
|
Math.abs((arrowBox?.x ?? -99) - expX) < 5 && Math.abs((arrowBox?.y ?? -99) - expY) < 5,
|
|
`arrow=(${arrowBox?.x},${arrowBox?.y}) expected=(${expX},${expY})`);
|
|
await page.screenshot({ path: path.join(OUT, `lib-open-${label}.png`) });
|
|
|
|
// Next browses without replaying the flight (desktop: arrows are lg+).
|
|
if (viewport.width >= 1024) {
|
|
await modal.locator('[aria-label=Next]').click();
|
|
await sleep(300);
|
|
const phaseAfter = await page.locator('[data-testid=sheet-flight]').getAttribute('data-phase');
|
|
const clones = await page.locator('[data-testid=sheet-flight-clone]').count();
|
|
check(`${label}: Next browses without replay`, phaseAfter === 'open' && clones === 0,
|
|
`phase=${phaseAfter} clones=${clones}`);
|
|
}
|
|
await page.keyboard.press('Escape');
|
|
await modal.waitFor({ state: 'detached', timeout: 8000 });
|
|
check(`${label}: Escape closes the sheet`, (await modal.count()) === 0);
|
|
|
|
// ── Select mode ─────────────────────────────────────────────────────
|
|
await page.locator('[data-testid=library-select-toggle]:visible').click();
|
|
await page.locator('[data-testid=garment-card-lib1]').click();
|
|
check(`${label}: select mode shows the selected ring`,
|
|
(await page.locator('[data-testid=garment-card-lib1] div.absolute.inset-0').count()) === 1);
|
|
await page.locator('[data-testid=library-select-toggle]:visible').click();
|
|
|
|
// ── Hygiene ─────────────────────────────────────────────────────────
|
|
const hasSelect = await page.evaluate(() => document.querySelectorAll('select').length > 0);
|
|
check(`${label}: no native <select>`, !hasSelect);
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1);
|
|
check(`${label}: no horizontal overflow`, overflow);
|
|
check(`${label}: zero JS errors`, pageErrors.length === 0, pageErrors.join(' | ').slice(0, 300));
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
await runScenario('1440', { width: 1440, height: 900 }, 5);
|
|
await runScenario('390', { width: 390, height: 844 }, 2);
|
|
console.log(failures.length === 0
|
|
? '\nALL PASS (2 viewports)'
|
|
: `\n${failures.length} FAILURE(S):\n - ` + failures.join('\n - '));
|
|
process.exit(failures.length === 0 ? 0 : 1);
|
|
})().catch((e) => { console.error('HARNESS ERROR:', e.message); process.exit(2); });
|