rollbridge 0.1.54 → 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.
@@ -1,13 +1,14 @@
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 {normalizeConfig, validateConfig} from "../src/config.js"
9
8
  import {runCli} from "../src/cli.js"
10
9
 
10
+ describe("config-validation", () => {
11
+
11
12
  test("validateConfig collects duplicate ids, proxied ports, and policy combinations", () => {
12
13
  const {issues} = validateConfig({
13
14
  application: "demo",
@@ -20,9 +21,9 @@ test("validateConfig collects duplicate ids, proxied ports, and policy combinati
20
21
  })
21
22
  const messages = issues.map((issue) => issue.message)
22
23
 
23
- assert.ok(messages.includes("Duplicate process id: web"), `expected duplicate id issue in ${JSON.stringify(messages)}`)
24
- assert.ok(messages.includes("Proxied process web must define a port range"), `expected missing proxied port issue in ${JSON.stringify(messages)}`)
25
- assert.ok(issues.every((issue) => typeof issue.fix === "string" && issue.fix.length > 0), "every issue should include an example fix")
24
+ expect({value: Boolean(messages.includes("Duplicate process id: web")), context: `expected duplicate id issue in ${JSON.stringify(messages)}`}).toMatchObject({value: true})
25
+ expect({value: Boolean(messages.includes("Proxied process web must define a port range")), context: `expected missing proxied port issue in ${JSON.stringify(messages)}`}).toMatchObject({value: true})
26
+ expect(issues.every((issue) => typeof issue.fix === "string" && issue.fix.length > 0)).toBe(true)
26
27
  })
27
28
 
28
29
  test("validateConfig reports invalid ranges and missing proxied process without throwing", () => {
@@ -35,8 +36,8 @@ test("validateConfig reports invalid ranges and missing proxied process without
35
36
  })
36
37
  const messages = issues.map((issue) => issue.message)
37
38
 
38
- assert.ok(messages.includes("processes[0].port must be a positive port or valid {from, to} range"), `expected invalid range issue in ${JSON.stringify(messages)}`)
39
- assert.ok(messages.includes("Config must define exactly one proxied process; found 0"), `expected missing proxied process issue in ${JSON.stringify(messages)}`)
39
+ expect({value: Boolean(messages.includes("processes[0].port must be a positive port or valid {from, to} range")), context: `expected invalid range issue in ${JSON.stringify(messages)}`}).toMatchObject({value: true})
40
+ expect({value: Boolean(messages.includes("Config must define exactly one proxied process; found 0")), context: `expected missing proxied process issue in ${JSON.stringify(messages)}`}).toMatchObject({value: true})
40
41
  })
41
42
 
42
43
  test("validateConfig returns a normalized config and no issues for a valid config", () => {
@@ -49,10 +50,10 @@ test("validateConfig returns a normalized config and no issues for a valid confi
49
50
  proxy: {host: "127.0.0.1", port: 8182}
50
51
  })
51
52
 
52
- assert.deepEqual(issues, [])
53
- assert.equal(config.processes.length, 1)
54
- assert.equal(config.processes[0].policy, "proxied")
55
- assert.equal(config.proxy.port, 8182)
53
+ expect(issues).toEqual([])
54
+ expect(config.processes.length).toBe(1)
55
+ expect(config.processes[0].policy).toBe("proxied")
56
+ expect(config.proxy.port).toBe(8182)
56
57
  })
57
58
 
58
59
  test("validateConfig defaults wildcard proxy upstreams to loopback", () => {
@@ -65,9 +66,9 @@ test("validateConfig defaults wildcard proxy upstreams to loopback", () => {
65
66
  proxy: {host: "0.0.0.0", port: 8182}
66
67
  })
67
68
 
68
- assert.deepEqual(issues, [])
69
- assert.equal(config.proxy.host, "0.0.0.0")
70
- assert.equal(config.proxy.upstreamHost, "127.0.0.1")
69
+ expect(issues).toEqual([])
70
+ expect(config.proxy.host).toBe("0.0.0.0")
71
+ expect(config.proxy.upstreamHost).toBe("127.0.0.1")
71
72
  })
72
73
 
73
74
  test("validateConfig accepts legacy takeover screens and process matchers", () => {
@@ -87,8 +88,8 @@ test("validateConfig accepts legacy takeover screens and process matchers", () =
87
88
  proxy: {host: "127.0.0.1", port: 8182}
88
89
  })
89
90
 
90
- assert.deepEqual(issues, [])
91
- assert.deepEqual(config.legacyTakeover, {
91
+ expect(issues).toEqual([])
92
+ expect(config.legacyTakeover).toEqual({
92
93
  forceStopTimeoutMs: 250,
93
94
  processes: [
94
95
  {includes: ["/srv/demo/", "velocious server", "--port 4500"], name: "legacy web"}
@@ -108,7 +109,7 @@ test("validateConfig rejects empty legacy takeover config", () => {
108
109
  proxy: {host: "127.0.0.1", port: 8182}
109
110
  })
110
111
 
111
- assert.ok(issues.some((issue) => issue.message === "legacyTakeover must define at least one screen or process matcher"), JSON.stringify(issues))
112
+ expect({value: Boolean(issues.some((issue) => issue.message === "legacyTakeover must define at least one screen or process matcher")), context: JSON.stringify(issues)}).toMatchObject({value: true})
112
113
  })
113
114
 
114
115
  test("validateConfig defaults outputLines and accepts a positive override", () => {
@@ -122,9 +123,9 @@ test("validateConfig defaults outputLines and accepts a positive override", () =
122
123
  proxy: {host: "127.0.0.1", port: 8182}
123
124
  })
124
125
 
125
- assert.deepEqual(issues, [])
126
- assert.equal(config.processes[0].outputLines, 50)
127
- assert.equal(config.processes[1].outputLines, 5)
126
+ expect(issues).toEqual([])
127
+ expect(config.processes[0].outputLines).toBe(50)
128
+ expect(config.processes[1].outputLines).toBe(5)
128
129
  })
129
130
 
130
131
  test("validateConfig defaults the restart policy, accepts overrides, and rejects bad values", () => {
@@ -141,30 +142,30 @@ test("validateConfig defaults the restart policy, accepts overrides, and rejects
141
142
 
142
143
  const defaulted = validateRestart(undefined)
143
144
 
144
- assert.deepEqual(defaulted.issues, [])
145
- assert.deepEqual(defaulted.config.processes[0].restart, {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0})
145
+ expect(defaulted.issues).toEqual([])
146
+ expect(defaulted.config.processes[0].restart).toEqual({backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0})
146
147
 
147
148
  const custom = validateRestart({backoffFactor: 2, maxDelayMs: 30000, maxRestarts: 5, windowMs: 60000})
148
149
 
149
- assert.deepEqual(custom.issues, [])
150
- assert.deepEqual(custom.config.processes[0].restart, {backoffFactor: 2, maxDelayMs: 30000, maxRestarts: 5, windowMs: 60000})
150
+ expect(custom.issues).toEqual([])
151
+ expect(custom.config.processes[0].restart).toEqual({backoffFactor: 2, maxDelayMs: 30000, maxRestarts: 5, windowMs: 60000})
151
152
 
152
153
  // maxRestarts: 0 disables automatic restarts.
153
154
  const disabled = validateRestart({maxRestarts: 0})
154
155
 
155
- assert.deepEqual(disabled.issues, [])
156
- assert.equal(disabled.config.processes[0].restart.maxRestarts, 0)
156
+ expect(disabled.issues).toEqual([])
157
+ expect(disabled.config.processes[0].restart.maxRestarts).toBe(0)
157
158
 
158
159
  const invalid = validateRestart({backoffFactor: 0.5, maxDelayMs: -1, maxRestarts: -2, windowMs: -3})
159
160
  const messages = invalid.issues.map((issue) => issue.message)
160
161
 
161
- assert.ok(messages.includes("processes[0].restart.backoffFactor must be a number greater than or equal to 1"), JSON.stringify(messages))
162
- assert.ok(messages.includes("processes[0].restart.maxRestarts must be a non-negative integer"), JSON.stringify(messages))
163
- assert.ok(messages.includes("processes[0].restart.maxDelayMs must be a non-negative number"), JSON.stringify(messages))
164
- assert.ok(messages.includes("processes[0].restart.windowMs must be a non-negative number"), JSON.stringify(messages))
162
+ expect({value: Boolean(messages.includes("processes[0].restart.backoffFactor must be a number greater than or equal to 1")), context: JSON.stringify(messages)}).toMatchObject({value: true})
163
+ expect({value: Boolean(messages.includes("processes[0].restart.maxRestarts must be a non-negative integer")), context: JSON.stringify(messages)}).toMatchObject({value: true})
164
+ expect({value: Boolean(messages.includes("processes[0].restart.maxDelayMs must be a non-negative number")), context: JSON.stringify(messages)}).toMatchObject({value: true})
165
+ expect({value: Boolean(messages.includes("processes[0].restart.windowMs must be a non-negative number")), context: JSON.stringify(messages)}).toMatchObject({value: true})
165
166
 
166
167
  // A fractional maxRestarts is rejected (it must be a whole number of restarts).
167
- assert.ok(validateRestart({maxRestarts: 1.5}).issues.some((issue) => issue.message === "processes[0].restart.maxRestarts must be a non-negative integer"))
168
+ expect(validateRestart({maxRestarts: 1.5}).issues.some((issue) => issue.message === "processes[0].restart.maxRestarts must be a non-negative integer")).toBeTruthy()
168
169
  })
169
170
 
170
171
  test("validateConfig defaults lifecycle, accepts hooks, and rejects bad values", () => {
@@ -180,30 +181,30 @@ test("validateConfig defaults lifecycle, accepts hooks, and rejects bad values",
180
181
  })
181
182
 
182
183
  // Omitted → no commands, zero drain.
183
- assert.deepEqual(validateLifecycle(undefined).config.processes[0].lifecycle, {activateTimeoutMs: 30000, drainTimeoutMs: 0})
184
+ expect(validateLifecycle(undefined).config.processes[0].lifecycle).toEqual({activateTimeoutMs: 30000, drainTimeoutMs: 0})
184
185
 
185
186
  const custom = validateLifecycle({activateTimeoutMs: 60000, drainTimeoutMs: 30000, quietCommand: "kill -TSTP $ROLLBRIDGE_PID", stopCommand: "kill -TERM $ROLLBRIDGE_PID"})
186
187
 
187
- assert.deepEqual(custom.issues, [])
188
- assert.equal(custom.config.processes[0].lifecycle.quietCommand, "kill -TSTP $ROLLBRIDGE_PID")
189
- assert.equal(custom.config.processes[0].lifecycle.stopCommand, "kill -TERM $ROLLBRIDGE_PID")
190
- assert.equal(custom.config.processes[0].lifecycle.activateTimeoutMs, 60000)
191
- assert.equal(custom.config.processes[0].lifecycle.drainTimeoutMs, 30000)
188
+ expect(custom.issues).toEqual([])
189
+ expect(custom.config.processes[0].lifecycle.quietCommand).toBe("kill -TSTP $ROLLBRIDGE_PID")
190
+ expect(custom.config.processes[0].lifecycle.stopCommand).toBe("kill -TERM $ROLLBRIDGE_PID")
191
+ expect(custom.config.processes[0].lifecycle.activateTimeoutMs).toBe(60000)
192
+ expect(custom.config.processes[0].lifecycle.drainTimeoutMs).toBe(30000)
192
193
 
193
194
  const invalid = validateLifecycle({activateTimeoutMs: 0, drainTimeoutMs: -1, quietCommand: 5})
194
195
  const messages = invalid.issues.map((issue) => issue.message)
195
196
 
196
- assert.ok(messages.includes("processes[0].lifecycle.activateTimeoutMs must be a positive number"), JSON.stringify(messages))
197
- assert.ok(messages.includes("processes[0].lifecycle.drainTimeoutMs must be a non-negative number"), JSON.stringify(messages))
198
- assert.ok(messages.includes("processes[0].lifecycle.quietCommand must be a string"), JSON.stringify(messages))
197
+ expect({value: Boolean(messages.includes("processes[0].lifecycle.activateTimeoutMs must be a positive number")), context: JSON.stringify(messages)}).toMatchObject({value: true})
198
+ expect({value: Boolean(messages.includes("processes[0].lifecycle.drainTimeoutMs must be a non-negative number")), context: JSON.stringify(messages)}).toMatchObject({value: true})
199
+ expect({value: Boolean(messages.includes("processes[0].lifecycle.quietCommand must be a string")), context: JSON.stringify(messages)}).toMatchObject({value: true})
199
200
 
200
201
  // drainCommand needs a positive drainTimeoutMs to bound it; otherwise the drain step is skipped.
201
- assert.ok(validateLifecycle({drainCommand: "drain"}).issues
202
- .some((issue) => issue.message === "processes[0].lifecycle.drainCommand requires a positive processes[0].lifecycle.drainTimeoutMs"))
203
- assert.deepEqual(validateLifecycle({drainCommand: "drain", drainTimeoutMs: 1000}).issues, [])
202
+ expect(validateLifecycle({drainCommand: "drain"}).issues
203
+ .some((issue) => issue.message === "processes[0].lifecycle.drainCommand requires a positive processes[0].lifecycle.drainTimeoutMs")).toBeTruthy()
204
+ expect(validateLifecycle({drainCommand: "drain", drainTimeoutMs: 1000}).issues).toEqual([])
204
205
 
205
206
  // A stopCommand replaces stopSignal, so a stopCommand alongside the default SIGTERM is fine.
206
- assert.deepEqual(validateLifecycle({stopCommand: "kill -TERM $ROLLBRIDGE_PID"}).issues, [])
207
+ expect(validateLifecycle({stopCommand: "kill -TERM $ROLLBRIDGE_PID"}).issues).toEqual([])
207
208
  })
208
209
 
209
210
  test("validateConfig accepts one durable handoff activation lifecycle and rejects unsafe placements", () => {
@@ -227,31 +228,31 @@ test("validateConfig accepts one durable handoff activation lifecycle and reject
227
228
  }
228
229
  const valid = validateConfig(base)
229
230
 
230
- assert.deepEqual(valid.issues, [])
231
- assert.equal(valid.config.processes[1].lifecycle.activateCommand, "jobs activate")
231
+ expect(valid.issues).toEqual([])
232
+ expect(valid.config.processes[1].lifecycle.activateCommand).toBe("jobs activate")
232
233
 
233
234
  const invalidType = validateConfig({...base, processes: [base.processes[0], {...base.processes[1], lifecycle: {activateCommand: 5, quietCommand: "jobs retire"}}]})
234
- assert.ok(invalidType.issues.some((issue) => issue.message === "processes[1].lifecycle.activateCommand must be a string"))
235
+ expect(invalidType.issues.some((issue) => issue.message === "processes[1].lifecycle.activateCommand must be a string")).toBeTruthy()
235
236
 
236
237
  const emptyCommands = validateConfig({...base, processes: [base.processes[0], {...base.processes[1], lifecycle: {activateCommand: " ", quietCommand: ""}}]})
237
- assert.ok(emptyCommands.issues.some((issue) => issue.message === "processes[1].lifecycle.activateCommand must not be empty"))
238
- assert.ok(emptyCommands.issues.some((issue) => issue.message === "processes[1].lifecycle.quietCommand must not be empty"))
238
+ expect(emptyCommands.issues.some((issue) => issue.message === "processes[1].lifecycle.activateCommand must not be empty")).toBeTruthy()
239
+ expect(emptyCommands.issues.some((issue) => issue.message === "processes[1].lifecycle.quietCommand must not be empty")).toBeTruthy()
239
240
 
240
241
  const missingRetirement = validateConfig({...base, processes: [base.processes[0], {...base.processes[1], lifecycle: {activateCommand: "jobs activate"}}]})
241
- assert.ok(missingRetirement.issues.some((issue) => /requires lifecycle\.quietCommand/.test(issue.message)))
242
+ expect(missingRetirement.issues.some((issue) => /requires lifecycle\.quietCommand/.test(issue.message))).toBeTruthy()
242
243
 
243
244
  const nonHandoff = validateConfig({...base, processes: [base.processes[0], {...base.processes[1], deployStrategy: "persistent"}]})
244
- assert.ok(nonHandoff.issues.some((issue) => /activateCommand.*handoff service/.test(issue.message)))
245
+ expect(nonHandoff.issues.some((issue) => /activateCommand.*handoff service/.test(issue.message))).toBeTruthy()
245
246
 
246
247
  const withoutRecovery = validateConfig({...base, ownerRecovery: undefined, statePath: undefined})
247
- assert.ok(withoutRecovery.issues.some((issue) => /activateCommand requires ownerRecovery and statePath/.test(issue.message)))
248
+ expect(withoutRecovery.issues.some((issue) => /activateCommand requires ownerRecovery and statePath/.test(issue.message))).toBeTruthy()
248
249
 
249
250
  const duplicate = validateConfig({...base, processes: [
250
251
  base.processes[0],
251
252
  base.processes[1],
252
253
  {...base.processes[1], id: "jobs-secondary", port: {from: 18200, to: 18299}}
253
254
  ]})
254
- assert.ok(duplicate.issues.some((issue) => /at most one lifecycle\.activateCommand/.test(issue.message)))
255
+ expect(duplicate.issues.some((issue) => /at most one lifecycle\.activateCommand/.test(issue.message))).toBeTruthy()
255
256
 
256
257
  const worker = {
257
258
  command: "run worker",
@@ -262,14 +263,14 @@ test("validateConfig accepts one durable handoff activation lifecycle and reject
262
263
  }
263
264
  const pairedWorker = validateConfig({...base, processes: [...base.processes, worker]})
264
265
 
265
- assert.deepEqual(pairedWorker.issues, [])
266
- assert.equal(pairedWorker.config.processes[2].lifecycle.reactivateCommand, "worker resume")
266
+ expect(pairedWorker.issues).toEqual([])
267
+ expect(pairedWorker.config.processes[2].lifecycle.reactivateCommand).toBe("worker resume")
267
268
 
268
269
  const unpairedWorker = validateConfig({...base, processes: [...base.processes, {...worker, lifecycle: {quietCommand: "worker quiet"}}]})
269
- assert.ok(unpairedWorker.issues.some((issue) => /quietCommand requires lifecycle\.reactivateCommand/.test(issue.message)))
270
+ expect(unpairedWorker.issues.some((issue) => /quietCommand requires lifecycle\.reactivateCommand/.test(issue.message))).toBeTruthy()
270
271
 
271
272
  const unsupportedPlacement = validateConfig({...base, processes: [...base.processes, {...worker, nonBlockingDrain: false}]})
272
- assert.ok(unsupportedPlacement.issues.some((issue) => /reactivateCommand.*nonBlockingDrain companion/.test(issue.message)))
273
+ expect(unsupportedPlacement.issues.some((issue) => /reactivateCommand.*nonBlockingDrain companion/.test(issue.message))).toBeTruthy()
273
274
  })
274
275
 
275
276
  test("validateConfig accepts indefinite graceful stop windows", () => {
@@ -283,8 +284,8 @@ test("validateConfig accepts indefinite graceful stop windows", () => {
283
284
  proxy: {host: "127.0.0.1", port: 8182}
284
285
  })
285
286
 
286
- assert.deepEqual(issues, [])
287
- assert.equal(config.processes[1].gracefulStopMs, "indefinite")
287
+ expect(issues).toEqual([])
288
+ expect(config.processes[1].gracefulStopMs).toBe("indefinite")
288
289
  })
289
290
 
290
291
  test("validateConfig accepts handoff services only with a multi-port service range", () => {
@@ -298,8 +299,8 @@ test("validateConfig accepts handoff services only with a multi-port service ran
298
299
  proxy: {host: "127.0.0.1", port: 8182}
299
300
  })
300
301
 
301
- assert.deepEqual(valid.issues, [])
302
- assert.equal(valid.config.processes[1].deployStrategy, "handoff")
302
+ expect(valid.issues).toEqual([])
303
+ expect(valid.config.processes[1].deployStrategy).toBe("handoff")
303
304
 
304
305
  const defaulted = validateConfig({
305
306
  application: "demo",
@@ -308,7 +309,7 @@ test("validateConfig accepts handoff services only with a multi-port service ran
308
309
  proxy: {host: "127.0.0.1", port: 8182}
309
310
  })
310
311
 
311
- assert.equal(defaulted.config.processes[0].deployStrategy, "persistent")
312
+ expect(defaulted.config.processes[0].deployStrategy).toBe("persistent")
312
313
 
313
314
  const invalidProcess = validateConfig({
314
315
  application: "demo",
@@ -320,7 +321,7 @@ test("validateConfig accepts handoff services only with a multi-port service ran
320
321
  proxy: {host: "127.0.0.1", port: 8182}
321
322
  })
322
323
 
323
- assert.ok(invalidProcess.issues.some((issue) => issue.message === "Process \"worker\" can only set deployStrategy: \"handoff\" on a service process"), JSON.stringify(invalidProcess.issues))
324
+ expect({value: Boolean(invalidProcess.issues.some((issue) => issue.message === "Process \"worker\" can only set deployStrategy: \"handoff\" on a service process")), context: JSON.stringify(invalidProcess.issues)}).toMatchObject({value: true})
324
325
 
325
326
  const missingPort = validateConfig({
326
327
  application: "demo",
@@ -332,7 +333,7 @@ test("validateConfig accepts handoff services only with a multi-port service ran
332
333
  proxy: {host: "127.0.0.1", port: 8182}
333
334
  })
334
335
 
335
- assert.ok(missingPort.issues.some((issue) => issue.message === "Handoff service \"beacon\" must define a port range"), JSON.stringify(missingPort.issues))
336
+ expect({value: Boolean(missingPort.issues.some((issue) => issue.message === "Handoff service \"beacon\" must define a port range")), context: JSON.stringify(missingPort.issues)}).toMatchObject({value: true})
336
337
 
337
338
  const fixedPort = validateConfig({
338
339
  application: "demo",
@@ -344,7 +345,7 @@ test("validateConfig accepts handoff services only with a multi-port service ran
344
345
  proxy: {host: "127.0.0.1", port: 8182}
345
346
  })
346
347
 
347
- assert.ok(fixedPort.issues.some((issue) => issue.message === "Handoff service \"beacon\" must use a multi-port range"), JSON.stringify(fixedPort.issues))
348
+ expect({value: Boolean(fixedPort.issues.some((issue) => issue.message === "Handoff service \"beacon\" must use a multi-port range")), context: JSON.stringify(fixedPort.issues)}).toMatchObject({value: true})
348
349
  })
349
350
 
350
351
  test("validateConfig rejects a custom stopSignal alongside a stopCommand that would ignore it", () => {
@@ -360,18 +361,17 @@ test("validateConfig rejects a custom stopSignal alongside a stopCommand that wo
360
361
  })
361
362
 
362
363
  // A custom stopSignal with a stopCommand is contradictory: stopCommand runs instead of the signal.
363
- assert.ok(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}, stopSignal: "SIGINT"}).issues
364
- .some((issue) => /sets both lifecycle.stopCommand and a custom stopSignal/.test(issue.message)),
365
- "a custom stopSignal next to a stopCommand must be rejected")
364
+ expect(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}, stopSignal: "SIGINT"}).issues
365
+ .some((issue) => /sets both lifecycle.stopCommand and a custom stopSignal/.test(issue.message))).toBe(true)
366
366
 
367
367
  // stopSignal alone (no stopCommand) is fine — the signal is what stops the worker.
368
- assert.deepEqual(validateWorker({stopSignal: "SIGINT"}).issues, [])
368
+ expect(validateWorker({stopSignal: "SIGINT"}).issues).toEqual([])
369
369
 
370
370
  // stopCommand alone (default SIGTERM) is fine — nothing custom is silently dropped.
371
- assert.deepEqual(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}}).issues, [])
371
+ expect(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}}).issues).toEqual([])
372
372
 
373
373
  // An explicit default stopSignal next to a stopCommand is not flagged (SIGTERM is the default).
374
- assert.deepEqual(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}, stopSignal: "SIGTERM"}).issues, [])
374
+ expect(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}, stopSignal: "SIGTERM"}).issues).toEqual([])
375
375
  })
376
376
 
377
377
  test("validateConfig defaults replicas, accepts companion replicas, and rejects bad placements", () => {
@@ -386,39 +386,39 @@ test("validateConfig defaults replicas, accepts companion replicas, and rejects
386
386
  proxy: {host: "127.0.0.1", port: 8182}
387
387
  })
388
388
 
389
- assert.equal(validateWorker({command: "run worker", id: "worker", policy: "companion"}).config.processes[1].replicas, 1)
389
+ expect(validateWorker({command: "run worker", id: "worker", policy: "companion"}).config.processes[1].replicas).toBe(1)
390
390
 
391
391
  const replicated = validateWorker({command: "run worker", id: "worker", policy: "companion", replicas: 4})
392
392
 
393
- assert.deepEqual(replicated.issues, [])
394
- assert.equal(replicated.config.processes[1].replicas, 4)
393
+ expect(replicated.issues).toEqual([])
394
+ expect(replicated.config.processes[1].replicas).toBe(4)
395
395
 
396
396
  // replicas > 1 on a companion with a port is rejected.
397
- assert.ok(validateWorker({command: "run worker", id: "worker", policy: "companion", port: {from: 19000, to: 19099}, replicas: 2}).issues
398
- .some((issue) => /can only set replicas > 1 on a companion process without a port/.test(issue.message)))
397
+ expect(validateWorker({command: "run worker", id: "worker", policy: "companion", port: {from: 19000, to: 19099}, replicas: 2}).issues
398
+ .some((issue) => /can only set replicas > 1 on a companion process without a port/.test(issue.message))).toBeTruthy()
399
399
 
400
400
  // replicas > 1 on a non-companion policy is rejected.
401
- assert.ok(validateWorker({command: "run broker", id: "broker", policy: "service", replicas: 2}).issues
402
- .some((issue) => /can only set replicas > 1 on a companion/.test(issue.message)))
401
+ expect(validateWorker({command: "run broker", id: "broker", policy: "service", replicas: 2}).issues
402
+ .some((issue) => /can only set replicas > 1 on a companion/.test(issue.message))).toBeTruthy()
403
403
 
404
404
  // Non-positive replicas is rejected.
405
- assert.ok(validateWorker({command: "run worker", id: "worker", policy: "companion", replicas: 0}).issues
406
- .some((issue) => issue.message === "processes[1].replicas must be a positive integer"))
405
+ expect(validateWorker({command: "run worker", id: "worker", policy: "companion", replicas: 0}).issues
406
+ .some((issue) => issue.message === "processes[1].replicas must be a positive integer")).toBeTruthy()
407
407
 
408
408
  // A "#" in a process id (reserved for replica instance ids) is rejected.
409
- assert.ok(validateWorker({command: "run worker", id: "work#er", policy: "companion"}).issues
410
- .some((issue) => /must not contain "#"/.test(issue.message)))
409
+ expect(validateWorker({command: "run worker", id: "work#er", policy: "companion"}).issues
410
+ .some((issue) => /must not contain "#"/.test(issue.message))).toBeTruthy()
411
411
 
412
412
  // nonBlockingDrain defaults to false, is accepted on a companion, and rejected elsewhere.
413
- assert.equal(validateWorker({command: "run worker", id: "worker", policy: "companion"}).config.processes[1].nonBlockingDrain, false)
413
+ expect(validateWorker({command: "run worker", id: "worker", policy: "companion"}).config.processes[1].nonBlockingDrain).toBe(false)
414
414
 
415
415
  const draining = validateWorker({command: "run worker", id: "worker", nonBlockingDrain: true, policy: "companion"})
416
416
 
417
- assert.deepEqual(draining.issues, [])
418
- assert.equal(draining.config.processes[1].nonBlockingDrain, true)
417
+ expect(draining.issues).toEqual([])
418
+ expect(draining.config.processes[1].nonBlockingDrain).toBe(true)
419
419
 
420
- assert.ok(validateWorker({command: "run b", id: "broker", nonBlockingDrain: true, policy: "service"}).issues
421
- .some((issue) => /can only set nonBlockingDrain on a companion/.test(issue.message)))
420
+ expect(validateWorker({command: "run b", id: "broker", nonBlockingDrain: true, policy: "service"}).issues
421
+ .some((issue) => /can only set nonBlockingDrain on a companion/.test(issue.message))).toBeTruthy()
422
422
  })
423
423
 
424
424
  test("validateConfig defaults stopSignal, accepts valid signals, and rejects unknown ones", () => {
@@ -433,16 +433,16 @@ test("validateConfig defaults stopSignal, accepts valid signals, and rejects unk
433
433
  proxy: {host: "127.0.0.1", port: 8182}
434
434
  })
435
435
 
436
- assert.equal(validateStopSignal(undefined).config.processes[0].stopSignal, "SIGTERM")
436
+ expect(validateStopSignal(undefined).config.processes[0].stopSignal).toBe("SIGTERM")
437
437
 
438
438
  const custom = validateStopSignal("SIGINT")
439
439
 
440
- assert.deepEqual(custom.issues, [])
441
- assert.equal(custom.config.processes[0].stopSignal, "SIGINT")
440
+ expect(custom.issues).toEqual([])
441
+ expect(custom.config.processes[0].stopSignal).toBe("SIGINT")
442
442
 
443
443
  const invalid = validateStopSignal("SIGBOGUS")
444
444
 
445
- assert.ok(invalid.issues.some((issue) => issue.message === "processes[0].stopSignal must be a valid signal name"), JSON.stringify(invalid.issues.map((issue) => issue.message)))
445
+ expect({value: Boolean(invalid.issues.some((issue) => issue.message === "processes[0].stopSignal must be a valid signal name")), context: JSON.stringify(invalid.issues.map((issue) => issue.message))}).toMatchObject({value: true})
446
446
  })
447
447
 
448
448
  test("validateConfig normalizes memory supervision and rejects bad values", () => {
@@ -458,25 +458,25 @@ test("validateConfig normalizes memory supervision and rejects bad values", () =
458
458
  })
459
459
 
460
460
  // Omitted → monitoring off.
461
- assert.equal(validateMemory(undefined).config.processes[0].memory, undefined)
461
+ expect(validateMemory(undefined).config.processes[0].memory).toBe(undefined)
462
462
 
463
463
  const custom = validateMemory({checkIntervalMs: 2000, limitBytes: 1048576, warnBytes: 524288})
464
464
 
465
- assert.deepEqual(custom.issues, [])
466
- assert.deepEqual(custom.config.processes[0].memory, {checkIntervalMs: 2000, limitBytes: 1048576, warnBytes: 524288})
465
+ expect(custom.issues).toEqual([])
466
+ expect(custom.config.processes[0].memory).toEqual({checkIntervalMs: 2000, limitBytes: 1048576, warnBytes: 524288})
467
467
 
468
468
  // Defaults checkIntervalMs and warnBytes when only limitBytes is given.
469
469
  const defaulted = validateMemory({limitBytes: 1048576})
470
470
 
471
- assert.deepEqual(defaulted.issues, [])
472
- assert.deepEqual(defaulted.config.processes[0].memory, {checkIntervalMs: 5000, limitBytes: 1048576, warnBytes: 0})
471
+ expect(defaulted.issues).toEqual([])
472
+ expect(defaulted.config.processes[0].memory).toEqual({checkIntervalMs: 5000, limitBytes: 1048576, warnBytes: 0})
473
473
 
474
474
  const invalid = validateMemory({checkIntervalMs: 0, limitBytes: 0, warnBytes: -1})
475
475
  const messages = invalid.issues.map((issue) => issue.message)
476
476
 
477
- assert.ok(messages.includes("processes[0].memory.limitBytes must be a positive integer"), JSON.stringify(messages))
478
- assert.ok(messages.includes("processes[0].memory.warnBytes must be a non-negative integer"), JSON.stringify(messages))
479
- assert.ok(messages.includes("processes[0].memory.checkIntervalMs must be a positive number"), JSON.stringify(messages))
477
+ expect({value: Boolean(messages.includes("processes[0].memory.limitBytes must be a positive integer")), context: JSON.stringify(messages)}).toMatchObject({value: true})
478
+ expect({value: Boolean(messages.includes("processes[0].memory.warnBytes must be a non-negative integer")), context: JSON.stringify(messages)}).toMatchObject({value: true})
479
+ expect({value: Boolean(messages.includes("processes[0].memory.checkIntervalMs must be a positive number")), context: JSON.stringify(messages)}).toMatchObject({value: true})
480
480
  })
481
481
 
482
482
  test("validateConfig rejects a non-positive-integer outputLines with a fix", () => {
@@ -491,8 +491,8 @@ test("validateConfig rejects a non-positive-integer outputLines with a fix", ()
491
491
 
492
492
  const issue = issues.find((candidate) => candidate.message === "processes[0].outputLines must be a positive integer")
493
493
 
494
- assert.ok(issue, `expected an outputLines issue in ${JSON.stringify(issues.map((candidate) => candidate.message))}`)
495
- assert.match(issue.fix, /positive integer/)
494
+ if (!issue) throw new Error(`expected an outputLines issue in ${JSON.stringify(issues.map((candidate) => candidate.message))}`)
495
+ expect(issue.fix).toMatch(/positive integer/)
496
496
  })
497
497
 
498
498
  test("validateConfig parses control.mode, defaults it to unset, and rejects invalid modes", () => {
@@ -509,20 +509,20 @@ test("validateConfig parses control.mode, defaults it to unset, and rejects inva
509
509
 
510
510
  const parsed = validateControl({mode: "660", path: "/tmp/demo.sock"})
511
511
 
512
- assert.deepEqual(parsed.issues, [])
513
- assert.equal(parsed.config.control.mode, 0o660)
512
+ expect(parsed.issues).toEqual([])
513
+ expect(parsed.config.control.mode).toBe(0o660)
514
514
 
515
515
  // Minimal octal strings are accepted, matching the numeric boundary (e.g. 0).
516
516
  const minimal = validateControl({mode: "0", path: "/tmp/demo.sock"})
517
517
 
518
- assert.deepEqual(minimal.issues, [])
519
- assert.equal(minimal.config.control.mode, 0)
518
+ expect(minimal.issues).toEqual([])
519
+ expect(minimal.config.control.mode).toBe(0)
520
520
 
521
- assert.equal(validateControl({path: "/tmp/demo.sock"}).config.control.mode, undefined)
521
+ expect(validateControl({path: "/tmp/demo.sock"}).config.control.mode).toBe(undefined)
522
522
 
523
523
  const invalid = validateControl({mode: "abc", path: "/tmp/demo.sock"})
524
524
 
525
- assert.ok(invalid.issues.some((issue) => issue.message === "control.mode must be an octal file mode between 0 and 0o777"))
525
+ expect(invalid.issues.some((issue) => issue.message === "control.mode must be an octal file mode between 0 and 0o777")).toBeTruthy()
526
526
  })
527
527
 
528
528
  test("validateConfig accepts control owner/group as ids or names and rejects bad values", () => {
@@ -539,25 +539,25 @@ test("validateConfig accepts control owner/group as ids or names and rejects bad
539
539
 
540
540
  const numeric = validateControl({group: 1000, owner: 1000, path: "/tmp/demo.sock"})
541
541
 
542
- assert.deepEqual(numeric.issues, [])
543
- assert.equal(numeric.config.control.owner, 1000)
544
- assert.equal(numeric.config.control.group, 1000)
542
+ expect(numeric.issues).toEqual([])
543
+ expect(numeric.config.control.owner).toBe(1000)
544
+ expect(numeric.config.control.group).toBe(1000)
545
545
 
546
546
  const named = validateControl({group: "deploy", owner: "deploy", path: "/tmp/demo.sock"})
547
547
 
548
- assert.deepEqual(named.issues, [])
549
- assert.equal(named.config.control.owner, "deploy")
550
- assert.equal(named.config.control.group, "deploy")
548
+ expect(named.issues).toEqual([])
549
+ expect(named.config.control.owner).toBe("deploy")
550
+ expect(named.config.control.group).toBe("deploy")
551
551
 
552
552
  // Unset by default.
553
- assert.equal(validateControl({path: "/tmp/demo.sock"}).config.control.owner, undefined)
554
- assert.equal(validateControl({path: "/tmp/demo.sock"}).config.control.group, undefined)
553
+ expect(validateControl({path: "/tmp/demo.sock"}).config.control.owner).toBe(undefined)
554
+ expect(validateControl({path: "/tmp/demo.sock"}).config.control.group).toBe(undefined)
555
555
 
556
556
  const invalid = validateControl({group: -1, owner: true, path: "/tmp/demo.sock"})
557
557
  const messages = invalid.issues.map((issue) => issue.message)
558
558
 
559
- assert.ok(messages.includes("control.owner must be a non-negative integer id or a name"), JSON.stringify(messages))
560
- assert.ok(messages.includes("control.group must be a non-negative integer id or a name"), JSON.stringify(messages))
559
+ expect({value: Boolean(messages.includes("control.owner must be a non-negative integer id or a name")), context: JSON.stringify(messages)}).toMatchObject({value: true})
560
+ expect({value: Boolean(messages.includes("control.group must be a non-negative integer id or a name")), context: JSON.stringify(messages)}).toMatchObject({value: true})
561
561
  })
562
562
 
563
563
  test("validateConfig defaults health.startDelayMs to 0, accepts an override, and rejects negatives", () => {
@@ -574,17 +574,17 @@ test("validateConfig defaults health.startDelayMs to 0, accepts an override, and
574
574
 
575
575
  const defaulted = validateHealth({path: "/ping"})
576
576
 
577
- assert.deepEqual(defaulted.issues, [])
578
- assert.equal(defaulted.config.processes[0].health?.startDelayMs, 0)
577
+ expect(defaulted.issues).toEqual([])
578
+ expect(defaulted.config.processes[0].health?.startDelayMs).toBe(0)
579
579
 
580
580
  const custom = validateHealth({path: "/ping", startDelayMs: 2000})
581
581
 
582
- assert.deepEqual(custom.issues, [])
583
- assert.equal(custom.config.processes[0].health?.startDelayMs, 2000)
582
+ expect(custom.issues).toEqual([])
583
+ expect(custom.config.processes[0].health?.startDelayMs).toBe(2000)
584
584
 
585
585
  const negative = validateHealth({path: "/ping", startDelayMs: -1})
586
586
 
587
- assert.ok(negative.issues.some((issue) => issue.message === "processes[0].health.startDelayMs must be a non-negative number"))
587
+ expect(negative.issues.some((issue) => issue.message === "processes[0].health.startDelayMs must be a non-negative number")).toBeTruthy()
588
588
  })
589
589
 
590
590
  test("validateConfig defaults releaseRetention, accepts overrides, and rejects bad values", () => {
@@ -602,18 +602,18 @@ test("validateConfig defaults releaseRetention, accepts overrides, and rejects b
602
602
 
603
603
  const defaulted = validateRetention(undefined)
604
604
 
605
- assert.deepEqual(defaulted.issues, [])
606
- assert.deepEqual(defaulted.config.releaseRetention, {keep: 10, maxAgeMs: 0})
605
+ expect(defaulted.issues).toEqual([])
606
+ expect(defaulted.config.releaseRetention).toEqual({keep: 10, maxAgeMs: 0})
607
607
 
608
608
  const custom = validateRetention({keep: 3, maxAgeMs: 60000})
609
609
 
610
- assert.deepEqual(custom.issues, [])
611
- assert.deepEqual(custom.config.releaseRetention, {keep: 3, maxAgeMs: 60000})
610
+ expect(custom.issues).toEqual([])
611
+ expect(custom.config.releaseRetention).toEqual({keep: 3, maxAgeMs: 60000})
612
612
 
613
613
  const invalid = validateRetention({keep: -1, maxAgeMs: -5})
614
614
 
615
- assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.keep must be a non-negative integer"))
616
- assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.maxAgeMs must be a non-negative number"))
615
+ expect(invalid.issues.some((issue) => issue.message === "releaseRetention.keep must be a non-negative integer")).toBeTruthy()
616
+ expect(invalid.issues.some((issue) => issue.message === "releaseRetention.maxAgeMs must be a non-negative number")).toBeTruthy()
617
617
  })
618
618
 
619
619
  test("validateConfig leaves statePath unset by default, accepts a string, and rejects non-strings", () => {
@@ -629,14 +629,14 @@ test("validateConfig leaves statePath unset by default, accepts a string, and re
629
629
  statePath
630
630
  })
631
631
 
632
- assert.equal(validateStatePath(undefined).config.statePath, undefined)
632
+ expect(validateStatePath(undefined).config.statePath).toBe(undefined)
633
633
 
634
634
  const set = validateStatePath("/var/lib/rollbridge/demo.state.json")
635
635
 
636
- assert.deepEqual(set.issues, [])
637
- assert.equal(set.config.statePath, "/var/lib/rollbridge/demo.state.json")
636
+ expect(set.issues).toEqual([])
637
+ expect(set.config.statePath).toBe("/var/lib/rollbridge/demo.state.json")
638
638
 
639
- assert.ok(validateStatePath(123).issues.some((issue) => issue.message === "statePath must be a string"))
639
+ expect(validateStatePath(123).issues.some((issue) => issue.message === "statePath must be a string")).toBeTruthy()
640
640
  })
641
641
 
642
642
  test("ownerRecovery requires durable state and a non-negative integer reconnection grace", () => {
@@ -649,36 +649,31 @@ test("ownerRecovery requires durable state and a non-negative integer reconnecti
649
649
  }
650
650
  const missingState = validateConfig(raw)
651
651
 
652
- assert.ok(missingState.issues.some((issue) => issue.message === "ownerRecovery requires statePath"))
652
+ expect(missingState.issues.some((issue) => issue.message === "ownerRecovery requires statePath")).toBeTruthy()
653
653
 
654
654
  const valid = validateConfig({...raw, statePath: "/var/lib/rollbridge/demo.state.json"})
655
655
 
656
- assert.deepEqual(valid.issues, [])
657
- assert.deepEqual(valid.config.ownerRecovery, {reconnectGraceMs: 45000})
656
+ expect(valid.issues).toEqual([])
657
+ expect(valid.config.ownerRecovery).toEqual({reconnectGraceMs: 45000})
658
658
 
659
659
  const invalidGrace = validateConfig({...raw, ownerRecovery: {reconnectGraceMs: -1}, statePath: "/var/lib/rollbridge/demo.state.json"})
660
660
 
661
- assert.ok(invalidGrace.issues.some((issue) => issue.message === "ownerRecovery.reconnectGraceMs must be a non-negative integer"))
661
+ expect(invalidGrace.issues.some((issue) => issue.message === "ownerRecovery.reconnectGraceMs must be a non-negative integer")).toBeTruthy()
662
662
  })
663
663
 
664
- test("normalizeConfig throws an aggregated error listing every issue", () => {
665
- assert.throws(
666
- () => normalizeConfig({
667
- application: "demo",
668
- processes: [
669
- {command: "run web", id: "web", policy: "proxied"},
670
- {command: "run web", id: "web", policy: "proxied"}
671
- ],
672
- proxy: {port: 8182}
673
- }),
674
- (error) => {
675
- assert.ok(error instanceof Error)
676
- assert.match(error.message, /Duplicate process id: web/)
677
- assert.match(error.message, /exactly one proxied process; found 2/)
678
-
679
- return true
680
- }
681
- )
664
+ test("normalizeConfig throws an aggregated error listing every issue", async () => {
665
+ const normalization = Promise.resolve().then(() => normalizeConfig({
666
+ application: "demo",
667
+ processes: [
668
+ {command: "run web", id: "web", policy: "proxied"},
669
+ {command: "run web", id: "web", policy: "proxied"}
670
+ ],
671
+ proxy: {port: 8182}
672
+ }))
673
+
674
+ await expect(normalization).rejects.toBeInstanceOf(Error)
675
+ await expect(normalization).rejects.toMatchObject({message: expect.stringMatching(/Duplicate process id: web/)})
676
+ await expect(normalization).rejects.toMatchObject({message: expect.stringMatching(/exactly one proxied process; found 2/)})
682
677
  })
683
678
 
684
679
  test("validate CLI command reports every issue with a fix and exits non-zero", async () => {
@@ -693,9 +688,9 @@ test("validate CLI command reports every issue with a fix and exits non-zero", a
693
688
  try {
694
689
  const {output} = await captureCli(["node", "rollbridge", "validate", "-c", configPath])
695
690
 
696
- assert.equal(process.exitCode, 1)
697
- assert.match(output, /Proxied process web must define a port range/)
698
- assert.match(output, /Fix: Add a port range to the proxied process "web"/)
691
+ expect(process.exitCode).toBe(1)
692
+ expect(output).toMatch(/Proxied process web must define a port range/)
693
+ expect(output).toMatch(/Fix: Add a port range to the proxied process "web"/)
699
694
  } finally {
700
695
  process.exitCode = 0
701
696
  await fs.rm(path.dirname(configPath), {force: true, recursive: true})
@@ -715,8 +710,8 @@ test("validate CLI command accepts a valid config without setting a failure exit
715
710
  try {
716
711
  const {output} = await captureCli(["node", "rollbridge", "validate", "-c", configPath])
717
712
 
718
- assert.notEqual(process.exitCode, 1)
719
- assert.match(output, /is valid: 1 process, proxy on 127\.0\.0\.1:8182\./)
713
+ expect(process.exitCode).not.toBe(1)
714
+ expect(output).toMatch(/is valid: 1 process, proxy on 127\.0\.0\.1:8182\./)
720
715
  } finally {
721
716
  await fs.rm(path.dirname(configPath), {force: true, recursive: true})
722
717
  }
@@ -738,17 +733,17 @@ test("validate --json emits a machine-readable result", async () => {
738
733
  try {
739
734
  const valid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", validPath])).output)
740
735
 
741
- assert.equal(valid.valid, true)
742
- assert.deepEqual(valid.issues, [])
743
- assert.equal(valid.config.processes, 1)
744
- assert.notEqual(process.exitCode, 1)
736
+ expect(valid.valid).toBe(true)
737
+ expect(valid.issues).toEqual([])
738
+ expect(valid.config.processes).toBe(1)
739
+ expect(process.exitCode).not.toBe(1)
745
740
 
746
741
  const invalid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", invalidPath])).output)
747
742
 
748
- assert.equal(invalid.valid, false)
749
- assert.equal(invalid.config, null)
750
- assert.ok(invalid.issues.some((/** @type {{message: string}} */ issue) => /must define a port range/.test(issue.message)))
751
- assert.equal(process.exitCode, 1)
743
+ expect(invalid.valid).toBe(false)
744
+ expect(invalid.config).toBe(null)
745
+ expect(invalid.issues.some((/** @type {{message: string}} */ issue) => /must define a port range/.test(issue.message))).toBeTruthy()
746
+ expect(process.exitCode).toBe(1)
752
747
  } finally {
753
748
  process.exitCode = 0
754
749
  await fs.rm(path.dirname(validPath), {force: true, recursive: true})
@@ -794,3 +789,4 @@ async function captureCli(argv) {
794
789
 
795
790
  return {output: lines.join("\n")}
796
791
  }
792
+ })