Skip to content

Commit 3931676

Browse files
committed
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.
1 parent b3ad0d0 commit 3931676

4 files changed

Lines changed: 130 additions & 0 deletions

File tree

.github/workflows/e2e-webapp.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ jobs:
9292
- name: 🏗️ Build Webapp
9393
run: pnpm run build --filter webapp
9494

95+
- name: 🎭 Install Playwright Chromium
96+
run: cd apps/webapp && pnpm exec playwright install --with-deps chromium
97+
9598
- name: 🧪 Run Webapp E2E Tests
9699
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default
97100
env:

apps/webapp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@
219219
"@internal/clickhouse": "workspace:*",
220220
"@internal/replication": "workspace:*",
221221
"@internal/testcontainers": "workspace:*",
222+
"@playwright/test": "^1.36.2",
222223
"@remix-run/dev": "2.17.5",
223224
"@remix-run/testing": "^2.17.5",
224225
"@sentry/cli": "2.50.2",
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)