Skip to content

Commit 9907a29

Browse files
ymansurozerclaude
andcommitted
test: cover guide staleness, deskAlive, and stable-port fallback
Three invariants the spec promises but nothing tested: guideStale()'s baseDiffHash comparison, deskAlive()'s dead-vs-live desk-lock probe, and startServer()'s EADDRINUSE fallback to a different port. Adds regression tests for all three, plus the minimal testability tweaks each needed (guideStale()'s hash logic extracted into a store-free guide-derive.ts since importing the Alpine store crashes under node:test; deskAlive exported and cli.ts gated behind an entry-point guard so importing it for the test doesn't launch a real desk). The entry-point guard initially compared import.meta.url to process.argv[1] directly, which breaks under npm's bin symlink (.bin/galley -> dist/cli.js): Node resolves import.meta.url through the symlink to the real path but leaves argv[1] as the symlink path, so the guard never matched and the CLI silently no-op'd. Fixed by realpath'ing argv[1] before comparing, with a regression test that runs cli.ts through an actual symlink. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6fab5e1 commit 9907a29

6 files changed

Lines changed: 145 additions & 12 deletions

File tree

src/cli.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { spawnSync } from "node:child_process";
4+
import { mkdtemp, rm, symlink } from "node:fs/promises";
5+
import http from "node:http";
6+
import { tmpdir } from "node:os";
7+
import path from "node:path";
8+
import { fileURLToPath } from "node:url";
9+
import { deskAlive } from "./cli.js";
10+
11+
// A desk lock can outlive its process (crash, SIGKILL) — deskAlive is the only thing standing
12+
// between "trust the lock" and "trust it only if the server answers" (see state.ts's stablePort
13+
// invariant notes). Cover both sides: a dead URL and a URL that actually answers.
14+
15+
test("deskAlive resolves false for a URL nothing is listening on, within the abort budget", async () => {
16+
// Port 1 is a privileged, essentially-never-bound port — connection refused (or a hang the
17+
// ~1500ms internal abort catches) either way lands on false. We only assert the outcome and a
18+
// generous wall-clock ceiling, not the exact abort timing.
19+
const start = Date.now();
20+
const alive = await deskAlive("http://127.0.0.1:1/");
21+
assert.equal(alive, false);
22+
assert.ok(Date.now() - start < 5000, "resolves well within the ~1500ms abort budget plus slack");
23+
});
24+
25+
test("deskAlive resolves true for a live server that answers", async () => {
26+
const server = http.createServer((_req, res) => {
27+
res.writeHead(200, { "content-type": "application/json" });
28+
res.end("{}");
29+
});
30+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
31+
try {
32+
const address = server.address();
33+
const port = typeof address === "object" && address ? address.port : 0;
34+
const alive = await deskAlive(`http://127.0.0.1:${port}/`);
35+
assert.equal(alive, true);
36+
} finally {
37+
server.close();
38+
}
39+
});
40+
41+
// Regression: npm installs the published bin as a SYMLINK (.bin/galley -> dist/cli.js). Node
42+
// resolves import.meta.url through the symlink to cli.ts's real path, but leaves
43+
// process.argv[1] as the symlink path — an entry-point guard that compares the two directly
44+
// (no realpath) never fires under a symlinked invocation, and the CLI silently no-ops (exits 0,
45+
// no output). Reproduce that exact shape here: symlink to the real src/cli.ts and run it through
46+
// `node --import tsx` the same way the built bin runs through node directly.
47+
test("running cli.ts through a symlink (npm bin shape) still runs main()", async () => {
48+
const dir = await mkdtemp(path.join(tmpdir(), "galley-cli-symlink-"));
49+
const link = path.join(dir, "galley");
50+
const cliPath = fileURLToPath(new URL("./cli.ts", import.meta.url));
51+
try {
52+
await symlink(cliPath, link);
53+
const result = spawnSync(process.execPath, ["--import", "tsx", link, "--help"], {
54+
encoding: "utf8",
55+
});
56+
assert.equal(result.status, 0);
57+
assert.match(result.stdout, /galley an integrated review environment/);
58+
} finally {
59+
await rm(dir, { recursive: true, force: true });
60+
}
61+
});

src/cli.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
#!/usr/bin/env node
2-
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
2+
import { readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
33
import http from "node:http";
44
import path from "node:path";
5+
import { pathToFileURL } from "node:url";
56

67
import { getBranch, getGitRoot, gh, git } from "./git.js";
78
import { validateGuide } from "./guide.js";
@@ -103,8 +104,8 @@ function httpGetJson(urlStr: string): Promise<{ status: number; body: any }> {
103104
}
104105

105106
// A desk lock can outlive its process (crash, SIGKILL) — trust it only if the
106-
// server actually answers.
107-
async function deskAlive(url: string): Promise<boolean> {
107+
// server actually answers. Exported for cli.test.ts; behavior is unchanged.
108+
export async function deskAlive(url: string): Promise<boolean> {
108109
const ctrl = new AbortController();
109110
const timer = setTimeout(() => ctrl.abort(), 1500);
110111
try {
@@ -662,7 +663,22 @@ async function main() {
662663
return runDesk("repo", undefined, args);
663664
}
664665

665-
main().catch((error) => {
666-
console.error(error instanceof Error ? error.stack || error.message : String(error));
667-
process.exitCode = 1;
668-
});
666+
// Run only when executed as the bin (`galley` / `node dist/cli.js`), not when cli.test.ts
667+
// imports this module to reach deskAlive() — otherwise import alone would launch a desk. npm
668+
// installs the bin as a SYMLINK (.bin/galley -> dist/cli.js); Node resolves import.meta.url
669+
// through the symlink to the real path, but leaves process.argv[1] as the symlink path — so
670+
// argv[1] must be realpath'd before comparing, or the guard never fires under the published
671+
// bin and the CLI silently no-ops. try/catch guards a dangling/unusual argv[1].
672+
let isMain = false;
673+
try {
674+
isMain =
675+
!!process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
676+
} catch {
677+
/* argv[1] doesn't resolve — treat as not the entry point */
678+
}
679+
if (isMain) {
680+
main().catch((error) => {
681+
console.error(error instanceof Error ? error.stack || error.message : String(error));
682+
process.exitCode = 1;
683+
});
684+
}

src/server.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,3 +719,36 @@ test("settings API round-trips editorCommand", async () => {
719719
assert.equal(loaded.settings?.editorCommand, "cursor -g {file}:{line}");
720720
});
721721
});
722+
723+
test("stable-port EADDRINUSE falls back to a different port instead of throwing", async () => {
724+
// A restarted desk's stablePort (deterministic per repo+session) can still be held by another
725+
// process — startServer must rebind elsewhere rather than crash the launch.
726+
const root = await mkdtemp(path.join(tmpdir(), "galley-portfallback-"));
727+
const oldHome = process.env.HOME;
728+
process.env.HOME = root;
729+
const occupied = http.createServer();
730+
await new Promise<void>((resolve) => occupied.listen(0, "127.0.0.1", resolve));
731+
const address = occupied.address();
732+
const takenPort = typeof address === "object" && address ? address.port : 0;
733+
try {
734+
const handle = await startServer({
735+
state: state(root),
736+
open: false,
737+
idleTimeoutMs: 0,
738+
port: takenPort,
739+
});
740+
try {
741+
const boundPort = Number(new URL(handle.url).port);
742+
assert.notEqual(boundPort, takenPort, "rebound to a different, non-zero port");
743+
assert.ok(boundPort > 0);
744+
const res = await fetch(`${handle.url}api/state`);
745+
assert.equal(res.status, 200, "the fallback server actually answers");
746+
} finally {
747+
handle.server.close();
748+
}
749+
} finally {
750+
occupied.close();
751+
process.env.HOME = oldHome;
752+
await rm(root, { recursive: true, force: true });
753+
}
754+
});

src/ui/guide-derive.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { isGuideBaseStale } from "./guide-derive.js";
4+
5+
test("matching baseDiffHash is not stale", () => {
6+
assert.equal(isGuideBaseStale("h1", "h1"), false);
7+
});
8+
9+
test("a differing baseDiffHash is stale", () => {
10+
assert.equal(isGuideBaseStale("h2", "h1"), true);
11+
});
12+
13+
test("a guide with no baseDiffHash is never stale", () => {
14+
assert.equal(isGuideBaseStale("h1", undefined), false);
15+
});

src/ui/guide-derive.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Pure guide-staleness check (store-free, so it's unit-testable — see skim-derive.ts for the
2+
// pattern). A guide is stale when it carries a baseDiffHash that no longer matches the diff now
3+
// loaded (the agent rewrote code and the desk reloaded onto a newer diff). A guide with no
4+
// baseDiffHash predates the field and is never flagged stale. guide.ts's guideStale() is the
5+
// thin store-reading wrapper.
6+
export function isGuideBaseStale(
7+
baseDiffHash: string,
8+
guideBaseDiffHash: string | undefined,
9+
): boolean {
10+
return !!guideBaseDiffHash && guideBaseDiffHash !== baseDiffHash;
11+
}

src/ui/guide.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { S, $, esc } from "./store";
22
import { flowIndex } from "./changes";
33
import type { FlowIndex } from "./flow-index";
4+
import { isGuideBaseStale } from "./guide-derive";
45
import { navFileOrder, nextUnreviewed, wrapNextTarget, wrapPrevTarget } from "./seek";
56
import { isSkimGroupExpanded } from "./skim";
67
import { renderMarkdown } from "./markdown";
@@ -215,11 +216,7 @@ export function showGuideBar(): boolean {
215216
// The guide was generated against an older diff than the one now loaded (e.g. the agent
216217
// edited code and the desk reloaded). Advisory only — the guide still renders.
217218
export function guideStale(): boolean {
218-
return (
219-
hasGuide() &&
220-
!!S.state.guide!.baseDiffHash &&
221-
S.state.guide!.baseDiffHash !== S.state.baseDiffHash
222-
);
219+
return hasGuide() && isGuideBaseStale(S.state.baseDiffHash, S.state.guide!.baseDiffHash);
223220
}
224221

225222
// Render the Overview page into #diff: overview → optional PR description → Start. No file

0 commit comments

Comments
 (0)