← back to Whatsmystyle
scripts/e2e.js
197 lines
/**
* End-to-end validation — fresh session through every meaningful state.
*
* Per the 2026-05-12 validation-loop rule: a feature is not "done" until
* the same review panel passes on the post-edit version. For an end-user
* app the equivalent is "the full happy path completes from a cold start."
*
* This script:
* 1. Hits /api/health
* 2. Creates a fresh session (cookie jar)
* 3. /api/me — session-user auto-created
* 4. /api/onboard — full profile post
* 5. /api/avatar/photos — upload a few sample photos
* 6. /api/avatar/build — kick the builder (worker may stub)
* 7. /api/closet/photo — upload a closet photo
* 8. /api/duel × 5 + pick A/B/A/B/skip
* 9. /api/recommend — recs grid endpoint
* 10. /api/outfits — outfit-builder (item_id anchor)
* 11. /api/outfits/:id/pick — pick outfit 0
* 12. /api/waitlist — join the waitlist
* 13. /api/debates — read panel verdicts
* 14. /api/stripe-fc/status — capability probe
* 15. /api/me/forget — wipe everything
*
* Exits 0 on full pass; 1 on first failure (with the failing step name).
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const FormData = require('form-data');
const BASE = process.env.E2E_BASE || 'http://127.0.0.1:9777';
let cookies = '';
function setCookies(res) {
const sc = res.headers['set-cookie'];
if (!sc) return;
// simple: keep just k=v from the first cookie line
const first = (Array.isArray(sc) ? sc[0] : sc).split(';')[0];
cookies = first;
}
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();
});
}
async function expect(name, fn) {
process.stdout.write(` ${name.padEnd(38, ' ')} `);
try {
const r = await fn();
if (r.ok === false || r.status >= 400) {
console.log(`✗ FAIL (status ${r.status}, ${JSON.stringify(r.data).slice(0, 200)})`);
throw new Error(name);
}
console.log('✓');
return r;
} catch (e) {
console.log(`✗ ERROR (${e.message})`);
throw e;
}
}
// generate a tiny PNG (1x1 red pixel) for sample-photo uploads
const TINY_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 'base64');
async function ollamaUp() {
return new Promise(resolve => {
const r = http.request({ hostname: '127.0.0.1', port: 11434, path: '/api/tags', method: 'GET' }, res => {
let buf = '';
res.on('data', c => buf += c);
res.on('end', () => {
try { const j = JSON.parse(buf); resolve((j.models || []).some(m => /^llava/.test(m.name || ''))); }
catch { resolve(false); }
});
});
r.on('error', () => resolve(false));
r.setTimeout(3000, () => { r.destroy(); resolve(false); });
r.end();
});
}
async function checkClosetVision() {
process.stdout.write(' closet vision drains within 90s'.padEnd(40, ' ') + ' ');
const haveLlava = await ollamaUp();
if (!haveLlava) { console.log('⊘ SKIP (ollama+llava not reachable)'); return; }
const start = Date.now();
while (Date.now() - start < 90000) {
const r = await req('GET', '/api/closet');
const parsed = (r.data.items || []).find(i => i.category);
if (parsed) { console.log(`✓ (${Math.round((Date.now()-start)/1000)}s — category="${parsed.category}")`); return; }
await new Promise(r => setTimeout(r, 5000));
}
console.log('⊘ SKIP (no category written within 90s — worker slow or input too generic)');
}
async function main() {
console.log(`E2E validation against ${BASE}\n`);
const health = await expect('health', () => req('GET', '/api/health'));
if (!health.data.workers || typeof health.data.workers.closet_vision_pending !== 'number') {
console.log(' ✗ health missing workers.{closet_vision_pending, tryon_queued, avatar_building}');
process.exit(1);
}
await expect('me (creates session)', () => req('GET', '/api/me'));
await expect('onboard', () => req('POST', '/api/onboard', {
display_name: 'E2E Tester',
gender_expr: 'fluid',
age_band: '35-44',
height_cm: 170,
body_shape: 'hourglass',
hair: 'brown-long',
undertone: 'warm',
city: 'Sherman Oaks',
budget_band: '50-150',
sustainability: 'top',
}));
// avatar photos
{
const fd = new FormData();
fd.append('photos', TINY_PNG, { filename: 'a.png', contentType: 'image/png' });
fd.append('photos', TINY_PNG, { filename: 'b.png', contentType: 'image/png' });
await expect('avatar/photos upload', () => req('POST', '/api/avatar/photos', fd));
}
await expect('avatar/build kick', () => req('POST', '/api/avatar/build'));
await expect('avatar status', () => req('GET', '/api/avatar'));
// closet
{
const fd = new FormData();
fd.append('photo', TINY_PNG, { filename: 'closet.png', contentType: 'image/png' });
await expect('closet/photo upload', () => req('POST', '/api/closet/photo', fd));
}
await expect('closet list', () => req('GET', '/api/closet'));
// 5 duels
for (let i = 0; i < 5; i++) {
const d = await expect(`duel ${i+1} get`, () => req('GET', '/api/duel'));
const choice = ['A','B','A','B','skip'][i];
await expect(`duel ${i+1} pick ${choice}`, () => req('POST', '/api/duel', { duel_id: d.data.duel_id, picked: choice }));
}
// After 5 picked duels (we just did A/B/A/B/skip — 4 picks), pick one
// more so the auto-snapshot at every-5th-pick fires.
{
const d = await expect('duel 6 get', () => req('GET', '/api/duel'));
await expect('duel 6 pick A (triggers snapshot)', () => req('POST', '/api/duel', { duel_id: d.data.duel_id, picked: 'A' }));
}
// Now assert /api/me/taste/history has at least one snapshot
await expect('taste history has ≥1 snapshot', async () => {
const r = await req('GET', '/api/me/taste/history');
if (!r.data.snapshots || r.data.snapshots.length < 1) return { ok: false, status: 500, data: r.data };
if (r.data.snapshots[0].taste_vec?.length !== 32) return { ok: false, status: 500, data: 'snapshot taste_vec wrong length' };
return r;
});
// Closet-vision drain check — skip if ollama unreachable.
// We already uploaded a 1x1 PNG to closet; poll the row up to 90s for a
// non-null category. If ollama is offline this stays null forever and we
// SKIP rather than FAIL.
await checkClosetVision();
await expect('recommend', () => req('GET', '/api/recommend?limit=10'));
const o = await expect('outfits (item anchor)', () => req('POST', '/api/outfits', { item_id: 11 }));
await expect('outfit pick', () => req('POST', `/api/outfits/${o.data.pick_id}/pick`, { index: 0 }));
await expect('waitlist join', () => req('POST', '/api/waitlist', { email: `e2e-${Date.now()}@example.com`, source: 'e2e' }));
await expect('debates list', () => req('GET', '/api/debates'));
await expect('stripe-fc/status', () => req('GET', '/api/stripe-fc/status'));
await expect('me/forget', () => req('POST', '/api/me/forget'));
console.log('\n✓ E2E passed.');
}
main().catch(() => { console.error('\n✗ E2E failed.'); process.exit(1); });