@lizard-build/cli 0.3.92 → 0.3.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import ora from "ora";
3
3
  import { Command } from "commander";
4
- import { execSync, spawn } from "child_process";
4
+ import { execSync } from "child_process";
5
+ import { createTarball } from "../lib/archive.js";
5
6
  import * as fs from "node:fs";
6
7
  import * as path from "node:path";
7
8
  import * as readline from "node:readline";
@@ -290,34 +291,6 @@ function collectFilesManually(root: string, dir: string): string[] {
290
291
  return results;
291
292
  }
292
293
 
293
- function createTarball(files: string[], cwd: string): Promise<Uint8Array> {
294
- return new Promise((resolve, reject) => {
295
- const chunks: Uint8Array[] = [];
296
- // `--null` makes tar read NUL-separated paths from stdin, matching what
297
- // `git ls-files -z` writes. Newline-separated input would split filenames
298
- // containing `\n` across multiple entries. Both bsdtar (macOS) and GNU
299
- // tar accept `--null` before `-T -`.
300
- const tar = spawn("tar", ["--null", "-czf", "-", "-T", "-"], { cwd });
301
- tar.stdout.on("data", (c: Buffer) => chunks.push(c));
302
- tar.stderr.on("data", () => {});
303
- tar.on("close", (code: number) => {
304
- if (code === 0) {
305
- const total = chunks.reduce((n, c) => n + c.length, 0);
306
- const out = new Uint8Array(total);
307
- let off = 0;
308
- for (const c of chunks) {
309
- out.set(c, off);
310
- off += c.length;
311
- }
312
- resolve(out);
313
- } else {
314
- reject(new Error(`tar exited ${code}`));
315
- }
316
- });
317
- if (files.length > 0) tar.stdin.write(files.join("\0") + "\0");
318
- tar.stdin.end();
319
- });
320
- }
321
294
 
322
295
  function detectLocalPort(dir: string): number | undefined {
323
296
  for (const name of ["Dockerfile", "dockerfile", "Dockerfile.production"]) {
@@ -340,7 +313,7 @@ function prompt(question: string): Promise<string> {
340
313
  });
341
314
  }
342
315
 
343
- async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuildId?: string) {
316
+ export async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuildId?: string) {
344
317
  // Prefer the buildId returned by the upload/redeploy response — polling
345
318
  // builds[0] races against a still-running previous build and can attach
346
319
  // to the wrong one.
@@ -372,12 +345,16 @@ async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuil
372
345
  // idle timeout, network blips). Reconnect until the build itself reports
373
346
  // a terminal status, with a hard cap so we don't loop forever.
374
347
  const deadline = Date.now() + 15 * 60 * 1000; // 15 min max
348
+ let buildFailed = false;
375
349
  while (Date.now() < deadline) {
376
350
  let dropped = false;
377
351
  try {
378
352
  await streamSSE(`/api/builds/${buildId}/logs`, (event, data) => {
379
353
  if (event === "done" || event === "error") {
380
- if (event === "error") emitBuildError(data);
354
+ if (event === "error") {
355
+ buildFailed = true;
356
+ emitBuildError(data);
357
+ }
381
358
  else emitBuildDone();
382
359
  return false;
383
360
  }
@@ -392,6 +369,7 @@ async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuil
392
369
  // build state — terminal status means we stop reconnecting.
393
370
  try {
394
371
  const build = await api.get<{ status: string }>(`/api/builds/${buildId}`);
372
+ if (build.status === "failed") buildFailed = true;
395
373
  if (build.status === "done" || build.status === "failed") break;
396
374
  } catch {}
397
375
 
@@ -399,6 +377,10 @@ async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuil
399
377
  await sleep(2000);
400
378
  }
401
379
 
380
+ if (buildFailed) {
381
+ process.exitCode = 1;
382
+ return;
383
+ }
402
384
  if (ciMode) return;
403
385
 
404
386
  const app = await api.get<App>(`/api/apps/${appId}`);
@@ -415,6 +397,7 @@ async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuil
415
397
  success(`Deployed! ${app.domain ? chalk.cyan(`https://${app.domain}`) : ""}`);
416
398
  }
417
399
  } else if (app.status === "failed") {
400
+ process.exitCode = 1;
418
401
  if (isJSONMode()) {
419
402
  process.stdout.write(
420
403
  JSON.stringify({ event: "failed", status: "failed" }) + "\n",
@@ -0,0 +1,34 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ // macOS tar otherwise creates AppleDouble ._* entries from file metadata.
4
+ export function createTarball(files: string[], cwd: string): Promise<Uint8Array> {
5
+ return new Promise((resolve, reject) => {
6
+ const chunks: Uint8Array[] = [];
7
+ // `--null` makes tar read NUL-separated paths from stdin, matching what
8
+ // `git ls-files -z` writes. Newline-separated input would split filenames
9
+ // containing `\n` across multiple entries. Both bsdtar (macOS) and GNU
10
+ // tar accept `--null` before `-T -`.
11
+ const tar = spawn("tar", ["--null", "-czf", "-", "-T", "-"], { cwd, env: { ...process.env, COPYFILE_DISABLE: "1" } });
12
+ tar.on("error", reject);
13
+ tar.stdin.on("error", reject);
14
+ tar.stdout.on("data", (c: Buffer) => chunks.push(c));
15
+ tar.stderr.on("data", () => {});
16
+ tar.on("close", (code: number) => {
17
+ if (code === 0) {
18
+ const total = chunks.reduce((n, c) => n + c.length, 0);
19
+ const out = new Uint8Array(total);
20
+ let off = 0;
21
+ for (const c of chunks) {
22
+ out.set(c, off);
23
+ off += c.length;
24
+ }
25
+ resolve(out);
26
+ } else {
27
+ reject(new Error(`tar exited ${code}`));
28
+ }
29
+ });
30
+ if (files.length > 0) tar.stdin.write(files.join("\0") + "\0");
31
+ tar.stdin.end();
32
+ });
33
+ }
34
+
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
5
5
  import os from "node:os";
6
6
  import { spawn } from "node:child_process";
7
7
 
8
- export const CURRENT_VERSION = "0.3.92";
8
+ export const CURRENT_VERSION = "0.3.94";
9
9
  const RELEASES_API = "https://api.github.com/repos/lizard-build/lizard-cli/releases/latest";
10
10
  const RELEASE_BASE = "https://github.com/lizard-build/lizard-cli/releases/latest/download";
11
11
 
@@ -0,0 +1,127 @@
1
+ import { api } from "./api.js";
2
+
3
+ export interface AppSnapshot {
4
+ status?: string;
5
+ deployStatus?: string | null;
6
+ restartedAt?: number | string | null;
7
+ domain?: string;
8
+ }
9
+
10
+ export interface WaitResult {
11
+ ok: boolean;
12
+ /** "running" on success; "failed" | "crashed" on a reported failure; "timeout" if the
13
+ * wait ran out before a terminal state for this attempt was reached. */
14
+ status: string;
15
+ /** Identifies the specific restart/deploy attempt that was waited on (the app's
16
+ * restartedAt value once it changed from the pre-trigger baseline) — an old,
17
+ * already-`running` read from before the trigger is never mistaken for success. */
18
+ attemptId: number | string | null;
19
+ domain?: string;
20
+ waitedMs: number;
21
+ }
22
+
23
+ function sleep(ms: number): Promise<void> {
24
+ return new Promise((r) => setTimeout(r, ms));
25
+ }
26
+
27
+ /**
28
+ * Two consecutive successful checks, spaced apart, against the app's own domain.
29
+ * A single 200 isn't enough during a restart handover — LIZARD-174's reproduction
30
+ * found one healthy response immediately followed by a 502 while the old process
31
+ * was still finishing its shutdown. Not run for apps with no domain (workers) —
32
+ * platform-reported readiness is the only signal available for those.
33
+ */
34
+ async function debounceHealthCheck(domain: string): Promise<boolean> {
35
+ for (let i = 0; i < 2; i++) {
36
+ try {
37
+ const res = await fetch(`https://${domain}/`, { signal: AbortSignal.timeout(5000) });
38
+ if (res.status >= 500) return false;
39
+ } catch {
40
+ return false;
41
+ }
42
+ if (i === 0) await sleep(1500);
43
+ }
44
+ return true;
45
+ }
46
+
47
+ /**
48
+ * Waits for a *specific* restart/deploy attempt to become ready, distinguishing
49
+ * it from the app simply already being `running` from before the attempt was
50
+ * triggered — the exact gap LIZARD-174 reports: `lizard restart --json` returned
51
+ * as soon as the restart was accepted, and the very next request could still hit
52
+ * the outgoing old process (200) or a mid-handover 502/503.
53
+ *
54
+ * `baselineRestartedAt` must be captured from GET /api/apps/:id *before* the
55
+ * restart call is made — readiness is only trusted once `restartedAt` has moved
56
+ * past that baseline, so an unrelated, still-`running` read from the old attempt
57
+ * never short-circuits the wait. Pass `undefined` (not `null`) for callers whose
58
+ * trigger doesn't touch restartedAt at all (e.g. redeploy, which is tracked by
59
+ * its own buildId instead) — this skips the restartedAt gate entirely and trusts
60
+ * deployStatus/status transitions directly, since deployStatus is flipped to
61
+ * building/deploying synchronously by those triggers.
62
+ */
63
+ export async function waitForAppReady(
64
+ id: string,
65
+ baselineRestartedAt: number | string | null | undefined,
66
+ opts: { timeoutMs?: number; healthCheck?: boolean } = {},
67
+ ): Promise<WaitResult> {
68
+ const timeoutMs = opts.timeoutMs ?? 120_000;
69
+ const healthCheck = opts.healthCheck ?? true;
70
+ const start = Date.now();
71
+
72
+ let attemptId: number | string | null = null;
73
+ let sawNewAttempt = baselineRestartedAt === undefined;
74
+
75
+ while (Date.now() - start < timeoutMs) {
76
+ let app: AppSnapshot;
77
+ try {
78
+ app = await api.get<AppSnapshot>(`/api/apps/${id}`);
79
+ } catch {
80
+ await sleep(1500);
81
+ continue;
82
+ }
83
+
84
+ if (!sawNewAttempt) {
85
+ const restartedAt = app.restartedAt ?? null;
86
+ if (restartedAt !== null && restartedAt !== baselineRestartedAt) {
87
+ sawNewAttempt = true;
88
+ attemptId = restartedAt;
89
+ } else {
90
+ // The platform hasn't picked up this attempt yet — deployStatus/status
91
+ // here still describe whatever was true *before* it was triggered.
92
+ // Reporting "running" at this point would be exactly the false
93
+ // positive this function exists to prevent.
94
+ await sleep(1000);
95
+ continue;
96
+ }
97
+ } else if (app.restartedAt != null && app.restartedAt !== attemptId) {
98
+ // A newer attempt interleaved (e.g. someone else restarted it again)
99
+ // — track that one instead; it's the one that will actually land.
100
+ attemptId = app.restartedAt;
101
+ }
102
+
103
+ if (app.deployStatus === "restarting" || app.deployStatus === "building" || app.deployStatus === "deploying") {
104
+ await sleep(1000);
105
+ continue;
106
+ }
107
+ if (app.status === "failed" || app.status === "crashed") {
108
+ return { ok: false, status: app.status, attemptId, domain: app.domain, waitedMs: Date.now() - start };
109
+ }
110
+ if (app.status === "running") {
111
+ if (healthCheck && app.domain) {
112
+ const healthy = await debounceHealthCheck(app.domain);
113
+ if (!healthy) {
114
+ // Platform says running, but the URL isn't consistently healthy yet
115
+ // (mid-handover) — keep polling rather than declaring success early.
116
+ await sleep(1500);
117
+ continue;
118
+ }
119
+ }
120
+ return { ok: true, status: "running", attemptId, domain: app.domain, waitedMs: Date.now() - start };
121
+ }
122
+
123
+ await sleep(1000);
124
+ }
125
+
126
+ return { ok: false, status: "timeout", attemptId, waitedMs: Date.now() - start };
127
+ }
package/test/cli.test.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  * mutate services (deploy, scale) are skipped so they can never touch
11
11
  * an arbitrary real project picked from the account.
12
12
  *
13
- * Run: npm test
13
+ * Run: LIZARD_LIVE_TESTS=1 npm run test:integration
14
+ * Mutations also require LIZARD_TEST_PROJECT_ID and LIZARD_TEST_ALLOW_MUTATIONS=1.
14
15
  *
15
16
  * Flag-order rules (commander):
16
17
  * - Global flags (--json, --token, --region) go BEFORE the subcommand.
@@ -39,7 +40,7 @@ import * as os from "node:os";
39
40
  // fall back to whatever `lizard` is on PATH. Resolve to absolute path so we
40
41
  // can run from any cwd (the e2e suite drops into /tmp for fixtures).
41
42
  function resolveLizard(): string[] {
42
- const raw = process.env.LIZARD_BIN ?? "lizard";
43
+ const raw = process.env.LIZARD_BIN ?? "dist/index.js";
43
44
  if (raw.endsWith(".js")) {
44
45
  return [process.execPath, path.resolve(raw)];
45
46
  }
@@ -110,10 +111,15 @@ let projectId: string;
110
111
 
111
112
  // Tracks created app IDs for afterAll cleanup
112
113
  const createdApps: string[] = [];
114
+ const allowMutations = process.env.LIZARD_TEST_ALLOW_MUTATIONS === "1"
115
+ && Boolean(process.env.LIZARD_TEST_PROJECT_ID);
113
116
 
114
117
  // ── Setup: resolve project ID ─────────────────────────────────────────────────
115
118
 
116
119
  beforeAll(async () => {
120
+ if (process.env.LIZARD_LIVE_TESTS !== "1") {
121
+ throw new Error("Live API tests require LIZARD_LIVE_TESTS=1. Run npm test for local unit tests.");
122
+ }
117
123
  // Explicit override wins (CI-friendly).
118
124
  if (process.env.LIZARD_TEST_PROJECT_ID) {
119
125
  projectId = process.env.LIZARD_TEST_PROJECT_ID;
@@ -205,7 +211,7 @@ describe("projects", () => {
205
211
 
206
212
  // ── Project-scope (global) secrets ────────────────────────────────────────────
207
213
 
208
- describe("project secrets", () => {
214
+ describe.skipIf(!allowMutations)("project secrets", () => {
209
215
  const KEY = `CLI_TEST_GLOBAL_${Date.now()}`;
210
216
 
211
217
  // For listing we use the bare `secret` form (no `list` subcommand) — the
@@ -265,7 +271,7 @@ describe("ps (service inventory)", () => {
265
271
  // Mutates a real app (forces replicas=1) — only safe against a dedicated,
266
272
  // explicitly pinned test project.
267
273
 
268
- describe.skipIf(!process.env.LIZARD_TEST_PROJECT_ID)("scale", () => {
274
+ describe.skipIf(!allowMutations)("scale", () => {
269
275
  test("scale --replicas succeeds when an app exists", async () => {
270
276
  const services = await cliJSON("ps", "--project", projectId);
271
277
  const apps: Array<{ id: string; name: string }> = services?.apps ?? [];
@@ -313,7 +319,7 @@ describe("domain", () => {
313
319
  let DEPLOY_DIR: string | undefined;
314
320
 
315
321
  describe.skipIf(
316
- process.env.LIZARD_SKIP_DEPLOY === "1" || !process.env.LIZARD_TEST_PROJECT_ID,
322
+ process.env.LIZARD_SKIP_DEPLOY === "1" || !allowMutations,
317
323
  )("deploy", () => {
318
324
  const appName = `cli-test-${Date.now()}`;
319
325
  let appId: string;
@@ -476,6 +482,7 @@ describe("error handling", () => {
476
482
  // ── Cleanup ───────────────────────────────────────────────────────────────────
477
483
 
478
484
  afterAll(async () => {
485
+ if (!allowMutations || process.env.LIZARD_LIVE_TESTS !== "1") return;
479
486
  for (const id of createdApps) {
480
487
  await execa(LIZARD_CMD, [
481
488
  ...LIZARD_ARGS_PREFIX,
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { createTarball } from "../../src/lib/archive.js";
7
+
8
+ describe("source upload archive", () => {
9
+ it("preserves source bytes and unusual names through extraction", async () => {
10
+ const root = mkdtempSync(join(tmpdir(), "lizard-archive-"));
11
+ const names = ["normal.ts", "space name.ts", "line\nbreak.ts", "-flag.ts"];
12
+ for (const name of names) writeFileSync(join(root, name), `source: ${name}`);
13
+ const archive = await createTarball(names, root);
14
+ const output = join(root, "output");
15
+ mkdirSync(output);
16
+ execFileSync("tar", ["-xzf", "-", "-C", output], { input: archive });
17
+ for (const name of names) expect(readFileSync(join(output, name), "utf8")).toBe(`source: ${name}`);
18
+ });
19
+
20
+ it.skipIf(process.platform !== "darwin")("omits AppleDouble entries for files with macOS metadata", async () => {
21
+ const root = mkdtempSync(join(tmpdir(), "lizard-archive-xattr-"));
22
+ writeFileSync(join(root, "route.ts"), "export default 42;");
23
+ execFileSync("xattr", ["-w", "com.lizard.docs-test", "metadata", join(root, "route.ts")]);
24
+ const archive = await createTarball(["route.ts"], root);
25
+ const listing = execFileSync("tar", ["-tzf", "-"], { input: archive, encoding: "utf8" });
26
+ expect(listing.trim().split("\n")).toEqual(["route.ts"]);
27
+ });
28
+ });
@@ -0,0 +1,38 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { Command } from "commander";
3
+ import { registerSandbox } from "../../src/commands/sandbox.js";
4
+ import { api } from "../../src/lib/api.js";
5
+
6
+ vi.mock("../../src/lib/api.js", () => ({ api: { post: vi.fn() } }));
7
+ vi.mock("../../src/lib/resolve.js", () => ({
8
+ resolveProjectScope: vi.fn().mockResolvedValue({ projectId: "project-test", scope: { workspaceId: "workspace-test" } }),
9
+ }));
10
+ vi.mock("../../src/lib/format.js", () => ({ isJSONMode: () => true, printJSON: vi.fn() }));
11
+
12
+ function create(args: string[]) {
13
+ const program = new Command().exitOverride();
14
+ registerSandbox(program);
15
+ return program.parseAsync(["sandbox", "create", ...args], { from: "user" });
16
+ }
17
+
18
+ describe("sandbox create timeout", () => {
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ vi.mocked(api.post).mockResolvedValue({ id: "sandbox-test" });
22
+ });
23
+
24
+ it("sends the documented five-minute default to the API", async () => {
25
+ await create([]);
26
+ expect(api.post).toHaveBeenCalledWith("/api/sandboxes", expect.objectContaining({ timeoutMs: 300_000 }));
27
+ });
28
+
29
+ it.each([0, 1000, 120_000, 2_147_483_647])("preserves an explicit timeout of %i", async (timeoutMs) => {
30
+ await create(["--timeout", String(timeoutMs)]);
31
+ expect(api.post).toHaveBeenCalledWith("/api/sandboxes", expect.objectContaining({ timeoutMs }));
32
+ });
33
+
34
+ it.each(["-1", "1.5", "1000ms", "Infinity", "2147483648"])("rejects %s before making a request", async (value) => {
35
+ await expect(create(["--timeout", value])).rejects.toThrow(/Timeout must be/);
36
+ expect(api.post).not.toHaveBeenCalled();
37
+ });
38
+ });
@@ -0,0 +1,46 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({ get: vi.fn(), stream: vi.fn() }));
4
+ vi.mock("../../src/lib/api.js", async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import("../../src/lib/api.js")>();
6
+ return { ...actual, api: { ...actual.api, get: mocks.get }, streamSSE: mocks.stream };
7
+ });
8
+ import { streamBuildLogs } from "../../src/commands/up.js";
9
+ import { setJSONMode } from "../../src/lib/format.js";
10
+
11
+ describe("upload build result", () => {
12
+ let output: string;
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ process.exitCode = 0;
16
+ setJSONMode(true);
17
+ output = "";
18
+ vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => { output += chunk; return true; });
19
+ });
20
+ afterEach(() => { process.exitCode = 0; setJSONMode(false); vi.restoreAllMocks(); });
21
+
22
+ it.each([false, true])("returns failure after an SSE build error (CI=%s)", async (ci) => {
23
+ mocks.stream.mockImplementation(async (_url, receive) => { receive("error", '"failed"'); });
24
+ mocks.get.mockResolvedValue({ status: "failed" });
25
+ await streamBuildLogs("app", ci, "build");
26
+ expect(process.exitCode).toBe(1);
27
+ expect(output).not.toContain('"event":"deployed"');
28
+ expect(mocks.get).not.toHaveBeenCalledWith("/api/apps/app");
29
+ });
30
+
31
+ it("uses the failed build state even when an older app is still running", async () => {
32
+ mocks.stream.mockResolvedValue(undefined);
33
+ mocks.get.mockImplementation(async (url) => url === "/api/builds/build" ? { status: "failed" } : { status: "running" });
34
+ await streamBuildLogs("app", false, "build");
35
+ expect(process.exitCode).toBe(1);
36
+ expect(output).not.toContain("deployed");
37
+ });
38
+
39
+ it("keeps a successful build and running app successful", async () => {
40
+ mocks.stream.mockImplementation(async (_url, receive) => { receive("done", ""); });
41
+ mocks.get.mockImplementation(async (url) => url === "/api/builds/build" ? { status: "done" } : { status: "running", domain: "example.test" });
42
+ await streamBuildLogs("app", false, "build");
43
+ expect(process.exitCode).toBe(0);
44
+ expect(output).toContain('"event":"deployed"');
45
+ });
46
+ });
@@ -0,0 +1,133 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import * as http from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import { setBaseURL, getBaseURL } from "../../src/lib/api.js";
5
+ import { waitForAppReady } from "../../src/lib/wait-ready.js";
6
+
7
+ type Handler = (req: http.IncomingMessage, res: http.ServerResponse) => void;
8
+
9
+ let server: http.Server;
10
+ let handle: Handler;
11
+ const originalBaseURL = getBaseURL();
12
+
13
+ beforeEach(async () => {
14
+ server = http.createServer((req, res) => handle(req, res));
15
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
16
+ const { port } = server.address() as AddressInfo;
17
+ setBaseURL(`http://127.0.0.1:${port}`);
18
+ });
19
+
20
+ afterEach(async () => {
21
+ setBaseURL(originalBaseURL);
22
+ await new Promise<void>((resolve) => server.close(() => resolve()));
23
+ });
24
+
25
+ function json(res: http.ServerResponse, body: unknown) {
26
+ res.writeHead(200, { "Content-Type": "application/json" });
27
+ res.end(JSON.stringify(body));
28
+ }
29
+
30
+ // A queue of app snapshots, one served per GET /api/apps/:id — the last entry
31
+ // repeats once the queue is drained, so a test only has to describe the
32
+ // interesting transitions.
33
+ function serveSequence(snapshots: any[]) {
34
+ let i = 0;
35
+ handle = (_req, res) => {
36
+ const snap = snapshots[Math.min(i, snapshots.length - 1)];
37
+ i++;
38
+ json(res, snap);
39
+ };
40
+ }
41
+
42
+ describe("waitForAppReady", () => {
43
+ test("LIZARD-174: a stale already-running read from before the restart is not mistaken for success", async () => {
44
+ // First poll still reflects the OLD attempt (status running, restartedAt
45
+ // unchanged from baseline) — this is exactly the false positive the
46
+ // baseline-diff exists to prevent. Only once restartedAt actually moves
47
+ // and settles back to running should the wait resolve.
48
+ serveSequence([
49
+ { status: "running", deployStatus: "idle", restartedAt: 1000 }, // stale — must be ignored
50
+ { status: "running", deployStatus: "idle", restartedAt: 1000 }, // still stale
51
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 }, // new attempt begins
52
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 },
53
+ { status: "running", deployStatus: "idle", restartedAt: 2000 }, // new attempt ready
54
+ ]);
55
+
56
+ const result = await waitForAppReady("app1", 1000, { timeoutMs: 15_000, healthCheck: false });
57
+ expect(result.ok).toBe(true);
58
+ expect(result.status).toBe("running");
59
+ expect(result.attemptId).toBe(2000);
60
+ });
61
+
62
+ test("reports failure for the new attempt (Pending -> CrashLoopBackOff)", async () => {
63
+ serveSequence([
64
+ { status: "running", deployStatus: "idle", restartedAt: 1000 },
65
+ { status: "crashed", deployStatus: "restarting", restartedAt: 2000 },
66
+ { status: "crashed", deployStatus: "idle", restartedAt: 2000 },
67
+ ]);
68
+
69
+ const result = await waitForAppReady("app1", 1000, { timeoutMs: 15_000, healthCheck: false });
70
+ expect(result.ok).toBe(false);
71
+ expect(result.status).toBe("crashed");
72
+ expect(result.attemptId).toBe(2000);
73
+ });
74
+
75
+ test("times out with a non-ok result if the new attempt never settles", async () => {
76
+ serveSequence([{ status: "running", deployStatus: "restarting", restartedAt: 2000 }]);
77
+
78
+ const result = await waitForAppReady("app1", 1000, { timeoutMs: 2500, healthCheck: false });
79
+ expect(result.ok).toBe(false);
80
+ expect(result.status).toBe("timeout");
81
+ });
82
+
83
+ test("late start: keeps waiting through several restarting polls before success", async () => {
84
+ serveSequence([
85
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 },
86
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 },
87
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 },
88
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 },
89
+ { status: "running", deployStatus: "idle", restartedAt: 2000 },
90
+ ]);
91
+
92
+ const result = await waitForAppReady("app1", null, { timeoutMs: 15_000, healthCheck: false });
93
+ expect(result.ok).toBe(true);
94
+ });
95
+
96
+ test("undefined baseline (redeploy) trusts deployStatus/status directly, no restartedAt gate", async () => {
97
+ // redeploy never touches restartedAt — it stays null throughout, and that
98
+ // must not block success the way a real restart's stale baseline would.
99
+ serveSequence([
100
+ { status: "running", deployStatus: "deploying", restartedAt: null },
101
+ { status: "running", deployStatus: "idle", restartedAt: null },
102
+ ]);
103
+
104
+ const result = await waitForAppReady("app1", undefined, { timeoutMs: 15_000, healthCheck: false });
105
+ expect(result.ok).toBe(true);
106
+ });
107
+
108
+ test("health check debounce: a single 200 mid-handover does not count as ready", async () => {
109
+ serveSequence([{ status: "running", deployStatus: "idle", restartedAt: 2000, domain: "app.example.test" }]);
110
+
111
+ const realFetch = globalThis.fetch;
112
+ let call = 0;
113
+ vi.stubGlobal("fetch", (url: string, opts?: any) => {
114
+ if (typeof url === "string" && url.includes("app.example.test")) {
115
+ call++;
116
+ // First health check: healthy. Second (1.5s later, per the debounce):
117
+ // 502 — the exact LIZARD-174 reproduction (old process still finishing
118
+ // shutdown). Must not be reported as ready on the strength of check #1 alone.
119
+ return Promise.resolve(new Response(null, { status: call === 1 ? 200 : 502 }));
120
+ }
121
+ return realFetch(url, opts);
122
+ });
123
+
124
+ try {
125
+ const result = await waitForAppReady("app1", 1000, { timeoutMs: 4000, healthCheck: true });
126
+ expect(result.ok).toBe(false);
127
+ expect(result.status).toBe("timeout");
128
+ expect(call).toBeGreaterThanOrEqual(2);
129
+ } finally {
130
+ vi.unstubAllGlobals();
131
+ }
132
+ });
133
+ });