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:
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user