@cldmv/gh-broker 0.7.0 → 0.8.0

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.
Files changed (90) hide show
  1. package/README.md +134 -40
  2. package/config.example.json +21 -8
  3. package/config.sample.json +45 -0
  4. package/deploy/README.md +73 -15
  5. package/deploy/install.mjs +166 -101
  6. package/deploy/install.sh +0 -0
  7. package/deploy/lib/config-merge.mjs +174 -16
  8. package/deploy/lib/credentials.mjs +143 -0
  9. package/deploy/lib/install-args.mjs +144 -0
  10. package/deploy/lib/npm-setup.mjs +164 -0
  11. package/deploy/lib/secret-store.mjs +20 -2
  12. package/deploy/lib/system.mjs +26 -2
  13. package/dist/index.mjs +1638 -202
  14. package/dist/index.mjs.map +4 -4
  15. package/docs/TOOLS.md +192 -1
  16. package/docs/changelog/v0/v0.8.0.md +115 -0
  17. package/docs/deploy-architecture.md +13 -11
  18. package/package.json +7 -7
  19. package/src/availability.ts +42 -0
  20. package/src/catalog.ts +18 -1
  21. package/src/config.ts +29 -2
  22. package/src/router.ts +43 -2
  23. package/src/secrets.ts +34 -4
  24. package/src/types.ts +25 -1
  25. package/src/verbs/ghcr/archive.ts +314 -0
  26. package/src/verbs/ghcr/config.ts +142 -0
  27. package/src/verbs/ghcr/jobs.ts +141 -0
  28. package/src/verbs/ghcr/push.ts +192 -0
  29. package/src/verbs/ghcr/registry.ts +275 -0
  30. package/src/verbs/ghcr/tar.ts +203 -0
  31. package/src/verbs/ghcr_push.ts +264 -0
  32. package/src/verbs/npm-registry.ts +168 -0
  33. package/src/verbs/npm-tarball.ts +76 -0
  34. package/src/verbs/npm_scaffold.ts +325 -0
  35. package/src/verbs/release-analysis.ts +3 -1
  36. package/tests/catalog.test.vitest.mts +55 -1
  37. package/tests/config-merge.test.vitest.mts +275 -8
  38. package/tests/config.test.vitest.mts +75 -1
  39. package/tests/credentials.test.vitest.mts +127 -0
  40. package/tests/ghcr-archive.test.vitest.mts +439 -0
  41. package/tests/ghcr-config.test.vitest.mts +96 -0
  42. package/tests/ghcr-push.test.vitest.mts +526 -0
  43. package/tests/ghcr-registry.test.vitest.mts +206 -0
  44. package/tests/helpers/ghcr-fixtures.mts +373 -0
  45. package/tests/install-args.test.vitest.mts +152 -0
  46. package/tests/issue-labels.test.vitest.mts +15 -0
  47. package/tests/npm-scaffold.test.vitest.mts +479 -0
  48. package/tests/npm-setup.test.vitest.mts +196 -0
  49. package/tests/paths.test.vitest.mts +5 -0
  50. package/tests/prompt.test.vitest.mts +128 -0
  51. package/tests/release-prs.test.vitest.mts +10 -0
  52. package/tests/router.test.vitest.mts +108 -2
  53. package/tests/secret-store.test.vitest.mts +64 -3
  54. package/tests/secrets.test.vitest.mts +53 -0
  55. package/tests/security-alerts.test.vitest.mts +10 -0
  56. package/tests/server.test.vitest.mts +187 -7
  57. package/tests/smoke.test.vitest.mts +177 -0
  58. package/tests/system.test.vitest.mts +355 -0
  59. package/tests/verb-helpers.test.vitest.mts +46 -0
  60. package/.configs/fix-headers.json +0 -3
  61. package/.configs/vitest.config.mjs +0 -44
  62. package/.github/dependabot.yml +0 -111
  63. package/.github/workflows/branch-retention.yml +0 -44
  64. package/.github/workflows/bundle-size.yml +0 -52
  65. package/.github/workflows/ci.yml +0 -389
  66. package/.github/workflows/cla.yml +0 -66
  67. package/.github/workflows/codeql.yml +0 -87
  68. package/.github/workflows/dependabot-auto-merge.yml +0 -62
  69. package/.github/workflows/dependabot-recreate.yml +0 -67
  70. package/.github/workflows/dependency-review.yml +0 -46
  71. package/.github/workflows/feature-pr.yml +0 -75
  72. package/.github/workflows/hotfix-redirector.yml +0 -67
  73. package/.github/workflows/hotfixes-release.yml +0 -70
  74. package/.github/workflows/labeler.yml +0 -48
  75. package/.github/workflows/master-commit-audit.yml +0 -56
  76. package/.github/workflows/member-auto-merge.yml +0 -75
  77. package/.github/workflows/next-release.yml +0 -76
  78. package/.github/workflows/next-reset.yml +0 -50
  79. package/.github/workflows/pr-notify.yml +0 -44
  80. package/.github/workflows/pr-title-normalizer.yml +0 -52
  81. package/.github/workflows/provenance.yml +0 -51
  82. package/.github/workflows/publish.yml +0 -146
  83. package/.github/workflows/release-merge.yml +0 -104
  84. package/.github/workflows/release-notify.yml +0 -41
  85. package/.github/workflows/scorecard.yml +0 -64
  86. package/.github/workflows/stale.yml +0 -50
  87. package/.github/workflows/tag-health.yml +0 -70
  88. package/.github/workflows/update-major-version-tags.yml +0 -94
  89. package/.github/workflows/v4-bootstrap.yml +0 -115
  90. package/.github/workflows/welcome.yml +0 -42
@@ -172,6 +172,16 @@ describe("reportSecurityAlerts — org-level reads", () => {
172
172
  expect(out.alerts.find((a) => a.type === "secrets")).toMatchObject({ severity: "open", summary: "" });
173
173
  });
174
174
 
175
+ it("leaves the location off a code scanning alert whose instance has no path", async () => {
176
+ const bare = { number: 7, html_url: "u", created_at: "t", repository: { full_name: "CLDMV/a" } };
177
+ const octokit = octokitWith(async (route) => ({
178
+ data: route === "GET /orgs/{owner}/code-scanning/alerts" ? [{ ...bare, most_recent_instance: { location: {} } }, { ...bare, number: 8 }] : [],
179
+ headers: {}
180
+ }));
181
+ const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
182
+ for (const a of out.alerts.filter((x) => x.type === "codeql")) expect(a).not.toHaveProperty("location");
183
+ });
184
+
175
185
  it("flags max-alerts when a type fills its alertLimit", async () => {
176
186
  const octokit = octokitWith(async (route) => ({ data: route.includes("dependabot") ? [dependabot(1, { repository: { full_name: "CLDMV/a" } }), dependabot(2, { repository: { full_name: "CLDMV/a" } })] : [], headers: {} }));
177
187
  const out = await reportSecurityAlerts(octokit, { owner: "cldmv", alertLimit: 2 });
@@ -1,12 +1,77 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { connect, type Socket } from "node:net";
3
+ import { mkdirSync, rmSync, statSync, writeFileSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
2
6
  import { startServer } from "../src/server.ts";
3
7
 
4
- // startServer owns a long-lived net.Server it never hands back, so a successful
5
- // listen can't be cleanly torn down from a unit test. We exercise the safe,
6
- // non-listening paths only: the unsupported-scheme guard (synchronous throw) and
7
- // a listen that fails (bind into a non-existent directory) → promise rejects.
8
- const fakeRouter: any = { call: () => Promise.resolve({ content: [] }) };
9
- const catalog: any[] = [];
8
+ // Real node:fs, except readFileSync is a spy so the /etc/group lookup can be forced to fail.
9
+ vi.mock("node:fs", async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import("node:fs")>();
11
+ return { ...actual, readFileSync: vi.fn(actual.readFileSync) };
12
+ });
13
+
14
+ // Real node:net, except createServer remembers the server-side sockets so a test can inject events on them.
15
+ const seen = vi.hoisted(() => ({ sockets: [] as any[] }));
16
+ vi.mock("node:net", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import("node:net")>();
18
+ return {
19
+ ...actual,
20
+ createServer: (listener: (s: any) => void) =>
21
+ actual.createServer((s) => {
22
+ seen.sockets.push(s);
23
+ listener(s);
24
+ })
25
+ };
26
+ });
27
+
28
+ const root = join(process.cwd(), "tmp", "tests", "server");
29
+ const fakeRouter: any = { call: vi.fn(async (name: string, args: unknown) => ({ content: [{ type: "text", text: `${name}:${JSON.stringify(args)}` }] })) };
30
+ const catalog: any[] = [{ name: "get_me", description: "me", inputSchema: { type: "object" } }];
31
+ const sockets: Socket[] = [];
32
+
33
+ function sockPath(name: string): string {
34
+ mkdirSync(root, { recursive: true });
35
+ rmSync(join(root, name), { force: true }); // a socket left behind by an earlier run
36
+ return join(root, name);
37
+ }
38
+
39
+ /** Open a client connection and return a request/response helper over newline-delimited JSON-RPC. */
40
+ async function client(path: string) {
41
+ const sock: Socket = await new Promise((resolve, reject) => {
42
+ const s = connect(path, () => resolve(s));
43
+ s.once("error", reject);
44
+ });
45
+ sockets.push(sock);
46
+ let buf = "";
47
+ const waiting = new Map<number, (m: any) => void>();
48
+ sock.on("data", (d) => {
49
+ buf += d.toString("utf8");
50
+ let nl;
51
+ while ((nl = buf.indexOf("\n")) >= 0) {
52
+ const msg = JSON.parse(buf.slice(0, nl));
53
+ buf = buf.slice(nl + 1);
54
+ waiting.get(msg.id)?.(msg);
55
+ }
56
+ });
57
+ let id = 0;
58
+ return {
59
+ sock,
60
+ request: (method: string, params: unknown) =>
61
+ new Promise<any>((resolve) => {
62
+ const i = ++id;
63
+ waiting.set(i, resolve);
64
+ sock.write(JSON.stringify({ jsonrpc: "2.0", id: i, method, params }) + "\n");
65
+ })
66
+ };
67
+ }
68
+
69
+ afterEach(() => {
70
+ vi.restoreAllMocks();
71
+ for (const s of sockets.splice(0)) s.destroy();
72
+ delete process.env.GH_BROKER_SOCKET_GROUP;
73
+ vi.mocked(readFileSync).mockClear();
74
+ });
10
75
 
11
76
  describe("startServer", () => {
12
77
  it("throws synchronously for a non-unix listen scheme", () => {
@@ -17,4 +82,119 @@ describe("startServer", () => {
17
82
  const config: any = { listen: "unix:/no/such/dir/gh-broker.sock" };
18
83
  await expect(startServer(config, fakeRouter, catalog)).rejects.toBeTruthy();
19
84
  });
85
+
86
+ it("serves tools/list and tools/call over the socket, then drops the connection cleanly", async () => {
87
+ const path = sockPath("serve.sock");
88
+ await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
89
+ expect(statSync(path).mode & 0o777).toBe(0o660);
90
+
91
+ const c = await client(path);
92
+ const init = await c.request("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "1" } });
93
+ expect(init.result.serverInfo.name).toBe("gh-broker");
94
+ const listed = await c.request("tools/list", {});
95
+ expect(listed.result.tools).toEqual(catalog);
96
+ const called = await c.request("tools/call", { name: "get_me", arguments: { a: 1 } });
97
+ expect(called.result.content[0].text).toBe('get_me:{"a":1}');
98
+ // arguments omitted → router receives {}
99
+ const noArgs = await c.request("tools/call", { name: "get_me" });
100
+ expect(noArgs.result.content[0].text).toBe("get_me:{}");
101
+
102
+ c.sock.destroy();
103
+ await new Promise((r) => setTimeout(r, 50));
104
+ });
105
+
106
+ it("replaces a stale socket file left by a previous run", async () => {
107
+ const path = sockPath("stale.sock");
108
+ writeFileSync(path, "");
109
+ await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
110
+ const c = await client(path);
111
+ expect((await c.request("tools/list", {})).result.tools).toEqual(catalog);
112
+ });
113
+
114
+ it("applies the configured socket group when it resolves", async () => {
115
+ const path = sockPath("group.sock");
116
+ // the process's own primary group always resolves and is chown-able by the owner
117
+ const gid = process.getgid!();
118
+ const groupLine = `testgrp:x:${gid}:\n`;
119
+ vi.mocked(readFileSync).mockImplementationOnce(((p: any, ...rest: any[]) => (p === "/etc/group" ? groupLine : undefined)) as any);
120
+ process.env.GH_BROKER_SOCKET_GROUP = "testgrp";
121
+ await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
122
+ expect(statSync(path).gid).toBe(gid);
123
+ });
124
+
125
+ it("still listens when the group is unknown or /etc/group is unreadable", async () => {
126
+ process.env.GH_BROKER_SOCKET_GROUP = "no-such-group-xyz";
127
+ await startServer({ listen: `unix:${sockPath("nogroup.sock")}` } as any, fakeRouter, catalog);
128
+
129
+ vi.mocked(readFileSync).mockImplementationOnce((() => {
130
+ throw new Error("EACCES");
131
+ }) as any);
132
+ await startServer({ listen: `unix:${sockPath("unreadable.sock")}` } as any, fakeRouter, catalog);
133
+ });
134
+
135
+ it("tolerates a failed chown (group the process cannot assign)", async () => {
136
+ const path = sockPath("chownfail.sock");
137
+ // gid 0 (root) is not assignable by an unprivileged owner → chownSync throws, which is swallowed
138
+ vi.mocked(readFileSync).mockImplementationOnce((() => "rootish:x:0:\n") as any);
139
+ process.env.GH_BROKER_SOCKET_GROUP = "rootish";
140
+ await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
141
+ const c = await client(path);
142
+ expect((await c.request("tools/list", {})).result.tools).toEqual(catalog);
143
+ });
144
+
145
+ it("survives garbage from a client without taking the server down", async () => {
146
+ const path = sockPath("garbage.sock");
147
+ await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
148
+ const bad = await client(path);
149
+ bad.sock.write("not json\n");
150
+ await new Promise((r) => setTimeout(r, 50));
151
+ bad.sock.destroy();
152
+ const good = await client(path);
153
+ expect((await good.request("tools/list", {})).result.tools).toEqual(catalog);
154
+ });
155
+
156
+ it("ignores an error on a client socket", async () => {
157
+ const path = sockPath("hangup.sock");
158
+ await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
159
+ const c = await client(path);
160
+ await c.request("tools/list", {});
161
+ const serverSide = seen.sockets.at(-1);
162
+ expect(() => serverSide.emit("error", new Error("ECONNRESET"))).not.toThrow();
163
+ expect((await c.request("tools/list", {})).result.tools).toEqual(catalog);
164
+ });
165
+
166
+ it("tolerates a failing MCP close when the client goes away", async () => {
167
+ const path = sockPath("closefail.sock");
168
+ await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
169
+ const close = vi.spyOn(McpServer.prototype, "close").mockRejectedValue(new Error("close failed"));
170
+ const c = await client(path);
171
+ await c.request("tools/list", {});
172
+ c.sock.destroy();
173
+ await vi.waitFor(() => expect(close).toHaveBeenCalled());
174
+ });
175
+
176
+ it("logs and drops a connection whose MCP transport fails to start", async () => {
177
+ const path = sockPath("connectfail.sock");
178
+ await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
179
+ vi.spyOn(McpServer.prototype, "connect").mockRejectedValue(new Error("transport broke"));
180
+ const err = vi.spyOn(console, "error").mockImplementation(() => {});
181
+ const c = await client(path);
182
+ await new Promise<void>((resolve) => c.sock.once("close", () => resolve()));
183
+ expect(err).toHaveBeenCalledWith("gh-broker: connection error: transport broke");
184
+ });
185
+
186
+ it("chowns with uid -1 when process.getuid is unavailable", async () => {
187
+ const orig = process.getuid;
188
+ const path = sockPath("nouid.sock");
189
+ const gid = process.getgid!();
190
+ vi.mocked(readFileSync).mockImplementationOnce(((p: any) => (p === "/etc/group" ? `testgrp:x:${gid}:\n` : undefined)) as any);
191
+ process.env.GH_BROKER_SOCKET_GROUP = "testgrp";
192
+ (process as any).getuid = undefined;
193
+ try {
194
+ await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
195
+ } finally {
196
+ process.getuid = orig;
197
+ }
198
+ expect(statSync(path).mode & 0o777).toBe(0o660);
199
+ });
20
200
  });
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { createServer, type Server, type Socket } from "node:net";
3
+ import { spawn } from "node:child_process";
4
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { smokeTest } from "../deploy/lib/smoke.mjs";
7
+
8
+ // Real node:net, except connect() lets a test grab the client socket (to inject an error on it).
9
+ const hook = vi.hoisted(() => ({ onSock: null as null | ((s: any) => void) }));
10
+ vi.mock("node:net", async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import("node:net")>();
12
+ return {
13
+ ...actual,
14
+ connect: (...a: any[]) => {
15
+ const sock = (actual.connect as any)(...a);
16
+ hook.onSock?.(sock);
17
+ return sock;
18
+ }
19
+ };
20
+ });
21
+
22
+ const root = join(process.cwd(), "tmp", "tests", "smoke");
23
+ mkdirSync(root, { recursive: true });
24
+ let n = 0;
25
+ const servers: Server[] = [];
26
+ const conns: Socket[] = [];
27
+
28
+ type Handler = (msg: any, sock: Socket) => void;
29
+
30
+ /** A fake broker on a real unix socket; `onMsg` answers each newline-delimited JSON-RPC message. */
31
+ function fakeBroker(path: string, onMsg: Handler, onConn?: (s: Socket) => void): Promise<Server> {
32
+ return new Promise((resolve) => {
33
+ const srv = createServer((sock) => {
34
+ conns.push(sock);
35
+ if (onConn) return onConn(sock);
36
+ let buf = "";
37
+ sock.on("data", (d) => {
38
+ buf += d.toString("utf8");
39
+ let nl;
40
+ while ((nl = buf.indexOf("\n")) >= 0) {
41
+ const line = buf.slice(0, nl);
42
+ buf = buf.slice(nl + 1);
43
+ onMsg(JSON.parse(line), sock);
44
+ }
45
+ });
46
+ sock.on("error", () => {});
47
+ });
48
+ servers.push(srv);
49
+ srv.listen(path, () => resolve(srv));
50
+ });
51
+ }
52
+
53
+ const reply = (sock: Socket, id: number, result: unknown) => sock.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
54
+ const replyErr = (sock: Socket, id: number, error: unknown) => sock.write(JSON.stringify({ jsonrpc: "2.0", id, error }) + "\n");
55
+ const path = () => {
56
+ const p = join(root, `s${++n}.sock`);
57
+ rmSync(p, { force: true }); // a socket left behind by an earlier run
58
+ return p;
59
+ };
60
+
61
+ afterEach(async () => {
62
+ for (const c of conns.splice(0)) c.destroy();
63
+ await Promise.all(servers.splice(0).map((s) => new Promise((r) => s.close(() => r(null)))));
64
+ });
65
+
66
+ describe("smokeTest", () => {
67
+ it("handshakes, lists tools and round-trips get_me", async () => {
68
+ const p = path();
69
+ await fakeBroker(p, (m, s) => {
70
+ if (m.method === "initialize") reply(s, m.id, {});
71
+ else if (m.method === "tools/list") reply(s, m.id, { tools: [{ name: "get_me" }, { name: "other" }] });
72
+ else if (m.method === "tools/call") reply(s, m.id, { content: [] });
73
+ });
74
+ expect(await smokeTest(p)).toEqual({ tools: ["get_me", "other"], roundTrip: "get_me ok" });
75
+ });
76
+
77
+ it("reports an isError get_me, and a failing get_me", async () => {
78
+ const p1 = path();
79
+ await fakeBroker(p1, (m, s) => {
80
+ if (m.method === "tools/list") reply(s, m.id, { tools: [{ name: "get_me" }] });
81
+ else if (m.method === "tools/call") reply(s, m.id, { isError: true });
82
+ else if (m.id) reply(s, m.id, {});
83
+ });
84
+ expect((await smokeTest(p1)).roundTrip).toBe("get_me returned an error");
85
+
86
+ const p2 = path();
87
+ await fakeBroker(p2, (m, s) => {
88
+ if (m.method === "tools/list") reply(s, m.id, { tools: [{ name: "get_me" }] });
89
+ else if (m.method === "tools/call") replyErr(s, m.id, { message: "denied" });
90
+ else if (m.id) reply(s, m.id, {});
91
+ });
92
+ expect((await smokeTest(p2)).roundTrip).toBe("get_me failed: denied");
93
+ });
94
+
95
+ it("skips the round trip when get_me is not advertised, tolerating an empty list result", async () => {
96
+ const p = path();
97
+ await fakeBroker(p, (m, s) => {
98
+ if (m.method === "tools/list") reply(s, m.id, {});
99
+ else if (m.id) reply(s, m.id, {});
100
+ });
101
+ expect(await smokeTest(p)).toEqual({ tools: [], roundTrip: null });
102
+ });
103
+
104
+ it("ignores blank lines, garbage and unknown ids, and reassembles split lines", async () => {
105
+ const p = path();
106
+ await fakeBroker(p, (m, s) => {
107
+ if (m.method === "initialize") {
108
+ s.write("\n \nnot-json\n");
109
+ s.write(JSON.stringify({ jsonrpc: "2.0", id: 999, result: {} }) + "\n");
110
+ const full = JSON.stringify({ jsonrpc: "2.0", id: m.id, result: {} }) + "\n";
111
+ s.write(full.slice(0, 5));
112
+ setTimeout(() => s.write(full.slice(5)), 20);
113
+ } else if (m.method === "tools/list") reply(s, m.id, { tools: [] });
114
+ });
115
+ expect((await smokeTest(p)).tools).toEqual([]);
116
+ });
117
+
118
+ it("rejects with the rpc error message, falling back to a generic one", async () => {
119
+ const p1 = path();
120
+ await fakeBroker(p1, (m, s) => replyErr(s, m.id, { message: "bad init" }));
121
+ await expect(smokeTest(p1)).rejects.toThrow("bad init");
122
+
123
+ const p2 = path();
124
+ await fakeBroker(p2, (m, s) => replyErr(s, m.id, {}));
125
+ await expect(smokeTest(p2)).rejects.toThrow("rpc error");
126
+ });
127
+
128
+ it("retries until the socket appears", async () => {
129
+ const p = path();
130
+ const pending = smokeTest(p, { timeoutMs: 5000 });
131
+ await new Promise((r) => setTimeout(r, 400)); // first attempts hit ENOENT
132
+ await fakeBroker(p, (m, s) => {
133
+ if (m.method === "tools/list") reply(s, m.id, { tools: [] });
134
+ else if (m.id) reply(s, m.id, {});
135
+ });
136
+ expect((await pending).tools).toEqual([]);
137
+ });
138
+
139
+ it("retries on ECONNREFUSED (stale socket file with no listener) until the deadline", async () => {
140
+ const p = path();
141
+ // a child binds the socket and is SIGKILLed, leaving the socket file behind with nobody listening
142
+ const child = spawn(process.execPath, ["-e", `require("net").createServer().listen(${JSON.stringify(p)}, () => console.log("up"))`], {
143
+ stdio: ["ignore", "pipe", "inherit"]
144
+ });
145
+ await new Promise((r) => child.stdout.once("data", r));
146
+ child.kill("SIGKILL");
147
+ await new Promise((r) => child.once("exit", r));
148
+ await expect(smokeTest(p, { timeoutMs: 600 })).rejects.toThrow(/smoke: socket error .*ECONNREFUSED/);
149
+ });
150
+
151
+ it("gives up once the deadline passes", async () => {
152
+ await expect(smokeTest(path(), { timeoutMs: 300 })).rejects.toThrow(/smoke: socket error .*ENOENT/);
153
+ });
154
+
155
+ it("fails immediately on a non-retryable connect error", async () => {
156
+ const file = join(root, "plainfile");
157
+ writeFileSync(file, "x");
158
+ await expect(smokeTest(join(file, "child.sock"), { timeoutMs: 5000 })).rejects.toThrow(/smoke: socket error/);
159
+ });
160
+
161
+ it("rejects when the broker never answers", async () => {
162
+ const p = path();
163
+ await fakeBroker(p, () => {});
164
+ await expect(smokeTest(p, { timeoutMs: 100 })).rejects.toThrow("smoke: no response before deadline");
165
+ });
166
+
167
+ it("rejects when the socket errors mid-handshake", async () => {
168
+ const p = path();
169
+ await fakeBroker(p, () => {});
170
+ hook.onSock = (sock) => sock.once("connect", () => setTimeout(() => sock.emit("error", new Error("boom")), 30));
171
+ try {
172
+ await expect(smokeTest(p)).rejects.toThrow("smoke: socket error: boom");
173
+ } finally {
174
+ hook.onSock = null;
175
+ }
176
+ });
177
+ });