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
|
@@ -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 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 {fileURLToPath} from "node:url"
|
|
12
11
|
import {sendControlCommand} from "../src/control-client.js"
|
|
13
12
|
import GuardianClient from "../src/guardian-client.js"
|
|
14
13
|
|
|
14
|
+
describe("release-runtime-retention", () => {
|
|
15
|
+
|
|
15
16
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
16
17
|
const dummyAppPath = path.join(repoRoot, "test", "fixtures", "dummy-app.js")
|
|
17
18
|
|
|
@@ -63,22 +64,22 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
|
|
|
63
64
|
const response = await fetch(`http://127.0.0.1:${proxyPort}/deferred-runtime`)
|
|
64
65
|
const runtime = /** @type {{digest: string, format: number, path: string, version: string}} */ (status.daemonRuntime)
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
expect(status.activeReleaseId).toBe("B")
|
|
68
|
+
expect(runtime.format).toBe(1)
|
|
69
|
+
expect(runtime.digest).toMatch(/^[a-f0-9]{64}$/)
|
|
70
|
+
expect(path.dirname(runtime.path)).toBe(runtimePath)
|
|
71
|
+
expect({value: Boolean(!runtime.path.startsWith(releaseA)), context: `runtime must be outside release A: ${runtime.path}`}).toMatchObject({value: true})
|
|
72
|
+
expect(response.status).toBe(200)
|
|
73
|
+
expect(await response.text()).toBe("deferred runtime loaded\n")
|
|
74
|
+
expect(typeof status.daemonPid).toBe("number")
|
|
74
75
|
const daemonPid = /** @type {number} */ (status.daemonPid)
|
|
75
76
|
|
|
76
77
|
process.kill(daemonPid, "SIGKILL")
|
|
77
78
|
const recoveredPid = await waitForChangedPid(pidPath, daemonPid)
|
|
78
79
|
const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
expect(recovered.daemonPid).toBe(recoveredPid)
|
|
82
|
+
expect(recovered.activeReleaseId).toBe("B")
|
|
82
83
|
} finally {
|
|
83
84
|
try {
|
|
84
85
|
await sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
@@ -124,7 +125,7 @@ test("ensure-daemon resolves an explicit relative config before changing to its
|
|
|
124
125
|
])
|
|
125
126
|
const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
expect(status.activeReleaseId).toBe("relative-config")
|
|
128
129
|
} finally {
|
|
129
130
|
await sendControlCommand({command: {command: "shutdown"}, path: socketPath}).catch(() => undefined)
|
|
130
131
|
await stopGuardian(statePath)
|
|
@@ -147,15 +148,12 @@ test("runtime preparation failure prevents daemon startup and deploy handoff", a
|
|
|
147
148
|
await fs.writeFile(invalidRuntimePath, "occupied\n")
|
|
148
149
|
await fs.writeFile(configPath, `export default ${JSON.stringify(basicConfig(socketPath), null, 2)}\n`)
|
|
149
150
|
|
|
150
|
-
await
|
|
151
|
-
() => runReleaseCli(release, [
|
|
151
|
+
await expect(runReleaseCli(release, [
|
|
152
152
|
"deploy", "--ensure-daemon", "--config", configPath,
|
|
153
153
|
"--release-path", release, "--release-id", "blocked",
|
|
154
154
|
"--daemon-runtime-path", invalidRuntimePath
|
|
155
|
-
])
|
|
156
|
-
|
|
157
|
-
)
|
|
158
|
-
await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
|
|
155
|
+
])).rejects.toThrow(/EEXIST|not a directory|ENOTDIR/)
|
|
156
|
+
await expect(fs.stat(socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
159
157
|
} finally {
|
|
160
158
|
await fs.rm(root, {force: true, recursive: true})
|
|
161
159
|
}
|
|
@@ -185,16 +183,13 @@ test("ensure-daemon refuses a responsive legacy daemon before deploy", async ()
|
|
|
185
183
|
legacyDaemon.listen(socketPath, () => resolve(undefined))
|
|
186
184
|
})
|
|
187
185
|
|
|
188
|
-
await
|
|
189
|
-
() => runReleaseCli(release, [
|
|
186
|
+
await expect(runReleaseCli(release, [
|
|
190
187
|
"deploy", "--ensure-daemon", "--config", configPath,
|
|
191
188
|
"--release-path", release, "--release-id", "must-not-deploy",
|
|
192
189
|
"--daemon-runtime-path", path.join(root, "runtime")
|
|
193
|
-
])
|
|
194
|
-
/legacy or mismatched runtime.*deploy was not sent/s
|
|
195
|
-
)
|
|
190
|
+
])).rejects.toThrow(/legacy or mismatched runtime.*deploy was not sent/s)
|
|
196
191
|
|
|
197
|
-
|
|
192
|
+
expect(deployReceived).toBe(false)
|
|
198
193
|
} finally {
|
|
199
194
|
await new Promise((resolve) => legacyDaemon.close(resolve))
|
|
200
195
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -230,15 +225,12 @@ test("ensure-daemon refuses a mismatched runtime attestation before deploy", asy
|
|
|
230
225
|
daemon.listen(socketPath, () => resolve(undefined))
|
|
231
226
|
})
|
|
232
227
|
|
|
233
|
-
await
|
|
234
|
-
() => runReleaseCli(release, [
|
|
228
|
+
await expect(runReleaseCli(release, [
|
|
235
229
|
"deploy", "--ensure-daemon", "--config", configPath,
|
|
236
230
|
"--release-path", release, "--release-id", "must-not-deploy",
|
|
237
231
|
"--daemon-runtime-path", path.join(root, "runtime")
|
|
238
|
-
])
|
|
239
|
-
|
|
240
|
-
)
|
|
241
|
-
assert.equal(deployReceived, false)
|
|
232
|
+
])).rejects.toThrow(/legacy or mismatched runtime.*deploy was not sent/s)
|
|
233
|
+
expect(deployReceived).toBe(false)
|
|
242
234
|
} finally {
|
|
243
235
|
await new Promise((resolve) => daemon.close(resolve))
|
|
244
236
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -271,15 +263,14 @@ test("concurrent startup loser re-attests the winner before sending deploy", asy
|
|
|
271
263
|
proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0}
|
|
272
264
|
}, null, 2)}\n`)
|
|
273
265
|
|
|
274
|
-
const loserRejected =
|
|
275
|
-
runReleaseCli(loserRelease, [
|
|
266
|
+
const loserRejected = (async () => {
|
|
267
|
+
await expect(runReleaseCli(loserRelease, [
|
|
276
268
|
"deploy", "--ensure-daemon", "--config", configPath,
|
|
277
269
|
"--release-path", loserRelease, "--release-id", "loser",
|
|
278
270
|
"--daemon-log-path", path.join(root, "loser.log"), "--daemon-pid-path", loserPidPath,
|
|
279
271
|
"--daemon-runtime-path", path.join(root, "loser-runtime")
|
|
280
|
-
])
|
|
281
|
-
|
|
282
|
-
)
|
|
272
|
+
])).rejects.toThrow(/legacy or mismatched runtime.*deploy was not sent/s)
|
|
273
|
+
})()
|
|
283
274
|
|
|
284
275
|
await waitForFile(loserPausedPath)
|
|
285
276
|
await runReleaseCli(winnerRelease, [
|
|
@@ -292,7 +283,7 @@ test("concurrent startup loser re-attests the winner before sending deploy", asy
|
|
|
292
283
|
await loserRejected
|
|
293
284
|
const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
294
285
|
|
|
295
|
-
|
|
286
|
+
expect(status.activeReleaseId).toBe("winner")
|
|
296
287
|
} finally {
|
|
297
288
|
try {
|
|
298
289
|
await sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
@@ -343,7 +334,7 @@ async function prepareRelease(releasePath, deferredImport) {
|
|
|
343
334
|
const marker = " proxyHttp(request, response) {\n"
|
|
344
335
|
const deferredRoute = `${marker} if (request.url === "/deferred-runtime") {\n void import("./deferred-runtime.js")\n .then(({default: body}) => { response.writeHead(200); response.end(body) })\n .catch((error) => { response.writeHead(500); response.end(String(error)) })\n return\n }\n\n`
|
|
345
336
|
|
|
346
|
-
|
|
337
|
+
expect(source.includes(marker)).toBeTruthy()
|
|
347
338
|
await fs.writeFile(daemonPath, source.replace(marker, deferredRoute))
|
|
348
339
|
await fs.writeFile(path.join(packagePath, "src", "deferred-runtime.js"), "export default \"deferred runtime loaded\\n\"\n")
|
|
349
340
|
}
|
|
@@ -360,7 +351,7 @@ async function installStartupPause(releasePath, pausedPath) {
|
|
|
360
351
|
const marker = " const candidate = await startDaemonProcess({\n"
|
|
361
352
|
const pause = ` await fsPromises.writeFile(${JSON.stringify(pausedPath)}, "paused\\n")\n await new Promise((resolve) => setTimeout(resolve, 750))\n\n${marker}`
|
|
362
353
|
|
|
363
|
-
|
|
354
|
+
expect(source.includes(marker)).toBeTruthy()
|
|
364
355
|
await fs.writeFile(cliPath, source.replace(marker, pause))
|
|
365
356
|
}
|
|
366
357
|
|
|
@@ -475,3 +466,4 @@ async function stopGuardian(statePath) {
|
|
|
475
466
|
if (!error || typeof error !== "object" || !("code" in error) || !["ENOENT", "ESRCH"].includes(String(error.code))) throw error
|
|
476
467
|
}
|
|
477
468
|
}
|
|
469
|
+
})
|