switchroom 0.18.29 → 0.18.30

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.
Files changed (38) hide show
  1. package/bin/handoff-briefing.sh +8 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -16
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2074 -1585
  9. package/dist/host-control/main.js +110 -13
  10. package/dist/vault/approvals/kernel-server.js +116 -13
  11. package/dist/vault/broker/server.js +314 -145
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +73 -20
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +560 -96
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/gateway.ts +212 -17
  18. package/telegram-plugin/gateway/model-command.ts +104 -0
  19. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  20. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  21. package/telegram-plugin/llm-error-present.ts +24 -0
  22. package/telegram-plugin/model-unavailable.ts +55 -0
  23. package/telegram-plugin/operator-events.ts +113 -0
  24. package/telegram-plugin/pending-user-notice.ts +88 -0
  25. package/telegram-plugin/shared/local-time.ts +43 -0
  26. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  27. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  28. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +26 -2
  29. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  30. package/telegram-plugin/tests/local-time.test.ts +68 -1
  31. package/telegram-plugin/tests/model-command.test.ts +133 -0
  32. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  33. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  34. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  35. package/vendor/hindsight-memory/scripts/lib/content.py +53 -1
  36. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  37. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  38. package/vendor/hindsight-memory/tests/test_content.py +35 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Outcome regression for the forwarded-message history row (#3300 / #3162).
3
+ *
4
+ * SCOPE (honest): this suite pins the PERSISTENCE leg of the end-to-end
5
+ * chain — `parseForwardOrigin` (#3162) feeding `recordInbound` against the
6
+ * real bun:sqlite history store. The ROUTING leg (an update reaching the
7
+ * pipeline at all — the layer where the 2026-07-16 silent drop happened) is
8
+ * pinned by `catch-all-unhandled-message.test.ts`, which drives the real
9
+ * production catch-all module on a real grammy composer. Together the two
10
+ * suites cover the chain; this one alone also passes on pre-#3300 code
11
+ * because #3162's persistence was always correct — it was simply never
12
+ * reached for the dropped message.
13
+ *
14
+ * Runs under bun (history.ts uses bun:sqlite; gateway.ts itself is a
15
+ * side-effecting module that cannot be imported into a test).
16
+ */
17
+
18
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
19
+ import { mkdtempSync, rmSync } from 'fs'
20
+ import { tmpdir } from 'os'
21
+ import { join } from 'path'
22
+ import {
23
+ initHistory,
24
+ recordInbound,
25
+ query as queryHistory,
26
+ _resetForTests as resetHistory,
27
+ } from '../history.js'
28
+ import { parseForwardOrigin } from '../gateway/forward-origin.js'
29
+
30
+ let stateDir: string
31
+
32
+ beforeEach(() => {
33
+ resetHistory()
34
+ stateDir = mkdtempSync(join(tmpdir(), 'catch-all-forward-'))
35
+ initHistory(stateDir, 0) // 0 disables the init-time prune so we can seed cleanly
36
+ })
37
+
38
+ afterEach(() => {
39
+ resetHistory()
40
+ rmSync(stateDir, { recursive: true, force: true })
41
+ })
42
+
43
+ describe('(a) a forwarded plain-text message records a history row with forwarded_from', () => {
44
+ it('parses the server-stamped forward_origin and persists forwarded_from', () => {
45
+ const chat_id = '777'
46
+ const message_id = 8100
47
+ // Telegram Bot API 7.0 forward_origin, server-stamped (untrusted body,
48
+ // trusted attrs) — a plain-text message forwarded from a user.
49
+ const forwardOrigin = parseForwardOrigin({
50
+ type: 'user',
51
+ date: 1_700_000_000,
52
+ sender_user: { id: 424242, is_bot: false, first_name: 'Ken', last_name: 'Thompson' },
53
+ })
54
+ expect(forwardOrigin).toBeDefined()
55
+ expect(forwardOrigin!.name).toBe('Ken Thompson')
56
+
57
+ recordInbound({
58
+ chat_id,
59
+ thread_id: null,
60
+ message_id,
61
+ user: 'ken',
62
+ user_id: '777',
63
+ ts: 1_700_000_100,
64
+ text: 'here is the brief I forwarded',
65
+ forwarded_from: forwardOrigin!.name,
66
+ forwarded_from_type: forwardOrigin!.type,
67
+ forwarded_from_id: forwardOrigin!.id != null ? String(forwardOrigin!.id) : null,
68
+ })
69
+
70
+ const rows = queryHistory({ chat_id, limit: 10 })
71
+ expect(rows).toHaveLength(1)
72
+ const row = rows[0]
73
+ // A delivered turn is present (the message was NOT dropped) AND carries
74
+ // forwarded provenance.
75
+ expect(row.role).toBe('user')
76
+ expect(row.text).toBe('here is the brief I forwarded')
77
+ expect(row.forwarded_from).toBe('Ken Thompson')
78
+ expect(row.forwarded_from_type).toBe('user')
79
+ expect(row.forwarded_from_id).toBe('424242')
80
+ })
81
+
82
+ it('a non-forwarded message leaves forwarded_from NULL (no false provenance)', () => {
83
+ const chat_id = '778'
84
+ // undefined forward_origin → parseForwardOrigin returns undefined.
85
+ const forwardOrigin = parseForwardOrigin(undefined)
86
+ expect(forwardOrigin).toBeUndefined()
87
+
88
+ recordInbound({
89
+ chat_id,
90
+ thread_id: null,
91
+ message_id: 8200,
92
+ user: 'ken',
93
+ user_id: '778',
94
+ ts: 1_700_000_200,
95
+ text: 'a normal message',
96
+ forwarded_from: forwardOrigin ?? null,
97
+ })
98
+
99
+ const rows = queryHistory({ chat_id, limit: 10 })
100
+ expect(rows).toHaveLength(1)
101
+ expect(rows[0].forwarded_from).toBeNull()
102
+ })
103
+ })
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Regression tests for the terminal catch-all message handler + diagnostic
3
+ * update tap (#3300).
4
+ *
5
+ * THE CLASS OF BUG: the gateway registered ONLY content-specific handlers —
6
+ * `bot.on('message:text')`, `:photo`, `:document`, … `:paid_media`. grammy
7
+ * ^1.44 routes `bot.on` as filtering middleware (`on → filter(pred, handler)
8
+ * → branch(pred, handler, pass)`, verified in grammy/out/composer.js); when
9
+ * NO registered filter matches an inbound `message`, grammy SILENTLY drops
10
+ * it — no log, no ack, no history row. Live signature (2026-07-16, klanker
11
+ * DM): message_id 19090 was allocated between an outbound reply and the next
12
+ * inbound with ZERO gateway trace while polling stayed healthy.
13
+ *
14
+ * THE FIX under test is the REAL production module
15
+ * (`gateway/unhandled-message.ts`) — the same `installUpdateTap` /
16
+ * `installUnhandledMessageCatchAll` gateway.ts wires — driven through a real
17
+ * grammy 1.44 Bot via `bot.handleUpdate`, with a real `bot.on('message:text')`
18
+ * registered BEFORE the catch-all exactly like the gateway's registration
19
+ * order. (gateway.ts itself is a side-effecting module that cannot be
20
+ * imported into a unit test — the extraction into unhandled-message.ts exists
21
+ * precisely so the registered composer path is testable; a structural suite
22
+ * below guards that gateway.ts actually wires it in that order.)
23
+ *
24
+ * Outcomes asserted:
25
+ * (b) an unregistered content type reaches the catch-all, is logged, and
26
+ * yields a turn with the placeholder text naming the content type;
27
+ * (c) a plain text message is consumed by the specific handler and does
28
+ * NOT double-handle in the catch-all;
29
+ * service-noise messages (forum topic lifecycle etc.) are logged but do
30
+ * NOT become turns;
31
+ * the tap observes every update and rate-limits with a suppression summary.
32
+ */
33
+
34
+ import { describe, it, expect, beforeEach } from 'vitest'
35
+ import { readFileSync } from 'node:fs'
36
+ import { Bot, type Context } from 'grammy'
37
+ import type { Update } from 'grammy/types'
38
+ import { makeMessageUpdate, resetUpdateCounters } from './update-factory.js'
39
+ import {
40
+ installUpdateTap,
41
+ installUnhandledMessageCatchAll,
42
+ planUnhandledMessage,
43
+ SERVICE_NOISE_KEYS,
44
+ TAP_MAX_LINES_PER_MINUTE,
45
+ } from '../gateway/unhandled-message.js'
46
+
47
+ interface DeliveredTurn {
48
+ via: 'text' | 'catch-all'
49
+ text: string
50
+ update_id: number
51
+ }
52
+
53
+ /**
54
+ * Wire a real grammy Bot in the gateway's registration order using the REAL
55
+ * production install functions: tap first, specific `message:text` handler,
56
+ * terminal catch-all LAST. `onInbound` records what would flow into
57
+ * handleInboundCoalesced.
58
+ */
59
+ function buildHarness() {
60
+ const delivered: DeliveredTurn[] = []
61
+ const logLines: string[] = []
62
+
63
+ const bot = new Bot('12345:TEST_TOKEN_NOT_REAL')
64
+ bot.botInfo = {
65
+ id: 999,
66
+ is_bot: true,
67
+ first_name: 'TestBot',
68
+ username: 'test_bot',
69
+ can_join_groups: true,
70
+ can_read_all_group_messages: false,
71
+ supports_inline_queries: false,
72
+ can_connect_to_business: false,
73
+ has_main_web_app: false,
74
+ }
75
+
76
+ // REAL production tap (same call gateway.ts makes).
77
+ installUpdateTap(bot, line => logLines.push(line))
78
+
79
+ // Specific handler — representative of the gateway's message:text
80
+ // registration, placed BEFORE the catch-all exactly as in gateway.ts.
81
+ bot.on('message:text', async ctx => {
82
+ delivered.push({ via: 'text', text: ctx.message.text, update_id: ctx.update.update_id })
83
+ })
84
+
85
+ // REAL production catch-all, registered LAST (same call gateway.ts makes).
86
+ installUnhandledMessageCatchAll(
87
+ bot,
88
+ async (ctx: Context, text: string) => {
89
+ delivered.push({ via: 'catch-all', text, update_id: ctx.update.update_id })
90
+ },
91
+ line => logLines.push(line),
92
+ )
93
+
94
+ return { bot, delivered, logLines }
95
+ }
96
+
97
+ /** A message update carrying content that no `message:*` filter covers. */
98
+ function makeContentUpdate(update_id: number, content: Record<string, unknown>): Update {
99
+ return {
100
+ update_id,
101
+ message: {
102
+ message_id: 5000 + update_id,
103
+ chat: { id: 777, type: 'private' },
104
+ from: { id: 777, is_bot: false, first_name: 'Test' },
105
+ date: Math.floor(Date.now() / 1000),
106
+ ...content,
107
+ },
108
+ } as unknown as Update
109
+ }
110
+
111
+ describe('terminal catch-all — real module on a real grammy 1.44 composer (#3300)', () => {
112
+ beforeEach(() => resetUpdateCounters())
113
+
114
+ it('(c) a plain-text message is consumed by message:text and does NOT reach the catch-all', async () => {
115
+ const { bot, delivered, logLines } = buildHarness()
116
+ await bot.handleUpdate(makeMessageUpdate({ text: 'hello brief', update_id: 42 }))
117
+
118
+ expect(delivered).toHaveLength(1)
119
+ expect(delivered[0]).toMatchObject({ via: 'text', text: 'hello brief' })
120
+ // No catch-all log line — specific handler won (no double-handling).
121
+ expect(logLines.filter(l => l.includes('catch-all inbound'))).toHaveLength(0)
122
+ })
123
+
124
+ it('(b) an unregistered content type reaches the catch-all, is logged, and yields a placeholder turn', async () => {
125
+ const { bot, delivered, logLines } = buildHarness()
126
+ await bot.handleUpdate(makeContentUpdate(99, { unknown_future_type: { some: 'payload' } }))
127
+
128
+ // Logged with content KEYS + ids only (never the payload body).
129
+ const catchAllLines = logLines.filter(l => l.includes('catch-all inbound'))
130
+ expect(catchAllLines).toHaveLength(1)
131
+ expect(catchAllLines[0]).toContain('update_id=99')
132
+ expect(catchAllLines[0]).toContain('content_keys=[unknown_future_type]')
133
+ expect(catchAllLines[0]).not.toContain('payload')
134
+
135
+ // Produced a delivered turn with placeholder text naming the content type.
136
+ expect(delivered).toHaveLength(1)
137
+ expect(delivered[0]).toMatchObject({
138
+ via: 'catch-all',
139
+ text: '(unhandled message content: unknown_future_type)',
140
+ })
141
+ })
142
+
143
+ it('a caption on an unhandled content type is used as the turn text (best-effort)', async () => {
144
+ const { bot, delivered } = buildHarness()
145
+ await bot.handleUpdate(
146
+ makeContentUpdate(120, { some_new_media: { id: 'x' }, caption: 'read this brief' }),
147
+ )
148
+ expect(delivered).toHaveLength(1)
149
+ expect(delivered[0]).toMatchObject({ via: 'catch-all', text: 'read this brief' })
150
+ })
151
+
152
+ it('known-noise service messages are LOGGED but produce NO agent turn', async () => {
153
+ const { bot, delivered, logLines } = buildHarness()
154
+ await bot.handleUpdate(
155
+ makeContentUpdate(130, { forum_topic_created: { name: 'spam topic', icon_color: 1 } }),
156
+ )
157
+ await bot.handleUpdate(
158
+ makeContentUpdate(131, { new_chat_members: [{ id: 5, is_bot: false, first_name: 'X' }] }),
159
+ )
160
+
161
+ // No turns delivered — service noise never reaches the agent.
162
+ expect(delivered).toHaveLength(0)
163
+ // But both were explicitly logged (never silently dropped).
164
+ const noiseLines = logLines.filter(l => l.includes('action=log-only'))
165
+ expect(noiseLines).toHaveLength(2)
166
+ expect(noiseLines[0]).toContain('content_keys=[forum_topic_created]')
167
+ expect(noiseLines[1]).toContain('content_keys=[new_chat_members]')
168
+ })
169
+
170
+ it('the diagnostic tap observes EVERY update (pass-through, both routed and caught)', async () => {
171
+ const { bot, logLines } = buildHarness()
172
+ await bot.handleUpdate(makeMessageUpdate({ text: 'routed', update_id: 1 }))
173
+ await bot.handleUpdate(makeContentUpdate(2, { unknown_future_type: {} }))
174
+
175
+ const rx = logLines.filter(l => l.includes('rx update_id='))
176
+ expect(rx).toHaveLength(2)
177
+ expect(rx[0]).toContain('rx update_id=1 type=message')
178
+ expect(rx[1]).toContain('rx update_id=2 type=message')
179
+ })
180
+ })
181
+
182
+ describe('planUnhandledMessage — service-noise classification', () => {
183
+ it('every SERVICE_NOISE_KEYS entry classifies log-only', () => {
184
+ for (const key of SERVICE_NOISE_KEYS) {
185
+ const plan = planUnhandledMessage({ message_id: 1, chat: {}, date: 0, [key]: {} })
186
+ expect(plan.action, `key ${key} should be log-only`).toBe('log-only')
187
+ }
188
+ })
189
+
190
+ it('an unknown content type classifies as a turn (fail-toward-delivery)', () => {
191
+ const plan = planUnhandledMessage({ message_id: 1, chat: {}, date: 0, mystery_type: {} })
192
+ expect(plan).toMatchObject({
193
+ action: 'turn',
194
+ text: '(unhandled message content: mystery_type)',
195
+ })
196
+ })
197
+
198
+ it('a message mixing noise with real content still becomes a turn', () => {
199
+ const plan = planUnhandledMessage({
200
+ message_id: 1, chat: {}, date: 0,
201
+ boost_added: {}, mystery_media: {}, caption: 'look',
202
+ })
203
+ expect(plan).toMatchObject({ action: 'turn', text: 'look' })
204
+ })
205
+ })
206
+
207
+ describe('installUpdateTap — rate limit', () => {
208
+ it('caps lines per minute and emits ONE suppression summary on window rollover', async () => {
209
+ const logLines: string[] = []
210
+ let now = 1_000_000
211
+ const mw: Array<(ctx: unknown, next: () => Promise<void>) => Promise<void>> = []
212
+ const fakeBot = { use: (fn: (ctx: unknown, next: () => Promise<void>) => Promise<void>) => { mw.push(fn) } }
213
+ installUpdateTap(fakeBot as never, l => logLines.push(l), () => now)
214
+
215
+ const fire = (update_id: number) =>
216
+ mw[0]({ update: { update_id }, message: undefined } as never, async () => {})
217
+
218
+ for (let i = 0; i < TAP_MAX_LINES_PER_MINUTE + 50; i++) await fire(i)
219
+ expect(logLines.filter(l => l.includes('rx update_id='))).toHaveLength(TAP_MAX_LINES_PER_MINUTE)
220
+
221
+ // Roll the window: the 50 suppressed lines surface as ONE summary.
222
+ now += 61_000
223
+ await fire(9999)
224
+ const summaries = logLines.filter(l => l.includes('suppressed 50 update lines'))
225
+ expect(summaries).toHaveLength(1)
226
+ // And logging resumes in the fresh window.
227
+ expect(logLines.filter(l => l.includes('rx update_id=9999'))).toHaveLength(1)
228
+ })
229
+ })
230
+
231
+ // ─── Structural guard on the real gateway.ts wiring ────────────────────────
232
+ // The functional harness above proves the composer contract using the REAL
233
+ // production install functions; this guards that gateway.ts wires those same
234
+ // functions in the required order. Hardened beyond a naive regex: it fails
235
+ // if the install call disappears (e.g. refactored away) OR if ANY
236
+ // `bot.on('message…')` registration appears after the catch-all install.
237
+ describe('gateway.ts catch-all registration invariant', () => {
238
+ const SRC = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
239
+
240
+ it('imports and installs the real catch-all + tap from unhandled-message.ts', () => {
241
+ expect(SRC).toContain("from './unhandled-message.js'")
242
+ expect(SRC).toContain('installUpdateTap(bot,')
243
+ expect(SRC).toContain('installUnhandledMessageCatchAll(')
244
+ })
245
+
246
+ it('the catch-all install is AFTER every message handler registration', () => {
247
+ const installIdx = SRC.indexOf('installUnhandledMessageCatchAll(')
248
+ expect(installIdx).toBeGreaterThan(0)
249
+ const after = SRC.slice(installIdx)
250
+ // No message-filter registration of any spelling may follow the terminal
251
+ // catch-all — grammy ordering is the no-double-handling guarantee.
252
+ // (`message_reaction` etc. are DIFFERENT update types, not `message`
253
+ // filters — the pattern requires `message` exactly or `message:<sub>`.)
254
+ expect(after).not.toMatch(/bot\.on\(\s*['"`]message(:|['"`])/)
255
+ // And there must be exactly one catch-all install (no duplicate turns).
256
+ expect(SRC.indexOf('installUnhandledMessageCatchAll(', installIdx + 1)).toBe(-1)
257
+ })
258
+
259
+ it('routes the catch-all through handleInboundCoalesced (same gating + forward-origin path)', () => {
260
+ const installIdx = SRC.indexOf('installUnhandledMessageCatchAll(')
261
+ const body = SRC.slice(installIdx, installIdx + 400)
262
+ expect(body).toMatch(/handleInboundCoalesced\(ctx, text, undefined\)/)
263
+ })
264
+ })
@@ -149,7 +149,7 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
149
149
  it('sends ONE switch-confirmation from the ACTUAL launched model, keyed on the /model reason (F1/N4)', () => {
150
150
  const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
151
151
  expect(idx).toBeGreaterThan(0)
152
- const win = GATEWAY_SRC.slice(idx, idx + 3400)
152
+ const win = GATEWAY_SRC.slice(idx, idx + 5200)
153
153
  // Keyed on the deterministic /model switch reason, so it also fires on a
154
154
  // launched===configured apply-boot (/model default) — N4. Never optimistic.
155
155
  expect(win).toContain('if (modelSwitchReason != null && modelSwitchMarkerChat)')
@@ -158,6 +158,30 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
158
158
  expect(win).toContain('(the configured default)')
159
159
  })
160
160
 
161
+ it('warns instead of a green ✅ when a non-default switch silently reverted to the default (silent-revert fix)', () => {
162
+ const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
163
+ const win = GATEWAY_SRC.slice(idx, idx + 5200)
164
+ // The confirmation card is derived from the pure classifier, not an inline
165
+ // isApplyBoot ternary — so a reverted non-default switch yields the ⚠️ card.
166
+ expect(win).toContain('classifyModelSwitchConfirmation({')
167
+ expect(win).toContain("confirmation.kind === 'applied'")
168
+ expect(win).toContain("confirmation.kind === 'not-applied'")
169
+ expect(win).toContain("⚠️ Your switch to")
170
+ expect(win).toContain("didn't apply")
171
+ // LOW-3: the re-issue hint interpolates the target inside backticks so a
172
+ // token containing Markdown metachars can't italicize / 400 the send.
173
+ expect(win).toContain('Re-issue \\`/model ${confirmation.target}\\`')
174
+ })
175
+
176
+ it('dedups the not-applied card against a tailored .session-model-alert (LOW-2)', () => {
177
+ const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
178
+ const win = GATEWAY_SRC.slice(idx, idx + 5200)
179
+ // When start.sh wrote a specific alert for this revert, the classifier's
180
+ // generic not-applied card is suppressed (the alert relay is the message).
181
+ expect(win).toContain("existsSync(join(smAgentDir, '.session-model-alert'))")
182
+ expect(win).toContain("confirmation.kind === 'not-applied' && hasSessionModelAlert")
183
+ })
184
+
161
185
  it('N4/reason: the /model switch reason is captured from the clean-shutdown marker', () => {
162
186
  expect(GATEWAY_SRC).toContain("cleanMarker.reason.startsWith('user: /model')")
163
187
  expect(GATEWAY_SRC).toContain('let modelSwitchReason: string | null = null')
@@ -172,7 +196,7 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
172
196
  })
173
197
 
174
198
  it('consumes the .session-model-alert sentinel, notifies ALL operators, and deletes it', () => {
175
- const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.session-model-alert')")
199
+ const idx = GATEWAY_SRC.indexOf("const alertPath = join(smAgentDir, '.session-model-alert')")
176
200
  expect(idx).toBeGreaterThan(0)
177
201
  const win = GATEWAY_SRC.slice(idx, idx + 1100)
178
202
  expect(win).toContain('unlinkSync(alertPath)')