@andypai/agent-kanban 0.7.0 → 0.8.0

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.
@@ -5,7 +5,7 @@ import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { ErrorCode, KanbanError, type ErrorCodeValue } from '../errors'
7
7
  import type { Task, TaskComment } from '../types'
8
- import { parseMcpArgs, parseServeArgs, run } from '../index'
8
+ import { assertTunnelSecurity, parseMcpArgs, parseServeArgs, run } from '../index'
9
9
 
10
10
  async function withTempDb(runTest: (dbPath: string) => Promise<void>): Promise<void> {
11
11
  const dir = mkdtempSync(join(tmpdir(), 'kanban-run-'))
@@ -85,12 +85,38 @@ describe('parseServeArgs', () => {
85
85
  })
86
86
  })
87
87
 
88
- test('--sync-interval-ms rejects invalid values', () => {
89
- for (const raw of ['999', '0']) {
88
+ test('--sync-interval-ms rejects below-minimum and overflow values as INVALID_ARGUMENT', () => {
89
+ // The whole flag family reports rejections with one code: a digit-but-too-small
90
+ // value, a value past MAX_SAFE_INTEGER (silent precision loss), OR a digit
91
+ // string so long that Number() overflows to Infinity — all INVALID_ARGUMENT.
92
+ for (const raw of ['999', '0', '9007199254740993', '9'.repeat(309)]) {
90
93
  expect(() => parseServeArgs(['serve', '--sync-interval-ms', raw])).toThrow(KanbanError)
94
+ try {
95
+ parseServeArgs(['serve', '--sync-interval-ms', raw])
96
+ } catch (err) {
97
+ expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
98
+ }
91
99
  }
92
100
  })
93
101
 
102
+ test('D7: --sync-interval-ms rejects non-digit notation (hex/scientific/garbage/empty)', () => {
103
+ for (const bad of ['0x1000', '1e3', '1_000', '3.5', 'abc', '-1000']) {
104
+ expect(() => parseServeArgs(['serve', `--sync-interval-ms=${bad}`])).toThrow(KanbanError)
105
+ try {
106
+ parseServeArgs(['serve', `--sync-interval-ms=${bad}`])
107
+ } catch (err) {
108
+ expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
109
+ }
110
+ }
111
+ // explicit empty is rejected rather than silently falling back to the default
112
+ expect(() => parseServeArgs(['serve', '--sync-interval-ms='])).toThrow(KanbanError)
113
+ })
114
+
115
+ test('D7: --sync-interval-ms accepts a plain integer >= 1000', () => {
116
+ expect(parseServeArgs(['serve', '--sync-interval-ms=1000']).syncIntervalMs).toBe(1000)
117
+ expect(parseServeArgs(['serve', '--sync-interval-ms=600000']).syncIntervalMs).toBe(600000)
118
+ })
119
+
94
120
  test('--token / --allowed-origin flags and env are parsed; flags win over env', () => {
95
121
  const prevToken = process.env['KANBAN_API_TOKEN']
96
122
  const prevOrigin = process.env['KANBAN_ALLOWED_ORIGIN']
@@ -129,6 +155,84 @@ describe('parseServeArgs', () => {
129
155
  expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
130
156
  }
131
157
  })
158
+
159
+ test('D6: rejects non-integer / out-of-range / trailing-garbage ports', () => {
160
+ for (const bad of ['abc', '123abc', '-1', '70000', '3.5', '0x10', '']) {
161
+ expect(() => parseServeArgs(['serve', `--port=${bad}`])).toThrow(KanbanError)
162
+ try {
163
+ parseServeArgs(['serve', `--port=${bad}`])
164
+ } catch (err) {
165
+ expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
166
+ }
167
+ }
168
+ })
169
+
170
+ test('D6: accepts valid ports including 0 (OS-assigned ephemeral)', () => {
171
+ expect(parseServeArgs(['serve', '--port=0']).port).toBe(0)
172
+ expect(parseServeArgs(['serve', '--port=8080']).port).toBe(8080)
173
+ expect(parseServeArgs(['serve', '--port=65535']).port).toBe(65535)
174
+ })
175
+
176
+ test('D6: an invalid PORT env value is rejected too', () => {
177
+ const prev = process.env['PORT']
178
+ process.env['PORT'] = 'not-a-port'
179
+ try {
180
+ expect(() => parseServeArgs(['serve'])).toThrow(KanbanError)
181
+ } finally {
182
+ if (prev === undefined) delete process.env['PORT']
183
+ else process.env['PORT'] = prev
184
+ }
185
+ })
186
+ })
187
+
188
+ describe('assertTunnelSecurity (F44 + D5)', () => {
189
+ test('no tunnel: always allowed regardless of token/secret', () => {
190
+ expect(() => assertTunnelSecurity({ tunnel: false }, {})).not.toThrow()
191
+ })
192
+
193
+ test('F44: tunnel without an API token is refused', () => {
194
+ expect(() => assertTunnelSecurity({ tunnel: true }, {})).toThrow(KanbanError)
195
+ try {
196
+ assertTunnelSecurity({ tunnel: true }, {})
197
+ } catch (err) {
198
+ expect((err as KanbanError).message).toContain('without an API token')
199
+ }
200
+ })
201
+
202
+ test('tunnel + token on the local provider is allowed (local has no webhooks)', () => {
203
+ expect(() =>
204
+ assertTunnelSecurity({ tunnel: true, authToken: 't' }, { KANBAN_PROVIDER: 'local' }),
205
+ ).not.toThrow()
206
+ // default provider is local when KANBAN_PROVIDER is unset
207
+ expect(() => assertTunnelSecurity({ tunnel: true, authToken: 't' }, {})).not.toThrow()
208
+ })
209
+
210
+ test('D5: tunnel + token on jira without JIRA_WEBHOOK_SECRET is refused', () => {
211
+ try {
212
+ assertTunnelSecurity({ tunnel: true, authToken: 't' }, { KANBAN_PROVIDER: 'jira' })
213
+ throw new Error('expected refusal')
214
+ } catch (err) {
215
+ expect((err as KanbanError).message).toContain('JIRA_WEBHOOK_SECRET')
216
+ }
217
+ })
218
+
219
+ test('D5: tunnel + token on jira WITH the secret is allowed', () => {
220
+ expect(() =>
221
+ assertTunnelSecurity(
222
+ { tunnel: true, authToken: 't' },
223
+ { KANBAN_PROVIDER: 'jira', JIRA_WEBHOOK_SECRET: 's' },
224
+ ),
225
+ ).not.toThrow()
226
+ })
227
+
228
+ test('D5: tunnel + token on linear without LINEAR_WEBHOOK_SECRET is refused', () => {
229
+ try {
230
+ assertTunnelSecurity({ tunnel: true, authToken: 't' }, { KANBAN_PROVIDER: 'Linear' })
231
+ throw new Error('expected refusal')
232
+ } catch (err) {
233
+ expect((err as KanbanError).message).toContain('LINEAR_WEBHOOK_SECRET')
234
+ }
235
+ })
132
236
  })
133
237
 
134
238
  describe('parseMcpArgs', () => {
@@ -132,14 +132,37 @@ describe('jira-wiring', () => {
132
132
  expect(provider.type).toBe('jira')
133
133
  })
134
134
 
135
- test('JIRA_BOARD_ID non-numeric falls back to undefined in env loader', () => {
135
+ test('JIRA_BOARD_ID unset omits boardId (no board pinned)', () => {
136
136
  setJiraRequiredEnv()
137
- process.env['JIRA_BOARD_ID'] = 'notanumber'
138
137
  const config = trackerConfigFromEnv()
139
138
  expect(config.provider).toBe('jira')
140
139
  expect(config).not.toHaveProperty('boardId')
141
140
  })
142
141
 
142
+ test('JIRA_BOARD_ID accepts a plain positive integer', () => {
143
+ setJiraRequiredEnv()
144
+ process.env['JIRA_BOARD_ID'] = ' 42 '
145
+ const config = trackerConfigFromEnv()
146
+ expect(config.provider).toBe('jira')
147
+ expect((config as { boardId?: number }).boardId).toBe(42)
148
+ })
149
+
150
+ test('JIRA_BOARD_ID rejects malformed values as INVALID_CONFIG (no silent wrong board)', () => {
151
+ setJiraRequiredEnv()
152
+ // Number.parseInt would have quietly coerced these to 12 / 1 / -5 / 0 and
153
+ // pinned a wrong board; they must now be rejected.
154
+ for (const raw of ['notanumber', '12abc', '1e3', '0x10', '3.5', '-5', '0']) {
155
+ process.env['JIRA_BOARD_ID'] = raw
156
+ let code: string | undefined
157
+ try {
158
+ trackerConfigFromEnv()
159
+ } catch (err) {
160
+ code = (err as KanbanError).code
161
+ }
162
+ expect(code).toBe(ErrorCode.INVALID_CONFIG)
163
+ }
164
+ })
165
+
143
166
  test('trackerConfigFromEnv carries remote polling sync interval', () => {
144
167
  setJiraRequiredEnv()
145
168
  process.env['KANBAN_SYNC_INTERVAL_MS'] = '60000'
@@ -370,6 +370,84 @@ describe('startServer auth + CORS', () => {
370
370
  expect(res.status).not.toBe(401)
371
371
  })
372
372
 
373
+ test('a throwing provider webhook handler returns an enveloped 500, not a dropped connection', async () => {
374
+ const runtime = startServer(
375
+ makeProvider({
376
+ type: 'local',
377
+ async handleWebhook() {
378
+ throw new Error('boom during webhook apply')
379
+ },
380
+ }),
381
+ 0,
382
+ { authToken: TOKEN },
383
+ )
384
+ runtimes.push(runtime)
385
+ const res = await fetch(`http://127.0.0.1:${runtime.port}/api/webhooks/local`, {
386
+ method: 'POST',
387
+ headers: { 'Content-Type': 'application/json' },
388
+ body: '{}',
389
+ })
390
+ const body = (await res.json()) as { ok: boolean; error: { code: string } }
391
+ expect(res.status).toBe(500)
392
+ expect(body.ok).toBe(false)
393
+ expect(body.error.code).toBe('INTERNAL_ERROR')
394
+ })
395
+
396
+ test('F06: a `/kanban`-prefixed API path still enforces the auth gate (no prefix bypass)', async () => {
397
+ const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
398
+ runtimes.push(runtime)
399
+
400
+ const noAuth = await fetch(`http://127.0.0.1:${runtime.port}/kanban/api/bootstrap`)
401
+ expect(noAuth.status).toBe(401)
402
+
403
+ const withAuth = await fetch(`http://127.0.0.1:${runtime.port}/kanban/api/bootstrap`, {
404
+ headers: { Authorization: `Bearer ${TOKEN}` },
405
+ })
406
+ expect(withAuth.status).toBe(200)
407
+
408
+ // Health stays public under the prefix too.
409
+ const health = await fetch(`http://127.0.0.1:${runtime.port}/kanban/api/health`)
410
+ expect(health.status).toBe(200)
411
+ })
412
+
413
+ test('F13: a connected WS client receives task:upsert when a task is created via the API', async () => {
414
+ const runtime = startServer(makeProvider(), 0)
415
+ runtimes.push(runtime)
416
+
417
+ const ws = new WebSocket(`ws://127.0.0.1:${runtime.port}/ws`)
418
+ await new Promise<void>((resolve, reject) => {
419
+ ws.onopen = () => resolve()
420
+ ws.onerror = () => reject(new Error('ws connection failed'))
421
+ })
422
+ const received = new Promise<{
423
+ type: string
424
+ task?: { column_id?: string }
425
+ columnId?: string
426
+ }>((resolve, reject) => {
427
+ ws.onmessage = (e) => resolve(JSON.parse(String(e.data)))
428
+ // Fast-fail with a clear message instead of hanging until the global test
429
+ // timeout if the broadcast never arrives (e.g. the socket was not yet
430
+ // registered when the mutation fired).
431
+ setTimeout(() => reject(new Error('timed out waiting for ws broadcast')), 4000)
432
+ })
433
+ try {
434
+ // Let the open handler register the socket before the mutation broadcasts.
435
+ await sleep(25)
436
+
437
+ await fetch(`http://127.0.0.1:${runtime.port}/api/tasks`, {
438
+ method: 'POST',
439
+ headers: { 'Content-Type': 'application/json' },
440
+ body: JSON.stringify({ title: 'Broadcast me' }),
441
+ })
442
+
443
+ const msg = await received
444
+ expect(msg.type).toBe('task:upsert')
445
+ expect(msg.columnId).toBe('backlog')
446
+ } finally {
447
+ ws.close()
448
+ }
449
+ })
450
+
373
451
  test('wsClients are tracked per server instance, not shared globally', async () => {
374
452
  const a = startServer(makeProvider(), 0)
375
453
  runtimes.push(a)
@@ -17,7 +17,20 @@ describe('sync config', () => {
17
17
  })
18
18
 
19
19
  test('rejects invalid or too-aggressive intervals', () => {
20
- for (const raw of ['999', '5s', '1000.5', '0']) {
20
+ // OBS-1: digits-only hex/scientific notation (which Number() would accept)
21
+ // and over-long digit strings (precision loss / Infinity) are now rejected
22
+ // for the env var too, matching the strict --sync-interval-ms CLI flag.
23
+ for (const raw of [
24
+ '999',
25
+ '5s',
26
+ '1000.5',
27
+ '0',
28
+ '0x3e8',
29
+ '1e3',
30
+ '1_000',
31
+ '9007199254740993',
32
+ '9'.repeat(309),
33
+ ]) {
21
34
  let err: unknown
22
35
  try {
23
36
  resolvePollingSyncIntervalMs(raw)
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ WEBHOOK_SECRET_ENV,
4
+ trackerProviderFromEnv,
5
+ webhookSecretFromEnv,
6
+ type TrackerProvider,
7
+ } from '../tracker-config'
8
+
9
+ // OBS-2: the provider -> webhook-secret-env mapping is a single source of truth.
10
+ // WEBHOOK_SECRET_ENV is typed Record<TrackerProvider, …>, so adding a new
11
+ // provider to the union is a COMPILE error until its secret env is declared here
12
+ // — that compile-time exhaustiveness is the real guard against assertTunnelSecurity
13
+ // silently failing open for a new webhook-capable provider. These runtime tests
14
+ // pin the map contents and the env normalization the guard relies on.
15
+
16
+ describe('WEBHOOK_SECRET_ENV (single source of truth)', () => {
17
+ test('maps each known provider to its secret env (local has none)', () => {
18
+ expect(WEBHOOK_SECRET_ENV).toEqual({
19
+ local: null,
20
+ linear: 'LINEAR_WEBHOOK_SECRET',
21
+ jira: 'JIRA_WEBHOOK_SECRET',
22
+ })
23
+ })
24
+
25
+ test('every non-local provider points at a *_WEBHOOK_SECRET env name', () => {
26
+ for (const [provider, envName] of Object.entries(WEBHOOK_SECRET_ENV)) {
27
+ if (provider === 'local') {
28
+ expect(envName).toBeNull()
29
+ } else {
30
+ expect(envName).toMatch(/_WEBHOOK_SECRET$/)
31
+ }
32
+ }
33
+ })
34
+ })
35
+
36
+ describe('trackerProviderFromEnv', () => {
37
+ test('normalizes known providers (case/whitespace-insensitive)', () => {
38
+ expect(trackerProviderFromEnv({ KANBAN_PROVIDER: 'jira' })).toBe('jira')
39
+ expect(trackerProviderFromEnv({ KANBAN_PROVIDER: 'Linear' })).toBe('linear')
40
+ expect(trackerProviderFromEnv({ KANBAN_PROVIDER: ' JIRA ' })).toBe('jira')
41
+ expect(trackerProviderFromEnv({ KANBAN_PROVIDER: 'local' })).toBe('local')
42
+ })
43
+
44
+ test('falls back to local for unset / blank / unrecognized values', () => {
45
+ expect(trackerProviderFromEnv({})).toBe('local')
46
+ expect(trackerProviderFromEnv({ KANBAN_PROVIDER: '' })).toBe('local')
47
+ // A future provider not yet wired in resolves to local (no webhooks) rather
48
+ // than an unmapped value; once added to TrackerProvider it must gain a
49
+ // WEBHOOK_SECRET_ENV entry (compile-enforced).
50
+ expect(trackerProviderFromEnv({ KANBAN_PROVIDER: 'github' })).toBe('local')
51
+ })
52
+
53
+ test('inherited Object property names do not leak through the map lookup', () => {
54
+ // The normalizer derives from WEBHOOK_SECRET_ENV's OWN keys, so prototype
55
+ // names must not be treated as providers.
56
+ for (const name of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) {
57
+ expect(trackerProviderFromEnv({ KANBAN_PROVIDER: name })).toBe('local')
58
+ }
59
+ })
60
+ })
61
+
62
+ describe('webhookSecretFromEnv', () => {
63
+ // The runtime signature enforcement in jira-core/linear-core resolves its secret
64
+ // through this helper, and the assertTunnelSecurity tunnel gate resolves the same
65
+ // env name through WEBHOOK_SECRET_ENV. These tests pin that they read the SAME env
66
+ // name, so the gate and enforcement can't drift into a fail-open.
67
+ test('reads exactly the env name WEBHOOK_SECRET_ENV declares for each provider', () => {
68
+ for (const [provider, envName] of Object.entries(WEBHOOK_SECRET_ENV)) {
69
+ if (envName === null) continue
70
+ const secret = `secret-for-${envName}`
71
+ expect(webhookSecretFromEnv(provider as TrackerProvider, { [envName]: secret })).toBe(secret)
72
+ }
73
+ })
74
+
75
+ test('returns undefined when the declared secret env is unset (open dev mode)', () => {
76
+ expect(webhookSecretFromEnv('jira', {})).toBeUndefined()
77
+ expect(webhookSecretFromEnv('linear', {})).toBeUndefined()
78
+ })
79
+
80
+ test('local has no webhook secret env, so it never resolves a secret', () => {
81
+ expect(webhookSecretFromEnv('local', { JIRA_WEBHOOK_SECRET: 'x' })).toBeUndefined()
82
+ })
83
+
84
+ test('does not read a different env than the one the provider declares', () => {
85
+ // Setting Linear's secret must not satisfy Jira's lookup, and vice versa.
86
+ expect(webhookSecretFromEnv('jira', { LINEAR_WEBHOOK_SECRET: 'l' })).toBeUndefined()
87
+ expect(webhookSecretFromEnv('linear', { JIRA_WEBHOOK_SECRET: 'j' })).toBeUndefined()
88
+ })
89
+ })
@@ -1,5 +1,71 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { parsePositiveInt } from '../transport-input'
2
+ import { ErrorCode, KanbanError } from '../errors'
3
+ import { parseBoundedInt, parseDecimalDigits, parsePositiveInt } from '../transport-input'
4
+
5
+ describe('parseDecimalDigits', () => {
6
+ test('parses plain decimal digit strings (whitespace-trimmed)', () => {
7
+ expect(parseDecimalDigits('0')).toBe(0)
8
+ expect(parseDecimalDigits('42')).toBe(42)
9
+ expect(parseDecimalDigits(' 1000 ')).toBe(1000)
10
+ expect(parseDecimalDigits(String(Number.MAX_SAFE_INTEGER))).toBe(Number.MAX_SAFE_INTEGER)
11
+ })
12
+
13
+ test('returns null for anything that is not exact non-negative decimal digits', () => {
14
+ for (const raw of [
15
+ '',
16
+ ' ',
17
+ 'notanumber',
18
+ '12abc',
19
+ '-1',
20
+ '3.5',
21
+ '0x10',
22
+ '1e3',
23
+ '1_000',
24
+ '+5',
25
+ '9007199254740993', // past MAX_SAFE_INTEGER → precision loss
26
+ '9'.repeat(309), // Number() → Infinity
27
+ ]) {
28
+ expect(parseDecimalDigits(raw)).toBeNull()
29
+ }
30
+ })
31
+ })
32
+
33
+ describe('parseBoundedInt', () => {
34
+ test('accepts in-range digit strings (inclusive bounds)', () => {
35
+ expect(parseBoundedInt('0', { min: 0, max: 65535, field: 'port' })).toBe(0)
36
+ expect(parseBoundedInt('65535', { min: 0, max: 65535, field: 'port' })).toBe(65535)
37
+ expect(parseBoundedInt('1000', { min: 1000, field: 'iv' })).toBe(1000)
38
+ expect(parseBoundedInt(' 42 ', { min: 1, max: 100, field: 'n' })).toBe(42)
39
+ })
40
+
41
+ test.each([
42
+ ['65536', { min: 0, max: 65535, field: 'port' }],
43
+ ['999', { min: 1000, field: 'iv' }],
44
+ ['-1', { min: 0, max: 65535, field: 'port' }],
45
+ ['3.5', { min: 0, max: 65535, field: 'port' }],
46
+ ['0x10', { min: 0, max: 65535, field: 'port' }],
47
+ ['1e3', { min: 1000, field: 'iv' }],
48
+ ['', { min: 0, max: 65535, field: 'port' }],
49
+ ['9007199254740993', { min: 1000, field: 'iv' }], // past MAX_SAFE_INTEGER → precision loss
50
+ ['9'.repeat(309), { min: 1000, field: 'iv' }], // Number() → Infinity
51
+ ] as const)('rejects %p as INVALID_ARGUMENT', (value, opts) => {
52
+ expect(() => parseBoundedInt(value, opts)).toThrow(KanbanError)
53
+ try {
54
+ parseBoundedInt(value, opts)
55
+ } catch (err) {
56
+ expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
57
+ }
58
+ })
59
+
60
+ test('message form depends on whether max is bounded', () => {
61
+ expect(() => parseBoundedInt('x', { min: 0, max: 65535, field: 'port' })).toThrow(
62
+ /port must be an integer between 0 and 65535/,
63
+ )
64
+ expect(() => parseBoundedInt('x', { min: 1000, field: '--sync-interval-ms' })).toThrow(
65
+ /--sync-interval-ms must be an integer >= 1000/,
66
+ )
67
+ })
68
+ })
3
69
 
4
70
  describe('parsePositiveInt', () => {
5
71
  test('returns undefined for absent values', () => {
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { startCloudflareTunnel } from '../tunnel'
3
+
4
+ // The tunnel is exercised with an injected `command` so no real `cloudflared`
5
+ // binary is needed; stdout/stderr from a tiny shell script drive URL detection,
6
+ // and the injected log/warn/onUrl sinks let us assert behaviour deterministically.
7
+
8
+ function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
9
+ let resolve!: (v: T) => void
10
+ const promise = new Promise<T>((r) => {
11
+ resolve = r
12
+ })
13
+ return { promise, resolve }
14
+ }
15
+
16
+ describe('startCloudflareTunnel (F42/F51/F52/F53)', () => {
17
+ test('F51: detects the trycloudflare URL from stdout and announces once', async () => {
18
+ const url = 'https://random-words-1234.trycloudflare.com'
19
+ const got = deferred<string>()
20
+ const logs: string[] = []
21
+ const handle = startCloudflareTunnel(3000, {
22
+ command: ['bash', '-c', `echo "INF | ${url} |"; sleep 1`],
23
+ onUrl: (u) => got.resolve(u),
24
+ log: (m) => logs.push(m),
25
+ warn: () => {},
26
+ })
27
+ expect(await got.promise).toBe(url)
28
+ expect(logs.some((l) => l.includes(url))).toBe(true)
29
+ handle.stop()
30
+ })
31
+
32
+ test('F51: detects the URL from stderr too', async () => {
33
+ const url = 'https://stderr-side-9999.trycloudflare.com'
34
+ const got = deferred<string>()
35
+ const handle = startCloudflareTunnel(3000, {
36
+ command: ['bash', '-c', `echo "${url}" 1>&2; sleep 1`],
37
+ onUrl: (u) => got.resolve(u),
38
+ log: () => {},
39
+ warn: () => {},
40
+ })
41
+ expect(await got.promise).toBe(url)
42
+ handle.stop()
43
+ })
44
+
45
+ test('F51: announces exactly once even when the URL is printed repeatedly', async () => {
46
+ const url = 'https://dup-5678.trycloudflare.com'
47
+ const urls: string[] = []
48
+ const handle = startCloudflareTunnel(3000, {
49
+ command: ['bash', '-c', `for i in 1 2 3 4; do echo ${url}; done; sleep 1`],
50
+ onUrl: (u) => urls.push(u),
51
+ log: () => {},
52
+ warn: () => {},
53
+ })
54
+ // Give the scanner time to read all four lines before asserting.
55
+ await new Promise((r) => setTimeout(r, 250))
56
+ handle.stop()
57
+ expect(urls).toEqual([url])
58
+ })
59
+
60
+ test('D4: detects a URL split across separate stream chunks', async () => {
61
+ // The URL is printed in two writes separated by a sleep so they land in
62
+ // distinct read chunks. Per-chunk matching (pre-fix) misses it; the buffered
63
+ // scanner reassembles it. parts: "https://abc" + "def-1234.trycloudflare.com"
64
+ const url = 'https://abcdef-1234.trycloudflare.com'
65
+ const got = deferred<string>()
66
+ const handle = startCloudflareTunnel(3000, {
67
+ command: [
68
+ 'bash',
69
+ '-c',
70
+ `printf 'INF | https://abc'; sleep 0.15; printf 'def-1234.trycloudflare.com |\n'; sleep 1`,
71
+ ],
72
+ onUrl: (u) => got.resolve(u),
73
+ log: () => {},
74
+ warn: () => {},
75
+ })
76
+ expect(await got.promise).toBe(url)
77
+ handle.stop()
78
+ })
79
+
80
+ test('F52: warns with an actionable message and rethrows when the binary is missing', () => {
81
+ let warned = ''
82
+ expect(() =>
83
+ startCloudflareTunnel(3000, {
84
+ command: ['cloudflared-does-not-exist-xyz-123'],
85
+ warn: (m) => {
86
+ warned = m
87
+ },
88
+ log: () => {},
89
+ }),
90
+ ).toThrow()
91
+ expect(warned).toContain('Failed to start cloudflared')
92
+ })
93
+
94
+ test('F53: warns when the process exits before a URL is established', async () => {
95
+ const warned = deferred<string>()
96
+ const handle = startCloudflareTunnel(3000, {
97
+ command: ['bash', '-c', 'exit 1'],
98
+ warn: (m) => warned.resolve(m),
99
+ log: () => {},
100
+ })
101
+ expect(await warned.promise).toContain('before a public URL was established')
102
+ handle.stop()
103
+ })
104
+
105
+ test('F53: stop() is best-effort and does not throw even if called twice', async () => {
106
+ const handle = startCloudflareTunnel(3000, {
107
+ command: ['bash', '-c', 'sleep 2'],
108
+ log: () => {},
109
+ warn: () => {},
110
+ })
111
+ expect(() => {
112
+ handle.stop()
113
+ handle.stop()
114
+ }).not.toThrow()
115
+ })
116
+ })