@marcusrbrown/infra 0.8.0 → 0.9.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.
- package/package.json +1 -1
- package/src/__snapshots__/cli.test.ts.snap +19 -1
- package/src/cli.ts +2 -0
- package/src/commands/cliproxy/setup/providers.test.ts +50 -33
- package/src/commands/cliproxy/setup/smoke-test.test.ts +178 -0
- package/src/commands/cliproxy/setup/smoke-test.ts +29 -11
- package/src/commands/cliproxy/setup.test.ts +454 -48
- package/src/commands/cliproxy/setup.ts +128 -4
- package/src/commands/mcp.test.ts +5 -1
- package/src/commands/mcp.ts +3 -0
- package/src/commands/status.test.ts +36 -0
- package/src/commands/status.ts +10 -3
- package/src/commands/umami/deploy.test.ts +202 -0
- package/src/commands/umami/deploy.ts +132 -0
- package/src/commands/umami/host.test.ts +62 -0
- package/src/commands/umami/host.ts +31 -0
- package/src/commands/umami/index.ts +13 -0
- package/src/commands/umami/logs.test.ts +154 -0
- package/src/commands/umami/logs.ts +161 -0
- package/src/commands/umami/status.test.ts +387 -0
- package/src/commands/umami/status.ts +267 -0
package/package.json
CHANGED
|
@@ -125,9 +125,27 @@ Commands:
|
|
|
125
125
|
--include-ca Restore the mitmproxy CA certificate and private key. Currently the only supported restore target. (default: true)
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
umami status Show operational health of the Umami analytics deployment via docker compose ps.
|
|
129
|
+
|
|
130
|
+
--key [key] Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
umami deploy Deploy Umami analytics. Default mode triggers the GitHub Deploy Umami workflow, while --local runs apps/umami deploy directly with Bun.
|
|
134
|
+
|
|
135
|
+
--local Run local deployment with Bun using apps/umami instead of triggering GitHub Actions. (default: false)
|
|
136
|
+
--dry-run Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow. (default: false)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
umami logs [service] Stream logs from an Umami service via SSH and docker compose.
|
|
140
|
+
|
|
141
|
+
--tail [n] Number of log lines to tail from each service. (default: 100)
|
|
142
|
+
--allow-ci Allow log streaming in CI environments. Logs may contain sensitive credentials. (default: false)
|
|
143
|
+
--key [key] Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.
|
|
144
|
+
|
|
145
|
+
|
|
128
146
|
status Show status of all deployments
|
|
129
147
|
|
|
130
|
-
--json Output machine-readable JSON with keeweb, cliproxy, and
|
|
148
|
+
--json Output machine-readable JSON with keeweb, cliproxy, gateway, and umami summary objects.
|
|
131
149
|
--verbose Include verbose per-app health check details when building the summary rows.
|
|
132
150
|
|
|
133
151
|
|
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {registerGatewayCommands} from './commands/gateway'
|
|
|
7
7
|
import {registerKeewebCommands} from './commands/keeweb'
|
|
8
8
|
import {registerMcp} from './commands/mcp'
|
|
9
9
|
import {registerStatus} from './commands/status'
|
|
10
|
+
import {registerUmamiCommands} from './commands/umami'
|
|
10
11
|
|
|
11
12
|
declare const process: {
|
|
12
13
|
argv: string[]
|
|
@@ -20,6 +21,7 @@ cli.option('--verbose', 'Enable verbose output for all commands')
|
|
|
20
21
|
registerKeewebCommands(cli)
|
|
21
22
|
registerCliproxyCommands(cli)
|
|
22
23
|
registerGatewayCommands(cli)
|
|
24
|
+
registerUmamiCommands(cli)
|
|
23
25
|
registerStatus(cli)
|
|
24
26
|
registerMcp(cli)
|
|
25
27
|
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
/// <reference types="bun" />
|
|
2
2
|
|
|
3
|
+
import type {MultiSelectOptions, TextOptions} from '@clack/prompts'
|
|
3
4
|
import {describe, expect, it, spyOn} from 'bun:test'
|
|
4
5
|
|
|
5
6
|
import {parseProviders, promptForModel, promptForProviders} from './providers'
|
|
6
7
|
|
|
8
|
+
// Type helper: cast a concrete-typed clack implementation to the generic spy type.
|
|
9
|
+
// clack's multiselect/text/select are generic functions; Bun's spyOn preserves the
|
|
10
|
+
// generic signature, so mockImplementation requires the same generic. We provide a
|
|
11
|
+
// concrete instantiation and widen through `unknown` — this is safe because the
|
|
12
|
+
// concrete type is a structural subtype of the generic at the call site.
|
|
13
|
+
function asMultiselectImpl<V>(fn: (opts: MultiSelectOptions<V>) => Promise<V[] | symbol>) {
|
|
14
|
+
return fn as unknown as <Value>(opts: MultiSelectOptions<Value>) => Promise<Value[] | symbol>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function asTextImpl(fn: (opts: TextOptions) => Promise<string | symbol>) {
|
|
18
|
+
return fn as unknown as (opts: TextOptions) => Promise<string | symbol>
|
|
19
|
+
}
|
|
20
|
+
|
|
7
21
|
describe('option parsing', () => {
|
|
8
22
|
describe('parseProviders', () => {
|
|
9
23
|
it("parses \"anthropic,openai\" to ['anthropic', 'openai']", () => {
|
|
@@ -40,7 +54,6 @@ describe('option parsing', () => {
|
|
|
40
54
|
})
|
|
41
55
|
})
|
|
42
56
|
|
|
43
|
-
/* eslint-disable @typescript-eslint/no-explicit-any -- spyOn mock return values require `any` casts */
|
|
44
57
|
describe('interactive provider/model prompts', () => {
|
|
45
58
|
// We spy on @clack/prompts functions directly since Bun's mock.module
|
|
46
59
|
// requires static hoisting. Instead we use spyOn on the imported module.
|
|
@@ -60,7 +73,8 @@ describe('interactive provider/model prompts', () => {
|
|
|
60
73
|
describe('promptForProviders', () => {
|
|
61
74
|
it('happy path: anthropic-only selection returns [anthropic]', async () => {
|
|
62
75
|
const clack = await import('@clack/prompts')
|
|
63
|
-
|
|
76
|
+
// multiselect<Value> returns Promise<Value[] | symbol>; resolved value is string[] | symbol
|
|
77
|
+
const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic'])
|
|
64
78
|
|
|
65
79
|
const result = await promptForProviders()
|
|
66
80
|
|
|
@@ -72,7 +86,7 @@ describe('interactive provider/model prompts', () => {
|
|
|
72
86
|
|
|
73
87
|
it('happy path: both providers selected returns [anthropic, openai]', async () => {
|
|
74
88
|
const clack = await import('@clack/prompts')
|
|
75
|
-
const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic', 'openai']
|
|
89
|
+
const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic', 'openai'])
|
|
76
90
|
|
|
77
91
|
const result = await promptForProviders()
|
|
78
92
|
|
|
@@ -84,11 +98,13 @@ describe('interactive provider/model prompts', () => {
|
|
|
84
98
|
it('edge case: empty selection re-prompts; multiselect called exactly twice', async () => {
|
|
85
99
|
const clack = await import('@clack/prompts')
|
|
86
100
|
let callCount = 0
|
|
87
|
-
const multiselectSpy = spyOn(clack, 'multiselect').mockImplementation(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
const multiselectSpy = spyOn(clack, 'multiselect').mockImplementation(
|
|
102
|
+
asMultiselectImpl<string>(async () => {
|
|
103
|
+
callCount++
|
|
104
|
+
if (callCount === 1) return []
|
|
105
|
+
return ['anthropic']
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
92
108
|
|
|
93
109
|
const result = await promptForProviders()
|
|
94
110
|
|
|
@@ -101,12 +117,12 @@ describe('interactive provider/model prompts', () => {
|
|
|
101
117
|
it('edge case: cancel mid-flow causes process.exit(0)', async () => {
|
|
102
118
|
const clack = await import('@clack/prompts')
|
|
103
119
|
const cancelSymbol = Symbol('cancel')
|
|
104
|
-
const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(cancelSymbol
|
|
120
|
+
const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(cancelSymbol)
|
|
105
121
|
const isCancelSpy = spyOn(clack, 'isCancel').mockImplementation(v => v === cancelSymbol)
|
|
106
122
|
const cancelSpy = spyOn(clack, 'cancel').mockImplementation(() => {})
|
|
107
|
-
const exitSpy = spyOn(process, 'exit').mockImplementation((
|
|
123
|
+
const exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number): never => {
|
|
108
124
|
throw new Error('process.exit called')
|
|
109
|
-
})
|
|
125
|
+
})
|
|
110
126
|
|
|
111
127
|
await expect(promptForProviders()).rejects.toThrow('process.exit called')
|
|
112
128
|
|
|
@@ -144,7 +160,7 @@ describe('interactive provider/model prompts', () => {
|
|
|
144
160
|
|
|
145
161
|
it('happy path: both providers, operator picks openai/gpt-5.4-mini from select', async () => {
|
|
146
162
|
const clack = await import('@clack/prompts')
|
|
147
|
-
const selectSpy = spyOn(clack, 'select').mockResolvedValue('openai/gpt-5.4-mini'
|
|
163
|
+
const selectSpy = spyOn(clack, 'select').mockResolvedValue('openai/gpt-5.4-mini')
|
|
148
164
|
|
|
149
165
|
const result = await promptForModel(['anthropic', 'openai'])
|
|
150
166
|
|
|
@@ -156,7 +172,7 @@ describe('interactive provider/model prompts', () => {
|
|
|
156
172
|
|
|
157
173
|
it('happy path: both providers, operator picks anthropic/claude-sonnet-4-6 from select', async () => {
|
|
158
174
|
const clack = await import('@clack/prompts')
|
|
159
|
-
const selectSpy = spyOn(clack, 'select').mockResolvedValue('anthropic/claude-sonnet-4-6'
|
|
175
|
+
const selectSpy = spyOn(clack, 'select').mockResolvedValue('anthropic/claude-sonnet-4-6')
|
|
160
176
|
|
|
161
177
|
const result = await promptForModel(['anthropic', 'openai'])
|
|
162
178
|
|
|
@@ -167,8 +183,8 @@ describe('interactive provider/model prompts', () => {
|
|
|
167
183
|
|
|
168
184
|
it('happy path: operator picks "enter custom..." then types openai/gpt-5.4-mini', async () => {
|
|
169
185
|
const clack = await import('@clack/prompts')
|
|
170
|
-
const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__'
|
|
171
|
-
const textSpy = spyOn(clack, 'text').mockResolvedValue('openai/gpt-5.4-mini'
|
|
186
|
+
const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__')
|
|
187
|
+
const textSpy = spyOn(clack, 'text').mockResolvedValue('openai/gpt-5.4-mini')
|
|
172
188
|
|
|
173
189
|
const result = await promptForModel(['anthropic', 'openai'])
|
|
174
190
|
|
|
@@ -181,21 +197,23 @@ describe('interactive provider/model prompts', () => {
|
|
|
181
197
|
|
|
182
198
|
it('edge case: custom model entry fails regex then succeeds on second attempt', async () => {
|
|
183
199
|
const clack = await import('@clack/prompts')
|
|
184
|
-
const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__'
|
|
200
|
+
const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__')
|
|
185
201
|
let textCallCount = 0
|
|
186
|
-
const textSpy = spyOn(clack, 'text').mockImplementation(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
202
|
+
const textSpy = spyOn(clack, 'text').mockImplementation(
|
|
203
|
+
asTextImpl(async () => {
|
|
204
|
+
textCallCount++
|
|
205
|
+
// Simulate the validate function being called inline by the mock
|
|
206
|
+
// The real clack text prompt calls validate internally; here we just
|
|
207
|
+
// return the value and let the helper's validate logic re-prompt.
|
|
208
|
+
// Since we can't simulate clack's internal validate loop, we test
|
|
209
|
+
// that the helper's validate function rejects bad input.
|
|
210
|
+
if (textCallCount === 1) {
|
|
211
|
+
// Return a bad value — the helper should detect this and re-prompt
|
|
212
|
+
return 'bad-model'
|
|
213
|
+
}
|
|
214
|
+
return 'openai/gpt-5.4-mini'
|
|
215
|
+
}),
|
|
216
|
+
)
|
|
199
217
|
|
|
200
218
|
const result = await promptForModel(['anthropic', 'openai'])
|
|
201
219
|
|
|
@@ -209,12 +227,12 @@ describe('interactive provider/model prompts', () => {
|
|
|
209
227
|
it('edge case: cancel during model select causes process.exit(0)', async () => {
|
|
210
228
|
const clack = await import('@clack/prompts')
|
|
211
229
|
const cancelSymbol = Symbol('cancel')
|
|
212
|
-
const selectSpy = spyOn(clack, 'select').mockResolvedValue(cancelSymbol
|
|
230
|
+
const selectSpy = spyOn(clack, 'select').mockResolvedValue(cancelSymbol)
|
|
213
231
|
const isCancelSpy = spyOn(clack, 'isCancel').mockImplementation(v => v === cancelSymbol)
|
|
214
232
|
const cancelSpy = spyOn(clack, 'cancel').mockImplementation(() => {})
|
|
215
|
-
const exitSpy = spyOn(process, 'exit').mockImplementation((
|
|
233
|
+
const exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number): never => {
|
|
216
234
|
throw new Error('process.exit called')
|
|
217
|
-
})
|
|
235
|
+
})
|
|
218
236
|
|
|
219
237
|
await expect(promptForModel(['anthropic', 'openai'])).rejects.toThrow('process.exit called')
|
|
220
238
|
|
|
@@ -225,4 +243,3 @@ describe('interactive provider/model prompts', () => {
|
|
|
225
243
|
})
|
|
226
244
|
})
|
|
227
245
|
})
|
|
228
|
-
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
@@ -640,4 +640,182 @@ describe('smoke test runner', () => {
|
|
|
640
640
|
expect(result.kind).toBe('pass')
|
|
641
641
|
expect(result.runUrl).toBe('https://github.com/owner/test-repo/actions/runs/105')
|
|
642
642
|
})
|
|
643
|
+
|
|
644
|
+
// ── Zod schema validation hardening ──────────────────────────────────────
|
|
645
|
+
|
|
646
|
+
it('poll JSON validation — non-array response degrades to unverified without throwing', async () => {
|
|
647
|
+
const triggerTime = new Date('2026-05-25T10:00:00Z')
|
|
648
|
+
|
|
649
|
+
let callIndex = 0
|
|
650
|
+
spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
|
|
651
|
+
callIndex++
|
|
652
|
+
if (callIndex === 1) {
|
|
653
|
+
// baseline: valid empty list
|
|
654
|
+
return makeSmokeChild('[]', '', 0)
|
|
655
|
+
}
|
|
656
|
+
if (callIndex === 2) {
|
|
657
|
+
// trigger succeeds
|
|
658
|
+
return makeSmokeChild('', '', 0)
|
|
659
|
+
}
|
|
660
|
+
// poll returns a non-array object instead of an array — schema rejects it
|
|
661
|
+
return makeSmokeChild('{"error":"unexpected"}', '', 0)
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
|
|
665
|
+
|
|
666
|
+
// Schema validation fails → pollRuns stays [] → no candidates → all polls exhaust → unverified
|
|
667
|
+
expect(result.kind).toBe('unverified')
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
it('poll JSON validation — missing required databaseId field degrades gracefully', async () => {
|
|
671
|
+
const triggerTime = new Date('2026-05-25T10:00:00Z')
|
|
672
|
+
|
|
673
|
+
let callIndex = 0
|
|
674
|
+
spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
|
|
675
|
+
callIndex++
|
|
676
|
+
if (callIndex === 1) {
|
|
677
|
+
return makeSmokeChild('[]', '', 0)
|
|
678
|
+
}
|
|
679
|
+
if (callIndex === 2) {
|
|
680
|
+
return makeSmokeChild('', '', 0)
|
|
681
|
+
}
|
|
682
|
+
// poll returns array entries missing databaseId — schema rejects it
|
|
683
|
+
return makeSmokeChild(
|
|
684
|
+
'[{"status":"completed","conclusion":"success","url":"https://x","createdAt":"2026-05-25T10:00:05Z"}]',
|
|
685
|
+
'',
|
|
686
|
+
0,
|
|
687
|
+
)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
|
|
691
|
+
|
|
692
|
+
expect(result.kind).toBe('unverified')
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
it('poll JSON validation — wrong type for databaseId (string instead of number) degrades gracefully', async () => {
|
|
696
|
+
const triggerTime = new Date('2026-05-25T10:00:00Z')
|
|
697
|
+
|
|
698
|
+
let callIndex = 0
|
|
699
|
+
spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
|
|
700
|
+
callIndex++
|
|
701
|
+
if (callIndex === 1) {
|
|
702
|
+
return makeSmokeChild('[]', '', 0)
|
|
703
|
+
}
|
|
704
|
+
if (callIndex === 2) {
|
|
705
|
+
return makeSmokeChild('', '', 0)
|
|
706
|
+
}
|
|
707
|
+
// databaseId is a string, not a number — schema rejects it
|
|
708
|
+
return makeSmokeChild(
|
|
709
|
+
'[{"databaseId":"not-a-number","status":"completed","conclusion":"success","url":"https://x","createdAt":"2026-05-25T10:00:05Z"}]',
|
|
710
|
+
'',
|
|
711
|
+
0,
|
|
712
|
+
)
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
|
|
716
|
+
|
|
717
|
+
expect(result.kind).toBe('unverified')
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it('poll JSON validation — one malformed entry does not discard a valid matching run', async () => {
|
|
721
|
+
const triggerTime = new Date('2026-05-25T10:00:00Z')
|
|
722
|
+
const createdAt = new Date(triggerTime.getTime() + 5000).toISOString()
|
|
723
|
+
|
|
724
|
+
let callIndex = 0
|
|
725
|
+
spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
|
|
726
|
+
callIndex++
|
|
727
|
+
if (callIndex === 1) {
|
|
728
|
+
// baseline: valid empty list → createdAt heuristic
|
|
729
|
+
return makeSmokeChild('[]', '', 0)
|
|
730
|
+
}
|
|
731
|
+
if (callIndex === 2) {
|
|
732
|
+
// trigger succeeds
|
|
733
|
+
return makeSmokeChild('', '', 0)
|
|
734
|
+
}
|
|
735
|
+
if (callIndex === 3) {
|
|
736
|
+
// poll: one malformed entry (databaseId is a string) PLUS one valid matching
|
|
737
|
+
// completed-success run. Per-entry validation must drop only the bad row and
|
|
738
|
+
// keep the good one — whole-batch rejection would lose our run.
|
|
739
|
+
return makeSmokeChild(
|
|
740
|
+
`[{"databaseId":"bad","status":"completed","conclusion":"success","url":"https://x","createdAt":"${
|
|
741
|
+
createdAt
|
|
742
|
+
}"},{"databaseId":105,"status":"completed","conclusion":"success","url":"${RUN_URL}","createdAt":"${
|
|
743
|
+
createdAt
|
|
744
|
+
}"}]`,
|
|
745
|
+
'',
|
|
746
|
+
0,
|
|
747
|
+
)
|
|
748
|
+
}
|
|
749
|
+
// log view for the matched run → contains "ack"
|
|
750
|
+
return makeSmokeChild('some log output with ack in it', '', 0)
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
|
|
754
|
+
|
|
755
|
+
// The valid entry survives per-entry validation and is matched → pass.
|
|
756
|
+
expect(result.kind).toBe('pass')
|
|
757
|
+
expect(result.runUrl).toBe(RUN_URL)
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
it('baseline JSON validation — non-array baseline falls back to createdAt heuristic', async () => {
|
|
761
|
+
const triggerTime = new Date('2026-05-25T10:00:00Z')
|
|
762
|
+
const createdAt = new Date(triggerTime.getTime() + 5000).toISOString()
|
|
763
|
+
|
|
764
|
+
let callIndex = 0
|
|
765
|
+
spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
|
|
766
|
+
callIndex++
|
|
767
|
+
if (callIndex === 1) {
|
|
768
|
+
// baseline returns a non-array object — schema rejects it, baselineId stays null
|
|
769
|
+
return makeSmokeChild('{"databaseId":100}', '', 0)
|
|
770
|
+
}
|
|
771
|
+
if (callIndex === 2) {
|
|
772
|
+
return makeSmokeChild('', '', 0)
|
|
773
|
+
}
|
|
774
|
+
if (callIndex === 3) {
|
|
775
|
+
return makeSmokeChild(
|
|
776
|
+
makeSmokeRunList([{databaseId: 1, status: 'completed', conclusion: 'success', url: RUN_URL, createdAt}]),
|
|
777
|
+
'',
|
|
778
|
+
0,
|
|
779
|
+
)
|
|
780
|
+
}
|
|
781
|
+
return makeSmokeChild('ack', '', 0)
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
|
|
785
|
+
|
|
786
|
+
// Schema rejects non-array → baselineId stays null → createdAt heuristic → run found → pass
|
|
787
|
+
expect(result.kind).toBe('pass')
|
|
788
|
+
expect(result.runUrl).toBe(RUN_URL)
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
it('baseline JSON validation — missing databaseId in baseline entry falls back to createdAt heuristic', async () => {
|
|
792
|
+
const triggerTime = new Date('2026-05-25T10:00:00Z')
|
|
793
|
+
const createdAt = new Date(triggerTime.getTime() + 5000).toISOString()
|
|
794
|
+
|
|
795
|
+
let callIndex = 0
|
|
796
|
+
spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
|
|
797
|
+
callIndex++
|
|
798
|
+
if (callIndex === 1) {
|
|
799
|
+
// baseline entry missing databaseId — schema rejects it, baselineId stays null
|
|
800
|
+
return makeSmokeChild('[{"id":100}]', '', 0)
|
|
801
|
+
}
|
|
802
|
+
if (callIndex === 2) {
|
|
803
|
+
return makeSmokeChild('', '', 0)
|
|
804
|
+
}
|
|
805
|
+
if (callIndex === 3) {
|
|
806
|
+
return makeSmokeChild(
|
|
807
|
+
makeSmokeRunList([{databaseId: 1, status: 'completed', conclusion: 'success', url: RUN_URL, createdAt}]),
|
|
808
|
+
'',
|
|
809
|
+
0,
|
|
810
|
+
)
|
|
811
|
+
}
|
|
812
|
+
return makeSmokeChild('ack', '', 0)
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
|
|
816
|
+
|
|
817
|
+
// Schema rejects missing databaseId → baselineId stays null → createdAt heuristic → run found → pass
|
|
818
|
+
expect(result.kind).toBe('pass')
|
|
819
|
+
expect(result.runUrl).toBe(RUN_URL)
|
|
820
|
+
})
|
|
643
821
|
})
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
/// <reference types="bun" />
|
|
2
2
|
|
|
3
|
+
import {z} from 'zod'
|
|
4
|
+
|
|
3
5
|
export type SmokeResult =
|
|
4
6
|
| {kind: 'pass'; message: string; runUrl: string}
|
|
5
7
|
| {kind: 'fail'; message: string; runUrl: string}
|
|
6
8
|
| {kind: 'unverified'; message: string; runUrl?: string}
|
|
7
9
|
|
|
10
|
+
// Zod schemas for gh CLI JSON output — single source of truth.
|
|
11
|
+
const baselineRunSchema = z.array(z.object({databaseId: z.number()}))
|
|
12
|
+
|
|
13
|
+
const ghRunEntrySchema = z.object({
|
|
14
|
+
databaseId: z.number(),
|
|
15
|
+
status: z.string(),
|
|
16
|
+
conclusion: z.string().nullable(),
|
|
17
|
+
url: z.string(),
|
|
18
|
+
createdAt: z.string(),
|
|
19
|
+
})
|
|
20
|
+
|
|
8
21
|
// Exported for tests only.
|
|
9
|
-
export
|
|
10
|
-
databaseId: number
|
|
11
|
-
status: string
|
|
12
|
-
conclusion: string | null
|
|
13
|
-
url: string
|
|
14
|
-
createdAt: string
|
|
15
|
-
}
|
|
22
|
+
export type GhRunEntry = z.infer<typeof ghRunEntrySchema>
|
|
16
23
|
|
|
17
24
|
// Exported for tests only. Override poll delays and trigger time.
|
|
18
25
|
export interface SmokeTestInternals {
|
|
@@ -66,10 +73,11 @@ export async function runSmokeTest(
|
|
|
66
73
|
baselineChild.exited,
|
|
67
74
|
])
|
|
68
75
|
if (baselineExit === 0) {
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
baselineId =
|
|
76
|
+
const parseResult = baselineRunSchema.safeParse(JSON.parse(baselineStdout))
|
|
77
|
+
if (parseResult.success && parseResult.data.length > 0 && parseResult.data[0]) {
|
|
78
|
+
baselineId = parseResult.data[0].databaseId
|
|
72
79
|
}
|
|
80
|
+
// If schema validation fails, baselineId stays null — we'll use createdAt heuristic
|
|
73
81
|
}
|
|
74
82
|
// If baseline call fails, baselineId stays null — we'll use createdAt heuristic
|
|
75
83
|
} catch {
|
|
@@ -123,7 +131,17 @@ export async function runSmokeTest(
|
|
|
123
131
|
pollChild.exited,
|
|
124
132
|
])
|
|
125
133
|
if (pollExit === 0) {
|
|
126
|
-
|
|
134
|
+
const rawParsed: unknown = JSON.parse(pollStdout)
|
|
135
|
+
if (Array.isArray(rawParsed)) {
|
|
136
|
+
// Validate each entry independently so a single malformed row does not
|
|
137
|
+
// discard the whole batch — dropping a legitimate matching run would be
|
|
138
|
+
// worse than skipping the bad entry.
|
|
139
|
+
pollRuns = rawParsed.flatMap(entry => {
|
|
140
|
+
const entryResult = ghRunEntrySchema.safeParse(entry)
|
|
141
|
+
return entryResult.success ? [entryResult.data] : []
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
// Non-array payload or all entries malformed → pollRuns stays [] — retry on next poll
|
|
127
145
|
}
|
|
128
146
|
} catch {
|
|
129
147
|
// Parse/network error — retry on next poll
|