Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ jobs:
- name: Campus account gate and character greeting
run: npm --prefix site run test:account-required
timeout-minutes: 2
- name: Guest sample question and account call to action
run: npm --prefix site run test:guest-sample
timeout-minutes: 2
- name: Direct university choice and one-tap first questions
run: npm --prefix site run test:first-question
timeout-minutes: 2
Expand Down
2 changes: 2 additions & 0 deletions site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"test:review-replies": "node scripts/review-replies.e2e.mjs",
"test:first-question": "node scripts/first-question-e2e.mjs",
"test:account-required": "node scripts/account-required-e2e.mjs",
"test:guest-sample": "node scripts/guest-sample-e2e.mjs",
"test:guest-sample:live": "node scripts/guest-sample-live-e2e.mjs",
"test:guest-signup": "node scripts/guest-signup-e2e.mjs",
"test:product-reviews": "node --test scripts/product-reviews.test.mjs",
"test:ucal-waitlist": "node --test scripts/ucal-waitlist.test.mjs"
Expand Down
145 changes: 145 additions & 0 deletions site/scripts/guest-sample-e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {createServer} from 'node:http';
import {readFile, stat} from 'node:fs/promises';
import {resolve, extname} from 'node:path';
import assert from 'node:assert/strict';
import {chromium} from 'playwright';

// Pregunta de muestra para invitados: brazo "sample" con una pregunta y CTA,
// brazo "gate" con el muro de siempre, y backend sin muestra igual que hoy.
const root = resolve('dist');
const server = createServer(async (req, res) => {
try {
const pathname = new URL(req.url, 'http://localhost').pathname;
let path = resolve(root, '.' + pathname);
if (path !== root && !path.startsWith(root + '/')) throw Error();
if ((await stat(path)).isDirectory()) path = resolve(path, 'index.html');
res.writeHead(200, {'content-type': ({'.html':'text/html', '.js':'text/javascript', '.css':'text/css', '.svg':'image/svg+xml', '.json':'application/json'})[extname(path)] ?? 'application/octet-stream'});
res.end(await readFile(path));
} catch { res.writeHead(404); res.end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch({headless:true});
const id = '22222222-2222-4222-8222-222222222222';

async function scenario(name, {arm, guestSample, rejectMessage, run}) {
const context = await browser.newContext({viewport:{width:390, height:900}, serviceWorkers:'block'});
if (arm) await context.addInitScript(value => localStorage.setItem('campus-guest-sample-arm-v1', value), arm);
const page = await context.newPage(), errors = [], messages = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', async route => {
const url = new URL(route.request().url());
if (url.origin !== base) return route.abort();
if (!url.pathname.startsWith('/api/app/')) return route.continue();
const path = url.pathname.replace(/\/$/, '');
const json = data => route.fulfill({contentType:'application/json', body:JSON.stringify(data)});
if (path === '/api/app/session') return json({authenticated:false, plan:'anon', aiReady:true, universityConfirmed:false, ...(guestSample ? {guestSample} : {}), usage:{remaining:5, limit:5, period:'trial'}});
if (path === '/api/app/profes/conversations') return json({conversations:[]});
if (path === '/api/app/profes/conversation') return json({messages:[], hasMore:false});
if (path === '/api/app/auth/start') return route.fulfill({contentType:'text/html', body:'<!doctype html><title>login</title>'});
if (path === '/api/app/profes/message') {
messages.push(JSON.parse(route.request().postData() ?? '{}'));
if (rejectMessage) return route.fulfill({status:401, contentType:'application/json', body:JSON.stringify({code:'LOGIN_REQUIRED_GUEST_LIMIT', error:'Crea tu cuenta Campus.'})});
return route.fulfill({contentType:'application/x-ndjson', body:JSON.stringify({type:'answer', answer:'Respuesta pública de muestra.', conversationId:id, usage:{remaining:0, limit:1, period:'sample'}})+'\n'});
}
return json({});
});
try {
await page.goto(base + '/app/asistente/');
await run({page, messages, errors});
assert.deepEqual(errors, []);
console.log(`Guest sample: ${name} passes.`);
} finally { await context.close(); }
}

try {
await scenario('sample arm asks one question and then shows the account CTA', {
arm:'sample', guestSample:{available:true, remaining:1, limit:1},
run: async ({page, messages}) => {
const box = page.getByRole('textbox', {name:'Escribe tu pregunta a Campu'});
await box.waitFor();
assert.equal(await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).count(), 0);
assert.equal(await page.getByRole('group', {name:'Ideas para empezar'}).getByRole('button').count(), 2, 'Only public professor and course starters');
await box.fill('¿Qué dicen los estudiantes de Luis Vives?');
await page.getByRole('button', {name:'Enviar pregunta', exact:true}).click();
await page.getByText('Respuesta pública de muestra.', {exact:true}).waitFor();
assert.equal(messages.length, 1);
const cta = page.locator('[data-guest-sample-cta]');
await cta.waitFor();
assert.equal(await box.isVisible(), false, 'The composer closes once the sample is used');
const href = new URL(await cta.getByRole('link', {name:'Crear cuenta y seguir'}).getAttribute('href'), page.url());
assert.equal(href.pathname, '/api/app/auth/start');
// A session refresh after the sample is spent (the backend no longer offers it)
// must not swap the conversation for the account wall.
await page.evaluate(() => window.dispatchEvent(new CustomEvent('campus:session-updated', {detail:{authenticated:false, plan:'anon', aiReady:true, guestSample:{available:false, remaining:0, limit:1}}})));
await page.waitForTimeout(300);
assert.equal(await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).count(), 0, 'The wall does not replace the answer');
assert.equal(await page.getByText('Respuesta pública de muestra.', {exact:true}).count(), 1);
assert.equal(await cta.count(), 1);
// Starting a new consultation after the sample must not leave a chat without input or prompt.
await page.evaluate(() => document.querySelector('[aria-label="Nueva consulta"]')?.click());
await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).waitFor();
assert.equal(await page.getByRole('group', {name:'Ideas para empezar'}).count(), 0);
assert.equal(await page.getByRole('textbox', {name:'Escribe tu pregunta a Campu'}).count(), 0);
},
});
await scenario('a sample rejected with 401 (spent in another tab) keeps the question for the login handoff', {
arm:'sample', guestSample:{available:true, remaining:1, limit:1}, rejectMessage:true,
run: async ({page, messages}) => {
const question = '¿Qué dicen los estudiantes de Luis Vives?';
await page.getByRole('textbox', {name:'Escribe tu pregunta a Campu'}).fill(question);
await page.getByRole('button', {name:'Enviar pregunta', exact:true}).click();
await page.waitForURL(url => new URL(url).pathname === '/api/app/auth/start');
assert.equal(messages.length, 1);
const stored = await page.evaluate(() => ({
drafts: JSON.parse(sessionStorage.getItem('campus-conversation-drafts-v1') ?? 'null'),
handoff: JSON.parse(sessionStorage.getItem('campus-guest-draft-handoff-v1') ?? 'null'),
}));
assert.equal(stored.drafts?.owner, 'guest');
assert.deepEqual(stored.drafts?.entries, [['new', question]], 'the rejected question is persisted');
assert.ok(stored.handoff?.record, 'the handoff marker points at that draft');
},
});
await scenario('gate arm keeps the account wall even when the backend offers the sample', {
arm:'gate', guestSample:{available:true, remaining:1, limit:1},
run: async ({page, messages}) => {
await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).waitFor();
assert.equal(await page.getByRole('textbox', {name:'Escribe tu pregunta a Campu'}).count(), 0);
assert.equal(messages.length, 0);
},
});
await scenario('backend without the sample behaves exactly like today', {
arm:'sample', guestSample:undefined,
run: async ({page, messages}) => {
await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).waitFor();
assert.equal(await page.getByRole('textbox', {name:'Escribe tu pregunta a Campu'}).count(), 0);
assert.equal(messages.length, 0);
},
});
await scenario('used sample goes back to the wall on reload', {
arm:'sample', guestSample:{available:false, remaining:0, limit:1},
run: async ({page}) => {
await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).waitFor();
},
});
// Arm assignment: stored once, only when the backend offers the sample.
const context = await browser.newContext({viewport:{width:390, height:900}, serviceWorkers:'block'});
const page = await context.newPage();
await page.route('**/*', async route => {
const url = new URL(route.request().url());
if (url.origin !== base) return route.abort();
if (!url.pathname.startsWith('/api/app/')) return route.continue();
const json = data => route.fulfill({contentType:'application/json', body:JSON.stringify(data)});
if (url.pathname.replace(/\/$/, '') === '/api/app/session') return json({authenticated:false, plan:'anon', aiReady:true, guestSample:{available:true, remaining:1, limit:1}});
return json({conversations:[], messages:[], hasMore:false});
});
await page.goto(base + '/app/asistente/');
await page.waitForFunction(() => ['sample', 'gate'].includes(localStorage.getItem('campus-guest-sample-arm-v1')));
const first = await page.evaluate(() => localStorage.getItem('campus-guest-sample-arm-v1'));
await page.reload();
await page.waitForTimeout(300);
assert.equal(await page.evaluate(() => localStorage.getItem('campus-guest-sample-arm-v1')), first, 'The arm is stable across visits');
await context.close();
console.log('Guest sample: arm assignment is stored once and stays stable.');
} finally { await browser.close(); await new Promise(resolve => server.close(resolve)); }
console.log('Guest sample E2E passed.');
154 changes: 154 additions & 0 deletions site/scripts/guest-sample-live-e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {spawn} from 'node:child_process';
import {existsSync} from 'node:fs';
import {resolve} from 'node:path';
import assert from 'node:assert/strict';
import {chromium} from 'playwright';

// E2E con el backend real (handler de campus-profes) y el sitio compilado.
// Requiere el repo del backend con la muestra de invitado: CAMPUS_PROFES_DIR.
const profesDir = resolve(process.env.CAMPUS_PROFES_DIR ?? '/Users/USER/alejo/campus-profes-guest-sample');
if (!existsSync(resolve(profesDir, 'src/app/guest-sample.ts'))) {
console.log(`Guest sample live E2E skipped: ${profesDir} has no guest sample backend.`);
process.exit(0);
}
const port = 4331;
const base = `http://127.0.0.1:${port}`;
const server = spawn('node', ['scripts/guest-sample-live-fixture.ts'], {env: {...process.env, CAMPUS_E2E_PORT: String(port), CAMPUS_PROFES_DIR: profesDir}, stdio: ['ignore', 'pipe', 'pipe']});
let log = '';
server.stdout.on('data', data => { log += data; });
server.stderr.on('data', data => { log += data; });
for (let i = 0; i < 100 && !log.includes('Guest sample live fixture'); i++) {
if (server.exitCode !== null) throw new Error(`Fixture exited early:\n${log}`);
await new Promise(resolve => setTimeout(resolve, 100));
}
assert.ok(log.includes('Guest sample live fixture'), `Fixture did not start:\n${log}`);

const post = (path, body) => fetch(base + path, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body ?? {})});
const state = async () => (await fetch(base + '/__e2e/state')).json();
const database = async () => (await fetch(base + '/__e2e/db')).json();
const usingDatabase = (await state()).database === true;
console.log(usingDatabase ? 'Mode: real PostgreSQL store.' : 'Mode: in-memory store (set CAMPUS_E2E_DATABASE_URL for PostgreSQL).');
const browser = await chromium.launch({headless: true});
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15';
// Cada contexto es un invitado nuevo (cookies vacías) con la IP que le demos.
async function guest(ip, arm) {
const context = await browser.newContext({viewport: {width: 390, height: 900}, serviceWorkers: 'block', userAgent: UA, extraHTTPHeaders: {'x-real-ip': ip}});
if (arm) await context.addInitScript(value => localStorage.setItem('campus-guest-sample-arm-v1', value), arm);
const page = await context.newPage(), errors = [];
page.on('pageerror', e => errors.push(e.message));
return {context, page, errors};
}
const wall = page => page.getByRole('heading', {name: 'Entra para hablar con Campu.'});
const box = page => page.getByRole('textbox', {name: 'Escribe tu pregunta a Campu'});

try {
await post('/__e2e/reset');

// 1. Invitado nuevo en el brazo "sample": una pregunta real contra el handler.
const a = await guest('203.0.113.10', 'sample');
await a.page.goto(base + '/app/asistente/');
await box(a.page).waitFor();
assert.equal(await wall(a.page).count(), 0);
await box(a.page).fill('¿Qué dicen los estudiantes de Luis Vives?');
await a.page.getByRole('button', {name: 'Enviar pregunta', exact: true}).click();
await a.page.getByText('Respuesta pública del catálogo (e2e).', {exact: true}).waitFor();
await a.page.locator('[data-guest-sample-cta]').waitFor();
assert.equal(await box(a.page).isVisible(), false, 'composer closes after the sample');
let s = await state();
assert.equal(s.agentCalls, 1);
assert.equal(s.agentMessages[0], '¿Qué dicen los estudiantes de Luis Vives?');
if (!usingDatabase) assert.equal(s.served, 1);
assert.ok(s.events.some(e => e.event.event === 'campus_guest_sample_served'), 'backend telemetry: served');
if (usingDatabase) {
const rows = await database();
assert.equal(rows.requests.length, 1, 'one request row');
const [row] = rows.requests;
assert.equal(row.plan, 'anon');
assert.equal(row.status, 'complete', 'the real finishGuest closed the request');
assert.match(row.ip_hash, /^[a-f0-9]{64}$/, 'only a sha256 of the IP is stored');
assert.notEqual(row.ip_hash, '203.0.113.10');
assert.match(row.guest_id, /^gst_/);
assert.equal(row.input_tokens, 10); assert.equal(row.output_tokens, 5);
assert.equal(rows.conversations, 1);
assert.deepEqual(rows.messages.map(m => m.role), ['user', 'assistant']);
assert.ok(rows.indexes.includes('campus_app_ai_ip_hash_time') && rows.indexes.includes('campus_app_ai_sample_time'), 'migration 209 indexes exist');
assert.equal(JSON.stringify(rows).includes('203.0.113.10'), false, 'the raw IP is nowhere in the database');
}
console.log('Live E2E: sample arm gets one real answer and the account CTA.');

// 2. Recargar: la muestra ya está gastada, vuelve el muro y no hay una segunda llamada.
await a.page.reload();
await wall(a.page).waitFor();
assert.equal((await state()).agentCalls, 1);
console.log('Live E2E: after the sample the wall is back on reload.');

// 3. Otra cookie desde la misma IP no obtiene muestra (límite por IP) y no se le asigna brazo.
const b = await guest('203.0.113.10', null);
await b.page.goto(base + '/app/asistente/');
await wall(b.page).waitFor();
assert.equal(await b.page.evaluate(() => localStorage.getItem('campus-guest-sample-arm-v1')), null, 'blocked visitors are not assigned an arm');
assert.equal((await state()).agentCalls, 1);
console.log('Live E2E: same IP, new cookie gets the wall and no arm.');

// 4. IP distinta + brazo "gate": el backend la ofrece pero el sitio mantiene el muro.
const c = await guest('203.0.113.20', 'gate');
await c.page.goto(base + '/app/asistente/');
await wall(c.page).waitFor();
assert.equal(await box(c.page).count(), 0);
assert.equal((await state()).agentCalls, 1);
console.log('Live E2E: gate arm keeps the wall even when the backend offers the sample.');

// 5. Interruptor apagado: aunque el brazo sea "sample", nada cambia respecto a hoy.
await post('/__e2e/config', {enabled: false});
const d = await guest('203.0.113.30', 'sample');
await d.page.goto(base + '/app/asistente/');
await wall(d.page).waitFor();
assert.equal((await state()).agentCalls, 1);
const offered = await d.page.evaluate(() => fetch('/api/app/session', {credentials: 'same-origin'}).then(r => r.json()));
assert.deepEqual(offered.guestSample, {available: false, remaining: 0, limit: 1}, 'backend does not offer the sample when off');
console.log('Live E2E: the kill switch restores today\'s behavior.');

// 6. Tope diario: con el interruptor encendido y tope 1 ya cubierto, nadie más recibe muestra.
await post('/__e2e/config', {enabled: true, dailyCap: 1});
const e = await guest('203.0.113.40', 'sample');
await e.page.goto(base + '/app/asistente/');
await wall(e.page).waitFor();
assert.equal((await state()).agentCalls, 1);
console.log('Live E2E: the daily cap closes the sample.');

// 7. Una cabecera X-Forwarded-For falsificada no abre una segunda muestra desde la misma IP real.
const f = await guest('203.0.113.10', 'sample');
await f.context.setExtraHTTPHeaders({'x-real-ip': '203.0.113.10', 'x-forwarded-for': '10.9.9.9'});
await post('/__e2e/config', {dailyCap: 150});
await f.page.goto(base + '/app/asistente/');
await wall(f.page).waitFor();
assert.equal((await state()).agentCalls, 1);
console.log('Live E2E: a forged X-Forwarded-For does not unlock a second sample.');

if (usingDatabase) assert.equal((await database()).requests.length, 1, 'blocked and disabled attempts leave no rows');

// 8. Carrera real: peticiones simultáneas desde una IP nueva, con la misma cookie y con cookies
// distintas, solo reservan una muestra (bloqueo de PostgreSQL, no solo lógica en memoria).
await post('/__e2e/config', {enabled: true, dailyCap: 150});
const send = (ip, cookie) => fetch(base + '/api/app/profes/message', {
method: 'POST',
headers: {origin: base, 'content-type': 'application/json', accept: 'application/x-ndjson', 'user-agent': UA, 'x-real-ip': ip, ...(cookie ? {cookie: `campus_guest=${cookie}`} : {})},
body: JSON.stringify({message: 'Carrera'}),
});
const sameCookie = await Promise.all(Array.from({length: 6}, () => send('203.0.113.60', 'gst_e2e0123456789abcdef')));
assert.equal(sameCookie.filter(r => r.status === 200).length, 1, `same cookie: one winner (got ${sameCookie.map(r => r.status)})`);
const sameIp = await Promise.all(Array.from({length: 6}, (_, i) => send('203.0.113.61', `gst_e2e${String(i).repeat(14)}`)));
assert.equal(sameIp.filter(r => r.status === 200).length, 1, `same IP, different cookies: one winner (got ${sameIp.map(r => r.status)})`);
if (usingDatabase) {
const rows = await database();
assert.equal(rows.requests.length, 3, 'exactly one row per winner plus the first sample');
assert.equal(new Set(rows.requests.map(r => r.ip_hash)).size, 3, 'one row per IP');
}
console.log('Live E2E: concurrent requests reserve a single sample.');

for (const {errors} of [a, b, c, d, e, f]) assert.deepEqual(errors, []);
console.log('Guest sample live E2E passed.');
} finally {
await browser.close();
server.kill('SIGTERM');
}
Loading
Loading