Files
kuber/tests/lib/build-api.test.ts
T
2026-09-03 11:28:30 +07:00

179 lines
5.6 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ApiRequestInit } from "../../lib/api";
import {
buildServices,
resolveBuildImages,
uploadWorkspaceSnapshot,
type ApiRequester,
} from "../../lib/build";
import {
workspaceManifestDigest,
type WorkspaceSnapshot,
} from "../../lib/workspace";
import type { BuildRequest } from "../../shared/build-protocol";
const directories: string[] = [];
function emptySnapshot(): WorkspaceSnapshot {
const manifest = { version: 1 as const, files: [] };
return { manifest, digest: workspaceManifestDigest(manifest), blobs: [] };
}
afterEach(async () => {
await Promise.all(
directories
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
describe("authenticated build API pipeline", () => {
test("negotiates and uploads the manifest through resumable blob routes", async () => {
const snapshot = emptySnapshot();
const calls: Array<{ path: string; init?: ApiRequestInit }> = [];
let negotiations = 0;
const request: ApiRequester = async <T>(
path: string,
init?: ApiRequestInit,
) => {
calls.push({ path, init });
if (path === "/snapshots/negotiate") {
negotiations += 1;
return (
negotiations === 1
? {
workspace: snapshot.digest,
missing: [snapshot.digest],
ready: false,
}
: { workspace: snapshot.digest, missing: [], ready: true }
) as T;
}
if (init?.method === "POST" && !path.endsWith("/complete"))
return { offset: 0, complete: false } as T;
if (init?.method === "PATCH")
return { offset: (init.body as Uint8Array).byteLength } as T;
return { complete: true } as T;
};
await uploadWorkspaceSnapshot(snapshot, request);
expect(
calls.map(({ path, init }) => [init?.method ?? "GET", path]),
).toEqual([
["POST", "/snapshots/negotiate"],
["POST", `/blobs/${encodeURIComponent(snapshot.digest)}/uploads`],
["PATCH", `/blobs/${encodeURIComponent(snapshot.digest)}/uploads`],
[
"POST",
`/blobs/${encodeURIComponent(snapshot.digest)}/uploads/complete`,
],
["POST", "/snapshots/negotiate"],
]);
expect(new Headers(calls[2]!.init?.headers).get("upload-offset")).toBe("0");
});
test("submits, reconciles, reports logs, and returns the server image reference", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-build-api-"));
directories.push(root);
const git = Bun.spawn(["git", "init", "-q", root]);
expect(await git.exited).toBe(0);
const snapshot = emptySnapshot();
const calls: Array<{ path: string; init?: ApiRequestInit }> = [];
let submitted: BuildRequest | undefined;
const output: string[] = [];
const request: ApiRequester = async <T>(
path: string,
init?: ApiRequestInit,
) => {
calls.push({ path, init });
if (path === "/snapshots/negotiate")
return { workspace: snapshot.digest, missing: [], ready: true } as T;
if (path === "/builds") {
submitted = init?.json as BuildRequest;
return {
version: 1,
id: submitted.id,
state: "queued",
createdAt: "2026-01-01T00:00:00Z",
} as T;
}
if (path.includes("/events"))
return [
{
type: "log",
id: submitted!.id,
sequence: 1,
message: "build log\n",
},
] as T;
if (path.endsWith("/reconcile"))
return {
version: 1,
id: submitted!.id,
state: "succeeded",
createdAt: "2026-01-01T00:00:00Z",
digest: `sha256:${"a".repeat(64)}`,
} as T;
if (path.endsWith("/result"))
return {
image: "registry.server/kuber/shop-web",
digest: `sha256:${"a".repeat(64)}`,
reference: `registry.server/kuber/shop-web@sha256:${"a".repeat(64)}`,
} as T;
throw new Error(`Unexpected request ${path}`);
};
const result = await buildServices(
"shop",
{
services: { web: { build: { context: ".", args: { MODE: "prod" } } } },
},
root,
{
progress: (message) => {
output.push(message);
},
},
{ request, snapshot, sleep: async () => {}, pollIntervalMs: 0 },
);
expect(submitted?.spec).toMatchObject({
architecture: "arm64",
context: ".",
buildArgs: ["MODE=prod"],
workspace: snapshot.digest,
});
expect(result).toEqual({
built: ["web"],
changed: ["web"],
images: {
web: `registry.server/kuber/shop-web@sha256:${"a".repeat(64)}`,
},
});
expect(output).toContain("build log");
expect(calls.some(({ path }) => path.endsWith("/result"))).toBe(true);
});
test("no-build image resolution uses only the resolve route", async () => {
const calls: string[] = [];
const images = await resolveBuildImages(
"shop",
{ services: { web: { build: "." }, cache: { image: "redis" } } },
{
request: async <T>(path: string, init?: ApiRequestInit) => {
calls.push(`${init?.method}:${path}:${JSON.stringify(init?.json)}`);
return { reference: "registry/web@sha256:immutable" } as T;
},
},
);
expect(images).toEqual({ web: "registry/web@sha256:immutable" });
expect(calls).toEqual([
'POST:/images/resolve:{"project":"shop","service":"web"}',
]);
});
});