Files
sapl/frontend/sapl-garment-capture-review-check.cjs

209 lines
9.4 KiB
JavaScript

// Garment-capture review step check (issue #435 phase 1) — local preview mock harness.
// Intercepts the upload + PUT endpoints, injects a FULL StoredUser persona, and asserts
// the AI review step: role-chip dropdowns (app-native), editable tag fields, low-confidence
// markers, Save→PUT, Skip→no-PUT. Screenshots to ../.qa-screens/.
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
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
// Full StoredUser persona with a permissions map covering the library module.
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',
},
};
// Upload fixture with shot_roles + field_confidences (one field < 85).
const UPLOAD_FIXTURE = {
garment_id: 'g-capture-1',
style_number: 'W0042',
review_status: 'in_review',
tags: {
auto_saved: {
silhouette: { value: 'Regular', confidence: 0.92 },
season: { value: 'AW26', confidence: 0.95 },
},
needs_review: {
neckline: { value: 'Round neck', confidence: 0.7 },
},
rejected: {},
},
tagging_notes: ['Test model note'],
shot_roles: [
{ index: 0, path: '/images/a.jpg', role: 'front', confidence: 90 },
{ index: 1, path: '/images/b.jpg', role: 'back', confidence: 85 },
],
field_confidences: { silhouette: 92, season: 95, neckline: 70 },
possible_duplicates: [],
similar: [],
capture_mode: 'gallery',
capture_quality: [
{ index: 0, bg_removed: true, depth_score: 70, texture_quality: 'Good', blur_warning: false },
{ index: 1, bg_removed: true, depth_score: 55, texture_quality: 'Good', blur_warning: false },
],
};
let failures = [];
function check(name, ok, detail = '') {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
if (!ok) failures.push(name);
}
async function setupRoutes(page, puts) {
// Catch-all logger first so specific routes registered after take precedence.
await page.route('**/api/**', (r) => {
const url = r.request().url();
if (/\/garments\/upload/.test(url)) return r.fulfill({ status: 201, json: UPLOAD_FIXTURE });
console.log(`ROUTE other: ${r.request().method()} ${url}`);
return r.continue();
});
// Boilerplate shell APIs.
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 } }));
// next-style-number is POSTed when a file is added.
await page.route('**/api/plm/next-style-number*', r => r.fulfill({ json: { style_number: 'W0042', segment: 'new_dev', category: 'womens', reserved: false } }));
// Upload endpoint — handled by the garments glob below; keep only that.
// Upload route (explicit, registered after the garments glob for precedence).
await page.route('**/api/garments/upload*', (r) => {
console.log(`ROUTE upload2: ${r.request().method()} ${r.request().url()}`);
return r.fulfill({ status: 201, json: UPLOAD_FIXTURE });
});
await page.route('**/api/garments/*', async (r) => {
const method = r.request().method();
const url = r.request().url();
if (/\/garments\/upload/.test(url)) {
console.log(`ROUTE upload: ${method} ${url}`);
return r.fulfill({ status: 201, json: UPLOAD_FIXTURE });
}
if (method === 'PUT') {
puts.push({ url, body: r.request().postDataJSON() });
return r.fulfill({ json: UPLOAD_FIXTURE });
}
return r.continue();
});
// Upload route (explicit, registered after the garments glob for precedence).
await page.route('**/api/garments/upload*', (r) => {
console.log(`ROUTE upload2: ${r.request().method()} ${r.request().url()}`);
return r.fulfill({ status: 201, json: UPLOAD_FIXTURE });
});
}
async function injectAuth(page) {
await page.evaluate((u) => {
localStorage.setItem('sapl_token', 'test-token');
localStorage.setItem('sapl_user', JSON.stringify(u));
}, USER);
}
async function setShots(page) {
const inputs = page.locator('input[type="file"]');
for (let i = 0; i < 2; i++) await inputs.nth(i).setInputFiles({ name: `shot${i}.png`, mimeType: 'image/png', buffer: PNG });
}
async function runScenario(label, viewport, opts = {}) {
const browser = await chromium.launch({ executablePath: CHROME });
try {
const ctx = await browser.newContext({ viewport });
const page = await ctx.newPage();
const puts = [];
await setupRoutes(page, puts);
await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' });
await injectAuth(page);
await page.goto(BASE + '/upload', { waitUntil: 'domcontentloaded' });
await setShots(page);
await page.waitForTimeout(300);
await page.locator('button:has-text("Process")').first().click({ timeout: 15000 });
const modal = page.locator('[data-testid=review-ai-modal]');
await modal.waitFor({ state: 'visible', timeout: 15000 });
check(`${label}: review step opens after upload`, true);
// No native <select> anywhere in the modal — app-native dropdowns only.
check(`${label}: no native select`, (await modal.locator('select').count()) === 0);
// Low-confidence marker on neckline (70 < 85).
const lowConf = modal.locator('[data-testid=low-conf-neckline]');
check(`${label}: low-confidence marker visible`, await lowConf.isVisible().catch(() => false));
// Per-shot role chips rendered.
check(`${label}: 2 shot rows`, (await modal.locator('[data-testid^=shot-row-]').count()) === 2);
// Capture the open review modal as pre-merge evidence (before save/skip closes it).
const shot = path.join(OUT, `capture-review-${label}.png`);
await page.screenshot({ path: shot, fullPage: false });
console.log(` screenshot → ${shot}`);
if (opts.saveFlow) {
// Open the first role-chip dropdown; assert app-native options render.
await modal.locator('[data-testid=shot-role-0] button').first().click({ timeout: 10000 });
const listbox = page.locator('[role=listbox]').last();
await listbox.waitFor({ state: 'visible', timeout: 10000 });
const optsCount = await listbox.locator('[role=option]').count();
const optsText = await listbox.locator('[role=option]').allInnerTexts();
check(`${label}: role dropdown opens + 6 options`, optsCount === 6, `got ${optsCount}: ${optsText.join(', ')}`);
const expectedLabels = ['Front', 'Back', 'Detail · Fabric', 'Detail · Trim', 'Silhouette full', 'Unknown'];
const norm = (t) => t.trim().toLowerCase();
check(`${label}: all six role options present`, expectedLabels.every((l) => optsText.map(norm).includes(norm(l))), optsText.join(', '));
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
// Edit a tag field, then Save → PUT fires with the edited payload.
const sil = modal.locator('[data-testid=field-silhouette]');
await sil.fill('Oversized');
await modal.locator('[data-testid=save-changes]').click({ timeout: 10000 });
await page.waitForTimeout(600);
check(`${label}: Save fires PUT`, puts.length === 1, JSON.stringify(puts[0]));
if (puts.length === 1) {
check(`${label}: PUT to garment URL`, /\/api\/garments\/g-capture-1\/?$/.test(puts[0].url), puts[0].url);
check(`${label}: PUT body has edited field`, puts[0].body && puts[0].body.silhouette === 'Oversized', JSON.stringify(puts[0].body));
}
check(`${label}: Save closes modal`, (await modal.count() === 0) || !(await modal.isVisible().catch(() => false)));
// Success toast should appear.
const toastTxt = await page.locator('.toast-item, [role=status]').allInnerTexts().catch(() => []);
check(`${label}: success toast`, toastTxt.some((t) => /Details saved/.test(t)), toastTxt.join(' | '));
} else {
// Skip closes without any PUT.
await modal.locator('[data-testid=skip-review]').click({ timeout: 10000 });
await page.waitForTimeout(400);
check(`${label}: Skip closes modal`, (await modal.count() === 0) || !(await modal.isVisible().catch(() => false)));
check(`${label}: Skip fires no PUT`, puts.length === 0, `PUTs=${puts.length}`);
}
await ctx.close();
} finally {
await browser.close();
}
}
(async () => {
const before = failures.length;
await runScenario('1440x900', { width: 1440, height: 900 }, { saveFlow: true });
await runScenario('390x844', { width: 390, height: 844 }, { saveFlow: false });
if (failures.length > before) {
console.log(`\n${failures.length} FAILURES: ${failures.join(', ')}`);
process.exit(1);
}
console.log('\nALL PASS');
process.exit(0);
})();