feat: complete TASK-WP0-02 contract baseline
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { EventHub } from "../../apps/api/src/event-hub.js";
|
||||
|
||||
const eventHub = new EventHub();
|
||||
let app: Awaited<ReturnType<typeof createApp>>;
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
app = await createApp({ eventHub });
|
||||
app.get("/__test/entity", async (request) => ({ entity_ref: (request.query as { ref?: string }).ref ?? null }));
|
||||
app.get("/__test/models", async () => ({ source: "rest" }));
|
||||
const apiUrl = await app.listen({ host: "127.0.0.1", port: 0 });
|
||||
|
||||
vite = await createServer({
|
||||
configFile: false,
|
||||
root: process.cwd(),
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
proxy: {
|
||||
"/__test": apiUrl,
|
||||
"/api": apiUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
eventHub.disconnectAll();
|
||||
await vite.close();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test("SSE remains a hint and recovers through REST", async ({ page }) => {
|
||||
const published = [
|
||||
{
|
||||
entity_ref: "projects:project-1",
|
||||
event_id: 40,
|
||||
event_type: "project_state_changed",
|
||||
occurred_at: "2026-07-27T09:00:00.000Z",
|
||||
state_version: 2,
|
||||
},
|
||||
{
|
||||
config_set_version: 9,
|
||||
entity_ref: "models:current",
|
||||
event_id: 42,
|
||||
event_type: "model_config_changed",
|
||||
occurred_at: "2026-07-27T09:00:01.000Z",
|
||||
},
|
||||
] as const;
|
||||
|
||||
await page.goto(`${webUrl}/tests/e2e/fixtures/event-sync.html`);
|
||||
await expect(page.locator("#status")).toHaveText("connected");
|
||||
expect(eventHub.subscriberCount).toBe(1);
|
||||
|
||||
eventHub.publish(published[0]);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "refetch_entity").length))
|
||||
.toBe(1);
|
||||
|
||||
eventHub.publish(published[1]);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "bootstrap").length))
|
||||
.toBe(1);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "refetch_models").length))
|
||||
.toBe(1);
|
||||
|
||||
eventHub.disconnectAll();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "bootstrap").length))
|
||||
.toBe(2);
|
||||
|
||||
const timeline = await page.evaluate(() => window.wp0EventFixture.timeline);
|
||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_EVT;
|
||||
if (evidenceDirectory) {
|
||||
mkdirSync(evidenceDirectory, { recursive: true });
|
||||
writeFileSync(
|
||||
resolve(evidenceDirectory, "sse-events.json"),
|
||||
`${JSON.stringify({ events: published, status: "passed" }, null, 2)}\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
resolve(evidenceDirectory, "network-timeline.json"),
|
||||
`${JSON.stringify({ status: "passed", timeline }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>WP0-02 event sync fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="status">starting</main>
|
||||
<script type="module" src="/tests/e2e/fixtures/event-sync.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { connectEventSource, createEventSyncController } from "../../../apps/web/src/event-sync.js";
|
||||
|
||||
interface TimelineEntry {
|
||||
action: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wp0EventFixture: {
|
||||
timeline: TimelineEntry[];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const timeline: TimelineEntry[] = [];
|
||||
const request = async (url: string, action: string, detail?: string) => {
|
||||
timeline.push({ action, ...(detail ? { detail } : {}) });
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`${action} failed`);
|
||||
await response.json();
|
||||
};
|
||||
|
||||
const controller = createEventSyncController({
|
||||
bootstrap: () => request("/api/v1/bootstrap", "bootstrap"),
|
||||
refetchEntity: (entityRef) =>
|
||||
request(`/__test/entity?ref=${encodeURIComponent(entityRef)}`, "refetch_entity", entityRef),
|
||||
refetchModels: (input) =>
|
||||
request("/__test/models", "refetch_models", input.reason),
|
||||
});
|
||||
const source = connectEventSource("/api/v1/events", controller);
|
||||
source.addEventListener("open", () => {
|
||||
timeline.push({ action: "connected" });
|
||||
document.getElementById("status")!.textContent = "connected";
|
||||
});
|
||||
|
||||
window.wp0EventFixture = { timeline };
|
||||
Reference in New Issue
Block a user