|
| 1 | +/** |
| 2 | + * Browser-level session-stream e2e. |
| 3 | + * |
| 4 | + * Same full stack as session-stream.e2e.test.ts, but the client is a real |
| 5 | + * Chromium page on a DIFFERENT origin than the webapp, driven via |
| 6 | + * page.evaluate. It exercises what node-fetch can't: a real browser doing a |
| 7 | + * cross-origin fetch + streaming read of the session `.out` SSE through the |
| 8 | + * webapp proxy, so the browser enforces the CORS preflight + response headers |
| 9 | + * the customer scenario depends on (a frontend on its own origin subscribing |
| 10 | + * to the API). The webapp runs production-mode here, so this checks the |
| 11 | + * production CORS path, not the dev-server one. |
| 12 | + * |
| 13 | + * Requires a pre-built webapp (pnpm run build --filter webapp) and Chromium |
| 14 | + * (pnpm exec playwright install chromium). |
| 15 | + */ |
| 16 | +import { randomBytes } from "crypto"; |
| 17 | +import { createServer, type Server } from "http"; |
| 18 | +import { chromium, type Browser } from "@playwright/test"; |
| 19 | +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; |
| 20 | +import type { SessionStreamTestServer } from "@internal/testcontainers/webapp"; |
| 21 | +import { startSessionStreamTestServer } from "@internal/testcontainers/webapp"; |
| 22 | +import { seedTestEnvironment } from "./helpers/seedTestEnvironment"; |
| 23 | +import { |
| 24 | + mintSessionToken, |
| 25 | + SessionStreamProducer, |
| 26 | + sessionStreamName, |
| 27 | +} from "./helpers/sessionStream"; |
| 28 | + |
| 29 | +vi.setConfig({ testTimeout: 120_000, hookTimeout: 240_000 }); |
| 30 | + |
| 31 | +let server: SessionStreamTestServer; |
| 32 | +let browser: Browser; |
| 33 | +let origin: Server; |
| 34 | +let originUrl: string; |
| 35 | + |
| 36 | +beforeAll(async () => { |
| 37 | + server = await startSessionStreamTestServer(); |
| 38 | + browser = await chromium.launch(); |
| 39 | + origin = createServer((_req, res) => { |
| 40 | + res.writeHead(200, { "content-type": "text/html" }); |
| 41 | + res.end("<!doctype html><html><body>origin</body></html>"); |
| 42 | + }); |
| 43 | + await new Promise<void>((resolve) => origin.listen(0, "127.0.0.1", () => resolve())); |
| 44 | + const addr = origin.address(); |
| 45 | + const port = typeof addr === "object" && addr ? addr.port : 0; |
| 46 | + originUrl = `http://127.0.0.1:${port}`; |
| 47 | +}, 240_000); |
| 48 | + |
| 49 | +afterAll(async () => { |
| 50 | + await browser?.close().catch(() => {}); |
| 51 | + await new Promise<void>((resolve) => (origin ? origin.close(() => resolve()) : resolve())); |
| 52 | + await server?.stop(); |
| 53 | +}, 120_000); |
| 54 | + |
| 55 | +describe("session stream browser e2e", () => { |
| 56 | + it("EB1: a cross-origin browser subscribes to .out and streams records", async () => { |
| 57 | + const { organization, environment, apiKey } = await seedTestEnvironment(server.prisma); |
| 58 | + const addressingKey = `sess-${randomBytes(6).toString("hex")}`; |
| 59 | + const token = await mintSessionToken({ apiKey, envId: environment.id, addressingKey }); |
| 60 | + const streamName = sessionStreamName({ |
| 61 | + orgId: organization.id, |
| 62 | + envSlug: environment.slug, |
| 63 | + envId: environment.id, |
| 64 | + addressingKey, |
| 65 | + }); |
| 66 | + const producer = new SessionStreamProducer({ |
| 67 | + endpoint: server.s2.endpoint, |
| 68 | + basin: server.s2.basin, |
| 69 | + streamName, |
| 70 | + }); |
| 71 | + |
| 72 | + await producer.appendData({ n: 0 }, "p0"); |
| 73 | + await producer.appendData({ n: 1 }, "p1"); |
| 74 | + await producer.appendTurnComplete(); |
| 75 | + |
| 76 | + const sseUrl = `${server.webapp.baseUrl}/realtime/v1/sessions/${encodeURIComponent( |
| 77 | + addressingKey |
| 78 | + )}/out`; |
| 79 | + |
| 80 | + const page = await browser.newPage(); |
| 81 | + try { |
| 82 | + await page.goto(originUrl); |
| 83 | + const result = await page.evaluate( |
| 84 | + async ({ url, token }) => { |
| 85 | + const ac = new AbortController(); |
| 86 | + try { |
| 87 | + const res = await fetch(url, { |
| 88 | + headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" }, |
| 89 | + signal: ac.signal, |
| 90 | + }); |
| 91 | + const reader = (res.body as ReadableStream<Uint8Array>).getReader(); |
| 92 | + const decoder = new TextDecoder(); |
| 93 | + let text = ""; |
| 94 | + const deadline = Date.now() + 8000; |
| 95 | + while (Date.now() < deadline) { |
| 96 | + const { done, value } = await reader.read(); |
| 97 | + if (done) break; |
| 98 | + text += decoder.decode(value, { stream: true }); |
| 99 | + if (text.includes('"records"')) break; |
| 100 | + } |
| 101 | + ac.abort(); |
| 102 | + return { |
| 103 | + ok: res.ok, |
| 104 | + status: res.status, |
| 105 | + sawBatch: text.includes('"records"'), |
| 106 | + pageOrigin: location.origin, |
| 107 | + }; |
| 108 | + } catch (e) { |
| 109 | + return { error: String(e) }; |
| 110 | + } |
| 111 | + }, |
| 112 | + { url: sseUrl, token } |
| 113 | + ); |
| 114 | + |
| 115 | + expect("error" in result ? result.error : undefined).toBeUndefined(); |
| 116 | + expect(result).toMatchObject({ ok: true, status: 200, sawBatch: true }); |
| 117 | + expect((result as { pageOrigin: string }).pageOrigin).toBe(originUrl); |
| 118 | + expect(originUrl).not.toBe(server.webapp.baseUrl); |
| 119 | + } finally { |
| 120 | + await page.close(); |
| 121 | + } |
| 122 | + }); |
| 123 | +}); |
0 commit comments