@openparachute/hub 0.5.14-rc.13 → 0.5.14-rc.15
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.
- package/package.json +1 -1
- package/src/__tests__/account-home-ui.test.ts +23 -1
- package/src/__tests__/expose-cloudflare.test.ts +225 -0
- package/src/__tests__/hub.test.ts +74 -6
- package/src/__tests__/lifecycle.test.ts +153 -0
- package/src/account-home-ui.ts +104 -39
- package/src/commands/expose-cloudflare.ts +249 -3
- package/src/commands/lifecycle.ts +192 -11
- package/src/hub.ts +72 -11
package/package.json
CHANGED
|
@@ -57,6 +57,23 @@ describe("renderAccountHome", () => {
|
|
|
57
57
|
expect(html).not.toContain("Authorization: Bearer");
|
|
58
58
|
// Copy-button progressive-enhancement script is present.
|
|
59
59
|
expect(html).toContain("navigator.clipboard");
|
|
60
|
+
// Friendlier framing: the block leads with "connect your AI assistant"
|
|
61
|
+
// rather than MCP jargon up top.
|
|
62
|
+
expect(html).toContain('data-testid="connect-ai-heading"');
|
|
63
|
+
expect(html).toContain("Connect your AI");
|
|
64
|
+
// BOTH connect methods render as distinct, labelled blocks.
|
|
65
|
+
expect(html).toContain('data-testid="connect-method-claude-code"');
|
|
66
|
+
expect(html).toContain("Claude Code");
|
|
67
|
+
expect(html).toContain('data-testid="connect-method-claude-ai"');
|
|
68
|
+
expect(html).toContain("Claude.ai");
|
|
69
|
+
// The Claude.ai path mirrors the install.njk canonical phrasing
|
|
70
|
+
// (Settings → Connectors → Add custom connector, paste the endpoint).
|
|
71
|
+
expect(html).toContain("Connectors");
|
|
72
|
+
expect(html).toContain("Add custom connector");
|
|
73
|
+
// A brief "any other MCP client" line is present (no bloat — just one).
|
|
74
|
+
expect(html).toContain('data-testid="connect-any-client-hint"');
|
|
75
|
+
// Notes CTA still present, now framed as the browser-UI option.
|
|
76
|
+
expect(html).toContain('data-testid="open-notes-cta"');
|
|
60
77
|
});
|
|
61
78
|
|
|
62
79
|
test("assigned-vault branch — trailing slash on hubOrigin is normalized", () => {
|
|
@@ -112,11 +129,16 @@ describe("renderAccountHome", () => {
|
|
|
112
129
|
twoFactorEnabled: false,
|
|
113
130
|
});
|
|
114
131
|
expect(html).toContain("Welcome, ghost");
|
|
115
|
-
|
|
132
|
+
// The message explains WHY there's nothing to connect (no vault yet) and
|
|
133
|
+
// gives a clear next step — not just a bare "ask your admin".
|
|
134
|
+
expect(html).toContain("Ask the hub operator to assign you a vault");
|
|
135
|
+
expect(html).toContain("don't have a vault yet");
|
|
116
136
|
// No /admin/ link in this branch — they have no admin role.
|
|
117
137
|
expect(html).not.toContain('href="/admin/"');
|
|
118
138
|
// No Notes CTA.
|
|
119
139
|
expect(html).not.toContain("notes.parachute.computer/add");
|
|
140
|
+
// No connect block — you can't connect a vault you don't have.
|
|
141
|
+
expect(html).not.toContain('data-testid="mcp-connect"');
|
|
120
142
|
});
|
|
121
143
|
|
|
122
144
|
test("account card — change-password link and sign-out form are present", () => {
|
|
@@ -551,6 +551,191 @@ describe("exposeCloudflareUp", () => {
|
|
|
551
551
|
}
|
|
552
552
|
});
|
|
553
553
|
|
|
554
|
+
test("hub#487: kills orphan connectors found by pgrep before spawning, not just the state pid", async () => {
|
|
555
|
+
// The orphan-accumulation bug: each re-expose spawned a fresh connector
|
|
556
|
+
// without killing prior ones, and state only tracked the most-recent pid.
|
|
557
|
+
// Orphans the state file lost track of (crashed mid-rewrite, started by
|
|
558
|
+
// hand) must still be swept — `connectorPids` finds them by UUID/config
|
|
559
|
+
// path. Here state knows pid 99999, but pgrep also surfaces 88888 + 77777
|
|
560
|
+
// serving the same tunnel; all three get SIGTERM before the new spawn.
|
|
561
|
+
const env = makeEnv();
|
|
562
|
+
try {
|
|
563
|
+
const uuid = "cccccccc-0000-0000-0000-000000000003";
|
|
564
|
+
const priorRecord: CloudflaredTunnelRecord = {
|
|
565
|
+
pid: 99999,
|
|
566
|
+
tunnelUuid: uuid,
|
|
567
|
+
tunnelName: "parachute",
|
|
568
|
+
hostname: "vault.example.com",
|
|
569
|
+
startedAt: "2026-04-21T00:00:00.000Z",
|
|
570
|
+
configPath: env.configPath,
|
|
571
|
+
};
|
|
572
|
+
writeCloudflaredState({ version: 2, tunnels: { parachute: priorRecord } }, env.statePath);
|
|
573
|
+
|
|
574
|
+
const { runner } = queueRunner([
|
|
575
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
576
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
577
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
578
|
+
]);
|
|
579
|
+
const { spawner, seen } = fakeSpawner(42010);
|
|
580
|
+
const killed: number[] = [];
|
|
581
|
+
|
|
582
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
583
|
+
runner,
|
|
584
|
+
spawner,
|
|
585
|
+
alive: () => true, // all candidate pids report alive
|
|
586
|
+
kill: (pid) => killed.push(pid),
|
|
587
|
+
// pgrep surfaces two orphans the state record didn't track.
|
|
588
|
+
connectorPids: () => [88888, 77777],
|
|
589
|
+
resolveHost: async () => ["104.16.0.1"], // Cloudflare — no DNS warning
|
|
590
|
+
log: () => {},
|
|
591
|
+
manifestPath: env.manifestPath,
|
|
592
|
+
statePath: env.statePath,
|
|
593
|
+
exposeStatePath: env.exposeStatePath,
|
|
594
|
+
configPath: env.configPath,
|
|
595
|
+
logPath: env.logPath,
|
|
596
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
597
|
+
configDir: env.configDir,
|
|
598
|
+
skipHub: true,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
expect(code).toBe(0);
|
|
602
|
+
// Every prior connector (state pid + both pgrep orphans) is stopped
|
|
603
|
+
// before the new one spawns.
|
|
604
|
+
expect(killed.sort()).toEqual([77777, 88888, 99999]);
|
|
605
|
+
// Exactly one fresh connector spawned, and it's the one recorded.
|
|
606
|
+
expect(seen).toHaveLength(1);
|
|
607
|
+
expect(findTunnelRecord(readCloudflaredState(env.statePath), "parachute")?.pid).toBe(42010);
|
|
608
|
+
} finally {
|
|
609
|
+
env.cleanup();
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("hub#487: warns when DNS doesn't resolve yet (pending zone)", async () => {
|
|
614
|
+
// route dns succeeded but the hostname doesn't resolve — the "pending"
|
|
615
|
+
// zone shape (NS not switched at the registrar). Non-fatal: still exit 0,
|
|
616
|
+
// still print the URLs, but add the nameserver-switch nudge.
|
|
617
|
+
const env = makeEnv();
|
|
618
|
+
try {
|
|
619
|
+
const uuid = "dddddddd-0000-0000-0000-000000000004";
|
|
620
|
+
const { runner } = queueRunner([
|
|
621
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
622
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
623
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
624
|
+
]);
|
|
625
|
+
const { spawner } = fakeSpawner(42020);
|
|
626
|
+
const logs: string[] = [];
|
|
627
|
+
|
|
628
|
+
const code = await exposeCloudflareUp("vault.newzone.com", {
|
|
629
|
+
runner,
|
|
630
|
+
spawner,
|
|
631
|
+
alive: () => false,
|
|
632
|
+
kill: () => {},
|
|
633
|
+
connectorPids: () => [],
|
|
634
|
+
resolveHost: async () => [], // NXDOMAIN / not live yet
|
|
635
|
+
log: (l) => logs.push(l),
|
|
636
|
+
manifestPath: env.manifestPath,
|
|
637
|
+
statePath: env.statePath,
|
|
638
|
+
exposeStatePath: env.exposeStatePath,
|
|
639
|
+
configPath: env.configPath,
|
|
640
|
+
logPath: env.logPath,
|
|
641
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
642
|
+
configDir: env.configDir,
|
|
643
|
+
skipHub: true,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
expect(code).toBe(0); // non-fatal — the expose still completes
|
|
647
|
+
const joined = logs.join("\n");
|
|
648
|
+
expect(joined).toContain("DNS isn't live yet for vault.newzone.com");
|
|
649
|
+
expect(joined).toContain("dig +short newzone.com NS");
|
|
650
|
+
expect(joined).toContain("ns.cloudflare.com");
|
|
651
|
+
// The success URLs still print.
|
|
652
|
+
expect(joined).toContain("https://vault.newzone.com/admin/");
|
|
653
|
+
} finally {
|
|
654
|
+
env.cleanup();
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("hub#487: warns when hostname resolves but not to Cloudflare (shadowed)", async () => {
|
|
659
|
+
// route dns succeeded but the hostname resolves to a non-Cloudflare IP —
|
|
660
|
+
// a Pages project / grey-cloud A record shadowing the tunnel → edge 404.
|
|
661
|
+
const env = makeEnv();
|
|
662
|
+
try {
|
|
663
|
+
const uuid = "eeeeeeee-0000-0000-0000-000000000006";
|
|
664
|
+
const { runner } = queueRunner([
|
|
665
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
666
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
667
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
668
|
+
]);
|
|
669
|
+
const { spawner } = fakeSpawner(42021);
|
|
670
|
+
const logs: string[] = [];
|
|
671
|
+
|
|
672
|
+
const code = await exposeCloudflareUp("docs.parachute.computer", {
|
|
673
|
+
runner,
|
|
674
|
+
spawner,
|
|
675
|
+
alive: () => false,
|
|
676
|
+
kill: () => {},
|
|
677
|
+
connectorPids: () => [],
|
|
678
|
+
resolveHost: async () => ["203.0.113.10"], // not a Cloudflare range
|
|
679
|
+
log: (l) => logs.push(l),
|
|
680
|
+
manifestPath: env.manifestPath,
|
|
681
|
+
statePath: env.statePath,
|
|
682
|
+
exposeStatePath: env.exposeStatePath,
|
|
683
|
+
configPath: env.configPath,
|
|
684
|
+
logPath: env.logPath,
|
|
685
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
686
|
+
configDir: env.configDir,
|
|
687
|
+
skipHub: true,
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
expect(code).toBe(0);
|
|
691
|
+
const joined = logs.join("\n");
|
|
692
|
+
expect(joined).toContain("not to Cloudflare's edge");
|
|
693
|
+
expect(joined).toContain("shadowed");
|
|
694
|
+
expect(joined).toContain("Pages project");
|
|
695
|
+
} finally {
|
|
696
|
+
env.cleanup();
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("hub#487: no DNS warning when hostname resolves at Cloudflare's edge", async () => {
|
|
701
|
+
const env = makeEnv();
|
|
702
|
+
try {
|
|
703
|
+
const uuid = "ffffffff-0000-0000-0000-000000000007";
|
|
704
|
+
const { runner } = queueRunner([
|
|
705
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
706
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
707
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
708
|
+
]);
|
|
709
|
+
const { spawner } = fakeSpawner(42022);
|
|
710
|
+
const logs: string[] = [];
|
|
711
|
+
|
|
712
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
713
|
+
runner,
|
|
714
|
+
spawner,
|
|
715
|
+
alive: () => false,
|
|
716
|
+
kill: () => {},
|
|
717
|
+
connectorPids: () => [],
|
|
718
|
+
resolveHost: async () => ["104.18.32.7"], // 104.16.0.0/13 — Cloudflare
|
|
719
|
+
log: (l) => logs.push(l),
|
|
720
|
+
manifestPath: env.manifestPath,
|
|
721
|
+
statePath: env.statePath,
|
|
722
|
+
exposeStatePath: env.exposeStatePath,
|
|
723
|
+
configPath: env.configPath,
|
|
724
|
+
logPath: env.logPath,
|
|
725
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
726
|
+
configDir: env.configDir,
|
|
727
|
+
skipHub: true,
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
expect(code).toBe(0);
|
|
731
|
+
const joined = logs.join("\n");
|
|
732
|
+
expect(joined).not.toContain("DNS isn't live yet");
|
|
733
|
+
expect(joined).not.toContain("not to Cloudflare's edge");
|
|
734
|
+
} finally {
|
|
735
|
+
env.cleanup();
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
554
739
|
test("two tunnels with different --tunnel-name coexist in state", async () => {
|
|
555
740
|
const env = makeEnv();
|
|
556
741
|
try {
|
|
@@ -929,6 +1114,46 @@ describe("exposeCloudflareOff", () => {
|
|
|
929
1114
|
}
|
|
930
1115
|
});
|
|
931
1116
|
|
|
1117
|
+
test("hub#487: off sweeps orphan connectors the state record didn't track", async () => {
|
|
1118
|
+
const env = makeEnv();
|
|
1119
|
+
try {
|
|
1120
|
+
const uuid = "abababab-0000-0000-0000-000000000009";
|
|
1121
|
+
writeCloudflaredState(
|
|
1122
|
+
{
|
|
1123
|
+
version: 2,
|
|
1124
|
+
tunnels: {
|
|
1125
|
+
parachute: {
|
|
1126
|
+
pid: 55555,
|
|
1127
|
+
tunnelUuid: uuid,
|
|
1128
|
+
tunnelName: "parachute",
|
|
1129
|
+
hostname: "vault.example.com",
|
|
1130
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
1131
|
+
configPath: env.configPath,
|
|
1132
|
+
},
|
|
1133
|
+
},
|
|
1134
|
+
},
|
|
1135
|
+
env.statePath,
|
|
1136
|
+
);
|
|
1137
|
+
const killed: number[] = [];
|
|
1138
|
+
const code = await exposeCloudflareOff({
|
|
1139
|
+
statePath: env.statePath,
|
|
1140
|
+
exposeStatePath: env.exposeStatePath,
|
|
1141
|
+
alive: () => true,
|
|
1142
|
+
kill: (pid) => killed.push(pid),
|
|
1143
|
+
// pgrep finds the tracked pid (skipped — already signalled) plus an
|
|
1144
|
+
// untracked orphan 66666 serving the same tunnel.
|
|
1145
|
+
connectorPids: () => [55555, 66666],
|
|
1146
|
+
log: () => {},
|
|
1147
|
+
});
|
|
1148
|
+
expect(code).toBe(0);
|
|
1149
|
+
// Tracked pid stopped once, orphan also stopped — no double-kill of 55555.
|
|
1150
|
+
expect(killed.sort()).toEqual([55555, 66666]);
|
|
1151
|
+
expect(existsSync(env.statePath)).toBe(false);
|
|
1152
|
+
} finally {
|
|
1153
|
+
env.cleanup();
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
|
|
932
1157
|
test("targets the named tunnel and leaves siblings intact", async () => {
|
|
933
1158
|
const env = makeEnv();
|
|
934
1159
|
try {
|
|
@@ -5,7 +5,15 @@ import { join } from "node:path";
|
|
|
5
5
|
import { renderHub, writeHubFile } from "../hub.ts";
|
|
6
6
|
|
|
7
7
|
describe("renderHub", () => {
|
|
8
|
-
|
|
8
|
+
// The verbose discovery body (Get started / Services / Admin) + its
|
|
9
|
+
// data-loading script render only for a signed-in visitor (the signed-out
|
|
10
|
+
// landing is slimmed — see the "signed-out slimming" describe block below).
|
|
11
|
+
// Assertions about that verbose body therefore run against a signed-in
|
|
12
|
+
// render; assertions about the page shell (doctype, styles, brand) hold for
|
|
13
|
+
// both and use whichever render is convenient.
|
|
14
|
+
const html = renderHub({
|
|
15
|
+
session: { displayName: "operator", csrfToken: "csrf-shell" },
|
|
16
|
+
});
|
|
9
17
|
|
|
10
18
|
test("is a self-contained HTML document with inline styles and script", () => {
|
|
11
19
|
expect(html).toStartWith("<!doctype html>");
|
|
@@ -162,12 +170,72 @@ describe("renderHub", () => {
|
|
|
162
170
|
});
|
|
163
171
|
|
|
164
172
|
test("default render (no session) emits the 'Sign in' affordance", () => {
|
|
165
|
-
|
|
166
|
-
expect(
|
|
167
|
-
expect(
|
|
173
|
+
const out = renderHub();
|
|
174
|
+
expect(out).toContain('class="auth-indicator"');
|
|
175
|
+
expect(out).toContain("Sign in");
|
|
176
|
+
expect(out).toContain('href="/login?next=/"');
|
|
168
177
|
// No POST form, no CSRF input — those only appear when signed in.
|
|
169
|
-
expect(
|
|
170
|
-
expect(
|
|
178
|
+
expect(out).not.toContain('action="/logout"');
|
|
179
|
+
expect(out).not.toContain("__csrf");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("renderHub — signed-out slimming (operator feedback)", () => {
|
|
184
|
+
// A signed-out visitor should see a clean, minimal landing: brand +
|
|
185
|
+
// tagline (in the header) + a single clear "Sign in" call. The hub's
|
|
186
|
+
// internal detail — the service catalog, vault listings, admin surfaces,
|
|
187
|
+
// and the well-known-driven loading script — must NOT render until the
|
|
188
|
+
// visitor authenticates. The signed-in render is unchanged.
|
|
189
|
+
const signedOut = renderHub();
|
|
190
|
+
const signedIn = renderHub({
|
|
191
|
+
session: { displayName: "operator", csrfToken: "csrf-xyz" },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("signed-out: brand wordmark + tagline still render (the slim landing keeps the brand)", () => {
|
|
195
|
+
expect(signedOut).toContain("<h1>Parachute</h1>");
|
|
196
|
+
expect(signedOut).toContain("Truly personal computing. Your knowledge belongs with you.");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("signed-out: a clear 'Sign in' call is the primary affordance", () => {
|
|
200
|
+
expect(signedOut).toContain('data-testid="signed-out-signin"');
|
|
201
|
+
expect(signedOut).toContain('href="/login?next=/"');
|
|
202
|
+
expect(signedOut).toContain("Sign in");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("signed-out: the verbose Services / Admin / Get started sections are absent", () => {
|
|
206
|
+
expect(signedOut).not.toContain('id="services-section"');
|
|
207
|
+
expect(signedOut).not.toContain('id="admin-section"');
|
|
208
|
+
expect(signedOut).not.toContain('id="get-started-section"');
|
|
209
|
+
expect(signedOut).not.toContain("<h2>Services</h2>");
|
|
210
|
+
expect(signedOut).not.toContain("<h2>Admin</h2>");
|
|
211
|
+
// Admin links / token surface must not be exposed pre-auth.
|
|
212
|
+
expect(signedOut).not.toContain("/admin/vaults");
|
|
213
|
+
expect(signedOut).not.toContain("/admin/tokens");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("signed-out: the well-known service-catalog loading script is not emitted", () => {
|
|
217
|
+
// No data-driven discovery body to populate when signed out → no script.
|
|
218
|
+
// (The brand mark is an inline SVG, not a <script>; assert on the IIFE's
|
|
219
|
+
// load function rather than a blanket "no <script>".) The footer's
|
|
220
|
+
// public "discovery" anchor → /.well-known/parachute.json stays — it's a
|
|
221
|
+
// plain link, not the catalog-fetching script — so assert on the fetch
|
|
222
|
+
// call + the loader function, not the URL string.
|
|
223
|
+
expect(signedOut).not.toContain("loadServices");
|
|
224
|
+
expect(signedOut).not.toContain("renderServices");
|
|
225
|
+
expect(signedOut).not.toContain("fetch('/.well-known/parachute.json'");
|
|
226
|
+
expect(signedOut).not.toContain("<script>");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("signed-in: the verbose sections + loading script DO render (signed-in view unchanged)", () => {
|
|
230
|
+
expect(signedIn).toContain('id="services-section"');
|
|
231
|
+
expect(signedIn).toContain('id="admin-section"');
|
|
232
|
+
expect(signedIn).toContain('id="get-started-section"');
|
|
233
|
+
expect(signedIn).toContain("/admin/vaults");
|
|
234
|
+
expect(signedIn).toContain("/.well-known/parachute.json");
|
|
235
|
+
expect(signedIn).toContain("loadServices");
|
|
236
|
+
// And the signed-out lede / standalone Sign-in CTA is gone (the
|
|
237
|
+
// auth-indicator carries sign-out instead).
|
|
238
|
+
expect(signedIn).not.toContain('data-testid="signed-out-signin"');
|
|
171
239
|
});
|
|
172
240
|
});
|
|
173
241
|
|
|
@@ -783,6 +783,159 @@ describe("parachute start", () => {
|
|
|
783
783
|
h.cleanup();
|
|
784
784
|
}
|
|
785
785
|
});
|
|
786
|
+
|
|
787
|
+
// hub#487 — readiness gating beyond the bare liveness settle. Aaron hit this
|
|
788
|
+
// on a fresh EC2 box: `parachute start vault` printed "✓ vault started" while
|
|
789
|
+
// the process died ~instantly on EADDRINUSE (an orphan held 1940), and
|
|
790
|
+
// `parachute status` then showed it inactive.
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* A stub spawner that also seeds the service's log file with `content`, so
|
|
794
|
+
* the readiness-failure path's log-tail + EADDRINUSE detection can read a
|
|
795
|
+
* realistic boot error. Mirrors how the real spawner appends stdout/stderr
|
|
796
|
+
* to the logfile.
|
|
797
|
+
*/
|
|
798
|
+
function makeSpawnerWithLog(pid: number, content: string): SpawnerStub {
|
|
799
|
+
const calls: SpawnerStub["calls"] = [];
|
|
800
|
+
return {
|
|
801
|
+
calls,
|
|
802
|
+
spawn(cmd, logFile, opts) {
|
|
803
|
+
calls.push({ cmd: [...cmd], logFile, env: opts?.env, cwd: opts?.cwd });
|
|
804
|
+
// The start path calls ensureLogPath() before spawn, so logFile's
|
|
805
|
+
// parent dir already exists — just write the simulated boot output.
|
|
806
|
+
writeFileSync(logFile, content);
|
|
807
|
+
return pid;
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
test("hub#487: EADDRINUSE in the log → port-in-use message + log tail, not ✓", async () => {
|
|
813
|
+
const h = makeHarness();
|
|
814
|
+
try {
|
|
815
|
+
seedVault(h.manifestPath);
|
|
816
|
+
const spawner = makeSpawnerWithLog(
|
|
817
|
+
4242,
|
|
818
|
+
"booting vault…\nerror: listen EADDRINUSE: address already in use 0.0.0.0:1940\n",
|
|
819
|
+
);
|
|
820
|
+
const lines: string[] = [];
|
|
821
|
+
const code = await start("vault", {
|
|
822
|
+
configDir: h.configDir,
|
|
823
|
+
manifestPath: h.manifestPath,
|
|
824
|
+
spawner,
|
|
825
|
+
alive: () => false, // process died right after the EADDRINUSE throw
|
|
826
|
+
sleep: async () => {},
|
|
827
|
+
startSettleMs: 1,
|
|
828
|
+
log: (l) => lines.push(l),
|
|
829
|
+
});
|
|
830
|
+
expect(code).toBe(1);
|
|
831
|
+
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
832
|
+
const out = lines.join("\n");
|
|
833
|
+
expect(out).toMatch(/port 1940 is already in use/);
|
|
834
|
+
expect(out).toMatch(/lsof -ti:1940/);
|
|
835
|
+
// The real boot error is surfaced inline so the operator doesn't have to
|
|
836
|
+
// go tail the log themselves.
|
|
837
|
+
expect(out).toMatch(/EADDRINUSE/);
|
|
838
|
+
expect(out).not.toMatch(/✓ vault started/);
|
|
839
|
+
} finally {
|
|
840
|
+
h.cleanup();
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test("hub#487: process survives settle but never binds its port → failure with log tail", async () => {
|
|
845
|
+
const h = makeHarness();
|
|
846
|
+
try {
|
|
847
|
+
seedVault(h.manifestPath);
|
|
848
|
+
const spawner = makeSpawnerWithLog(4242, "vault crashed mid-boot\n");
|
|
849
|
+
const lines: string[] = [];
|
|
850
|
+
let aliveCalls = 0;
|
|
851
|
+
const code = await start("vault", {
|
|
852
|
+
configDir: h.configDir,
|
|
853
|
+
manifestPath: h.manifestPath,
|
|
854
|
+
spawner,
|
|
855
|
+
// Alive through the settle + first readiness poll, then dies — the
|
|
856
|
+
// slow-EADDRINUSE / crash-after-boot shape.
|
|
857
|
+
alive: () => {
|
|
858
|
+
aliveCalls++;
|
|
859
|
+
return aliveCalls <= 1;
|
|
860
|
+
},
|
|
861
|
+
sleep: async () => {},
|
|
862
|
+
startSettleMs: 1,
|
|
863
|
+
startReadyMs: 50,
|
|
864
|
+
startReadyPollMs: 1,
|
|
865
|
+
portListening: async () => false, // never binds
|
|
866
|
+
log: (l) => lines.push(l),
|
|
867
|
+
});
|
|
868
|
+
expect(code).toBe(1);
|
|
869
|
+
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
870
|
+
const out = lines.join("\n");
|
|
871
|
+
expect(out).toMatch(/✗ vault failed to start/);
|
|
872
|
+
expect(out).toMatch(/exited during startup/);
|
|
873
|
+
expect(out).not.toMatch(/✓ vault started/);
|
|
874
|
+
} finally {
|
|
875
|
+
h.cleanup();
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
test("hub#487: alive but port silent past the window → non-fatal warning, exit 0", async () => {
|
|
880
|
+
const h = makeHarness();
|
|
881
|
+
try {
|
|
882
|
+
seedVault(h.manifestPath);
|
|
883
|
+
const spawner = makeSpawner([4242]);
|
|
884
|
+
const lines: string[] = [];
|
|
885
|
+
const code = await start("vault", {
|
|
886
|
+
configDir: h.configDir,
|
|
887
|
+
manifestPath: h.manifestPath,
|
|
888
|
+
spawner,
|
|
889
|
+
alive: () => true, // stays up the whole time
|
|
890
|
+
sleep: async () => {},
|
|
891
|
+
startSettleMs: 1,
|
|
892
|
+
startReadyMs: 10,
|
|
893
|
+
startReadyPollMs: 1,
|
|
894
|
+
portListening: async () => false, // slow boot — not listening yet
|
|
895
|
+
log: (l) => lines.push(l),
|
|
896
|
+
});
|
|
897
|
+
// A slow-but-alive daemon isn't a hard failure — we warn rather than fail.
|
|
898
|
+
expect(code).toBe(0);
|
|
899
|
+
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
900
|
+
const out = lines.join("\n");
|
|
901
|
+
expect(out).toMatch(/port 1940 isn't accepting connections yet/);
|
|
902
|
+
expect(out).not.toMatch(/✓ vault started/);
|
|
903
|
+
} finally {
|
|
904
|
+
h.cleanup();
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("hub#487: alive + port listening → success", async () => {
|
|
909
|
+
const h = makeHarness();
|
|
910
|
+
try {
|
|
911
|
+
seedVault(h.manifestPath);
|
|
912
|
+
const spawner = makeSpawner([4242]);
|
|
913
|
+
const lines: string[] = [];
|
|
914
|
+
let probeCalls = 0;
|
|
915
|
+
const code = await start("vault", {
|
|
916
|
+
configDir: h.configDir,
|
|
917
|
+
manifestPath: h.manifestPath,
|
|
918
|
+
spawner,
|
|
919
|
+
alive: () => true,
|
|
920
|
+
sleep: async () => {},
|
|
921
|
+
startSettleMs: 1,
|
|
922
|
+
startReadyMs: 50,
|
|
923
|
+
startReadyPollMs: 1,
|
|
924
|
+
// Not listening on the first poll, bound on the second — exercises the
|
|
925
|
+
// poll loop rather than an instant true.
|
|
926
|
+
portListening: async () => {
|
|
927
|
+
probeCalls++;
|
|
928
|
+
return probeCalls >= 2;
|
|
929
|
+
},
|
|
930
|
+
log: (l) => lines.push(l),
|
|
931
|
+
});
|
|
932
|
+
expect(code).toBe(0);
|
|
933
|
+
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
934
|
+
expect(lines.join("\n")).toMatch(/✓ vault started \(pid 4242\)/);
|
|
935
|
+
} finally {
|
|
936
|
+
h.cleanup();
|
|
937
|
+
}
|
|
938
|
+
});
|
|
786
939
|
});
|
|
787
940
|
|
|
788
941
|
describe("parachute stop", () => {
|