import { describe, expect, it } from "vitest";
import { decodeLogFrame, pipelineLogsUrl } from "#lib/api/spindleLogs.ts";
// captured off the spindle's own serializer: CiSubscribePipelineLogs_Event
// .Serialize in api/tangled/cisubscribePipelineLogs_ext.go, hex-dumped
const frames = {
controlStart:
"a261746823636f6e74726f6c626f7001a7646b696e6464757365726473746570016474696d6574323032362d30382d30365431323a30303a30305a6673746174757365737461727467636f6d6d616e64676563686f20686967636f6e74656e746952756e20746573747368776f726b666c6f77656275696c64",
controlMinimal:
"a261746823636f6e74726f6c626f7001a46473746570016474696d6574323032362d30382d30365431323a30303a30395a67636f6e74656e746952756e20746573747368776f726b666c6f77656275696c64",
data: "a26174652364617461626f7001a56473746570016474696d6574323032362d30382d30365431323a30303a30315a6673747265616d6673746465727267636f6e74656e746e1b5b33316d626f6f6d1b5b306d0a68776f726b666c6f77656275696c64",
error: "a1626f7020a2656572726f7270576f726b666c6f774e6f74466f756e64676d657373616765706e6f207375636820776f726b666c6f77"
} as const;
const bytes = (hex: string): Uint8Array =>
Uint8Array.from(hex.match(/../g)!.map((pair) => parseInt(pair, 16)));
describe("decodeLogFrame", () => {
it("reads a step start with its command and kind", () => {
expect(decodeLogFrame(bytes(frames.controlStart))).toEqual({
kind: "control",
control: {
time: "2026-08-06T12:00:00Z",
workflow: "build",
step: 1,
content: "Run tests",
command: "echo hi",
status: "start",
kind: "user"
}
});
});
it("leaves the optional control fields off when the spindle omits them", () => {
const frame = decodeLogFrame(bytes(frames.controlMinimal));
expect(frame).toEqual({
kind: "control",
control: {
time: "2026-08-06T12:00:09Z",
workflow: "build",
step: 1,
content: "Run tests"
}
});
});
it("keeps a data line's ansi bytes and trailing newline intact", () => {
expect(decodeLogFrame(bytes(frames.data))).toEqual({
kind: "data",
data: {
time: "2026-08-06T12:00:01Z",
workflow: "build",
step: 1,
content: "\u001b[31mboom\u001b[0m\n",
stream: "stderr"
}
});
});
it("surfaces the stream's error frame", () => {
expect(decodeLogFrame(bytes(frames.error))).toEqual({
kind: "error",
error: "WorkflowNotFound",
message: "no such workflow"
});
});
it("ignores a message type it doesn't know", () => {
// same header shape, unknown t
const unknown = "a2617464236e6577626f7001a0";
expect(decodeLogFrame(bytes(unknown))).toBeNull();
});
});
describe("pipelineLogsUrl", () => {
it("upgrades the spindle's scheme and repeats the workflow param", () => {
expect(
pipelineLogsUrl("spindle.tangled.org", "did:plc:thisrepo", "3lz", ["build", "test"])
).toBe(
"wss://spindle.tangled.org/xrpc/org.tangled.ci.subscribePipelineLogs?repo=did%3Aplc%3Athisrepo&pipeline=3lz&workflows=build&workflows=test"
);
});
it("stays plaintext for a local spindle", () => {
expect(pipelineLogsUrl("localhost:6555", "did:plc:thisrepo", "3lz", ["build"])).toBe(
"ws://localhost:6555/xrpc/org.tangled.ci.subscribePipelineLogs?repo=did%3Aplc%3Athisrepo&pipeline=3lz&workflows=build"
);
});
});