test(webapp): browser-level cross-origin session-stream e2e

Adds a real-Chromium leg to the session-stream e2e: a page on a different
origin than the webapp cross-origin fetches and streams the session `.out`
through the proxy, exercising the browser CORS preflight and streaming read
that a node-fetch client skips. Runs against the same testcontainer stack.
This commit is contained in:
Eric Allam
2026-07-24 11:43:32 +01:00
parent 98f3f0f2fa
commit 75c799084a
4 changed files with 130 additions and 0 deletions
+3
View File
@@ -92,6 +92,9 @@ jobs:
- name: 🏗️ Build Webapp
run: pnpm run build --filter webapp
- name: 🎭 Install Playwright Chromium
run: cd apps/webapp && pnpm exec playwright install --with-deps chromium
- name: 🧪 Run Webapp E2E Tests
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default
env:
+1
View File
@@ -219,6 +219,7 @@
"@internal/clickhouse": "workspace:*",
"@internal/replication": "workspace:*",
"@internal/testcontainers": "workspace:*",
"@playwright/test": "^1.36.2",
"@remix-run/dev": "2.17.5",
"@remix-run/testing": "^2.17.5",
"@sentry/cli": "2.50.2",
@@ -0,0 +1,123 @@
/**
* Browser-level session-stream e2e.
*
* Same full stack as session-stream.e2e.test.ts, but the client is a real
* Chromium page on a DIFFERENT origin than the webapp, driven via
* page.evaluate. It exercises what node-fetch can't: a real browser doing a
* cross-origin fetch + streaming read of the session `.out` SSE through the
* webapp proxy, so the browser enforces the CORS preflight + response headers
* the customer scenario depends on (a frontend on its own origin subscribing
* to the API). The webapp runs production-mode here, so this checks the
* production CORS path, not the dev-server one.
*
* Requires a pre-built webapp (pnpm run build --filter webapp) and Chromium
* (pnpm exec playwright install chromium).
*/
import { randomBytes } from "crypto";
import { createServer, type Server } from "http";
import { chromium, type Browser } from "@playwright/test";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { SessionStreamTestServer } from "@internal/testcontainers/webapp";
import { startSessionStreamTestServer } from "@internal/testcontainers/webapp";
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
import {
mintSessionToken,
SessionStreamProducer,
sessionStreamName,
} from "./helpers/sessionStream";
vi.setConfig({ testTimeout: 120_000, hookTimeout: 240_000 });
let server: SessionStreamTestServer;
let browser: Browser;
let origin: Server;
let originUrl: string;
beforeAll(async () => {
server = await startSessionStreamTestServer();
browser = await chromium.launch();
origin = createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/html" });
res.end("<!doctype html><html><body>origin</body></html>");
});
await new Promise<void>((resolve) => origin.listen(0, "127.0.0.1", () => resolve()));
const addr = origin.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
originUrl = `http://127.0.0.1:${port}`;
}, 240_000);
afterAll(async () => {
await browser?.close().catch(() => {});
await new Promise<void>((resolve) => (origin ? origin.close(() => resolve()) : resolve()));
await server?.stop();
}, 120_000);
describe("session stream browser e2e", () => {
it("EB1: a cross-origin browser subscribes to .out and streams records", async () => {
const { organization, environment, apiKey } = await seedTestEnvironment(server.prisma);
const addressingKey = `sess-${randomBytes(6).toString("hex")}`;
const token = await mintSessionToken({ apiKey, envId: environment.id, addressingKey });
const streamName = sessionStreamName({
orgId: organization.id,
envSlug: environment.slug,
envId: environment.id,
addressingKey,
});
const producer = new SessionStreamProducer({
endpoint: server.s2.endpoint,
basin: server.s2.basin,
streamName,
});
await producer.appendData({ n: 0 }, "p0");
await producer.appendData({ n: 1 }, "p1");
await producer.appendTurnComplete();
const sseUrl = `${server.webapp.baseUrl}/realtime/v1/sessions/${encodeURIComponent(
addressingKey
)}/out`;
const page = await browser.newPage();
try {
await page.goto(originUrl);
const result = await page.evaluate(
async ({ url, token }) => {
const ac = new AbortController();
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
signal: ac.signal,
});
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
const decoder = new TextDecoder();
let text = "";
const deadline = Date.now() + 8000;
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
if (text.includes('"records"')) break;
}
ac.abort();
return {
ok: res.ok,
status: res.status,
sawBatch: text.includes('"records"'),
pageOrigin: location.origin,
};
} catch (e) {
return { error: String(e) };
}
},
{ url: sseUrl, token }
);
expect("error" in result ? result.error : undefined).toBeUndefined();
expect(result).toMatchObject({ ok: true, status: 200, sawBatch: true });
expect((result as { pageOrigin: string }).pageOrigin).toBe(originUrl);
expect(originUrl).not.toBe(server.webapp.baseUrl);
} finally {
await page.close();
}
});
});
+3
View File
@@ -780,6 +780,9 @@ importers:
'@internal/testcontainers':
specifier: workspace:*
version: link:../../internal-packages/testcontainers
'@playwright/test':
specifier: ^1.36.2
version: 1.37.0
'@remix-run/dev':
specifier: 2.17.5
version: 2.17.5(@remix-run/react@2.17.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@remix-run/serve@2.17.5(typescript@6.0.3))(@types/node@24.13.3)(bufferutil@4.0.9)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(typescript@6.0.3)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(yaml@2.9.0)