← 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); });