omnia-agent-api / scripts /e2e-agent.mjs
github-actions
Deploy 04d496c from ilyasdaoudrma/omnia
b43bc6c
Raw History Blame Contribute Delete
9.83 kB
#!/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);
});