diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65891c95..83ae4eea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/site/package.json b/site/package.json index f2485882..b47a8c84 100644 --- a/site/package.json +++ b/site/package.json @@ -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" diff --git a/site/scripts/guest-sample-e2e.mjs b/site/scripts/guest-sample-e2e.mjs new file mode 100644 index 00000000..4957f4a1 --- /dev/null +++ b/site/scripts/guest-sample-e2e.mjs @@ -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:'login'}); + 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.'); diff --git a/site/scripts/guest-sample-live-e2e.mjs b/site/scripts/guest-sample-live-e2e.mjs new file mode 100644 index 00000000..54ac026b --- /dev/null +++ b/site/scripts/guest-sample-live-e2e.mjs @@ -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'); +} diff --git a/site/scripts/guest-sample-live-fixture.ts b/site/scripts/guest-sample-live-fixture.ts new file mode 100644 index 00000000..52f5b930 --- /dev/null +++ b/site/scripts/guest-sample-live-fixture.ts @@ -0,0 +1,125 @@ +// Servidor e2e de la muestra de invitado: el handler REAL de campus-profes (PR de backend) +// con el sitio compilado y un agente falso. Sin login ni IA. +// - Sin CAMPUS_E2E_DATABASE_URL: reglas (1 por cookie, 1 por IP, tope diario) en memoria. +// - Con CAMPUS_E2E_DATABASE_URL (solo localhost): el store REAL sobre PostgreSQL, en un +// esquema temporal con las migraciones del backend; se borra al salir. +import { createServer } from "node:http"; +import { createRequire } from "node:module"; +import { randomUUID } from "node:crypto"; +import { readFile, stat } from "node:fs/promises"; +import { extname, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const port = Number(process.env.CAMPUS_E2E_PORT ?? 4331); +const origin = `http://127.0.0.1:${port}`; +process.env.CAMPUS_APP_ORIGIN = origin; +const profesDir = resolve(process.env.CAMPUS_PROFES_DIR ?? "/Users/USER/alejo/campus-profes-guest-sample"); +const root = fileURLToPath(new URL("../dist/", import.meta.url)); +const { createAppHandler } = await import(pathToFileURL(resolve(profesDir, "src/app/http.ts")).href); + +type Reason = "already_used" | "ip_used" | "daily_cap"; +const state = { + config: { enabled: true, dailyCap: 150, maxOutputTokens: 700, ipSalt: "sal-e2e", ipHeader: "x-real-ip" }, + guests: new Set(), ips: new Set(), served: 0, + agentCalls: 0, agentMessages: [] as string[], events: [] as any[], +}; +const block = (guestId: string | null, ipHash: string, cap: number): Reason | null => { + if (guestId && state.guests.has(guestId)) return "already_used"; + if (state.ips.has(ipHash)) return "ip_used"; + if (state.served >= cap) return "daily_cap"; + return null; +}; + +// ---- PostgreSQL real (opcional) ---- +let db: any = null, admin: any = null, schema = ""; +const databaseUrl = process.env.CAMPUS_E2E_DATABASE_URL; +let realStore: any = null; +if (databaseUrl) { + const url = new URL(databaseUrl); + if (!["localhost", "127.0.0.1"].includes(url.hostname)) throw new Error("The e2e fixture only runs against an explicitly local database"); + const requireFromBackend = createRequire(resolve(profesDir, "package.json")); + const postgres = (await import(pathToFileURL(requireFromBackend.resolve("postgres")).href)).default; + const { appStore } = await import(pathToFileURL(resolve(profesDir, "src/app/store.ts")).href); + schema = `campus_e2e_${randomUUID().replaceAll("-", "")}`; + admin = postgres(url.toString(), { onnotice: () => {} }); + await admin`create schema ${admin(schema)}`; + db = postgres(url.toString(), { connection: { search_path: schema }, onnotice: () => {}, max: 4 }); + await db.unsafe("create table account(id integer primary key);"); + for (const file of ["151_campus_app_ai.sql", "152_campus_app_profes_chat.sql", "153_campus_app_conversations.sql", "209_guest_sample.sql"]) { + await db.unsafe(await readFile(resolve(profesDir, "sql", file), "utf8")); + } + realStore = appStore(db); +} + +const handler = createAppHandler({} as any, { + session: async () => null, + ready: () => true, + guestSample: { config: () => state.config, emit: (event: any, distinctId: string) => { state.events.push({ event, distinctId }); } }, + store: { + universities: async () => [{ id: 1, name: "Universidad Peruana de Ciencias Aplicadas", shortName: "UPC" }], + conversations: async () => [], guestConversations: async () => [], claimGuestHistory: async () => {}, + guestUsage: async () => ({ used: 0, limit: 5, remaining: 5, period: "trial" }), + ...(realStore ? { + guestSampleStatus: realStore.guestSampleStatus, + reserveGuestSample: realStore.reserveGuestSample, + finishGuest: realStore.finishGuest, + } : { + guestSampleStatus: async (guestId: string | null, ipHash: string, cap: number) => block(guestId, ipHash, cap), + reserveGuestSample: async (guestId: string, ipHash: string, cap: number) => { + const reason = block(guestId, ipHash, cap); + if (reason) return { status: "blocked", reason }; + state.guests.add(guestId); state.ips.add(ipHash); state.served++; + return { status: "reserved", id: randomUUID() }; + }, + finishGuest: async () => ({ conversationId: randomUUID(), title: "Consulta de muestra" }), + }), + } as any, + agent: (async (message: string) => { + state.agentCalls++; state.agentMessages.push(message); + return { text: "Respuesta pública del catálogo (e2e).", usage: { inputTokens: 10, outputTokens: 5 }, model: "openai/gpt-4o-mini" }; + }) as any, +}); + +const types: Record = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".webp": "image/webp", ".png": "image/png", ".json": "application/json", ".woff2": "font/woff2" }; +const json = (res: any, data: unknown) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(data)); }; +const server = createServer(async (req, res) => { + const path = new URL(req.url ?? "/", origin).pathname; + if (path === "/__e2e/state") { json(res, { ...state, guests: [...state.guests], ips: [...state.ips], database: Boolean(db) }); return; } + if (path === "/__e2e/db") { + if (!db) { json(res, null); return; } + json(res, { + requests: await db`select guest_id, plan, status, ip_hash, input_tokens, output_tokens, model, error_code from campus_app_ai_request order by created_at`, + conversations: (await db`select count(*)::int as n from campus_app_conversation`)[0].n, + messages: await db`select role, content from campus_app_profes_message order by id`, + indexes: (await db`select indexname from pg_indexes where schemaname = ${schema} and tablename = 'campus_app_ai_request' order by 1`).map((r: any) => r.indexname), + }); return; + } + if (path === "/__e2e/config" && req.method === "POST") { + let body = ""; for await (const chunk of req) body += chunk; + Object.assign(state.config, JSON.parse(body || "{}")); + json(res, state.config); return; + } + if (path === "/__e2e/reset" && req.method === "POST") { + state.guests.clear(); state.ips.clear(); state.served = 0; state.agentCalls = 0; state.agentMessages = []; state.events = []; + if (db) await db.unsafe("truncate campus_app_profes_message, campus_app_conversation, campus_app_ai_request cascade;"); + res.writeHead(200); res.end("ok"); return; + } + if (await handler(req, res, path)) return; + try { + if (req.method !== "GET") throw new Error("GET only"); + let file = resolve(root, `.${decodeURIComponent(path)}`); + if (!file.startsWith(resolve(root) + sep)) throw new Error("invalid path"); + // Las conversaciones se sirven con la misma página del asistente. + if (/^\/app\/asistente\/c\/[0-9a-f-]+\/?$/.test(path)) file = resolve(root, "app/asistente/index.html"); + if ((await stat(file)).isDirectory()) file = resolve(file, "index.html"); + res.writeHead(200, { "content-type": types[extname(file)] ?? "application/octet-stream", "cache-control": "no-store" }); + res.end(await readFile(file)); + } catch { res.writeHead(404); res.end("Not found"); } +}); +server.listen(port, "127.0.0.1", () => console.log(`Guest sample live fixture${db ? " (PostgreSQL)" : ""}: ${origin}/app/asistente/`)); +async function shutdown() { + server.close(); + try { if (db) { await db.end({ timeout: 2 }); await admin`drop schema ${admin(schema)} cascade`; await admin.end({ timeout: 2 }); } } catch { /* Best effort. */ } + process.exit(0); +} +for (const signal of ["SIGTERM", "SIGINT"] as const) process.on(signal, () => { void shutdown(); }); diff --git a/site/src/components/ProfesChat.tsx b/site/src/components/ProfesChat.tsx index bc41f539..4f6d173d 100644 --- a/site/src/components/ProfesChat.tsx +++ b/site/src/components/ProfesChat.tsx @@ -9,7 +9,7 @@ import GuestUniversity from './GuestUniversity'; import { readGuestUniversity, rememberGuestUniversity, refreshGuestUniversity, guestUniversitySupported, universityLabel, universitySelectionPayload, type UniversityChoice } from '../scripts/guest-university'; import {assistantAccountView} from "../scripts/assistant-account"; import {assistantLoginHref} from "../scripts/assistant-handoff"; -import { createConversationDrafts, restoreConversationDrafts, markGuestDraftHandoff } from "../scripts/conversation-drafts"; +import { createConversationDrafts, restoreConversationDrafts, markGuestDraftHandoff, persistRejectedGuestDraft } from "../scripts/conversation-drafts"; import { createPortal } from "react-dom"; import MiupcReconnect from "./MiupcReconnect"; import ChatMarkdown from "./ChatMarkdown"; @@ -19,6 +19,7 @@ import { useChat } from "@ai-sdk/react"; import type { ChatTransport, UIMessage, UIMessageChunk } from "ai"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent, type ReactElement } from "react"; import { getCampusSession, getCampusProfilePreview, type CampusSession } from "../scripts/campus-session"; +import { assignGuestSampleArm, guestSampleOffered } from "../scripts/guest-sample"; import { createConversationCache } from "../scripts/conversation-cache"; @@ -616,6 +617,8 @@ function profesTransport( if(traceId)track('campus_agent_response_received',{trace_id:traceId,duration_ms:Math.round(performance.now()-started)}); const data = response.ok ? null : await response.json().catch(() => ({})); if (response.status === 401) { + // sendQuestion ya vació el campo y su borrador: se guarda la pregunta rechazada antes de pasar por el login. + persistRejectedGuestDraft(activeConvId || null, question); markGuestDraftHandoff(); window.location.assign(getLoginUrl()); throw new Error("Inicia sesión para continuar."); } @@ -693,12 +696,16 @@ export default function ProfesChat() { const [access, setAccess] = useState("checking"); const [session, setSession] = useState(null); const [authenticated, setAuthenticated] = useState(false); + // Pregunta de muestra para invitados: solo si el backend la ofrece y el brazo de la prueba es "sample". + const [sampleMode, setSampleMode] = useState(false); + const sampleModeRef = useRef(false); + const [sampleUsed, setSampleUsed] = useState(false); const [guestUniversity, setGuestUniversity] = useState(readGuestUniversity); const [changingUniversity, setChangingUniversity] = useState(false); // "Cambiar universidad" en Mi perfil llega aquí con ?universidad=cambiar: misma pantalla que el alta, // y al guardar se vuelve a Mi perfil. const [editingFromProfile] = useState(() => typeof window !== "undefined" && new URLSearchParams(window.location.search).get("universidad") === "cambiar"); - const guestNeedsUniversity = session?.authenticated === false && (changingUniversity || !guestUniversity) + const guestNeedsUniversity = session?.authenticated === false && !sampleMode && (changingUniversity || !guestUniversity) || session?.authenticated === true && (!session.universityConfirmed || editingFromProfile || changingUniversity); const hasCatalogCoverage = session?.authenticated ? session.profesAvailable === true : guestUniversitySupported(guestUniversity); // La cobertura la decide el backend: se actualiza la elección guardada del invitado al abrir el chat. @@ -779,8 +786,15 @@ export default function ProfesChat() { const preserveScrollAfterPrepend = useRef<{ height: number; top: number } | null>(null); const currentConversationIdRef = useRef(null); currentConversationIdRef.current = currentConversationId; - const usageHandler = useRef(setUsage); - usageHandler.current = setUsage; + const usageHandler = useRef<(value: CampusSession["usage"]) => void>(setUsage); + usageHandler.current = (value: CampusSession["usage"]) => { + setUsage(value); + // El backend marca la muestra como gastada con period "sample" y sin cupo. + if (value?.period === "sample" && value.remaining <= 0) { + setSampleUsed(true); + track('campus_guest_sample_served'); + } + }; const activityHandler = useRef((value: string) => setActivitySteps(steps => appendActivity(steps, value))); useEffect(() => { @@ -887,9 +901,16 @@ export default function ProfesChat() { setAuthenticated(session.authenticated); if (!session.authenticated) { - setAccess("login"); - setAccessNote(""); + // Una vez ofrecida, la muestra se mantiene en esta página: al gastarla (o mientras se responde) + // el backend ya no la ofrece y un refresco de sesión no debe cambiar el chat por el muro. + const offered = sampleModeRef.current || guestSampleOffered(session, assignGuestSampleArm(session)); + sampleModeRef.current = offered; + setSampleMode(offered); + setAccess(offered ? (session.aiReady ? "ready" : "unavailable") : "login"); + setAccessNote(offered && !session.aiReady ? "Campus no está disponible por ahora." : ""); } else { + sampleModeRef.current = false; + setSampleMode(false); setAccess(!session.universityConfirmed ? "setup" : !session.accessUnavailable && session.aiReady ? "ready" : "unavailable"); setAccessNote(session.accessUnavailable ? "No pudimos comprobar tu saldo. Inténtalo de nuevo." : !session.aiReady ? "Campus no está disponible por ahora." : ""); } @@ -1063,7 +1084,7 @@ export default function ProfesChat() { async function sendQuestion(raw: string, example?: (typeof suggestions[number] | typeof studySuggestions[number])["id"]) { const question = raw.trim(); - if (session?.authenticated === false) { markGuestDraftHandoff(); window.location.assign(getLoginUrl()); return; } + if (session?.authenticated === false && (!sampleMode || sampleUsed)) { markGuestDraftHandoff(); window.location.assign(getLoginUrl()); return; } if (!question || sendingQuestion.current || busy || loadingConversation || guestNeedsUniversity || access === "checking" || access === "unavailable") return; if (wantsToReview(question)) { openReview(); @@ -1428,7 +1449,9 @@ export default function ProfesChat() { } }, [showStarters]); - if (session?.authenticated === false) return
+ // Con la muestra gastada y sin conversación visible (p. ej. tras "Nueva consulta") vuelve el muro: + // ya no hay composer y el CTA solo acompaña a una respuesta. + if (session?.authenticated === false && (!sampleMode || (sampleUsed && messages.length === 0 && !busy))) return

TU CUENTA GRATIS · CAMPUS FREE

@@ -1666,7 +1689,16 @@ export default function ProfesChat() { {!busy&&feedbackTrace&&messages.at(-1)?.role==='assistant' ? : null} -
+ {sampleUsed && !busy && messages.length > 0 ? ( +
+

Esa fue tu pregunta de muestra

+

Crea tu cuenta Campus Free para seguir preguntando, guardar este chat y conectar tu Blackboard. Incluye 25 consultas de bienvenida.

+ +
+ ) : null} +