rollbridge 0.1.49 → 0.1.55

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 (39) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +5 -0
  3. package/changelog.d/20260909120000-velocious-testing.md +1 -0
  4. package/docs/cli.md +7 -1
  5. package/docs/generation-deployment-contract.md +9 -0
  6. package/eslint.config.js +8 -0
  7. package/package.json +3 -2
  8. package/src/cli.js +10 -2
  9. package/src/daemon.js +102 -12
  10. package/src/process-guardian.js +5 -1
  11. package/src/release-group.js +48 -1
  12. package/test/completion.test.js +18 -16
  13. package/test/config-examples.test.js +16 -17
  14. package/test/config-path.test.js +10 -11
  15. package/test/config-validation.test.js +163 -167
  16. package/test/control-protocol.test.js +75 -14
  17. package/test/daemon-bootstrap.test.js +104 -104
  18. package/test/daemon-runtime.test.js +17 -26
  19. package/test/doctor.test.js +51 -49
  20. package/test/event-log.test.js +13 -11
  21. package/test/guardian-client.test.js +160 -145
  22. package/test/health.test.js +6 -4
  23. package/test/logs.test.js +23 -17
  24. package/test/managed-process.test.js +96 -91
  25. package/test/owner-recovery.test.js +254 -239
  26. package/test/owner-replacement.test.js +228 -223
  27. package/test/package-metadata.test.js +48 -39
  28. package/test/port-allocator.test.js +13 -16
  29. package/test/predeploy-cleanup.test.js +12 -10
  30. package/test/process-memory.test.js +17 -15
  31. package/test/proxy.test.js +10 -8
  32. package/test/recover.test.js +30 -23
  33. package/test/release-group.test.js +16 -17
  34. package/test/release-retention.test.js +10 -8
  35. package/test/release-runtime-retention.test.js +31 -39
  36. package/test/rollbridge.test.js +388 -395
  37. package/test/shutdown-completion.test.js +51 -51
  38. package/test/state-store.test.js +10 -8
  39. package/test/system-ids.test.js +15 -13
@@ -1,19 +1,20 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
3
  import {spawn} from "node:child_process"
5
4
  import {once} from "node:events"
6
5
  import fs from "node:fs/promises"
7
6
  import net from "node:net"
8
7
  import os from "node:os"
9
8
  import path from "node:path"
10
- import test from "node:test"
9
+ import {describe, expect, test} from "@velocious/testing"
11
10
  import {fileURLToPath} from "node:url"
12
11
  import {normalizeConfig} from "../src/config.js"
13
12
  import {sendControlCommand} from "../src/control-client.js"
14
13
  import RollbridgeDaemon from "../src/daemon.js"
15
14
  import {isProcessAlive, readState} from "../src/state-store.js"
16
15
 
16
+ describe("shutdown-completion", () => {
17
+
17
18
  const dummyAppPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "dummy-app.js")
18
19
 
19
20
  test("shutdown response waits for endpoint and owned-process cleanup before immediate replacement", async () => {
@@ -24,7 +25,7 @@ test("shutdown response waits for endpoint and owned-process cleanup before imme
24
25
  const stoppingPath = path.join(root, "stopping")
25
26
  const gate = spawn("mkfifo", [gatePath])
26
27
 
27
- assert.equal((await once(gate, "exit"))[0], 0)
28
+ expect((await once(gate, "exit"))[0]).toBe(0)
28
29
 
29
30
  const config = buildConfig(socketPath, {
30
31
  companion: {
@@ -52,7 +53,7 @@ test("shutdown response waits for endpoint and owned-process cleanup before imme
52
53
 
53
54
  const workerPid = daemon.activeRelease?.getProcess("worker")?.pid
54
55
 
55
- assert.equal(typeof workerPid, "number")
56
+ expect(typeof workerPid).toBe("number")
56
57
 
57
58
  const stopping = waitForFile(stoppingPath)
58
59
  let shutdownResolved = false
@@ -85,23 +86,28 @@ test("shutdown response waits for endpoint and owned-process cleanup before imme
85
86
 
86
87
  const response = await shutdown
87
88
 
88
- assert.equal(shutdownResolved, true)
89
- assert.equal(resolvedDuringStop, false, "shutdown must not acknowledge while an owned process is still stopping")
90
- assert.equal(oldEndpointAccepted, false, "the targeted endpoint must stop accepting new commands before cleanup")
91
- assert.equal(processAliveDuringStop, true, "the fixture must hold shutdown while its owned process is alive")
92
- assert.equal(idleTargetClosedDuringStop, true, "an idle accepted client must be closed when the targeted endpoint retires")
93
- assert.equal(idleUnrelatedClosedDuringStop, false, "an unrelated daemon's accepted clients must remain untouched")
94
- assert.deepEqual(response, {message: "shutdown", status: "success"})
95
- await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
96
- assert.equal(isProcessAlive(/** @type {number} */ (workerPid)), false)
89
+ expect(shutdownResolved).toBe(true)
90
+ // Shutdown must not acknowledge while an owned process is still stopping.
91
+ expect(resolvedDuringStop).toBe(false)
92
+ // The targeted endpoint must stop accepting new commands before cleanup.
93
+ expect(oldEndpointAccepted).toBe(false)
94
+ // The fixture must hold shutdown while its owned process is alive.
95
+ expect(processAliveDuringStop).toBe(true)
96
+ // An idle accepted client must be closed when the targeted endpoint retires.
97
+ expect(idleTargetClosedDuringStop).toBe(true)
98
+ // An unrelated daemon's accepted clients must remain untouched.
99
+ expect(idleUnrelatedClosedDuringStop).toBe(false)
100
+ expect(response).toEqual({message: "shutdown", status: "success"})
101
+ await expect(fs.stat(socketPath)).rejects.toMatchObject({code: "ENOENT"})
102
+ expect(isProcessAlive(/** @type {number} */ (workerPid))).toBe(false)
97
103
 
98
104
  // A different daemon remains reachable; shutdown is scoped to the targeted control endpoint.
99
- assert.equal((await sendControlCommand({command: {command: "status"}, path: unrelatedSocketPath})).application, "shutdown-unrelated")
105
+ expect((await sendControlCommand({command: {command: "status"}, path: unrelatedSocketPath})).application).toBe("shutdown-unrelated")
100
106
 
101
107
  // Replacement starts immediately, with no polling or retry between truthful ACK and bind.
102
108
  replacement = new RollbridgeDaemon({config, logger: () => {}})
103
109
  await replacement.start()
104
- assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).application, "shutdown-target")
110
+ expect((await sendControlCommand({command: {command: "status"}, path: socketPath})).application).toBe("shutdown-target")
105
111
  } finally {
106
112
  if (!gateReleased) {
107
113
  await fs.writeFile(gatePath, "continue\n").catch(() => {})
@@ -136,8 +142,8 @@ test("shutdown keeps daemon services alive until release-owned dependents stop",
136
142
  const release = daemon.activeRelease
137
143
  const coordinator = daemon.services.get("coordinator")
138
144
 
139
- assert.ok(release)
140
- assert.ok(coordinator)
145
+ if (!release) throw new Error("Missing required fixture: release")
146
+ if (!coordinator) throw new Error("Missing required fixture: coordinator")
141
147
 
142
148
  const originalReleaseStop = release.stop.bind(release)
143
149
  const originalCoordinatorStop = coordinator.stop.bind(coordinator)
@@ -161,7 +167,8 @@ test("shutdown keeps daemon services alive until release-owned dependents stop",
161
167
  releaseWorker()
162
168
  await shutdown
163
169
 
164
- assert.equal(serviceStoppedWhileWorkerWasDraining, false, "a worker must retain access to daemon services throughout its drain")
170
+ // A worker must retain access to daemon services throughout its drain.
171
+ expect(serviceStoppedWhileWorkerWasDraining).toBe(false)
165
172
  } finally {
166
173
  releaseWorker()
167
174
  await daemon.shutdown()
@@ -175,7 +182,7 @@ test("external-owner retirement releases listeners before a long-draining compan
175
182
  const gatePath = path.join(root, "retire.fifo")
176
183
  const stoppingPath = path.join(root, "stopping")
177
184
  const gate = spawn("mkfifo", [gatePath])
178
- assert.equal((await once(gate, "exit"))[0], 0)
185
+ expect((await once(gate, "exit"))[0]).toBe(0)
179
186
  const config = buildConfig(socketPath, {companion: {
180
187
  command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
181
188
  id: "worker",
@@ -190,14 +197,16 @@ test("external-owner retirement releases listeners before a long-draining compan
190
197
  await daemon.deploy({releaseId: "old", releasePath: root, revision: "old"})
191
198
  const retirement = sendControlCommand({command: {attestation: `sha256:${"a".repeat(64)}`, command: "retire-owner"}, path: socketPath})
192
199
  const response = await retirement
193
- assert.deepEqual(response, {message: "owner retired", status: "success"})
194
- assert.equal(await fs.readFile(stoppingPath, "utf8"), "stopping", "old worker must stop accepting work before listener takeover")
200
+ expect(response).toEqual({message: "owner retired", status: "success"})
201
+ // Old worker must stop accepting work before listener takeover.
202
+ expect(await fs.readFile(stoppingPath, "utf8")).toBe("stopping")
195
203
 
196
204
  replacement = new RollbridgeDaemon({config, logger: () => {}})
197
205
  await replacement.start({reportOrphans: false})
198
- assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).application, "shutdown-target")
199
- assert.deepEqual(replacement.status().orphans, [], "intentional retired companions are not replacement orphans")
200
- assert.equal(daemon.status().releases[0].processes[0].state, "quiesced")
206
+ expect((await sendControlCommand({command: {command: "status"}, path: socketPath})).application).toBe("shutdown-target")
207
+ // Intentional retired companions are not replacement orphans.
208
+ expect(replacement.status().orphans).toEqual([])
209
+ expect(daemon.status().releases[0].processes[0].state).toBe("quiesced")
201
210
  } finally {
202
211
  await fs.writeFile(gatePath, "done\n").catch(() => {})
203
212
  if (replacement) await replacement.shutdown()
@@ -227,13 +236,14 @@ test("a retired owner cannot clear replacement state during late shutdown", asyn
227
236
 
228
237
  const replacementState = /** @type {{activeReleaseId: string} | undefined} */ (await readState(statePath))
229
238
 
230
- assert.equal(replacementState?.activeReleaseId, "replacement")
239
+ expect(replacementState?.activeReleaseId).toBe("replacement")
231
240
 
232
241
  await retired.shutdown()
233
242
 
234
243
  const stateAfterRetiredShutdown = /** @type {{activeReleaseId: string} | undefined} */ (await readState(statePath))
235
244
 
236
- assert.equal(stateAfterRetiredShutdown?.activeReleaseId, "replacement", "late shutdown of the retired owner must preserve replacement state")
245
+ // Late shutdown of the retired owner must preserve replacement state.
246
+ expect(stateAfterRetiredShutdown?.activeReleaseId).toBe("replacement")
237
247
  } finally {
238
248
  if (replacement) await replacement.shutdown()
239
249
  await retired.shutdown()
@@ -256,19 +266,17 @@ test("control socket unlink failure is reported only after owned cleanup complet
256
266
  const webPid = daemon.activeRelease?.getProcess("web")?.pid
257
267
  const proxyPort = daemon.getProxyPort()
258
268
 
259
- assert.equal(typeof webPid, "number")
260
- assert.equal(typeof proxyPort, "number")
269
+ expect(typeof webPid).toBe("number")
270
+ expect(typeof proxyPort).toBe("number")
261
271
 
262
272
  daemon.removeControlSocket = async () => { throw new Error("injected unlink failure") }
263
273
 
264
- await assert.rejects(
265
- () => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
266
- /control socket unlink failed: injected unlink failure/
267
- )
274
+ await expect(sendControlCommand({command: {command: "shutdown"}, path: socketPath})).rejects.toThrow(/control socket unlink failed: injected unlink failure/)
268
275
 
269
- assert.equal(isProcessAlive(/** @type {number} */ (webPid)), false, "unlink failure must not strand an owned process")
270
- await assert.rejects(() => fetch(`http://127.0.0.1:${proxyPort}/ping`))
271
- await assert.rejects(() => fs.stat(statePath), {code: "ENOENT"})
276
+ // Unlink failure must not strand an owned process.
277
+ expect(isProcessAlive(/** @type {number} */ (webPid))).toBe(false)
278
+ await expect(fetch(`http://127.0.0.1:${proxyPort}/ping`)).rejects.toThrow()
279
+ await expect(fs.stat(statePath)).rejects.toMatchObject({code: "ENOENT"})
272
280
  } finally {
273
281
  await fs.rm(root, {force: true, recursive: true})
274
282
  }
@@ -289,8 +297,8 @@ test("direct shutdown closes idle accepted clients and converges", async () => {
289
297
  await daemon.shutdown()
290
298
  await idleClosed
291
299
 
292
- assert.equal(idle.destroyed, true)
293
- await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
300
+ expect(idle.destroyed).toBe(true)
301
+ await expect(fs.stat(socketPath)).rejects.toMatchObject({code: "ENOENT"})
294
302
  } finally {
295
303
  idle?.destroy()
296
304
  await fs.rm(root, {force: true, recursive: true})
@@ -313,11 +321,8 @@ test("shutdown reports cleanup failure and still retires the targeted endpoint",
313
321
  try {
314
322
  await daemon.start()
315
323
 
316
- await assert.rejects(
317
- () => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
318
- /directory|EISDIR/i
319
- )
320
- await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
324
+ await expect(sendControlCommand({command: {command: "shutdown"}, path: socketPath})).rejects.toThrow(/directory|EISDIR/i)
325
+ await expect(fs.stat(socketPath)).rejects.toMatchObject({code: "ENOENT"})
321
326
  } finally {
322
327
  await fs.rm(root, {force: true, recursive: true})
323
328
  }
@@ -336,16 +341,13 @@ test("shutdown does not turn an owned-resource stop rejection into success", asy
336
341
 
337
342
  const release = daemon.activeRelease
338
343
 
339
- assert.ok(release)
344
+ if (!release) throw new Error("Missing required fixture: release")
340
345
  const originalStop = release.stop.bind(release)
341
346
 
342
347
  restoreStop = originalStop
343
348
  release.stop = async () => { throw new Error("owned release stop failed") }
344
349
 
345
- await assert.rejects(
346
- () => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
347
- /Shutdown failed to stop 1 owned resource/
348
- )
350
+ await expect(sendControlCommand({command: {command: "shutdown"}, path: socketPath})).rejects.toThrow(/Shutdown failed to stop 1 owned resource/)
349
351
  } finally {
350
352
  if (restoreStop) await restoreStop()
351
353
  await fs.rm(root, {force: true, recursive: true})
@@ -357,10 +359,7 @@ test("shutdown of an already-stopped endpoint fails explicitly", async () => {
357
359
  const socketPath = path.join(root, "missing.sock")
358
360
 
359
361
  try {
360
- await assert.rejects(
361
- () => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
362
- (error) => Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT")
363
- )
362
+ await expect(sendControlCommand({command: {command: "shutdown"}, path: socketPath})).rejects.toMatchObject({code: "ENOENT"})
364
363
  } finally {
365
364
  await fs.rm(root, {force: true, recursive: true})
366
365
  }
@@ -414,3 +413,4 @@ async function waitForFile(filePath) {
414
413
 
415
414
  throw new Error(`Watcher ended before ${filePath} appeared`)
416
415
  }
416
+ })
@@ -1,12 +1,13 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
3
  import fs from "node:fs/promises"
5
4
  import os from "node:os"
6
5
  import path from "node:path"
7
- import test from "node:test"
6
+ import {describe, expect, test} from "@velocious/testing"
8
7
  import {clearState, readState, writeState} from "../src/state-store.js"
9
8
 
9
+ describe("state-store", () => {
10
+
10
11
  test("writeState then readState round-trips a snapshot", async () => {
11
12
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-state-"))
12
13
  const statePath = path.join(dir, "state.json")
@@ -16,7 +17,7 @@ test("writeState then readState round-trips a snapshot", async () => {
16
17
 
17
18
  const state = /** @type {{activeReleaseId: string}} */ (await readState(statePath))
18
19
 
19
- assert.equal(state.activeReleaseId, "v1")
20
+ expect(state.activeReleaseId).toBe("v1")
20
21
  } finally {
21
22
  await fs.rm(dir, {force: true, recursive: true})
22
23
  }
@@ -28,7 +29,7 @@ test("writeState keeps durable guardian capabilities private", async () => {
28
29
 
29
30
  try {
30
31
  await writeState(statePath, {recovery: {guardian: {token: "private"}}})
31
- assert.equal((await fs.stat(statePath)).mode & 0o777, 0o600)
32
+ expect((await fs.stat(statePath)).mode & 0o777).toBe(0o600)
32
33
  } finally {
33
34
  await fs.rm(dir, {force: true, recursive: true})
34
35
  }
@@ -39,11 +40,11 @@ test("readState returns undefined for a missing or unparseable file", async () =
39
40
  const statePath = path.join(dir, "state.json")
40
41
 
41
42
  try {
42
- assert.equal(await readState(statePath), undefined)
43
+ expect(await readState(statePath)).toBe(undefined)
43
44
 
44
45
  await fs.writeFile(statePath, "{not json")
45
46
 
46
- assert.equal(await readState(statePath), undefined)
47
+ expect(await readState(statePath)).toBe(undefined)
47
48
  } finally {
48
49
  await fs.rm(dir, {force: true, recursive: true})
49
50
  }
@@ -59,7 +60,7 @@ test("concurrent writes leave a complete, uncorrupted snapshot", async () => {
59
60
  const state = /** @type {{n: number}} */ (await readState(statePath))
60
61
 
61
62
  // A complete snapshot from one of the writers — never a partial/corrupt file or a temp race.
62
- assert.ok(state && typeof state.n === "number")
63
+ expect(state && typeof state.n === "number").toBeTruthy()
63
64
  } finally {
64
65
  await fs.rm(dir, {force: true, recursive: true})
65
66
  }
@@ -73,9 +74,10 @@ test("clearState removes the file and ignores a missing one", async () => {
73
74
  await writeState(statePath, {ok: true})
74
75
  await clearState(statePath)
75
76
 
76
- assert.equal(await readState(statePath), undefined)
77
+ expect(await readState(statePath)).toBe(undefined)
77
78
  await clearState(statePath)
78
79
  } finally {
79
80
  await fs.rm(dir, {force: true, recursive: true})
80
81
  }
81
82
  })
83
+ })
@@ -1,24 +1,26 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
- import test from "node:test"
3
+ import {describe, expect, test} from "@velocious/testing"
5
4
  import {resolveGroupId, resolveUserId} from "../src/system-ids.js"
6
5
 
7
- const linuxOnly = process.platform !== "linux" && "requires /etc/passwd and /etc/group (Linux)"
6
+ describe("system-ids", () => {
7
+
8
+ const linuxTest = process.platform === "linux" ? test : test.skip
8
9
 
9
10
  test("resolves numeric ids and numeric strings as-is", () => {
10
- assert.equal(resolveUserId(1000), 1000)
11
- assert.equal(resolveUserId("1000"), 1000)
12
- assert.equal(resolveGroupId(0), 0)
13
- assert.equal(resolveGroupId("42"), 42)
11
+ expect(resolveUserId(1000)).toBe(1000)
12
+ expect(resolveUserId("1000")).toBe(1000)
13
+ expect(resolveGroupId(0)).toBe(0)
14
+ expect(resolveGroupId("42")).toBe(42)
14
15
  })
15
16
 
16
- test("resolves user and group names to ids", {skip: linuxOnly}, () => {
17
- assert.equal(resolveUserId("root"), 0)
18
- assert.equal(resolveGroupId("root"), 0)
17
+ linuxTest("resolves user and group names to ids", () => {
18
+ expect(resolveUserId("root")).toBe(0)
19
+ expect(resolveGroupId("root")).toBe(0)
19
20
  })
20
21
 
21
- test("throws for an unknown user or group name", {skip: linuxOnly}, () => {
22
- assert.throws(() => resolveUserId("rollbridge-no-such-user"), /Unknown user "rollbridge-no-such-user"/)
23
- assert.throws(() => resolveGroupId("rollbridge-no-such-group"), /Unknown group "rollbridge-no-such-group"/)
22
+ linuxTest("throws for an unknown user or group name", async () => {
23
+ await expect(() => resolveUserId("rollbridge-no-such-user")).toThrow(/Unknown user "rollbridge-no-such-user"/)
24
+ await expect(() => resolveGroupId("rollbridge-no-such-group")).toThrow(/Unknown group "rollbridge-no-such-group"/)
25
+ })
24
26
  })