@yasserkhanorg/e2e-agents 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -20
- package/dist/agent/config.d.ts +3 -0
- package/dist/agent/config.d.ts.map +1 -1
- package/dist/agent/config.js +38 -0
- package/dist/agent/operational_insights.d.ts +1 -1
- package/dist/agent/operational_insights.d.ts.map +1 -1
- package/dist/agent/operational_insights.js +2 -1
- package/dist/agent/pipeline.d.ts +8 -1
- package/dist/agent/pipeline.d.ts.map +1 -1
- package/dist/agent/pipeline.js +844 -33
- package/dist/agent/plan.d.ts +39 -0
- package/dist/agent/plan.d.ts.map +1 -1
- package/dist/agent/plan.js +146 -0
- package/dist/agent/report.d.ts +16 -0
- package/dist/agent/report.d.ts.map +1 -1
- package/dist/agent/report.js +12 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +66 -11
- package/dist/agent/tests.d.ts.map +1 -1
- package/dist/agent/tests.js +12 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -0
- package/dist/cli.js +111 -7
- package/dist/esm/agent/config.js +38 -0
- package/dist/esm/agent/operational_insights.js +2 -1
- package/dist/esm/agent/pipeline.js +844 -33
- package/dist/esm/agent/plan.js +145 -1
- package/dist/esm/agent/report.js +12 -0
- package/dist/esm/agent/runner.js +66 -11
- package/dist/esm/agent/tests.js +12 -2
- package/dist/esm/api.js +2 -1
- package/dist/esm/cli.js +112 -8
- package/package.json +1 -1
- package/schemas/impact.schema.json +37 -0
- package/schemas/plan.schema.json +48 -0
- package/dist/agent/cache_utils.d.ts +0 -38
- package/dist/agent/cache_utils.d.ts.map +0 -1
- package/dist/agent/cache_utils.js +0 -67
- package/dist/agent/impact-analyzer.d.ts +0 -114
- package/dist/agent/impact-analyzer.d.ts.map +0 -1
- package/dist/agent/impact-analyzer.js +0 -557
- package/dist/agent/index.d.ts +0 -21
- package/dist/agent/index.d.ts.map +0 -1
- package/dist/agent/index.js +0 -38
- package/dist/agent/model-router.d.ts +0 -57
- package/dist/agent/model-router.d.ts.map +0 -1
- package/dist/agent/model-router.js +0 -154
- package/dist/agent/report-generator.d.ts +0 -24
- package/dist/agent/report-generator.d.ts.map +0 -1
- package/dist/agent/report-generator.js +0 -250
- package/dist/agent/spec-bridge.d.ts +0 -101
- package/dist/agent/spec-bridge.d.ts.map +0 -1
- package/dist/agent/spec-bridge.js +0 -273
- package/dist/agent/spec-builder.d.ts +0 -102
- package/dist/agent/spec-builder.d.ts.map +0 -1
- package/dist/agent/spec-builder.js +0 -273
- package/dist/agent/telemetry.d.ts +0 -84
- package/dist/agent/telemetry.d.ts.map +0 -1
- package/dist/agent/telemetry.js +0 -220
- package/dist/agent/validators/selector-validator.d.ts +0 -74
- package/dist/agent/validators/selector-validator.d.ts.map +0 -1
- package/dist/agent/validators/selector-validator.js +0 -165
- package/dist/e2e-test-gen/index.d.ts +0 -51
- package/dist/e2e-test-gen/index.d.ts.map +0 -1
- package/dist/e2e-test-gen/index.js +0 -57
- package/dist/e2e-test-gen/spec_parser.d.ts +0 -142
- package/dist/e2e-test-gen/spec_parser.d.ts.map +0 -1
- package/dist/e2e-test-gen/spec_parser.js +0 -786
- package/dist/e2e-test-gen/types.d.ts +0 -185
- package/dist/e2e-test-gen/types.d.ts.map +0 -1
- package/dist/e2e-test-gen/types.js +0 -4
- package/dist/esm/agent/cache_utils.js +0 -63
- package/dist/esm/agent/impact-analyzer.js +0 -548
- package/dist/esm/agent/index.js +0 -22
- package/dist/esm/agent/model-router.js +0 -150
- package/dist/esm/agent/report-generator.js +0 -247
- package/dist/esm/agent/spec-bridge.js +0 -267
- package/dist/esm/agent/spec-builder.js +0 -267
- package/dist/esm/agent/telemetry.js +0 -216
- package/dist/esm/agent/validators/selector-validator.js +0 -160
- package/dist/esm/e2e-test-gen/index.js +0 -50
- package/dist/esm/e2e-test-gen/spec_parser.js +0 -782
- package/dist/esm/e2e-test-gen/types.js +0 -3
- package/dist/esm/plan-and-test-constants.js +0 -126
- package/dist/plan-and-test-constants.d.ts +0 -110
- package/dist/plan-and-test-constants.d.ts.map +0 -1
- package/dist/plan-and-test-constants.js +0 -132
package/dist/agent/pipeline.js
CHANGED
|
@@ -8,6 +8,47 @@ const fs_1 = require("fs");
|
|
|
8
8
|
const path_1 = require("path");
|
|
9
9
|
const child_process_1 = require("child_process");
|
|
10
10
|
const utils_js_1 = require("./utils.js");
|
|
11
|
+
function createMcpStatus(backend, requested) {
|
|
12
|
+
return {
|
|
13
|
+
requested,
|
|
14
|
+
active: requested && (backend === 'e2e-test-gen' || backend === 'playwright-agents'),
|
|
15
|
+
backend,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function classifyPipelineFailure(result) {
|
|
19
|
+
if (result.failureCategory || result.failureCode) {
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
if (!result.error) {
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
const errorText = result.error.toLowerCase();
|
|
26
|
+
if (errorText.includes('outside testsroot')) {
|
|
27
|
+
return { ...result, failureCategory: 'path-safety', failureCode: 'path_outside_tests_root' };
|
|
28
|
+
}
|
|
29
|
+
if (errorText.includes('playwright binary') || errorText.includes('not found')) {
|
|
30
|
+
return { ...result, failureCategory: 'environment', failureCode: 'dependency_missing' };
|
|
31
|
+
}
|
|
32
|
+
if (errorText.includes('compile validation')) {
|
|
33
|
+
return { ...result, failureCategory: 'validation', failureCode: 'compile_validation_failed' };
|
|
34
|
+
}
|
|
35
|
+
if (errorText.includes('runtime validation') || errorText.includes('playwright test failed')) {
|
|
36
|
+
return { ...result, failureCategory: 'runtime', failureCode: 'runtime_validation_failed' };
|
|
37
|
+
}
|
|
38
|
+
if (errorText.includes('quality checks failed') || errorText.includes('invalid test content')) {
|
|
39
|
+
return { ...result, failureCategory: 'quality', failureCode: 'quality_guard_failed' };
|
|
40
|
+
}
|
|
41
|
+
if (errorText.includes('generate failed') || errorText.includes('did not produce expected test file')) {
|
|
42
|
+
return { ...result, failureCategory: 'generation', failureCode: 'generation_failed' };
|
|
43
|
+
}
|
|
44
|
+
return { ...result, failureCategory: 'unknown', failureCode: 'unknown' };
|
|
45
|
+
}
|
|
46
|
+
function finalizePipelineSummary(summary) {
|
|
47
|
+
return {
|
|
48
|
+
...summary,
|
|
49
|
+
results: summary.results.map(classifyPipelineFailure),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
11
52
|
function hasE2eTestGenCLI(testsRoot) {
|
|
12
53
|
const cliPath = (0, path_1.join)(testsRoot, 'e2e-test-gen-cli.ts');
|
|
13
54
|
return (0, fs_1.existsSync)(cliPath) ? cliPath : null;
|
|
@@ -44,6 +85,7 @@ function firstFlowFiles(flow) {
|
|
|
44
85
|
return (flow.files || []).filter(Boolean).slice(0, 5);
|
|
45
86
|
}
|
|
46
87
|
function buildNativeStrategyOrder(flow) {
|
|
88
|
+
const flowId = (flow.id || '').toLowerCase();
|
|
47
89
|
const haystack = [
|
|
48
90
|
flow.id,
|
|
49
91
|
flow.name,
|
|
@@ -52,22 +94,259 @@ function buildNativeStrategyOrder(flow) {
|
|
|
52
94
|
...(flow.keywords || []),
|
|
53
95
|
].join(' ').toLowerCase();
|
|
54
96
|
const strategies = [];
|
|
97
|
+
if (flowId.includes('search')) {
|
|
98
|
+
strategies.push('search-baseline');
|
|
99
|
+
}
|
|
100
|
+
if (flowId.includes('threads') || flowId.includes('thread')) {
|
|
101
|
+
strategies.push('thread-reply');
|
|
102
|
+
}
|
|
103
|
+
if (flowId.includes('channels.lifecycle')) {
|
|
104
|
+
strategies.push('lifecycle-channel');
|
|
105
|
+
}
|
|
106
|
+
if (flowId.includes('channels.settings')) {
|
|
107
|
+
strategies.push('channel-settings');
|
|
108
|
+
}
|
|
109
|
+
if (flowId.includes('channels.switch')) {
|
|
110
|
+
strategies.push('channel-switch');
|
|
111
|
+
}
|
|
112
|
+
if (flowId.includes('messaging.markdown')) {
|
|
113
|
+
strategies.push('markdown-post');
|
|
114
|
+
}
|
|
115
|
+
if (flowId.includes('messaging.mentions')) {
|
|
116
|
+
strategies.push('mentions-post');
|
|
117
|
+
}
|
|
118
|
+
if (flowId.includes('messaging.realtime')) {
|
|
119
|
+
strategies.push('realtime-post');
|
|
120
|
+
}
|
|
55
121
|
if (/(thread|reply|rhs|sidebar[_-]?right)/.test(haystack)) {
|
|
56
122
|
strategies.push('thread-reply');
|
|
57
123
|
}
|
|
124
|
+
if (/(create|join|leave|invite)/.test(haystack)) {
|
|
125
|
+
strategies.push('lifecycle-channel');
|
|
126
|
+
}
|
|
127
|
+
if (/(settings|preferences)/.test(haystack)) {
|
|
128
|
+
strategies.push('channel-settings');
|
|
129
|
+
}
|
|
130
|
+
if (/(switch|quick\\s*switch)/.test(haystack)) {
|
|
131
|
+
strategies.push('channel-switch');
|
|
132
|
+
}
|
|
133
|
+
if (/(markdown|format)/.test(haystack)) {
|
|
134
|
+
strategies.push('markdown-post');
|
|
135
|
+
}
|
|
136
|
+
if (/(mention|@)/.test(haystack)) {
|
|
137
|
+
strategies.push('mentions-post');
|
|
138
|
+
}
|
|
139
|
+
if (/(realtime|websocket|presence)/.test(haystack)) {
|
|
140
|
+
strategies.push('realtime-post');
|
|
141
|
+
}
|
|
142
|
+
if (/(search|find|spotlight)/.test(haystack)) {
|
|
143
|
+
strategies.push('search-baseline');
|
|
144
|
+
}
|
|
58
145
|
if (/(message|post|realtime|websocket|chat)/.test(haystack)) {
|
|
59
146
|
strategies.push('message-post');
|
|
60
147
|
}
|
|
61
148
|
if (/(channel|navigation|sidebar|switch)/.test(haystack)) {
|
|
62
149
|
strategies.push('channel-baseline');
|
|
63
150
|
}
|
|
64
|
-
if (/(search|find|spotlight)/.test(haystack)) {
|
|
65
|
-
strategies.push('search-baseline');
|
|
66
|
-
}
|
|
67
151
|
strategies.push('generic-baseline');
|
|
68
152
|
return Array.from(new Set(strategies));
|
|
69
153
|
}
|
|
70
|
-
function
|
|
154
|
+
function createDefaultApiSurfaceCatalog() {
|
|
155
|
+
const pwNestedMethods = new Map();
|
|
156
|
+
pwNestedMethods.set('apiClient', new Set([
|
|
157
|
+
'createPost',
|
|
158
|
+
'createDirectChannel',
|
|
159
|
+
'createChannel',
|
|
160
|
+
'getChannels',
|
|
161
|
+
'getChannelByName',
|
|
162
|
+
'getPostsSince',
|
|
163
|
+
]));
|
|
164
|
+
return {
|
|
165
|
+
pwProps: new Set([
|
|
166
|
+
'initSetup',
|
|
167
|
+
'testBrowser',
|
|
168
|
+
'apiInitSetup',
|
|
169
|
+
'apiAdminSetup',
|
|
170
|
+
'apiCreateChannel',
|
|
171
|
+
'apiCreateUser',
|
|
172
|
+
'apiLogin',
|
|
173
|
+
'apiClient',
|
|
174
|
+
]),
|
|
175
|
+
pwNestedMethods,
|
|
176
|
+
initSetupKeys: new Set([
|
|
177
|
+
'user',
|
|
178
|
+
'team',
|
|
179
|
+
'adminClient',
|
|
180
|
+
'adminUser',
|
|
181
|
+
'adminConfig',
|
|
182
|
+
'userClient',
|
|
183
|
+
'offTopicUrl',
|
|
184
|
+
'townSquareUrl',
|
|
185
|
+
]),
|
|
186
|
+
initSetupVariableMethods: new Map(),
|
|
187
|
+
testBrowserMethods: new Set([
|
|
188
|
+
'login',
|
|
189
|
+
'openNewBrowserContext',
|
|
190
|
+
'newContext',
|
|
191
|
+
]),
|
|
192
|
+
channelsPageMembers: new Set([
|
|
193
|
+
'goto',
|
|
194
|
+
'page',
|
|
195
|
+
'postMessage',
|
|
196
|
+
'getLastPost',
|
|
197
|
+
'sidebarRight',
|
|
198
|
+
'openChannelSettings',
|
|
199
|
+
'newChannel',
|
|
200
|
+
'globalHeader',
|
|
201
|
+
'searchBox',
|
|
202
|
+
]),
|
|
203
|
+
sidebarRightMembers: new Set([
|
|
204
|
+
'openThreadForPost',
|
|
205
|
+
'postMessage',
|
|
206
|
+
'getLastPost',
|
|
207
|
+
]),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function collectMatches(content, pattern) {
|
|
211
|
+
const out = new Set();
|
|
212
|
+
for (const match of content.matchAll(pattern)) {
|
|
213
|
+
const value = match[1];
|
|
214
|
+
if (value) {
|
|
215
|
+
out.add(value);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
function addNestedMethod(catalog, objectName, methodName) {
|
|
221
|
+
const methods = catalog.pwNestedMethods.get(objectName) || new Set();
|
|
222
|
+
methods.add(methodName);
|
|
223
|
+
catalog.pwNestedMethods.set(objectName, methods);
|
|
224
|
+
}
|
|
225
|
+
function escapeRegExp(value) {
|
|
226
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
227
|
+
}
|
|
228
|
+
function parseInitSetupBindings(content) {
|
|
229
|
+
const bindings = [];
|
|
230
|
+
for (const match of content.matchAll(/(?:const|let|var)\s*\{\s*([^}]+)\s*\}\s*=\s*await\s+pw\.initSetup\s*\(/g)) {
|
|
231
|
+
const raw = match[1];
|
|
232
|
+
if (!raw) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
for (const part of raw.split(',')) {
|
|
236
|
+
const cleaned = part.trim();
|
|
237
|
+
if (!cleaned) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const [leftRaw, rightRaw] = cleaned.split(':');
|
|
241
|
+
const key = (leftRaw || '').trim();
|
|
242
|
+
const variableCandidate = (rightRaw || leftRaw || '').trim().split('=')[0]?.trim();
|
|
243
|
+
if (!key || !variableCandidate) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
bindings.push({ key, variable: variableCandidate });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return bindings;
|
|
250
|
+
}
|
|
251
|
+
function collectDestructuredInitSetupKeys(content) {
|
|
252
|
+
return new Set(parseInitSetupBindings(content).map((binding) => binding.key));
|
|
253
|
+
}
|
|
254
|
+
function addInitSetupVariableMethod(catalog, variable, methodName) {
|
|
255
|
+
const methods = catalog.initSetupVariableMethods.get(variable) || new Set();
|
|
256
|
+
methods.add(methodName);
|
|
257
|
+
catalog.initSetupVariableMethods.set(variable, methods);
|
|
258
|
+
}
|
|
259
|
+
function collectApiSurfaceFromContent(content, catalog) {
|
|
260
|
+
for (const prop of collectMatches(content, /\bpw\.([A-Za-z_][A-Za-z0-9_]*)\b/g)) {
|
|
261
|
+
catalog.pwProps.add(prop);
|
|
262
|
+
}
|
|
263
|
+
for (const match of content.matchAll(/\bpw\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)\b/g)) {
|
|
264
|
+
const objectName = match[1];
|
|
265
|
+
const methodName = match[2];
|
|
266
|
+
if (!objectName || !methodName) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
addNestedMethod(catalog, objectName, methodName);
|
|
270
|
+
}
|
|
271
|
+
for (const method of collectMatches(content, /\bpw\.testBrowser\.([A-Za-z_][A-Za-z0-9_]*)\b/g)) {
|
|
272
|
+
catalog.testBrowserMethods.add(method);
|
|
273
|
+
}
|
|
274
|
+
for (const member of collectMatches(content, /\bchannelsPage\.([A-Za-z_][A-Za-z0-9_]*)\b/g)) {
|
|
275
|
+
catalog.channelsPageMembers.add(member);
|
|
276
|
+
}
|
|
277
|
+
for (const member of collectMatches(content, /\bchannelsPage\.sidebarRight\.([A-Za-z_][A-Za-z0-9_]*)\b/g)) {
|
|
278
|
+
catalog.sidebarRightMembers.add(member);
|
|
279
|
+
}
|
|
280
|
+
for (const binding of parseInitSetupBindings(content)) {
|
|
281
|
+
catalog.initSetupKeys.add(binding.key);
|
|
282
|
+
const methodPattern = new RegExp(`\\b${escapeRegExp(binding.variable)}\\.([A-Za-z_][A-Za-z0-9_]*)\\b`, 'g');
|
|
283
|
+
for (const method of collectMatches(content, methodPattern)) {
|
|
284
|
+
addInitSetupVariableMethod(catalog, binding.variable, method);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function buildApiSurfaceCatalog(testsRoot, seedFile) {
|
|
289
|
+
const catalog = createDefaultApiSurfaceCatalog();
|
|
290
|
+
const candidateRoots = [
|
|
291
|
+
(0, path_1.join)(testsRoot, 'specs'),
|
|
292
|
+
(0, path_1.join)(testsRoot, 'tests'),
|
|
293
|
+
];
|
|
294
|
+
const files = [];
|
|
295
|
+
for (const root of candidateRoots) {
|
|
296
|
+
if (!(0, fs_1.existsSync)(root)) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const stack = [root];
|
|
300
|
+
while (stack.length > 0) {
|
|
301
|
+
const current = stack.pop();
|
|
302
|
+
let entries;
|
|
303
|
+
try {
|
|
304
|
+
entries = (0, fs_1.readdirSync)(current, { withFileTypes: true });
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
const full = (0, path_1.join)(current, entry.name);
|
|
311
|
+
if (entry.isDirectory()) {
|
|
312
|
+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist') {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
stack.push(full);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!entry.isFile()) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (!/\.(spec|test)\.[jt]sx?$/.test(entry.name)) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
files.push(full);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const uniqueFiles = Array.from(new Set(files)).slice(0, 2500);
|
|
329
|
+
for (const filePath of uniqueFiles) {
|
|
330
|
+
try {
|
|
331
|
+
const content = (0, fs_1.readFileSync)(filePath, 'utf-8');
|
|
332
|
+
collectApiSurfaceFromContent(content, catalog);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const absoluteSeed = (0, path_1.join)(testsRoot, seedFile);
|
|
339
|
+
if ((0, fs_1.existsSync)(absoluteSeed)) {
|
|
340
|
+
try {
|
|
341
|
+
collectApiSurfaceFromContent((0, fs_1.readFileSync)(absoluteSeed, 'utf-8'), catalog);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// ignore seed read failures; defaults + catalog scan still apply
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return catalog;
|
|
348
|
+
}
|
|
349
|
+
function validateGeneratedSpecContent(content, apiSurface) {
|
|
71
350
|
const issues = [];
|
|
72
351
|
if (/\btest\.describe\s*\(/.test(content)) {
|
|
73
352
|
issues.push({
|
|
@@ -93,13 +372,64 @@ function validateGeneratedSpecContent(content) {
|
|
|
93
372
|
message: 'Generated tests must use a single tag string, not a tag array.',
|
|
94
373
|
});
|
|
95
374
|
}
|
|
96
|
-
const
|
|
97
|
-
|
|
375
|
+
const hasTagOption = /\btag\s*:\s*['"][^'"]+['"]/.test(content);
|
|
376
|
+
const hasTagInTitle = /\btest(?:\.\w+)?\s*\(\s*['"][^'"]*@ai-assisted[^'"]*['"]/.test(content);
|
|
377
|
+
if (!(hasTagOption || hasTagInTitle) || !/@ai-assisted/.test(content)) {
|
|
98
378
|
issues.push({
|
|
99
379
|
code: 'missing-tag',
|
|
100
|
-
message: "Generated tests must include
|
|
380
|
+
message: "Generated tests must include '@ai-assisted' either as tag option or in test title.",
|
|
101
381
|
});
|
|
102
382
|
}
|
|
383
|
+
if (apiSurface) {
|
|
384
|
+
const unknownPwProps = Array.from(collectMatches(content, /\bpw\.([A-Za-z_][A-Za-z0-9_]*)\b/g)).filter((prop) => !apiSurface.pwProps.has(prop));
|
|
385
|
+
const unknownBrowserMethods = Array.from(collectMatches(content, /\bpw\.testBrowser\.([A-Za-z_][A-Za-z0-9_]*)\b/g)).filter((method) => !apiSurface.testBrowserMethods.has(method));
|
|
386
|
+
const unknownNestedPwMembers = [];
|
|
387
|
+
for (const match of content.matchAll(/\bpw\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)\b/g)) {
|
|
388
|
+
const objectName = match[1];
|
|
389
|
+
const methodName = match[2];
|
|
390
|
+
if (!objectName || !methodName || objectName === 'testBrowser') {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const knownMethods = apiSurface.pwNestedMethods.get(objectName);
|
|
394
|
+
if (!knownMethods || !knownMethods.has(methodName)) {
|
|
395
|
+
unknownNestedPwMembers.push(`pw.${objectName}.${methodName}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const unknownChannelMembers = Array.from(collectMatches(content, /\bchannelsPage\.([A-Za-z_][A-Za-z0-9_]*)\b/g)).filter((member) => !apiSurface.channelsPageMembers.has(member));
|
|
399
|
+
const unknownSidebarMembers = Array.from(collectMatches(content, /\bchannelsPage\.sidebarRight\.([A-Za-z_][A-Za-z0-9_]*)\b/g)).filter((member) => !apiSurface.sidebarRightMembers.has(member));
|
|
400
|
+
const initSetupBindings = parseInitSetupBindings(content);
|
|
401
|
+
const unknownInitSetupKeys = initSetupBindings
|
|
402
|
+
.map((binding) => binding.key)
|
|
403
|
+
.filter((key) => !apiSurface.initSetupKeys.has(key));
|
|
404
|
+
const unknownInitSetupVariableMethods = [];
|
|
405
|
+
for (const binding of initSetupBindings) {
|
|
406
|
+
const knownMethods = apiSurface.initSetupVariableMethods.get(binding.variable);
|
|
407
|
+
if (!knownMethods || knownMethods.size === 0) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const methodPattern = new RegExp(`\\b${escapeRegExp(binding.variable)}\\.([A-Za-z_][A-Za-z0-9_]*)\\b`, 'g');
|
|
411
|
+
for (const method of collectMatches(content, methodPattern)) {
|
|
412
|
+
if (!knownMethods.has(method)) {
|
|
413
|
+
unknownInitSetupVariableMethods.push(`${binding.variable}.${method}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const unknown = [
|
|
418
|
+
...unknownPwProps.map((value) => `pw.${value}`),
|
|
419
|
+
...unknownBrowserMethods.map((value) => `pw.testBrowser.${value}`),
|
|
420
|
+
...unknownNestedPwMembers,
|
|
421
|
+
...unknownChannelMembers.map((value) => `channelsPage.${value}`),
|
|
422
|
+
...unknownSidebarMembers.map((value) => `channelsPage.sidebarRight.${value}`),
|
|
423
|
+
...unknownInitSetupKeys.map((value) => `pw.initSetup.{${value}}`),
|
|
424
|
+
...unknownInitSetupVariableMethods,
|
|
425
|
+
];
|
|
426
|
+
if (unknown.length > 0) {
|
|
427
|
+
issues.push({
|
|
428
|
+
code: 'unknown-api-surface',
|
|
429
|
+
message: `Generated test uses unknown API/page-object members: ${Array.from(new Set(unknown)).join(', ')}`,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
103
433
|
return issues;
|
|
104
434
|
}
|
|
105
435
|
function createNativePlaywrightSpec(flow, slug, strategy) {
|
|
@@ -130,10 +460,76 @@ function createNativePlaywrightSpec(flow, slug, strategy) {
|
|
|
130
460
|
...start,
|
|
131
461
|
` const parentMessage = \`ai-${slug}-parent-\${Date.now()}\`;`,
|
|
132
462
|
' await channelsPage.postMessage(parentMessage);',
|
|
133
|
-
' await channelsPage.
|
|
463
|
+
' const rootPost = await channelsPage.getLastPost();',
|
|
464
|
+
' await rootPost.openAThread();',
|
|
134
465
|
` const replyMessage = \`ai-${slug}-reply-\${Date.now()}\`;`,
|
|
135
466
|
' await channelsPage.sidebarRight.postMessage(replyMessage);',
|
|
136
|
-
' await
|
|
467
|
+
' const lastReply = await channelsPage.sidebarRight.getLastPost();',
|
|
468
|
+
' await expect(lastReply.container).toContainText(replyMessage);',
|
|
469
|
+
...end,
|
|
470
|
+
].join('\n');
|
|
471
|
+
}
|
|
472
|
+
if (strategy === 'lifecycle-channel') {
|
|
473
|
+
return [
|
|
474
|
+
...header,
|
|
475
|
+
...start,
|
|
476
|
+
` const channelName = \`ai-${slug}-\${Date.now().toString().slice(-6)}\`;`,
|
|
477
|
+
" await channelsPage.newChannel(channelName, 'O');",
|
|
478
|
+
' await expect(channelsPage.page).toHaveURL(new RegExp(`/channels/${channelName}$`));',
|
|
479
|
+
...end,
|
|
480
|
+
].join('\n');
|
|
481
|
+
}
|
|
482
|
+
if (strategy === 'channel-settings') {
|
|
483
|
+
return [
|
|
484
|
+
...header,
|
|
485
|
+
...start,
|
|
486
|
+
' await channelsPage.openChannelSettings();',
|
|
487
|
+
" await expect(channelsPage.page.getByRole('dialog', {name: 'Channel Settings'})).toBeVisible();",
|
|
488
|
+
" await channelsPage.page.keyboard.press('Escape');",
|
|
489
|
+
...end,
|
|
490
|
+
].join('\n');
|
|
491
|
+
}
|
|
492
|
+
if (strategy === 'channel-switch') {
|
|
493
|
+
return [
|
|
494
|
+
...header,
|
|
495
|
+
...start,
|
|
496
|
+
" await channelsPage.goto(team.name, 'off-topic');",
|
|
497
|
+
" await expect(channelsPage.page).toHaveURL(/\\/channels\\/off-topic$/);",
|
|
498
|
+
" await expect(channelsPage.page.locator('#channelHeaderTitle')).toContainText(/off-topic/i);",
|
|
499
|
+
...end,
|
|
500
|
+
].join('\n');
|
|
501
|
+
}
|
|
502
|
+
if (strategy === 'markdown-post') {
|
|
503
|
+
return [
|
|
504
|
+
...header,
|
|
505
|
+
...start,
|
|
506
|
+
` const message = '**ai-${slug}-bold** _italic_';`,
|
|
507
|
+
' await channelsPage.postMessage(message);',
|
|
508
|
+
' const lastPost = await channelsPage.getLastPost();',
|
|
509
|
+
" await expect(lastPost.container.locator('strong')).toBeVisible();",
|
|
510
|
+
...end,
|
|
511
|
+
].join('\n');
|
|
512
|
+
}
|
|
513
|
+
if (strategy === 'mentions-post') {
|
|
514
|
+
return [
|
|
515
|
+
...header,
|
|
516
|
+
...start,
|
|
517
|
+
' const mention = `@${user.username}`;',
|
|
518
|
+
' await channelsPage.postMessage(`Ping ${mention}`);',
|
|
519
|
+
' const lastPost = await channelsPage.getLastPost();',
|
|
520
|
+
' await expect(lastPost.container).toContainText(mention);',
|
|
521
|
+
...end,
|
|
522
|
+
].join('\n');
|
|
523
|
+
}
|
|
524
|
+
if (strategy === 'realtime-post') {
|
|
525
|
+
return [
|
|
526
|
+
...header,
|
|
527
|
+
...start,
|
|
528
|
+
` const message = \`ai-${slug}-realtime-\${Date.now()}\`;`,
|
|
529
|
+
' await channelsPage.postMessage(message);',
|
|
530
|
+
' const lastPost = await channelsPage.getLastPost();',
|
|
531
|
+
' await expect(lastPost.container).toContainText(message);',
|
|
532
|
+
" await expect(channelsPage.page.locator('#channel_view')).toBeVisible();",
|
|
137
533
|
...end,
|
|
138
534
|
].join('\n');
|
|
139
535
|
}
|
|
@@ -160,11 +556,12 @@ function createNativePlaywrightSpec(flow, slug, strategy) {
|
|
|
160
556
|
return [
|
|
161
557
|
...header,
|
|
162
558
|
...start,
|
|
163
|
-
` const searchTerm =
|
|
164
|
-
|
|
165
|
-
' await channelsPage.
|
|
166
|
-
|
|
167
|
-
|
|
559
|
+
` const searchTerm = \`ai-${slug}-\${Date.now().toString().slice(-6)}\`;`,
|
|
560
|
+
' await channelsPage.postMessage(searchTerm);',
|
|
561
|
+
' await channelsPage.globalHeader.openSearch();',
|
|
562
|
+
' await channelsPage.searchBox.searchInput.fill(searchTerm);',
|
|
563
|
+
" await channelsPage.page.keyboard.press('Enter');",
|
|
564
|
+
" await expect(channelsPage.page.locator('#searchContainer')).toBeVisible();",
|
|
168
565
|
...end,
|
|
169
566
|
].join('\n');
|
|
170
567
|
}
|
|
@@ -195,11 +592,11 @@ function summarizeCommandOutput(stdout, stderr) {
|
|
|
195
592
|
const lines = combined.split('\n').slice(-20);
|
|
196
593
|
return lines.join('\n').slice(0, 2000);
|
|
197
594
|
}
|
|
198
|
-
function runCommand(command, args, cwd) {
|
|
595
|
+
function runCommand(command, args, cwd, timeoutMs = 60 * 60 * 1000) {
|
|
199
596
|
const result = (0, child_process_1.spawnSync)(command, args, {
|
|
200
597
|
cwd,
|
|
201
598
|
encoding: 'utf-8',
|
|
202
|
-
timeout:
|
|
599
|
+
timeout: timeoutMs,
|
|
203
600
|
stdio: 'pipe',
|
|
204
601
|
});
|
|
205
602
|
return {
|
|
@@ -209,6 +606,37 @@ function runCommand(command, args, cwd) {
|
|
|
209
606
|
error: result.error ? result.error.message : undefined,
|
|
210
607
|
};
|
|
211
608
|
}
|
|
609
|
+
function runPlaywrightRuntimeValidation(testsRoot, testFile, pipeline, playwrightBinary) {
|
|
610
|
+
if (!playwrightBinary) {
|
|
611
|
+
return {
|
|
612
|
+
status: 'failed',
|
|
613
|
+
detail: 'Playwright binary not found; cannot execute runtime validation.',
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
const relativeSpecPath = (0, utils_js_1.normalizePath)((0, path_1.relative)(testsRoot, testFile));
|
|
617
|
+
if (relativeSpecPath.startsWith('../') || relativeSpecPath.startsWith('..\\')) {
|
|
618
|
+
return {
|
|
619
|
+
status: 'failed',
|
|
620
|
+
detail: 'Generated spec path resolved outside testsRoot during runtime validation.',
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const args = ['test', relativeSpecPath, '--workers', '1', '--retries', '0', '--max-failures', '1', '--reporter', 'line'];
|
|
624
|
+
if (pipeline.headless === false) {
|
|
625
|
+
args.push('--headed');
|
|
626
|
+
}
|
|
627
|
+
if (pipeline.project) {
|
|
628
|
+
args.push('--project', pipeline.project);
|
|
629
|
+
}
|
|
630
|
+
const commandResult = runCommand(playwrightBinary, args, testsRoot, 10 * 60 * 1000);
|
|
631
|
+
if (commandResult.status === 0) {
|
|
632
|
+
return { status: 'passed' };
|
|
633
|
+
}
|
|
634
|
+
const summary = summarizeCommandOutput(commandResult.stdout, commandResult.stderr);
|
|
635
|
+
return {
|
|
636
|
+
status: 'failed',
|
|
637
|
+
detail: summary || commandResult.error || `playwright test failed with status ${commandResult.status}`,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
212
640
|
function runPlaywrightListValidation(testsRoot, testFile, pipeline, playwrightBinary) {
|
|
213
641
|
if (!playwrightBinary) {
|
|
214
642
|
return {
|
|
@@ -224,6 +652,9 @@ function runPlaywrightListValidation(testsRoot, testFile, pipeline, playwrightBi
|
|
|
224
652
|
};
|
|
225
653
|
}
|
|
226
654
|
const args = ['test', '--list', relativeSpecPath];
|
|
655
|
+
if (pipeline.headless === false) {
|
|
656
|
+
args.push('--headed');
|
|
657
|
+
}
|
|
227
658
|
if (pipeline.project) {
|
|
228
659
|
args.push('--project', pipeline.project);
|
|
229
660
|
}
|
|
@@ -243,7 +674,7 @@ function runPlaywrightListValidation(testsRoot, testFile, pipeline, playwrightBi
|
|
|
243
674
|
detail: summary || commandResult.error || `playwright --list failed with status ${commandResult.status}`,
|
|
244
675
|
};
|
|
245
676
|
}
|
|
246
|
-
function runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, playwrightBinary) {
|
|
677
|
+
function runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, playwrightBinary, apiSurface) {
|
|
247
678
|
const flowId = flow.id;
|
|
248
679
|
const flowName = flow.name;
|
|
249
680
|
const existingFile = (0, fs_1.existsSync)(testFile);
|
|
@@ -284,7 +715,7 @@ function runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, pl
|
|
|
284
715
|
wroteNewFile = true;
|
|
285
716
|
}
|
|
286
717
|
const currentContent = candidate.write ? candidate.content : (originalContent || '');
|
|
287
|
-
const qualityIssues = validateGeneratedSpecContent(currentContent);
|
|
718
|
+
const qualityIssues = validateGeneratedSpecContent(currentContent, apiSurface);
|
|
288
719
|
if (qualityIssues.length > 0) {
|
|
289
720
|
attempts.push(`${candidate.label}: ${qualityIssues.map((issue) => issue.message).join(' ')}`);
|
|
290
721
|
if (pipeline.heal && i < candidates.length - 1) {
|
|
@@ -353,10 +784,10 @@ function runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, pl
|
|
|
353
784
|
}
|
|
354
785
|
function runPackageNativePipeline(testsRoot, flows, pipeline, baseWarnings = []) {
|
|
355
786
|
const warningSet = new Set(baseWarnings);
|
|
356
|
-
|
|
357
|
-
warningSet.add('Package-native pipeline does not run Playwright MCP directly. Use follow-up heal workflows if MCP is required.');
|
|
358
|
-
}
|
|
787
|
+
const mcp = createMcpStatus('package-native', Boolean(pipeline.mcp));
|
|
359
788
|
const playwrightBinary = pipeline.heal ? resolvePlaywrightBinary(testsRoot) : null;
|
|
789
|
+
const seedFile = resolveAgentSeedSpec(testsRoot) || 'specs/seed.spec.ts';
|
|
790
|
+
const apiSurface = buildApiSurfaceCatalog(testsRoot, seedFile);
|
|
360
791
|
if (pipeline.heal && !playwrightBinary) {
|
|
361
792
|
warningSet.add('Playwright binary was not found. Heal uses static quality checks without runtime compile validation.');
|
|
362
793
|
}
|
|
@@ -364,7 +795,7 @@ function runPackageNativePipeline(testsRoot, flows, pipeline, baseWarnings = [])
|
|
|
364
795
|
const outputBase = (0, path_1.resolve)(testsRoot, pipeline.outputDir || 'specs/functional/ai-assisted');
|
|
365
796
|
if (!(0, utils_js_1.isPathWithinRoot)(testsRoot, outputBase)) {
|
|
366
797
|
warningSet.add(`Pipeline outputDir resolves outside testsRoot and was blocked: ${pipeline.outputDir}`);
|
|
367
|
-
return { runner: 'unknown', results, warnings: Array.from(warningSet) };
|
|
798
|
+
return { runner: 'unknown', results, warnings: Array.from(warningSet), mcp: createMcpStatus('unknown', Boolean(pipeline.mcp)) };
|
|
368
799
|
}
|
|
369
800
|
for (const flow of flows) {
|
|
370
801
|
if (flow.priority !== 'P0' && flow.priority !== 'P1') {
|
|
@@ -403,22 +834,26 @@ function runPackageNativePipeline(testsRoot, flows, pipeline, baseWarnings = [])
|
|
|
403
834
|
});
|
|
404
835
|
continue;
|
|
405
836
|
}
|
|
406
|
-
results.push(runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, playwrightBinary));
|
|
837
|
+
results.push(runPackageNativeFlow(testsRoot, flow, pipeline, outputDir, testFile, playwrightBinary, apiSurface));
|
|
407
838
|
}
|
|
408
|
-
return { runner: 'package-native', results, warnings: Array.from(warningSet) };
|
|
839
|
+
return { runner: 'package-native', results, warnings: Array.from(warningSet), mcp };
|
|
409
840
|
}
|
|
410
841
|
function runTargetedSpecHeal(testsRoot, targets, pipeline) {
|
|
411
842
|
const warnings = new Set();
|
|
412
843
|
const results = [];
|
|
844
|
+
const mcp = createMcpStatus('package-native', Boolean(pipeline.mcp));
|
|
413
845
|
if (targets.length === 0) {
|
|
414
846
|
warnings.add('No targeted specs provided for heal.');
|
|
415
|
-
return {
|
|
847
|
+
return finalizePipelineSummary({
|
|
416
848
|
runner: 'package-native',
|
|
417
849
|
results,
|
|
418
850
|
warnings: Array.from(warnings),
|
|
419
|
-
|
|
851
|
+
mcp,
|
|
852
|
+
});
|
|
420
853
|
}
|
|
421
854
|
const playwrightBinary = pipeline.heal ? resolvePlaywrightBinary(testsRoot) : null;
|
|
855
|
+
const seedFile = resolveAgentSeedSpec(testsRoot) || 'specs/seed.spec.ts';
|
|
856
|
+
const apiSurface = buildApiSurfaceCatalog(testsRoot, seedFile);
|
|
422
857
|
if (pipeline.heal && !playwrightBinary) {
|
|
423
858
|
warnings.add('Playwright binary was not found. Targeted heal uses static quality checks without runtime compile validation.');
|
|
424
859
|
}
|
|
@@ -470,13 +905,14 @@ function runTargetedSpecHeal(testsRoot, targets, pipeline) {
|
|
|
470
905
|
continue;
|
|
471
906
|
}
|
|
472
907
|
const syntheticFlow = buildSyntheticFlowFromSpecTarget(relativeSpecPath, target);
|
|
473
|
-
results.push(runPackageNativeFlow(testsRoot, syntheticFlow, pipeline, (0, utils_js_1.normalizePath)((0, path_1.dirname)(absoluteSpecPath)), absoluteSpecPath, playwrightBinary));
|
|
908
|
+
results.push(runPackageNativeFlow(testsRoot, syntheticFlow, pipeline, (0, utils_js_1.normalizePath)((0, path_1.dirname)(absoluteSpecPath)), absoluteSpecPath, playwrightBinary, apiSurface));
|
|
474
909
|
}
|
|
475
|
-
return {
|
|
910
|
+
return finalizePipelineSummary({
|
|
476
911
|
runner: 'package-native',
|
|
477
912
|
results,
|
|
478
913
|
warnings: Array.from(warnings),
|
|
479
|
-
|
|
914
|
+
mcp,
|
|
915
|
+
});
|
|
480
916
|
}
|
|
481
917
|
function findSpecFiles(root) {
|
|
482
918
|
if (!(0, fs_1.existsSync)(root)) {
|
|
@@ -499,17 +935,387 @@ function findDisallowedDescribeFiles(root) {
|
|
|
499
935
|
const files = findSpecFiles(root);
|
|
500
936
|
return files.filter((file) => /\btest\.describe\s*\(/.test((0, fs_1.readFileSync)(file, 'utf-8')));
|
|
501
937
|
}
|
|
938
|
+
function hasCommand(command, cwd) {
|
|
939
|
+
const result = runCommand(command, ['--version'], cwd);
|
|
940
|
+
return result.status === 0;
|
|
941
|
+
}
|
|
942
|
+
function hasPlaywrightAgentDefinitions(testsRoot) {
|
|
943
|
+
const required = [
|
|
944
|
+
'.mcp.json',
|
|
945
|
+
'.claude/agents/playwright-test-planner.md',
|
|
946
|
+
'.claude/agents/playwright-test-generator.md',
|
|
947
|
+
'.claude/agents/playwright-test-healer.md',
|
|
948
|
+
];
|
|
949
|
+
return required.every((path) => (0, fs_1.existsSync)((0, path_1.join)(testsRoot, path)));
|
|
950
|
+
}
|
|
951
|
+
function hasPlaywrightConfig(testsRoot) {
|
|
952
|
+
const candidates = [
|
|
953
|
+
'playwright.config.ts',
|
|
954
|
+
'playwright.config.js',
|
|
955
|
+
'playwright.config.mts',
|
|
956
|
+
'playwright.config.mjs',
|
|
957
|
+
'playwright.config.cts',
|
|
958
|
+
'playwright.config.cjs',
|
|
959
|
+
];
|
|
960
|
+
return candidates.some((candidate) => (0, fs_1.existsSync)((0, path_1.join)(testsRoot, candidate)));
|
|
961
|
+
}
|
|
962
|
+
function bootstrapPlaywrightAgentDefinitions(testsRoot, pipeline) {
|
|
963
|
+
const args = ['playwright', 'init-agents', '--loop=claude', '--prompts'];
|
|
964
|
+
if (pipeline.project) {
|
|
965
|
+
args.push('--project', pipeline.project);
|
|
966
|
+
}
|
|
967
|
+
return runCommand('npx', args, testsRoot);
|
|
968
|
+
}
|
|
969
|
+
function resolveAgentSeedSpec(testsRoot) {
|
|
970
|
+
const preferred = (0, path_1.join)(testsRoot, 'specs', 'seed.spec.ts');
|
|
971
|
+
const specsRoot = (0, path_1.join)(testsRoot, 'specs');
|
|
972
|
+
const specFiles = findSpecFiles(specsRoot).filter((file) => !(0, utils_js_1.normalizePath)(file).includes('/functional/ai-assisted/'));
|
|
973
|
+
const scored = specFiles
|
|
974
|
+
.map((file) => {
|
|
975
|
+
const rel = (0, utils_js_1.normalizePath)((0, path_1.relative)(testsRoot, file));
|
|
976
|
+
const content = (0, fs_1.readFileSync)(file, 'utf-8');
|
|
977
|
+
let score = 0;
|
|
978
|
+
if (rel.endsWith('/seed.spec.ts')) {
|
|
979
|
+
// Generated default seed from init-agents is often a placeholder; prefer real tests.
|
|
980
|
+
if (!/generate code here/i.test(content)) {
|
|
981
|
+
score += 2;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (content.includes('@mattermost/playwright-lib')) {
|
|
985
|
+
score += 8;
|
|
986
|
+
}
|
|
987
|
+
if (content.includes('pw.initSetup(')) {
|
|
988
|
+
score += 6;
|
|
989
|
+
}
|
|
990
|
+
if (content.includes('testBrowser.login(')) {
|
|
991
|
+
score += 4;
|
|
992
|
+
}
|
|
993
|
+
if (content.includes('channelsPage')) {
|
|
994
|
+
score += 2;
|
|
995
|
+
}
|
|
996
|
+
if (rel.includes('/functional/channels/')) {
|
|
997
|
+
score += 1;
|
|
998
|
+
}
|
|
999
|
+
return { rel, score };
|
|
1000
|
+
})
|
|
1001
|
+
.sort((a, b) => b.score - a.score);
|
|
1002
|
+
if (scored.length > 0 && scored[0].score > 0) {
|
|
1003
|
+
return scored[0].rel;
|
|
1004
|
+
}
|
|
1005
|
+
if ((0, fs_1.existsSync)(preferred)) {
|
|
1006
|
+
return (0, utils_js_1.normalizePath)((0, path_1.relative)(testsRoot, preferred));
|
|
1007
|
+
}
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
function buildPlaywrightAgentsPrompt(flow, seedFile, planFile, testFile, includeHealer) {
|
|
1011
|
+
const linkedFiles = firstFlowFiles(flow).join(', ') || 'N/A';
|
|
1012
|
+
const reasons = (flow.reasons || []).slice(0, 5).join(' | ') || 'N/A';
|
|
1013
|
+
return [
|
|
1014
|
+
'Use official Playwright Test agents (planner, generator, healer) to implement exactly one high-quality test for this flow.',
|
|
1015
|
+
'',
|
|
1016
|
+
`Flow ID: ${flow.id}`,
|
|
1017
|
+
`Flow Name: ${flow.name}`,
|
|
1018
|
+
`Priority: ${flow.priority}`,
|
|
1019
|
+
`Linked files: ${linkedFiles}`,
|
|
1020
|
+
`Risk reasons: ${reasons}`,
|
|
1021
|
+
'',
|
|
1022
|
+
'Workflow requirements:',
|
|
1023
|
+
'1) Use #playwright-test-planner to explore and save a focused test plan.',
|
|
1024
|
+
'2) Use #playwright-test-generator to generate one test from that plan.',
|
|
1025
|
+
includeHealer
|
|
1026
|
+
? '3) Use #playwright-test-healer to run and fix that generated test.'
|
|
1027
|
+
: '3) Skip runtime healing and focus on producing compile-ready test code.',
|
|
1028
|
+
'',
|
|
1029
|
+
`Seed file: ${seedFile}`,
|
|
1030
|
+
`Plan file to save: ${planFile}`,
|
|
1031
|
+
`Generated test file path (must be exact): ${testFile}`,
|
|
1032
|
+
'',
|
|
1033
|
+
'Quality constraints (must follow):',
|
|
1034
|
+
'- The generated file must contain a standalone test() and must not use test.describe or test.only.',
|
|
1035
|
+
'- Do not mark the test with test.fixme unless user explicitly requests skipping.',
|
|
1036
|
+
"- The generated test must include a single tag string '@ai-assisted'.",
|
|
1037
|
+
'- Match fixture/import style from the seed file. Prefer existing page-object APIs over raw brittle selectors.',
|
|
1038
|
+
'- Only use `pw` and page-object methods that already exist in the seed/current specs (for example, do not invent APIs like `pw.mainClient.*`).',
|
|
1039
|
+
'- Keep the scenario strictly aligned to the flow and linked files, not broad unrelated flows.',
|
|
1040
|
+
'',
|
|
1041
|
+
'At the end, return a short summary that includes the generated test file path and whether healing succeeded.',
|
|
1042
|
+
].join('\n');
|
|
1043
|
+
}
|
|
1044
|
+
function buildPlaywrightHealerPrompt(testFile, extra) {
|
|
1045
|
+
const lines = [
|
|
1046
|
+
'Heal this specific Playwright test file and keep edits minimal.',
|
|
1047
|
+
`Target test file: ${testFile}`,
|
|
1048
|
+
'Constraints:',
|
|
1049
|
+
'- Do not use test.describe or test.only.',
|
|
1050
|
+
"- Keep a single tag string '@ai-assisted'.",
|
|
1051
|
+
'- Use only existing Mattermost Playwright fixture/page-object APIs; do not invent new `pw.*` clients or methods.',
|
|
1052
|
+
'- Keep the test intent unchanged and focused.',
|
|
1053
|
+
'',
|
|
1054
|
+
'Run and fix this test until it compiles/passes, or mark test.fixme with a clear comment when behavior is truly broken.',
|
|
1055
|
+
];
|
|
1056
|
+
if (extra) {
|
|
1057
|
+
lines.push('', `Context: ${extra}`);
|
|
1058
|
+
}
|
|
1059
|
+
return lines.join('\n');
|
|
1060
|
+
}
|
|
1061
|
+
function runPlaywrightAgentsFlow(testsRoot, flow, pipeline, outputDir, preferredTestFile, seedFile, apiSurface, playwrightBinary) {
|
|
1062
|
+
(0, fs_1.mkdirSync)(outputDir, { recursive: true });
|
|
1063
|
+
const slug = toSafeSlug(flow.id);
|
|
1064
|
+
const planFile = (0, utils_js_1.normalizePath)((0, path_1.relative)(testsRoot, (0, path_1.join)(outputDir, `${slug}.plan.md`)));
|
|
1065
|
+
const targetTestFile = (0, utils_js_1.normalizePath)((0, path_1.relative)(testsRoot, preferredTestFile));
|
|
1066
|
+
if (pipeline.dryRun) {
|
|
1067
|
+
return {
|
|
1068
|
+
flowId: flow.id,
|
|
1069
|
+
flowName: flow.name,
|
|
1070
|
+
generatedDir: outputDir,
|
|
1071
|
+
generateStatus: 'skipped',
|
|
1072
|
+
healStatus: pipeline.heal ? 'skipped' : undefined,
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
const prompt = buildPlaywrightAgentsPrompt(flow, seedFile, planFile, targetTestFile, Boolean(pipeline.heal));
|
|
1076
|
+
const runArgs = [
|
|
1077
|
+
'-p',
|
|
1078
|
+
'--permission-mode',
|
|
1079
|
+
'bypassPermissions',
|
|
1080
|
+
'--mcp-config',
|
|
1081
|
+
'.mcp.json',
|
|
1082
|
+
'--add-dir',
|
|
1083
|
+
testsRoot,
|
|
1084
|
+
'--',
|
|
1085
|
+
prompt,
|
|
1086
|
+
];
|
|
1087
|
+
const runResult = runCommand('claude', runArgs, testsRoot);
|
|
1088
|
+
if (runResult.status !== 0) {
|
|
1089
|
+
return {
|
|
1090
|
+
flowId: flow.id,
|
|
1091
|
+
flowName: flow.name,
|
|
1092
|
+
generatedDir: outputDir,
|
|
1093
|
+
generateStatus: 'failed',
|
|
1094
|
+
healStatus: pipeline.heal ? 'failed' : undefined,
|
|
1095
|
+
error: summarizeCommandOutput(runResult.stdout, runResult.stderr) || runResult.error || 'Playwright agents run failed',
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
let actualTestFile = preferredTestFile;
|
|
1099
|
+
if (!(0, fs_1.existsSync)(actualTestFile)) {
|
|
1100
|
+
const candidates = findSpecFiles(outputDir);
|
|
1101
|
+
if (candidates.length === 1) {
|
|
1102
|
+
actualTestFile = candidates[0];
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (!(0, fs_1.existsSync)(actualTestFile)) {
|
|
1106
|
+
return {
|
|
1107
|
+
flowId: flow.id,
|
|
1108
|
+
flowName: flow.name,
|
|
1109
|
+
generatedDir: outputDir,
|
|
1110
|
+
generateStatus: 'failed',
|
|
1111
|
+
healStatus: pipeline.heal ? 'failed' : undefined,
|
|
1112
|
+
error: `Playwright agents did not produce expected test file: ${targetTestFile}`,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
const relativeActualTestFile = (0, utils_js_1.normalizePath)((0, path_1.relative)(testsRoot, actualTestFile));
|
|
1116
|
+
let qualityIssues = validateGeneratedSpecContent((0, fs_1.readFileSync)(actualTestFile, 'utf-8'), apiSurface);
|
|
1117
|
+
if (qualityIssues.length > 0 && pipeline.heal) {
|
|
1118
|
+
const healResult = runCommand('claude', [
|
|
1119
|
+
'-p',
|
|
1120
|
+
'--permission-mode',
|
|
1121
|
+
'bypassPermissions',
|
|
1122
|
+
'--agent',
|
|
1123
|
+
'playwright-test-healer',
|
|
1124
|
+
'--mcp-config',
|
|
1125
|
+
'.mcp.json',
|
|
1126
|
+
'--add-dir',
|
|
1127
|
+
testsRoot,
|
|
1128
|
+
'--',
|
|
1129
|
+
buildPlaywrightHealerPrompt(relativeActualTestFile, qualityIssues.map((issue) => issue.message).join(' | ')),
|
|
1130
|
+
], testsRoot);
|
|
1131
|
+
if (healResult.status === 0 && (0, fs_1.existsSync)(actualTestFile)) {
|
|
1132
|
+
qualityIssues = validateGeneratedSpecContent((0, fs_1.readFileSync)(actualTestFile, 'utf-8'), apiSurface);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (qualityIssues.length > 0) {
|
|
1136
|
+
return {
|
|
1137
|
+
flowId: flow.id,
|
|
1138
|
+
flowName: flow.name,
|
|
1139
|
+
generatedDir: outputDir,
|
|
1140
|
+
generateStatus: 'failed',
|
|
1141
|
+
healStatus: pipeline.heal ? 'failed' : undefined,
|
|
1142
|
+
error: `Playwright agents produced invalid test content: ${qualityIssues.map((issue) => issue.message).join(' | ')}`,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
if (pipeline.heal) {
|
|
1146
|
+
let compileValidation = runPlaywrightListValidation(testsRoot, actualTestFile, pipeline, playwrightBinary);
|
|
1147
|
+
if (compileValidation.status === 'failed') {
|
|
1148
|
+
const healResult = runCommand('claude', [
|
|
1149
|
+
'-p',
|
|
1150
|
+
'--permission-mode',
|
|
1151
|
+
'bypassPermissions',
|
|
1152
|
+
'--agent',
|
|
1153
|
+
'playwright-test-healer',
|
|
1154
|
+
'--mcp-config',
|
|
1155
|
+
'.mcp.json',
|
|
1156
|
+
'--add-dir',
|
|
1157
|
+
testsRoot,
|
|
1158
|
+
'--',
|
|
1159
|
+
buildPlaywrightHealerPrompt(relativeActualTestFile, compileValidation.detail || 'playwright --list failed'),
|
|
1160
|
+
], testsRoot);
|
|
1161
|
+
if (healResult.status === 0 && (0, fs_1.existsSync)(actualTestFile)) {
|
|
1162
|
+
compileValidation = runPlaywrightListValidation(testsRoot, actualTestFile, pipeline, playwrightBinary);
|
|
1163
|
+
}
|
|
1164
|
+
if (compileValidation.status === 'failed') {
|
|
1165
|
+
return {
|
|
1166
|
+
flowId: flow.id,
|
|
1167
|
+
flowName: flow.name,
|
|
1168
|
+
generatedDir: outputDir,
|
|
1169
|
+
generateStatus: 'failed',
|
|
1170
|
+
healStatus: 'failed',
|
|
1171
|
+
error: `Playwright agents compile validation failed: ${compileValidation.detail || 'playwright --list failed'}`,
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
let runtimeValidation = runPlaywrightRuntimeValidation(testsRoot, actualTestFile, pipeline, playwrightBinary);
|
|
1176
|
+
if (runtimeValidation.status === 'failed') {
|
|
1177
|
+
const healResult = runCommand('claude', [
|
|
1178
|
+
'-p',
|
|
1179
|
+
'--permission-mode',
|
|
1180
|
+
'bypassPermissions',
|
|
1181
|
+
'--agent',
|
|
1182
|
+
'playwright-test-healer',
|
|
1183
|
+
'--mcp-config',
|
|
1184
|
+
'.mcp.json',
|
|
1185
|
+
'--add-dir',
|
|
1186
|
+
testsRoot,
|
|
1187
|
+
'--',
|
|
1188
|
+
buildPlaywrightHealerPrompt(relativeActualTestFile, runtimeValidation.detail || 'playwright runtime failed'),
|
|
1189
|
+
], testsRoot);
|
|
1190
|
+
if (healResult.status === 0 && (0, fs_1.existsSync)(actualTestFile)) {
|
|
1191
|
+
runtimeValidation = runPlaywrightRuntimeValidation(testsRoot, actualTestFile, pipeline, playwrightBinary);
|
|
1192
|
+
}
|
|
1193
|
+
if (runtimeValidation.status === 'failed') {
|
|
1194
|
+
return {
|
|
1195
|
+
flowId: flow.id,
|
|
1196
|
+
flowName: flow.name,
|
|
1197
|
+
generatedDir: outputDir,
|
|
1198
|
+
generateStatus: 'failed',
|
|
1199
|
+
healStatus: 'failed',
|
|
1200
|
+
error: `Playwright agents runtime validation failed: ${runtimeValidation.detail || 'playwright test failed'}`,
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return {
|
|
1206
|
+
flowId: flow.id,
|
|
1207
|
+
flowName: flow.name,
|
|
1208
|
+
generatedDir: outputDir,
|
|
1209
|
+
generateStatus: 'success',
|
|
1210
|
+
healStatus: pipeline.heal ? 'success' : undefined,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
function runPlaywrightAgentsPipeline(testsRoot, flows, pipeline) {
|
|
1214
|
+
const warnings = [];
|
|
1215
|
+
const results = [];
|
|
1216
|
+
if (!hasCommand('claude', testsRoot)) {
|
|
1217
|
+
warnings.push('Claude CLI is required for official Playwright planner/generator/healer execution but was not found.');
|
|
1218
|
+
return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
|
|
1219
|
+
}
|
|
1220
|
+
if (!hasPlaywrightConfig(testsRoot)) {
|
|
1221
|
+
warnings.push('Playwright config file not found in testsRoot; skipping official Playwright agents backend.');
|
|
1222
|
+
return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
|
|
1223
|
+
}
|
|
1224
|
+
if (!hasPlaywrightAgentDefinitions(testsRoot)) {
|
|
1225
|
+
const bootstrap = bootstrapPlaywrightAgentDefinitions(testsRoot, pipeline);
|
|
1226
|
+
if (bootstrap.status !== 0) {
|
|
1227
|
+
warnings.push(summarizeCommandOutput(bootstrap.stdout, bootstrap.stderr) ||
|
|
1228
|
+
bootstrap.error ||
|
|
1229
|
+
'Failed to initialize Playwright agents via `npx playwright init-agents`.');
|
|
1230
|
+
return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (!hasPlaywrightAgentDefinitions(testsRoot)) {
|
|
1234
|
+
warnings.push('Playwright agent definitions are missing after bootstrap.');
|
|
1235
|
+
return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
|
|
1236
|
+
}
|
|
1237
|
+
const seedFile = resolveAgentSeedSpec(testsRoot);
|
|
1238
|
+
if (!seedFile) {
|
|
1239
|
+
warnings.push('No seed spec file found under specs/. Playwright planner cannot be initialized.');
|
|
1240
|
+
return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
|
|
1241
|
+
}
|
|
1242
|
+
const playwrightBinary = pipeline.heal ? resolvePlaywrightBinary(testsRoot) : null;
|
|
1243
|
+
const apiSurface = buildApiSurfaceCatalog(testsRoot, seedFile);
|
|
1244
|
+
if (pipeline.heal && !playwrightBinary) {
|
|
1245
|
+
warnings.push('Playwright binary was not found. Healer runtime validation may be limited.');
|
|
1246
|
+
}
|
|
1247
|
+
const outputBase = (0, path_1.resolve)(testsRoot, pipeline.outputDir || 'specs/functional/ai-assisted');
|
|
1248
|
+
if (!(0, utils_js_1.isPathWithinRoot)(testsRoot, outputBase)) {
|
|
1249
|
+
warnings.push(`Pipeline outputDir resolves outside testsRoot and was blocked: ${pipeline.outputDir}`);
|
|
1250
|
+
return { runner: 'unknown', results, warnings, mcp: createMcpStatus('unknown', true) };
|
|
1251
|
+
}
|
|
1252
|
+
for (const flow of flows) {
|
|
1253
|
+
if (flow.priority !== 'P0' && flow.priority !== 'P1') {
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
const slug = toSafeSlug(flow.id);
|
|
1257
|
+
const outputDir = (0, utils_js_1.normalizePath)((0, path_1.join)(outputBase, slug));
|
|
1258
|
+
if (!(0, utils_js_1.isPathWithinRoot)(testsRoot, outputDir)) {
|
|
1259
|
+
results.push({
|
|
1260
|
+
flowId: flow.id,
|
|
1261
|
+
flowName: flow.name,
|
|
1262
|
+
generatedDir: outputDir,
|
|
1263
|
+
generateStatus: 'failed',
|
|
1264
|
+
error: 'output directory resolves outside testsRoot',
|
|
1265
|
+
});
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
const testFile = (0, utils_js_1.normalizePath)((0, path_1.join)(outputDir, `${slug}.spec.ts`));
|
|
1269
|
+
if (!(0, utils_js_1.isPathWithinRoot)(testsRoot, testFile)) {
|
|
1270
|
+
results.push({
|
|
1271
|
+
flowId: flow.id,
|
|
1272
|
+
flowName: flow.name,
|
|
1273
|
+
generatedDir: outputDir,
|
|
1274
|
+
generateStatus: 'failed',
|
|
1275
|
+
error: 'generated test path resolves outside testsRoot',
|
|
1276
|
+
});
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
results.push(runPlaywrightAgentsFlow(testsRoot, flow, pipeline, outputDir, testFile, seedFile, apiSurface, playwrightBinary));
|
|
1280
|
+
}
|
|
1281
|
+
return { runner: 'playwright-agents', results, warnings, mcp: createMcpStatus('playwright-agents', true) };
|
|
1282
|
+
}
|
|
502
1283
|
function runPlaywrightPipeline(testsRoot, flows, pipeline) {
|
|
1284
|
+
const mcpFallbackWarnings = [];
|
|
1285
|
+
if (pipeline.mcp) {
|
|
1286
|
+
const agentsSummary = runPlaywrightAgentsPipeline(testsRoot, flows, pipeline);
|
|
1287
|
+
if (agentsSummary.runner !== 'unknown' || agentsSummary.results.length > 0) {
|
|
1288
|
+
return finalizePipelineSummary(agentsSummary);
|
|
1289
|
+
}
|
|
1290
|
+
if (!pipeline.mcpAllowFallback) {
|
|
1291
|
+
const warnings = [
|
|
1292
|
+
...agentsSummary.warnings,
|
|
1293
|
+
'Official Playwright MCP mode is strict; fallback generation is disabled unless pipeline.mcpAllowFallback=true.',
|
|
1294
|
+
];
|
|
1295
|
+
return finalizePipelineSummary({
|
|
1296
|
+
runner: 'unknown',
|
|
1297
|
+
results: agentsSummary.results,
|
|
1298
|
+
warnings,
|
|
1299
|
+
mcp: createMcpStatus('unknown', true),
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
mcpFallbackWarnings.push(...agentsSummary.warnings);
|
|
1303
|
+
}
|
|
503
1304
|
const cliPath = hasE2eTestGenCLI(testsRoot);
|
|
504
1305
|
if (!cliPath) {
|
|
505
|
-
return runPackageNativePipeline(testsRoot, flows, pipeline,
|
|
1306
|
+
return finalizePipelineSummary(runPackageNativePipeline(testsRoot, flows, pipeline, mcpFallbackWarnings));
|
|
506
1307
|
}
|
|
507
|
-
const warnings = [];
|
|
1308
|
+
const warnings = [...mcpFallbackWarnings];
|
|
508
1309
|
const results = [];
|
|
509
1310
|
const outputBase = (0, path_1.resolve)(testsRoot, pipeline.outputDir || 'specs/functional/ai-assisted');
|
|
510
1311
|
if (!(0, utils_js_1.isPathWithinRoot)(testsRoot, outputBase)) {
|
|
511
1312
|
warnings.push(`Pipeline outputDir resolves outside testsRoot and was blocked: ${pipeline.outputDir}`);
|
|
512
|
-
return {
|
|
1313
|
+
return finalizePipelineSummary({
|
|
1314
|
+
runner: 'unknown',
|
|
1315
|
+
results,
|
|
1316
|
+
warnings,
|
|
1317
|
+
mcp: createMcpStatus('unknown', Boolean(pipeline.mcp)),
|
|
1318
|
+
});
|
|
513
1319
|
}
|
|
514
1320
|
for (const flow of flows) {
|
|
515
1321
|
if (flow.priority !== 'P0' && flow.priority !== 'P1') {
|
|
@@ -605,5 +1411,10 @@ function runPlaywrightPipeline(testsRoot, flows, pipeline) {
|
|
|
605
1411
|
healStatus,
|
|
606
1412
|
});
|
|
607
1413
|
}
|
|
608
|
-
return {
|
|
1414
|
+
return finalizePipelineSummary({
|
|
1415
|
+
runner: 'e2e-test-gen',
|
|
1416
|
+
results,
|
|
1417
|
+
warnings,
|
|
1418
|
+
mcp: createMcpStatus('e2e-test-gen', Boolean(pipeline.mcp)),
|
|
1419
|
+
});
|
|
609
1420
|
}
|