@yasserkhanorg/e2e-agents 0.3.2 → 0.3.3

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 (74) hide show
  1. package/README.md +22 -18
  2. package/dist/agent/config.d.ts +1 -0
  3. package/dist/agent/config.d.ts.map +1 -1
  4. package/dist/agent/config.js +10 -0
  5. package/dist/agent/pipeline.d.ts +6 -1
  6. package/dist/agent/pipeline.d.ts.map +1 -1
  7. package/dist/agent/pipeline.js +627 -27
  8. package/dist/agent/report.d.ts +5 -0
  9. package/dist/agent/report.d.ts.map +1 -1
  10. package/dist/agent/report.js +3 -0
  11. package/dist/agent/runner.d.ts.map +1 -1
  12. package/dist/agent/runner.js +25 -6
  13. package/dist/agent/tests.d.ts.map +1 -1
  14. package/dist/agent/tests.js +12 -2
  15. package/dist/cli.js +73 -5
  16. package/dist/esm/agent/config.js +10 -0
  17. package/dist/esm/agent/pipeline.js +627 -27
  18. package/dist/esm/agent/report.js +3 -0
  19. package/dist/esm/agent/runner.js +25 -6
  20. package/dist/esm/agent/tests.js +12 -2
  21. package/dist/esm/cli.js +73 -5
  22. package/package.json +1 -1
  23. package/dist/agent/cache_utils.d.ts +0 -38
  24. package/dist/agent/cache_utils.d.ts.map +0 -1
  25. package/dist/agent/cache_utils.js +0 -67
  26. package/dist/agent/impact-analyzer.d.ts +0 -114
  27. package/dist/agent/impact-analyzer.d.ts.map +0 -1
  28. package/dist/agent/impact-analyzer.js +0 -557
  29. package/dist/agent/index.d.ts +0 -21
  30. package/dist/agent/index.d.ts.map +0 -1
  31. package/dist/agent/index.js +0 -38
  32. package/dist/agent/model-router.d.ts +0 -57
  33. package/dist/agent/model-router.d.ts.map +0 -1
  34. package/dist/agent/model-router.js +0 -154
  35. package/dist/agent/report-generator.d.ts +0 -24
  36. package/dist/agent/report-generator.d.ts.map +0 -1
  37. package/dist/agent/report-generator.js +0 -250
  38. package/dist/agent/spec-bridge.d.ts +0 -101
  39. package/dist/agent/spec-bridge.d.ts.map +0 -1
  40. package/dist/agent/spec-bridge.js +0 -273
  41. package/dist/agent/spec-builder.d.ts +0 -102
  42. package/dist/agent/spec-builder.d.ts.map +0 -1
  43. package/dist/agent/spec-builder.js +0 -273
  44. package/dist/agent/telemetry.d.ts +0 -84
  45. package/dist/agent/telemetry.d.ts.map +0 -1
  46. package/dist/agent/telemetry.js +0 -220
  47. package/dist/agent/validators/selector-validator.d.ts +0 -74
  48. package/dist/agent/validators/selector-validator.d.ts.map +0 -1
  49. package/dist/agent/validators/selector-validator.js +0 -165
  50. package/dist/e2e-test-gen/index.d.ts +0 -51
  51. package/dist/e2e-test-gen/index.d.ts.map +0 -1
  52. package/dist/e2e-test-gen/index.js +0 -57
  53. package/dist/e2e-test-gen/spec_parser.d.ts +0 -142
  54. package/dist/e2e-test-gen/spec_parser.d.ts.map +0 -1
  55. package/dist/e2e-test-gen/spec_parser.js +0 -786
  56. package/dist/e2e-test-gen/types.d.ts +0 -185
  57. package/dist/e2e-test-gen/types.d.ts.map +0 -1
  58. package/dist/e2e-test-gen/types.js +0 -4
  59. package/dist/esm/agent/cache_utils.js +0 -63
  60. package/dist/esm/agent/impact-analyzer.js +0 -548
  61. package/dist/esm/agent/index.js +0 -22
  62. package/dist/esm/agent/model-router.js +0 -150
  63. package/dist/esm/agent/report-generator.js +0 -247
  64. package/dist/esm/agent/spec-bridge.js +0 -267
  65. package/dist/esm/agent/spec-builder.js +0 -267
  66. package/dist/esm/agent/telemetry.js +0 -216
  67. package/dist/esm/agent/validators/selector-validator.js +0 -160
  68. package/dist/esm/e2e-test-gen/index.js +0 -50
  69. package/dist/esm/e2e-test-gen/spec_parser.js +0 -782
  70. package/dist/esm/e2e-test-gen/types.js +0 -3
  71. package/dist/esm/plan-and-test-constants.js +0 -126
  72. package/dist/plan-and-test-constants.d.ts +0 -110
  73. package/dist/plan-and-test-constants.d.ts.map +0 -1
  74. package/dist/plan-and-test-constants.js +0 -132
@@ -4,6 +4,13 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
4
4
  import { basename, dirname, join, relative, resolve } from 'path';
5
5
  import { spawnSync } from 'child_process';
6
6
  import { baseNameWithoutExt, isPathWithinRoot, normalizePath, titleCase, tokenize, uniqueTokens } from './utils.js';
7
+ function createMcpStatus(backend, requested) {
8
+ return {
9
+ requested,
10
+ active: requested && (backend === 'e2e-test-gen' || backend === 'playwright-agents'),
11
+ backend,
12
+ };
13
+ }
7
14
  function hasE2eTestGenCLI(testsRoot) {
8
15
  const cliPath = join(testsRoot, 'e2e-test-gen-cli.ts');
9
16
  return existsSync(cliPath) ? cliPath : null;
@@ -40,6 +47,7 @@ function firstFlowFiles(flow) {
40
47
  return (flow.files || []).filter(Boolean).slice(0, 5);
41
48
  }
42
49
  function buildNativeStrategyOrder(flow) {
50
+ const flowId = (flow.id || '').toLowerCase();
43
51
  const haystack = [
44
52
  flow.id,
45
53
  flow.name,
@@ -48,22 +56,183 @@ function buildNativeStrategyOrder(flow) {
48
56
  ...(flow.keywords || []),
49
57
  ].join(' ').toLowerCase();
50
58
  const strategies = [];
59
+ if (flowId.includes('search')) {
60
+ strategies.push('search-baseline');
61
+ }
62
+ if (flowId.includes('threads') || flowId.includes('thread')) {
63
+ strategies.push('thread-reply');
64
+ }
65
+ if (flowId.includes('channels.lifecycle')) {
66
+ strategies.push('lifecycle-channel');
67
+ }
68
+ if (flowId.includes('channels.settings')) {
69
+ strategies.push('channel-settings');
70
+ }
71
+ if (flowId.includes('channels.switch')) {
72
+ strategies.push('channel-switch');
73
+ }
74
+ if (flowId.includes('messaging.markdown')) {
75
+ strategies.push('markdown-post');
76
+ }
77
+ if (flowId.includes('messaging.mentions')) {
78
+ strategies.push('mentions-post');
79
+ }
80
+ if (flowId.includes('messaging.realtime')) {
81
+ strategies.push('realtime-post');
82
+ }
51
83
  if (/(thread|reply|rhs|sidebar[_-]?right)/.test(haystack)) {
52
84
  strategies.push('thread-reply');
53
85
  }
86
+ if (/(create|join|leave|invite)/.test(haystack)) {
87
+ strategies.push('lifecycle-channel');
88
+ }
89
+ if (/(settings|preferences)/.test(haystack)) {
90
+ strategies.push('channel-settings');
91
+ }
92
+ if (/(switch|quick\\s*switch)/.test(haystack)) {
93
+ strategies.push('channel-switch');
94
+ }
95
+ if (/(markdown|format)/.test(haystack)) {
96
+ strategies.push('markdown-post');
97
+ }
98
+ if (/(mention|@)/.test(haystack)) {
99
+ strategies.push('mentions-post');
100
+ }
101
+ if (/(realtime|websocket|presence)/.test(haystack)) {
102
+ strategies.push('realtime-post');
103
+ }
104
+ if (/(search|find|spotlight)/.test(haystack)) {
105
+ strategies.push('search-baseline');
106
+ }
54
107
  if (/(message|post|realtime|websocket|chat)/.test(haystack)) {
55
108
  strategies.push('message-post');
56
109
  }
57
110
  if (/(channel|navigation|sidebar|switch)/.test(haystack)) {
58
111
  strategies.push('channel-baseline');
59
112
  }
60
- if (/(search|find|spotlight)/.test(haystack)) {
61
- strategies.push('search-baseline');
62
- }
63
113
  strategies.push('generic-baseline');
64
114
  return Array.from(new Set(strategies));
65
115
  }
66
- function validateGeneratedSpecContent(content) {
116
+ function createDefaultApiSurfaceCatalog() {
117
+ return {
118
+ pwProps: new Set([
119
+ 'initSetup',
120
+ 'testBrowser',
121
+ 'apiInitSetup',
122
+ 'apiAdminSetup',
123
+ 'apiCreateChannel',
124
+ 'apiCreateUser',
125
+ 'apiLogin',
126
+ ]),
127
+ testBrowserMethods: new Set([
128
+ 'login',
129
+ 'openNewBrowserContext',
130
+ 'newContext',
131
+ ]),
132
+ channelsPageMembers: new Set([
133
+ 'goto',
134
+ 'page',
135
+ 'postMessage',
136
+ 'getLastPost',
137
+ 'sidebarRight',
138
+ 'openChannelSettings',
139
+ 'newChannel',
140
+ 'globalHeader',
141
+ 'searchBox',
142
+ ]),
143
+ sidebarRightMembers: new Set([
144
+ 'openThreadForPost',
145
+ 'postMessage',
146
+ 'getLastPost',
147
+ ]),
148
+ };
149
+ }
150
+ function collectMatches(content, pattern) {
151
+ const out = new Set();
152
+ for (const match of content.matchAll(pattern)) {
153
+ const value = match[1];
154
+ if (value) {
155
+ out.add(value);
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+ function collectApiSurfaceFromContent(content, catalog) {
161
+ for (const prop of collectMatches(content, /\bpw\.([A-Za-z_][A-Za-z0-9_]*)\b/g)) {
162
+ catalog.pwProps.add(prop);
163
+ }
164
+ for (const method of collectMatches(content, /\bpw\.testBrowser\.([A-Za-z_][A-Za-z0-9_]*)\b/g)) {
165
+ catalog.testBrowserMethods.add(method);
166
+ }
167
+ for (const member of collectMatches(content, /\bchannelsPage\.([A-Za-z_][A-Za-z0-9_]*)\b/g)) {
168
+ catalog.channelsPageMembers.add(member);
169
+ }
170
+ for (const member of collectMatches(content, /\bchannelsPage\.sidebarRight\.([A-Za-z_][A-Za-z0-9_]*)\b/g)) {
171
+ catalog.sidebarRightMembers.add(member);
172
+ }
173
+ }
174
+ function buildApiSurfaceCatalog(testsRoot, seedFile) {
175
+ const catalog = createDefaultApiSurfaceCatalog();
176
+ const candidateRoots = [
177
+ join(testsRoot, 'specs'),
178
+ join(testsRoot, 'tests'),
179
+ ];
180
+ const files = [];
181
+ for (const root of candidateRoots) {
182
+ if (!existsSync(root)) {
183
+ continue;
184
+ }
185
+ const stack = [root];
186
+ while (stack.length > 0) {
187
+ const current = stack.pop();
188
+ let entries;
189
+ try {
190
+ entries = readdirSync(current, { withFileTypes: true });
191
+ }
192
+ catch {
193
+ continue;
194
+ }
195
+ for (const entry of entries) {
196
+ const full = join(current, entry.name);
197
+ if (entry.isDirectory()) {
198
+ if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist') {
199
+ continue;
200
+ }
201
+ stack.push(full);
202
+ continue;
203
+ }
204
+ if (!entry.isFile()) {
205
+ continue;
206
+ }
207
+ if (!/\.(spec|test)\.[jt]sx?$/.test(entry.name)) {
208
+ continue;
209
+ }
210
+ files.push(full);
211
+ }
212
+ }
213
+ }
214
+ const uniqueFiles = Array.from(new Set(files)).slice(0, 2500);
215
+ for (const filePath of uniqueFiles) {
216
+ try {
217
+ const content = readFileSync(filePath, 'utf-8');
218
+ collectApiSurfaceFromContent(content, catalog);
219
+ }
220
+ catch {
221
+ continue;
222
+ }
223
+ }
224
+ const absoluteSeed = join(testsRoot, seedFile);
225
+ if (existsSync(absoluteSeed)) {
226
+ try {
227
+ collectApiSurfaceFromContent(readFileSync(absoluteSeed, 'utf-8'), catalog);
228
+ }
229
+ catch {
230
+ // ignore seed read failures; defaults + catalog scan still apply
231
+ }
232
+ }
233
+ return catalog;
234
+ }
235
+ function validateGeneratedSpecContent(content, apiSurface) {
67
236
  const issues = [];
68
237
  if (/\btest\.describe\s*\(/.test(content)) {
69
238
  issues.push({
@@ -89,13 +258,32 @@ function validateGeneratedSpecContent(content) {
89
258
  message: 'Generated tests must use a single tag string, not a tag array.',
90
259
  });
91
260
  }
92
- const hasTagString = /\btag\s*:\s*['"][^'"]+['"]/.test(content);
93
- if (!hasTagString || !/@ai-assisted/.test(content)) {
261
+ const hasTagOption = /\btag\s*:\s*['"][^'"]+['"]/.test(content);
262
+ const hasTagInTitle = /\btest(?:\.\w+)?\s*\(\s*['"][^'"]*@ai-assisted[^'"]*['"]/.test(content);
263
+ if (!(hasTagOption || hasTagInTitle) || !/@ai-assisted/.test(content)) {
94
264
  issues.push({
95
265
  code: 'missing-tag',
96
- message: "Generated tests must include a single '@ai-assisted' tag.",
266
+ message: "Generated tests must include '@ai-assisted' either as tag option or in test title.",
97
267
  });
98
268
  }
269
+ if (apiSurface) {
270
+ const unknownPwProps = Array.from(collectMatches(content, /\bpw\.([A-Za-z_][A-Za-z0-9_]*)\b/g)).filter((prop) => !apiSurface.pwProps.has(prop));
271
+ const unknownBrowserMethods = Array.from(collectMatches(content, /\bpw\.testBrowser\.([A-Za-z_][A-Za-z0-9_]*)\b/g)).filter((method) => !apiSurface.testBrowserMethods.has(method));
272
+ const unknownChannelMembers = Array.from(collectMatches(content, /\bchannelsPage\.([A-Za-z_][A-Za-z0-9_]*)\b/g)).filter((member) => !apiSurface.channelsPageMembers.has(member));
273
+ const unknownSidebarMembers = Array.from(collectMatches(content, /\bchannelsPage\.sidebarRight\.([A-Za-z_][A-Za-z0-9_]*)\b/g)).filter((member) => !apiSurface.sidebarRightMembers.has(member));
274
+ const unknown = [
275
+ ...unknownPwProps.map((value) => `pw.${value}`),
276
+ ...unknownBrowserMethods.map((value) => `pw.testBrowser.${value}`),
277
+ ...unknownChannelMembers.map((value) => `channelsPage.${value}`),
278
+ ...unknownSidebarMembers.map((value) => `channelsPage.sidebarRight.${value}`),
279
+ ];
280
+ if (unknown.length > 0) {
281
+ issues.push({
282
+ code: 'unknown-api-surface',
283
+ message: `Generated test uses unknown API/page-object members: ${Array.from(new Set(unknown)).join(', ')}`,
284
+ });
285
+ }
286
+ }
99
287
  return issues;
100
288
  }
101
289
  function createNativePlaywrightSpec(flow, slug, strategy) {
@@ -126,10 +314,76 @@ function createNativePlaywrightSpec(flow, slug, strategy) {
126
314
  ...start,
127
315
  ` const parentMessage = \`ai-${slug}-parent-\${Date.now()}\`;`,
128
316
  ' await channelsPage.postMessage(parentMessage);',
129
- ' await channelsPage.openAThread(parentMessage);',
317
+ ' const rootPost = await channelsPage.getLastPost();',
318
+ ' await rootPost.openAThread();',
130
319
  ` const replyMessage = \`ai-${slug}-reply-\${Date.now()}\`;`,
131
320
  ' await channelsPage.sidebarRight.postMessage(replyMessage);',
132
- ' await expect(channelsPage.sidebarRight.getLastPost()).toContainText(replyMessage);',
321
+ ' const lastReply = await channelsPage.sidebarRight.getLastPost();',
322
+ ' await expect(lastReply.container).toContainText(replyMessage);',
323
+ ...end,
324
+ ].join('\n');
325
+ }
326
+ if (strategy === 'lifecycle-channel') {
327
+ return [
328
+ ...header,
329
+ ...start,
330
+ ` const channelName = \`ai-${slug}-\${Date.now().toString().slice(-6)}\`;`,
331
+ " await channelsPage.newChannel(channelName, 'O');",
332
+ ' await expect(channelsPage.page).toHaveURL(new RegExp(`/channels/${channelName}$`));',
333
+ ...end,
334
+ ].join('\n');
335
+ }
336
+ if (strategy === 'channel-settings') {
337
+ return [
338
+ ...header,
339
+ ...start,
340
+ ' await channelsPage.openChannelSettings();',
341
+ " await expect(channelsPage.page.getByRole('dialog', {name: 'Channel Settings'})).toBeVisible();",
342
+ " await channelsPage.page.keyboard.press('Escape');",
343
+ ...end,
344
+ ].join('\n');
345
+ }
346
+ if (strategy === 'channel-switch') {
347
+ return [
348
+ ...header,
349
+ ...start,
350
+ " await channelsPage.goto(team.name, 'off-topic');",
351
+ " await expect(channelsPage.page).toHaveURL(/\\/channels\\/off-topic$/);",
352
+ " await expect(channelsPage.page.locator('#channelHeaderTitle')).toContainText(/off-topic/i);",
353
+ ...end,
354
+ ].join('\n');
355
+ }
356
+ if (strategy === 'markdown-post') {
357
+ return [
358
+ ...header,
359
+ ...start,
360
+ ` const message = '**ai-${slug}-bold** _italic_';`,
361
+ ' await channelsPage.postMessage(message);',
362
+ ' const lastPost = await channelsPage.getLastPost();',
363
+ " await expect(lastPost.container.locator('strong')).toBeVisible();",
364
+ ...end,
365
+ ].join('\n');
366
+ }
367
+ if (strategy === 'mentions-post') {
368
+ return [
369
+ ...header,
370
+ ...start,
371
+ ' const mention = `@${user.username}`;',
372
+ ' await channelsPage.postMessage(`Ping ${mention}`);',
373
+ ' const lastPost = await channelsPage.getLastPost();',
374
+ ' await expect(lastPost.container).toContainText(mention);',
375
+ ...end,
376
+ ].join('\n');
377
+ }
378
+ if (strategy === 'realtime-post') {
379
+ return [
380
+ ...header,
381
+ ...start,
382
+ ` const message = \`ai-${slug}-realtime-\${Date.now()}\`;`,
383
+ ' await channelsPage.postMessage(message);',
384
+ ' const lastPost = await channelsPage.getLastPost();',
385
+ ' await expect(lastPost.container).toContainText(message);',
386
+ " await expect(channelsPage.page.locator('#channel_view')).toBeVisible();",
133
387
  ...end,
134
388
  ].join('\n');
135
389
  }
@@ -156,11 +410,12 @@ function createNativePlaywrightSpec(flow, slug, strategy) {
156
410
  return [
157
411
  ...header,
158
412
  ...start,
159
- ` const searchTerm = '${slug}'.slice(0, 20);`,
160
- " await channelsPage.page.keyboard.press('ControlOrMeta+K');",
161
- ' await channelsPage.page.keyboard.type(searchTerm);',
162
- " await channelsPage.page.keyboard.press('Escape');",
163
- ' await expect(channelsPage.page).toHaveURL(/\\/channels\\//);',
413
+ ` const searchTerm = \`ai-${slug}-\${Date.now().toString().slice(-6)}\`;`,
414
+ ' await channelsPage.postMessage(searchTerm);',
415
+ ' await channelsPage.globalHeader.openSearch();',
416
+ ' await channelsPage.searchBox.searchInput.fill(searchTerm);',
417
+ " await channelsPage.page.keyboard.press('Enter');",
418
+ " await expect(channelsPage.page.locator('#searchContainer')).toBeVisible();",
164
419
  ...end,
165
420
  ].join('\n');
166
421
  }
@@ -239,7 +494,7 @@ function runPlaywrightListValidation(testsRoot, testFile, pipeline, playwrightBi
239
494
  detail: summary || commandResult.error || `playwright --list failed with status ${commandResult.status}`,
240
495
  };
241
496
  }
242
- function runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, playwrightBinary) {
497
+ function runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, playwrightBinary, apiSurface) {
243
498
  const flowId = flow.id;
244
499
  const flowName = flow.name;
245
500
  const existingFile = existsSync(testFile);
@@ -280,7 +535,7 @@ function runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, pl
280
535
  wroteNewFile = true;
281
536
  }
282
537
  const currentContent = candidate.write ? candidate.content : (originalContent || '');
283
- const qualityIssues = validateGeneratedSpecContent(currentContent);
538
+ const qualityIssues = validateGeneratedSpecContent(currentContent, apiSurface);
284
539
  if (qualityIssues.length > 0) {
285
540
  attempts.push(`${candidate.label}: ${qualityIssues.map((issue) => issue.message).join(' ')}`);
286
541
  if (pipeline.heal && i < candidates.length - 1) {
@@ -349,10 +604,10 @@ function runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, pl
349
604
  }
350
605
  function runPackageNativePipeline(testsRoot, flows, pipeline, baseWarnings = []) {
351
606
  const warningSet = new Set(baseWarnings);
352
- if (pipeline.mcp) {
353
- warningSet.add('Package-native pipeline does not run Playwright MCP directly. Use follow-up heal workflows if MCP is required.');
354
- }
607
+ const mcp = createMcpStatus('package-native', Boolean(pipeline.mcp));
355
608
  const playwrightBinary = pipeline.heal ? resolvePlaywrightBinary(testsRoot) : null;
609
+ const seedFile = resolveAgentSeedSpec(testsRoot) || 'specs/seed.spec.ts';
610
+ const apiSurface = buildApiSurfaceCatalog(testsRoot, seedFile);
356
611
  if (pipeline.heal && !playwrightBinary) {
357
612
  warningSet.add('Playwright binary was not found. Heal uses static quality checks without runtime compile validation.');
358
613
  }
@@ -360,7 +615,7 @@ function runPackageNativePipeline(testsRoot, flows, pipeline, baseWarnings = [])
360
615
  const outputBase = resolve(testsRoot, pipeline.outputDir || 'specs/functional/ai-assisted');
361
616
  if (!isPathWithinRoot(testsRoot, outputBase)) {
362
617
  warningSet.add(`Pipeline outputDir resolves outside testsRoot and was blocked: ${pipeline.outputDir}`);
363
- return { runner: 'unknown', results, warnings: Array.from(warningSet) };
618
+ return { runner: 'unknown', results, warnings: Array.from(warningSet), mcp: createMcpStatus('unknown', Boolean(pipeline.mcp)) };
364
619
  }
365
620
  for (const flow of flows) {
366
621
  if (flow.priority !== 'P0' && flow.priority !== 'P1') {
@@ -399,22 +654,26 @@ function runPackageNativePipeline(testsRoot, flows, pipeline, baseWarnings = [])
399
654
  });
400
655
  continue;
401
656
  }
402
- results.push(runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, playwrightBinary));
657
+ results.push(runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, playwrightBinary, apiSurface));
403
658
  }
404
- return { runner: 'package-native', results, warnings: Array.from(warningSet) };
659
+ return { runner: 'package-native', results, warnings: Array.from(warningSet), mcp };
405
660
  }
406
661
  export function runTargetedSpecHeal(testsRoot, targets, pipeline) {
407
662
  const warnings = new Set();
408
663
  const results = [];
664
+ const mcp = createMcpStatus('package-native', Boolean(pipeline.mcp));
409
665
  if (targets.length === 0) {
410
666
  warnings.add('No targeted specs provided for heal.');
411
667
  return {
412
668
  runner: 'package-native',
413
669
  results,
414
670
  warnings: Array.from(warnings),
671
+ mcp,
415
672
  };
416
673
  }
417
674
  const playwrightBinary = pipeline.heal ? resolvePlaywrightBinary(testsRoot) : null;
675
+ const seedFile = resolveAgentSeedSpec(testsRoot) || 'specs/seed.spec.ts';
676
+ const apiSurface = buildApiSurfaceCatalog(testsRoot, seedFile);
418
677
  if (pipeline.heal && !playwrightBinary) {
419
678
  warnings.add('Playwright binary was not found. Targeted heal uses static quality checks without runtime compile validation.');
420
679
  }
@@ -466,12 +725,13 @@ export function runTargetedSpecHeal(testsRoot, targets, pipeline) {
466
725
  continue;
467
726
  }
468
727
  const syntheticFlow = buildSyntheticFlowFromSpecTarget(relativeSpecPath, target);
469
- results.push(runPackageNativeFlow(testsRoot, syntheticFlow, pipeline, normalizePath(dirname(absoluteSpecPath)), absoluteSpecPath, playwrightBinary));
728
+ results.push(runPackageNativeFlow(testsRoot, syntheticFlow, pipeline, normalizePath(dirname(absoluteSpecPath)), absoluteSpecPath, playwrightBinary, apiSurface));
470
729
  }
471
730
  return {
472
731
  runner: 'package-native',
473
732
  results,
474
733
  warnings: Array.from(warnings),
734
+ mcp,
475
735
  };
476
736
  }
477
737
  function findSpecFiles(root) {
@@ -495,17 +755,357 @@ function findDisallowedDescribeFiles(root) {
495
755
  const files = findSpecFiles(root);
496
756
  return files.filter((file) => /\btest\.describe\s*\(/.test(readFileSync(file, 'utf-8')));
497
757
  }
758
+ function hasCommand(command, cwd) {
759
+ const result = runCommand(command, ['--version'], cwd);
760
+ return result.status === 0;
761
+ }
762
+ function hasPlaywrightAgentDefinitions(testsRoot) {
763
+ const required = [
764
+ '.mcp.json',
765
+ '.claude/agents/playwright-test-planner.md',
766
+ '.claude/agents/playwright-test-generator.md',
767
+ '.claude/agents/playwright-test-healer.md',
768
+ ];
769
+ return required.every((path) => existsSync(join(testsRoot, path)));
770
+ }
771
+ function hasPlaywrightConfig(testsRoot) {
772
+ const candidates = [
773
+ 'playwright.config.ts',
774
+ 'playwright.config.js',
775
+ 'playwright.config.mts',
776
+ 'playwright.config.mjs',
777
+ 'playwright.config.cts',
778
+ 'playwright.config.cjs',
779
+ ];
780
+ return candidates.some((candidate) => existsSync(join(testsRoot, candidate)));
781
+ }
782
+ function bootstrapPlaywrightAgentDefinitions(testsRoot, pipeline) {
783
+ const args = ['playwright', 'init-agents', '--loop=claude', '--prompts'];
784
+ if (pipeline.project) {
785
+ args.push('--project', pipeline.project);
786
+ }
787
+ return runCommand('npx', args, testsRoot);
788
+ }
789
+ function resolveAgentSeedSpec(testsRoot) {
790
+ const preferred = join(testsRoot, 'specs', 'seed.spec.ts');
791
+ const specsRoot = join(testsRoot, 'specs');
792
+ const specFiles = findSpecFiles(specsRoot).filter((file) => !normalizePath(file).includes('/functional/ai-assisted/'));
793
+ const scored = specFiles
794
+ .map((file) => {
795
+ const rel = normalizePath(relative(testsRoot, file));
796
+ const content = readFileSync(file, 'utf-8');
797
+ let score = 0;
798
+ if (rel.endsWith('/seed.spec.ts')) {
799
+ // Generated default seed from init-agents is often a placeholder; prefer real tests.
800
+ if (!/generate code here/i.test(content)) {
801
+ score += 2;
802
+ }
803
+ }
804
+ if (content.includes('@mattermost/playwright-lib')) {
805
+ score += 8;
806
+ }
807
+ if (content.includes('pw.initSetup(')) {
808
+ score += 6;
809
+ }
810
+ if (content.includes('testBrowser.login(')) {
811
+ score += 4;
812
+ }
813
+ if (content.includes('channelsPage')) {
814
+ score += 2;
815
+ }
816
+ if (rel.includes('/functional/channels/')) {
817
+ score += 1;
818
+ }
819
+ return { rel, score };
820
+ })
821
+ .sort((a, b) => b.score - a.score);
822
+ if (scored.length > 0 && scored[0].score > 0) {
823
+ return scored[0].rel;
824
+ }
825
+ if (existsSync(preferred)) {
826
+ return normalizePath(relative(testsRoot, preferred));
827
+ }
828
+ return null;
829
+ }
830
+ function buildPlaywrightAgentsPrompt(flow, seedFile, planFile, testFile, includeHealer) {
831
+ const linkedFiles = firstFlowFiles(flow).join(', ') || 'N/A';
832
+ const reasons = (flow.reasons || []).slice(0, 5).join(' | ') || 'N/A';
833
+ return [
834
+ 'Use official Playwright Test agents (planner, generator, healer) to implement exactly one high-quality test for this flow.',
835
+ '',
836
+ `Flow ID: ${flow.id}`,
837
+ `Flow Name: ${flow.name}`,
838
+ `Priority: ${flow.priority}`,
839
+ `Linked files: ${linkedFiles}`,
840
+ `Risk reasons: ${reasons}`,
841
+ '',
842
+ 'Workflow requirements:',
843
+ '1) Use #playwright-test-planner to explore and save a focused test plan.',
844
+ '2) Use #playwright-test-generator to generate one test from that plan.',
845
+ includeHealer
846
+ ? '3) Use #playwright-test-healer to run and fix that generated test.'
847
+ : '3) Skip runtime healing and focus on producing compile-ready test code.',
848
+ '',
849
+ `Seed file: ${seedFile}`,
850
+ `Plan file to save: ${planFile}`,
851
+ `Generated test file path (must be exact): ${testFile}`,
852
+ '',
853
+ 'Quality constraints (must follow):',
854
+ '- The generated file must contain a standalone test() and must not use test.describe or test.only.',
855
+ '- Do not mark the test with test.fixme unless user explicitly requests skipping.',
856
+ "- The generated test must include a single tag string '@ai-assisted'.",
857
+ '- Match fixture/import style from the seed file. Prefer existing page-object APIs over raw brittle selectors.',
858
+ '- Only use `pw` and page-object methods that already exist in the seed/current specs (for example, do not invent APIs like `pw.mainClient.*`).',
859
+ '- Keep the scenario strictly aligned to the flow and linked files, not broad unrelated flows.',
860
+ '',
861
+ 'At the end, return a short summary that includes the generated test file path and whether healing succeeded.',
862
+ ].join('\n');
863
+ }
864
+ function buildPlaywrightHealerPrompt(testFile, extra) {
865
+ const lines = [
866
+ 'Heal this specific Playwright test file and keep edits minimal.',
867
+ `Target test file: ${testFile}`,
868
+ 'Constraints:',
869
+ '- Do not use test.describe or test.only.',
870
+ "- Keep a single tag string '@ai-assisted'.",
871
+ '- Use only existing Mattermost Playwright fixture/page-object APIs; do not invent new `pw.*` clients or methods.',
872
+ '- Keep the test intent unchanged and focused.',
873
+ '',
874
+ 'Run and fix this test until it compiles/passes, or mark test.fixme with a clear comment when behavior is truly broken.',
875
+ ];
876
+ if (extra) {
877
+ lines.push('', `Context: ${extra}`);
878
+ }
879
+ return lines.join('\n');
880
+ }
881
+ function runPlaywrightAgentsFlow(testsRoot, flow, pipeline, outputDir, preferredTestFile, seedFile, apiSurface, playwrightBinary, allowRuntimeHeal) {
882
+ mkdirSync(outputDir, { recursive: true });
883
+ const slug = toSafeSlug(flow.id);
884
+ const planFile = normalizePath(relative(testsRoot, join(outputDir, `${slug}.plan.md`)));
885
+ const targetTestFile = normalizePath(relative(testsRoot, preferredTestFile));
886
+ if (pipeline.dryRun) {
887
+ return {
888
+ flowId: flow.id,
889
+ flowName: flow.name,
890
+ generatedDir: outputDir,
891
+ generateStatus: 'skipped',
892
+ healStatus: pipeline.heal ? 'skipped' : undefined,
893
+ };
894
+ }
895
+ const prompt = buildPlaywrightAgentsPrompt(flow, seedFile, planFile, targetTestFile, allowRuntimeHeal);
896
+ const runArgs = [
897
+ '-p',
898
+ '--permission-mode',
899
+ 'bypassPermissions',
900
+ '--mcp-config',
901
+ '.mcp.json',
902
+ '--add-dir',
903
+ testsRoot,
904
+ '--',
905
+ prompt,
906
+ ];
907
+ const runResult = runCommand('claude', runArgs, testsRoot);
908
+ if (runResult.status !== 0) {
909
+ return {
910
+ flowId: flow.id,
911
+ flowName: flow.name,
912
+ generatedDir: outputDir,
913
+ generateStatus: 'failed',
914
+ healStatus: pipeline.heal ? 'failed' : undefined,
915
+ error: summarizeCommandOutput(runResult.stdout, runResult.stderr) || runResult.error || 'Playwright agents run failed',
916
+ };
917
+ }
918
+ let actualTestFile = preferredTestFile;
919
+ if (!existsSync(actualTestFile)) {
920
+ const candidates = findSpecFiles(outputDir);
921
+ if (candidates.length === 1) {
922
+ actualTestFile = candidates[0];
923
+ }
924
+ }
925
+ if (!existsSync(actualTestFile)) {
926
+ return {
927
+ flowId: flow.id,
928
+ flowName: flow.name,
929
+ generatedDir: outputDir,
930
+ generateStatus: 'failed',
931
+ healStatus: pipeline.heal ? 'failed' : undefined,
932
+ error: `Playwright agents did not produce expected test file: ${targetTestFile}`,
933
+ };
934
+ }
935
+ const relativeActualTestFile = normalizePath(relative(testsRoot, actualTestFile));
936
+ let qualityIssues = validateGeneratedSpecContent(readFileSync(actualTestFile, 'utf-8'), apiSurface);
937
+ if (qualityIssues.length > 0 && allowRuntimeHeal) {
938
+ const healResult = runCommand('claude', [
939
+ '-p',
940
+ '--permission-mode',
941
+ 'bypassPermissions',
942
+ '--agent',
943
+ 'playwright-test-healer',
944
+ '--mcp-config',
945
+ '.mcp.json',
946
+ '--add-dir',
947
+ testsRoot,
948
+ '--',
949
+ buildPlaywrightHealerPrompt(relativeActualTestFile, qualityIssues.map((issue) => issue.message).join(' | ')),
950
+ ], testsRoot);
951
+ if (healResult.status === 0 && existsSync(actualTestFile)) {
952
+ qualityIssues = validateGeneratedSpecContent(readFileSync(actualTestFile, 'utf-8'), apiSurface);
953
+ }
954
+ }
955
+ if (qualityIssues.length > 0) {
956
+ return {
957
+ flowId: flow.id,
958
+ flowName: flow.name,
959
+ generatedDir: outputDir,
960
+ generateStatus: 'failed',
961
+ healStatus: pipeline.heal ? 'failed' : undefined,
962
+ error: `Playwright agents produced invalid test content: ${qualityIssues.map((issue) => issue.message).join(' | ')}`,
963
+ };
964
+ }
965
+ if (allowRuntimeHeal) {
966
+ let validation = runPlaywrightListValidation(testsRoot, actualTestFile, pipeline, playwrightBinary);
967
+ if (validation.status === 'failed') {
968
+ const healResult = runCommand('claude', [
969
+ '-p',
970
+ '--permission-mode',
971
+ 'bypassPermissions',
972
+ '--agent',
973
+ 'playwright-test-healer',
974
+ '--mcp-config',
975
+ '.mcp.json',
976
+ '--add-dir',
977
+ testsRoot,
978
+ '--',
979
+ buildPlaywrightHealerPrompt(relativeActualTestFile, validation.detail || 'playwright --list failed'),
980
+ ], testsRoot);
981
+ if (healResult.status === 0 && existsSync(actualTestFile)) {
982
+ validation = runPlaywrightListValidation(testsRoot, actualTestFile, pipeline, playwrightBinary);
983
+ }
984
+ if (validation.status === 'failed') {
985
+ return {
986
+ flowId: flow.id,
987
+ flowName: flow.name,
988
+ generatedDir: outputDir,
989
+ generateStatus: 'failed',
990
+ healStatus: 'failed',
991
+ error: `Playwright agents heal failed: ${validation.detail || 'playwright validation failed'}`,
992
+ };
993
+ }
994
+ }
995
+ }
996
+ return {
997
+ flowId: flow.id,
998
+ flowName: flow.name,
999
+ generatedDir: outputDir,
1000
+ generateStatus: 'success',
1001
+ healStatus: pipeline.heal ? 'success' : undefined,
1002
+ };
1003
+ }
1004
+ function runPlaywrightAgentsPipeline(testsRoot, flows, pipeline) {
1005
+ const warnings = [];
1006
+ const results = [];
1007
+ if (!hasCommand('claude', testsRoot)) {
1008
+ warnings.push('Claude CLI is required for official Playwright planner/generator/healer execution but was not found.');
1009
+ return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
1010
+ }
1011
+ if (!hasPlaywrightConfig(testsRoot)) {
1012
+ warnings.push('Playwright config file not found in testsRoot; skipping official Playwright agents backend.');
1013
+ return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
1014
+ }
1015
+ if (!hasPlaywrightAgentDefinitions(testsRoot)) {
1016
+ const bootstrap = bootstrapPlaywrightAgentDefinitions(testsRoot, pipeline);
1017
+ if (bootstrap.status !== 0) {
1018
+ warnings.push(summarizeCommandOutput(bootstrap.stdout, bootstrap.stderr) ||
1019
+ bootstrap.error ||
1020
+ 'Failed to initialize Playwright agents via `npx playwright init-agents`.');
1021
+ return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
1022
+ }
1023
+ }
1024
+ if (!hasPlaywrightAgentDefinitions(testsRoot)) {
1025
+ warnings.push('Playwright agent definitions are missing after bootstrap.');
1026
+ return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
1027
+ }
1028
+ const seedFile = resolveAgentSeedSpec(testsRoot);
1029
+ if (!seedFile) {
1030
+ warnings.push('No seed spec file found under specs/. Playwright planner cannot be initialized.');
1031
+ return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
1032
+ }
1033
+ const allowRuntimeHeal = Boolean(pipeline.heal && pipeline.baseUrl);
1034
+ const playwrightBinary = allowRuntimeHeal ? resolvePlaywrightBinary(testsRoot) : null;
1035
+ const apiSurface = buildApiSurfaceCatalog(testsRoot, seedFile);
1036
+ if (allowRuntimeHeal && !playwrightBinary) {
1037
+ warnings.push('Playwright binary was not found. Healer runtime validation may be limited.');
1038
+ }
1039
+ if (pipeline.heal && !allowRuntimeHeal) {
1040
+ warnings.push('Skipping runtime healer in official MCP mode because no --pipeline-base-url was provided.');
1041
+ }
1042
+ const outputBase = resolve(testsRoot, pipeline.outputDir || 'specs/functional/ai-assisted');
1043
+ if (!isPathWithinRoot(testsRoot, outputBase)) {
1044
+ warnings.push(`Pipeline outputDir resolves outside testsRoot and was blocked: ${pipeline.outputDir}`);
1045
+ return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
1046
+ }
1047
+ for (const flow of flows) {
1048
+ if (flow.priority !== 'P0' && flow.priority !== 'P1') {
1049
+ continue;
1050
+ }
1051
+ const slug = toSafeSlug(flow.id);
1052
+ const outputDir = normalizePath(join(outputBase, slug));
1053
+ if (!isPathWithinRoot(testsRoot, outputDir)) {
1054
+ results.push({
1055
+ flowId: flow.id,
1056
+ flowName: flow.name,
1057
+ generatedDir: outputDir,
1058
+ generateStatus: 'failed',
1059
+ error: 'output directory resolves outside testsRoot',
1060
+ });
1061
+ continue;
1062
+ }
1063
+ const testFile = normalizePath(join(outputDir, `${slug}.spec.ts`));
1064
+ if (!isPathWithinRoot(testsRoot, testFile)) {
1065
+ results.push({
1066
+ flowId: flow.id,
1067
+ flowName: flow.name,
1068
+ generatedDir: outputDir,
1069
+ generateStatus: 'failed',
1070
+ error: 'generated test path resolves outside testsRoot',
1071
+ });
1072
+ continue;
1073
+ }
1074
+ results.push(runPlaywrightAgentsFlow(testsRoot, flow, pipeline, outputDir, testFile, seedFile, apiSurface, playwrightBinary, allowRuntimeHeal));
1075
+ }
1076
+ return { runner: 'playwright-agents', results, warnings, mcp: createMcpStatus('playwright-agents', true) };
1077
+ }
498
1078
  export function runPlaywrightPipeline(testsRoot, flows, pipeline) {
1079
+ const mcpFallbackWarnings = [];
1080
+ if (pipeline.mcp) {
1081
+ const agentsSummary = runPlaywrightAgentsPipeline(testsRoot, flows, pipeline);
1082
+ if (agentsSummary.runner !== 'unknown' || agentsSummary.results.length > 0) {
1083
+ return agentsSummary;
1084
+ }
1085
+ if (!pipeline.mcpAllowFallback) {
1086
+ const warnings = [
1087
+ ...agentsSummary.warnings,
1088
+ 'Official Playwright MCP mode is strict; fallback generation is disabled unless pipeline.mcpAllowFallback=true.',
1089
+ ];
1090
+ return {
1091
+ runner: 'unknown',
1092
+ results: agentsSummary.results,
1093
+ warnings,
1094
+ mcp: createMcpStatus('unknown', true),
1095
+ };
1096
+ }
1097
+ mcpFallbackWarnings.push(...agentsSummary.warnings);
1098
+ }
499
1099
  const cliPath = hasE2eTestGenCLI(testsRoot);
500
1100
  if (!cliPath) {
501
- return runPackageNativePipeline(testsRoot, flows, pipeline, ['e2e-test-gen-cli.ts not found; using package-native pipeline fallback.']);
1101
+ return runPackageNativePipeline(testsRoot, flows, pipeline, mcpFallbackWarnings);
502
1102
  }
503
- const warnings = [];
1103
+ const warnings = [...mcpFallbackWarnings];
504
1104
  const results = [];
505
1105
  const outputBase = resolve(testsRoot, pipeline.outputDir || 'specs/functional/ai-assisted');
506
1106
  if (!isPathWithinRoot(testsRoot, outputBase)) {
507
1107
  warnings.push(`Pipeline outputDir resolves outside testsRoot and was blocked: ${pipeline.outputDir}`);
508
- return { runner: 'unknown', results, warnings };
1108
+ return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', Boolean(pipeline.mcp)) };
509
1109
  }
510
1110
  for (const flow of flows) {
511
1111
  if (flow.priority !== 'P0' && flow.priority !== 'P1') {
@@ -601,5 +1201,5 @@ export function runPlaywrightPipeline(testsRoot, flows, pipeline) {
601
1201
  healStatus,
602
1202
  });
603
1203
  }
604
- return { runner: 'e2e-test-gen', results, warnings };
1204
+ return { runner: 'e2e-test-gen', results, warnings, mcp: createMcpStatus('e2e-test-gen', Boolean(pipeline.mcp)) };
605
1205
  }