rollbridge 0.1.1
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/AI_TODO_PROMPT.md +39 -0
- package/README.md +200 -0
- package/TODO.md +96 -0
- package/bin/rollbridge +4 -0
- package/eslint.config.js +99 -0
- package/examples/tensorbuzz.com.js +85 -0
- package/package.json +32 -0
- package/scripts/release-patch.js +83 -0
- package/src/cli.js +359 -0
- package/src/config.js +414 -0
- package/src/control-client.js +42 -0
- package/src/daemon.js +575 -0
- package/src/health.js +31 -0
- package/src/json.d.ts +3 -0
- package/src/json.js +5 -0
- package/src/managed-process.js +232 -0
- package/src/port-allocator.js +88 -0
- package/src/release-group.js +287 -0
- package/src/template.js +74 -0
- package/tensorbuzz.yml +4 -0
- package/test/config-examples.test.js +59 -0
- package/test/config-path.test.js +90 -0
- package/test/config-validation.test.js +156 -0
- package/test/fixtures/dependent-app.js +57 -0
- package/test/fixtures/dummy-app.js +69 -0
- package/test/fixtures/service-app.js +42 -0
- package/test/fixtures/singleton-app.js +35 -0
- package/test/port-allocator.test.js +76 -0
- package/test/rollbridge.test.js +444 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,444 @@
|
|
|
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 {fileURLToPath} from "node:url"
|
|
10
|
+
import RollbridgeDaemon from "../src/daemon.js"
|
|
11
|
+
import {normalizeConfig} from "../src/config.js"
|
|
12
|
+
import {sendControlCommand} from "../src/control-client.js"
|
|
13
|
+
import {runCli} from "../src/cli.js"
|
|
14
|
+
|
|
15
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const binPath = path.join(currentDir, "..", "bin", "rollbridge")
|
|
17
|
+
const dependentAppPath = path.join(currentDir, "fixtures", "dependent-app.js")
|
|
18
|
+
const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
19
|
+
const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
|
|
20
|
+
const singletonAppPath = path.join(currentDir, "fixtures", "singleton-app.js")
|
|
21
|
+
|
|
22
|
+
test("deploy switches new HTTP traffic while old WebSockets drain", async () => {
|
|
23
|
+
const fixture = await createFixture()
|
|
24
|
+
const daemon = await startDaemon(fixture.config)
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
28
|
+
assert.equal(await fetchText(daemon, "/release"), "v1")
|
|
29
|
+
|
|
30
|
+
const websocket = await openWebSocket(daemon)
|
|
31
|
+
|
|
32
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
33
|
+
assert.equal(await fetchText(daemon, "/release"), "v2")
|
|
34
|
+
|
|
35
|
+
const drainingRelease = statusRelease(daemon, "v1")
|
|
36
|
+
assert.equal(drainingRelease.state, "draining")
|
|
37
|
+
assert.equal(drainingRelease.connections.websocket, 1)
|
|
38
|
+
|
|
39
|
+
websocket.close()
|
|
40
|
+
await waitFor(async () => statusRelease(daemon, "v1").state === "stopped")
|
|
41
|
+
} finally {
|
|
42
|
+
await daemon.shutdown()
|
|
43
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("failed health check leaves the previous release active", async () => {
|
|
48
|
+
const fixture = await createFixture()
|
|
49
|
+
const daemon = await startDaemon(fixture.config)
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await daemon.deploy({releaseId: "good", releasePath: fixture.root, revision: "good"})
|
|
53
|
+
|
|
54
|
+
await assert.rejects(
|
|
55
|
+
() => daemon.deploy({releaseId: "bad", releasePath: fixture.root, revision: "bad"}),
|
|
56
|
+
/Health check failed/
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
assert.equal(await fetchText(daemon, "/release"), "good")
|
|
60
|
+
assert.equal(daemon.status().activeReleaseId, "good")
|
|
61
|
+
} finally {
|
|
62
|
+
await daemon.shutdown()
|
|
63
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("singleton processes restart without overlap during deploy", async () => {
|
|
68
|
+
const fixture = await createFixture({includeSingleton: true})
|
|
69
|
+
const daemon = await startDaemon(fixture.config)
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
73
|
+
await waitFor(async () => (await processEvents(fixture.singletonLogPath)).some((event) => event.event === "start" && event.releaseId === "v1"))
|
|
74
|
+
|
|
75
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
76
|
+
await waitFor(async () => {
|
|
77
|
+
const events = await processEvents(fixture.singletonLogPath)
|
|
78
|
+
|
|
79
|
+
return events.some((event) => event.event === "stop" && event.releaseId === "v1") &&
|
|
80
|
+
events.some((event) => event.event === "start" && event.releaseId === "v2")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const status = daemon.status()
|
|
84
|
+
|
|
85
|
+
assert.equal(status.singletons.length, 1)
|
|
86
|
+
assert.equal(status.singletons[0].process.state, "running")
|
|
87
|
+
} finally {
|
|
88
|
+
await daemon.shutdown()
|
|
89
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("service processes start before releases and restart with the latest deploy template", async () => {
|
|
94
|
+
const fixture = await createFixture({includeService: true, webDependsOnService: true})
|
|
95
|
+
const daemon = await startDaemon(fixture.config)
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
99
|
+
await waitFor(async () => (await processEvents(fixture.serviceLogPath)).some((event) => event.event === "start" && event.releaseId === "v1"))
|
|
100
|
+
|
|
101
|
+
const firstServiceStatus = daemon.status().services[0].process
|
|
102
|
+
|
|
103
|
+
assert.ok(firstServiceStatus.pid, "service should have a pid")
|
|
104
|
+
assert.match(firstServiceStatus.command, /v1/)
|
|
105
|
+
|
|
106
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
107
|
+
|
|
108
|
+
const secondServiceStatus = daemon.status().services[0].process
|
|
109
|
+
|
|
110
|
+
assert.equal(secondServiceStatus.pid, firstServiceStatus.pid)
|
|
111
|
+
assert.match(secondServiceStatus.command, /v2/)
|
|
112
|
+
|
|
113
|
+
process.kill(-Number(secondServiceStatus.pid), "SIGTERM")
|
|
114
|
+
await waitFor(async () => {
|
|
115
|
+
const events = await processEvents(fixture.serviceLogPath)
|
|
116
|
+
|
|
117
|
+
return events.some((event) => event.event === "start" && event.releaseId === "v2")
|
|
118
|
+
})
|
|
119
|
+
} finally {
|
|
120
|
+
await daemon.shutdown()
|
|
121
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("control socket accepts deploy and status commands", async () => {
|
|
126
|
+
const fixture = await createFixture()
|
|
127
|
+
const daemon = await startDaemon(fixture.config)
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await sendControlCommand({
|
|
131
|
+
command: {
|
|
132
|
+
command: "deploy",
|
|
133
|
+
releaseId: "control-v1",
|
|
134
|
+
releasePath: fixture.root,
|
|
135
|
+
revision: "control-v1"
|
|
136
|
+
},
|
|
137
|
+
path: fixture.config.control.path
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const status = await sendControlCommand({
|
|
141
|
+
command: {command: "status"},
|
|
142
|
+
path: fixture.config.control.path
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
assert.equal(status.activeReleaseId, "control-v1")
|
|
146
|
+
assert.equal(await fetchText(daemon, "/release"), "control-v1")
|
|
147
|
+
} finally {
|
|
148
|
+
await daemon.shutdown()
|
|
149
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test("starting a second daemon on a live control socket reports the running daemon", async () => {
|
|
154
|
+
const fixture = await createFixture()
|
|
155
|
+
const daemon = await startDaemon(fixture.config)
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
159
|
+
|
|
160
|
+
const second = new RollbridgeDaemon({config: fixture.config, logger: () => {}})
|
|
161
|
+
|
|
162
|
+
await assert.rejects(
|
|
163
|
+
() => second.prepareControlSocketPath(),
|
|
164
|
+
(error) => {
|
|
165
|
+
assert.ok(error instanceof Error)
|
|
166
|
+
assert.match(error.message, /A Rollbridge daemon for application "rollbridge-test" is already running/)
|
|
167
|
+
assert.match(error.message, /active release: v1/)
|
|
168
|
+
assert.match(error.message, /rollbridge shutdown/)
|
|
169
|
+
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// The original daemon keeps its socket and still answers control commands.
|
|
175
|
+
const status = await sendControlCommand({command: {command: "status"}, path: fixture.config.control.path})
|
|
176
|
+
assert.equal(status.application, "rollbridge-test")
|
|
177
|
+
} finally {
|
|
178
|
+
await daemon.shutdown()
|
|
179
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("a control socket held by a non-Rollbridge process reports a generic conflict", async () => {
|
|
184
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
|
|
185
|
+
const socketPath = path.join(root, "busy.sock")
|
|
186
|
+
const connections = /** @type {Set<import("node:net").Socket>} */ (new Set())
|
|
187
|
+
const stranger = net.createServer((socket) => {
|
|
188
|
+
// Accept connections but never answer, so the probe falls through to its timeout.
|
|
189
|
+
connections.add(socket)
|
|
190
|
+
socket.on("error", () => {})
|
|
191
|
+
socket.on("close", () => connections.delete(socket))
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
await new Promise((resolve) => stranger.listen(socketPath, () => resolve(undefined)))
|
|
195
|
+
|
|
196
|
+
const config = normalizeConfig({
|
|
197
|
+
application: "rollbridge-test",
|
|
198
|
+
control: {path: socketPath},
|
|
199
|
+
processes: [{command: "true", id: "web", policy: "proxied", port: {from: 0, to: 0}}],
|
|
200
|
+
proxy: {host: "127.0.0.1", port: 0}
|
|
201
|
+
})
|
|
202
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await assert.rejects(
|
|
206
|
+
() => daemon.prepareControlSocketPath(),
|
|
207
|
+
/The control socket .* is already in use by another process/
|
|
208
|
+
)
|
|
209
|
+
} finally {
|
|
210
|
+
for (const socket of connections) socket.destroy()
|
|
211
|
+
await new Promise((resolve) => stranger.close(() => resolve(undefined)))
|
|
212
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test("deploy can ensure the daemon before sending the release command", async () => {
|
|
217
|
+
const fixture = await createFixture()
|
|
218
|
+
const configPath = await writeConfigFile(fixture.config, fixture.root)
|
|
219
|
+
const logPath = path.join(fixture.root, "daemon.log")
|
|
220
|
+
const pidPath = path.join(fixture.root, "daemon.pid")
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await runCli([
|
|
224
|
+
"node",
|
|
225
|
+
binPath,
|
|
226
|
+
"deploy",
|
|
227
|
+
"--ensure-daemon",
|
|
228
|
+
"--config",
|
|
229
|
+
configPath,
|
|
230
|
+
"--release-path",
|
|
231
|
+
fixture.root,
|
|
232
|
+
"--release-id",
|
|
233
|
+
"ensured-v1",
|
|
234
|
+
"--daemon-log-path",
|
|
235
|
+
logPath,
|
|
236
|
+
"--daemon-pid-path",
|
|
237
|
+
pidPath
|
|
238
|
+
])
|
|
239
|
+
|
|
240
|
+
const status = await sendControlCommand({
|
|
241
|
+
command: {command: "status"},
|
|
242
|
+
path: fixture.config.control.path
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const proxy = /** @type {{port: number}} */ (status.proxy)
|
|
246
|
+
|
|
247
|
+
assert.equal(status.activeReleaseId, "ensured-v1")
|
|
248
|
+
assert.match(await fs.readFile(pidPath, "utf8"), /\d+/)
|
|
249
|
+
assert.equal(await fetchTextFromPort(proxy.port, "/release"), "ensured-v1")
|
|
250
|
+
} finally {
|
|
251
|
+
try {
|
|
252
|
+
await sendControlCommand({
|
|
253
|
+
command: {command: "shutdown"},
|
|
254
|
+
path: fixture.config.control.path
|
|
255
|
+
})
|
|
256
|
+
} catch (_error) {
|
|
257
|
+
// The daemon may have failed before it accepted commands.
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param {{includeService?: boolean, includeSingleton?: boolean, webDependsOnService?: boolean}} [options] - Fixture options.
|
|
266
|
+
* @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, singletonLogPath: string}>} Fixture data.
|
|
267
|
+
*/
|
|
268
|
+
async function createFixture(options = {}) {
|
|
269
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
|
|
270
|
+
const serviceLogPath = path.join(root, "service.log")
|
|
271
|
+
const singletonLogPath = path.join(root, "singleton.log")
|
|
272
|
+
/** @type {Array<Record<string, import("../src/json.js").JsonValue>>} */
|
|
273
|
+
const processes = []
|
|
274
|
+
|
|
275
|
+
if (options.includeService) {
|
|
276
|
+
processes.push({
|
|
277
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(serviceAppPath)} --release={{releaseId}}`,
|
|
278
|
+
env: {
|
|
279
|
+
ROLLBRIDGE_SERVICE_LOG: serviceLogPath
|
|
280
|
+
},
|
|
281
|
+
id: "beacon",
|
|
282
|
+
policy: "service",
|
|
283
|
+
port: {from: 0, to: 0},
|
|
284
|
+
restartDelayMs: 50
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
processes.push({
|
|
289
|
+
command: options.webDependsOnService
|
|
290
|
+
? `${JSON.stringify(process.execPath)} ${JSON.stringify(dependentAppPath)}`
|
|
291
|
+
: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
|
|
292
|
+
health: {
|
|
293
|
+
intervalMs: 50,
|
|
294
|
+
path: "/ping",
|
|
295
|
+
timeoutMs: 3000
|
|
296
|
+
},
|
|
297
|
+
id: "web",
|
|
298
|
+
policy: "proxied",
|
|
299
|
+
port: {from: 0, to: 0}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
if (options.includeSingleton) {
|
|
303
|
+
processes.push({
|
|
304
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(singletonAppPath)}`,
|
|
305
|
+
env: {
|
|
306
|
+
ROLLBRIDGE_SINGLETON_LOG: singletonLogPath
|
|
307
|
+
},
|
|
308
|
+
id: "jobs-main",
|
|
309
|
+
policy: "singleton"
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const config = normalizeConfig({
|
|
314
|
+
application: "rollbridge-test",
|
|
315
|
+
control: {
|
|
316
|
+
path: path.join(root, "rollbridge.sock")
|
|
317
|
+
},
|
|
318
|
+
processes,
|
|
319
|
+
proxy: {
|
|
320
|
+
drainTimeoutMs: 1000,
|
|
321
|
+
forceStopTimeoutMs: 500,
|
|
322
|
+
healthPath: "/ping",
|
|
323
|
+
healthTimeoutMs: 3000,
|
|
324
|
+
host: "127.0.0.1",
|
|
325
|
+
port: 0
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
return {config, root, serviceLogPath, singletonLogPath}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* @param {import("../src/config.js").RollbridgeConfig} config - Config.
|
|
334
|
+
* @returns {Promise<RollbridgeDaemon>} Started daemon.
|
|
335
|
+
*/
|
|
336
|
+
async function startDaemon(config) {
|
|
337
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
338
|
+
|
|
339
|
+
await daemon.start()
|
|
340
|
+
|
|
341
|
+
return daemon
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* @param {RollbridgeDaemon} daemon - Daemon.
|
|
346
|
+
* @param {string} pathName - Path.
|
|
347
|
+
* @returns {Promise<string>} Response text.
|
|
348
|
+
*/
|
|
349
|
+
async function fetchText(daemon, pathName) {
|
|
350
|
+
return await fetchTextFromPort(Number(daemon.getProxyPort()), pathName)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* @param {number} port - Port.
|
|
355
|
+
* @param {string} pathName - Path.
|
|
356
|
+
* @returns {Promise<string>} Response text.
|
|
357
|
+
*/
|
|
358
|
+
async function fetchTextFromPort(port, pathName) {
|
|
359
|
+
const response = await fetch(`http://127.0.0.1:${port}${pathName}`)
|
|
360
|
+
|
|
361
|
+
assert.equal(response.status, 200)
|
|
362
|
+
|
|
363
|
+
return (await response.text()).trim()
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* @param {RollbridgeDaemon} daemon - Daemon.
|
|
368
|
+
* @returns {Promise<WebSocket>} Open WebSocket.
|
|
369
|
+
*/
|
|
370
|
+
async function openWebSocket(daemon) {
|
|
371
|
+
const websocket = new WebSocket(`ws://127.0.0.1:${daemon.getProxyPort()}/socket`)
|
|
372
|
+
|
|
373
|
+
await new Promise((resolve, reject) => {
|
|
374
|
+
websocket.addEventListener("open", () => resolve(undefined), {once: true})
|
|
375
|
+
websocket.addEventListener("error", () => reject(new Error("WebSocket open failed")), {once: true})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
return websocket
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @param {RollbridgeDaemon} daemon - Daemon.
|
|
383
|
+
* @param {string} releaseId - Release id.
|
|
384
|
+
* @returns {import("../src/release-group.js").ReleaseStatus} Release status.
|
|
385
|
+
*/
|
|
386
|
+
function statusRelease(daemon, releaseId) {
|
|
387
|
+
const status = daemon.status()
|
|
388
|
+
const release = status.releases.find((candidate) => candidate.releaseId === releaseId)
|
|
389
|
+
|
|
390
|
+
assert.ok(release, `Release ${releaseId} should be present`)
|
|
391
|
+
|
|
392
|
+
return release
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* @param {string} logPath - Log path.
|
|
397
|
+
* @returns {Promise<Array<{event: string, pid: number, releaseId: string}>>} Events.
|
|
398
|
+
*/
|
|
399
|
+
async function processEvents(logPath) {
|
|
400
|
+
try {
|
|
401
|
+
const text = await fs.readFile(logPath, "utf8")
|
|
402
|
+
|
|
403
|
+
return text
|
|
404
|
+
.trim()
|
|
405
|
+
.split("\n")
|
|
406
|
+
.filter(Boolean)
|
|
407
|
+
.map((line) => JSON.parse(line))
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
410
|
+
return []
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
throw error
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* @param {import("../src/config.js").RollbridgeConfig} config - Config.
|
|
419
|
+
* @param {string} root - Fixture root.
|
|
420
|
+
* @returns {Promise<string>} Written config path.
|
|
421
|
+
*/
|
|
422
|
+
async function writeConfigFile(config, root) {
|
|
423
|
+
const configPath = path.join(root, "rollbridge.js")
|
|
424
|
+
|
|
425
|
+
// CommonJS so the module loads from a temp dir (no package.json) on any supported Node version.
|
|
426
|
+
await fs.writeFile(configPath, `module.exports = ${JSON.stringify(config, null, 2)}\n`)
|
|
427
|
+
|
|
428
|
+
return configPath
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* @param {() => Promise<boolean> | boolean} callback - Probe callback.
|
|
433
|
+
* @returns {Promise<void>} Resolves when callback returns true.
|
|
434
|
+
*/
|
|
435
|
+
async function waitFor(callback) {
|
|
436
|
+
const deadline = Date.now() + 3000
|
|
437
|
+
|
|
438
|
+
while (Date.now() < deadline) {
|
|
439
|
+
if (await callback()) return
|
|
440
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
throw new Error("Timed out waiting for condition")
|
|
444
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"allowJs": true,
|
|
4
|
+
"allowSyntheticDefaultImports": true,
|
|
5
|
+
"checkJs": true,
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"lib": ["ES2024", "DOM"],
|
|
8
|
+
"module": "NodeNext",
|
|
9
|
+
"moduleResolution": "NodeNext",
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"target": "ES2024",
|
|
13
|
+
"types": ["node"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["bin/**/*", "scripts/**/*", "src/**/*", "test/**/*"]
|
|
16
|
+
}
|