File size: 9,827 Bytes
b43bc6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
#!/usr/bin/env node
/**
 * OMNIA Agent β€” repeatable end-to-end smoke test (no secrets printed).
 *
 * Drives the live agent over its SSE endpoint with the customer prompts from the
 * brief and asserts the assembled checkout / clarify / refusal. Also checks CORS
 * for 127.0.0.1 and that signed-out marketplace bookings are rejected (401).
 *
 * Usage:  node scripts/e2e-agent.mjs        (needs the 4 backends running)
 *         npm run e2e
 *
 * Requires Node 18+ (global fetch). Reads no .env and prints no credentials.
 */

const AGENT = process.env.AGENT_URL ?? 'http://localhost:3000';
const STAYS = process.env.STAYS_URL ?? 'http://localhost:3001';
const EATS = process.env.EATS_URL ?? 'http://localhost:3002';
const RIDES = process.env.RIDES_URL ?? 'http://localhost:3003';

const CITY = {
  Tanger: [35.7595, -5.834],
  Rabat: [34.0209, -6.8416],
  Marrakech: [31.6295, -7.9811],
  Agadir: [30.4278, -9.5981],
};

let passed = 0;
let failed = 0;
const fails = [];

function check(name, cond, detail = '') {
  if (cond) {
    passed++;
    console.log(`  βœ“ ${name}`);
  } else {
    failed++;
    fails.push(name);
    console.log(`  βœ— ${name}${detail ? `  β€” ${detail}` : ''}`);
  }
}

/** POST a prompt to the agent and collect the parsed SSE events. */
async function runAgent(prompt, city) {
  const [lat, lon] = CITY[city] ?? CITY.Rabat;
  const res = await fetch(`${AGENT}/agent/run`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt, history: [], location: { lat, lon, city, country: 'Morocco' } }),
  });
  const text = await res.text();
  const events = [];
  let tokens = '';
  for (const line of text.split('\n')) {
    if (!line.startsWith('data:')) continue;
    const p = line.slice(5).trim();
    if (!p || p === '[DONE]') continue;
    try {
      const e = JSON.parse(p);
      events.push(e);
      if (e.type === 'token') tokens += e.text;
    } catch {
      /* ignore non-JSON frames */
    }
  }
  return {
    tokens: tokens.trim(),
    checkout: events.find((e) => e.type === 'checkout'),
    clarify: events.find((e) => e.type === 'clarify'),
  };
}

const itemNames = (d) => (d?.items ?? []).map((i) => i.name.toLowerCase()).join(' | ');

async function main() {
  console.log(`\nOMNIA Agent E2E β€” agent=${AGENT}\n`);

  // ── 0) CORS: 127.0.0.1 must be allowed ──────────────────────────────────
  console.log('[CORS] 127.0.0.1 origin allowed');
  try {
    const r = await fetch(`${AGENT}/health`, { headers: { Origin: 'http://127.0.0.1:5180' } });
    const acao = r.headers.get('access-control-allow-origin');
    check('agent /health reflects 127.0.0.1 origin', acao === 'http://127.0.0.1:5180', `ACAO=${acao}`);
    const body = await r.json();
    check('public /health hides key-pool detail', body.groqKeys === undefined, JSON.stringify(Object.keys(body)));
    const kr = await fetch(`${AGENT}/health/keys`);
    check('gated /health/keys reachable in dev', kr.ok, `status=${kr.status}`);
  } catch (e) {
    check('agent reachable', false, String(e));
    return finish();
  }

  // ── 1) Eats: pepperoni pizza + coke in Tanger ───────────────────────────
  console.log('\n[1] "Order me a pepperoni pizza and a coke in Tanger"');
  {
    const r = await runAgent('Order me a pepperoni pizza and a coke in Tanger', 'Tanger');
    const d = r.checkout?.drafts?.[0];
    check('returns an eats checkout', d?.marketplace === 'eats', r.tokens.slice(0, 80));
    check('order includes a pizza', /pizza|margher|pepperoni/.test(itemNames(d)), itemNames(d));
    check('order total > 0', (d?.total ?? 0) > 0);
    // Coke isn't in the seed β€” it must be substituted with a drink or flagged, never silently dropped.
    const cokeFlagged = /coke/i.test(r.tokens) && /(isn'?t on|left (it|them) off|instead|added)/i.test(r.tokens);
    const hasDrink = /juice|tea|soda|water|cola|lemonade|smoothie/i.test(itemNames(d));
    check('unavailable coke is substituted or flagged (not silently dropped)', cokeFlagged || hasDrink, r.tokens.slice(0, 160));
  }

  // ── 2) Ride: from X to Y, ~12 km (the parseEndpoints fix) ───────────────
  console.log('\n[2] "Book me an economy ride from Rabat Agdal station to Rabat airport, about 12 km"');
  {
    const r = await runAgent('Book me an economy ride from Rabat Agdal station to Rabat airport, about 12 km', 'Rabat');
    const d = r.checkout?.drafts?.[0];
    check('returns a rides checkout', d?.marketplace === 'rides');
    check('pickup = "Rabat Agdal station"', d?.pickup === 'Rabat Agdal station', `pickup=${d?.pickup}`);
    check('dropoff = "Rabat airport"', d?.dropoff === 'Rabat airport', `dropoff=${d?.dropoff}`);
    check('distance = 12 km', d?.distanceKm === 12, `km=${d?.distanceKm}`);
    check('offers alternative ride tiers', (d?.rideOptions?.length ?? 0) > 1);
  }

  // ── 3) Stay: riad in Marrakech, 2 guests, 3 nights, under 900/night ─────
  console.log('\n[3] "Book a riad in Marrakech for 2 guests for 3 nights under 900 MAD per night"');
  {
    const r = await runAgent('Book a riad in Marrakech for 2 guests for 3 nights under 900 MAD per night', 'Marrakech');
    const d = r.checkout?.drafts?.[0];
    check('returns a stays checkout', d?.marketplace === 'stays');
    check('nights = 3', d?.nights === 3, `nights=${d?.nights}`);
    check('guests = 2', d?.guests === 2, `guests=${d?.guests}`);
    check('per-night within 900', d ? d.total / (d.nights || 1) <= 900 : false, `total=${d?.total}`);
  }

  // ── 3b) Misspelled city in the PROMPT must still resolve to Marrakech ────
  console.log('\n[3b] "Book a riad in marakech for 2 guests for 3 nights" (misspelled; location=Tanger)');
  {
    const r = await runAgent('Book a riad in marakech for 2 guests for 3 nights', 'Tanger');
    const d = r.checkout?.drafts?.[0];
    check('misspelled prompt city β†’ stays checkout', d?.marketplace === 'stays', r.tokens.slice(0, 80));
    check('resolves to Marrakech inventory', /marrakech/i.test(d?.subtitle ?? ''), `subtitle=${d?.subtitle}`);
  }

  // ── 3c) Misspelled city in the LOCATION (prompt names no city) ──────────
  console.log('\n[3c] "Book me a riad for 2 guests for 3 nights" (location city="marakech")');
  {
    const r = await runAgent('Book me a riad for 2 guests for 3 nights', 'marakech');
    const d = r.checkout?.drafts?.[0];
    check('misspelled location city β†’ stays checkout (not empty)', d?.marketplace === 'stays', r.tokens.slice(0, 80));
  }

  // ── 4) Trip with no party size β†’ must ASK guests first ──────────────────
  console.log('\n[4] "Plan my full trip to Agadir"  (expects a clarifying question)');
  {
    const r = await runAgent('Plan my full trip to Agadir', 'Agadir');
    check('asks a clarifying question', Boolean(r.clarify), r.tokens.slice(0, 80));
    check('question is about guests', /guest/i.test(r.clarify?.question ?? ''), r.clarify?.question);
    check('does not pre-book', !r.checkout);
  }

  // ── 5) Full trip with guests + nights + budget β†’ receipt + itinerary ────
  console.log('\n[5] "Plan my full trip to Agadir for 2 guests for 3 nights under 3000 MAD"');
  {
    const r = await runAgent('Plan my full trip to Agadir for 2 guests for 3 nights under 3000 MAD', 'Agadir');
    const c = r.checkout;
    check('returns a multi-action checkout', (c?.drafts?.length ?? 0) >= 2, `drafts=${c?.drafts?.length}`);
    check('includes a trip plan', Boolean(c?.trip));
    check('itinerary has days', (c?.trip?.days?.length ?? 0) >= 3, `days=${c?.trip?.days?.length}`);
    check('budget cap recorded = 3000', c?.trip?.budget?.budget === 3000, `budget=${c?.trip?.budget?.budget}`);
    check('budget breakdown present', c?.trip?.budget && ['stay', 'ride', 'food', 'total'].every((k) => typeof c.trip.budget[k] === 'number'));
  }

  // ── 6) Unsupported city β†’ polite refusal, no booking ────────────────────
  console.log('\n[6] "Book me a hotel and dinner in Paris for 2 nights"  (expects refusal)');
  {
    const r = await runAgent('Book me a hotel and dinner in Paris for 2 nights', 'Rabat');
    check("refuses (doesn't operate in Paris)", /doesn'?t operate in Paris/i.test(r.tokens), r.tokens.slice(0, 100));
    check('does not book', !r.checkout);
  }

  // ── 7) Signed-out bookings must be rejected (401), never silent ─────────
  console.log('\n[7] Signed-out Buy Now β€” marketplace APIs reject without a token (401)');
  {
    const post = (url, body) =>
      fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then((r) => r.status);
    check('Eats /orders β†’ 401', (await post(`${EATS}/orders`, { vendorId: 'x', items: [] })) === 401);
    check('Stays /bookings β†’ 401', (await post(`${STAYS}/bookings`, { listingId: 'x', nights: 1 })) === 401);
    check('Rides /trips β†’ 401', (await post(`${RIDES}/trips`, { rideClassId: 'x' })) === 401);
  }

  // ── 8) Signed-in Buy Now β€” needs a real Clerk session (manual) ──────────
  console.log('\n[8] Signed-in Buy Now');
  console.log('  ~ SKIPPED: requires a real Clerk session token (sign in with Google in the UI, then tap Buy now).');

  finish();
}

function finish() {
  console.log(`\n────────────────────────────\n${passed} passed, ${failed} failed`);
  if (fails.length) console.log(`Failed: ${fails.join('; ')}`);
  process.exit(failed ? 1 : 0);
}

main().catch((e) => {
  console.error('E2E runner crashed:', e);
  process.exit(1);
});