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,68 +1,77 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
3
  import {execFile} from "node:child_process"
5
4
  import fs from "node:fs/promises"
6
5
  import os from "node:os"
7
6
  import path from "node:path"
8
- import test from "node:test"
7
+ import {describe, expect, test} from "@velocious/testing"
9
8
  import {fileURLToPath} from "node:url"
10
9
  import {promisify} from "node:util"
11
10
 
11
+ describe("package-metadata", () => {
12
+
12
13
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
13
14
  const execFileAsync = promisify(execFile)
14
15
 
15
16
  test("package.json declares publish metadata", async () => {
16
17
  const pkg = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8"))
17
18
 
18
- assert.equal(pkg.name, "rollbridge")
19
- assert.equal(pkg.license, "MIT")
20
- assert.equal(pkg.homepage, "https://github.com/kaspernj/rollbridge#readme")
21
- assert.equal(pkg.bugs.url, "https://github.com/kaspernj/rollbridge/issues")
22
- assert.equal(pkg.repository.type, "git")
23
- assert.match(pkg.repository.url, /github\.com\/kaspernj\/rollbridge/)
24
- assert.ok(typeof pkg.author === "string" && pkg.author.length > 0)
25
- assert.ok(Array.isArray(pkg.keywords) && pkg.keywords.length > 0)
19
+ expect(pkg.name).toBe("rollbridge")
20
+ expect(pkg.license).toBe("MIT")
21
+ expect(pkg.homepage).toBe("https://github.com/kaspernj/rollbridge#readme")
22
+ expect(pkg.bugs.url).toBe("https://github.com/kaspernj/rollbridge/issues")
23
+ expect(pkg.repository.type).toBe("git")
24
+ expect(pkg.repository.url).toMatch(/github\.com\/kaspernj\/rollbridge/)
25
+ expect(typeof pkg.author === "string" && pkg.author.length > 0).toBeTruthy()
26
+ expect(Array.isArray(pkg.keywords) && pkg.keywords.length > 0).toBeTruthy()
26
27
  })
27
28
 
28
29
  test("a LICENSE file matching the declared license exists", async () => {
29
30
  const license = await fs.readFile(path.join(repoRoot, "LICENSE"), "utf8")
30
31
 
31
- assert.match(license, /MIT License/)
32
- assert.match(license, /Copyright \(c\) \d{4} kaspernj/)
32
+ expect(license).toMatch(/MIT License/)
33
+ expect(license).toMatch(/Copyright \(c\) \d{4} kaspernj/)
33
34
  })
34
35
 
35
- test("package manifest excludes unexpected operational and coverage files", async (t) => {
36
+ test("package manifest excludes unexpected operational and coverage files", async () => {
36
37
  const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-pack-"))
37
- t.after(() => fs.rm(fixtureRoot, {force: true, recursive: true}))
38
38
 
39
- await fs.cp(repoRoot, fixtureRoot, {
40
- filter: (source) => {
41
- const relative = path.relative(repoRoot, source)
42
- return relative !== ".git" && relative !== "node_modules" && relative !== "tmp"
43
- },
44
- recursive: true,
45
- })
39
+ try {
40
+ await fs.cp(repoRoot, fixtureRoot, {
41
+ filter: (source) => {
42
+ const relative = path.relative(repoRoot, source)
43
+ return relative !== ".git" && relative !== "node_modules" && relative !== "tmp"
44
+ },
45
+ recursive: true,
46
+ })
47
+
48
+ const unexpectedTmpPath = path.join(fixtureRoot, "tmp", "worker-control", "unexpected-transcript.jsonl")
49
+ const unexpectedCoveragePath = path.join(fixtureRoot, "coverage", "unexpected.txt")
50
+ await Promise.all([
51
+ fs.mkdir(path.dirname(unexpectedTmpPath), {recursive: true}),
52
+ fs.mkdir(path.dirname(unexpectedCoveragePath), {recursive: true}),
53
+ ])
54
+ await Promise.all([
55
+ fs.writeFile(unexpectedTmpPath, '{"operational":"state"}\n'),
56
+ fs.writeFile(unexpectedCoveragePath, "unexpected coverage output\n"),
57
+ ])
46
58
 
47
- const unexpectedTmpPath = path.join(fixtureRoot, "tmp", "worker-control", "unexpected-transcript.jsonl")
48
- const unexpectedCoveragePath = path.join(fixtureRoot, "coverage", "unexpected.txt")
49
- await Promise.all([
50
- fs.mkdir(path.dirname(unexpectedTmpPath), {recursive: true}),
51
- fs.mkdir(path.dirname(unexpectedCoveragePath), {recursive: true}),
52
- ])
53
- await Promise.all([
54
- fs.writeFile(unexpectedTmpPath, '{"operational":"state"}\n'),
55
- fs.writeFile(unexpectedCoveragePath, "unexpected coverage output\n"),
56
- ])
59
+ const {stdout} = await execFileAsync("npm", ["pack", "--dry-run", "--json"], {cwd: fixtureRoot})
60
+ const packageOutput = JSON.parse(stdout)
61
+ const packageArchive = Array.isArray(packageOutput) ? packageOutput[0] : Object.values(packageOutput)[0]
57
62
 
58
- const {stdout} = await execFileAsync("npm", ["pack", "--dry-run", "--json"], {cwd: fixtureRoot})
59
- /** @type {Array<{path: string}>} */
60
- const packageFiles = JSON.parse(stdout)[0].files
61
- const packagePaths = packageFiles.map((file) => file.path)
63
+ if (!(packageArchive && Array.isArray(packageArchive.files))) throw new Error("Expected package archive file list")
64
+ /** @type {Array<{path: string}>} */
65
+ const packageFiles = packageArchive.files
66
+ const packagePaths = packageFiles.map((file) => file.path)
62
67
 
63
- for (const requiredPath of ["LICENSE", "README.md", "bin/rollbridge", "package.json", "src/cli.js", "src/daemon-runtime.js"]) {
64
- assert.ok(packagePaths.includes(requiredPath), `expected package to include ${requiredPath}`)
68
+ for (const requiredPath of ["LICENSE", "README.md", "bin/rollbridge", "package.json", "src/cli.js", "src/daemon-runtime.js"]) {
69
+ expect({value: Boolean(packagePaths.includes(requiredPath)), context: `expected package to include ${requiredPath}`}).toMatchObject({value: true})
70
+ }
71
+ expect(!packagePaths.some((packagePath) => packagePath === "tmp" || packagePath.startsWith("tmp/"))).toBeTruthy()
72
+ expect(!packagePaths.some((packagePath) => packagePath === "coverage" || packagePath.startsWith("coverage/"))).toBeTruthy()
73
+ } finally {
74
+ await fs.rm(fixtureRoot, {force: true, recursive: true})
65
75
  }
66
- assert.ok(!packagePaths.some((packagePath) => packagePath === "tmp" || packagePath.startsWith("tmp/")))
67
- assert.ok(!packagePaths.some((packagePath) => packagePath === "coverage" || packagePath.startsWith("coverage/")))
76
+ })
68
77
  })
@@ -1,10 +1,11 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
3
  import net from "node:net"
5
- import test from "node:test"
4
+ import {describe, expect, test} from "@velocious/testing"
6
5
  import {findAvailablePort} from "../src/port-allocator.js"
7
6
 
7
+ describe("port-allocator", () => {
8
+
8
9
  const host = "127.0.0.1"
9
10
 
10
11
  /**
@@ -40,18 +41,13 @@ test("findAvailablePort reports reserved and in-use counts when a range is exhau
40
41
  const range = {from: reservedPort, to: port}
41
42
 
42
43
  try {
43
- await assert.rejects(
44
- () => findAvailablePort({host, range, usedPorts: new Set([reservedPort])}),
45
- (error) => {
46
- assert.ok(error instanceof Error)
47
- assert.match(error.message, new RegExp(`No available ports in range ${reservedPort}-${port}`))
48
- assert.match(error.message, /2 ports on 127\.0\.0\.1/)
49
- assert.match(error.message, /1 reserved by this deploy/)
50
- assert.match(error.message, /1 already in use/)
44
+ const allocation = findAvailablePort({host, range, usedPorts: new Set([reservedPort])})
51
45
 
52
- return true
53
- }
54
- )
46
+ await expect(allocation).rejects.toBeInstanceOf(Error)
47
+ await expect(allocation).rejects.toMatchObject({message: expect.stringMatching(new RegExp(`No available ports in range ${reservedPort}-${port}`))})
48
+ await expect(allocation).rejects.toMatchObject({message: expect.stringMatching(/2 ports on 127\.0\.0\.1/)})
49
+ await expect(allocation).rejects.toMatchObject({message: expect.stringMatching(/1 reserved by this deploy/)})
50
+ await expect(allocation).rejects.toMatchObject({message: expect.stringMatching(/1 already in use/)})
55
51
  } finally {
56
52
  await closeServer(server)
57
53
  }
@@ -67,10 +63,11 @@ test("findAvailablePort skips the occupied port and records the allocated one",
67
63
  try {
68
64
  const allocated = await findAvailablePort({host, range: {from, to}, usedPorts})
69
65
 
70
- assert.notEqual(allocated, port)
71
- assert.ok(allocated >= from && allocated <= to)
72
- assert.ok(usedPorts.has(allocated))
66
+ expect(allocated).not.toBe(port)
67
+ expect(allocated >= from && allocated <= to).toBeTruthy()
68
+ expect(usedPorts.has(allocated)).toBeTruthy()
73
69
  } finally {
74
70
  await closeServer(server)
75
71
  }
76
72
  })
73
+ })
@@ -1,16 +1,17 @@
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 os from "node:os"
8
7
  import path from "node:path"
9
- import test from "node:test"
8
+ import {describe, expect, test} from "@velocious/testing"
10
9
  import {normalizeConfig} from "../src/config.js"
11
10
  import {isProcessAlive} from "../src/state-store.js"
12
11
  import {predeployCleanup} from "../src/predeploy-cleanup.js"
13
12
 
13
+ describe("predeploy-cleanup", () => {
14
+
14
15
  /**
15
16
  * @param {string} dir - Working directory.
16
17
  * @param {string} marker - Unique process marker.
@@ -39,11 +40,11 @@ test("predeploy cleanup stops configured legacy process when no daemon is active
39
40
  try {
40
41
  const result = await predeployCleanup({config: buildConfig(dir, marker)})
41
42
 
42
- assert.equal(result.action, "no-daemon-cleaned")
43
- assert.equal(result.recoveredOrphans, 0)
44
- assert.equal(result.legacyProcesses.length, 1)
45
- assert.equal(result.legacyProcesses[0].pid, legacy.pid)
46
- assert.ok(legacy.pid === undefined || !isProcessAlive(legacy.pid))
43
+ expect(result.action).toBe("no-daemon-cleaned")
44
+ expect(result.recoveredOrphans).toBe(0)
45
+ expect(result.legacyProcesses.length).toBe(1)
46
+ expect(result.legacyProcesses[0].pid).toBe(legacy.pid)
47
+ expect(legacy.pid === undefined || !isProcessAlive(legacy.pid)).toBeTruthy()
47
48
  } finally {
48
49
  legacy.kill("SIGKILL")
49
50
  await fs.rm(dir, {force: true, recursive: true})
@@ -74,7 +75,7 @@ test("predeploy cleanup leaves legacy processes alone when daemon already has an
74
75
  })
75
76
  })
76
77
 
77
- assert.deepEqual(result, {
78
+ expect(result).toEqual({
78
79
  action: "daemon-active",
79
80
  legacyProcesses: [],
80
81
  recoveredOrphans: 0
@@ -123,9 +124,10 @@ test("predeploy cleanup stops an active daemon when the proxy config changed", a
123
124
  }
124
125
  })
125
126
 
126
- assert.equal(result.action, "daemon-stopped")
127
- assert.deepEqual(commands.map((command) => command.command), ["status", "shutdown"])
127
+ expect(result.action).toBe("daemon-stopped")
128
+ expect(commands.map((command) => command.command)).toEqual(["status", "shutdown"])
128
129
  } finally {
129
130
  await fs.rm(dir, {force: true, recursive: true})
130
131
  }
131
132
  })
133
+ })
@@ -1,13 +1,14 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
3
  import fs from "node:fs"
5
- import test from "node:test"
4
+ import {describe, expect, test} from "@velocious/testing"
6
5
  import os from "node:os"
7
6
  import path from "node:path"
8
7
  import {measureProcessGroupRssBytes, processGroupHasLiveMembers, processGroupMembers} from "../src/process-memory.js"
9
8
 
10
- const linuxOnly = process.platform !== "linux" && "requires /proc (Linux)"
9
+ describe("process-memory", () => {
10
+
11
+ const linuxTest = process.platform === "linux" ? test : test.skip
11
12
 
12
13
  /**
13
14
  * @returns {number} The current process's group id, read from /proc.
@@ -18,27 +19,27 @@ function currentProcessGroupId() {
18
19
  return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[2])
19
20
  }
20
21
 
21
- test("measures the resident memory of a live process group", {skip: linuxOnly}, () => {
22
+ linuxTest("measures the resident memory of a live process group", () => {
22
23
  const rssBytes = measureProcessGroupRssBytes(currentProcessGroupId())
23
24
 
24
- assert.ok(typeof rssBytes === "number" && rssBytes > 0, `expected a positive RSS, got ${rssBytes}`)
25
+ expect({value: Boolean(typeof rssBytes === "number" && rssBytes > 0), context: `expected a positive RSS, got ${rssBytes}`}).toMatchObject({value: true})
25
26
  })
26
27
 
27
- test("returns undefined for a process group with no members", {skip: linuxOnly}, () => {
28
- assert.equal(measureProcessGroupRssBytes(2147483646), undefined)
28
+ linuxTest("returns undefined for a process group with no members", () => {
29
+ expect(measureProcessGroupRssBytes(2147483646)).toBe(undefined)
29
30
  })
30
31
 
31
- test("lists process-group members with their command and resident memory", {skip: linuxOnly}, () => {
32
+ linuxTest("lists process-group members with their command and resident memory", () => {
32
33
  const members = processGroupMembers(currentProcessGroupId())
33
34
  const self = members.find((member) => member.pid === process.pid)
34
35
 
35
- assert.ok(self, "the current process should be a group member")
36
- assert.ok(typeof self.rssBytes === "number" && self.rssBytes > 0)
37
- assert.equal(typeof self.command, "string")
36
+ if (!self) throw new Error("the current process should be a group member")
37
+ expect(typeof self.rssBytes === "number" && self.rssBytes > 0).toBeTruthy()
38
+ expect(typeof self.command).toBe("string")
38
39
  })
39
40
 
40
- test("returns an empty list for a process group with no members", {skip: linuxOnly}, () => {
41
- assert.deepEqual(processGroupMembers(2147483646), [])
41
+ linuxTest("returns an empty list for a process group with no members", () => {
42
+ expect(processGroupMembers(2147483646)).toEqual([])
42
43
  })
43
44
 
44
45
  test("treats a process group containing only defunct members as stopped", () => {
@@ -48,13 +49,14 @@ test("treats a process group containing only defunct members as stopped", () =>
48
49
  fs.mkdirSync(path.join(procPath, "101"))
49
50
  fs.writeFileSync(path.join(procPath, "101", "stat"), "101 (worker) Z 1 77 0 0")
50
51
 
51
- assert.equal(processGroupHasLiveMembers(77, procPath), false)
52
+ expect(processGroupHasLiveMembers(77, procPath)).toBe(false)
52
53
 
53
54
  fs.mkdirSync(path.join(procPath, "102"))
54
55
  fs.writeFileSync(path.join(procPath, "102", "stat"), "102 (worker) S 1 77 0 0")
55
56
 
56
- assert.equal(processGroupHasLiveMembers(77, procPath), true)
57
+ expect(processGroupHasLiveMembers(77, procPath)).toBe(true)
57
58
  } finally {
58
59
  fs.rmSync(procPath, {force: true, recursive: true})
59
60
  }
60
61
  })
62
+ })
@@ -1,14 +1,15 @@
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 {fileURLToPath} from "node:url"
9
8
  import RollbridgeDaemon from "../src/daemon.js"
10
9
  import {normalizeConfig} from "../src/config.js"
11
10
 
11
+ describe("proxy", () => {
12
+
12
13
  const currentDir = path.dirname(fileURLToPath(import.meta.url))
13
14
  const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
14
15
 
@@ -52,11 +53,11 @@ async function proxyFetch(daemon, pathName) {
52
53
  function webPid(daemon, releaseId) {
53
54
  const release = daemon.status().releases.find((candidate) => candidate.releaseId === releaseId)
54
55
 
55
- assert.ok(release, `Release ${releaseId} should be present`)
56
+ if (!release) throw new Error(`Release ${releaseId} should be present`)
56
57
 
57
58
  const web = release.processes.find((candidate) => candidate.id === "web")
58
59
 
59
- assert.ok(web && typeof web.pid === "number", "web process should report a pid")
60
+ if (!(web && typeof web.pid === "number")) throw new Error("web process should report a pid")
60
61
 
61
62
  return web.pid
62
63
  }
@@ -84,7 +85,7 @@ test("proxy returns 502 while the active release web process is down", async ()
84
85
 
85
86
  try {
86
87
  await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
87
- assert.equal((await proxyFetch(daemon, "/release")).status, 200)
88
+ expect((await proxyFetch(daemon, "/release")).status).toBe(200)
88
89
 
89
90
  // Kill the web process group so the active release exits unexpectedly; restart is held off for 60s.
90
91
  process.kill(-webPid(daemon, "v1"), "SIGKILL")
@@ -97,7 +98,7 @@ test("proxy returns 502 while the active release web process is down", async ()
97
98
  return lastStatus === 502
98
99
  })
99
100
 
100
- assert.equal(lastStatus, 502)
101
+ expect(lastStatus).toBe(502)
101
102
  } finally {
102
103
  await daemon.shutdown()
103
104
  await fs.rm(root, {force: true, recursive: true})
@@ -112,7 +113,7 @@ test("proxy recovers once the crashed web process restarts", async () => {
112
113
 
113
114
  try {
114
115
  await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
115
- assert.equal((await proxyFetch(daemon, "/release")).status, 200)
116
+ expect((await proxyFetch(daemon, "/release")).status).toBe(200)
116
117
 
117
118
  process.kill(-webPid(daemon, "v1"), "SIGKILL")
118
119
 
@@ -120,9 +121,10 @@ test("proxy recovers once the crashed web process restarts", async () => {
120
121
  // observing the outage, then confirm the restart brings the proxy back to 200.
121
122
  await waitFor(async () => (await proxyFetch(daemon, "/release")).status === 502)
122
123
  await waitFor(async () => (await proxyFetch(daemon, "/release")).status === 200)
123
- assert.equal((await proxyFetch(daemon, "/release")).status, 200)
124
+ expect((await proxyFetch(daemon, "/release")).status).toBe(200)
124
125
  } finally {
125
126
  await daemon.shutdown()
126
127
  await fs.rm(root, {force: true, recursive: true})
127
128
  }
128
129
  })
130
+ })
@@ -1,17 +1,18 @@
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 os from "node:os"
8
7
  import path from "node:path"
9
- import test from "node:test"
8
+ import {describe, expect, test} from "@velocious/testing"
10
9
  import RollbridgeDaemon from "../src/daemon.js"
11
10
  import {normalizeConfig} from "../src/config.js"
12
11
  import {recoverOrphans} from "../src/recover.js"
13
12
  import {isProcessAlive, readState, writeState} from "../src/state-store.js"
14
13
 
14
+ describe("recover", () => {
15
+
15
16
  /**
16
17
  * @param {string} dir - Working directory.
17
18
  * @param {{statePath?: string}} [options] - Config options.
@@ -68,7 +69,7 @@ test("recover requires a configured statePath", async () => {
68
69
  try {
69
70
  const result = await recoverOrphans({config: buildConfig(dir), force: true})
70
71
 
71
- assert.ok("error" in result && /statePath/.test(result.error))
72
+ expect("error" in result && /statePath/.test(result.error)).toBeTruthy()
72
73
  } finally {
73
74
  await fs.rm(dir, {force: true, recursive: true})
74
75
  }
@@ -84,14 +85,16 @@ test("recover lists orphans without stopping them unless forced", async () => {
84
85
 
85
86
  const result = await recoverOrphans({config: buildConfig(dir, {statePath}), force: false})
86
87
 
87
- assert.ok(!("error" in result))
88
- assert.equal(result.forced, false)
89
- assert.equal(result.cleared, false)
90
- assert.deepEqual(result.remaining, [])
91
- assert.equal(result.orphans.length, 1)
92
- assert.equal(result.orphans[0].pid, orphan.pid)
93
- assert.ok(orphan.pid !== undefined && isProcessAlive(orphan.pid), "the orphan must not be stopped by a dry run")
94
- assert.ok(await readState(statePath), "a dry run must not clear the state file")
88
+ if ("error" in result) throw new Error(`Recovery failed: ${result.error}`)
89
+ expect(result.forced).toBe(false)
90
+ expect(result.cleared).toBe(false)
91
+ expect(result.remaining).toEqual([])
92
+ expect(result.orphans.length).toBe(1)
93
+ expect(result.orphans[0].pid).toBe(orphan.pid)
94
+ // The orphan must not be stopped by a dry run.
95
+ expect(orphan.pid !== undefined && isProcessAlive(orphan.pid)).toBeTruthy()
96
+ // A dry run must not clear the state file.
97
+ expect(await readState(statePath)).toBeTruthy()
95
98
  } finally {
96
99
  orphan.kill("SIGKILL")
97
100
  await fs.rm(dir, {force: true, recursive: true})
@@ -108,12 +111,13 @@ test("recover --force stops orphan process groups and clears the state file", as
108
111
 
109
112
  const result = await recoverOrphans({config: buildConfig(dir, {statePath}), force: true})
110
113
 
111
- assert.ok(!("error" in result))
112
- assert.equal(result.forced, true)
113
- assert.equal(result.cleared, true)
114
- assert.deepEqual(result.remaining, [])
114
+ if ("error" in result) throw new Error(`Recovery failed: ${result.error}`)
115
+ expect(result.forced).toBe(true)
116
+ expect(result.cleared).toBe(true)
117
+ expect(result.remaining).toEqual([])
115
118
  await waitFor(() => orphan.pid === undefined || !isProcessAlive(orphan.pid))
116
- assert.equal(await readState(statePath), undefined, "the state file is cleared after a forced recovery")
119
+ // The state file is cleared after a forced recovery.
120
+ expect(await readState(statePath)).toBe(undefined)
117
121
  } finally {
118
122
  orphan.kill("SIGKILL")
119
123
  await fs.rm(dir, {force: true, recursive: true})
@@ -132,12 +136,14 @@ test("recover --force keeps the state file when an orphan cannot be stopped", as
132
136
  // reports it is still alive.
133
137
  const result = await recoverOrphans({config: buildConfig(dir, {statePath}), force: true, stopGroup: async () => false})
134
138
 
135
- assert.ok(!("error" in result))
136
- assert.equal(result.forced, true)
137
- assert.equal(result.cleared, false, "the state file is kept when an orphan survives")
138
- assert.equal(result.remaining.length, 1)
139
- assert.equal(result.remaining[0].pid, orphan.pid)
140
- assert.ok(await readState(statePath), "the state file must remain so the operator can retry")
139
+ if ("error" in result) throw new Error(`Recovery failed: ${result.error}`)
140
+ expect(result.forced).toBe(true)
141
+ // The state file is kept when an orphan survives.
142
+ expect(result.cleared).toBe(false)
143
+ expect(result.remaining.length).toBe(1)
144
+ expect(result.remaining[0].pid).toBe(orphan.pid)
145
+ // The state file must remain so the operator can retry.
146
+ expect(await readState(statePath)).toBeTruthy()
141
147
  } finally {
142
148
  orphan.kill("SIGKILL")
143
149
  await fs.rm(dir, {force: true, recursive: true})
@@ -154,9 +160,10 @@ test("recover refuses while a daemon is running", async () => {
154
160
  try {
155
161
  const result = await recoverOrphans({config, force: true})
156
162
 
157
- assert.ok("error" in result && /is using/.test(result.error))
163
+ expect("error" in result && /is using/.test(result.error)).toBeTruthy()
158
164
  } finally {
159
165
  await daemon.shutdown()
160
166
  await fs.rm(dir, {force: true, recursive: true})
161
167
  }
162
168
  })
169
+ })
@@ -1,10 +1,11 @@
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 ReleaseGroup from "../src/release-group.js"
6
5
  import {normalizeConfig} from "../src/config.js"
7
6
 
7
+ describe("release-group", () => {
8
+
8
9
  /**
9
10
  * @param {import("../src/json.js").JsonValue} webProcess - The single proxied process definition.
10
11
  * @param {() => boolean} [shouldStart] - Whether process starts remain allowed.
@@ -35,8 +36,8 @@ test("templates interpolate values from the daemon environment", () => {
35
36
  try {
36
37
  const managed = release.buildProcess(release.config.processes[0])
37
38
 
38
- assert.equal(managed.command, "run --token from-daemon")
39
- assert.equal(managed.env.DOWNSTREAM_TOKEN, "from-daemon")
39
+ expect(managed.command).toBe("run --token from-daemon")
40
+ expect(managed.env.DOWNSTREAM_TOKEN).toBe("from-daemon")
40
41
  } finally {
41
42
  delete process.env.ROLLBRIDGE_ENV_TEST
42
43
  }
@@ -56,15 +57,15 @@ test("replica processes get a replica index, count, and template context", () =>
56
57
  const workerConfig = release.config.processes[1]
57
58
  const replica = release.buildProcess(workerConfig, {count: 3, index: 1, instanceId: "worker#1"})
58
59
 
59
- assert.equal(replica.id, "worker#1")
60
- assert.equal(replica.command, "worker 1/3")
61
- assert.equal(replica.env.ROLLBRIDGE_REPLICA_INDEX, "1")
62
- assert.equal(replica.env.ROLLBRIDGE_REPLICA_COUNT, "3")
63
- assert.equal(replica.env.ROLLBRIDGE_PROCESS_ID, "worker")
64
- assert.equal(replica.env.SLOT, "1")
60
+ expect(replica.id).toBe("worker#1")
61
+ expect(replica.command).toBe("worker 1/3")
62
+ expect(replica.env.ROLLBRIDGE_REPLICA_INDEX).toBe("1")
63
+ expect(replica.env.ROLLBRIDGE_REPLICA_COUNT).toBe("3")
64
+ expect(replica.env.ROLLBRIDGE_PROCESS_ID).toBe("worker")
65
+ expect(replica.env.SLOT).toBe("1")
65
66
  })
66
67
 
67
- test("a referenced daemon environment variable that is unset fails fast", () => {
68
+ test("a referenced daemon environment variable that is unset fails fast", async () => {
68
69
  const release = buildRelease({
69
70
  command: "run {{env.ROLLBRIDGE_ENV_MISSING}}",
70
71
  id: "web",
@@ -74,10 +75,7 @@ test("a referenced daemon environment variable that is unset fails fast", () =>
74
75
 
75
76
  delete process.env.ROLLBRIDGE_ENV_MISSING
76
77
 
77
- assert.throws(
78
- () => release.buildProcess(release.config.processes[0]),
79
- /Missing template value for \{\{env.ROLLBRIDGE_ENV_MISSING\}\}/
80
- )
78
+ await expect(() => release.buildProcess(release.config.processes[0])).toThrow(/Missing template value for \{\{env.ROLLBRIDGE_ENV_MISSING\}\}/)
81
79
  })
82
80
 
83
81
  test("committed generation restoration does not start after shutdown begins", async () => {
@@ -92,6 +90,7 @@ test("committed generation restoration does not start after shutdown begins", as
92
90
  }
93
91
  release.processes.set("web", process)
94
92
 
95
- await assert.rejects(() => release.restartCommittedGeneration(), /shutting down/)
96
- assert.equal(starts, 0)
93
+ await expect(release.restartCommittedGeneration()).rejects.toThrow(/shutting down/)
94
+ expect(starts).toBe(0)
95
+ })
97
96
  })
@@ -1,14 +1,15 @@
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 {fileURLToPath} from "node:url"
9
8
  import RollbridgeDaemon, {releasesToPrune} from "../src/daemon.js"
10
9
  import {normalizeConfig} from "../src/config.js"
11
10
 
11
+ describe("release-retention", () => {
12
+
12
13
  const currentDir = path.dirname(fileURLToPath(import.meta.url))
13
14
  const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
14
15
 
@@ -23,7 +24,7 @@ test("releasesToPrune keeps the most recent stopped releases and never active or
23
24
 
24
25
  const remove = releasesToPrune(releases, {keep: 1, maxAgeMs: 0}, Date.parse("2026-05-22T00:00:10.000Z"))
25
26
 
26
- assert.deepEqual([...remove].sort(), ["v1", "v2"])
27
+ expect([...remove].sort()).toEqual(["v1", "v2"])
27
28
  })
28
29
 
29
30
  test("releasesToPrune keeps the later-deployed release when stoppedAt ties", () => {
@@ -36,7 +37,7 @@ test("releasesToPrune keeps the later-deployed release when stoppedAt ties", ()
36
37
 
37
38
  const remove = releasesToPrune(releases, {keep: 1, maxAgeMs: 0}, Date.parse("2026-05-22T00:00:10.000Z"))
38
39
 
39
- assert.deepEqual(remove, ["v1"])
40
+ expect(remove).toEqual(["v1"])
40
41
  })
41
42
 
42
43
  test("releasesToPrune prunes stopped releases older than maxAgeMs", () => {
@@ -48,7 +49,7 @@ test("releasesToPrune prunes stopped releases older than maxAgeMs", () => {
48
49
 
49
50
  const remove = releasesToPrune(releases, {keep: 100, maxAgeMs: 30000}, now)
50
51
 
51
- assert.deepEqual(remove, ["old"])
52
+ expect(remove).toEqual(["old"])
52
53
  })
53
54
 
54
55
  test("the daemon prunes stopped releases beyond the retention count across deploys", async () => {
@@ -82,9 +83,9 @@ test("the daemon prunes stopped releases beyond the retention count across deplo
82
83
 
83
84
  const ids = daemon.status().releases.map((release) => release.releaseId)
84
85
 
85
- assert.ok(ids.includes("v3"), `active release should be retained, got ${JSON.stringify(ids)}`)
86
- assert.ok(!ids.includes("v1"), `oldest stopped release should be pruned, got ${JSON.stringify(ids)}`)
87
- assert.ok(ids.length <= 2, `expected at most the active release plus one stopped, got ${JSON.stringify(ids)}`)
86
+ expect({value: Boolean(ids.includes("v3")), context: `active release should be retained, got ${JSON.stringify(ids)}`}).toMatchObject({value: true})
87
+ expect({value: Boolean(!ids.includes("v1")), context: `oldest stopped release should be pruned, got ${JSON.stringify(ids)}`}).toMatchObject({value: true})
88
+ expect({value: Boolean(ids.length <= 2), context: `expected at most the active release plus one stopped, got ${JSON.stringify(ids)}`}).toMatchObject({value: true})
88
89
  } finally {
89
90
  await daemon.shutdown()
90
91
  await fs.rm(root, {force: true, recursive: true})
@@ -105,3 +106,4 @@ async function waitFor(callback) {
105
106
 
106
107
  throw new Error("Timed out waiting for condition")
107
108
  }
109
+ })