179 lines
5.6 KiB
TypeScript
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"}',
|
|
]);
|
|
});
|
|
});
|