feat: prepare 2.6.1-rc5 shared databases and build SSE
This commit is contained in:
@@ -4,6 +4,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Writable } from "node:stream";
|
||||
import { stripVTControlCharacters } from "node:util";
|
||||
import type { ApiRequestInit, ApiRequestOptions } from "../../lib/api";
|
||||
import { KuberApiError } from "../../lib/api";
|
||||
import type { ApiRequester } from "../../lib/build";
|
||||
@@ -25,8 +26,21 @@ const snapshot = {
|
||||
blobs: [],
|
||||
};
|
||||
|
||||
// DefaultRenderer.create returns a complete TTY frame before the cursor redraw.
|
||||
function frameRows(frame: string): string[] {
|
||||
return stripVTControlCharacters(frame).split("\n");
|
||||
}
|
||||
|
||||
function taskRow(rows: string[], title: string): string {
|
||||
return rows.find((row) => row.includes(title)) ?? "";
|
||||
}
|
||||
|
||||
function depth(row: string): number {
|
||||
return row.match(/^\s*/)?.[0].length ?? 0;
|
||||
}
|
||||
|
||||
describe("up API pipeline", () => {
|
||||
test("keeps queued builds in the TTY render tree without duplicate rows", async () => {
|
||||
test("renders build lifecycle in child titles without duplicate phase output", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-up-api-"));
|
||||
const previousCwd = process.cwd();
|
||||
const tty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
@@ -37,9 +51,7 @@ describe("up API pipeline", () => {
|
||||
frames.push(frame);
|
||||
return frame;
|
||||
});
|
||||
const writes = spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => {
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
const writes = spyOn(process.stdout, "write").mockImplementation((() => true) as typeof process.stdout.write);
|
||||
const polls = new Map<string, number>();
|
||||
const builds = new Map<string, string>();
|
||||
try {
|
||||
@@ -60,13 +72,13 @@ describe("up API pipeline", () => {
|
||||
if (path.includes("/events"))
|
||||
return [{ type: "status", status: {
|
||||
state: "queued",
|
||||
phase: ["queued", "preparing", "waiting", "waiting"][polls.get(service) ?? 0],
|
||||
phase: ["queued", "creating", "starting", "running", "done"][polls.get(service) ?? 0],
|
||||
} }] as T;
|
||||
if (path.endsWith("/reconcile")) {
|
||||
const count = (polls.get(service) ?? 0) + 1;
|
||||
polls.set(service, count);
|
||||
await Bun.sleep(110);
|
||||
return count < 3
|
||||
return count < 4
|
||||
? { state: "queued" } as T
|
||||
: service === "client"
|
||||
? { state: "failed", error: "build stopped\nstack detail" } as T
|
||||
@@ -76,29 +88,27 @@ describe("up API pipeline", () => {
|
||||
throw new Error(path);
|
||||
};
|
||||
await expect(provideContext(() => runUp(true, request, { trust }))).rejects.toThrow("build stopped");
|
||||
expect(polls.get("client")).toBe(3);
|
||||
const active = frames.filter((frame) =>
|
||||
frame.includes("Build images") && frame.includes("Build app") && frame.includes("Build client") &&
|
||||
frame.split("\n").filter((row) => row.includes("Build queued")).length === 2);
|
||||
expect(polls.get("client")).toBe(4);
|
||||
const active = frames.map(frameRows).filter((rows) =>
|
||||
taskRow(rows, "Build images") && taskRow(rows, "Build app") && taskRow(rows, "Build client"));
|
||||
expect(active.length).toBeGreaterThan(0);
|
||||
for (const frame of frames.filter((frame) => frame.includes("Build images") && frame.includes("Build app") && frame.includes("Build client"))) {
|
||||
const rows = frame.split("\n");
|
||||
const parent = rows.find((row) => row.includes("Build images"))!;
|
||||
for (const rows of active) {
|
||||
const parent = taskRow(rows, "Build images");
|
||||
for (const name of ["app", "client"]) {
|
||||
const children = rows.filter((row) => row.includes(`Build ${name}`));
|
||||
expect(children).toHaveLength(1);
|
||||
expect(children[0]!.search(/\S/)).toBeGreaterThan(parent.search(/\S/));
|
||||
expect(depth(children[0]!)).toBeGreaterThan(depth(parent));
|
||||
}
|
||||
expect(rows.filter((row) => row.includes("Build queued")).length).toBeLessThanOrEqual(2);
|
||||
expect(rows.filter((row) => /^\s*› Build (?:queued|creating|starting|running|done)/.test(row))).toHaveLength(0);
|
||||
}
|
||||
expect(active[0]).toMatch(/Build app\n {2,}.*Build queued[\s\S]*Build client\n {2,}.*Build queued/);
|
||||
const final = frames.at(-1)!;
|
||||
expect(final).toMatch(/Build images[\s\S]* .*Build app[\s\S]* .*Build client/);
|
||||
expect(final).toMatch(/✔.*Build app/);
|
||||
expect(final).toMatch(/✖.*Build client/);
|
||||
expect(frames.some((frame) => frame.includes("Build preparing") && frame.includes("Build waiting"))).toBe(true);
|
||||
expect(final).toContain("build stopped");
|
||||
expect(final).toContain("stack detail");
|
||||
for (const phase of ["queued", "creating", "starting", "running", "done"])
|
||||
expect(frames.some((frame) => frame.includes(`Build app: ${phase}`))).toBe(true);
|
||||
const final = frameRows(frames.at(-1)!);
|
||||
expect(taskRow(final, "Build app: done")).toMatch(/✔/);
|
||||
expect(taskRow(final, "Build client: failed")).toMatch(/✖/);
|
||||
expect(final.join("\n")).toContain("build stopped");
|
||||
expect(final.join("\n")).toContain("stack detail");
|
||||
expect(final.join("\n").split("build stopped")).toHaveLength(2);
|
||||
} finally {
|
||||
renderSpy.mockRestore();
|
||||
writes.mockRestore();
|
||||
@@ -292,11 +302,18 @@ describe("up API pipeline", () => {
|
||||
const order: string[] = [];
|
||||
const started: string[] = [];
|
||||
let rootTasks: Listr["tasks"] | undefined;
|
||||
let backingTasks: Listr["tasks"] | undefined;
|
||||
const originalRun = Listr.prototype.run;
|
||||
const frames: string[] = [];
|
||||
const create = DefaultRenderer.prototype.create;
|
||||
const renderSpy = spyOn(DefaultRenderer.prototype, "create").mockImplementation(function (this: DefaultRenderer, options) {
|
||||
const frame = create.call(this, options);
|
||||
frames.push(frame);
|
||||
return frame;
|
||||
});
|
||||
const tty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
const writes = spyOn(process.stdout, "write").mockImplementation((() => true) as typeof process.stdout.write);
|
||||
const runSpy = spyOn(Listr.prototype, "run").mockImplementation(function (this: Listr) {
|
||||
if (this.tasks[0]?.title === "Read compose") rootTasks = this.tasks;
|
||||
if (this.tasks[0]?.title === "Reconcile databases") backingTasks = this.tasks;
|
||||
return originalRun.call(this);
|
||||
});
|
||||
let bothReady!: () => void;
|
||||
@@ -312,6 +329,7 @@ describe("up API pipeline", () => {
|
||||
releaseStorage = resolve;
|
||||
});
|
||||
try {
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
await writeFile(
|
||||
join(root, "compose.yml"),
|
||||
"services:\n app:\n image: nginx\n volumes:\n - postgresql:app\n - s3:assets\n",
|
||||
@@ -356,10 +374,21 @@ describe("up API pipeline", () => {
|
||||
expect(titles.indexOf("Render manifests")).toBeLessThan(
|
||||
titles.indexOf("Reconcile resources"),
|
||||
);
|
||||
expect(backingTasks!.map(({ title }) => title)).toEqual([
|
||||
const backing = rootTasks!.find(({ title }) => title === "Reconcile backing services")!;
|
||||
expect(backing.subtasks.map(({ title }) => title)).toEqual([
|
||||
"Reconcile databases",
|
||||
"Reconcile S3 storage",
|
||||
]);
|
||||
const active = frames.map(frameRows).filter((rows) =>
|
||||
taskRow(rows, "Reconcile backing services") &&
|
||||
taskRow(rows, "Reconcile databases") &&
|
||||
taskRow(rows, "Reconcile S3 storage"));
|
||||
expect(active.length).toBeGreaterThan(0);
|
||||
for (const rows of active) {
|
||||
const parent = taskRow(rows, "Reconcile backing services");
|
||||
expect(depth(taskRow(rows, "Reconcile databases"))).toBeGreaterThan(depth(parent));
|
||||
expect(depth(taskRow(rows, "Reconcile S3 storage"))).toBeGreaterThan(depth(parent));
|
||||
}
|
||||
expect(rootTasks!.filter(({ title }) => title === "Reconcile databases")).toEqual([]);
|
||||
expect(started).toEqual(["database", "storage"]);
|
||||
expect(order.indexOf("/workspaces/shop/adopt")).toBeLessThan(
|
||||
@@ -388,6 +417,10 @@ describe("up API pipeline", () => {
|
||||
}
|
||||
} finally {
|
||||
runSpy.mockRestore();
|
||||
renderSpy.mockRestore();
|
||||
writes.mockRestore();
|
||||
if (tty) Object.defineProperty(process.stdout, "isTTY", tty);
|
||||
else Reflect.deleteProperty(process.stdout, "isTTY");
|
||||
process.chdir(previousCwd);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -396,17 +429,18 @@ describe("up API pipeline", () => {
|
||||
test("shows database API problem details on the database task without starting resources", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-up-api-"));
|
||||
const previousCwd = process.cwd();
|
||||
const rendered: string[] = [];
|
||||
const writes = spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => {
|
||||
rendered.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
const errors = spyOn(process.stderr, "write").mockImplementation(((chunk: string | Uint8Array) => {
|
||||
rendered.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stderr.write);
|
||||
const frames: string[] = [];
|
||||
const create = DefaultRenderer.prototype.create;
|
||||
const renderSpy = spyOn(DefaultRenderer.prototype, "create").mockImplementation(function (this: DefaultRenderer, options) {
|
||||
const frame = create.call(this, options);
|
||||
frames.push(frame);
|
||||
return frame;
|
||||
});
|
||||
const tty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
const writes = spyOn(process.stdout, "write").mockImplementation((() => true) as typeof process.stdout.write);
|
||||
const calls: string[] = [];
|
||||
try {
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
await writeFile(join(root, "compose.yml"), "services:\n web:\n image: nginx\n volumes:\n - postgresql:web_db\n");
|
||||
await writeFile(join(root, ".kuberrc.ts"), 'export default { project: "shop" };\n');
|
||||
process.chdir(root);
|
||||
@@ -423,11 +457,17 @@ describe("up API pipeline", () => {
|
||||
throw new Error(`Unexpected request: ${path}`);
|
||||
};
|
||||
await expect(provideContext(() => runUp(false, request, { trust }))).rejects.toThrow(detail);
|
||||
expect(rendered.join("")).toContain("database apply for database web_db");
|
||||
const rows = frameRows(frames.at(-1)!);
|
||||
expect(depth(taskRow(rows, "Reconcile databases"))).toBeGreaterThan(depth(taskRow(rows, "Reconcile backing services")));
|
||||
expect(taskRow(rows, "Reconcile databases")).toMatch(/✖/);
|
||||
const visible = rows.join(" ").replace(/\s+/g, " ");
|
||||
expect(visible.split(detail)).toHaveLength(2);
|
||||
expect(calls).not.toContain("/workspaces/shop/resources/plan");
|
||||
} finally {
|
||||
errors.mockRestore();
|
||||
renderSpy.mockRestore();
|
||||
writes.mockRestore();
|
||||
if (tty) Object.defineProperty(process.stdout, "isTTY", tty);
|
||||
else Reflect.deleteProperty(process.stdout, "isTTY");
|
||||
process.chdir(previousCwd);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user