276 lines
9.8 KiB
TypeScript
276 lines
9.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
createNotification,
|
|
decodeChannelBytes,
|
|
DUPLEX_CHANNEL_DATA_NOTIFICATION,
|
|
DUPLEX_CHANNEL_EXIT_NOTIFICATION,
|
|
encodeChannelBytes,
|
|
parseMessage,
|
|
serializeMessage,
|
|
type PluginDuplexChannelCloseParams,
|
|
type PluginDuplexChannelCloseResult,
|
|
type PluginDuplexChannelDataParams,
|
|
type PluginDuplexChannelExitParams,
|
|
type PluginDuplexChannelOpenParams,
|
|
type PluginDuplexChannelOpenResult,
|
|
type PluginDuplexChannelStopParams,
|
|
type PluginDuplexChannelWriteParams,
|
|
} from "./protocol.js";
|
|
|
|
// The generic duplex channel messages model the setup-token pseudo-terminal
|
|
// contract: open, write, stop, and close requests, plus data and exit
|
|
// notifications. The host owns the route identifier. The worker returns a worker
|
|
// session identifier that binds the data and the exit notification only. A close
|
|
// keys on the host route identifier, so a lost open reply still permits a
|
|
// host-keyed close.
|
|
//
|
|
// The messages are static types, so a valid payload assigns to its type and an
|
|
// invalid payload fails the type check. The `@ts-expect-error` directives make
|
|
// the compiler reject each invalid payload. The `tsc --noEmit` check validates
|
|
// the directives, so an invalid payload that the type accepts fails the build.
|
|
|
|
describe("duplex channel request schemas", () => {
|
|
it("accepts a valid open request and its reply", () => {
|
|
const open: PluginDuplexChannelOpenParams = {
|
|
hostRouteId: "route-1",
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: "lease-1",
|
|
command: ["paperclip-bridge"],
|
|
};
|
|
const reply: PluginDuplexChannelOpenResult = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
};
|
|
expect(open.hostRouteId).toBe("route-1");
|
|
expect(reply.hostRouteId).toBe("route-1");
|
|
expect(reply.workerSessionId).toBe("ws-1");
|
|
});
|
|
|
|
it("rejects an open reply that omits the echoed host route identifier", () => {
|
|
// @ts-expect-error — the open reply echoes the host route identifier.
|
|
const reply: PluginDuplexChannelOpenResult = { workerSessionId: "ws-1" };
|
|
expect(reply).toBeDefined();
|
|
});
|
|
|
|
it("rejects an open request that omits the host route identifier", () => {
|
|
// @ts-expect-error — hostRouteId is required.
|
|
const open: PluginDuplexChannelOpenParams = {
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: "lease-1",
|
|
command: ["paperclip-bridge"],
|
|
};
|
|
expect(open).toBeDefined();
|
|
});
|
|
|
|
it("rejects an open request whose command is not a string array", () => {
|
|
const open: PluginDuplexChannelOpenParams = {
|
|
hostRouteId: "route-1",
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: "lease-1",
|
|
// @ts-expect-error — command must be a string array.
|
|
command: "paperclip-bridge",
|
|
};
|
|
expect(open).toBeDefined();
|
|
});
|
|
|
|
it("accepts a valid write request that carries the exact pair", () => {
|
|
const write: PluginDuplexChannelWriteParams = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
data: "payload",
|
|
};
|
|
expect(write.data).toBe("payload");
|
|
});
|
|
|
|
it("rejects a write request that omits the host route identifier", () => {
|
|
// @ts-expect-error — hostRouteId is required on a post-bind write.
|
|
const write: PluginDuplexChannelWriteParams = {
|
|
workerSessionId: "ws-1",
|
|
data: "payload",
|
|
};
|
|
expect(write).toBeDefined();
|
|
});
|
|
|
|
it("rejects a write request that omits the data", () => {
|
|
// @ts-expect-error — data is required.
|
|
const write: PluginDuplexChannelWriteParams = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
};
|
|
expect(write).toBeDefined();
|
|
});
|
|
|
|
it("accepts a valid stop request that carries the exact pair", () => {
|
|
const stop: PluginDuplexChannelStopParams = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
};
|
|
expect(stop.workerSessionId).toBe("ws-1");
|
|
});
|
|
|
|
it("rejects a stop request that omits the host route identifier", () => {
|
|
// @ts-expect-error — hostRouteId is required on a post-bind stop.
|
|
const stop: PluginDuplexChannelStopParams = { workerSessionId: "ws-1" };
|
|
expect(stop).toBeDefined();
|
|
});
|
|
|
|
it("rejects a stop request that omits the worker session identifier", () => {
|
|
// @ts-expect-error — workerSessionId is required.
|
|
const stop: PluginDuplexChannelStopParams = { hostRouteId: "route-1" };
|
|
expect(stop).toBeDefined();
|
|
});
|
|
|
|
it("accepts a pre-bind close request and a route-only acknowledgement", () => {
|
|
const close: PluginDuplexChannelCloseParams = { hostRouteId: "route-1" };
|
|
const reply: PluginDuplexChannelCloseResult = { hostRouteId: "route-1" };
|
|
expect(close.hostRouteId).toBe("route-1");
|
|
expect(reply.hostRouteId).toBe("route-1");
|
|
});
|
|
|
|
it("accepts a bound close acknowledgement that echoes both identifiers", () => {
|
|
const reply: PluginDuplexChannelCloseResult = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
};
|
|
expect(reply.workerSessionId).toBe("ws-1");
|
|
});
|
|
|
|
it("accepts a close request that also carries the worker session identifier", () => {
|
|
const close: PluginDuplexChannelCloseParams = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
};
|
|
expect(close.workerSessionId).toBe("ws-1");
|
|
});
|
|
|
|
it("rejects a close request that omits the host route identifier", () => {
|
|
// @ts-expect-error — hostRouteId is the authoritative close key and is required.
|
|
const close: PluginDuplexChannelCloseParams = { workerSessionId: "ws-1" };
|
|
expect(close).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("duplex channel notification schemas", () => {
|
|
it("uses the generic notification method names", () => {
|
|
expect(DUPLEX_CHANNEL_DATA_NOTIFICATION).toBe("duplexChannel.data");
|
|
expect(DUPLEX_CHANNEL_EXIT_NOTIFICATION).toBe("duplexChannel.exit");
|
|
});
|
|
|
|
it("accepts a valid data notification that carries the exact pair", () => {
|
|
const data: PluginDuplexChannelDataParams = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
chunk: "output bytes",
|
|
};
|
|
expect(data.chunk).toBe("output bytes");
|
|
});
|
|
|
|
it("rejects a data notification that omits the host route identifier", () => {
|
|
// @ts-expect-error — hostRouteId is required on a notification.
|
|
const data: PluginDuplexChannelDataParams = {
|
|
workerSessionId: "ws-1",
|
|
chunk: "output bytes",
|
|
};
|
|
expect(data).toBeDefined();
|
|
});
|
|
|
|
it("rejects a data notification that omits the chunk", () => {
|
|
// @ts-expect-error — chunk is required.
|
|
const data: PluginDuplexChannelDataParams = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
};
|
|
expect(data).toBeDefined();
|
|
});
|
|
|
|
it("accepts an exit notification with a numeric code and with null", () => {
|
|
const exit: PluginDuplexChannelExitParams = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
exitCode: 0,
|
|
};
|
|
const exitNull: PluginDuplexChannelExitParams = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
exitCode: null,
|
|
};
|
|
expect(exit.exitCode).toBe(0);
|
|
expect(exitNull.exitCode).toBeNull();
|
|
});
|
|
|
|
it("rejects an exit notification that omits the host route identifier", () => {
|
|
// @ts-expect-error — hostRouteId is required on a notification.
|
|
const exit: PluginDuplexChannelExitParams = {
|
|
workerSessionId: "ws-1",
|
|
exitCode: 0,
|
|
};
|
|
expect(exit).toBeDefined();
|
|
});
|
|
|
|
it("rejects an exit notification with a non-numeric, non-null exit code", () => {
|
|
const exit: PluginDuplexChannelExitParams = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
// @ts-expect-error — exitCode must be a number or null.
|
|
exitCode: "0",
|
|
};
|
|
expect(exit).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// JSON-RPC travels as one line of JSON text (see `serializeMessage`), and JSON
|
|
// carries no binary type. `chunk` and `data` cross this hop as a base64 string
|
|
// (`ChannelBytesWireValue`); `encodeChannelBytes`/`decodeChannelBytes` are the
|
|
// one shared codec both ends use.
|
|
describe("duplex channel byte-safe wire representation", () => {
|
|
it("test_json_rpc_hop_preserves_all_byte_values", () => {
|
|
const allByteValues = Uint8Array.from({ length: 256 }, (_, value) => value);
|
|
|
|
// Encode the full byte corpus into a data notification, exactly as
|
|
// worker-rpc-host.ts does at the worker→host boundary, then send it through
|
|
// the same newline-delimited JSON serialization the host and the worker use
|
|
// over stdio.
|
|
const params: PluginDuplexChannelDataParams = {
|
|
hostRouteId: "route-1",
|
|
workerSessionId: "ws-1",
|
|
chunk: encodeChannelBytes(allByteValues),
|
|
};
|
|
const line = serializeMessage(
|
|
createNotification(DUPLEX_CHANNEL_DATA_NOTIFICATION, params),
|
|
);
|
|
|
|
// Parse the line back, exactly as the reading side does, and decode the
|
|
// chunk back to raw bytes.
|
|
const parsed = parseMessage(line);
|
|
expect("params" in parsed).toBe(true);
|
|
const receivedParams = (parsed as { params: PluginDuplexChannelDataParams }).params;
|
|
const decoded = decodeChannelBytes(receivedParams.chunk);
|
|
|
|
expect(decoded).not.toBeNull();
|
|
expect(decoded).toEqual(allByteValues);
|
|
// The byte value zero is the case a UTF-8 string hop loses or mistreats as a
|
|
// terminator. Assert it by name, not only as part of the full-corpus check.
|
|
expect(decoded?.[0]).toBe(0);
|
|
});
|
|
|
|
it("round-trips an empty byte chunk through encode and decode", () => {
|
|
const empty = new Uint8Array(0);
|
|
const encoded = encodeChannelBytes(empty);
|
|
expect(encoded).toBe("");
|
|
expect(decodeChannelBytes(encoded)).toBeNull();
|
|
});
|
|
|
|
it("decodeChannelBytes rejects a malformed wire value", () => {
|
|
expect(decodeChannelBytes("not valid base64!!")).toBeNull();
|
|
expect(decodeChannelBytes(undefined)).toBeNull();
|
|
expect(decodeChannelBytes(42)).toBeNull();
|
|
});
|
|
});
|