rollbridge 0.1.2 → 0.1.5

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.
@@ -0,0 +1,228 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import fs from "node:fs/promises"
5
+ import net from "node:net"
6
+ import os from "node:os"
7
+ import path from "node:path"
8
+ import test from "node:test"
9
+ import RollbridgeDaemon from "../src/daemon.js"
10
+ import {normalizeConfig} from "../src/config.js"
11
+ import {runEnvironmentChecks} from "../src/doctor.js"
12
+ import {runCli} from "../src/cli.js"
13
+
14
+ /**
15
+ * @param {object} args - Options.
16
+ * @param {string} args.controlPath - Control socket path.
17
+ * @param {number} args.proxyPort - Proxy port.
18
+ * @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
19
+ */
20
+ function buildConfig({controlPath, proxyPort}) {
21
+ return normalizeConfig({
22
+ application: "doctor-test",
23
+ control: {path: controlPath},
24
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
25
+ proxy: {host: "127.0.0.1", port: proxyPort}
26
+ })
27
+ }
28
+
29
+ /**
30
+ * @returns {Promise<number>} A port that was free when probed.
31
+ */
32
+ async function freePort() {
33
+ return await new Promise((resolve) => {
34
+ const server = net.createServer()
35
+
36
+ server.listen(0, "127.0.0.1", () => {
37
+ const address = server.address()
38
+ const port = address && typeof address === "object" ? address.port : 0
39
+
40
+ server.close(() => resolve(port))
41
+ })
42
+ })
43
+ }
44
+
45
+ /**
46
+ * @returns {Promise<{port: number, server: import("node:net").Server}>} An occupied port and its server.
47
+ */
48
+ async function occupyPort() {
49
+ const server = net.createServer()
50
+ const port = await new Promise((resolve) => {
51
+ server.listen(0, "127.0.0.1", () => {
52
+ const address = server.address()
53
+
54
+ resolve(address && typeof address === "object" ? address.port : 0)
55
+ })
56
+ })
57
+
58
+ return {port, server}
59
+ }
60
+
61
+ /**
62
+ * @param {DoctorCheck[]} checks - Checks.
63
+ * @param {string} name - Check name.
64
+ * @returns {DoctorCheck} The matching check.
65
+ * @typedef {import("../src/doctor.js").DoctorCheck} DoctorCheck
66
+ */
67
+ function checkNamed(checks, name) {
68
+ const check = checks.find((candidate) => candidate.name === name)
69
+
70
+ assert.ok(check, `expected a "${name}" check`)
71
+
72
+ return check
73
+ }
74
+
75
+ test("runEnvironmentChecks passes when no daemon runs, the port is free, and the directory is writable", async () => {
76
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
77
+
78
+ try {
79
+ const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort()}))
80
+
81
+ assert.equal(checkNamed(checks, "control socket").ok, true)
82
+ assert.equal(checkNamed(checks, "control socket directory").ok, true)
83
+ assert.equal(checkNamed(checks, "proxy port").ok, true)
84
+ } finally {
85
+ await fs.rm(root, {force: true, recursive: true})
86
+ }
87
+ })
88
+
89
+ test("runEnvironmentChecks reports an unavailable proxy port", async () => {
90
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
91
+ const {port, server} = await occupyPort()
92
+
93
+ try {
94
+ const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: port}))
95
+ const proxyCheck = checkNamed(checks, "proxy port")
96
+
97
+ assert.equal(proxyCheck.ok, false)
98
+ assert.match(proxyCheck.detail, /unavailable/)
99
+ } finally {
100
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
101
+ await fs.rm(root, {force: true, recursive: true})
102
+ }
103
+ })
104
+
105
+ test("runEnvironmentChecks reports a missing control socket directory", async () => {
106
+ const checks = await runEnvironmentChecks(buildConfig({controlPath: "/rollbridge-doctor-missing-dir/rollbridge.sock", proxyPort: await freePort()}))
107
+ const directoryCheck = checkNamed(checks, "control socket directory")
108
+
109
+ assert.equal(directoryCheck.ok, false)
110
+ assert.match(directoryCheck.detail, /missing or not writable/)
111
+ })
112
+
113
+ test("runEnvironmentChecks fails when a Rollbridge daemon already holds the socket and port", async () => {
114
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
115
+ const config = buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort()})
116
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
117
+
118
+ await daemon.start()
119
+
120
+ try {
121
+ const checks = await runEnvironmentChecks(config)
122
+ const socketCheck = checkNamed(checks, "control socket")
123
+
124
+ // A daemon already running means `rollbridge daemon` would fail to bind, so doctor must fail too.
125
+ assert.equal(socketCheck.ok, false)
126
+ assert.match(socketCheck.detail, /a Rollbridge daemon for "doctor-test" is already running/)
127
+ assert.equal(checkNamed(checks, "proxy port").ok, false)
128
+ } finally {
129
+ await daemon.shutdown()
130
+ await fs.rm(root, {force: true, recursive: true})
131
+ }
132
+ })
133
+
134
+ /**
135
+ * Runs the CLI while capturing console output.
136
+ * @param {string[]} argv - Process argv.
137
+ * @returns {Promise<string>} Captured stdout and stderr lines.
138
+ */
139
+ async function captureCli(argv) {
140
+ const originalLog = console.log
141
+ const originalError = console.error
142
+ /** @type {string[]} */
143
+ const lines = []
144
+ const collect = (/** @type {string[]} */ ...args) => { lines.push(args.map((arg) => String(arg)).join(" ")) }
145
+
146
+ console.log = collect
147
+ console.error = collect
148
+
149
+ try {
150
+ await runCli(argv)
151
+ } finally {
152
+ console.log = originalLog
153
+ console.error = originalError
154
+ }
155
+
156
+ return lines.join("\n")
157
+ }
158
+
159
+ test("doctor CLI passes for a valid, bindable config", async () => {
160
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
161
+ const rawConfig = {
162
+ application: "doctor-cli-test",
163
+ control: {path: path.join(root, "rollbridge.sock")},
164
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
165
+ proxy: {host: "127.0.0.1", port: await freePort()}
166
+ }
167
+
168
+ await fs.writeFile(path.join(root, "rollbridge.js"), `module.exports = ${JSON.stringify(rawConfig)}\n`)
169
+
170
+ try {
171
+ const output = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js")])
172
+
173
+ assert.match(output, /All checks passed\./)
174
+ assert.notEqual(process.exitCode, 1)
175
+ } finally {
176
+ await fs.rm(root, {force: true, recursive: true})
177
+ }
178
+ })
179
+
180
+ test("doctor CLI fails and exits non-zero for an invalid config", async () => {
181
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
182
+
183
+ await fs.writeFile(path.join(root, "rollbridge.js"), "module.exports = {application: \"x\", proxy: {port: 8182}, processes: []}\n")
184
+
185
+ try {
186
+ const output = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js")])
187
+
188
+ assert.equal(process.exitCode, 1)
189
+ assert.match(output, /✗ config:/)
190
+ } finally {
191
+ process.exitCode = 0
192
+ await fs.rm(root, {force: true, recursive: true})
193
+ }
194
+ })
195
+
196
+ test("doctor --json emits structured checks", async () => {
197
+ const okRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
198
+ const okConfig = {
199
+ application: "doctor-json-test",
200
+ control: {path: path.join(okRoot, "rollbridge.sock")},
201
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
202
+ proxy: {host: "127.0.0.1", port: await freePort()}
203
+ }
204
+
205
+ await fs.writeFile(path.join(okRoot, "rollbridge.js"), `module.exports = ${JSON.stringify(okConfig)}\n`)
206
+
207
+ const badRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
208
+
209
+ await fs.writeFile(path.join(badRoot, "rollbridge.js"), "module.exports = {application: \"x\", proxy: {port: 8182}, processes: []}\n")
210
+
211
+ try {
212
+ const passing = JSON.parse(await captureCli(["node", "rollbridge", "doctor", "--json", "-c", path.join(okRoot, "rollbridge.js")]))
213
+
214
+ assert.equal(passing.ok, true)
215
+ assert.ok(passing.checks.some((/** @type {{name: string, ok: boolean}} */ check) => check.name === "proxy port" && check.ok === true))
216
+ assert.notEqual(process.exitCode, 1)
217
+
218
+ const failing = JSON.parse(await captureCli(["node", "rollbridge", "doctor", "--json", "-c", path.join(badRoot, "rollbridge.js")]))
219
+
220
+ assert.equal(failing.ok, false)
221
+ assert.ok(failing.checks.some((/** @type {{name: string, ok: boolean}} */ check) => check.name === "config" && check.ok === false))
222
+ assert.equal(process.exitCode, 1)
223
+ } finally {
224
+ process.exitCode = 0
225
+ await fs.rm(okRoot, {force: true, recursive: true})
226
+ await fs.rm(badRoot, {force: true, recursive: true})
227
+ }
228
+ })
@@ -0,0 +1,2 @@
1
+ // Exits non-zero shortly after starting, to exercise auto-restart behavior.
2
+ setTimeout(() => process.exit(1), 40)
@@ -0,0 +1,63 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import http from "node:http"
5
+ import test from "node:test"
6
+ import {waitForHealth} from "../src/health.js"
7
+
8
+ /**
9
+ * Starts a health server that records when it first receives a probe.
10
+ * @returns {Promise<{firstProbeDelay: () => number, port: number, close: () => Promise<void>}>} Server handle.
11
+ */
12
+ async function startHealthServer() {
13
+ const start = Date.now()
14
+ let firstProbeAt = 0
15
+ const server = http.createServer((request, response) => {
16
+ if (firstProbeAt === 0) firstProbeAt = Date.now()
17
+
18
+ response.writeHead(200, {"Content-Type": "text/plain"})
19
+ response.end("ok")
20
+ })
21
+
22
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(undefined)))
23
+
24
+ const address = server.address()
25
+
26
+ return {
27
+ close: () => new Promise((resolve) => server.close(() => resolve(undefined))),
28
+ firstProbeDelay: () => firstProbeAt - start,
29
+ port: address && typeof address === "object" ? address.port : 0
30
+ }
31
+ }
32
+
33
+ test("waitForHealth delays the first probe by startDelayMs", async () => {
34
+ const server = await startHealthServer()
35
+
36
+ try {
37
+ await waitForHealth({
38
+ health: {intervalMs: 25, path: "/ping", startDelayMs: 200, timeoutMs: 2000},
39
+ host: "127.0.0.1",
40
+ port: server.port
41
+ })
42
+
43
+ assert.ok(server.firstProbeDelay() >= 180, `expected first probe to be delayed ~200ms, was ${server.firstProbeDelay()}ms`)
44
+ } finally {
45
+ await server.close()
46
+ }
47
+ })
48
+
49
+ test("waitForHealth probes immediately when startDelayMs is 0", async () => {
50
+ const server = await startHealthServer()
51
+
52
+ try {
53
+ await waitForHealth({
54
+ health: {intervalMs: 25, path: "/ping", startDelayMs: 0, timeoutMs: 2000},
55
+ host: "127.0.0.1",
56
+ port: server.port
57
+ })
58
+
59
+ assert.ok(server.firstProbeDelay() < 150, `expected an immediate first probe, was ${server.firstProbeDelay()}ms`)
60
+ } finally {
61
+ await server.close()
62
+ }
63
+ })
@@ -0,0 +1,99 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import fs from "node:fs/promises"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+ import test from "node:test"
8
+ import {fileURLToPath} from "node:url"
9
+ import RollbridgeDaemon from "../src/daemon.js"
10
+ import {normalizeConfig} from "../src/config.js"
11
+ import {formatLogSources, runCli} from "../src/cli.js"
12
+
13
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
14
+ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
15
+
16
+ test("formatLogSources renders a section per process with timestamped lines", () => {
17
+ const output = formatLogSources([
18
+ {id: "web", logs: [{at: "2026-05-22T00:00:00.000Z", line: "listening", stream: "stdout"}], source: "release v1 (active)"},
19
+ {id: "beacon", logs: [], source: "service"}
20
+ ], undefined)
21
+
22
+ assert.match(output, /== web \[release v1 \(active\)\] ==/)
23
+ assert.match(output, /2026-05-22T00:00:00\.000Z \[stdout\] listening/)
24
+ assert.match(output, /== beacon \[service\] ==/)
25
+ assert.match(output, /\(no recent output\)/)
26
+ })
27
+
28
+ test("formatLogSources filters to a single process id", () => {
29
+ const sources = [
30
+ {id: "web", logs: [], source: "release v1 (active)"},
31
+ {id: "beacon", logs: [], source: "service"}
32
+ ]
33
+
34
+ const output = formatLogSources(sources, "web")
35
+
36
+ assert.match(output, /== web /)
37
+ assert.doesNotMatch(output, /beacon/)
38
+ })
39
+
40
+ test("formatLogSources reports when there are no processes or no match", () => {
41
+ assert.equal(formatLogSources([], undefined), "No managed processes.")
42
+ assert.equal(
43
+ formatLogSources([{id: "web", logs: [], source: "release v1 (active)"}], "missing"),
44
+ 'No process found with id "missing".'
45
+ )
46
+ })
47
+
48
+ test("logs CLI prints captured output per managed process", async () => {
49
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-logs-"))
50
+ const socketPath = path.join(root, "rollbridge.sock")
51
+ const rawConfig = {
52
+ application: "rollbridge-logs-test",
53
+ control: {path: socketPath},
54
+ processes: [
55
+ {
56
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
57
+ health: {intervalMs: 50, path: "/ping", timeoutMs: 3000},
58
+ id: "web",
59
+ policy: "proxied",
60
+ port: {from: 0, to: 0}
61
+ }
62
+ ],
63
+ proxy: {host: "127.0.0.1", port: 0}
64
+ }
65
+
66
+ // CommonJS config file so the CLI can load it from a temp dir on any Node version.
67
+ await fs.writeFile(path.join(root, "rollbridge.js"), `module.exports = ${JSON.stringify(rawConfig)}\n`)
68
+
69
+ const daemon = new RollbridgeDaemon({config: normalizeConfig(rawConfig), logger: () => {}})
70
+
71
+ await daemon.start()
72
+
73
+ const originalLog = console.log
74
+ /** @type {string[]} */
75
+ const lines = []
76
+
77
+ console.log = (/** @type {string[]} */ ...args) => { lines.push(args.map((arg) => String(arg)).join(" ")) }
78
+
79
+ try {
80
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
81
+ await runCli(["node", "rollbridge", "logs", "-c", path.join(root, "rollbridge.js")])
82
+
83
+ assert.match(lines.join("\n"), /== web \[release v1 \(active\)\] ==/)
84
+
85
+ lines.length = 0
86
+ await runCli(["node", "rollbridge", "logs", "--json", "-c", path.join(root, "rollbridge.js")])
87
+
88
+ const parsed = JSON.parse(lines.join("\n"))
89
+ const web = parsed.find((/** @type {{id: string, logs: import("../src/managed-process.js").ManagedProcessLog[], source: string}} */ entry) => entry.id === "web")
90
+
91
+ assert.ok(web, "expected a web entry in the JSON output")
92
+ assert.match(web.source, /release v1 \(active\)/)
93
+ assert.ok(Array.isArray(web.logs))
94
+ } finally {
95
+ console.log = originalLog
96
+ await daemon.shutdown()
97
+ await fs.rm(root, {force: true, recursive: true})
98
+ }
99
+ })
@@ -1,9 +1,13 @@
1
1
  // @ts-check
2
2
 
3
3
  import assert from "node:assert/strict"
4
+ import path from "node:path"
4
5
  import test from "node:test"
6
+ import {fileURLToPath} from "node:url"
5
7
  import ManagedProcess from "../src/managed-process.js"
6
8
 
9
+ const crasherPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "crasher.js")
10
+
7
11
  /**
8
12
  * Builds a managed process that is never spawned, for exercising output retention directly.
9
13
  * @param {number} outputLines - Recent output lines to retain and report.
@@ -23,6 +27,21 @@ function buildProcess(outputLines) {
23
27
  })
24
28
  }
25
29
 
30
+ /**
31
+ * @param {() => boolean} callback - Probe.
32
+ * @returns {Promise<void>} Resolves once the probe returns true.
33
+ */
34
+ async function waitFor(callback) {
35
+ const deadline = Date.now() + 3000
36
+
37
+ while (Date.now() < deadline) {
38
+ if (callback()) return
39
+ await new Promise((resolve) => setTimeout(resolve, 25))
40
+ }
41
+
42
+ throw new Error("Timed out waiting for condition")
43
+ }
44
+
26
45
  test("retains and reports only the configured number of recent output lines", () => {
27
46
  const managed = buildProcess(3)
28
47
 
@@ -44,3 +63,130 @@ test("keeps every output line when fewer than the retention limit are produced",
44
63
 
45
64
  assert.deepEqual(logs.map((entry) => entry.line), ["one", "two"])
46
65
  })
66
+
67
+ test("reports zeroed restart and uptime fields before the process starts", () => {
68
+ const status = buildProcess(50).status()
69
+
70
+ assert.equal(status.restarts, 0)
71
+ assert.equal(status.startedAt, undefined)
72
+ assert.equal(status.uptimeMs, undefined)
73
+ assert.equal(status.state, "stopped")
74
+ })
75
+
76
+ test("counts automatic restarts and reports startedAt and uptime while running", async () => {
77
+ const managed = new ManagedProcess({
78
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(crasherPath)}`,
79
+ cwd: undefined,
80
+ env: {},
81
+ id: "crasher",
82
+ logger: () => {},
83
+ outputLines: 50,
84
+ restartDelayMs: 20,
85
+ shouldRestart: () => true,
86
+ stopTimeoutMs: 500
87
+ })
88
+
89
+ try {
90
+ await managed.start()
91
+
92
+ const initial = managed.status()
93
+
94
+ assert.equal(initial.restarts, 0)
95
+ assert.equal(initial.state, "running")
96
+ assert.equal(typeof initial.startedAt, "string")
97
+ assert.ok(typeof initial.uptimeMs === "number" && initial.uptimeMs >= 0)
98
+
99
+ // The fixture exits non-zero ~40ms after each start, so it keeps auto-restarting.
100
+ await waitFor(() => managed.status().restarts >= 2)
101
+
102
+ assert.ok(managed.status().restarts >= 2)
103
+ } finally {
104
+ await managed.stop()
105
+ }
106
+ })
107
+
108
+ /**
109
+ * Builds a managed crasher with a specific restart policy.
110
+ * @param {import("../src/config.js").RestartConfig} restart - Restart policy.
111
+ * @returns {ManagedProcess} Managed process.
112
+ */
113
+ function buildCrasher(restart) {
114
+ return new ManagedProcess({
115
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(crasherPath)}`,
116
+ cwd: undefined,
117
+ env: {},
118
+ id: "crasher",
119
+ logger: () => {},
120
+ outputLines: 50,
121
+ restart,
122
+ restartDelayMs: 10,
123
+ shouldRestart: () => true,
124
+ stopTimeoutMs: 500
125
+ })
126
+ }
127
+
128
+ test("does not auto-restart when the restart policy is disabled (maxRestarts: 0)", async () => {
129
+ const managed = buildCrasher({backoffFactor: 1, maxDelayMs: 0, maxRestarts: 0, windowMs: 0})
130
+
131
+ try {
132
+ await managed.start()
133
+
134
+ // The fixture exits ~40ms after start; with restarts disabled it should stay failed.
135
+ await waitFor(() => managed.status().state === "failed")
136
+ await new Promise((resolve) => setTimeout(resolve, 100))
137
+
138
+ assert.equal(managed.status().restarts, 0)
139
+ assert.equal(managed.status().state, "failed")
140
+ } finally {
141
+ await managed.stop()
142
+ }
143
+ })
144
+
145
+ test("stops auto-restarting once maxRestarts within the window is reached", async () => {
146
+ const managed = buildCrasher({backoffFactor: 1, maxDelayMs: 0, maxRestarts: 2, windowMs: 60000})
147
+
148
+ try {
149
+ await managed.start()
150
+
151
+ // It restarts at most twice within the window, then gives up and stays failed.
152
+ await waitFor(() => managed.status().restarts === 2 && managed.status().state === "failed")
153
+ await new Promise((resolve) => setTimeout(resolve, 100))
154
+
155
+ assert.equal(managed.status().restarts, 2)
156
+ assert.equal(managed.status().state, "failed")
157
+ } finally {
158
+ await managed.stop()
159
+ }
160
+ })
161
+
162
+ test("applies exponential backoff to restart delays, capped by maxDelayMs", () => {
163
+ const capped = buildCrasher({backoffFactor: 2, maxDelayMs: 500, maxRestarts: undefined, windowMs: 0})
164
+
165
+ // restartDelayMs (10) * 2 ** attempt, capped at 500.
166
+ assert.equal(capped.restartDelayFor(0), 10)
167
+ assert.equal(capped.restartDelayFor(1), 20)
168
+ assert.equal(capped.restartDelayFor(2), 40)
169
+ assert.equal(capped.restartDelayFor(6), 500) // 10 * 64 = 640, capped to 500
170
+ assert.equal(capped.restartDelayFor(7), 500)
171
+
172
+ // maxDelayMs: 0 means no cap.
173
+ const uncapped = buildCrasher({backoffFactor: 3, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0})
174
+
175
+ assert.equal(uncapped.restartDelayFor(0), 10)
176
+ assert.equal(uncapped.restartDelayFor(2), 90)
177
+ })
178
+
179
+ test("the unlimited constant-delay fast path still applies maxDelayMs", () => {
180
+ // restartDelayMs (10) above maxDelayMs (5), with no backoff and unlimited restarts.
181
+ const managed = buildCrasher({backoffFactor: 1, maxDelayMs: 5, maxRestarts: undefined, windowMs: 0})
182
+
183
+ assert.equal(managed.restartDelayFor(0), 5)
184
+
185
+ /** @type {number | undefined} */
186
+ let queued
187
+
188
+ managed.queueRestart = (delayMs) => { queued = delayMs }
189
+ managed.scheduleRestart()
190
+
191
+ assert.equal(queued, 5)
192
+ })
@@ -0,0 +1,29 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import fs from "node:fs/promises"
5
+ import path from "node:path"
6
+ import test from "node:test"
7
+ import {fileURLToPath} from "node:url"
8
+
9
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
10
+
11
+ test("package.json declares publish metadata", async () => {
12
+ const pkg = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8"))
13
+
14
+ assert.equal(pkg.name, "rollbridge")
15
+ assert.equal(pkg.license, "MIT")
16
+ assert.equal(pkg.homepage, "https://github.com/kaspernj/rollbridge#readme")
17
+ assert.equal(pkg.bugs.url, "https://github.com/kaspernj/rollbridge/issues")
18
+ assert.equal(pkg.repository.type, "git")
19
+ assert.match(pkg.repository.url, /github\.com\/kaspernj\/rollbridge/)
20
+ assert.ok(typeof pkg.author === "string" && pkg.author.length > 0)
21
+ assert.ok(Array.isArray(pkg.keywords) && pkg.keywords.length > 0)
22
+ })
23
+
24
+ test("a LICENSE file matching the declared license exists", async () => {
25
+ const license = await fs.readFile(path.join(repoRoot, "LICENSE"), "utf8")
26
+
27
+ assert.match(license, /MIT License/)
28
+ assert.match(license, /Copyright \(c\) \d{4} kaspernj/)
29
+ })