209 lines
9.4 KiB
JavaScript
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);
|
|
})();
|