← back to Whatsmystyle
scripts/e2e-random-photos.js
298 lines
/**
* End-to-end test with RANDOM REAL photos.
*
* Different from scripts/e2e.js which uploads 1x1 PNGs — this script downloads
* actual diverse photos (faces from thispersondoesnotexist + real garment
* images sampled from the live catalog) and runs them through the whole
* upload/extract/recommend/tryon flow. The point is to validate that:
*
* 1. Avatar uploads accept real face JPEGs and don't crash the builder
* 2. Closet uploads work end-to-end with the llava worker drain
* 3. llava actually extracts something resembling category/color from a
* real garment shot (not the 1x1 stub which it can't)
* 4. /recommend, /tryon, /studio/extract-garment behave with real inputs
*
* Exit 0 = pass. Exit 1 = any uploaded photo failed to drain or any API
* step returned non-2xx.
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const FormData = require('form-data');
const Database = require('better-sqlite3');
const BASE = process.env.E2E_BASE || 'http://127.0.0.1:9777';
const DB_PATH = path.join(__dirname, '..', 'data', 'whatsmystyle.db');
const TMP = path.join(__dirname, '..', 'tmp', `e2e-rand-${Date.now()}`);
fs.mkdirSync(TMP, { recursive: true });
let cookies = '';
function setCookies(res) {
const sc = res.headers['set-cookie'];
if (!sc) return;
cookies = (Array.isArray(sc) ? sc[0] : sc).split(';')[0];
}
function req(method, p, body, extraHeaders) {
return new Promise((resolve, reject) => {
const url = new URL(p, BASE);
const headers = { ...(extraHeaders || {}), ...(cookies ? { cookie: cookies } : {}) };
let postBody;
if (body instanceof FormData) {
Object.assign(headers, body.getHeaders());
} else if (body != null) {
postBody = typeof body === 'string' ? body : JSON.stringify(body);
if (!headers['content-type']) headers['content-type'] = 'application/json';
headers['content-length'] = Buffer.byteLength(postBody);
}
const r = http.request({
hostname: url.hostname, port: url.port,
path: url.pathname + url.search, method, headers
}, res => {
setCookies(res);
let buf = '';
res.on('data', c => buf += c);
res.on('end', () => {
let data = buf;
try { data = JSON.parse(buf); } catch {}
resolve({ status: res.statusCode, data });
});
});
r.on('error', reject);
if (body instanceof FormData) body.pipe(r);
else if (postBody) { r.write(postBody); r.end(); }
else r.end();
});
}
function download(url, outPath) {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https:') ? https : http;
const get = (u, redirects = 0) => {
lib.get(u, { headers: { 'user-agent': 'wms-e2e/1.0' } }, res => {
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location && redirects < 5) {
return get(res.headers.location, redirects + 1);
}
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
const f = fs.createWriteStream(outPath);
res.pipe(f);
f.on('finish', () => f.close(() => resolve(outPath)));
f.on('error', reject);
}).on('error', reject);
};
get(url);
});
}
async function expect(name, fn) {
process.stdout.write(` ${name.padEnd(48, ' ')} `);
try {
const r = await fn();
if (r && (r.ok === false || r.status >= 400)) {
console.log(`✗ FAIL (status ${r.status}) ${JSON.stringify(r.data).slice(0, 140)}`);
throw new Error(name);
}
console.log('✓');
return r;
} catch (e) {
console.log(`✗ ERROR (${e.message})`);
throw e;
}
}
async function fetchFacePhotos(n) {
const out = [];
for (let i = 0; i < n; i++) {
const p = path.join(TMP, `face-${i}.jpg`);
try {
await download('https://thispersondoesnotexist.com/', p);
const sz = fs.statSync(p).size;
if (sz < 20000) throw new Error(`face ${i} too small (${sz}b)`);
out.push(p);
} catch (e) {
// fallback to picsum people-ish
console.log(` (face source down, falling back to picsum seed=face${i})`);
await download(`https://picsum.photos/seed/face${i}-${Date.now()}/512/768`, p);
out.push(p);
}
}
return out;
}
async function fetchGarmentPhotos(n) {
const db = new Database(DB_PATH, { readonly: true });
const rows = db.prepare(
`SELECT image_url, title, category, brand FROM items
WHERE image_url LIKE 'http%'
ORDER BY random() LIMIT ?`
).all(n);
db.close();
const out = [];
for (let i = 0; i < rows.length; i++) {
const p = path.join(TMP, `garment-${i}.jpg`);
try {
await download(rows[i].image_url, p);
out.push({ path: p, expected: rows[i] });
} catch (e) {
console.log(` (garment ${i} download failed, skipping: ${e.message})`);
}
}
return out;
}
async function fetchLifestylePhoto() {
const p = path.join(TMP, `lifestyle.jpg`);
await download(`https://picsum.photos/seed/lifestyle-${Date.now()}/800/1100`, p);
return p;
}
async function pollClosetDrain(timeoutMs = 120000) {
const start = Date.now();
let lastSnapshot = [];
while (Date.now() - start < timeoutMs) {
const r = await req('GET', '/api/closet');
const items = r.data.items || [];
lastSnapshot = items;
const pending = items.filter(it => !it.category).length;
if (pending === 0 && items.length > 0) return { drained: true, items, elapsedMs: Date.now() - start };
await new Promise(r => setTimeout(r, 3000));
}
return { drained: false, items: lastSnapshot, elapsedMs: timeoutMs };
}
async function pickAnyItemId() {
const r = await req('GET', '/api/recommend?limit=1');
return r.data?.items?.[0]?.id || null;
}
async function main() {
console.log(`\nRandom-photo E2E against ${BASE}`);
console.log(`Scratch dir: ${TMP}\n`);
console.log('— Fetching random photos —');
const t0 = Date.now();
const [faces, garments, lifestyle] = await Promise.all([
fetchFacePhotos(3),
fetchGarmentPhotos(3),
fetchLifestylePhoto(),
]);
console.log(` ${faces.length} face(s), ${garments.length} garment(s), lifestyle ✓ (${((Date.now()-t0)/1000).toFixed(1)}s)\n`);
console.log('— Cold session + onboard —');
await expect('health', () => req('GET', '/api/health'));
await expect('me (cold session)', () => req('GET', '/api/me'));
await expect('onboard (random profile)', () => req('POST', '/api/onboard', {
display_name: 'Random Tester',
gender_expr: 'fluid', age_band: '25-34', height_cm: 175,
body_shape: 'rectangle', hair: 'brown-short', undertone: 'cool',
city: 'Los Angeles', budget_band: '50-150', sustainability: 'top',
}));
console.log('\n— Avatar uploads (real faces) —');
// Upload all 3 faces as one multipart batch
{
const fd = new FormData();
for (const fp of faces) {
fd.append('photos', fs.createReadStream(fp), { filename: path.basename(fp), contentType: 'image/jpeg' });
}
await expect(`avatar/photos × ${faces.length}`, () => req('POST', '/api/avatar/photos', fd));
}
await expect('avatar/build kick', () => req('POST', '/api/avatar/build'));
const av = await expect('avatar status', () => req('GET', '/api/avatar'));
console.log(` → avatar.status="${av.data.avatar?.status}"`);
console.log('\n— Closet uploads (real garments) —');
const closetIds = [];
for (let i = 0; i < garments.length; i++) {
const g = garments[i];
const fd = new FormData();
fd.append('photo', fs.createReadStream(g.path), { filename: path.basename(g.path), contentType: 'image/jpeg' });
const r = await expect(`closet/photo ${i+1} (${g.expected.category || '?'})`, () => req('POST', '/api/closet/photo', fd));
if (r.data.closet_id) closetIds.push({ id: r.data.closet_id, expected: g.expected });
}
await expect('closet list', () => req('GET', '/api/closet'));
console.log('\n— Draining closet vision (inline worker run) —');
// The closet-vision worker is run-on-demand (no scheduler in pm2 / launchd
// wires it). For an honest e2e test we trigger it inline rather than
// waiting for a cron that doesn't exist.
try {
const { spawnSync } = require('child_process');
const env = { ...process.env, OLLAMA_VISION_MODEL: process.env.OLLAMA_VISION_MODEL || 'llava:latest' };
const r = spawnSync('node', [path.join(__dirname, 'closet-vision.js')], { env, stdio: 'inherit', timeout: 300000 });
if (r.status !== 0) console.log(` ⚠ worker exit ${r.status} signal=${r.signal || ''}`);
} catch (e) {
console.log(` ⚠ worker run failed: ${e.message}`);
}
const drain = await pollClosetDrain(15000);
if (drain.drained) {
console.log(` ✓ drained in ${(drain.elapsedMs/1000).toFixed(1)}s — extractions:`);
} else {
console.log(` ⚠ NOT fully drained after ${(drain.elapsedMs/1000).toFixed(1)}s — partial extractions:`);
}
for (const it of drain.items) {
const exp = closetIds.find(c => c.id === it.id)?.expected;
const expectedCat = exp?.category || '(unknown)';
const cat = it.category || '(pending)';
const color = it.color || it.dominant_color || '';
const brand = it.brand || '';
const marker = it.category ? '✓' : '…';
console.log(` ${marker} closet#${it.id}: expected="${expectedCat}" → got cat="${cat}"${color ? ` color="${color}"` : ''}${brand ? ` brand="${brand}"` : ''}`);
}
console.log('\n— Recommend / Try-on / Studio —');
const recs = await expect('recommend (limit=10)', () => req('GET', '/api/recommend?limit=10'));
console.log(` → ${recs.data?.items?.length || 0} recs returned`);
const itemId = await pickAnyItemId();
if (itemId) {
const fd = new FormData();
fd.append('source_photo', fs.createReadStream(lifestyle), { filename: 'lifestyle.jpg', contentType: 'image/jpeg' });
fd.append('item_id', String(itemId));
fd.append('mode', 'photo_swap');
const t = await expect(`tryon (item_id=${itemId})`, () => req('POST', '/api/tryon', fd));
console.log(` → tryon_job_id=${t.data?.id || t.data?.job_id || '?'} provider=${t.data?.provider || '?'}`);
} else {
console.log(' ⊘ tryon SKIP (no recommendable item)');
}
// Studio extract — need a path inside data/uploads. Use the lifestyle photo
// uploaded via tryon (lives under data/uploads/swap). Pull most recent.
const { execSync } = require('child_process');
const swapDir = path.join(__dirname, '..', 'data', 'uploads', 'swap');
let latestSwap = null;
try {
const files = fs.readdirSync(swapDir).map(n => ({ n, t: fs.statSync(path.join(swapDir, n)).mtimeMs }));
files.sort((a, b) => b.t - a.t);
if (files[0]) latestSwap = path.join(swapDir, files[0].n);
} catch {}
if (latestSwap) {
await expect('studio/extract-garment (crop)', () => req('POST', '/api/studio/extract-garment', {
source_photo: latestSwap,
bbox: { x: 0.25, y: 0.3, w: 0.4, h: 0.4 },
category: 'top',
brand_hint: 'random-test',
}));
} else {
console.log(' ⊘ studio extract SKIP (no swap photo found on disk)');
}
if (!process.env.KEEP) {
console.log('\n— Cleanup —');
await expect('me/forget', () => req('POST', '/api/me/forget'));
} else {
console.log('\n— Cleanup SKIPPED (KEEP=1) —');
}
console.log('\n✓ Random-photo E2E passed.\n');
console.log(`(scratch files left in ${TMP} for inspection)`);
}
main().catch(e => {
console.error(`\n✗ Random-photo E2E failed: ${e.message}`);
process.exit(1);
});