Initial Commit
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = path.resolve(import.meta.dirname, "..");
|
||||
|
||||
describe("api-plugin-sse — dependency graph and exports map", () => {
|
||||
it("exports map advertises every feature subpath", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const raw = await readFile(path.join(PKG_ROOT, "package.json"), "utf8");
|
||||
const pkg = JSON.parse(raw) as {
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
const subpaths = Object.keys(pkg.exports)
|
||||
.filter(
|
||||
(k) =>
|
||||
(k === "." || k.startsWith("./")) && k !== "./package.json"
|
||||
)
|
||||
.sort();
|
||||
expect(subpaths).toEqual(
|
||||
[
|
||||
".",
|
||||
"./plugin/plugin",
|
||||
"./services/service",
|
||||
"./middleware/sse-handler",
|
||||
"./formatter/format",
|
||||
"./formatter/parse",
|
||||
"./buffer/ring-buffer",
|
||||
"./fanout/in-process",
|
||||
"./schemas/options",
|
||||
"./schemas/event",
|
||||
"./error-codes/error-codes",
|
||||
"./otel/instruments",
|
||||
"./types/types",
|
||||
"./registry/service-registry",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("dependencies use only workspace and catalog refs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const raw = await readFile(path.join(PKG_ROOT, "package.json"), "utf8");
|
||||
const pkg = JSON.parse(raw) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const all = {
|
||||
...(pkg.dependencies ?? {}),
|
||||
...(pkg.devDependencies ?? {}),
|
||||
};
|
||||
for (const [name, version] of Object.entries(all)) {
|
||||
// Allow exact versions for explicitly pinned testing deps.
|
||||
if (name === "@opentelemetry/sdk-metrics") continue;
|
||||
if (
|
||||
!version.startsWith("workspace:") &&
|
||||
!version.startsWith("catalog:")
|
||||
) {
|
||||
throw new Error(
|
||||
`Dependency ${name} uses bare version '${version}' — must be workspace:/catalog:`
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { createRingBuffer } from "../../src/buffer/ring-buffer.js";
|
||||
import { createInProcessFanout } from "../../src/fanout/in-process.js";
|
||||
import { parseSseStream } from "../../src/formatter/parse.js";
|
||||
import { createSseRoute } from "../../src/middleware/sse-handler.js";
|
||||
import { createSseService } from "../../src/services/service.js";
|
||||
|
||||
/**
|
||||
* End-to-end integration coverage. A real Hono app, a real fetch
|
||||
* round-trip (via `app.fetch` over a `Request`), and a real ReadableStream
|
||||
* client reader that decodes the SSE wire format using `parseSseStream`.
|
||||
*/
|
||||
describe("api-plugin-sse — full-stream integration", () => {
|
||||
it("end-to-end fetch subscriber receives three ordered events", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
let n = 0;
|
||||
const service = createSseService({
|
||||
buffer: createRingBuffer(100),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: () => `evt-${++n}`,
|
||||
maxConnectionsPerPrincipal: 16,
|
||||
maxTotalConnections: 100,
|
||||
});
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://test.local/sse/notifications", {
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Broadcast on a small delay so the subscriber has installed.
|
||||
setTimeout(() => {
|
||||
service.broadcast("notifications", { data: { n: 1 } });
|
||||
service.broadcast("notifications", { data: { n: 2 } });
|
||||
service.broadcast("notifications", { data: { n: 3 } });
|
||||
}, 10);
|
||||
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
const reader = body.getReader();
|
||||
const collected = [] as { id: string; data: unknown }[];
|
||||
for await (const event of parseSseStream(reader)) {
|
||||
collected.push({
|
||||
id: event.id,
|
||||
data: JSON.parse(event.data as string),
|
||||
});
|
||||
if (collected.length >= 3) break;
|
||||
}
|
||||
controller.abort();
|
||||
await reader.cancel().catch(() => undefined);
|
||||
|
||||
// service.subscribe consumes one id for the connection handle
|
||||
// (evt-1), then broadcasts consume evt-2/3/4 for the events.
|
||||
expect(collected.map((c) => c.id)).toEqual(["evt-2", "evt-3", "evt-4"]);
|
||||
expect(collected.map((c) => (c.data as { n: number }).n)).toEqual([
|
||||
1, 2, 3,
|
||||
]);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("reconnect with Last-Event-ID skips delivered events", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
let n = 0;
|
||||
const service = createSseService({
|
||||
buffer: createRingBuffer(100),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: () => `evt-${++n}`,
|
||||
maxConnectionsPerPrincipal: 16,
|
||||
maxTotalConnections: 100,
|
||||
});
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
|
||||
// Pre-load three broadcasts into the ring buffer.
|
||||
service.broadcast("c", { data: { n: 1 } });
|
||||
service.broadcast("c", { data: { n: 2 } });
|
||||
service.broadcast("c", { data: { n: 3 } });
|
||||
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://test.local/sse/c", {
|
||||
headers: { "Last-Event-ID": "evt-2" },
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
const reader = body.getReader();
|
||||
const collected = [] as { id: string }[];
|
||||
for await (const event of parseSseStream(reader)) {
|
||||
collected.push({ id: event.id });
|
||||
if (collected.length >= 1) break;
|
||||
}
|
||||
controller.abort();
|
||||
await reader.cancel().catch(() => undefined);
|
||||
|
||||
expect(collected.map((c) => c.id)).toEqual(["evt-3"]);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("heartbeat tick via injected interval triggers a comment frame", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const service = createSseService({
|
||||
buffer: createRingBuffer(10),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: () => "x",
|
||||
maxConnectionsPerPrincipal: 16,
|
||||
maxTotalConnections: 100,
|
||||
});
|
||||
const app = new Hono();
|
||||
let fire: (() => void) | undefined;
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 30_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
setIntervalImpl: (fn) => {
|
||||
fire = fn;
|
||||
return { mock: true };
|
||||
},
|
||||
clearIntervalImpl: () => {
|
||||
fire = undefined;
|
||||
},
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", { signal: controller.signal })
|
||||
);
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
const reader = body.getReader();
|
||||
// Allow stream install
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
fire?.();
|
||||
const { value } = await reader.read();
|
||||
const decoder = new TextDecoder();
|
||||
const text = decoder.decode(value ?? new Uint8Array());
|
||||
expect(text).toContain(": heartbeat");
|
||||
controller.abort();
|
||||
await reader.cancel().catch(() => undefined);
|
||||
await service.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-sse.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider so OTel
|
||||
* instruments resolved on first call bind against an exporter the
|
||||
* tests can flush + read.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
Reference in New Issue
Block a user