feat: release 2.7.0-rc2
This commit is contained in:
+689
-64
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Writable } from "node:stream";
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
MAX_REQUESTS_PER_SECOND,
|
||||
} from "../../lib/build";
|
||||
import {
|
||||
enumerateWorkspace,
|
||||
serializeWorkspaceManifest,
|
||||
workspaceManifestDigest,
|
||||
type WorkspaceSnapshot,
|
||||
} from "../../lib/workspace";
|
||||
@@ -22,6 +24,84 @@ import { ApiStreamUnsupportedError } from "../../lib/api";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
test("auto URI is submitted and grouped by exact package identity while bare auto stays distinct", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-auto-uri-"));
|
||||
directories.push(root);
|
||||
expect(await Bun.spawn(["git", "init", "-q", root]).exited).toBe(0);
|
||||
await writeFile(join(root, "package.json"), "{}\n");
|
||||
const uri =
|
||||
"https://github.com/example/bun/releases/download/v1/buildpack.cnb";
|
||||
const submitted: BuildRequest[] = [];
|
||||
const request: ApiRequester = async <T>(
|
||||
path: string,
|
||||
init?: ApiRequestInit,
|
||||
) => {
|
||||
if (path === "/snapshots/negotiate") return { ready: true } as T;
|
||||
if (path.includes("/events")) return [] as T;
|
||||
if (path === "/builds") {
|
||||
const build = init!.json as BuildRequest;
|
||||
submitted.push(build);
|
||||
return { state: "succeeded", id: build.id } as T;
|
||||
}
|
||||
if (path.endsWith("/result")) {
|
||||
const build = submitted.find((build) => path.includes(build.id))!;
|
||||
return {
|
||||
reference: `image:${build.service}`,
|
||||
references: Object.fromEntries(
|
||||
(build.destinations ?? []).map(({ service }) => [
|
||||
service,
|
||||
`image:${service}`,
|
||||
]),
|
||||
),
|
||||
} as T;
|
||||
}
|
||||
throw new Error(path);
|
||||
};
|
||||
const result = await buildServices(
|
||||
"shop",
|
||||
{
|
||||
services: {
|
||||
one: { build: `auto:${uri}` },
|
||||
same: { build: `auto:${uri}` },
|
||||
other: { build: `auto:${uri.replace("/v1/", "/v2/")}` },
|
||||
bare: { build: "auto" },
|
||||
},
|
||||
},
|
||||
root,
|
||||
undefined,
|
||||
{ request, sleep: async () => {}, workspaceRoot: root },
|
||||
);
|
||||
expect(result.built).toHaveLength(4);
|
||||
expect(submitted).toHaveLength(3);
|
||||
expect(
|
||||
submitted.every(
|
||||
({ spec }) =>
|
||||
spec.builder === "buildpacks" &&
|
||||
spec.context === "." &&
|
||||
spec.dockerfile === undefined,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
submitted.find(({ service }) => service === "one")?.destinations?.[0]
|
||||
?.service,
|
||||
).toBe("same");
|
||||
expect(
|
||||
submitted.find(({ service }) => service === "one")?.spec.buildpackUri,
|
||||
).toBe(uri);
|
||||
expect(
|
||||
submitted.find(({ service }) => service === "bare")?.spec.buildpackUri,
|
||||
).toBeUndefined();
|
||||
await expect(
|
||||
buildServices(
|
||||
"shop",
|
||||
{ services: { bad: { build: "auto:http://localhost/package.cnb" } } },
|
||||
root,
|
||||
undefined,
|
||||
{ request, workspaceRoot: root },
|
||||
),
|
||||
).rejects.toThrow(/URI/);
|
||||
});
|
||||
|
||||
function emptySnapshot(): WorkspaceSnapshot {
|
||||
const manifest = { version: 1 as const, files: [] };
|
||||
return { manifest, digest: workspaceManifestDigest(manifest), blobs: [] };
|
||||
@@ -72,6 +152,411 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("authenticated build API pipeline", () => {
|
||||
test("reports aggregate unique missing bytes per chunk through renegotiation", async () => {
|
||||
const snapshot = createSnapshotWithBlobs(2, 1024);
|
||||
const [first, second] = snapshot.blobs;
|
||||
const total =
|
||||
first!.data.byteLength +
|
||||
second!.data.byteLength +
|
||||
serializeWorkspaceManifest(snapshot.manifest).byteLength;
|
||||
const reports: Array<[number, number]> = [];
|
||||
let release!: () => void;
|
||||
let patchStarted!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const started = new Promise<void>((resolve) => {
|
||||
patchStarted = resolve;
|
||||
});
|
||||
let negotiations = 0;
|
||||
const patches = new Map<string, number>();
|
||||
const request: ApiRequester = async <T>(
|
||||
path: string,
|
||||
init?: ApiRequestInit,
|
||||
) => {
|
||||
if (path === "/snapshots/negotiate") {
|
||||
negotiations++;
|
||||
return (
|
||||
negotiations === 1
|
||||
? {
|
||||
ready: false,
|
||||
missing: [
|
||||
first!.digest,
|
||||
first!.digest,
|
||||
second!.digest,
|
||||
snapshot.digest,
|
||||
],
|
||||
}
|
||||
: negotiations === 2
|
||||
? { ready: false, missing: [first!.digest, snapshot.digest] }
|
||||
: { ready: true, missing: [] }
|
||||
) as T;
|
||||
}
|
||||
const digest = decodeURIComponent(path.split("/")[2]!);
|
||||
if (init?.method === "PATCH") {
|
||||
if (digest === first!.digest) {
|
||||
patchStarted();
|
||||
await held;
|
||||
}
|
||||
patches.set(digest, (patches.get(digest) ?? 0) + 1);
|
||||
return { offset: (init.body as Uint8Array).byteLength } as T;
|
||||
}
|
||||
if (init?.method === "POST" && !path.includes("/complete"))
|
||||
return {
|
||||
offset: patches.has(digest)
|
||||
? digest === snapshot.digest
|
||||
? total - 2048
|
||||
: 1024
|
||||
: 0,
|
||||
complete: patches.has(digest),
|
||||
} as T;
|
||||
return { complete: true } as T;
|
||||
};
|
||||
const upload = uploadWorkspaceSnapshot(snapshot, request, {
|
||||
upload: (bytes, size) => reports.push([bytes, size]),
|
||||
});
|
||||
await started;
|
||||
expect(reports.at(-1)?.[1]).toBe(total);
|
||||
expect(reports.at(-1)?.[0]).toBeLessThan(total);
|
||||
release();
|
||||
await upload;
|
||||
expect(patches.get(first!.digest)).toBe(1);
|
||||
expect(patches.get(second!.digest)).toBe(1);
|
||||
expect(patches.get(snapshot.digest)).toBe(1);
|
||||
expect(reports.at(-1)).toEqual([total, total]);
|
||||
expect(
|
||||
reports.every(([bytes, size]) => bytes <= size && size === total),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("settles the upload with its original error before starting a build", async () => {
|
||||
const snapshot = emptySnapshot();
|
||||
const failure = new Error("upload interrupted");
|
||||
let builds = 0;
|
||||
const settled: unknown[] = [];
|
||||
const request: ApiRequester = async <T>(
|
||||
path: string,
|
||||
init?: ApiRequestInit,
|
||||
) => {
|
||||
if (path === "/snapshots/negotiate")
|
||||
return { ready: false, missing: [snapshot.digest] } as T;
|
||||
if (path === "/builds") {
|
||||
builds++;
|
||||
throw new Error("unexpected build");
|
||||
}
|
||||
if (init?.method === "PATCH") throw failure;
|
||||
return { offset: 0, complete: false } as T;
|
||||
};
|
||||
await expect(
|
||||
buildServices(
|
||||
"shop",
|
||||
{ services: { web: { build: "." } } },
|
||||
process.cwd(),
|
||||
{
|
||||
uploadSettled: (error) => {
|
||||
settled.push(error);
|
||||
},
|
||||
},
|
||||
{ request, snapshot },
|
||||
),
|
||||
).rejects.toBe(failure);
|
||||
expect(settled).toEqual([failure]);
|
||||
expect(builds).toBe(0);
|
||||
});
|
||||
|
||||
test("keeps builder groups separate even when snapshot digests coincide", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-same-digest-"));
|
||||
directories.push(root);
|
||||
expect(await Bun.spawn(["git", "init", "-q", root]).exited).toBe(0);
|
||||
const snapshot = emptySnapshot();
|
||||
const submissions: BuildRequest[] = [];
|
||||
const uploads: string[] = [];
|
||||
const request: ApiRequester = async <T>(
|
||||
path: string,
|
||||
init?: ApiRequestInit,
|
||||
) => {
|
||||
if (path === "/snapshots/negotiate") {
|
||||
uploads.push((init!.json as { workspace: string }).workspace);
|
||||
return { ready: true } as T;
|
||||
}
|
||||
if (path === "/builds") {
|
||||
const build = init!.json as BuildRequest;
|
||||
submissions.push(build);
|
||||
return { state: "queued", id: build.id } as T;
|
||||
}
|
||||
if (path.includes("/events")) return [] as T;
|
||||
if (path.endsWith("/reconcile")) return { state: "succeeded" } as T;
|
||||
if (path.endsWith("/result")) return { reference: "image:built" } as T;
|
||||
throw new Error(path);
|
||||
};
|
||||
await buildServices(
|
||||
"shop",
|
||||
{
|
||||
services: {
|
||||
docker: { build: "." },
|
||||
auto: { build: "auto" },
|
||||
},
|
||||
},
|
||||
root,
|
||||
undefined,
|
||||
{ request, snapshot, sleep: async () => {}, pollIntervalMs: 0 },
|
||||
);
|
||||
expect(uploads).toEqual([snapshot.digest, snapshot.digest]);
|
||||
expect(submissions).toHaveLength(2);
|
||||
expect(submissions.map(({ spec }) => spec.workspace)).toEqual([
|
||||
snapshot.digest,
|
||||
snapshot.digest,
|
||||
]);
|
||||
expect(submissions[0]?.spec).not.toHaveProperty("builder");
|
||||
expect(submissions[1]?.spec).toHaveProperty("builder", "buildpacks");
|
||||
});
|
||||
|
||||
test("auto fails before uploading when no Git repository is available", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-auto-gitless-"));
|
||||
directories.push(root);
|
||||
const calls: string[] = [];
|
||||
const request: ApiRequester = async <T>(path: string) => {
|
||||
calls.push(path);
|
||||
return { ready: true } as T;
|
||||
};
|
||||
await expect(
|
||||
buildServices(
|
||||
"shop",
|
||||
{ services: { web: { build: "auto" } } },
|
||||
root,
|
||||
undefined,
|
||||
{ request, snapshot: emptySnapshot() },
|
||||
),
|
||||
).rejects.toThrow(/requires Git and a Git repository/);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
test("auto uses a contained selected subproject context and Git-filtered snapshot", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-auto-subproject-"));
|
||||
directories.push(root);
|
||||
expect(await Bun.spawn(["git", "init", "-q", root]).exited).toBe(0);
|
||||
await writeFile(join(root, ".gitignore"), "*.secret\n");
|
||||
await mkdir(join(root, "apps", "web"), { recursive: true });
|
||||
await writeFile(join(root, "apps", "web", "package.json"), "{}\n");
|
||||
await writeFile(join(root, "apps", "web", "app.secret"), "private");
|
||||
|
||||
const submitted: BuildRequest[] = [];
|
||||
const request: ApiRequester = async <T>(
|
||||
path: string,
|
||||
init?: ApiRequestInit,
|
||||
) => {
|
||||
if (path === "/snapshots/negotiate") return { ready: true } as T;
|
||||
if (path === "/builds") {
|
||||
const build = init!.json as BuildRequest;
|
||||
submitted.push(build);
|
||||
return { state: "queued", id: build.id } as T;
|
||||
}
|
||||
if (path.includes("/events")) return [] as T;
|
||||
if (path.endsWith("/reconcile")) return { state: "succeeded" } as T;
|
||||
if (path.endsWith("/result")) return { reference: "image:built" } as T;
|
||||
throw new Error(path);
|
||||
};
|
||||
|
||||
await buildServices(
|
||||
"shop",
|
||||
{
|
||||
services: {
|
||||
selected: { build: "auto", "x-kuber-build-context": "apps/web" },
|
||||
root: { build: "auto" },
|
||||
},
|
||||
},
|
||||
root,
|
||||
undefined,
|
||||
{ request, sleep: async () => {}, pollIntervalMs: 0 },
|
||||
);
|
||||
|
||||
expect(submitted.map(({ spec }) => spec.context)).toEqual([
|
||||
"apps/web",
|
||||
".",
|
||||
]);
|
||||
expect(
|
||||
submitted.every(
|
||||
({ spec }) =>
|
||||
(spec as BuildRequest["spec"] & { builder?: string }).builder ===
|
||||
"buildpacks",
|
||||
),
|
||||
).toBe(true);
|
||||
const autoSnapshot = await enumerateWorkspace(root, "auto");
|
||||
expect(autoSnapshot.manifest.files.map(({ path }) => path)).toContain(
|
||||
"apps/web/package.json",
|
||||
);
|
||||
expect(autoSnapshot.manifest.files.map(({ path }) => path)).not.toContain(
|
||||
"apps/web/app.secret",
|
||||
);
|
||||
});
|
||||
|
||||
test("auto rejects traversal and absolute selected contexts", async () => {
|
||||
const root = await mkdtemp(
|
||||
join(tmpdir(), "kuber-auto-context-validation-"),
|
||||
);
|
||||
directories.push(root);
|
||||
expect(await Bun.spawn(["git", "init", "-q", root]).exited).toBe(0);
|
||||
const request: ApiRequester = async <T>() => ({ ready: true }) as T;
|
||||
|
||||
for (const context of [
|
||||
"../outside",
|
||||
"apps/../../outside",
|
||||
"/tmp/outside",
|
||||
"C:/outside",
|
||||
]) {
|
||||
await expect(
|
||||
buildServices(
|
||||
"shop",
|
||||
{
|
||||
services: {
|
||||
web: { build: "auto", "x-kuber-build-context": context },
|
||||
},
|
||||
},
|
||||
root,
|
||||
undefined,
|
||||
{ request, snapshot: emptySnapshot() },
|
||||
),
|
||||
).rejects.toThrow(/x-kuber-build-context/);
|
||||
}
|
||||
});
|
||||
|
||||
test("uploads distinct auto and Dockerfile snapshots and groups only matching builders", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-mixed-build-"));
|
||||
directories.push(root);
|
||||
expect(await Bun.spawn(["git", "init", "-q", root]).exited).toBe(0);
|
||||
await writeFile(join(root, ".gitignore"), "tracked.secret\n.env*\n");
|
||||
await writeFile(join(root, "tracked.secret"), "private");
|
||||
expect(
|
||||
await Bun.spawn(["git", "-C", root, "add", "-f", "tracked.secret"])
|
||||
.exited,
|
||||
).toBe(0);
|
||||
await writeFile(join(root, "Dockerfile"), "FROM scratch\n");
|
||||
await writeFile(join(root, ".dockerignore"), "app.txt\n");
|
||||
await writeFile(join(root, "app.txt"), "app");
|
||||
const regular = await enumerateWorkspace(root);
|
||||
const auto = await enumerateWorkspace(root, "auto");
|
||||
const negotiations: string[] = [];
|
||||
const uploadedManifests = new Map<string, string>();
|
||||
const submissions: BuildRequest[] = [];
|
||||
const progress: Array<[number, number]> = [];
|
||||
const request: ApiRequester = async <T>(
|
||||
path: string,
|
||||
init?: ApiRequestInit,
|
||||
) => {
|
||||
if (path === "/snapshots/negotiate") {
|
||||
const workspace = (init!.json as { workspace: string }).workspace;
|
||||
negotiations.push(workspace);
|
||||
return {
|
||||
workspace,
|
||||
missing:
|
||||
negotiations.filter((digest) => digest === workspace).length === 1
|
||||
? [workspace]
|
||||
: [],
|
||||
ready:
|
||||
negotiations.filter((digest) => digest === workspace).length !== 1,
|
||||
} as T;
|
||||
}
|
||||
if (path.includes("/uploads")) {
|
||||
if (init?.method === "PATCH") {
|
||||
const digest = decodeURIComponent(path.split("/")[2]!);
|
||||
uploadedManifests.set(
|
||||
digest,
|
||||
Buffer.from(init.body as Uint8Array).toString(),
|
||||
);
|
||||
return { offset: (init.body as Uint8Array).byteLength } as T;
|
||||
}
|
||||
return { offset: 0, complete: false } as T;
|
||||
}
|
||||
if (path === "/builds") {
|
||||
const build = init?.json as BuildRequest;
|
||||
submissions.push(build);
|
||||
return { state: "queued", id: build.id } as T;
|
||||
}
|
||||
if (path.endsWith("/reconcile")) return { state: "succeeded" } as T;
|
||||
if (path.includes("/events")) return [] as T;
|
||||
if (path.endsWith("/result")) {
|
||||
const build = submissions.find(({ id }) => path.includes(id))!;
|
||||
return {
|
||||
reference: `image:${build.service}`,
|
||||
references: Object.fromEntries(
|
||||
(build.destinations ?? []).map(({ service }) => [
|
||||
service,
|
||||
`image:${service}`,
|
||||
]),
|
||||
),
|
||||
} as T;
|
||||
}
|
||||
throw new Error(path);
|
||||
};
|
||||
const result = await buildServices(
|
||||
"shop",
|
||||
{
|
||||
services: {
|
||||
docker: { build: "." },
|
||||
auto: { build: "auto" },
|
||||
second: { build: "auto" },
|
||||
},
|
||||
},
|
||||
root,
|
||||
{
|
||||
upload: (uploaded, total) => {
|
||||
progress.push([uploaded, total]);
|
||||
},
|
||||
},
|
||||
{ request, sleep: async () => {}, pollIntervalMs: 0 },
|
||||
);
|
||||
expect(negotiations).toEqual([
|
||||
regular.digest,
|
||||
auto.digest,
|
||||
regular.digest,
|
||||
auto.digest,
|
||||
]);
|
||||
const totalBytes =
|
||||
serializeWorkspaceManifest(regular.manifest).byteLength +
|
||||
serializeWorkspaceManifest(auto.manifest).byteLength;
|
||||
expect(progress[0]).toEqual([0, totalBytes]);
|
||||
expect(progress.at(-1)).toEqual([totalBytes, totalBytes]);
|
||||
expect(
|
||||
progress.every(
|
||||
([uploaded, total]) => uploaded <= total && total === totalBytes,
|
||||
),
|
||||
).toBe(true);
|
||||
expect([...uploadedManifests.keys()]).toEqual([
|
||||
regular.digest,
|
||||
auto.digest,
|
||||
]);
|
||||
expect(
|
||||
JSON.parse(uploadedManifests.get(regular.digest)!).files.map(
|
||||
(file: { path: string }) => file.path,
|
||||
),
|
||||
).toContain("tracked.secret");
|
||||
expect(
|
||||
JSON.parse(uploadedManifests.get(auto.digest)!).files.map(
|
||||
(file: { path: string }) => file.path,
|
||||
),
|
||||
).not.toContain("tracked.secret");
|
||||
expect(submissions).toHaveLength(2);
|
||||
expect(submissions[0]?.spec).toMatchObject({
|
||||
workspace: regular.digest,
|
||||
context: ".",
|
||||
});
|
||||
expect(submissions[0]?.spec).not.toHaveProperty("builder");
|
||||
expect(submissions[1]?.spec).toMatchObject({
|
||||
workspace: auto.digest,
|
||||
context: ".",
|
||||
builder: "buildpacks",
|
||||
});
|
||||
expect(submissions[1]?.spec).not.toHaveProperty("dockerfile");
|
||||
expect(submissions[1]?.destinations).toEqual([
|
||||
{ service: "second", image: expect.any(String) },
|
||||
]);
|
||||
expect(result.images).toEqual({
|
||||
docker: "image:docker",
|
||||
auto: "image:auto",
|
||||
second: "image:second",
|
||||
});
|
||||
});
|
||||
|
||||
test("negotiates and uploads the manifest through resumable blob routes", async () => {
|
||||
const snapshot = emptySnapshot();
|
||||
const controller = new AbortController();
|
||||
@@ -615,29 +1100,88 @@ describe("authenticated build API pipeline", () => {
|
||||
calls.push(path);
|
||||
if (path === "/snapshots/negotiate") return { ready: true } as T;
|
||||
if (path === "/builds") return { state: "queued" } as T;
|
||||
if (path.endsWith("/result")) return { reference: "image:web", references: { worker: "image:worker" } } as T;
|
||||
if (path.endsWith("/result"))
|
||||
return {
|
||||
reference: "image:web",
|
||||
references: { worker: "image:worker" },
|
||||
} as T;
|
||||
throw new Error(`Unexpected JSON request ${path}`);
|
||||
};
|
||||
const result = await buildServices("shop", {
|
||||
services: { web: { build: "." }, worker: { build: "." } },
|
||||
}, process.cwd(), { stream: new Writable({ write(chunk, _, done) {
|
||||
output.push(String(chunk)); done();
|
||||
} }) }, { request, snapshot, pollIntervalMs: 0, sleep: async () => {},
|
||||
streamEvents: async function* (_path, after) {
|
||||
cursors.push(after);
|
||||
if (after === 0) {
|
||||
yield { id: 1, event: { type: "log", sequence: 1, id: "build", message: "once\n" } };
|
||||
yield { event: { type: "status", status: { version: 1, id: "build", state: "running", createdAt: "now" } } };
|
||||
} else {
|
||||
yield { id: 1, event: { type: "log", sequence: 1, id: "build", message: "once\n" } };
|
||||
yield { event: { type: "status", status: { version: 1, id: "build", state: "succeeded", createdAt: "now" } } };
|
||||
}
|
||||
const result = await buildServices(
|
||||
"shop",
|
||||
{
|
||||
services: { web: { build: "." }, worker: { build: "." } },
|
||||
},
|
||||
});
|
||||
process.cwd(),
|
||||
{
|
||||
stream: new Writable({
|
||||
write(chunk, _, done) {
|
||||
output.push(String(chunk));
|
||||
done();
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
request,
|
||||
snapshot,
|
||||
pollIntervalMs: 0,
|
||||
sleep: async () => {},
|
||||
streamEvents: async function* (_path, after) {
|
||||
cursors.push(after);
|
||||
if (after === 0) {
|
||||
yield {
|
||||
id: 1,
|
||||
event: {
|
||||
type: "log",
|
||||
sequence: 1,
|
||||
id: "build",
|
||||
message: "once\n",
|
||||
},
|
||||
};
|
||||
yield {
|
||||
event: {
|
||||
type: "status",
|
||||
status: {
|
||||
version: 1,
|
||||
id: "build",
|
||||
state: "running",
|
||||
createdAt: "now",
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
yield {
|
||||
id: 1,
|
||||
event: {
|
||||
type: "log",
|
||||
sequence: 1,
|
||||
id: "build",
|
||||
message: "once\n",
|
||||
},
|
||||
};
|
||||
yield {
|
||||
event: {
|
||||
type: "status",
|
||||
status: {
|
||||
version: 1,
|
||||
id: "build",
|
||||
state: "succeeded",
|
||||
createdAt: "now",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.images).toEqual({ web: "image:web", worker: "image:worker" });
|
||||
expect(cursors).toEqual([0, 1]);
|
||||
expect(output).toEqual(["[web] once\n"]);
|
||||
expect(calls.filter((path) => path.includes("/events") || path.includes("/reconcile"))).toEqual([]);
|
||||
expect(
|
||||
calls.filter(
|
||||
(path) => path.includes("/events") || path.includes("/reconcile"),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("falls back to JSON events/reconcile only after SSE is unsupported", async () => {
|
||||
@@ -646,16 +1190,35 @@ describe("authenticated build API pipeline", () => {
|
||||
calls.push(path);
|
||||
if (path === "/snapshots/negotiate") return { ready: true } as T;
|
||||
if (path === "/builds") return { state: "queued" } as T;
|
||||
if (path.includes("/events")) return [{ type: "log", sequence: 1, message: "fallback\n" }] as T;
|
||||
if (path.includes("/events"))
|
||||
return [{ type: "log", sequence: 1, message: "fallback\n" }] as T;
|
||||
if (path.endsWith("/reconcile")) return { state: "succeeded" } as T;
|
||||
if (path.endsWith("/result")) return { reference: "image:web" } as T;
|
||||
throw new Error(path);
|
||||
};
|
||||
expect((await buildServices("shop", { services: { web: { build: "." } } },
|
||||
process.cwd(), undefined, { request, snapshot: emptySnapshot(),
|
||||
streamEvents: async function* () { yield* []; throw new ApiStreamUnsupportedError(); },
|
||||
})).images).toEqual({ web: "image:web" });
|
||||
expect(calls.filter((path) => path.includes("/events") || path.includes("/reconcile"))).toEqual([
|
||||
expect(
|
||||
(
|
||||
await buildServices(
|
||||
"shop",
|
||||
{ services: { web: { build: "." } } },
|
||||
process.cwd(),
|
||||
undefined,
|
||||
{
|
||||
request,
|
||||
snapshot: emptySnapshot(),
|
||||
streamEvents: async function* () {
|
||||
yield* [];
|
||||
throw new ApiStreamUnsupportedError();
|
||||
},
|
||||
},
|
||||
)
|
||||
).images,
|
||||
).toEqual({ web: "image:web" });
|
||||
expect(
|
||||
calls.filter(
|
||||
(path) => path.includes("/events") || path.includes("/reconcile"),
|
||||
),
|
||||
).toEqual([
|
||||
expect.stringContaining("/events?after=0"),
|
||||
expect.stringContaining("/reconcile"),
|
||||
expect.stringContaining("/events?after=1"),
|
||||
@@ -666,7 +1229,9 @@ describe("authenticated build API pipeline", () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new DOMException("Stopped", "AbortError");
|
||||
let listening!: () => void;
|
||||
const ready = new Promise<void>((resolve) => { listening = resolve; });
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
listening = resolve;
|
||||
});
|
||||
const calls: string[] = [];
|
||||
const request: ApiRequester = async <T>(path: string) => {
|
||||
calls.push(path);
|
||||
@@ -674,14 +1239,26 @@ describe("authenticated build API pipeline", () => {
|
||||
if (path === "/builds") return { state: "queued" } as T;
|
||||
throw new Error(path);
|
||||
};
|
||||
const result = buildServices("shop", { services: { web: { build: "." } } },
|
||||
process.cwd(), undefined, { request, snapshot: emptySnapshot(), signal: controller.signal,
|
||||
const result = buildServices(
|
||||
"shop",
|
||||
{ services: { web: { build: "." } } },
|
||||
process.cwd(),
|
||||
undefined,
|
||||
{
|
||||
request,
|
||||
snapshot: emptySnapshot(),
|
||||
signal: controller.signal,
|
||||
streamEvents: async function* (_path, _after, signal) {
|
||||
yield* [];
|
||||
listening();
|
||||
await new Promise<void>((_resolve, reject) => signal?.addEventListener("abort", () => reject(signal.reason), { once: true }));
|
||||
await new Promise<void>((_resolve, reject) =>
|
||||
signal?.addEventListener("abort", () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await ready;
|
||||
controller.abort(reason);
|
||||
await expect(result).rejects.toBe(reason);
|
||||
@@ -763,8 +1340,13 @@ describe("authenticated build API pipeline", () => {
|
||||
const output = new Map<string, string[]>();
|
||||
let started = 0;
|
||||
let release!: () => void;
|
||||
const bothStarted = new Promise<void>((resolve) => { release = resolve; });
|
||||
const request: ApiRequester = async <T>(path: string, init?: ApiRequestInit) => {
|
||||
const bothStarted = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const request: ApiRequester = async <T>(
|
||||
path: string,
|
||||
init?: ApiRequestInit,
|
||||
) => {
|
||||
if (path === "/snapshots/negotiate") return { ready: true } as T;
|
||||
if (path === "/builds") {
|
||||
const build = init!.json as BuildRequest;
|
||||
@@ -776,11 +1358,14 @@ describe("authenticated build API pipeline", () => {
|
||||
const build = submissions.get(id)!;
|
||||
if (path.includes("/events"))
|
||||
return path.includes("after=0")
|
||||
? [
|
||||
{ type: "status", status: { state: "running", phase: "running" } },
|
||||
? ([
|
||||
{
|
||||
type: "status",
|
||||
status: { state: "running", phase: "running" },
|
||||
},
|
||||
{ type: "log", sequence: 1, message: `${build.service} log\n` },
|
||||
] as T
|
||||
: [] as T;
|
||||
] as T)
|
||||
: ([] as T);
|
||||
if (path.endsWith("/reconcile")) {
|
||||
await bothStarted;
|
||||
return { state: "succeeded" } as T;
|
||||
@@ -794,34 +1379,60 @@ describe("authenticated build API pipeline", () => {
|
||||
};
|
||||
const result = await buildServices(
|
||||
"shop",
|
||||
{ services: {
|
||||
web: { build: "." },
|
||||
worker: { build: "." },
|
||||
admin: { build: { context: ".", args: { ROLE: "admin" } } },
|
||||
} },
|
||||
{
|
||||
services: {
|
||||
web: { build: "." },
|
||||
worker: { build: "." },
|
||||
admin: { build: { context: ".", args: { ROLE: "admin" } } },
|
||||
},
|
||||
},
|
||||
process.cwd(),
|
||||
{ service: (name) => {
|
||||
const lines: string[] = [];
|
||||
output.set(name, lines);
|
||||
return {
|
||||
progress: (message) => { lines.push(message); },
|
||||
stream: new Writable({ write(chunk, _encoding, done) {
|
||||
lines.push(String(chunk));
|
||||
done();
|
||||
} }),
|
||||
};
|
||||
} },
|
||||
{ request, snapshot, buildConcurrency: 2, pollIntervalMs: 0, sleep: async () => {} },
|
||||
{
|
||||
service: (name) => {
|
||||
const lines: string[] = [];
|
||||
output.set(name, lines);
|
||||
return {
|
||||
progress: (message) => {
|
||||
lines.push(message);
|
||||
},
|
||||
stream: new Writable({
|
||||
write(chunk, _encoding, done) {
|
||||
lines.push(String(chunk));
|
||||
done();
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
request,
|
||||
snapshot,
|
||||
buildConcurrency: 2,
|
||||
pollIntervalMs: 0,
|
||||
sleep: async () => {},
|
||||
},
|
||||
);
|
||||
expect(submissions.size).toBe(2);
|
||||
expect([...submissions.values()].find(({ service }) => service === "web")?.destinations?.map(({ service }) => service)).toEqual(["worker"]);
|
||||
expect(result.images).toEqual({ web: "image:web", worker: "image:worker", admin: "image:admin" });
|
||||
expect(
|
||||
[...submissions.values()]
|
||||
.find(({ service }) => service === "web")
|
||||
?.destinations?.map(({ service }) => service),
|
||||
).toEqual(["worker"]);
|
||||
expect(result.images).toEqual({
|
||||
web: "image:web",
|
||||
worker: "image:worker",
|
||||
admin: "image:admin",
|
||||
});
|
||||
for (const name of ["web", "worker"]) {
|
||||
expect(output.get(name)?.filter((line) => line === "web log\n")).toHaveLength(1);
|
||||
expect(
|
||||
output.get(name)?.filter((line) => line === "web log\n"),
|
||||
).toHaveLength(1);
|
||||
expect(output.get(name)?.join("")).not.toContain("admin log");
|
||||
expect(output.get(name)).toContain("Build running");
|
||||
}
|
||||
expect(output.get("admin")?.filter((line) => line === "admin log\n")).toHaveLength(1);
|
||||
expect(
|
||||
output.get("admin")?.filter((line) => line === "admin log\n"),
|
||||
).toHaveLength(1);
|
||||
expect(output.get("admin")?.join("")).not.toContain("web log");
|
||||
});
|
||||
|
||||
@@ -832,22 +1443,36 @@ describe("authenticated build API pipeline", () => {
|
||||
if (path === "/builds") return { state: "queued" } as T;
|
||||
if (path.includes("/events"))
|
||||
return path.includes("after=0")
|
||||
? [{ type: "log", sequence: 1, message: "one physical build\n" }] as T
|
||||
: [] as T;
|
||||
? ([
|
||||
{ type: "log", sequence: 1, message: "one physical build\n" },
|
||||
] as T)
|
||||
: ([] as T);
|
||||
if (path.endsWith("/reconcile")) return { state: "succeeded" } as T;
|
||||
if (path.endsWith("/result"))
|
||||
return { reference: "image:web", references: { worker: "image:worker" } } as T;
|
||||
return {
|
||||
reference: "image:web",
|
||||
references: { worker: "image:worker" },
|
||||
} as T;
|
||||
throw new Error(path);
|
||||
};
|
||||
await buildServices(
|
||||
"shop",
|
||||
{ services: { web: { build: "." }, worker: { build: "." } } },
|
||||
process.cwd(),
|
||||
{ stream: new Writable({ write(chunk, _encoding, done) {
|
||||
output.push(String(chunk));
|
||||
done();
|
||||
} }) },
|
||||
{ request, snapshot: emptySnapshot(), sleep: async () => {}, pollIntervalMs: 0 },
|
||||
{
|
||||
stream: new Writable({
|
||||
write(chunk, _encoding, done) {
|
||||
output.push(String(chunk));
|
||||
done();
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
request,
|
||||
snapshot: emptySnapshot(),
|
||||
sleep: async () => {},
|
||||
pollIntervalMs: 0,
|
||||
},
|
||||
);
|
||||
expect(output).toEqual(["[web] one physical build\n"]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
listDockerfileTemplates,
|
||||
renderDockerfileTemplate,
|
||||
templateIds,
|
||||
type DockerfileTemplateId,
|
||||
} from "../../lib/scaffold-templates";
|
||||
|
||||
// Parse each Dockerfile's instructions and JSON commands to catch malformed
|
||||
// templates without requiring a Docker daemon in the test environment.
|
||||
function parseDockerfile(source: string): {
|
||||
stages: string[];
|
||||
commands: string[][];
|
||||
} {
|
||||
const stages: string[] = [];
|
||||
const commands: string[][] = [];
|
||||
let stageCount = 0;
|
||||
for (const line of source.split("\n")) {
|
||||
const text = line.trim();
|
||||
if (!text || text.startsWith("#")) continue;
|
||||
const match = /^([A-Z]+)\s+(.+)$/.exec(text);
|
||||
expect(match, `Invalid Dockerfile instruction: ${text}`).not.toBeNull();
|
||||
const [, instruction, argument] = match!;
|
||||
expect([
|
||||
"FROM",
|
||||
"WORKDIR",
|
||||
"COPY",
|
||||
"RUN",
|
||||
"ENV",
|
||||
"USER",
|
||||
"EXPOSE",
|
||||
"CMD",
|
||||
"ENTRYPOINT",
|
||||
]).toContain(instruction!);
|
||||
if (instruction === "FROM") {
|
||||
stageCount++;
|
||||
const alias = /\s+AS\s+(\w+)$/i.exec(argument!)?.[1];
|
||||
if (alias) stages.push(alias);
|
||||
} else {
|
||||
expect(stageCount, `${instruction} must follow FROM`).toBeGreaterThan(0);
|
||||
}
|
||||
if (instruction === "COPY") {
|
||||
const from = /^--from=(\w+)\s/.exec(argument!)?.[1];
|
||||
if (from) expect(stages).toContain(from);
|
||||
}
|
||||
if (instruction === "CMD" || instruction === "ENTRYPOINT") {
|
||||
const parsed: unknown = JSON.parse(argument!);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(
|
||||
(parsed as unknown[]).every((value) => typeof value === "string"),
|
||||
).toBe(true);
|
||||
commands.push(parsed as string[]);
|
||||
}
|
||||
}
|
||||
expect(stageCount).toBeGreaterThan(0);
|
||||
expect(commands).toHaveLength(1);
|
||||
return { stages, commands };
|
||||
}
|
||||
|
||||
describe("embedded scaffold templates", () => {
|
||||
test("lists eight selectable, distinct templates in stable order", () => {
|
||||
const options = listDockerfileTemplates();
|
||||
expect(options.map(({ id }) => id)).toEqual([...templateIds]);
|
||||
expect(new Set(templateIds).size).toBe(8);
|
||||
for (const { id, label, description } of options) {
|
||||
expect(label.trim()).not.toBe("");
|
||||
expect(description.trim()).not.toBe("");
|
||||
expect(description).toMatch(
|
||||
/src\/|server\.js|dist\/|app:app|binary|port/i,
|
||||
);
|
||||
expect(renderDockerfileTemplate(id).dockerfile).toContain("FROM ");
|
||||
}
|
||||
expect(listDockerfileTemplates()).toEqual(options);
|
||||
});
|
||||
|
||||
test.each([...templateIds])(
|
||||
"renders and parses %s with a safe build context",
|
||||
(id) => {
|
||||
const { dockerfile, dockerignore } = renderDockerfileTemplate(id);
|
||||
expect(dockerfile.length).toBeGreaterThan(50);
|
||||
expect(dockerignore.length).toBeGreaterThan(50);
|
||||
expect(dockerfile.endsWith("\n")).toBe(true);
|
||||
expect(dockerignore.endsWith("\n")).toBe(true);
|
||||
parseDockerfile(dockerfile);
|
||||
const patterns = dockerignore
|
||||
.split("\n")
|
||||
.filter((line) => line && !line.startsWith("#"));
|
||||
expect(patterns).toContain(".git");
|
||||
expect(patterns).toContain(".env");
|
||||
expect(patterns).toContain(".env.*");
|
||||
expect(patterns).toContain("**/.env");
|
||||
expect(patterns).toContain("**/.env.*");
|
||||
expect(dockerfile).not.toMatch(
|
||||
/COPY\s+.*(?:\.env|credentials|\.pem|\.key)/i,
|
||||
);
|
||||
expect(dockerfile).not.toMatch(
|
||||
/\/home\/dmgnr|\/home\/container|hamsterstore|kuber-server/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("static site includes built output while app builders omit it", () => {
|
||||
expect(renderDockerfileTemplate("static-nginx").dockerignore).not.toMatch(
|
||||
/^dist\/?$/m,
|
||||
);
|
||||
expect(renderDockerfileTemplate("static-nginx").dockerfile).toContain(
|
||||
"COPY dist/",
|
||||
);
|
||||
for (const id of ["bun-compiled", "node-pnpm"] as const) {
|
||||
expect(renderDockerfileTemplate(id).dockerignore).toMatch(/^dist$/m);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown selections", () => {
|
||||
expect(() =>
|
||||
renderDockerfileTemplate("missing" as DockerfileTemplateId),
|
||||
).toThrow("Unknown Dockerfile template: missing");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
getSessionPath,
|
||||
@@ -69,6 +76,35 @@ describe("API sessions", () => {
|
||||
expect(await readSession()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removes malformed runtime sessions without caching them", async () => {
|
||||
await setupDirectories();
|
||||
const path = getSessionPath(false);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({ ...session, expiresAt: "not-a-date" }),
|
||||
);
|
||||
|
||||
expect(await readSession()).toBeUndefined();
|
||||
await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readSession()).toBeUndefined();
|
||||
await writeSession(session, false);
|
||||
expect(await readSession()).toEqual(session);
|
||||
});
|
||||
|
||||
test("removes malformed persistent sessions", async () => {
|
||||
await setupDirectories();
|
||||
const path = getSessionPath(true);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({ ...session, expiresAt: "not-a-date" }),
|
||||
);
|
||||
|
||||
expect(await readSession()).toBeUndefined();
|
||||
await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("invalidates a memoized missing session after writing", async () => {
|
||||
await setupDirectories();
|
||||
expect(await readSession()).toBeUndefined();
|
||||
|
||||
@@ -1,51 +1,62 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("a short CLI exits while its version updater remains pending", async () => {
|
||||
const moduleUrl = new URL("../../lib/version-update.ts", import.meta.url)
|
||||
.href;
|
||||
const script = `
|
||||
const { createVersionObserver } = await import(${JSON.stringify(moduleUrl)});
|
||||
createVersionObserver({
|
||||
currentVersion: "2.6.1-rc3",
|
||||
runner: () => new Promise(() => {}),
|
||||
})("2.7.0");
|
||||
`;
|
||||
const child = Bun.spawn([process.execPath, "-e", script], {
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exit = await Promise.race([
|
||||
child.exited,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("short CLI stayed alive")), 1_000),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(exit).toBe(0);
|
||||
expect(await new Response(child.stderr).text()).toBe("");
|
||||
});
|
||||
const updaterUrl = new URL("../../lib/version-update.ts", import.meta.url).href;
|
||||
const entryUrl = new URL("../../index.ts", import.meta.url).href;
|
||||
const mainUrl = new URL("../../command/main.ts", import.meta.url).href;
|
||||
const errorUrl = new URL("../../lib/error.ts", import.meta.url).href;
|
||||
|
||||
test.each([
|
||||
["success", "return true", "+ Updated to 2.7.0"],
|
||||
["failure", "throw new Error('install failed')", "+ New version available: 2.7.0"],
|
||||
])("reports exactly one %s outcome when it settles", async (_name, outcome, message) => {
|
||||
const moduleUrl = new URL("../../lib/version-update.ts", import.meta.url).href;
|
||||
const script = `
|
||||
const { createVersionObserver } = await import(${JSON.stringify(moduleUrl)});
|
||||
createVersionObserver({
|
||||
currentVersion: "2.6.1-rc3",
|
||||
runner: async () => { ${outcome}; },
|
||||
})("2.7.0");
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
["command error", "return true", "+ Updated to 2.7.0"],
|
||||
["failure", "throw new Error('offline')", "+ New version available: 2.7.0"],
|
||||
[
|
||||
"timeout",
|
||||
"return await installVersion(version, () => ({ exited: new Promise(() => {}), kill: () => {} }), 1)",
|
||||
"+ New version available: 2.7.0",
|
||||
],
|
||||
])(
|
||||
"CLI waits for a %s update and preserves its exit status",
|
||||
async (_name, outcome, message) => {
|
||||
const script = `
|
||||
import { mock } from "bun:test";
|
||||
const { createVersionObserver, installVersion } = await import(${JSON.stringify(updaterUrl)});
|
||||
const observe = createVersionObserver({ currentVersion: "2.6.1", runner: async (version) => {
|
||||
if (${JSON.stringify(_name)} !== "timeout") await new Promise(resolve => setTimeout(resolve, 80));
|
||||
${outcome};
|
||||
}});
|
||||
mock.module("citty", () => ({ createMain: () => async () => {
|
||||
observe("2.7.0");
|
||||
observe("2.8.0");
|
||||
if (${JSON.stringify(_name)} === "command error") throw new Error("command failed");
|
||||
process.exitCode = 7;
|
||||
}}));
|
||||
mock.module(${JSON.stringify(mainUrl)}, () => ({ main: {}, initializeCompletion: async () => {} }));
|
||||
mock.module(${JSON.stringify(errorUrl)}, () => ({ formatUnknownError: String, wrapCommandErrors: () => {} }));
|
||||
await import(${JSON.stringify(entryUrl)});
|
||||
`;
|
||||
const child = Bun.spawn([process.execPath, "-e", script], {
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
expect(await child.exited).toBe(0);
|
||||
const stderr = await new Response(child.stderr).text();
|
||||
expect(stderr.split("\n").filter((line) => line.includes(message))).toHaveLength(1);
|
||||
});
|
||||
const started = Date.now();
|
||||
const child = Bun.spawn([process.execPath, "-e", script, "login"], {
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const timer = setTimeout(() => child.kill(), 5_000);
|
||||
try {
|
||||
const [code, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
]);
|
||||
expect(code).toBe(_name === "command error" ? 1 : 7);
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(
|
||||
_name === "timeout" ? 900 : 70,
|
||||
);
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr).toBe(
|
||||
`${_name === "command error" ? "Error: command failed\n" : ""}\x1b[90m${message}\x1b[0m\n`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
compareVersions,
|
||||
createVersionObserver,
|
||||
installVersion,
|
||||
waitForVersionUpdate,
|
||||
} from "../../lib/version-update";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
@@ -36,16 +37,15 @@ describe("server version updates", () => {
|
||||
expect(await installVersion("2.7.0;echo hacked")).toBe(false);
|
||||
});
|
||||
|
||||
test("uses a quiet detached timeout argument vector", async () => {
|
||||
test("uses a quiet Bun install with explicit inherited environment", async () => {
|
||||
const calls: string[][] = [];
|
||||
let unrefs = 0;
|
||||
const spawn = (
|
||||
argv: string[],
|
||||
options: {
|
||||
stdin: "ignore";
|
||||
stdout: "ignore";
|
||||
stderr: "ignore";
|
||||
detached: true;
|
||||
env: NodeJS.ProcessEnv;
|
||||
},
|
||||
) => {
|
||||
calls.push(argv);
|
||||
@@ -53,30 +53,69 @@ describe("server version updates", () => {
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
detached: true,
|
||||
env: process.env,
|
||||
});
|
||||
expect(options.env.PATH).toBe(process.env.PATH);
|
||||
return {
|
||||
exited: Promise.resolve(0),
|
||||
unref: () => {
|
||||
unrefs++;
|
||||
kill: () => {
|
||||
throw new Error("unexpected kill");
|
||||
},
|
||||
};
|
||||
};
|
||||
expect(await installVersion("2.7.0-rc.1+build.2", spawn, 1)).toBe(true);
|
||||
expect(await installVersion("2.7.0-rc.1+build.2", spawn, 2)).toBe(true);
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
"timeout",
|
||||
"--signal=TERM",
|
||||
"--kill-after=2s",
|
||||
"1s",
|
||||
"bun",
|
||||
process.execPath,
|
||||
"i",
|
||||
"-g",
|
||||
"--no-cache",
|
||||
"@dmgnr/[email protected]+build.2",
|
||||
],
|
||||
]);
|
||||
expect(unrefs).toBe(1);
|
||||
});
|
||||
|
||||
test("kills a stalled installer and treats late success as failure", async () => {
|
||||
let finish!: (code: number) => void;
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const signals: (NodeJS.Signals | undefined)[] = [];
|
||||
const installing = installVersion(
|
||||
"2.7.0",
|
||||
() => ({
|
||||
exited,
|
||||
kill: (signal) => {
|
||||
signals.push(signal);
|
||||
finish(0);
|
||||
},
|
||||
}),
|
||||
1,
|
||||
);
|
||||
expect(await installing).toBe(false);
|
||||
expect(signals).toEqual([undefined]);
|
||||
});
|
||||
|
||||
test("handles spawn errors and bounds an unresponsive child", async () => {
|
||||
expect(
|
||||
await installVersion("2.7.0", () => {
|
||||
throw new Error("spawn failed");
|
||||
}),
|
||||
).toBe(false);
|
||||
const signals: (NodeJS.Signals | undefined)[] = [];
|
||||
expect(
|
||||
await installVersion(
|
||||
"2.7.0",
|
||||
() => ({
|
||||
exited: new Promise<number>(() => {}),
|
||||
kill: (signal) => {
|
||||
signals.push(signal);
|
||||
},
|
||||
}),
|
||||
1,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(signals).toEqual([undefined, "SIGKILL"]);
|
||||
});
|
||||
|
||||
test("ignores malformed, equal, and older headers without attempting an install", async () => {
|
||||
@@ -126,7 +165,7 @@ describe("server version updates", () => {
|
||||
expect(runs).toEqual(["2.6.1"]);
|
||||
expect(lines).toEqual([]);
|
||||
finish(true);
|
||||
await tick();
|
||||
await waitForVersionUpdate();
|
||||
expect(lines).toEqual(["\x1b[90m+ Updated to 2.6.1\x1b[0m\n"]);
|
||||
observe("10.0.0");
|
||||
await tick();
|
||||
@@ -155,28 +194,6 @@ describe("server version updates", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a detached pending install does not delay a short CLI or pollute its streams", async () => {
|
||||
const url = new URL("../../lib/version-update.ts", import.meta.url).href;
|
||||
const script = `
|
||||
const { createVersionObserver } = await import(${JSON.stringify(url)});
|
||||
createVersionObserver({ currentVersion: '2.6.1',
|
||||
runner: () => new Promise(() => {}) })('2.7.0');
|
||||
`;
|
||||
const child = Bun.spawn([process.execPath, "-e", script], {
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const timeout = setTimeout(() => child.kill(), 1_000);
|
||||
try {
|
||||
expect(await child.exited).toBe(0);
|
||||
expect(await new Response(child.stdout).text()).toBe("");
|
||||
expect(await new Response(child.stderr).text()).toBe("");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
|
||||
test("a short subprocess emits one outcome to stderr when its runner settles", async () => {
|
||||
const url = new URL("../../lib/version-update.ts", import.meta.url).href;
|
||||
for (const result of ["true", "false", "reject"]) {
|
||||
|
||||
+206
-2
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
readlink,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -51,6 +53,105 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("workspace snapshots", () => {
|
||||
test("auto uses Git ignore rules even for tracked files, not .dockerignore", async () => {
|
||||
const root = await repository();
|
||||
await mkdir(join(root, "nested"));
|
||||
await writeFile(join(root, ".gitignore"), "*.secret\n.env*\nignored/\n");
|
||||
await writeFile(join(root, "nested/.gitignore"), "*.log\n!important.log\n");
|
||||
await writeFile(join(root, ".dockerignore"), "Dockerfile\nvisible.txt\n");
|
||||
await writeFile(join(root, "Dockerfile"), "FROM scratch\n");
|
||||
await writeFile(join(root, "visible.txt"), "visible");
|
||||
await writeFile(join(root, "tracked.secret"), "tracked secret");
|
||||
await writeFile(join(root, ".env.tracked"), "tracked dotenv");
|
||||
await run("git", [
|
||||
"-C",
|
||||
root,
|
||||
"add",
|
||||
"-f",
|
||||
"tracked.secret",
|
||||
".env.tracked",
|
||||
]);
|
||||
await writeFile(join(root, "untracked.secret"), "untracked secret");
|
||||
await writeFile(join(root, ".env.local"), "untracked dotenv");
|
||||
await writeFile(join(root, "nested/debug.log"), "ignored");
|
||||
await run("git", ["-C", root, "add", "-f", "nested/debug.log"]);
|
||||
await writeFile(join(root, "nested/important.log"), "included");
|
||||
|
||||
const regular = await enumerateWorkspace(root);
|
||||
const auto = await enumerateWorkspace(root, "auto");
|
||||
expect(regular.manifest.files.map((file) => file.path)).toContain(
|
||||
"tracked.secret",
|
||||
);
|
||||
expect(regular.manifest.files.map((file) => file.path)).toContain(
|
||||
".env.local",
|
||||
);
|
||||
expect(auto.manifest.files.map((file) => file.path)).toEqual([
|
||||
".dockerignore",
|
||||
".gitignore",
|
||||
"Dockerfile",
|
||||
"nested/.gitignore",
|
||||
"nested/important.log",
|
||||
"visible.txt",
|
||||
]);
|
||||
expect(auto.digest).not.toBe(regular.digest);
|
||||
expect(await enumerateWorkspace(root, "auto")).toEqual(auto);
|
||||
});
|
||||
|
||||
test("auto rejects selected dotenv and conventional credential paths", async () => {
|
||||
for (const [path, tracked] of [
|
||||
[".env", true],
|
||||
[".env.local", false],
|
||||
[".npmrc", false],
|
||||
["nested/.ssh/id_ed25519", false],
|
||||
["services/secrets/production.yaml", false],
|
||||
] as const) {
|
||||
const root = await repository();
|
||||
const file = join(root, path);
|
||||
await mkdir(join(file, ".."), { recursive: true });
|
||||
await writeFile(file, "credential");
|
||||
if (tracked) await run("git", ["-C", root, "add", "-f", path]);
|
||||
await expect(enumerateWorkspace(root, "auto")).rejects.toThrow(
|
||||
`refuses to snapshot potential credentials at ${path}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("auto does not reject ignored dotenv or safe sample files", async () => {
|
||||
const root = await repository();
|
||||
await writeFile(join(root, ".gitignore"), ".env\nignored/.env.local\n");
|
||||
await writeFile(join(root, ".env"), "ignored secret");
|
||||
await mkdir(join(root, "ignored"));
|
||||
await writeFile(join(root, "ignored/.env.local"), "ignored secret");
|
||||
await writeFile(join(root, ".env.example"), "TOKEN=replace-me");
|
||||
await writeFile(join(root, "secretary-notes.txt"), "ordinary source");
|
||||
|
||||
const snapshot = await enumerateWorkspace(root, "auto");
|
||||
expect(snapshot.manifest.files.map((file) => file.path)).toEqual([
|
||||
".env.example",
|
||||
".gitignore",
|
||||
"secretary-notes.txt",
|
||||
]);
|
||||
});
|
||||
|
||||
test("auto fails closed without a Git repository or executable", async () => {
|
||||
const root = await temporaryDirectory("kuber-auto-no-git-");
|
||||
await writeFile(join(root, "app.txt"), "app");
|
||||
await expect(enumerateWorkspace(root, "auto")).rejects.toThrow(
|
||||
/requires Git and a Git repository/,
|
||||
);
|
||||
await run("git", ["init", "-q", root]);
|
||||
const previousPath = process.env.PATH;
|
||||
try {
|
||||
process.env.PATH = "";
|
||||
await expect(enumerateWorkspace(root, "auto")).rejects.toThrow(
|
||||
/requires Git and a Git repository/,
|
||||
);
|
||||
} finally {
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
}
|
||||
});
|
||||
|
||||
test("snapshots Gitless directories deterministically without secrets", async () => {
|
||||
const root = await temporaryDirectory("kuber-workspace-filesystem-");
|
||||
await writeFile(join(root, "Dockerfile"), "FROM scratch\n");
|
||||
@@ -69,7 +170,10 @@ describe("workspace snapshots", () => {
|
||||
|
||||
test("works without Git and conservatively prunes ignored/generated and credential files", async () => {
|
||||
const root = await temporaryDirectory("kuber-workspace-no-git-");
|
||||
await writeFile(join(root, ".gitignore"), "local-only/\n*.generated\nsecrets/\n!secrets/keep.txt\n");
|
||||
await writeFile(
|
||||
join(root, ".gitignore"),
|
||||
"local-only/\n*.generated\nsecrets/\n!secrets/keep.txt\n",
|
||||
);
|
||||
await writeFile(join(root, "app.ts"), "source");
|
||||
await mkdir(join(root, "local-only"));
|
||||
await writeFile(join(root, "local-only", "hidden"), "secret");
|
||||
@@ -117,7 +221,8 @@ describe("workspace snapshots", () => {
|
||||
const second = await enumerateWorkspace(root);
|
||||
expect(first).toEqual(second);
|
||||
expect(first.manifest.files.map((file) => file.path)).toEqual([
|
||||
".gitignore", "source.ts",
|
||||
".gitignore",
|
||||
"source.ts",
|
||||
]);
|
||||
} finally {
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
@@ -212,6 +317,105 @@ describe("workspace snapshots", () => {
|
||||
await expect(enumerateWorkspace(specialRoot)).rejects.toThrow(
|
||||
"Special files",
|
||||
);
|
||||
|
||||
const autoRoot = await repository();
|
||||
await run("ln", ["-s", "../outside", join(autoRoot, "escape")]);
|
||||
await expect(enumerateWorkspace(autoRoot, "auto")).rejects.toThrow(
|
||||
"Symlink escapes workspace",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not read Git-tracked files through replaced parent symlinks in either mode", async () => {
|
||||
const root = await repository();
|
||||
const outside = await temporaryDirectory("kuber-workspace-outside-");
|
||||
await mkdir(join(root, "sub"));
|
||||
await writeFile(join(root, "sub/visible.txt"), "original");
|
||||
await run("git", ["-C", root, "add", "sub/visible.txt"]);
|
||||
await rm(join(root, "sub"), { recursive: true });
|
||||
await writeFile(join(outside, "visible.txt"), "outside content");
|
||||
await symlink(outside, join(root, "sub"));
|
||||
|
||||
// Git itself may reject a tracked path beneath a symlink before the
|
||||
// parent guard runs; both outcomes must fail closed.
|
||||
for (const mode of ["default", "auto"] as const)
|
||||
await expect(enumerateWorkspace(root, mode)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("rejects snapshot symlink chains before normalizing target components", async () => {
|
||||
const root = await repository();
|
||||
await symlink(".", join(root, "sub"));
|
||||
await symlink("sub/..", join(root, "chain"));
|
||||
|
||||
for (const mode of ["default", "auto"] as const) {
|
||||
await expect(enumerateWorkspace(root, mode)).rejects.toThrow(
|
||||
"Symlink traverses snapshot symlink: chain -> sub/..",
|
||||
);
|
||||
}
|
||||
|
||||
const blobs = new Map(
|
||||
["sub/..", "."].map((target) => {
|
||||
const data = Buffer.from(target);
|
||||
const digest =
|
||||
`sha256:${createHash("sha256").update(data).digest("hex")}` as const;
|
||||
return [digest, data] as const;
|
||||
}),
|
||||
);
|
||||
const manifest: WorkspaceManifest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
files: [
|
||||
{
|
||||
path: "chain",
|
||||
type: "symlink",
|
||||
digest: [...blobs.keys()][0]!,
|
||||
size: Buffer.byteLength("sub/.."),
|
||||
mode: 0o777,
|
||||
},
|
||||
{
|
||||
path: "sub",
|
||||
type: "symlink",
|
||||
digest: [...blobs.keys()][1]!,
|
||||
size: Buffer.byteLength("."),
|
||||
mode: 0o777,
|
||||
},
|
||||
],
|
||||
};
|
||||
const destination = join(await temporaryDirectory("kuber-chain-"), "tree");
|
||||
await expect(
|
||||
materializeWorkspace(destination, manifest, async (digest) =>
|
||||
blobs.get(digest)!,
|
||||
),
|
||||
).rejects.toThrow("Symlink traverses snapshot symlink: chain -> sub/..");
|
||||
await expect(stat(destination)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("retains safe symlinks in both snapshot modes and materialization", async () => {
|
||||
const root = await repository();
|
||||
await writeFile(join(root, "app.txt"), "safe");
|
||||
await symlink(".", join(root, "sub"));
|
||||
await symlink("./app.txt", join(root, "alias"));
|
||||
|
||||
for (const mode of ["default", "auto"] as const) {
|
||||
const snapshot = await enumerateWorkspace(root, mode);
|
||||
expect(snapshot.manifest.files.map((file) => file.path)).toEqual([
|
||||
"alias",
|
||||
"app.txt",
|
||||
"sub",
|
||||
]);
|
||||
const blobs = new Map(
|
||||
snapshot.blobs.map((blob) => [blob.digest, blob.data]),
|
||||
);
|
||||
const destination = join(
|
||||
await temporaryDirectory("kuber-safe-links-"),
|
||||
"tree",
|
||||
);
|
||||
await materializeWorkspace(
|
||||
destination,
|
||||
snapshot.manifest,
|
||||
async (digest) => blobs.get(digest)!,
|
||||
);
|
||||
expect(await readlink(join(destination, "sub"))).toBe(".");
|
||||
expect(await readFile(join(destination, "alias"), "utf8")).toBe("safe");
|
||||
}
|
||||
});
|
||||
|
||||
test("allows ignored special files and prunes ignored directories", async () => {
|
||||
|
||||
@@ -47,6 +47,37 @@ describe("Compose file discovery", () => {
|
||||
});
|
||||
|
||||
describe("Compose parsing", () => {
|
||||
test("accepts the generated marker and preserves x- extensions", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const path = join(directory, "compose.yml");
|
||||
await writeFile(
|
||||
path,
|
||||
"managedBy: kuber # See https://npmx.dev/@dmgnr/kuber for documentation.\nname: compose-project\nx-note: retained\nservices:\n app:\n image: nginx:latest\n",
|
||||
);
|
||||
expect(await readCompose(path)).toMatchObject({
|
||||
managedBy: "kuber",
|
||||
name: "compose-project",
|
||||
"x-note": "retained",
|
||||
services: { app: { image: "nginx:latest" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects incorrect managedBy values", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const path = join(directory, "compose.yml");
|
||||
for (const marker of ["other", "42"]) {
|
||||
await writeFile(path, `managedBy: ${marker}\nservices: {}\n`);
|
||||
await expect(readCompose(path)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects other unknown top-level properties", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const path = join(directory, "compose.yml");
|
||||
await writeFile(path, "managedBy: kuber\nunknown: value\nservices: {}\n");
|
||||
await expect(readCompose(path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("parses and validates a minimal Compose project", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const path = join(directory, "compose.yml");
|
||||
|
||||
Reference in New Issue
Block a user