@joeytheman/gemini-mcp-tool 2.2.0 → 2.3.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 (107) hide show
  1. package/README.md +10 -7
  2. package/dist/__tests__/agyModelSteps.test.d.ts +2 -0
  3. package/dist/__tests__/agyModelSteps.test.d.ts.map +1 -0
  4. package/dist/__tests__/agyModelSteps.test.js +58 -0
  5. package/dist/__tests__/agyModelSteps.test.js.map +1 -0
  6. package/dist/__tests__/capture-script.chromium.test.d.ts +2 -0
  7. package/dist/__tests__/capture-script.chromium.test.d.ts.map +1 -0
  8. package/dist/__tests__/capture-script.chromium.test.js +235 -0
  9. package/dist/__tests__/capture-script.chromium.test.js.map +1 -0
  10. package/dist/__tests__/layout-snippet.chromium.test.d.ts +2 -0
  11. package/dist/__tests__/layout-snippet.chromium.test.d.ts.map +1 -0
  12. package/dist/__tests__/layout-snippet.chromium.test.js +333 -0
  13. package/dist/__tests__/layout-snippet.chromium.test.js.map +1 -0
  14. package/dist/__tests__/layout-snippet.test.d.ts +2 -0
  15. package/dist/__tests__/layout-snippet.test.d.ts.map +1 -0
  16. package/dist/__tests__/layout-snippet.test.js +12 -0
  17. package/dist/__tests__/layout-snippet.test.js.map +1 -0
  18. package/dist/__tests__/live-pass.test.js +531 -7
  19. package/dist/__tests__/live-pass.test.js.map +1 -1
  20. package/dist/__tests__/screen-review.test.js +111 -2
  21. package/dist/__tests__/screen-review.test.js.map +1 -1
  22. package/dist/__tests__/server-progress.test.d.ts +2 -0
  23. package/dist/__tests__/server-progress.test.d.ts.map +1 -0
  24. package/dist/__tests__/server-progress.test.js +63 -0
  25. package/dist/__tests__/server-progress.test.js.map +1 -0
  26. package/dist/__tests__/server.test.js +1 -8
  27. package/dist/__tests__/server.test.js.map +1 -1
  28. package/dist/__tests__/simple-tools.test.js +8 -3
  29. package/dist/__tests__/simple-tools.test.js.map +1 -1
  30. package/dist/__tests__/tools.test.js +29 -1
  31. package/dist/__tests__/tools.test.js.map +1 -1
  32. package/dist/__tests__/utils/agyExecutor.test.js +176 -61
  33. package/dist/__tests__/utils/agyExecutor.test.js.map +1 -1
  34. package/dist/__tests__/utils/commandExecutor.test.js +155 -1
  35. package/dist/__tests__/utils/commandExecutor.test.js.map +1 -1
  36. package/dist/__tests__/utils/progressUpdates.test.d.ts +2 -0
  37. package/dist/__tests__/utils/progressUpdates.test.d.ts.map +1 -0
  38. package/dist/__tests__/utils/progressUpdates.test.js +62 -0
  39. package/dist/__tests__/utils/progressUpdates.test.js.map +1 -0
  40. package/dist/constants.d.ts +33 -10
  41. package/dist/constants.d.ts.map +1 -1
  42. package/dist/constants.js +42 -10
  43. package/dist/constants.js.map +1 -1
  44. package/dist/index.js +19 -94
  45. package/dist/index.js.map +1 -1
  46. package/dist/layoutCheckSnippet.generated.d.ts +2 -0
  47. package/dist/layoutCheckSnippet.generated.d.ts.map +1 -0
  48. package/dist/layoutCheckSnippet.generated.js +3 -0
  49. package/dist/layoutCheckSnippet.generated.js.map +1 -0
  50. package/dist/tools/ask-gemini.tool.d.ts.map +1 -1
  51. package/dist/tools/ask-gemini.tool.js +6 -5
  52. package/dist/tools/ask-gemini.tool.js.map +1 -1
  53. package/dist/tools/brainstorm.tool.d.ts.map +1 -1
  54. package/dist/tools/brainstorm.tool.js +2 -1
  55. package/dist/tools/brainstorm.tool.js.map +1 -1
  56. package/dist/tools/live-pass.tool.d.ts +24 -1
  57. package/dist/tools/live-pass.tool.d.ts.map +1 -1
  58. package/dist/tools/live-pass.tool.js +174 -52
  59. package/dist/tools/live-pass.tool.js.map +1 -1
  60. package/dist/tools/registry.d.ts +2 -2
  61. package/dist/tools/registry.d.ts.map +1 -1
  62. package/dist/tools/registry.js +2 -2
  63. package/dist/tools/registry.js.map +1 -1
  64. package/dist/tools/screen-review.tool.d.ts +3 -0
  65. package/dist/tools/screen-review.tool.d.ts.map +1 -1
  66. package/dist/tools/screen-review.tool.js +42 -8
  67. package/dist/tools/screen-review.tool.js.map +1 -1
  68. package/dist/tools/simple-tools.d.ts.map +1 -1
  69. package/dist/tools/simple-tools.js +6 -2
  70. package/dist/tools/simple-tools.js.map +1 -1
  71. package/dist/utils/agyExecutor.d.ts +9 -1
  72. package/dist/utils/agyExecutor.d.ts.map +1 -1
  73. package/dist/utils/agyExecutor.js +54 -48
  74. package/dist/utils/agyExecutor.js.map +1 -1
  75. package/dist/utils/agyModelSteps.d.ts +5 -0
  76. package/dist/utils/agyModelSteps.d.ts.map +1 -0
  77. package/dist/utils/agyModelSteps.js +33 -0
  78. package/dist/utils/agyModelSteps.js.map +1 -0
  79. package/dist/utils/artifactFile.d.ts +6 -0
  80. package/dist/utils/artifactFile.d.ts.map +1 -0
  81. package/dist/utils/artifactFile.js +55 -0
  82. package/dist/utils/artifactFile.js.map +1 -0
  83. package/dist/utils/captureScript.d.ts +24 -0
  84. package/dist/utils/captureScript.d.ts.map +1 -0
  85. package/dist/utils/captureScript.js +83 -0
  86. package/dist/utils/captureScript.js.map +1 -0
  87. package/dist/utils/commandExecutor.d.ts +7 -1
  88. package/dist/utils/commandExecutor.d.ts.map +1 -1
  89. package/dist/utils/commandExecutor.js +154 -22
  90. package/dist/utils/commandExecutor.js.map +1 -1
  91. package/dist/utils/gitCheckout.d.ts +14 -0
  92. package/dist/utils/gitCheckout.d.ts.map +1 -0
  93. package/dist/utils/gitCheckout.js +36 -0
  94. package/dist/utils/gitCheckout.js.map +1 -0
  95. package/dist/utils/layoutCheck.d.ts +58 -0
  96. package/dist/utils/layoutCheck.d.ts.map +1 -0
  97. package/dist/utils/layoutCheck.js +84 -0
  98. package/dist/utils/layoutCheck.js.map +1 -0
  99. package/dist/utils/logger.d.ts +0 -1
  100. package/dist/utils/logger.d.ts.map +1 -1
  101. package/dist/utils/logger.js +0 -6
  102. package/dist/utils/logger.js.map +1 -1
  103. package/dist/utils/progressUpdates.d.ts +14 -0
  104. package/dist/utils/progressUpdates.d.ts.map +1 -0
  105. package/dist/utils/progressUpdates.js +40 -0
  106. package/dist/utils/progressUpdates.js.map +1 -0
  107. package/package.json +13 -11
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
+ import { execFileSync } from 'child_process';
4
5
  vi.mock('../utils/logger.js', () => ({
5
6
  Logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn(), toolInvocation: vi.fn() },
6
7
  }));
@@ -16,15 +17,24 @@ const cleanSnapshot = (root = '/repo') => ({
16
17
  truncated: false,
17
18
  });
18
19
  const mockSnapshotWorkingTree = vi.fn().mockReturnValue(cleanSnapshot());
20
+ const mockCountAgyModelSteps = vi.fn(async (_conversationId) => null);
21
+ vi.mock('../utils/agyModelSteps.js', async (importOriginal) => {
22
+ const actual = await importOriginal();
23
+ return { ...actual, countAgyModelSteps: (id) => mockCountAgyModelSteps(id) };
24
+ });
19
25
  vi.mock('../utils/gitStatusGuard.js', async (importOriginal) => {
20
26
  const actual = await importOriginal();
21
27
  return { ...actual, snapshotWorkingTree: (...args) => mockSnapshotWorkingTree(...args) };
22
28
  });
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';
29
+ import { livePassTool, livePassArgsSchema, livePassResultSchema, LIVE_PASS_RESULT_JSON_SCHEMA, buildLivePassPrompt, frameFileName, prescribedFrameNames, prescribedTargetFiles, formatLayoutSection, readLayoutChecks, validateFrames, checkCoverage, deriveVerdict, runPublishCommand, writeCaptureScripts, } from '../tools/live-pass.tool.js';
30
+ import { ERROR_MESSAGES, LAYOUT_CHECK_SNIPPET, LIVE_PASS, MODELS } from '../constants.js';
31
+ import { readLayoutCheck } from '../utils/layoutCheck.js';
32
+ import { buildCaptureScript, SCROLL_OVERFLOW_SNIPPET } from '../utils/captureScript.js';
25
33
  import { makeTempDir, cleanupTempDirs } from './utils/test-helpers.js';
26
34
  // ------------------------------------------------------------------ helpers
27
35
  const makeWorkDir = () => makeTempDir('live-pass');
36
+ const PNG = fs.readFileSync(new URL('./fixtures/images/pixel.png', import.meta.url));
37
+ const JPEG = fs.readFileSync(new URL('./fixtures/images/pixel.jpg', import.meta.url));
28
38
  const HOME_TARGET = {
29
39
  screen: 'home',
30
40
  state: 'default',
@@ -59,15 +69,37 @@ function makeResult(overrides = {}) {
59
69
  }
60
70
  const CLEAN_VALIDATION = { checks: [], okFiles: [], problems: [], notes: [], unreportedCaptures: [] };
61
71
  const CLEAN_COVERAGE = { uncovered: [], skipped: [], extraFrames: [] };
62
- function writeFrameFile(artifactDir, name) {
72
+ function writeFrameFile(artifactDir, name, contents) {
63
73
  fs.mkdirSync(artifactDir, { recursive: true });
64
74
  const file = path.join(artifactDir, name);
65
- fs.writeFileSync(file, 'PNG');
75
+ fs.writeFileSync(file, contents ?? (/\.png$/i.test(name) ? PNG : /\.jpe?g$/i.test(name) ? JPEG : 'not an image'));
66
76
  return file;
67
77
  }
68
78
  function writeManifest(artifactDir, frames) {
69
79
  fs.writeFileSync(path.join(artifactDir, LIVE_PASS.MANIFEST_FILE), JSON.stringify({ run_started: new Date().toISOString(), driver: 'playwright', frames, skipped: [] }));
70
80
  }
81
+ function makeLayoutReport(overrides = {}) {
82
+ return {
83
+ check: LIVE_PASS.LAYOUT_CHECK_ID,
84
+ viewport: { width: 390, height: 844 },
85
+ found: 0,
86
+ entries: [],
87
+ clamped: [],
88
+ ...overrides,
89
+ };
90
+ }
91
+ const SPILL_ENTRY = {
92
+ kind: 'text-spill',
93
+ text: 'Uploaded to the studio wins wall',
94
+ testID: 'label',
95
+ rect: { x: 0, y: 0, width: 320, height: 20 },
96
+ };
97
+ function writeLayoutFile(artifactDir, frameName, contents) {
98
+ fs.mkdirSync(artifactDir, { recursive: true });
99
+ const file = path.join(artifactDir, frameName.replace(/\.png$/, LIVE_PASS.LAYOUT_FILE_SUFFIX));
100
+ fs.writeFileSync(file, typeof contents === 'string' ? contents : JSON.stringify(contents, null, 2));
101
+ return file;
102
+ }
71
103
  function envelope(structured, overrides = {}) {
72
104
  return {
73
105
  conversation_id: 'conv-abc',
@@ -97,6 +129,10 @@ describe('livePassArgsSchema', () => {
97
129
  workingDirectory: '/tmp/project',
98
130
  driver: 'playwright',
99
131
  };
132
+ it('asks the caller\'s brief for the start URL and any link a user arrives by, with its source', () => {
133
+ expect(livePassArgsSchema.shape.brief.description).toContain("the app's start URL or app id, any link a user arrives by (an invite, email or share link) with where it comes from");
134
+ expect(livePassArgsSchema.shape.brief.description).not.toMatch(/entry url/i);
135
+ });
100
136
  it('applies the documented defaults', () => {
101
137
  const parsed = livePassArgsSchema.parse(minimal);
102
138
  expect(parsed.yolo).toBe(true);
@@ -122,6 +158,17 @@ describe('livePassArgsSchema', () => {
122
158
  });
123
159
  expect(parsed.targets).toHaveLength(2);
124
160
  });
161
+ it('rejects a playwright target whose viewport is not WxH, and lets maestro name a form factor', () => {
162
+ const phone = { ...HOME_TARGET, viewport: 'iPhone 15' };
163
+ const web = livePassArgsSchema.safeParse({ ...minimal, targets: [HOME_TARGET, phone] });
164
+ const native = livePassArgsSchema.safeParse({ ...minimal, driver: 'maestro', targets: [phone] });
165
+ expect(web.success).toBe(false);
166
+ expect(web.error?.issues).toEqual([expect.objectContaining({
167
+ path: ['targets', 1, 'viewport'],
168
+ message: `${ERROR_MESSAGES.LIVE_PASS_WEB_VIEWPORT}. Received 'iPhone 15'.`,
169
+ })]);
170
+ expect(native.success).toBe(true);
171
+ });
125
172
  });
126
173
  describe('LIVE_PASS_RESULT_JSON_SCHEMA', () => {
127
174
  it('has no $schema key and requires every result section', () => {
@@ -135,6 +182,11 @@ describe('LIVE_PASS_RESULT_JSON_SCHEMA', () => {
135
182
  const frames = LIVE_PASS_RESULT_JSON_SCHEMA.properties.frames;
136
183
  expect(frames.items.required).toEqual(expect.arrayContaining(['file', 'expected', 'observed', 'verdict']));
137
184
  });
185
+ it('offers link_source on a frame without requiring it', () => {
186
+ const frames = LIVE_PASS_RESULT_JSON_SCHEMA.properties.frames;
187
+ expect(frames.items.properties.link_source.type).toBe('string');
188
+ expect(frames.items.required).not.toContain('link_source');
189
+ });
138
190
  });
139
191
  // ------------------------------------------------------------------- prompt
140
192
  describe('frameFileName', () => {
@@ -162,10 +214,11 @@ describe('frameFileName', () => {
162
214
  });
163
215
  describe('buildLivePassPrompt', () => {
164
216
  const base = {
165
- brief: 'Base URL http://localhost:8123; marker: footer BRANCH: smoke',
217
+ brief: 'Start URL http://localhost:8123; marker: footer BRANCH: feat/orders',
166
218
  targets: [HOME_TARGET, { ...HOME_TARGET, state: 'offline', expected: 'offline notice plus Retry' }],
167
219
  workingDirectory: '/work/tree',
168
220
  artifactDir: '/work/tree/.live-pass/run1',
221
+ checkout: { branch: 'feat/orders', head: '0123456789abcdef0123456789abcdef01234567' },
169
222
  };
170
223
  it('renders the targets as a numbered list with their expected state', () => {
171
224
  const prompt = buildLivePassPrompt({ ...base, driver: 'playwright', resumed: false });
@@ -176,9 +229,15 @@ describe('buildLivePassPrompt', () => {
176
229
  });
177
230
  it('carries the playwright procedure and the evidence discipline', () => {
178
231
  const prompt = buildLivePassPrompt({ ...base, driver: 'playwright', resumed: false });
179
- expect(prompt).toContain('browser_take_screenshot');
232
+ expect(prompt).toContain('Capture it: browser_run_code_unsafe with filename set to the exact absolute path printed as "capture:" for that target below. Never pass code, never write, edit or read a capture file');
180
233
  expect(prompt).toContain('/work/tree/.live-pass/run1');
181
- expect(prompt).toContain('never use fullPage');
234
+ expect(prompt).toContain('If a capture returns an error, add the target to "skipped" with the error text. Never fall back to browser_take_screenshot.');
235
+ expect(prompt).toContain('The capture sets the target\'s viewport and grows a scrolling screen taller, a few times at most and up to 4000 px, until its scroll area fits; a screen that still scrolls after that is shot as it stands.');
236
+ expect(prompt).toContain('an added "; layout not measured: <error>" or "; viewport not restored: <error>" still saved the frame.');
237
+ expect(prompt).not.toContain('full height');
238
+ expect(prompt).not.toContain('browser_resize');
239
+ expect(prompt).not.toContain('fullPage');
240
+ expect(prompt).not.toContain('320x568');
182
241
  expect(prompt).toContain('answer is ONLY the schema JSON');
183
242
  expect(prompt).toContain('a spinner, blank page, skeleton, error, or the wrong screen is a FAIL');
184
243
  expect(prompt).toContain('PASSES when it shows that state with everything "expected" names');
@@ -205,6 +264,7 @@ describe('buildLivePassPrompt', () => {
205
264
  expect(prompt).toContain('xcrun simctl io');
206
265
  expect(prompt).toContain('Never use take_screenshot for evidence');
207
266
  expect(prompt).not.toContain('browser_take_screenshot');
267
+ expect(prompt).not.toContain('browser_run_code_unsafe');
208
268
  });
209
269
  it('adds the delta paragraph only for a resumed pass', () => {
210
270
  const fresh = buildLivePassPrompt({ ...base, driver: 'playwright', resumed: false });
@@ -215,6 +275,296 @@ describe('buildLivePassPrompt', () => {
215
275
  expect(resumed).toContain('Never put an improvement in "findings" or "regressions"');
216
276
  });
217
277
  });
278
+ describe('buildLivePassPrompt: context Gemini used to dig for', () => {
279
+ const base = {
280
+ brief: 'Start URL http://localhost:8123; marker: footer BRANCH: feat/orders',
281
+ targets: [HOME_TARGET, { ...HOME_TARGET, state: 'offline', expected: 'offline notice plus Retry' }],
282
+ workingDirectory: '/work/tree',
283
+ artifactDir: '/work/tree/.live-pass/run1',
284
+ checkout: { branch: 'feat/orders', head: '0123456789abcdef0123456789abcdef01234567' },
285
+ resumed: false,
286
+ };
287
+ it('prints the branch and HEAD read in code, and forbids running git to find them', () => {
288
+ const prompt = buildLivePassPrompt({ ...base, driver: 'playwright' });
289
+ expect(prompt).toContain('- Checkout under test: feat/orders at 0123456789abcdef0123456789abcdef01234567. Read for you, so never run git.');
290
+ expect(prompt).toContain('The checkout above is context. Set BLOCKED over it only when the marker names a branch or commit that contradicts it. A detached HEAD never contradicts a branch name, and a short commit hash matches the full one it begins.');
291
+ });
292
+ it('names a detached HEAD instead of a branch', () => {
293
+ const prompt = buildLivePassPrompt({ ...base, driver: 'playwright', checkout: { branch: null, head: 'abc1234' } });
294
+ expect(prompt).toContain('- Checkout under test: a detached HEAD at abc1234.');
295
+ });
296
+ it('falls back to the brief marker when the working directory is not a git checkout', () => {
297
+ const prompt = buildLivePassPrompt({ ...base, driver: 'playwright', checkout: null });
298
+ expect(prompt).toContain("- Checkout under test: not a git checkout with a commit, so rely on the brief's marker alone.");
299
+ });
300
+ it('carries a parameter cheat sheet for every playwright tool the procedure uses', () => {
301
+ const prompt = buildLivePassPrompt({ ...base, driver: 'playwright' });
302
+ expect(prompt).toContain('## Tool cheat sheet (the installed parameters: never read tool schemas or Playwright source)');
303
+ for (const line of [
304
+ '- browser_navigate {url}',
305
+ '- browser_click {target, element?, doubleClick?, button?, modifiers?}',
306
+ '- browser_type {target, text, submit?, slowly?}',
307
+ '- browser_fill_form {fields: [{target, name, type: textbox | checkbox | radio | combobox | slider, value}]}',
308
+ '- browser_select_option {target, values: [...]}',
309
+ '- browser_press_key {key}',
310
+ '- browser_handle_dialog {accept, promptText?}',
311
+ '- browser_wait_for {text? | textGone? | time?}',
312
+ '- browser_find {text | regex}',
313
+ '- browser_snapshot {target?, depth?, boxes?}',
314
+ '- browser_run_code_unsafe {filename}: filename is absolute.',
315
+ '- browser_evaluate {function, filename}: the return value is written to filename as JSON instead of returned to you.',
316
+ ]) {
317
+ expect(prompt).toContain(line);
318
+ }
319
+ expect(prompt).not.toContain('- browser_take_screenshot');
320
+ expect(prompt).toContain('a unique selector such as [data-testid="save"]');
321
+ expect(prompt).not.toContain('- inspect_screen {device_id}');
322
+ });
323
+ it('carries the maestro tool cheat sheet for the maestro driver instead', () => {
324
+ const prompt = buildLivePassPrompt({ ...base, driver: 'maestro' });
325
+ for (const line of [
326
+ '- list_devices {}',
327
+ '- run {device_id, yaml, env?}',
328
+ '- inspect_screen {device_id}',
329
+ '- take_screenshot {device_id}: returns an image inline; never evidence.',
330
+ '- cheat_sheet {}',
331
+ ]) {
332
+ expect(prompt).toContain(line);
333
+ }
334
+ expect(prompt).not.toContain('- browser_click {');
335
+ });
336
+ it('tells a web pass to answer native dialogs right after the triggering click', () => {
337
+ const prompt = buildLivePassPrompt({ ...base, driver: 'playwright' });
338
+ expect(prompt).toContain('Native dialogs such as window.confirm and window.alert block the page: accept or dismiss each with browser_handle_dialog right after the click that opened it.');
339
+ });
340
+ it.each(['playwright', 'maestro'])('forbids code, database and git reads for the %s driver', (driver) => {
341
+ const prompt = buildLivePassPrompt({ ...base, driver });
342
+ expect(prompt).toContain('Never read product source, tests, docs, config, tool schemas or tool source. Never query a database.');
343
+ expect(prompt).toContain("Never run a shell command except the capture command the procedure names and removing a stale file at a target's own \"file:\" path.");
344
+ expect(prompt).toContain('Never read code to work out why something failed. Report what you saw.');
345
+ expect(prompt).toContain('never run a git command.');
346
+ expect(prompt).toContain('It is only where you run: read, list and glob nothing outside the artifact directory.');
347
+ expect(prompt).not.toContain('Working directory (read-only)');
348
+ });
349
+ it.each(['playwright', 'maestro'])('opens the %s app once and reaches every other target by taps from the previous screen', (driver) => {
350
+ const prompt = buildLivePassPrompt({ ...base, driver });
351
+ expect(prompt).toContain('## User path\n- The pass opens the app once, at its start URL (or by launching it on a device), as the brief names it.');
352
+ expect(prompt).toContain('- Every other target is reached by taps from the previous screen: sign in through the sign-in form, tap through the navigation, and fill and submit forms. Any other URL or deep link in the brief is context, never a navigation target.');
353
+ expect(prompt).toContain('- A reload, only where the brief makes one part of the path, opens the URL the page is already on.');
354
+ expect(prompt).toContain('- No script that clicks, navigates, or sets storage, cookies or app state for you.');
355
+ });
356
+ it.each(['playwright', 'maestro'])('lets a %s target begin at its own link only when the brief names the link the user arrives by, and has the frame quote its source', (driver) => {
357
+ const prompt = buildLivePassPrompt({ ...base, driver });
358
+ expect(prompt).toContain('- A target may begin at a link of its own only when the brief says the user arrives by that link, such as an invite, email or share link, and names where the link comes from. Open it as following that link would, and quote the brief\'s words naming the source in that frame\'s "link_source".');
359
+ });
360
+ it.each(['playwright', 'maestro'])('leaves no per-target entry URL or URL jump anywhere in the %s prompt', (driver) => {
361
+ const prompt = buildLivePassPrompt({ ...base, driver });
362
+ expect(prompt).not.toMatch(/entry url/i);
363
+ expect(prompt).not.toContain('deeper URL');
364
+ expect(prompt).not.toContain('walk on from wherever');
365
+ expect(prompt).not.toContain('Never use openLink to skip a user step.');
366
+ });
367
+ it('sends a web pass\'s browser_navigate only where the user path allows, and keeps browser_evaluate and run_code to the printed save functions and capture files', () => {
368
+ const prompt = buildLivePassPrompt({ ...base, driver: 'playwright' });
369
+ expect(prompt).not.toContain("browser_navigate to the target's route");
370
+ expect(prompt).toContain('browser_navigate opens only the URLs the "User path" section allows.\nFor each target, in order:\n- Reach it as the "User path" section says.');
371
+ expect(prompt).toContain('browser_evaluate runs only the "save layout:" functions and browser_run_code_unsafe only the "capture:" files. Neither is used to click, type, navigate, or set storage, cookies or app state.');
372
+ });
373
+ it('has a maestro pass launch the app only at the start and openLink only for a link arrival', () => {
374
+ const prompt = buildLivePassPrompt({ ...base, driver: 'maestro' });
375
+ expect(prompt).toContain('- Reach each state as the "User path" section says, with inline YAML flows: launchApp only to open the app at the start, then tapOn and inputText; openLink only for a link arrival that section allows.');
376
+ });
377
+ it('locates web controls by testID, then browser_find, then a scoped snapshot', () => {
378
+ const prompt = buildLivePassPrompt({ ...base, driver: 'playwright' });
379
+ expect(prompt).toContain('Locate each control cheaply, in this order: a testID the brief names, as target [data-testid="<testID>"]; then browser_find with its visible label; then browser_snapshot with a target and a small depth scoped to that region. Never take a full-page snapshot just to look around');
380
+ });
381
+ it.each(['playwright', 'maestro'])('reports a %s dead end with the broken step, and a missing fact as skipped', (driver) => {
382
+ const prompt = buildLivePassPrompt({ ...base, driver });
383
+ expect(prompt).toContain('add it to "skipped" with the step where the path broke, such as "step 3: tapped Save, nothing happened". A dead end a user would hit is also a finding.');
384
+ expect(prompt).toContain('add the target to "skipped" with "brief missing: <the fact>". Never search the code for it.');
385
+ });
386
+ it('prints each web target its own capture file, layout file and save function, and neither page snippet', () => {
387
+ const prompt = buildLivePassPrompt({ ...base, driver: 'playwright' });
388
+ expect(prompt).toContain([
389
+ ' file: /work/tree/.live-pass/run1/home-default-390x844.png',
390
+ ' capture: /work/tree/.live-pass/run1/home-default-390x844.capture.js',
391
+ ' layout: /work/tree/.live-pass/run1/home-default-390x844.layout.json',
392
+ ' save layout: () => window.__livePassLayout?.["home-default-390x844.png"]',
393
+ ].join('\n'));
394
+ expect(prompt).toContain([
395
+ ' file: /work/tree/.live-pass/run1/home-offline-390x844.png',
396
+ ' capture: /work/tree/.live-pass/run1/home-offline-390x844.capture.js',
397
+ ' layout: /work/tree/.live-pass/run1/home-offline-390x844.layout.json',
398
+ ' save layout: () => window.__livePassLayout?.["home-offline-390x844.png"]',
399
+ ].join('\n'));
400
+ expect(prompt).toContain('browser_evaluate with function set to the "save layout:" function printed for that target, copied verbatim, and filename set to the exact absolute path printed as "layout:" for that target. Do not open that file or report on it');
401
+ expect(prompt).not.toContain(LAYOUT_CHECK_SNIPPET);
402
+ expect(prompt).not.toContain(SCROLL_OVERFLOW_SNIPPET);
403
+ expect(prompt).not.toContain('getBoundingClientRect');
404
+ });
405
+ it('asks a maestro pass for no capture file or layout check', () => {
406
+ const prompt = buildLivePassPrompt({ ...base, driver: 'maestro' });
407
+ expect(prompt).not.toContain('capture:');
408
+ expect(prompt).not.toContain('layout:');
409
+ expect(prompt).not.toContain(LAYOUT_CHECK_SNIPPET);
410
+ expect(prompt).not.toContain(SCROLL_OVERFLOW_SNIPPET);
411
+ });
412
+ });
413
+ describe('prescribedTargetFiles', () => {
414
+ it('puts each capture and layout file next to its frame, sharing the frame stem and its collision suffix', () => {
415
+ const targets = [
416
+ { ...HOME_TARGET, screen: 'settings/profile' },
417
+ { ...HOME_TARGET, screen: 'settings-profile' },
418
+ ];
419
+ const files = prescribedTargetFiles(targets, '/art');
420
+ expect([...files.values()]).toEqual([
421
+ {
422
+ frameName: 'settings-profile-default-390x844.png',
423
+ frame: '/art/settings-profile-default-390x844.png',
424
+ capture: '/art/settings-profile-default-390x844.capture.js',
425
+ layout: '/art/settings-profile-default-390x844.layout.json',
426
+ },
427
+ {
428
+ frameName: 'settings-profile-default-390x844-2.png',
429
+ frame: '/art/settings-profile-default-390x844-2.png',
430
+ capture: '/art/settings-profile-default-390x844-2.capture.js',
431
+ layout: '/art/settings-profile-default-390x844-2.layout.json',
432
+ },
433
+ ]);
434
+ });
435
+ });
436
+ // ------------------------------------------------------------ layout checks
437
+ describe('readLayoutCheck', () => {
438
+ const frameName = frameFileName(HOME_TARGET);
439
+ it('accepts a fresh, schema-valid file inside the artifact directory', () => {
440
+ const artifactDir = path.join(makeWorkDir(), 'frames');
441
+ const report = makeLayoutReport({ found: 1, entries: [SPILL_ENTRY] });
442
+ const file = writeLayoutFile(artifactDir, frameName, report);
443
+ const check = readLayoutCheck(file, artifactDir, Date.now());
444
+ expect(check).toEqual({ file, status: 'OK', report });
445
+ });
446
+ it('reports a file that was never written as MISSING', () => {
447
+ const artifactDir = path.join(makeWorkDir(), 'frames');
448
+ fs.mkdirSync(artifactDir, { recursive: true });
449
+ const file = path.join(artifactDir, 'home-default-390x844.layout.json');
450
+ expect(readLayoutCheck(file, artifactDir, Date.now())).toEqual({ file, status: 'MISSING', problem: 'not written' });
451
+ });
452
+ it('reports a file left over from an earlier run as STALE', () => {
453
+ const artifactDir = path.join(makeWorkDir(), 'frames');
454
+ const file = writeLayoutFile(artifactDir, frameName, makeLayoutReport());
455
+ const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
456
+ fs.utimesSync(file, yesterday, yesterday);
457
+ expect(readLayoutCheck(file, artifactDir, Date.now())).toEqual({ file, status: 'STALE', problem: 'predates this run' });
458
+ });
459
+ it('accepts a file written within the freshness tolerance before the run clock', () => {
460
+ const artifactDir = path.join(makeWorkDir(), 'frames');
461
+ const file = writeLayoutFile(artifactDir, frameName, makeLayoutReport());
462
+ const runStartMs = fs.statSync(file).mtimeMs + LIVE_PASS.FRESHNESS_TOLERANCE_MS - 1;
463
+ expect(readLayoutCheck(file, artifactDir, runStartMs).status).toBe('OK');
464
+ });
465
+ it('reports a prescribed path symlinked out of the artifact directory as OUTSIDE', () => {
466
+ const workDir = makeWorkDir();
467
+ const artifactDir = path.join(workDir, 'frames');
468
+ fs.mkdirSync(artifactDir, { recursive: true });
469
+ const outside = writeLayoutFile(makeWorkDir(), frameName, makeLayoutReport());
470
+ const prescribed = path.join(artifactDir, path.basename(outside));
471
+ fs.symlinkSync(outside, prescribed);
472
+ expect(readLayoutCheck(prescribed, artifactDir, Date.now())).toEqual({
473
+ file: outside,
474
+ status: 'OUTSIDE',
475
+ problem: 'resolves outside the artifact directory',
476
+ });
477
+ });
478
+ it('reports an empty file as MALFORMED', () => {
479
+ const artifactDir = path.join(makeWorkDir(), 'frames');
480
+ const file = writeLayoutFile(artifactDir, frameName, '');
481
+ expect(readLayoutCheck(file, artifactDir, Date.now())).toEqual({ file, status: 'MALFORMED', problem: 'empty' });
482
+ });
483
+ it('reports a result measured at another width than the frame as MISMATCHED', () => {
484
+ const artifactDir = path.join(makeWorkDir(), 'frames');
485
+ const file = writeLayoutFile(artifactDir, frameName, makeLayoutReport({ viewport: { width: 1280, height: 800 } }));
486
+ expect(readLayoutCheck(file, artifactDir, Date.now(), 390)).toEqual({
487
+ file,
488
+ status: 'MISMATCHED',
489
+ problem: 'measured at width 1280, but the frame is 390 wide',
490
+ });
491
+ });
492
+ it('accepts a result measured at the frame width whatever the height, since a scrolling frame is resized taller', () => {
493
+ const artifactDir = path.join(makeWorkDir(), 'frames');
494
+ const report = makeLayoutReport({ viewport: { width: 390, height: 2210 } });
495
+ const file = writeLayoutFile(artifactDir, frameName, report);
496
+ expect(readLayoutCheck(file, artifactDir, Date.now(), 390)).toEqual({ file, status: 'OK', report });
497
+ });
498
+ it('reports a file that is not JSON as MALFORMED', () => {
499
+ const artifactDir = path.join(makeWorkDir(), 'frames');
500
+ const file = writeLayoutFile(artifactDir, frameName, 'undefined');
501
+ expect(readLayoutCheck(file, artifactDir, Date.now())).toEqual({ file, status: 'MALFORMED', problem: 'not valid JSON' });
502
+ });
503
+ it.each([
504
+ ['another check id', { ...makeLayoutReport(), check: 'layout-check/0' }, 'check'],
505
+ ['an unknown kind', makeLayoutReport({ found: 1, entries: [{ ...SPILL_ENTRY, kind: 'ugly' }] }), 'entries.0.kind'],
506
+ ['text longer than 80 characters', makeLayoutReport({ found: 1, entries: [{ ...SPILL_ENTRY, text: 'x'.repeat(81) }] }), 'entries.0.text'],
507
+ ['more than 50 entries', makeLayoutReport({ found: 51, entries: Array(51).fill(SPILL_ENTRY) }), 'entries'],
508
+ ['a defect kind in the clamped list', makeLayoutReport({ clamped: [SPILL_ENTRY] }), 'clamped.0.kind'],
509
+ ])('reports a result with %s as MALFORMED', (_label, contents, issuePath) => {
510
+ const artifactDir = path.join(makeWorkDir(), 'frames');
511
+ const file = writeLayoutFile(artifactDir, frameName, contents);
512
+ const check = readLayoutCheck(file, artifactDir, Date.now());
513
+ expect(check.status).toBe('MALFORMED');
514
+ expect(check.report).toBeUndefined();
515
+ expect(check.problem).toContain(`not a ${LIVE_PASS.LAYOUT_CHECK_ID} result (${issuePath}:`);
516
+ });
517
+ });
518
+ describe('formatLayoutSection', () => {
519
+ it('lists each frame\'s entries, its intentional caps apart, a clean frame, and a frame never run', () => {
520
+ const artifactDir = path.join(makeWorkDir(), 'frames');
521
+ const offline = { ...HOME_TARGET, state: 'offline' };
522
+ const empty = { ...HOME_TARGET, state: 'empty' };
523
+ writeLayoutFile(artifactDir, frameFileName(HOME_TARGET), makeLayoutReport({
524
+ found: 1,
525
+ entries: [SPILL_ENTRY],
526
+ clamped: [{ kind: 'text-clamped', text: 'Costume checklist', testID: null, rect: { x: 0, y: 40, width: 200, height: 40 } }],
527
+ }));
528
+ writeLayoutFile(artifactDir, frameFileName(offline), makeLayoutReport());
529
+ const section = formatLayoutSection(readLayoutChecks([HOME_TARGET, offline, empty], artifactDir, Date.now()));
530
+ expect(section).toBe([
531
+ '## Layout checks (measured in the page)',
532
+ '- home / default / 390x844: 1 entry',
533
+ ' - text-spill "Uploaded to the studio wins wall" testID=label at 0,0 320x20',
534
+ ' - capped (line clamp / ellipsis): text-clamped "Costume checklist" at 0,40 200x40',
535
+ '- home / offline / 390x844: clean',
536
+ `- home / empty / 390x844: not run (MISSING: ${path.join(artifactDir, 'home-empty-390x844.layout.json')} not written)`,
537
+ ].join('\n'));
538
+ });
539
+ it('shows a frame measured at the wrong width as not run, with both widths', () => {
540
+ const artifactDir = path.join(makeWorkDir(), 'frames');
541
+ const file = writeLayoutFile(artifactDir, frameFileName(HOME_TARGET), makeLayoutReport({
542
+ viewport: { width: 1280, height: 800 }, found: 1, entries: [SPILL_ENTRY],
543
+ }));
544
+ const section = formatLayoutSection(readLayoutChecks([HOME_TARGET], artifactDir, Date.now()));
545
+ expect(section).toBe([
546
+ '## Layout checks (measured in the page)',
547
+ `- home / default / 390x844: not run (MISMATCHED: ${file} measured at width 1280, but the frame is 390 wide)`,
548
+ ].join('\n'));
549
+ });
550
+ it('marks a frame a framework dev overlay covered as polluted', () => {
551
+ const artifactDir = path.join(makeWorkDir(), 'frames');
552
+ const overlay = { kind: 'dev-overlay', text: 'Uncaught Error', testID: null, rect: { x: 0, y: 0, width: 390, height: 844 } };
553
+ writeLayoutFile(artifactDir, frameFileName(HOME_TARGET), makeLayoutReport({ found: 1, entries: [overlay] }));
554
+ const section = formatLayoutSection(readLayoutChecks([HOME_TARGET], artifactDir, Date.now()));
555
+ expect(section).toBe([
556
+ '## Layout checks (measured in the page)',
557
+ '- home / default / 390x844: 1 entry (a framework dev overlay was on screen: this frame is polluted)',
558
+ ' - dev-overlay "Uncaught Error" at 0,0 390x844',
559
+ ].join('\n'));
560
+ });
561
+ it('says when the snippet found more entries than it listed', () => {
562
+ const artifactDir = path.join(makeWorkDir(), 'frames');
563
+ writeLayoutFile(artifactDir, frameFileName(HOME_TARGET), makeLayoutReport({ found: 73, entries: [SPILL_ENTRY] }));
564
+ const section = formatLayoutSection(readLayoutChecks([HOME_TARGET], artifactDir, Date.now()));
565
+ expect(section).toContain('- home / default / 390x844: 73 entries (73 found, first 1 listed)');
566
+ });
567
+ });
218
568
  // --------------------------------------------------------------- validation
219
569
  describe('validateFrames', () => {
220
570
  it('accepts a fresh, non-empty frame inside the artifact directory', () => {
@@ -226,6 +576,14 @@ describe('validateFrames', () => {
226
576
  expect(validation.okFiles).toEqual([file]);
227
577
  expect(validation.problems).toEqual([]);
228
578
  });
579
+ it.each(['jpg', 'jpeg'])('accepts a real .%s frame', extension => {
580
+ const artifactDir = path.join(makeWorkDir(), 'frames');
581
+ const file = writeFrameFile(artifactDir, `home-default.${extension}`);
582
+ writeManifest(artifactDir, [{ file }]);
583
+ const validation = validateFrames(makeResult({ frames: [makeFrame({ file })] }), artifactDir, Date.now());
584
+ expect(validation.checks[0].status).toBe('OK');
585
+ expect(validation.okFiles).toEqual([file]);
586
+ });
229
587
  it.each([
230
588
  ['MISSING', (dir) => path.join(dir, 'never-written.png')],
231
589
  ['EMPTY', (dir) => {
@@ -270,6 +628,32 @@ describe('validateFrames', () => {
270
628
  expect(validation.notes.join('\n')).toContain('MISNAMED');
271
629
  expect(validation.notes.join('\n')).toContain(frameFileName(HOME_TARGET));
272
630
  });
631
+ it.each([
632
+ ['a capture file', 'home-default-390x844.capture.js'],
633
+ ['a layout file', 'home-default-390x844.layout.json'],
634
+ ['text with a PNG extension', 'home-default-390x844.png'],
635
+ ['text with a JPEG extension', 'home-default-390x844.jpg'],
636
+ ])('rejects %s reported as a frame as NOT_IMAGE, and fails the pass', (_label, name) => {
637
+ const artifactDir = path.join(makeWorkDir(), 'frames');
638
+ const file = writeFrameFile(artifactDir, name, 'not an image');
639
+ writeManifest(artifactDir, [{ file }]);
640
+ const result = makeResult({ frames: [makeFrame({ file })] });
641
+ const validation = validateFrames(result, artifactDir, Date.now(), [HOME_TARGET]);
642
+ const decision = deriveVerdict({ result, validation, coverage: CLEAN_COVERAGE, deniedActions: '' });
643
+ expect(validation.checks[0].status).toBe('NOT_IMAGE');
644
+ expect(validation.okFiles).toEqual([]);
645
+ expect(validation.problems).toEqual([`NOT_IMAGE: ${file} (home / default)`]);
646
+ expect(decision.verdict).toBe('FAIL');
647
+ expect(decision.reasons).toEqual([`NOT_IMAGE: ${file} (home / default)`]);
648
+ });
649
+ it('rejects JPEG bytes renamed to .png', () => {
650
+ const artifactDir = path.join(makeWorkDir(), 'frames');
651
+ const file = writeFrameFile(artifactDir, 'home-default-390x844.png', JPEG);
652
+ writeManifest(artifactDir, [{ file }]);
653
+ const validation = validateFrames(makeResult({ frames: [makeFrame({ file })] }), artifactDir, Date.now());
654
+ expect(validation.checks[0].status).toBe('NOT_IMAGE');
655
+ expect(validation.okFiles).toEqual([]);
656
+ });
273
657
  it('treats a directory reported as a frame as MISSING', () => {
274
658
  const artifactDir = path.join(makeWorkDir(), 'frames');
275
659
  const file = path.join(artifactDir, 'home-default.png');
@@ -713,10 +1097,150 @@ describe('live-pass execute', () => {
713
1097
  expect(report).toContain('src/app/index.tsx');
714
1098
  });
715
1099
  });
1100
+ describe('live-pass execute: checkout, turns and layout checks', () => {
1101
+ function passWithOneFrame(artifactDir, layout) {
1102
+ mockExecuteAgyJson.mockImplementation(async () => {
1103
+ const file = writeFrameFile(artifactDir, frameFileName(HOME_TARGET));
1104
+ if (layout !== undefined)
1105
+ writeLayoutFile(artifactDir, frameFileName(HOME_TARGET), layout);
1106
+ writeManifest(artifactDir, [{ file }]);
1107
+ return envelope(makeResult({ frames: [makeFrame({ file })] }), { num_turns: 9 });
1108
+ });
1109
+ }
1110
+ it('reads the branch and HEAD of a git working directory into the prompt', async () => {
1111
+ const workDir = makeWorkDir();
1112
+ const git = (...args) => execFileSync('git', args, { cwd: workDir, encoding: 'utf-8' }).trim();
1113
+ git('init', '--quiet', '--initial-branch', 'feat/served');
1114
+ git('-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '--quiet', '--allow-empty', '-m', 'seed');
1115
+ const head = git('rev-parse', 'HEAD');
1116
+ mockExecuteAgyJson.mockResolvedValue(envelope(makeResult()));
1117
+ await run({ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir });
1118
+ expect(mockExecuteAgyJson.mock.calls[0][0]).toContain(`- Checkout under test: feat/served at ${head}. Read for you, so never run git.`);
1119
+ });
1120
+ it('runs on a working directory that is not a git checkout', async () => {
1121
+ const workDir = makeWorkDir();
1122
+ mockExecuteAgyJson.mockResolvedValue(envelope(makeResult()));
1123
+ await run({ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir });
1124
+ expect(mockExecuteAgyJson.mock.calls[0][0]).toContain("- Checkout under test: not a git checkout with a commit, so rely on the brief's marker alone.");
1125
+ });
1126
+ it("prints Gemini's model steps per frame, read from agy's store for this conversation, under the usage line", async () => {
1127
+ const workDir = makeWorkDir();
1128
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
1129
+ passWithOneFrame(artifactDir);
1130
+ mockCountAgyModelSteps.mockResolvedValueOnce(18);
1131
+ const report = await run({ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir, artifactDir });
1132
+ expect(mockCountAgyModelSteps).toHaveBeenLastCalledWith('conv-abc');
1133
+ expect(report).toMatch(/\nUsage: 9 turns [^\n]*\n\nGemini steps: 18 \(18 per frame, 1 frame\)\n/);
1134
+ });
1135
+ it('prints the quoted link source under a frame that began at a link the user arrives by', async () => {
1136
+ const workDir = makeWorkDir();
1137
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
1138
+ mockExecuteAgyJson.mockImplementation(async () => {
1139
+ const file = writeFrameFile(artifactDir, frameFileName(HOME_TARGET));
1140
+ writeManifest(artifactDir, [{ file }]);
1141
+ return envelope(makeResult({ frames: [makeFrame({ file, link_source: 'the invite email the studio owner sends' })] }));
1142
+ });
1143
+ const report = await run({ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir, artifactDir });
1144
+ expect(report).toContain(` file: ${path.join(artifactDir, frameFileName(HOME_TARGET))}\n arrived by link: the invite email the studio owner sends\n expected: heading 'Hello'`);
1145
+ });
1146
+ it('prints no link line under a frame reached by taps', async () => {
1147
+ const workDir = makeWorkDir();
1148
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
1149
+ passWithOneFrame(artifactDir);
1150
+ const report = await run({ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir, artifactDir });
1151
+ expect(report).toContain(` file: ${path.join(artifactDir, frameFileName(HOME_TARGET))}\n expected: heading 'Hello'`);
1152
+ expect(report).not.toContain('arrived by link');
1153
+ });
1154
+ it('reports layout defects for the caller to judge without changing a PASS', async () => {
1155
+ const workDir = makeWorkDir();
1156
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
1157
+ passWithOneFrame(artifactDir, makeLayoutReport({ found: 1, entries: [SPILL_ENTRY] }));
1158
+ const report = await run({ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir, artifactDir });
1159
+ expect(report).toContain('# Live pass: PASS\n');
1160
+ expect(report).toContain('## Why\n- none');
1161
+ expect(report).toContain('## Layout checks (measured in the page)\n- home / default / 390x844: 1 entry\n - text-spill "Uploaded to the studio wins wall" testID=label at 0,0 320x20');
1162
+ });
1163
+ it.each([
1164
+ ['missing', undefined, 'MISSING'],
1165
+ ['malformed', '{"check":', 'MALFORMED'],
1166
+ ])('shows a %s layout file as not run, and still passes', async (_label, layout, status) => {
1167
+ const workDir = makeWorkDir();
1168
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
1169
+ passWithOneFrame(artifactDir, layout);
1170
+ const report = await run({ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir, artifactDir });
1171
+ expect(report).toContain('# Live pass: PASS\n');
1172
+ expect(report).toContain(`- home / default / 390x844: not run (${status}: `);
1173
+ expect(report).not.toContain('layout.json (home');
1174
+ });
1175
+ it('never feeds layout entries into the derived verdict', async () => {
1176
+ const workDir = makeWorkDir();
1177
+ const clean = path.join(workDir, '.live-pass', 'clean');
1178
+ const defective = path.join(workDir, '.live-pass', 'defective');
1179
+ passWithOneFrame(clean, makeLayoutReport());
1180
+ const cleanReport = await run({ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir, artifactDir: clean });
1181
+ passWithOneFrame(defective, makeLayoutReport({ found: 50, entries: Array(50).fill(SPILL_ENTRY) }));
1182
+ const defectiveReport = await run({ targets: [HOME_TARGET], brief: 'b', driver: 'playwright', workingDirectory: workDir, artifactDir: defective });
1183
+ expect(defectiveReport).toContain('- home / default / 390x844: 50 entries');
1184
+ expect(cleanReport).toContain('- home / default / 390x844: clean');
1185
+ const verdictAndWhy = (report) => report.slice(report.indexOf('# Live pass'), report.indexOf('## Served-branch check'));
1186
+ expect(verdictAndWhy(defectiveReport).replace(defective, clean)).toBe(verdictAndWhy(cleanReport));
1187
+ });
1188
+ it('writes one capture script per web target at its prescribed path before agy runs', async () => {
1189
+ const workDir = makeWorkDir();
1190
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
1191
+ const wide = { ...HOME_TARGET, viewport: '1280x800' };
1192
+ let writtenBeforeAgy = [];
1193
+ mockExecuteAgyJson.mockImplementation(async () => {
1194
+ writtenBeforeAgy = fs.readdirSync(artifactDir).sort();
1195
+ return envelope(makeResult());
1196
+ });
1197
+ await run({ targets: [HOME_TARGET, wide], brief: 'b', driver: 'playwright', workingDirectory: workDir, artifactDir });
1198
+ expect(writtenBeforeAgy).toEqual(['home-default-1280x800.capture.js', 'home-default-390x844.capture.js']);
1199
+ for (const [stem, viewport] of [['home-default-390x844', { width: 390, height: 844 }], ['home-default-1280x800', { width: 1280, height: 800 }]]) {
1200
+ expect(fs.readFileSync(path.join(artifactDir, `${stem}.capture.js`), 'utf-8')).toBe(buildCaptureScript({
1201
+ viewport, frameFile: path.join(artifactDir, `${stem}.png`), key: `${stem}.png`,
1202
+ }));
1203
+ }
1204
+ });
1205
+ it('replaces a symlink at a capture path with a regular file, leaving the link\'s target untouched', () => {
1206
+ const artifactDir = makeWorkDir();
1207
+ const outside = path.join(makeWorkDir(), 'victim.txt');
1208
+ fs.writeFileSync(outside, 'keep me');
1209
+ const capture = path.join(artifactDir, 'home-default-390x844.capture.js');
1210
+ fs.symlinkSync(outside, capture);
1211
+ writeCaptureScripts([HOME_TARGET], artifactDir);
1212
+ expect(fs.readFileSync(outside, 'utf-8')).toBe('keep me');
1213
+ expect(fs.lstatSync(capture).isFile()).toBe(true);
1214
+ });
1215
+ it('writes no capture script for a maestro pass', async () => {
1216
+ const workDir = makeWorkDir();
1217
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
1218
+ mockExecuteAgyJson.mockResolvedValue(envelope(makeResult()));
1219
+ await run({ targets: [HOME_TARGET], brief: 'b', driver: 'maestro', workingDirectory: workDir, artifactDir });
1220
+ expect(fs.readdirSync(artifactDir)).toEqual([]);
1221
+ });
1222
+ it('refuses to write a capture script for a viewport that is not WxH', () => {
1223
+ const artifactDir = makeWorkDir();
1224
+ expect(() => writeCaptureScripts([{ ...HOME_TARGET, viewport: 'iPhone 15' }], artifactDir))
1225
+ .toThrow(`${ERROR_MESSAGES.LIVE_PASS_WEB_VIEWPORT}. Received 'iPhone 15'.`);
1226
+ expect(fs.readdirSync(artifactDir)).toEqual([]);
1227
+ });
1228
+ it('adds no layout section to a maestro pass', async () => {
1229
+ const workDir = makeWorkDir();
1230
+ const artifactDir = path.join(workDir, '.live-pass', 'run1');
1231
+ passWithOneFrame(artifactDir);
1232
+ const report = await run({ targets: [HOME_TARGET], brief: 'b', driver: 'maestro', workingDirectory: workDir, artifactDir });
1233
+ expect(report).not.toContain('Layout checks');
1234
+ });
1235
+ });
716
1236
  describe('livePassResultSchema', () => {
717
1237
  it('accepts a fresh pass with empty delta arrays', () => {
718
1238
  expect(livePassResultSchema.safeParse(makeResult()).success).toBe(true);
719
1239
  });
1240
+ it('accepts a frame with a link_source and one without', () => {
1241
+ const result = makeResult({ frames: [makeFrame(), makeFrame({ file: '/tmp/invite.png', link_source: 'the share link a teammate texts' })] });
1242
+ expect(livePassResultSchema.safeParse(result).success).toBe(true);
1243
+ });
720
1244
  it('rejects a frame verdict outside PASS/FAIL', () => {
721
1245
  const result = makeResult({ frames: [makeFrame({ verdict: 'BLOCKED' })] });
722
1246
  expect(livePassResultSchema.safeParse(result).success).toBe(false);