Files
tech-log-frontend/tests/unit/browser-rpc/browser-rpc-runtime.test.ts
T

452 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
createBrowserRpcRuntime,
createUnavailableBrowserRpcTransport,
defineBrowserRpcTransport,
type BrowserRpcObservation,
type BrowserRpcStreamFrame,
type BrowserRpcTransport,
} from "../../../src/adapters/browser-rpc/index.ts";
import type { Result } from "../../../src/application/result.ts";
import type { AppFailure } from "../../../src/contracts/errors.ts";
import {
MAPPERS,
SCHEMA_CODECS,
STREAM_ENCODER,
UNARY_ENCODER,
isResourceView,
streamOperation,
streamProfile,
unaryOperation,
unaryProfile,
type ResourceView,
} from "./fixture.ts";
describe("Browser RPC provider-neutral runtime", () => {
it("validates, encodes, maps and admits only the typed unary result", async () => {
const observations: BrowserRpcObservation[] = [];
const transport = defineBrowserRpcTransport({
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
providerId: "REFERENCE_RPC",
protocol: "CONNECT_HTTP",
rpcKind: "UNARY",
async invokeUnary(call) {
expect(call.request).toEqual({ resourceId: "resource-1" });
expect(call.timeoutMs).toBeGreaterThan(0);
return Object.freeze({
ok: true,
message: Object.freeze({ id: "resource-1", name: "Resource one" }),
encodedBytes: 48,
});
},
});
const port = unaryRuntime(transport, {
observe(value) {
observations.push(value);
},
}).bindUnary("GET_RPC_RESOURCE", isResourceView);
await expect(port.execute({ resourceId: "resource-1" })).resolves.toEqual({
ok: true,
value: { id: "resource-1", label: "Resource one" },
});
expect(observations).toEqual([
{
operationId: "GET_RPC_RESOURCE",
protocol: "CONNECT_HTTP",
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
rpcKind: "UNARY",
outcome: "SUCCESS",
attemptCount: 1,
messageCount: 1,
},
]);
});
it("fails before transport on invalid input or unexpected idempotency metadata", async () => {
let calls = 0;
const transport = defineBrowserRpcTransport({
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
providerId: "REFERENCE_RPC",
protocol: "CONNECT_HTTP",
rpcKind: "UNARY",
async invokeUnary() {
calls += 1;
return {
ok: false,
failure: { code: "UNAVAILABLE" },
};
},
});
const port = unaryRuntime(transport).bindUnary(
"GET_RPC_RESOURCE",
isResourceView,
);
const invalid = await port.execute({ resourceId: 42 });
expect(invalid).toMatchObject({
ok: false,
error: {
kind: "VALIDATION_REJECTED",
code: "RPC_REQUEST_SCHEMA_INVALID",
},
});
const metadata = await port.execute(
{ resourceId: "resource-1" },
{ idempotencyKey: "caller-key-is-not-allowed" },
);
expect(metadata).toMatchObject({
ok: false,
error: {
kind: "VALIDATION_REJECTED",
code: "RPC_IDEMPOTENCY_KEY_INVALID",
},
});
expect(calls).toBe(0);
});
it("keeps the frontend retry owner bounded by replay policy and one total deadline", async () => {
let calls = 0;
const profile = unaryProfile({
retryProfileId: "RPC_RETRY_TWO",
retryOwner: "FRONTEND_ADAPTER",
maxAttempts: 2,
backoffMs: [0],
retryableFailures: ["UNAVAILABLE"],
maxRetryAfterMs: 100,
});
const operation = unaryOperation({
retryProfileId: "RPC_RETRY_TWO",
});
const transport = defineBrowserRpcTransport({
runtimeProfileId: profile.runtimeProfileId,
providerId: profile.providerId,
protocol: profile.protocol,
rpcKind: profile.rpcKind,
async invokeUnary() {
calls += 1;
if (calls === 1) {
return {
ok: false,
failure: { code: "UNAVAILABLE" },
};
}
return {
ok: true,
message: { id: "resource-2", name: "Retried resource" },
encodedBytes: 32,
};
},
});
const runtime = createBrowserRpcRuntime({
operations: { GET_RPC_RESOURCE: operation },
profiles: { CONNECT_REFERENCE_UNARY: profile },
schemaCodecs: SCHEMA_CODECS,
mappers: MAPPERS,
requestEncoders: {
RpcResourceRequestEncoder: UNARY_ENCODER,
},
transports: { CONNECT_REFERENCE_UNARY: transport },
});
await expect(
runtime
.bindUnary("GET_RPC_RESOURCE", isResourceView)
.execute({ resourceId: "resource-2" }),
).resolves.toMatchObject({
ok: true,
value: { id: "resource-2", label: "Retried resource" },
});
expect(calls).toBe(2);
});
it("drops a late unary result after its scope generation changes", async () => {
const transport = defineBrowserRpcTransport({
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
providerId: "REFERENCE_RPC",
protocol: "CONNECT_HTTP",
rpcKind: "UNARY",
async invokeUnary() {
return {
ok: true,
message: { id: "late", name: "Late resource" },
encodedBytes: 24,
};
},
});
const runtime = createBrowserRpcRuntime({
...baseDependencies(transport),
generationFence: {
capture: () => 1,
isCurrent: () => false,
},
});
await expect(
runtime
.bindUnary("GET_RPC_RESOURCE", isResourceView)
.execute({ resourceId: "late" }),
).resolves.toMatchObject({
ok: false,
error: {
kind: "SCOPE_GENERATION_CHANGED",
code: "RPC_SCOPE_GENERATION_CHANGED",
},
});
});
it("uses an explicit unavailable adapter without network fallback", async () => {
const transport = createUnavailableBrowserRpcTransport({
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
providerId: "REFERENCE_RPC",
protocol: "CONNECT_HTTP",
rpcKind: "UNARY",
});
const result = await unaryRuntime(transport)
.bindUnary("GET_RPC_RESOURCE", isResourceView)
.execute({ resourceId: "resource-1" });
expect(result).toMatchObject({
ok: false,
error: {
kind: "SERVER_FAILURE",
code: "RPC_UNAVAILABLE",
},
});
});
it("classifies valid oversized transport metadata as a response limit", async () => {
const transport = defineBrowserRpcTransport({
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
providerId: "REFERENCE_RPC",
protocol: "CONNECT_HTTP",
rpcKind: "UNARY",
async invokeUnary() {
return {
ok: true,
message: { id: "large", name: "Large resource" },
encodedBytes: 4_097,
};
},
});
await expect(
unaryRuntime(transport)
.bindUnary("GET_RPC_RESOURCE", isResourceView)
.execute({ resourceId: "large" }),
).resolves.toMatchObject({
ok: false,
error: {
kind: "RESPONSE_BODY_LIMIT",
code: "RPC_RESPONSE_MESSAGE_LIMIT",
},
});
});
it("rejects transports that expose the wrong call shape", () => {
expect(() =>
defineBrowserRpcTransport({
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
providerId: "REFERENCE_RPC",
protocol: "CONNECT_HTTP",
rpcKind: "UNARY",
async invokeUnary() {
return {
ok: false,
failure: { code: "UNAVAILABLE" },
};
},
async *openServerStream() {
yield Object.freeze({ kind: "TERMINAL", ok: true });
},
}),
).toThrow("transport is invalid");
});
it("commits mapped stream messages only before one valid terminal envelope", async () => {
const transport = streamTransport(async function* () {
yield message("resource-1", "One", 24);
yield message("resource-2", "Two", 24);
yield Object.freeze({ kind: "TERMINAL", ok: true });
});
const observations: BrowserRpcObservation[] = [];
const stream = streamRuntime(transport, {
observe(value) {
observations.push(value);
},
})
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
.open({ resourceId: "scope-1" });
await expect(collect(stream)).resolves.toEqual([
{
ok: true,
value: { id: "resource-1", label: "One" },
},
{
ok: true,
value: { id: "resource-2", label: "Two" },
},
]);
expect(observations).toEqual([
{
operationId: "WATCH_RPC_RESOURCES",
protocol: "CONNECT_HTTP",
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
rpcKind: "SERVER_STREAM",
outcome: "SUCCESS",
attemptCount: 1,
messageCount: 2,
},
]);
});
it("rejects EOF without terminal and data after terminal", async () => {
const missingTerminal = streamTransport(async function* () {
yield message("resource-1", "One", 24);
});
const missingResults = await collect(
streamRuntime(missingTerminal)
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
.open({ resourceId: "scope-1" }),
);
expect(missingResults.at(-1)).toMatchObject({
ok: false,
error: {
kind: "API_CONTRACT_MISMATCH",
code: "RPC_PROTOCOL_MISMATCH",
},
});
const afterTerminal = streamTransport(async function* () {
yield Object.freeze({ kind: "TERMINAL", ok: true });
yield message("resource-2", "Two", 24);
});
await expect(
collect(
streamRuntime(afterTerminal)
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
.open({ resourceId: "scope-1" }),
),
).resolves.toMatchObject([
{
ok: false,
error: {
kind: "API_CONTRACT_MISMATCH",
code: "RPC_PROTOCOL_MISMATCH",
},
},
]);
});
it("aborts the transport when stream message limits are exceeded", async () => {
let cleaned = false;
const transport = streamTransport(async function* (signal) {
try {
yield message("resource-1", "One", 24);
yield message("resource-2", "Two", 24);
yield Object.freeze({ kind: "TERMINAL", ok: true });
} finally {
cleaned = signal.aborted;
}
});
const operation = streamOperation({ maxResponseMessages: 1 });
const results = await collect(
streamRuntime(transport, undefined, operation)
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
.open({ resourceId: "scope-1" }),
);
expect(results.at(-1)).toMatchObject({
ok: false,
error: {
kind: "RESPONSE_BODY_LIMIT",
code: "RPC_STREAM_MESSAGE_LIMIT",
},
});
expect(cleaned).toBe(true);
});
});
function unaryRuntime(
transport: BrowserRpcTransport,
observations?: Readonly<{
observe(value: BrowserRpcObservation): void;
}>,
) {
return createBrowserRpcRuntime({
...baseDependencies(transport),
observations,
});
}
function baseDependencies(transport: BrowserRpcTransport) {
return {
operations: { GET_RPC_RESOURCE: unaryOperation() },
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
schemaCodecs: SCHEMA_CODECS,
mappers: MAPPERS,
requestEncoders: {
RpcResourceRequestEncoder: UNARY_ENCODER,
},
transports: { CONNECT_REFERENCE_UNARY: transport },
} as const;
}
function streamRuntime(
transport: BrowserRpcTransport,
observations?: Readonly<{
observe(value: BrowserRpcObservation): void;
}>,
operation = streamOperation(),
) {
return createBrowserRpcRuntime({
operations: { WATCH_RPC_RESOURCES: operation },
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
schemaCodecs: SCHEMA_CODECS,
mappers: MAPPERS,
requestEncoders: {
RpcResourceStreamRequestEncoder: STREAM_ENCODER,
},
transports: { CONNECT_REFERENCE_STREAM: transport },
observations,
});
}
function streamTransport(
source: (
signal: AbortSignal,
) => AsyncIterable<BrowserRpcStreamFrame>,
): BrowserRpcTransport {
return defineBrowserRpcTransport({
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
providerId: "REFERENCE_RPC",
protocol: "CONNECT_HTTP",
rpcKind: "SERVER_STREAM",
openServerStream(call) {
return source(call.signal);
},
});
}
function message(
id: string,
name: string,
encodedBytes: number,
): BrowserRpcStreamFrame {
return Object.freeze({
kind: "MESSAGE",
message: Object.freeze({ id, name }),
encodedBytes,
});
}
async function collect<Value>(
iterable: AsyncIterable<Result<Value, AppFailure>>,
): Promise<readonly Result<Value, AppFailure>[]> {
const values: Result<Value, AppFailure>[] = [];
for await (const value of iterable) values.push(value);
return values;
}