@joeytheman/gemini-mcp-tool 1.3.0 → 2.2.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.
Files changed (115) hide show
  1. package/README.md +123 -34
  2. package/dist/__tests__/ask-gemini.test.js +99 -37
  3. package/dist/__tests__/ask-gemini.test.js.map +1 -1
  4. package/dist/__tests__/live-pass.test.d.ts +2 -0
  5. package/dist/__tests__/live-pass.test.d.ts.map +1 -0
  6. package/dist/__tests__/live-pass.test.js +725 -0
  7. package/dist/__tests__/live-pass.test.js.map +1 -0
  8. package/dist/__tests__/screen-review.test.d.ts +2 -0
  9. package/dist/__tests__/screen-review.test.d.ts.map +1 -0
  10. package/dist/__tests__/screen-review.test.js +444 -0
  11. package/dist/__tests__/screen-review.test.js.map +1 -0
  12. package/dist/__tests__/server.test.js +17 -12
  13. package/dist/__tests__/server.test.js.map +1 -1
  14. package/dist/__tests__/simple-tools.test.d.ts +2 -0
  15. package/dist/__tests__/simple-tools.test.d.ts.map +1 -0
  16. package/dist/__tests__/simple-tools.test.js +25 -0
  17. package/dist/__tests__/simple-tools.test.js.map +1 -0
  18. package/dist/__tests__/tools.test.js +63 -0
  19. package/dist/__tests__/tools.test.js.map +1 -1
  20. package/dist/__tests__/utils/agyExecutor.test.d.ts +2 -0
  21. package/dist/__tests__/utils/agyExecutor.test.d.ts.map +1 -0
  22. package/dist/__tests__/utils/agyExecutor.test.js +616 -0
  23. package/dist/__tests__/utils/agyExecutor.test.js.map +1 -0
  24. package/dist/__tests__/utils/agyTranscriptRecovery.test.d.ts +2 -0
  25. package/dist/__tests__/utils/agyTranscriptRecovery.test.d.ts.map +1 -0
  26. package/dist/__tests__/utils/agyTranscriptRecovery.test.js +127 -0
  27. package/dist/__tests__/utils/agyTranscriptRecovery.test.js.map +1 -0
  28. package/dist/__tests__/utils/chunkCache.test.js +26 -2
  29. package/dist/__tests__/utils/chunkCache.test.js.map +1 -1
  30. package/dist/__tests__/utils/commandExecutor.test.js +30 -6
  31. package/dist/__tests__/utils/commandExecutor.test.js.map +1 -1
  32. package/dist/__tests__/utils/fileReferences.test.d.ts +2 -0
  33. package/dist/__tests__/utils/fileReferences.test.d.ts.map +1 -0
  34. package/dist/__tests__/utils/fileReferences.test.js +87 -0
  35. package/dist/__tests__/utils/fileReferences.test.js.map +1 -0
  36. package/dist/__tests__/utils/gitStatusGuard.test.d.ts +2 -0
  37. package/dist/__tests__/utils/gitStatusGuard.test.d.ts.map +1 -0
  38. package/dist/__tests__/utils/gitStatusGuard.test.js +106 -0
  39. package/dist/__tests__/utils/gitStatusGuard.test.js.map +1 -0
  40. package/dist/__tests__/utils/responseCache.test.js +31 -11
  41. package/dist/__tests__/utils/responseCache.test.js.map +1 -1
  42. package/dist/__tests__/utils/test-helpers.d.ts +4 -0
  43. package/dist/__tests__/utils/test-helpers.d.ts.map +1 -1
  44. package/dist/__tests__/utils/test-helpers.js +16 -0
  45. package/dist/__tests__/utils/test-helpers.js.map +1 -1
  46. package/dist/constants.d.ts +68 -26
  47. package/dist/constants.d.ts.map +1 -1
  48. package/dist/constants.js +69 -28
  49. package/dist/constants.js.map +1 -1
  50. package/dist/index.js +2 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/tools/ask-gemini.tool.d.ts +34 -0
  53. package/dist/tools/ask-gemini.tool.d.ts.map +1 -1
  54. package/dist/tools/ask-gemini.tool.js +38 -22
  55. package/dist/tools/ask-gemini.tool.js.map +1 -1
  56. package/dist/tools/brainstorm.tool.d.ts.map +1 -1
  57. package/dist/tools/brainstorm.tool.js +5 -4
  58. package/dist/tools/brainstorm.tool.js.map +1 -1
  59. package/dist/tools/index.d.ts.map +1 -1
  60. package/dist/tools/index.js +3 -1
  61. package/dist/tools/index.js.map +1 -1
  62. package/dist/tools/live-pass.tool.d.ts +183 -0
  63. package/dist/tools/live-pass.tool.d.ts.map +1 -0
  64. package/dist/tools/live-pass.tool.js +548 -0
  65. package/dist/tools/live-pass.tool.js.map +1 -0
  66. package/dist/tools/registry.d.ts +4 -2
  67. package/dist/tools/registry.d.ts.map +1 -1
  68. package/dist/tools/registry.js +12 -9
  69. package/dist/tools/registry.js.map +1 -1
  70. package/dist/tools/screen-review.tool.d.ts +121 -0
  71. package/dist/tools/screen-review.tool.d.ts.map +1 -0
  72. package/dist/tools/screen-review.tool.js +415 -0
  73. package/dist/tools/screen-review.tool.js.map +1 -0
  74. package/dist/tools/simple-tools.d.ts.map +1 -1
  75. package/dist/tools/simple-tools.js +4 -2
  76. package/dist/tools/simple-tools.js.map +1 -1
  77. package/dist/utils/agyExecutor.d.ts +68 -0
  78. package/dist/utils/agyExecutor.d.ts.map +1 -0
  79. package/dist/utils/agyExecutor.js +443 -0
  80. package/dist/utils/agyExecutor.js.map +1 -0
  81. package/dist/utils/agyReport.d.ts +19 -0
  82. package/dist/utils/agyReport.d.ts.map +1 -0
  83. package/dist/utils/agyReport.js +62 -0
  84. package/dist/utils/agyReport.js.map +1 -0
  85. package/dist/utils/agyTranscriptRecovery.d.ts +16 -0
  86. package/dist/utils/agyTranscriptRecovery.d.ts.map +1 -0
  87. package/dist/utils/agyTranscriptRecovery.js +149 -0
  88. package/dist/utils/agyTranscriptRecovery.js.map +1 -0
  89. package/dist/utils/chunkCache.d.ts.map +1 -1
  90. package/dist/utils/chunkCache.js +19 -5
  91. package/dist/utils/chunkCache.js.map +1 -1
  92. package/dist/utils/commandExecutor.d.ts.map +1 -1
  93. package/dist/utils/commandExecutor.js +15 -7
  94. package/dist/utils/commandExecutor.js.map +1 -1
  95. package/dist/utils/fileReferences.d.ts +45 -0
  96. package/dist/utils/fileReferences.d.ts.map +1 -0
  97. package/dist/utils/fileReferences.js +169 -0
  98. package/dist/utils/fileReferences.js.map +1 -0
  99. package/dist/utils/gitStatusGuard.d.ts +35 -0
  100. package/dist/utils/gitStatusGuard.d.ts.map +1 -0
  101. package/dist/utils/gitStatusGuard.js +82 -0
  102. package/dist/utils/gitStatusGuard.js.map +1 -0
  103. package/dist/utils/responseCache.d.ts +3 -3
  104. package/dist/utils/responseCache.d.ts.map +1 -1
  105. package/dist/utils/responseCache.js +11 -6
  106. package/dist/utils/responseCache.js.map +1 -1
  107. package/package.json +10 -9
  108. package/dist/__tests__/utils/geminiExecutor.test.d.ts +0 -2
  109. package/dist/__tests__/utils/geminiExecutor.test.d.ts.map +0 -1
  110. package/dist/__tests__/utils/geminiExecutor.test.js +0 -337
  111. package/dist/__tests__/utils/geminiExecutor.test.js.map +0 -1
  112. package/dist/utils/geminiExecutor.d.ts +0 -17
  113. package/dist/utils/geminiExecutor.d.ts.map +0 -1
  114. package/dist/utils/geminiExecutor.js +0 -238
  115. package/dist/utils/geminiExecutor.js.map +0 -1
@@ -0,0 +1,725 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ vi.mock('../utils/logger.js', () => ({
5
+ Logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn(), toolInvocation: vi.fn() },
6
+ }));
7
+ const mockExecuteAgyJson = vi.fn();
8
+ vi.mock('../utils/agyExecutor.js', async (importOriginal) => {
9
+ const actual = await importOriginal();
10
+ return { ...actual, executeAgyJson: (...args) => mockExecuteAgyJson(...args) };
11
+ });
12
+ const cleanSnapshot = (root = '/repo') => ({
13
+ root,
14
+ lines: [],
15
+ hashes: new Map(),
16
+ truncated: false,
17
+ });
18
+ const mockSnapshotWorkingTree = vi.fn().mockReturnValue(cleanSnapshot());
19
+ vi.mock('../utils/gitStatusGuard.js', async (importOriginal) => {
20
+ const actual = await importOriginal();
21
+ return { ...actual, snapshotWorkingTree: (...args) => mockSnapshotWorkingTree(...args) };
22
+ });
23
+ import { livePassTool, livePassArgsSchema, livePassResultSchema, LIVE_PASS_RESULT_JSON_SCHEMA, buildLivePassPrompt, frameFileName, prescribedFrameNames, validateFrames, checkCoverage, deriveVerdict, runPublishCommand, } from '../tools/live-pass.tool.js';
24
+ import { ERROR_MESSAGES, LIVE_PASS, MODELS } from '../constants.js';
25
+ import { makeTempDir, cleanupTempDirs } from './utils/test-helpers.js';
26
+ // ------------------------------------------------------------------ helpers
27
+ const makeWorkDir = () => makeTempDir('live-pass');
28
+ const HOME_TARGET = {
29
+ screen: 'home',
30
+ state: 'default',
31
+ viewport: '390x844',
32
+ expected: "heading 'Hello'",
33
+ };
34
+ function makeFrame(overrides = {}) {
35
+ return {
36
+ file: '/tmp/frame.png',
37
+ screen: 'home',
38
+ state: 'default',
39
+ expected: "heading 'Hello'",
40
+ viewport: '390x844',
41
+ verdict: 'PASS',
42
+ observed: 'Shows the Hello heading above the list.',
43
+ ...overrides,
44
+ };
45
+ }
46
+ function makeResult(overrides = {}) {
47
+ return {
48
+ verdict: 'PASS',
49
+ summary: 'Everything rendered.',
50
+ served_branch_check: "footer reads 'BRANCH: smoke'",
51
+ frames: [makeFrame()],
52
+ findings: [],
53
+ regressions: [],
54
+ resolved: [],
55
+ changes: [],
56
+ skipped: [],
57
+ ...overrides,
58
+ };
59
+ }
60
+ const CLEAN_VALIDATION = { checks: [], okFiles: [], problems: [], notes: [], unreportedCaptures: [] };
61
+ const CLEAN_COVERAGE = { uncovered: [], skipped: [], extraFrames: [] };
62
+ function writeFrameFile(artifactDir, name) {
63
+ fs.mkdirSync(artifactDir, { recursive: true });
64
+ const file = path.join(artifactDir, name);
65
+ fs.writeFileSync(file, 'PNG');
66
+ return file;
67
+ }
68
+ function writeManifest(artifactDir, frames) {
69
+ fs.writeFileSync(path.join(artifactDir, LIVE_PASS.MANIFEST_FILE), JSON.stringify({ run_started: new Date().toISOString(), driver: 'playwright', frames, skipped: [] }));
70
+ }
71
+ function envelope(structured, overrides = {}) {
72
+ return {
73
+ conversation_id: 'conv-abc',
74
+ status: 'SUCCESS',
75
+ response: '',
76
+ structured_output: structured,
77
+ num_turns: 3,
78
+ duration_seconds: 42,
79
+ usage: { input_tokens: 16000, output_tokens: 900, total_tokens: 16900 },
80
+ denied_actions: [],
81
+ ...overrides,
82
+ };
83
+ }
84
+ function run(args) {
85
+ return livePassTool.execute(args);
86
+ }
87
+ beforeEach(() => {
88
+ vi.clearAllMocks();
89
+ mockSnapshotWorkingTree.mockReturnValue(cleanSnapshot());
90
+ });
91
+ afterEach(cleanupTempDirs);
92
+ // ------------------------------------------------------------------- schema
93
+ describe('livePassArgsSchema', () => {
94
+ const minimal = {
95
+ targets: [HOME_TARGET],
96
+ brief: 'Base URL http://localhost:8123',
97
+ workingDirectory: '/tmp/project',
98
+ driver: 'playwright',
99
+ };
100
+ it('applies the documented defaults', () => {
101
+ const parsed = livePassArgsSchema.parse(minimal);
102
+ expect(parsed.yolo).toBe(true);
103
+ expect(parsed.printTimeout).toBe(LIVE_PASS.DEFAULT_PRINT_TIMEOUT);
104
+ expect(parsed.printTimeout).toBe('15m');
105
+ expect(parsed.model).toBe(MODELS.MEDIUM);
106
+ expect(parsed.model).toBe('Gemini 3.8 Flash (Medium)');
107
+ });
108
+ it('requires a driver and at least one target', () => {
109
+ expect(() => livePassArgsSchema.parse({ ...minimal, driver: undefined })).toThrow();
110
+ expect(() => livePassArgsSchema.parse({ ...minimal, targets: [] })).toThrow();
111
+ });
112
+ it('rejects a duplicate screen/state/viewport triple', () => {
113
+ expect(() => livePassArgsSchema.parse({
114
+ ...minimal,
115
+ targets: [HOME_TARGET, { ...HOME_TARGET, screen: ' Home ' }],
116
+ })).toThrow(/duplicate target/);
117
+ });
118
+ it('keeps two states of the same screen', () => {
119
+ const parsed = livePassArgsSchema.parse({
120
+ ...minimal,
121
+ targets: [HOME_TARGET, { ...HOME_TARGET, state: 'empty' }],
122
+ });
123
+ expect(parsed.targets).toHaveLength(2);
124
+ });
125
+ });
126
+ describe('LIVE_PASS_RESULT_JSON_SCHEMA', () => {
127
+ it('has no $schema key and requires every result section', () => {
128
+ expect(LIVE_PASS_RESULT_JSON_SCHEMA).not.toHaveProperty('$schema');
129
+ expect(LIVE_PASS_RESULT_JSON_SCHEMA.required).toEqual(expect.arrayContaining([
130
+ 'verdict', 'summary', 'served_branch_check', 'frames',
131
+ 'findings', 'regressions', 'resolved', 'changes', 'skipped',
132
+ ]));
133
+ });
134
+ it('requires expected and observed on every frame', () => {
135
+ const frames = LIVE_PASS_RESULT_JSON_SCHEMA.properties.frames;
136
+ expect(frames.items.required).toEqual(expect.arrayContaining(['file', 'expected', 'observed', 'verdict']));
137
+ });
138
+ });
139
+ // ------------------------------------------------------------------- prompt
140
+ describe('frameFileName', () => {
141
+ it('slugifies every segment so spaces and slashes cannot collide', () => {
142
+ expect(frameFileName({ screen: 'Settings/Profile', state: 'sheet open', viewport: '390x844' }))
143
+ .toBe('settings-profile-sheet-open-390x844.png');
144
+ expect(frameFileName({ screen: ' Order Detail ', state: 'Empty!', viewport: '1280 x 800' }))
145
+ .toBe('order-detail-empty-1280-x-800.png');
146
+ });
147
+ it('suffixes a name two different targets would otherwise slugify onto', () => {
148
+ const names = prescribedFrameNames([
149
+ { screen: 'settings/profile', state: 'default', viewport: '390x844' },
150
+ { screen: 'settings-profile', state: 'default', viewport: '390x844' },
151
+ ]);
152
+ expect([...names.values()]).toEqual([
153
+ 'settings-profile-default-390x844.png',
154
+ 'settings-profile-default-390x844-2.png',
155
+ ]);
156
+ });
157
+ it('gives two viewports of the same screen and state different files', () => {
158
+ const mobile = frameFileName({ screen: 'home', state: 'default', viewport: '390x844' });
159
+ const desktop = frameFileName({ screen: 'home', state: 'default', viewport: '1280x800' });
160
+ expect(mobile).not.toBe(desktop);
161
+ });
162
+ });
163
+ describe('buildLivePassPrompt', () => {
164
+ const base = {
165
+ brief: 'Base URL http://localhost:8123; marker: footer BRANCH: smoke',
166
+ targets: [HOME_TARGET, { ...HOME_TARGET, state: 'offline', expected: 'offline notice plus Retry' }],
167
+ workingDirectory: '/work/tree',
168
+ artifactDir: '/work/tree/.live-pass/run1',
169
+ };
170
+ it('renders the targets as a numbered list with their expected state', () => {
171
+ const prompt = buildLivePassPrompt({ ...base, driver: 'playwright', resumed: false });
172
+ expect(prompt).toContain('1. screen: home | state: default | viewport: 390x844');
173
+ expect(prompt).toContain("expected: heading 'Hello'");
174
+ expect(prompt).toContain('2. screen: home | state: offline | viewport: 390x844');
175
+ expect(prompt).toContain('expected: offline notice plus Retry');
176
+ });
177
+ it('carries the playwright procedure and the evidence discipline', () => {
178
+ const prompt = buildLivePassPrompt({ ...base, driver: 'playwright', resumed: false });
179
+ expect(prompt).toContain('browser_take_screenshot');
180
+ expect(prompt).toContain('/work/tree/.live-pass/run1');
181
+ expect(prompt).toContain('never use fullPage');
182
+ expect(prompt).toContain('answer is ONLY the schema JSON');
183
+ expect(prompt).toContain('a spinner, blank page, skeleton, error, or the wrong screen is a FAIL');
184
+ expect(prompt).toContain('PASSES when it shows that state with everything "expected" names');
185
+ expect(prompt).toContain('Something "expected" does not ask for is at most a MINOR finding');
186
+ expect(prompt).toContain(LIVE_PASS.MANIFEST_FILE);
187
+ expect(prompt).not.toContain('list_devices');
188
+ });
189
+ it('prints one prescribed absolute file path per target, for both drivers', () => {
190
+ for (const driver of ['playwright', 'maestro']) {
191
+ const prompt = buildLivePassPrompt({ ...base, driver, resumed: false });
192
+ expect(prompt).toContain(` file: /work/tree/.live-pass/run1/${frameFileName(base.targets[0])}`);
193
+ expect(prompt).toContain(` file: /work/tree/.live-pass/run1/${frameFileName(base.targets[1])}`);
194
+ // The viewport is part of every prescribed name, so two viewports of one
195
+ // screen/state can never land in the same file.
196
+ expect(prompt).toContain('home-default-390x844.png');
197
+ expect(prompt).toContain('home-offline-390x844.png');
198
+ expect(prompt).toContain('a file that answers two targets is treated as no evidence at all');
199
+ expect(prompt).toContain('never reuse another target\'s file');
200
+ }
201
+ });
202
+ it('carries the maestro procedure instead for the maestro driver', () => {
203
+ const prompt = buildLivePassPrompt({ ...base, driver: 'maestro', resumed: false });
204
+ expect(prompt).toContain('list_devices');
205
+ expect(prompt).toContain('xcrun simctl io');
206
+ expect(prompt).toContain('Never use take_screenshot for evidence');
207
+ expect(prompt).not.toContain('browser_take_screenshot');
208
+ });
209
+ it('adds the delta paragraph only for a resumed pass', () => {
210
+ const fresh = buildLivePassPrompt({ ...base, driver: 'playwright', resumed: false });
211
+ const resumed = buildLivePassPrompt({ ...base, driver: 'playwright', resumed: true });
212
+ expect(fresh).not.toContain('Delta reporting');
213
+ expect(fresh).toContain('This is a fresh pass');
214
+ expect(resumed).toContain('Delta reporting');
215
+ expect(resumed).toContain('Never put an improvement in "findings" or "regressions"');
216
+ });
217
+ });
218
+ // --------------------------------------------------------------- validation
219
+ describe('validateFrames', () => {
220
+ it('accepts a fresh, non-empty frame inside the artifact directory', () => {
221
+ const artifactDir = path.join(makeWorkDir(), 'frames');
222
+ const file = writeFrameFile(artifactDir, 'home-default.png');
223
+ writeManifest(artifactDir, [{ file }]);
224
+ const validation = validateFrames(makeResult({ frames: [makeFrame({ file })] }), artifactDir, Date.now());
225
+ expect(validation.checks[0].status).toBe('OK');
226
+ expect(validation.okFiles).toEqual([file]);
227
+ expect(validation.problems).toEqual([]);
228
+ });
229
+ it.each([
230
+ ['MISSING', (dir) => path.join(dir, 'never-written.png')],
231
+ ['EMPTY', (dir) => {
232
+ const file = path.join(dir, 'empty.png');
233
+ fs.writeFileSync(file, '');
234
+ return file;
235
+ }],
236
+ ['STALE', (dir) => {
237
+ const file = writeFrameFile(dir, 'stale.png');
238
+ const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
239
+ fs.utimesSync(file, yesterday, yesterday);
240
+ return file;
241
+ }],
242
+ ])('flags a %s frame', (status, makeFile) => {
243
+ const artifactDir = path.join(makeWorkDir(), 'frames');
244
+ fs.mkdirSync(artifactDir, { recursive: true });
245
+ const file = makeFile(artifactDir);
246
+ writeManifest(artifactDir, [{ file }]);
247
+ const validation = validateFrames(makeResult({ frames: [makeFrame({ file })] }), artifactDir, Date.now());
248
+ expect(validation.checks[0].status).toBe(status);
249
+ expect(validation.okFiles).toEqual([]);
250
+ expect(validation.problems.join('\n')).toContain(status);
251
+ });
252
+ it('marks every frame after the first that reuses an evidence file as DUPLICATE', () => {
253
+ const artifactDir = path.join(makeWorkDir(), 'frames');
254
+ const file = writeFrameFile(artifactDir, 'home-default-390x844.png');
255
+ writeManifest(artifactDir, [{ file }, { file }]);
256
+ const wide = { ...HOME_TARGET, viewport: '1280x800' };
257
+ const validation = validateFrames(makeResult({ frames: [makeFrame({ file }), makeFrame({ file, viewport: '1280x800' })] }), artifactDir, Date.now(), [HOME_TARGET, wide]);
258
+ expect(validation.checks.map(check => check.status)).toEqual(['OK', 'DUPLICATE']);
259
+ expect(validation.okFiles).toEqual([file]);
260
+ expect(validation.problems.join('\n')).toContain('reused evidence file');
261
+ });
262
+ it('notes a frame saved under a name other than the one prescribed, without failing it', () => {
263
+ const artifactDir = path.join(makeWorkDir(), 'frames');
264
+ const file = writeFrameFile(artifactDir, 'whatever-i-felt-like.png');
265
+ writeManifest(artifactDir, [{ file }]);
266
+ const validation = validateFrames(makeResult({ frames: [makeFrame({ file })] }), artifactDir, Date.now(), [HOME_TARGET]);
267
+ expect(validation.checks[0].status).toBe('OK');
268
+ expect(validation.checks[0].misnamed).toBe(true);
269
+ expect(validation.problems).toEqual([]);
270
+ expect(validation.notes.join('\n')).toContain('MISNAMED');
271
+ expect(validation.notes.join('\n')).toContain(frameFileName(HOME_TARGET));
272
+ });
273
+ it('treats a directory reported as a frame as MISSING', () => {
274
+ const artifactDir = path.join(makeWorkDir(), 'frames');
275
+ const file = path.join(artifactDir, 'home-default.png');
276
+ fs.mkdirSync(file, { recursive: true });
277
+ writeManifest(artifactDir, [{ file }]);
278
+ const validation = validateFrames(makeResult({ frames: [makeFrame({ file })] }), artifactDir, Date.now());
279
+ expect(validation.checks[0].status).toBe('MISSING');
280
+ expect(validation.okFiles).toEqual([]);
281
+ });
282
+ it('flags a frame written outside the artifact directory', () => {
283
+ const workDir = makeWorkDir();
284
+ const artifactDir = path.join(workDir, 'frames');
285
+ fs.mkdirSync(artifactDir, { recursive: true });
286
+ const outside = writeFrameFile(workDir, 'escaped.png');
287
+ writeManifest(artifactDir, [{ file: outside }]);
288
+ const validation = validateFrames(makeResult({ frames: [makeFrame({ file: outside })] }), artifactDir, Date.now());
289
+ expect(validation.checks[0].status).toBe('OUTSIDE');
290
+ });
291
+ it('reports a missing manifest and captures that were never reported', () => {
292
+ const artifactDir = path.join(makeWorkDir(), 'frames');
293
+ const reported = writeFrameFile(artifactDir, 'home-default.png');
294
+ writeFrameFile(artifactDir, 'orphan.png');
295
+ const validation = validateFrames(makeResult({ frames: [makeFrame({ file: reported })] }), artifactDir, Date.now());
296
+ expect(validation.problems.join('\n')).toContain('was not written');
297
+ expect(validation.unreportedCaptures).toEqual([path.join(artifactDir, 'orphan.png')]);
298
+ });
299
+ it('rejects a manifest left over from an earlier run in a reused artifact directory', () => {
300
+ const artifactDir = path.join(makeWorkDir(), 'frames');
301
+ const file = writeFrameFile(artifactDir, 'home-default-390x844.png');
302
+ writeManifest(artifactDir, [{ file }]);
303
+ const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
304
+ fs.utimesSync(path.join(artifactDir, LIVE_PASS.MANIFEST_FILE), yesterday, yesterday);
305
+ const validation = validateFrames(makeResult({ frames: [makeFrame({ file })] }), artifactDir, Date.now(), [HOME_TARGET]);
306
+ expect(validation.problems.join('\n')).toContain('predates this run');
307
+ });
308
+ it('reports a manifest whose frame count disagrees with the answer', () => {
309
+ const artifactDir = path.join(makeWorkDir(), 'frames');
310
+ const file = writeFrameFile(artifactDir, 'home-default.png');
311
+ writeManifest(artifactDir, []);
312
+ const validation = validateFrames(makeResult({ frames: [makeFrame({ file })] }), artifactDir, Date.now());
313
+ expect(validation.problems.join('\n')).toContain('lists 0 frames');
314
+ });
315
+ });
316
+ describe('checkCoverage', () => {
317
+ it('matches a target to its frame exactly', () => {
318
+ const coverage = checkCoverage([HOME_TARGET], makeResult());
319
+ expect(coverage.uncovered).toEqual([]);
320
+ expect(coverage.extraFrames).toEqual([]);
321
+ });
322
+ it('matches despite case and whitespace differences', () => {
323
+ const result = makeResult({
324
+ frames: [makeFrame({ screen: ' HOME ', state: 'Default', viewport: '390 x 844' })],
325
+ });
326
+ expect(checkCoverage([HOME_TARGET], result).uncovered).toEqual([]);
327
+ });
328
+ it('reports a target with no frame as uncovered', () => {
329
+ const missing = { ...HOME_TARGET, screen: 'settings' };
330
+ const coverage = checkCoverage([HOME_TARGET, missing], makeResult());
331
+ expect(coverage.uncovered).toEqual([missing]);
332
+ });
333
+ it('reports a target only present in skipped as skipped', () => {
334
+ const missing = { ...HOME_TARGET, screen: 'settings' };
335
+ const result = makeResult({ skipped: [{ target: 'settings / default', reason: 'route 500s' }] });
336
+ const coverage = checkCoverage([HOME_TARGET, missing], result);
337
+ expect(coverage.uncovered).toEqual([]);
338
+ expect(coverage.skipped).toEqual([{ target: missing, reason: 'route 500s' }]);
339
+ });
340
+ it('keeps a frame that matches no requested target as an extra', () => {
341
+ const result = makeResult({
342
+ frames: [makeFrame(), makeFrame({ screen: 'bonus', file: '/tmp/bonus.png' })],
343
+ });
344
+ expect(checkCoverage([HOME_TARGET], result).extraFrames).toEqual(['/tmp/bonus.png']);
345
+ });
346
+ });
347
+ describe('deriveVerdict', () => {
348
+ it('downgrades a reported PASS when a frame failed', () => {
349
+ const result = makeResult({ frames: [makeFrame({ verdict: 'FAIL' })] });
350
+ const decision = deriveVerdict({ result, validation: CLEAN_VALIDATION, coverage: CLEAN_COVERAGE, deniedActions: '' });
351
+ expect(decision.verdict).toBe('FAIL');
352
+ expect(decision.reported).toBe('PASS');
353
+ expect(decision.reasons.join('\n')).toContain('frame FAIL: home / default');
354
+ });
355
+ it('downgrades a reported PASS when a target was never covered', () => {
356
+ const decision = deriveVerdict({
357
+ result: makeResult(),
358
+ validation: CLEAN_VALIDATION,
359
+ coverage: { ...CLEAN_COVERAGE, uncovered: [{ ...HOME_TARGET, screen: 'settings' }] },
360
+ deniedActions: '',
361
+ });
362
+ expect(decision.verdict).toBe('FAIL');
363
+ expect(decision.reasons.join('\n')).toContain('uncovered target: settings');
364
+ });
365
+ it('downgrades a reported PASS when a frame did not validate', () => {
366
+ const decision = deriveVerdict({
367
+ result: makeResult(),
368
+ validation: { ...CLEAN_VALIDATION, problems: ['STALE: /tmp/frame.png (home / default)'] },
369
+ coverage: CLEAN_COVERAGE,
370
+ deniedActions: '',
371
+ });
372
+ expect(decision.verdict).toBe('FAIL');
373
+ });
374
+ it.each([
375
+ ['BLOCKER', 'FAIL'],
376
+ ['MAJOR', 'FAIL'],
377
+ ['MINOR', 'PASS'],
378
+ ])('treats a %s finding as %s', (severity, expected) => {
379
+ const result = makeResult({ findings: [{ severity, screen: 'home', description: 'label clipped' }] });
380
+ const decision = deriveVerdict({ result, validation: CLEAN_VALIDATION, coverage: CLEAN_COVERAGE, deniedActions: '' });
381
+ expect(decision.verdict).toBe(expected);
382
+ });
383
+ it('fails a target Gemini skipped: a reason explains missing evidence, it does not excuse it', () => {
384
+ const decision = deriveVerdict({
385
+ result: makeResult(),
386
+ validation: CLEAN_VALIDATION,
387
+ coverage: {
388
+ ...CLEAN_COVERAGE,
389
+ skipped: [{ target: { ...HOME_TARGET, screen: 'settings' }, reason: 'simulator busy' }],
390
+ },
391
+ deniedActions: '',
392
+ });
393
+ expect(decision.verdict).toBe('FAIL');
394
+ expect(decision.reasons.join('\n')).toContain('skipped target: settings / default (simulator busy)');
395
+ });
396
+ it('is BLOCKED whenever Gemini reported BLOCKED', () => {
397
+ const result = makeResult({ verdict: 'BLOCKED', frames: [] });
398
+ const decision = deriveVerdict({ result, validation: CLEAN_VALIDATION, coverage: CLEAN_COVERAGE, deniedActions: '' });
399
+ expect(decision.verdict).toBe('BLOCKED');
400
+ });
401
+ it('is BLOCKED whenever an action was denied, whatever Gemini reported', () => {
402
+ const decision = deriveVerdict({
403
+ result: makeResult(),
404
+ validation: CLEAN_VALIDATION,
405
+ coverage: CLEAN_COVERAGE,
406
+ deniedActions: 'RunCommand (command)',
407
+ });
408
+ expect(decision.verdict).toBe('BLOCKED');
409
+ expect(decision.reasons.join('\n')).toContain('RunCommand (command)');
410
+ });
411
+ it('fails on a regression and passes on resolved or neutral changes', () => {
412
+ const regressed = deriveVerdict({
413
+ result: makeResult({ regressions: [{ severity: 'MAJOR', screen: 'home', description: 'Retry no longer works' }] }),
414
+ validation: CLEAN_VALIDATION,
415
+ coverage: CLEAN_COVERAGE,
416
+ deniedActions: '',
417
+ });
418
+ const improved = deriveVerdict({
419
+ result: makeResult({
420
+ resolved: [{ screen: 'home', description: 'clipped label now wraps' }],
421
+ changes: [{ screen: 'home', description: 'Retry moved below the message' }],
422
+ }),
423
+ validation: CLEAN_VALIDATION,
424
+ coverage: CLEAN_COVERAGE,
425
+ deniedActions: '',
426
+ });
427
+ expect(regressed.verdict).toBe('FAIL');
428
+ expect(improved.verdict).toBe('PASS');
429
+ });
430
+ });
431
+ // --------------------------------------------------------------- publishing
432
+ describe('runPublishCommand', () => {
433
+ it('substitutes shell-quoted file paths', async () => {
434
+ const dir = makeWorkDir();
435
+ const file = writeFrameFile(dir, "it's here.png");
436
+ const result = await runPublishCommand('echo {files}', [file], dir);
437
+ expect(result.ran).toBe(true);
438
+ expect(result.exitCode).toBe(0);
439
+ expect(result.stdout).toBe(file);
440
+ });
441
+ it('does not run without a command or without OK frames', async () => {
442
+ const dir = makeWorkDir();
443
+ expect((await runPublishCommand(undefined, ['/tmp/a.png'], dir)).ran).toBe(false);
444
+ const skipped = await runPublishCommand('echo {files}', [], dir);
445
+ expect(skipped.ran).toBe(false);
446
+ expect(skipped.skippedReason).toContain('no frames validated OK');
447
+ });
448
+ it('does not hang on a command that reads stdin', async () => {
449
+ const dir = makeWorkDir();
450
+ const result = await runPublishCommand('cat; echo done', ['/tmp/a.png'], dir);
451
+ expect(result.ran).toBe(true);
452
+ expect(result.stdout).toBe('done');
453
+ });
454
+ it('reports a non-zero exit instead of throwing', async () => {
455
+ const dir = makeWorkDir();
456
+ const result = await runPublishCommand('echo boom >&2; exit 3', ['/tmp/a.png'], dir);
457
+ expect(result.exitCode).toBe(3);
458
+ expect(result.stderr).toContain('boom');
459
+ });
460
+ });
461
+ // ------------------------------------------------------------------ execute
462
+ describe('live-pass execute', () => {
463
+ it('rejects a working directory that is a root or does not exist', async () => {
464
+ const base = { targets: [HOME_TARGET], brief: 'b', driver: 'playwright' };
465
+ await expect(run({ ...base, workingDirectory: '/' })).rejects.toThrow(ERROR_MESSAGES.INVALID_WORKING_DIRECTORY);
466
+ await expect(run({ ...base, workingDirectory: '/no/such/dir/here' })).rejects.toThrow(ERROR_MESSAGES.INVALID_WORKING_DIRECTORY);
467
+ expect(mockExecuteAgyJson).not.toHaveBeenCalled();
468
+ });
469
+ it.each([
470
+ ['outside the working directory', (workDir) => path.join(path.dirname(workDir), 'elsewhere')],
471
+ ['equal to the working directory', (workDir) => workDir],
472
+ ['relative', () => '.live-pass/run1'],
473
+ ])('rejects an artifactDir that is %s', async (_label, makeDir) => {
474
+ const workDir = makeWorkDir();
475
+ await expect(run({
476
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright',
477
+ workingDirectory: workDir, artifactDir: makeDir(workDir),
478
+ })).rejects.toThrow(ERROR_MESSAGES.LIVE_PASS_ARTIFACT_DIR);
479
+ expect(mockExecuteAgyJson).not.toHaveBeenCalled();
480
+ });
481
+ it('accepts an artifactDir reached through a symlinked spelling of the worktree', async () => {
482
+ const workDir = makeWorkDir();
483
+ const link = path.join(makeWorkDir(), 'link');
484
+ fs.symlinkSync(workDir, link);
485
+ mockExecuteAgyJson.mockResolvedValue(envelope(makeResult()));
486
+ await run({
487
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright',
488
+ workingDirectory: link, artifactDir: path.join(link, '.live-pass', 'run1'),
489
+ });
490
+ const options = mockExecuteAgyJson.mock.calls[0][1];
491
+ expect(options.cwd).toBe(workDir);
492
+ expect(options.includeDirectories[1]).toBe(path.join(workDir, '.live-pass', 'run1'));
493
+ });
494
+ it('refuses the default artifact directory when .live-pass is a symlink out of the worktree', async () => {
495
+ const workDir = makeWorkDir();
496
+ const outside = makeWorkDir();
497
+ fs.symlinkSync(outside, path.join(workDir, LIVE_PASS.DEFAULT_ARTIFACT_SEGMENT));
498
+ await expect(run({
499
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir,
500
+ })).rejects.toThrow(ERROR_MESSAGES.LIVE_PASS_ARTIFACT_DIR);
501
+ expect(mockExecuteAgyJson).not.toHaveBeenCalled();
502
+ });
503
+ it('reports uninvited edits when the agy call itself fails', async () => {
504
+ const workDir = makeWorkDir();
505
+ mockSnapshotWorkingTree
506
+ .mockReturnValueOnce(cleanSnapshot(workDir))
507
+ .mockReturnValueOnce({
508
+ root: workDir,
509
+ lines: [' M src/app/index.tsx'],
510
+ hashes: new Map(),
511
+ truncated: false,
512
+ });
513
+ mockExecuteAgyJson.mockRejectedValue(new Error('Error: timed out waiting for response'));
514
+ await expect(run({
515
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir,
516
+ })).rejects.toThrow(/timed out[\s\S]*Working tree[\s\S]*src\/app\/index\.tsx/);
517
+ });
518
+ it('defaults the artifact directory to a timestamped folder under the worktree', async () => {
519
+ const workDir = makeWorkDir();
520
+ mockExecuteAgyJson.mockResolvedValue(envelope(makeResult()));
521
+ await run({ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir });
522
+ const options = mockExecuteAgyJson.mock.calls[0][1];
523
+ const artifactDir = options.includeDirectories[1];
524
+ expect(artifactDir.startsWith(path.join(workDir, LIVE_PASS.DEFAULT_ARTIFACT_SEGMENT))).toBe(true);
525
+ expect(fs.existsSync(artifactDir)).toBe(true);
526
+ });
527
+ it('forwards the agy options the pass depends on', async () => {
528
+ const workDir = makeWorkDir();
529
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
530
+ mockExecuteAgyJson.mockResolvedValue(envelope(makeResult()));
531
+ await run({
532
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright',
533
+ workingDirectory: workDir, artifactDir, conversationId: 'conv-abc',
534
+ });
535
+ const [prompt, options] = mockExecuteAgyJson.mock.calls[0];
536
+ expect(prompt).toContain('Delta reporting');
537
+ expect(options).toMatchObject({
538
+ cwd: workDir,
539
+ noCache: true,
540
+ conversationId: 'conv-abc',
541
+ yolo: true,
542
+ model: MODELS.MEDIUM,
543
+ printTimeout: LIVE_PASS.DEFAULT_PRINT_TIMEOUT,
544
+ });
545
+ expect(options.includeDirectories).toEqual([workDir, artifactDir]);
546
+ expect(JSON.parse(options.jsonSchema).properties).toHaveProperty('frames');
547
+ });
548
+ it('reports PASS with frames, PR markdown and no image data', async () => {
549
+ const workDir = makeWorkDir();
550
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
551
+ let file = '';
552
+ mockExecuteAgyJson.mockImplementation(async () => {
553
+ file = writeFrameFile(artifactDir, 'home-default-390x844.png');
554
+ writeManifest(artifactDir, [{ file }]);
555
+ return envelope(makeResult({ frames: [makeFrame({ file })] }));
556
+ });
557
+ const report = await run({
558
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright',
559
+ workingDirectory: workDir, artifactDir, publishCommand: 'echo "![home]({files})"',
560
+ });
561
+ expect(report).toContain('[GEMINI_CONVERSATION_ID=conv-abc]');
562
+ expect(report).toContain('# Live pass: PASS');
563
+ expect(report).toContain(file);
564
+ expect(report).toContain('## PR markdown');
565
+ expect(report).toContain('![home](');
566
+ expect(report).not.toMatch(/data:image|base64/);
567
+ expect(report).not.toContain('WARNING');
568
+ });
569
+ it('fails two targets answered by one screenshot, even when Gemini reports PASS', async () => {
570
+ const workDir = makeWorkDir();
571
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
572
+ const wide = { ...HOME_TARGET, viewport: '1280x800' };
573
+ mockExecuteAgyJson.mockImplementation(async () => {
574
+ const file = writeFrameFile(artifactDir, frameFileName(HOME_TARGET));
575
+ writeManifest(artifactDir, [{ file }, { file }]);
576
+ return envelope(makeResult({
577
+ frames: [makeFrame({ file }), makeFrame({ file, viewport: '1280x800' })],
578
+ }));
579
+ });
580
+ const report = await run({
581
+ targets: [HOME_TARGET, wide], brief: 'b', driver: 'playwright',
582
+ workingDirectory: workDir, artifactDir,
583
+ });
584
+ expect(report).toContain('# Live pass: FAIL (Gemini reported PASS)');
585
+ expect(report).toContain('reused evidence file');
586
+ });
587
+ it('passes the same two targets when each has its own screenshot', async () => {
588
+ const workDir = makeWorkDir();
589
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
590
+ const wide = { ...HOME_TARGET, viewport: '1280x800' };
591
+ mockExecuteAgyJson.mockImplementation(async () => {
592
+ const mobile = writeFrameFile(artifactDir, frameFileName(HOME_TARGET));
593
+ const desktop = writeFrameFile(artifactDir, frameFileName(wide));
594
+ writeManifest(artifactDir, [{ file: mobile }, { file: desktop }]);
595
+ return envelope(makeResult({
596
+ frames: [makeFrame({ file: mobile }), makeFrame({ file: desktop, viewport: '1280x800' })],
597
+ }));
598
+ });
599
+ const report = await run({
600
+ targets: [HOME_TARGET, wide], brief: 'b', driver: 'playwright',
601
+ workingDirectory: workDir, artifactDir,
602
+ });
603
+ expect(report).toContain('# Live pass: PASS');
604
+ expect(report).not.toContain('reused evidence file');
605
+ expect(report).not.toContain('MISNAMED');
606
+ });
607
+ it('derives FAIL from an uncovered target even when Gemini reports PASS', async () => {
608
+ const workDir = makeWorkDir();
609
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
610
+ mockExecuteAgyJson.mockImplementation(async () => {
611
+ const file = writeFrameFile(artifactDir, 'home-default-390x844.png');
612
+ writeManifest(artifactDir, [{ file }]);
613
+ return envelope(makeResult({ frames: [makeFrame({ file })] }));
614
+ });
615
+ const report = await run({
616
+ targets: [HOME_TARGET, { ...HOME_TARGET, screen: 'missing', state: 'not-found', expected: '404 text' }],
617
+ brief: 'b', driver: 'playwright', workingDirectory: workDir, artifactDir,
618
+ });
619
+ expect(report).toContain('# Live pass: FAIL (Gemini reported PASS)');
620
+ expect(report).toContain('uncovered target: missing / not-found');
621
+ });
622
+ it('passes a resumed re-pass whose only earlier defect is now fixed', async () => {
623
+ const workDir = makeWorkDir();
624
+ const artifactDir = path.join(workDir, '.live-pass', 'run2');
625
+ mockExecuteAgyJson.mockImplementation(async () => {
626
+ const file = writeFrameFile(artifactDir, 'home-default-390x844.png');
627
+ writeManifest(artifactDir, [{ file }]);
628
+ return envelope(makeResult({
629
+ frames: [makeFrame({ file })],
630
+ resolved: [{ screen: 'home', description: 'the clipped Retry label now wraps' }],
631
+ }));
632
+ });
633
+ const report = await run({
634
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright',
635
+ workingDirectory: workDir, artifactDir, conversationId: 'conv-abc',
636
+ });
637
+ expect(report).toContain('# Live pass: PASS');
638
+ expect(report).toContain('the clipped Retry label now wraps');
639
+ });
640
+ it('fails a resumed re-pass that reports a regression', async () => {
641
+ const workDir = makeWorkDir();
642
+ const artifactDir = path.join(workDir, '.live-pass', 'run2');
643
+ mockExecuteAgyJson.mockImplementation(async () => {
644
+ const file = writeFrameFile(artifactDir, 'home-default-390x844.png');
645
+ writeManifest(artifactDir, [{ file }]);
646
+ return envelope(makeResult({
647
+ frames: [makeFrame({ file })],
648
+ regressions: [{ severity: 'MAJOR', screen: 'home', description: 'Retry no longer reloads' }],
649
+ }));
650
+ });
651
+ const report = await run({
652
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright',
653
+ workingDirectory: workDir, artifactDir, conversationId: 'conv-abc',
654
+ });
655
+ expect(report).toContain('# Live pass: FAIL');
656
+ expect(report).toContain('Retry no longer reloads');
657
+ });
658
+ it('blocks and skips publishing when actions were denied', async () => {
659
+ const workDir = makeWorkDir();
660
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
661
+ const marker = path.join(workDir, 'published.txt');
662
+ mockExecuteAgyJson.mockResolvedValue(envelope(makeResult({ verdict: 'BLOCKED', frames: [] }), {
663
+ denied_actions: [{ action: 'command', display_name: 'RunCommand' }],
664
+ }));
665
+ const report = await run({
666
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright',
667
+ workingDirectory: workDir, artifactDir, publishCommand: `touch ${marker}`,
668
+ });
669
+ expect(report).toContain('# Live pass: BLOCKED');
670
+ expect(report).toContain('RunCommand (command)');
671
+ expect(fs.existsSync(marker)).toBe(false);
672
+ });
673
+ it('blocks when the answer does not match the result schema', async () => {
674
+ const workDir = makeWorkDir();
675
+ mockExecuteAgyJson.mockResolvedValue(envelope({ verdict: 'MAYBE' }));
676
+ const report = await run({
677
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir,
678
+ });
679
+ expect(report).toContain('# Live pass: BLOCKED');
680
+ expect(report).toContain('Schema issues');
681
+ expect(report).toContain('Raw answer (truncated)');
682
+ });
683
+ it('still reports uninvited edits when the answer is off-schema', async () => {
684
+ const workDir = makeWorkDir();
685
+ mockSnapshotWorkingTree
686
+ .mockReturnValueOnce(cleanSnapshot(workDir))
687
+ .mockReturnValueOnce({ root: workDir, lines: [' M src/app/index.tsx'], hashes: new Map(), truncated: false });
688
+ mockExecuteAgyJson.mockResolvedValue(envelope({ verdict: 'MAYBE' }));
689
+ const report = await run({
690
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir,
691
+ });
692
+ expect(report).toContain('# Live pass: BLOCKED');
693
+ expect(report).toContain('## Working tree');
694
+ expect(report).toContain('src/app/index.tsx');
695
+ });
696
+ it('warns when the working tree changed during the pass', async () => {
697
+ const workDir = makeWorkDir();
698
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
699
+ mockSnapshotWorkingTree
700
+ .mockReturnValueOnce(cleanSnapshot(workDir))
701
+ .mockReturnValueOnce({ root: workDir, lines: [' M src/app/index.tsx'], hashes: new Map(), truncated: false });
702
+ mockExecuteAgyJson.mockImplementation(async () => {
703
+ const file = writeFrameFile(artifactDir, 'home-default-390x844.png');
704
+ writeManifest(artifactDir, [{ file }]);
705
+ return envelope(makeResult({ frames: [makeFrame({ file })] }));
706
+ });
707
+ const report = await run({
708
+ targets: [HOME_TARGET], brief: 'b', driver: 'playwright',
709
+ workingDirectory: workDir, artifactDir,
710
+ });
711
+ expect(report).toContain('## Working tree');
712
+ expect(report).toContain('WARNING');
713
+ expect(report).toContain('src/app/index.tsx');
714
+ });
715
+ });
716
+ describe('livePassResultSchema', () => {
717
+ it('accepts a fresh pass with empty delta arrays', () => {
718
+ expect(livePassResultSchema.safeParse(makeResult()).success).toBe(true);
719
+ });
720
+ it('rejects a frame verdict outside PASS/FAIL', () => {
721
+ const result = makeResult({ frames: [makeFrame({ verdict: 'BLOCKED' })] });
722
+ expect(livePassResultSchema.safeParse(result).success).toBe(false);
723
+ });
724
+ });
725
+ //# sourceMappingURL=live-pass.test.js.map