← back to Whatsmystyle
scripts/e2e-random-photos.js — real-photo E2E harness
8af7af164a698d1fc4c75d1188b53aff307204d3 · 2026-05-12 17:34:15 -0700 · SteveStudio2
Uploads 3 random faces (thispersondoesnotexist) + 3 real garments
(sampled from items.image_url) + 1 lifestyle photo, runs through
avatar build, closet vision, recommend, tryon, studio extract.
Exposed: closet-vision worker has no scheduler (launchd only has
wms-embed-drainer), default model is llava:13b not llava:latest, and
llava hallucinates 12 items per single-garment shot (3 uploads →
13 closet rows on test user).
Files touched
A scripts/e2e-random-photos.js
Diff
commit 8af7af164a698d1fc4c75d1188b53aff307204d3
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 17:34:15 2026 -0700
scripts/e2e-random-photos.js — real-photo E2E harness
Uploads 3 random faces (thispersondoesnotexist) + 3 real garments
(sampled from items.image_url) + 1 lifestyle photo, runs through
avatar build, closet vision, recommend, tryon, studio extract.
Exposed: closet-vision worker has no scheduler (launchd only has
wms-embed-drainer), default model is llava:13b not llava:latest, and
llava hallucinates 12 items per single-garment shot (3 uploads →
13 closet rows on test user).
---
scripts/e2e-random-photos.js | 296 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 296 insertions(+)
diff --git a/scripts/e2e-random-photos.js b/scripts/e2e-random-photos.js
new file mode 100644
index 0000000..567070c
--- /dev/null
+++ b/scripts/e2e-random-photos.js
@@ -0,0 +1,296 @@
+/**
+ * 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 { execSync } = require('child_process');
+ const env = { ...process.env, OLLAMA_VISION_MODEL: process.env.OLLAMA_VISION_MODEL || 'llava:latest' };
+ execSync(`node ${path.join(__dirname, 'closet-vision.js')}`, { env, stdio: 'inherit', timeout: 180000 });
+ } 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);
+});
← 24e6d95 yolo tick 32: copy-URL button on /share/recs/:id page (clipb
·
back to Whatsmystyle
·
closet vision — wire launchd drain + cap to 1 item per singl 4904ff4 →