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.
- package/AGENTS.md +5 -0
- package/README.md +5 -0
- package/changelog.d/20260909120000-velocious-testing.md +1 -0
- package/docs/cli.md +7 -1
- package/docs/generation-deployment-contract.md +9 -0
- package/eslint.config.js +8 -0
- package/package.json +3 -2
- package/src/cli.js +10 -2
- package/src/daemon.js +102 -12
- package/src/process-guardian.js +5 -1
- package/src/release-group.js +48 -1
- package/test/completion.test.js +18 -16
- package/test/config-examples.test.js +16 -17
- package/test/config-path.test.js +10 -11
- package/test/config-validation.test.js +163 -167
- package/test/control-protocol.test.js +75 -14
- package/test/daemon-bootstrap.test.js +104 -104
- package/test/daemon-runtime.test.js +17 -26
- package/test/doctor.test.js +51 -49
- package/test/event-log.test.js +13 -11
- package/test/guardian-client.test.js +160 -145
- package/test/health.test.js +6 -4
- package/test/logs.test.js +23 -17
- package/test/managed-process.test.js +96 -91
- package/test/owner-recovery.test.js +254 -239
- package/test/owner-replacement.test.js +228 -223
- package/test/package-metadata.test.js +48 -39
- package/test/port-allocator.test.js +13 -16
- package/test/predeploy-cleanup.test.js +12 -10
- package/test/process-memory.test.js +17 -15
- package/test/proxy.test.js +10 -8
- package/test/recover.test.js +30 -23
- package/test/release-group.test.js +16 -17
- package/test/release-retention.test.js +10 -8
- package/test/release-runtime-retention.test.js +31 -39
- package/test/rollbridge.test.js +388 -395
- package/test/shutdown-completion.test.js +51 -51
- package/test/state-store.test.js +10 -8
- package/test/system-ids.test.js +15 -13
package/test/doctor.test.js
CHANGED
|
@@ -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 "
|
|
9
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
11
10
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
12
11
|
import {normalizeConfig} from "../src/config.js"
|
|
13
12
|
import {runEnvironmentChecks, runReleaseChecks} from "../src/doctor.js"
|
|
14
13
|
import {writeState} from "../src/state-store.js"
|
|
15
14
|
import {runCli} from "../src/cli.js"
|
|
16
15
|
|
|
16
|
+
describe("doctor", () => {
|
|
17
|
+
|
|
17
18
|
/**
|
|
18
19
|
* @param {object} args - Options.
|
|
19
20
|
* @param {string} args.controlPath - Control socket path.
|
|
@@ -72,7 +73,7 @@ async function occupyPort() {
|
|
|
72
73
|
function checkNamed(checks, name) {
|
|
73
74
|
const check = checks.find((candidate) => candidate.name === name)
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
if (!check) throw new Error(`expected a "${name}" check`)
|
|
76
77
|
|
|
77
78
|
return check
|
|
78
79
|
}
|
|
@@ -83,9 +84,9 @@ test("runEnvironmentChecks passes when no daemon runs, the port is free, and the
|
|
|
83
84
|
try {
|
|
84
85
|
const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort()}))
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
expect(checkNamed(checks, "control socket").ok).toBe(true)
|
|
88
|
+
expect(checkNamed(checks, "control socket directory").ok).toBe(true)
|
|
89
|
+
expect(checkNamed(checks, "proxy port").ok).toBe(true)
|
|
89
90
|
} finally {
|
|
90
91
|
await fs.rm(root, {force: true, recursive: true})
|
|
91
92
|
}
|
|
@@ -99,8 +100,8 @@ test("runEnvironmentChecks reports an unavailable proxy port", async () => {
|
|
|
99
100
|
const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: port}))
|
|
100
101
|
const proxyCheck = checkNamed(checks, "proxy port")
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
expect(proxyCheck.ok).toBe(false)
|
|
104
|
+
expect(proxyCheck.detail).toMatch(/unavailable/)
|
|
104
105
|
} finally {
|
|
105
106
|
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
106
107
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -113,8 +114,8 @@ test("runEnvironmentChecks checks the state path directory and reports no orphan
|
|
|
113
114
|
try {
|
|
114
115
|
const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort(), statePath: path.join(root, "state.json")}))
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
expect(checkNamed(checks, "state path directory").ok).toBe(true)
|
|
118
|
+
expect(checkNamed(checks, "orphaned processes").ok).toBe(true)
|
|
118
119
|
} finally {
|
|
119
120
|
await fs.rm(root, {force: true, recursive: true})
|
|
120
121
|
}
|
|
@@ -126,8 +127,8 @@ test("runEnvironmentChecks omits state checks when no statePath is configured",
|
|
|
126
127
|
try {
|
|
127
128
|
const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort()}))
|
|
128
129
|
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
expect(!checks.some((check) => check.name === "state path directory")).toBeTruthy()
|
|
131
|
+
expect(!checks.some((check) => check.name === "orphaned processes")).toBeTruthy()
|
|
131
132
|
} finally {
|
|
132
133
|
await fs.rm(root, {force: true, recursive: true})
|
|
133
134
|
}
|
|
@@ -146,8 +147,8 @@ test("runEnvironmentChecks does not flag orphans while a daemon is running", asy
|
|
|
146
147
|
const orphanCheck = checkNamed(checks, "orphaned processes")
|
|
147
148
|
|
|
148
149
|
// A running daemon's persisted pids are its own managed processes, not orphans.
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
expect(orphanCheck.ok).toBe(true)
|
|
151
|
+
expect(orphanCheck.detail).toMatch(/a daemon is running/)
|
|
151
152
|
} finally {
|
|
152
153
|
await daemon.shutdown()
|
|
153
154
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -172,8 +173,8 @@ test("runEnvironmentChecks reports orphaned processes left in the state file", a
|
|
|
172
173
|
const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort(), statePath}))
|
|
173
174
|
const orphanCheck = checkNamed(checks, "orphaned processes")
|
|
174
175
|
|
|
175
|
-
|
|
176
|
-
|
|
176
|
+
expect(orphanCheck.ok).toBe(false)
|
|
177
|
+
expect(orphanCheck.detail).toMatch(new RegExp(`worker \\(pid ${leftover.pid}\\)`))
|
|
177
178
|
} finally {
|
|
178
179
|
leftover.kill("SIGKILL")
|
|
179
180
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -184,8 +185,8 @@ test("runEnvironmentChecks reports a missing control socket directory", async ()
|
|
|
184
185
|
const checks = await runEnvironmentChecks(buildConfig({controlPath: "/rollbridge-doctor-missing-dir/rollbridge.sock", proxyPort: await freePort()}))
|
|
185
186
|
const directoryCheck = checkNamed(checks, "control socket directory")
|
|
186
187
|
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
expect(directoryCheck.ok).toBe(false)
|
|
189
|
+
expect(directoryCheck.detail).toMatch(/missing or not writable/)
|
|
189
190
|
})
|
|
190
191
|
|
|
191
192
|
test("runEnvironmentChecks passes when the running Rollbridge daemon holds the socket and port", async () => {
|
|
@@ -199,9 +200,9 @@ test("runEnvironmentChecks passes when the running Rollbridge daemon holds the s
|
|
|
199
200
|
const checks = await runEnvironmentChecks(config)
|
|
200
201
|
const socketCheck = checkNamed(checks, "control socket")
|
|
201
202
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
expect(socketCheck.ok).toBe(true)
|
|
204
|
+
expect(socketCheck.detail).toMatch(/Rollbridge daemon for "doctor-test" is running/)
|
|
205
|
+
expect(checkNamed(checks, "proxy port").ok).toBe(true)
|
|
205
206
|
} finally {
|
|
206
207
|
await daemon.shutdown()
|
|
207
208
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -222,8 +223,8 @@ test("runEnvironmentChecks fails when the running daemon does not own the config
|
|
|
222
223
|
try {
|
|
223
224
|
const checks = await runEnvironmentChecks(changedConfig)
|
|
224
225
|
|
|
225
|
-
|
|
226
|
-
|
|
226
|
+
expect(checkNamed(checks, "control socket").ok).toBe(true)
|
|
227
|
+
expect(checkNamed(checks, "proxy port").ok).toBe(false)
|
|
227
228
|
} finally {
|
|
228
229
|
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
229
230
|
await daemon.shutdown()
|
|
@@ -256,9 +257,9 @@ test("runReleaseChecks passes for an existing release with resolvable templates"
|
|
|
256
257
|
const config = releaseConfig({processes: [{command: "run web --port {{port}} --release {{releaseId}}", cwd: "{{releasePath}}/backend", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}], root})
|
|
257
258
|
const checks = await runReleaseChecks(config, {releasePath})
|
|
258
259
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
260
|
+
expect(checkNamed(checks, "release path").ok).toBe(true)
|
|
261
|
+
expect(checkNamed(checks, "process templates").ok).toBe(true)
|
|
262
|
+
expect(checkNamed(checks, "process working directories").ok).toBe(true)
|
|
262
263
|
} finally {
|
|
263
264
|
await fs.rm(root, {force: true, recursive: true})
|
|
264
265
|
}
|
|
@@ -274,8 +275,8 @@ test("runReleaseChecks flags a command that references an undefined template var
|
|
|
274
275
|
const config = releaseConfig({processes: [{command: "run web --secret {{missingVar}}", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}], root})
|
|
275
276
|
const templates = checkNamed(await runReleaseChecks(config, {releasePath}), "process templates")
|
|
276
277
|
|
|
277
|
-
|
|
278
|
-
|
|
278
|
+
expect(templates.ok).toBe(false)
|
|
279
|
+
expect(templates.detail).toMatch(/web:.*missingVar/)
|
|
279
280
|
} finally {
|
|
280
281
|
await fs.rm(root, {force: true, recursive: true})
|
|
281
282
|
}
|
|
@@ -292,12 +293,12 @@ test("runReleaseChecks reports a missing process working directory", async () =>
|
|
|
292
293
|
const config = releaseConfig({processes: [{command: "run web", cwd: "{{releasePath}}/backend", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}], root})
|
|
293
294
|
const checks = await runReleaseChecks(config, {releasePath})
|
|
294
295
|
|
|
295
|
-
|
|
296
|
+
expect(checkNamed(checks, "release path").ok).toBe(true)
|
|
296
297
|
|
|
297
298
|
const directories = checkNamed(checks, "process working directories")
|
|
298
299
|
|
|
299
|
-
|
|
300
|
-
|
|
300
|
+
expect(directories.ok).toBe(false)
|
|
301
|
+
expect(directories.detail).toMatch(/web .*backend/)
|
|
301
302
|
} finally {
|
|
302
303
|
await fs.rm(root, {force: true, recursive: true})
|
|
303
304
|
}
|
|
@@ -310,7 +311,7 @@ test("runReleaseChecks reports a missing release path", async () => {
|
|
|
310
311
|
const config = releaseConfig({processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}], root})
|
|
311
312
|
const checks = await runReleaseChecks(config, {releasePath: path.join(root, "does-not-exist")})
|
|
312
313
|
|
|
313
|
-
|
|
314
|
+
expect(checkNamed(checks, "release path").ok).toBe(false)
|
|
314
315
|
} finally {
|
|
315
316
|
await fs.rm(root, {force: true, recursive: true})
|
|
316
317
|
}
|
|
@@ -355,8 +356,8 @@ test("doctor CLI passes for a valid, bindable config", async () => {
|
|
|
355
356
|
try {
|
|
356
357
|
const output = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js")])
|
|
357
358
|
|
|
358
|
-
|
|
359
|
-
|
|
359
|
+
expect(output).toMatch(/All checks passed\./)
|
|
360
|
+
expect(process.exitCode).not.toBe(1)
|
|
360
361
|
} finally {
|
|
361
362
|
await fs.rm(root, {force: true, recursive: true})
|
|
362
363
|
}
|
|
@@ -370,8 +371,8 @@ test("doctor CLI fails and exits non-zero for an invalid config", async () => {
|
|
|
370
371
|
try {
|
|
371
372
|
const output = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js")])
|
|
372
373
|
|
|
373
|
-
|
|
374
|
-
|
|
374
|
+
expect(process.exitCode).toBe(1)
|
|
375
|
+
expect(output).toMatch(/✗ config:/)
|
|
375
376
|
} finally {
|
|
376
377
|
process.exitCode = 0
|
|
377
378
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -396,15 +397,15 @@ test("doctor --json emits structured checks", async () => {
|
|
|
396
397
|
try {
|
|
397
398
|
const passing = JSON.parse(await captureCli(["node", "rollbridge", "doctor", "--json", "-c", path.join(okRoot, "rollbridge.js")]))
|
|
398
399
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
400
|
+
expect(passing.ok).toBe(true)
|
|
401
|
+
expect(passing.checks.some((/** @type {{name: string, ok: boolean}} */ check) => check.name === "proxy port" && check.ok === true)).toBeTruthy()
|
|
402
|
+
expect(process.exitCode).not.toBe(1)
|
|
402
403
|
|
|
403
404
|
const failing = JSON.parse(await captureCli(["node", "rollbridge", "doctor", "--json", "-c", path.join(badRoot, "rollbridge.js")]))
|
|
404
405
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
406
|
+
expect(failing.ok).toBe(false)
|
|
407
|
+
expect(failing.checks.some((/** @type {{name: string, ok: boolean}} */ check) => check.name === "config" && check.ok === false)).toBeTruthy()
|
|
408
|
+
expect(process.exitCode).toBe(1)
|
|
408
409
|
} finally {
|
|
409
410
|
process.exitCode = 0
|
|
410
411
|
await fs.rm(okRoot, {force: true, recursive: true})
|
|
@@ -430,11 +431,11 @@ test("doctor --release-path adds release checks, passing or failing on the worki
|
|
|
430
431
|
try {
|
|
431
432
|
const passing = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js"), "--release-path", releasePath])
|
|
432
433
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
434
|
+
expect(passing).toMatch(/✓ release path:/)
|
|
435
|
+
expect(passing).toMatch(/✓ process templates:/)
|
|
436
|
+
expect(passing).toMatch(/✓ process working directories:/)
|
|
437
|
+
expect(passing).toMatch(/All checks passed\./)
|
|
438
|
+
expect(process.exitCode).not.toBe(1)
|
|
438
439
|
|
|
439
440
|
// A release without the rendered backend directory fails the working-directories check.
|
|
440
441
|
const emptyRelease = path.join(root, "empty-release")
|
|
@@ -443,10 +444,11 @@ test("doctor --release-path adds release checks, passing or failing on the worki
|
|
|
443
444
|
|
|
444
445
|
const failing = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js"), "--release-path", emptyRelease])
|
|
445
446
|
|
|
446
|
-
|
|
447
|
-
|
|
447
|
+
expect(failing).toMatch(/✗ process working directories:/)
|
|
448
|
+
expect(process.exitCode).toBe(1)
|
|
448
449
|
} finally {
|
|
449
450
|
process.exitCode = 0
|
|
450
451
|
await fs.rm(root, {force: true, recursive: true})
|
|
451
452
|
}
|
|
452
453
|
})
|
|
454
|
+
})
|
package/test/event-log.test.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import test from "node:test"
|
|
3
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
5
4
|
import EventLog from "../src/event-log.js"
|
|
6
5
|
|
|
6
|
+
describe("event-log", () => {
|
|
7
|
+
|
|
7
8
|
test("records events with a timestamp, message, and data", () => {
|
|
8
9
|
const log = new EventLog(10)
|
|
9
10
|
|
|
@@ -11,9 +12,9 @@ test("records events with a timestamp, message, and data", () => {
|
|
|
11
12
|
|
|
12
13
|
const [event] = log.recent()
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
expect(event.message).toBe("traffic switched")
|
|
16
|
+
expect(event.data).toEqual({releaseId: "v1"})
|
|
17
|
+
expect(event.at).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/)
|
|
17
18
|
})
|
|
18
19
|
|
|
19
20
|
test("drops the oldest events once the limit is exceeded", () => {
|
|
@@ -23,8 +24,8 @@ test("drops the oldest events once the limit is exceeded", () => {
|
|
|
23
24
|
|
|
24
25
|
const events = log.recent()
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
expect(events.length).toBe(3)
|
|
28
|
+
expect(events.map((event) => event.data.index)).toEqual([2, 3, 4])
|
|
28
29
|
})
|
|
29
30
|
|
|
30
31
|
test("recent(limit) returns only the most recent events, oldest first", () => {
|
|
@@ -32,7 +33,7 @@ test("recent(limit) returns only the most recent events, oldest first", () => {
|
|
|
32
33
|
|
|
33
34
|
for (let index = 0; index < 5; index += 1) log.record("tick", {index})
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
expect(log.recent(2).map((event) => event.data.index)).toEqual([3, 4])
|
|
36
37
|
})
|
|
37
38
|
|
|
38
39
|
test("recent returns every event when the limit is omitted or not a positive number", () => {
|
|
@@ -40,7 +41,8 @@ test("recent returns every event when the limit is omitted or not a positive num
|
|
|
40
41
|
|
|
41
42
|
for (let index = 0; index < 3; index += 1) log.record("tick", {index})
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
expect(log.recent().length).toBe(3)
|
|
45
|
+
expect(log.recent(0).length).toBe(3)
|
|
46
|
+
expect(log.recent(99).length).toBe(3)
|
|
47
|
+
})
|
|
46
48
|
})
|