playwright-intelligence-engine 2.0.0 → 2.0.2

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 (48) hide show
  1. package/README.md +229 -229
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/core/version.d.ts +3 -3
  4. package/dist/index.js +81 -24
  5. package/package.json +76 -76
  6. package/src/authoring/author.ts +81 -81
  7. package/src/authoring/generation-contract.ts +283 -283
  8. package/src/authoring/hybrid-author.ts +115 -115
  9. package/src/authoring/index.ts +7 -7
  10. package/src/authoring/llm-author.ts +282 -282
  11. package/src/authoring/naming/name-plan.ts +28 -28
  12. package/src/authoring/naming/name-planner.ts +339 -339
  13. package/src/authoring/naming/naming-linter.ts +292 -292
  14. package/src/cli/index.ts +1238 -1172
  15. package/src/collectors/git-diff-collector.ts +190 -190
  16. package/src/core/version.ts +25 -25
  17. package/src/diagnostics/diagnostic-report.ts +204 -204
  18. package/src/diagnostics/failure-classifier.ts +701 -701
  19. package/src/evaluation/repository-runner.ts +197 -197
  20. package/src/gate/quality.ts +101 -101
  21. package/src/generator/architecture-generator.ts +582 -582
  22. package/src/generator/assertion-quality-gate.ts +88 -88
  23. package/src/generator/candidate-writer.ts +288 -288
  24. package/src/generator/test-generator.ts +52 -52
  25. package/src/index.ts +107 -107
  26. package/src/intelligence/evidence-bundle.ts +300 -300
  27. package/src/intelligence/generation-agent.ts +561 -561
  28. package/src/intelligence/generation-verifier.ts +1115 -1115
  29. package/src/intelligence/locator-disambiguation-engine.ts +331 -331
  30. package/src/intelligence/project-intelligence.ts +140 -140
  31. package/src/intelligence/repository-profile.ts +796 -796
  32. package/src/intelligence/trace-proposal-workflow.ts +318 -318
  33. package/src/intelligence/workflow-planner.ts +966 -966
  34. package/src/mcp/server.ts +970 -970
  35. package/src/perception/app-model.ts +32 -32
  36. package/src/perception/crawler.ts +263 -263
  37. package/src/perception/index.ts +3 -3
  38. package/src/repair/assertion-hasher.ts +386 -386
  39. package/src/repair/diff-allowlist.ts +612 -612
  40. package/src/repair/intent-validator.ts +761 -761
  41. package/src/repair/repair-planner.ts +467 -467
  42. package/src/repair/statement-patcher.ts +544 -544
  43. package/src/reproduce/interactive-workflow-runner.ts +828 -828
  44. package/src/sandbox/execution-runner.ts +408 -408
  45. package/src/selector/abstention-policy.ts +175 -175
  46. package/src/selector/candidate-extractor.ts +343 -343
  47. package/src/selector/repair-candidate-ranker.ts +92 -92
  48. package/src/selector/selector-scorer.ts +344 -344
@@ -1,582 +1,582 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import type { GeneratedStep } from './test-generator.js';
4
- import type { RepositoryConventionProfile } from '../intelligence/repository-profile.js';
5
- import { NamePlanner, NamingLinter, type NamePlan, type NamingLintReport } from '../authoring/naming/index.js';
6
- import type { AppModel } from '../perception/app-model.js';
7
- import { AssertionQualityGate } from './assertion-quality-gate.js';
8
-
9
- export type ArchitectureMode = 'greenfield' | 'brownfield';
10
- export type ArchitecturePlanStatus = 'READY' | 'PARTIAL' | 'ABSTAIN' | 'NEEDS_INPUT';
11
-
12
- export interface ArchitectureGenerationInput {
13
- featureName: string;
14
- mode: ArchitectureMode;
15
- baseUrl?: string | undefined;
16
- requirements?: string | undefined;
17
- steps?: GeneratedStep[] | undefined;
18
- profile?: RepositoryConventionProfile | undefined;
19
- existingFiles?: string[] | undefined;
20
- includeCi?: boolean | undefined;
21
- namePlan?: NamePlan | undefined;
22
- appModel?: AppModel | undefined;
23
- }
24
-
25
- export interface ArchitectureFile {
26
- relativePath: string;
27
- content: string;
28
- purpose: string;
29
- action: 'CREATE' | 'MODIFY' | 'SKIP_EXISTING';
30
- }
31
-
32
- export interface ArchitecturePlan {
33
- mode: ArchitectureMode;
34
- status: ArchitecturePlanStatus;
35
- featureName: string;
36
- slug: string;
37
- conventions: {
38
- testDirectory: string;
39
- pageObjectDirectory: string;
40
- fixtureDirectory: string;
41
- preferredLocator: string;
42
- usesCustomFixtures: boolean;
43
- usesStorageState: boolean;
44
- };
45
- assumptions: string[];
46
- warnings: string[];
47
- files: ArchitectureFile[];
48
- namePlan?: NamePlan | undefined;
49
- namingLintReport?: NamingLintReport | undefined;
50
- }
51
-
52
- export interface ArchitectureWriteResult {
53
- written: string[];
54
- skipped: string[];
55
- modified?: string[] | undefined;
56
- }
57
-
58
- function quote(value: string): string {
59
- return JSON.stringify(value);
60
- }
61
-
62
- export function toSlug(value: string): string {
63
- return value
64
- .trim()
65
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
66
- .replace(/[^a-zA-Z0-9]+/g, '-')
67
- .replace(/^-+|-+$/g, '')
68
- .toLowerCase() || 'feature';
69
- }
70
-
71
- export function toClassName(value: string): string {
72
- const words = value
73
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
74
- .match(/[A-Za-z0-9]+/g) ?? ['Feature'];
75
- const className = words
76
- .map((word) => word[0]!.toUpperCase() + word.slice(1).toLowerCase())
77
- .join('');
78
- return /^[A-Za-z_]/.test(className) ? className : `Feature${className}`;
79
- }
80
-
81
- function normalizeRelativeDirectory(value: string | undefined, fallback: string): string {
82
- const normalized = (value ?? fallback).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
83
- if (!normalized || normalized === '.') return fallback;
84
- if (normalized.startsWith('/') || normalized.split('/').includes('..')) return fallback;
85
- return normalized;
86
- }
87
-
88
- export function inferBehavioralSteps(featureName: string, appModel?: AppModel): GeneratedStep[] | undefined {
89
- if (!appModel || !appModel.routes || appModel.routes.length === 0) return undefined;
90
-
91
- const targetSlug = toSlug(featureName);
92
- let route = appModel.routes.find((r) =>
93
- r.route.toLowerCase().includes(targetSlug) || (r.title && r.title.toLowerCase().includes(featureName.toLowerCase()))
94
- );
95
- if (!route) {
96
- route = appModel.routes.find((r) => r.elements && r.elements.length > 0) ?? appModel.routes[0];
97
- }
98
- if (!route || !route.elements || route.elements.length === 0) return undefined;
99
-
100
- const elements = route.elements;
101
- const inputEl = elements.find((el) =>
102
- (el.tag === 'input' && el.type !== 'checkbox' && el.type !== 'radio' && el.type !== 'submit' && el.type !== 'button') ||
103
- el.tag === 'textarea' ||
104
- Boolean(el.placeholder) ||
105
- (el.role === 'textbox' && el.type !== 'checkbox')
106
- );
107
-
108
- const submitBtn = elements.find((el) =>
109
- (el.tag === 'button' || el.role === 'button' || el.type === 'submit') &&
110
- el.accessibleName &&
111
- !/delete|remove|close|cancel/i.test(el.accessibleName)
112
- );
113
-
114
- const testValue = `Test ${featureName.replace(/Page$/, '')}`;
115
-
116
- if (inputEl) {
117
- const steps: GeneratedStep[] = [
118
- { action: 'navigate' },
119
- {
120
- action: 'fill',
121
- role: inputEl.role ?? 'textbox',
122
- name: inputEl.accessibleName,
123
- placeholder: inputEl.placeholder,
124
- selector: inputEl.testId,
125
- value: testValue,
126
- frame: inputEl.frameSelector,
127
- frameSelector: inputEl.frameSelector,
128
- },
129
- ];
130
-
131
- if (submitBtn) {
132
- steps.push({
133
- action: 'click',
134
- role: 'button',
135
- name: submitBtn.accessibleName,
136
- frame: submitBtn.frameSelector,
137
- frameSelector: submitBtn.frameSelector,
138
- });
139
- } else {
140
- steps.push({
141
- action: 'press',
142
- name: inputEl.accessibleName,
143
- placeholder: inputEl.placeholder,
144
- value: 'Enter',
145
- frame: inputEl.frameSelector,
146
- frameSelector: inputEl.frameSelector,
147
- });
148
- }
149
-
150
- steps.push({
151
- action: 'assert-visible',
152
- expectedText: testValue,
153
- name: testValue,
154
- });
155
-
156
- return steps;
157
- }
158
-
159
- const actionEl = elements.find((el) => (el.tag === 'button' || el.role === 'button' || el.tag === 'a') && el.accessibleName);
160
- if (actionEl) {
161
- return [
162
- { action: 'navigate' },
163
- {
164
- action: 'click',
165
- role: actionEl.role ?? 'button',
166
- name: actionEl.accessibleName,
167
- frame: actionEl.frameSelector,
168
- frameSelector: actionEl.frameSelector,
169
- },
170
- {
171
- action: 'assert-visible',
172
- role: 'heading',
173
- name: featureName,
174
- },
175
- ];
176
- }
177
-
178
- return undefined;
179
- }
180
-
181
- function defaultSteps(input: ArchitectureGenerationInput): GeneratedStep[] {
182
- if (input.steps && input.steps.length > 0) return input.steps;
183
- if (input.appModel) {
184
- const inferred = inferBehavioralSteps(input.featureName, input.appModel);
185
- if (inferred && inferred.length > 0) return inferred;
186
- }
187
- return [
188
- { action: 'navigate' },
189
- { action: 'assert-visible', role: 'heading', name: input.featureName },
190
- ];
191
- }
192
-
193
- export function renderLocator(step: GeneratedStep, preferredLocator: string, frameSelector?: string): string {
194
- let inner: string;
195
- if (step.placeholder) {
196
- inner = `getByPlaceholder('${step.placeholder}')`;
197
- } else if (step.action === 'click' && step.role && step.name) {
198
- inner = `getByRole('${step.role}', { name: '${step.name}' })`;
199
- } else if (step.action === 'fill' && step.name) {
200
- if (preferredLocator === 'getByLabel') inner = `getByLabel('${step.name}')`;
201
- else inner = `getByRole('textbox', { name: '${step.name}' })`;
202
- } else if (step.selector && preferredLocator === 'getByTestId' && /^[A-Za-z0-9_-]+$/.test(step.selector)) {
203
- inner = `getByTestId('${step.selector}')`;
204
- } else {
205
- inner = `locator('${step.selector ?? (step.action === 'fill' ? 'input' : 'button')}')`;
206
- }
207
-
208
- const frame = frameSelector ?? step.frame ?? step.frameSelector;
209
- if (frame) {
210
- return `this.page.frameLocator('${frame}').${inner}`;
211
- }
212
- return `this.page.${inner}`;
213
- }
214
-
215
- function renderPageObject(className: string, baseUrl: string, steps: GeneratedStep[], preferredLocator: string, namePlan: NamePlan): string {
216
- const declarations: string[] = [];
217
- const assignments: string[] = [];
218
- const methods: string[] = [];
219
- const seenProperties = new Set<string>();
220
-
221
- steps.forEach((step, index) => {
222
- if (step.action !== 'click' && step.action !== 'fill' && step.action !== 'press') return;
223
- const symbol = namePlan.symbols.find((s) => s.ref === `step-${index + 1}`) ?? namePlan.symbols[index];
224
- const property = symbol?.property ?? (step.action === 'click' ? `actionButton` : `inputField`);
225
- const method = symbol?.method ?? (step.action === 'click' ? `clickAction` : step.action === 'press' ? `pressKey` : `fillInput`);
226
- const frame = symbol?.frame ?? step.frame ?? step.frameSelector;
227
-
228
- if (!seenProperties.has(property)) {
229
- seenProperties.add(property);
230
- declarations.push(` readonly ${property}: Locator;`);
231
- assignments.push(` this.${property} = ${renderLocator(step, preferredLocator, frame)};`);
232
- }
233
-
234
- if (step.action === 'click') {
235
- methods.push(` async ${method}(): Promise<void> {\n await this.${property}.click();\n }`);
236
- } else if (step.action === 'press') {
237
- methods.push(` async ${method}(key = ${quote(step.value ?? 'Enter')}): Promise<void> {\n await this.${property}.press(key);\n }`);
238
- } else {
239
- methods.push(` async ${method}(value: string): Promise<void> {\n await this.${property}.fill(value);\n }`);
240
- }
241
- });
242
-
243
- return `import type { Page, Locator } from '@playwright/test';
244
-
245
- export class ${className} {
246
- readonly page: Page;
247
- ${declarations.length > 0 ? `\n${declarations.join('\n')}\n` : ''}
248
- constructor(page: Page) {
249
- this.page = page;
250
- ${assignments.length > 0 ? `${assignments.join('\n')}\n` : ''} }
251
-
252
- async goto(): Promise<void> {
253
- await this.page.goto(${quote(baseUrl)});
254
- }
255
- ${methods.length > 0 ? `\n${methods.join('\n\n')}\n` : ''}}
256
- `;
257
- }
258
-
259
- function renderSpec(
260
- featureName: string,
261
- className: string,
262
- pageImport: string,
263
- fixtureImport: string | undefined,
264
- steps: GeneratedStep[],
265
- namePlan: NamePlan,
266
- requirements?: string,
267
- useFixtures = false,
268
- ): string {
269
- const variable = className[0]!.toLowerCase() + className.slice(1);
270
- const lines: string[] = [];
271
-
272
- if (!useFixtures) {
273
- lines.push(` const ${variable} = new ${className}(page);`);
274
- lines.push(` await ${variable}.goto();`);
275
- }
276
-
277
- steps.forEach((step, index) => {
278
- const symbol = namePlan.symbols.find((s) => s.ref === `step-${index + 1}`) ?? namePlan.symbols[index];
279
- const method = symbol?.method ?? (step.action === 'click' ? `clickAction` : step.action === 'press' ? `pressKey` : `fillInput`);
280
- const action = String(step.action || '').replace(/([A-Z])/g, '-$1').toLowerCase();
281
-
282
- if (action === 'navigate' && step.value) {
283
- lines.push(` await page.goto(${quote(step.value)});`);
284
- } else if (action === 'click') {
285
- lines.push(` await ${variable}.${method}();`);
286
- } else if (action === 'press') {
287
- lines.push(` await ${variable}.${method}(${quote(step.value ?? 'Enter')});`);
288
- } else if (action === 'fill') {
289
- lines.push(` await ${variable}.${method}(${quote(step.value ?? 'test-data')});`);
290
- } else if (action === 'assert-visible') {
291
- if (step.expectedText) {
292
- lines.push(` await expect(page.getByText(${quote(step.expectedText)})).toBeVisible();`);
293
- } else if (step.role && step.name) {
294
- lines.push(` await expect(page.getByRole('${step.role}', { name: '${step.name}' })).toBeVisible();`);
295
- } else if (step.name) {
296
- lines.push(` await expect(page.getByRole('${step.role ?? 'heading'}', { name: '${step.name}' })).toBeVisible();`);
297
- } else if (step.selector) {
298
- lines.push(` await expect(page.locator(${quote(step.selector)})).toBeVisible();`);
299
- } else {
300
- lines.push(` await expect(page.getByRole('${step.role ?? 'heading'}', { name: '${featureName}' })).toBeVisible();`);
301
- }
302
- } else if (action === 'assert-text') {
303
- lines.push(` await expect(page.locator('${step.selector ?? 'body'}')).toContainText('${step.expectedText ?? ''}');`);
304
- } else if (action === 'assert-count') {
305
- lines.push(` await expect(page.locator('${step.selector ?? 'li'}')).toHaveCount(${step.expectedCount ?? 1});`);
306
- } else if (action === 'assert-value') {
307
- lines.push(` await expect(page.locator('${step.selector ?? 'input'}')).toHaveValue('${step.expectedValue ?? ''}');`);
308
- }
309
- });
310
-
311
- const requirementComment = requirements?.trim()
312
- ? `\n // Requirement: ${requirements.trim().replace(/\r?\n/g, ' ').replace(/\*\//g, '* /')}`
313
- : '';
314
-
315
- if (useFixtures && fixtureImport) {
316
- return `import { test, expect } from ${quote(fixtureImport)};
317
- import { ${className} } from ${quote(pageImport)};
318
-
319
- test.describe(${quote(`${featureName} workflow`)}, () => {
320
- test(${quote(`should complete ${featureName}`)}, async ({ ${variable}, page }) => {${requirementComment}
321
- ${lines.join('\n')}
322
- });
323
- });
324
- `;
325
- }
326
-
327
- return `import { test, expect } from '@playwright/test';
328
- import { ${className} } from ${quote(pageImport)};
329
-
330
- test.describe(${quote(`${featureName} workflow`)}, () => {
331
- test(${quote(`should complete ${featureName}`)}, async ({ page }) => {${requirementComment}
332
- ${lines.join('\n')}
333
- });
334
- });
335
- `;
336
- }
337
-
338
- function renderFixtureFile(className?: string, pageImport?: string): string {
339
- if (!className || !pageImport) {
340
- return `import { test as base } from '@playwright/test';
341
-
342
- export const test = base.extend({});
343
- export { expect } from '@playwright/test';
344
- `;
345
- }
346
- const variable = className[0]!.toLowerCase() + className.slice(1);
347
- return `import { test as base } from '@playwright/test';
348
- import { ${className} } from ${quote(pageImport)};
349
-
350
- type ${className}Fixtures = {
351
- ${variable}: ${className};
352
- };
353
-
354
- export const test = base.extend<${className}Fixtures>({
355
- ${variable}: async ({ page }, use) => {
356
- const ${variable} = new ${className}(page);
357
- await ${variable}.goto();
358
- await use(${variable});
359
- },
360
- });
361
-
362
- export { expect } from '@playwright/test';
363
- `;
364
- }
365
-
366
- function renderPlaywrightConfig(baseUrl: string, testDirectory: string): string {
367
- return `import { defineConfig, devices } from '@playwright/test';
368
-
369
- declare const process: { env: Record<string, string | undefined> };
370
-
371
- export default defineConfig({
372
- testDir: ${quote(`./${testDirectory}`)},
373
- fullyParallel: true,
374
- forbidOnly: !!process.env.CI,
375
- retries: process.env.CI ? 2 : 0,
376
- reporter: [['list'], ['html', { open: 'never' }]],
377
- use: {
378
- baseURL: ${quote(baseUrl === '/' ? 'http://localhost:3000' : baseUrl)},
379
- trace: 'on-first-retry',
380
- screenshot: 'only-on-failure',
381
- video: 'retain-on-failure',
382
- },
383
- projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
384
- });
385
- `;
386
- }
387
-
388
- function renderCiWorkflow(): string {
389
- return `name: Playwright tests
390
-
391
- on:
392
- pull_request:
393
- push:
394
- branches: [main]
395
-
396
- jobs:
397
- test:
398
- runs-on: ubuntu-latest
399
- steps:
400
- - uses: actions/checkout@v4
401
- - uses: actions/setup-node@v4
402
- with:
403
- node-version: 20
404
- cache: npm
405
- - run: npm ci
406
- - run: npx playwright install --with-deps chromium
407
- - run: npx playwright test
408
- `;
409
- }
410
-
411
- function fileExists(existingFiles: Set<string>, relativePath: string): boolean {
412
- const normalized = relativePath.replace(/\\/g, '/').replace(/^\.\//, '');
413
- return [...existingFiles].some((file) => file.replace(/\\/g, '/').replace(/^\.\//, '') === normalized);
414
- }
415
-
416
- function makeFile(relativePath: string, content: string, purpose: string, existingFiles: Set<string>): ArchitectureFile {
417
- return { relativePath, content, purpose, action: fileExists(existingFiles, relativePath) ? 'SKIP_EXISTING' : 'CREATE' };
418
- }
419
-
420
- export function planArchitecture(input: ArchitectureGenerationInput): ArchitecturePlan {
421
- const featureName = input.featureName.trim();
422
- if (!featureName) throw new Error('featureName must be a non-empty string');
423
- if (input.mode !== 'greenfield' && input.mode !== 'brownfield') throw new Error(`Unsupported architecture mode '${input.mode}'`);
424
-
425
- const slug = toSlug(featureName);
426
- const rawBaseName = toClassName(featureName);
427
- const fallbackClassName = rawBaseName.endsWith('Page') ? rawBaseName : `${rawBaseName}Page`;
428
- let steps = defaultSteps(input);
429
- if (input.appModel && input.appModel.routes) {
430
- const allElements = input.appModel.routes.flatMap((r) => r.elements);
431
- steps = steps.map((s) => {
432
- if (s.frame || s.frameSelector) return s;
433
- const match = allElements.find((el) =>
434
- el.frameSelector &&
435
- ((s.name && (el.accessibleName === s.name || el.label === s.name)) ||
436
- (s.role && el.role === s.role && s.name && el.accessibleName === s.name))
437
- );
438
- if (match?.frameSelector) {
439
- return { ...s, frame: match.frameSelector, frameSelector: match.frameSelector };
440
- }
441
- return s;
442
- });
443
- }
444
- const namePlan = input.namePlan ?? NamePlanner.planNames(featureName, steps, input.mode === 'greenfield' ? { existingPageClassName: fallbackClassName } : undefined);
445
- const className = input.mode === 'brownfield' && namePlan.pageClassName ? namePlan.pageClassName : fallbackClassName;
446
- const namingLintReport = NamingLinter.lint(namePlan);
447
-
448
- const profile = input.profile;
449
- const testConfidence = profile?.pathConfidence?.testDirectory ?? 1;
450
- const pageConfidence = profile?.pathConfidence?.pageObjectDirectory ?? 1;
451
- const rawTestDir = (testConfidence >= 0.5 && profile?.paths?.testDirectory) ? profile.paths.testDirectory : 'tests';
452
- const rawPageDir = (pageConfidence >= 0.5 && profile?.paths?.pageObjectDirectory) ? profile.paths.pageObjectDirectory : `${rawTestDir}/pages`;
453
- const testDirectory = normalizeRelativeDirectory(rawTestDir, 'tests');
454
- const pageObjectDirectory = normalizeRelativeDirectory(rawPageDir, `${testDirectory}/pages`);
455
- const fixtureDirectory = normalizeRelativeDirectory(profile?.paths?.fixtureDirectory, `${testDirectory}/fixtures`);
456
- const preferredLocator = profile?.locatorConventions.preferred ?? 'getByRole';
457
- const existingFiles = new Set((input.existingFiles ?? []).map((file) => file.replace(/\\/g, '/')));
458
- const baseUrl = input.baseUrl ?? profile?.testConventions?.baseUrl ?? '/';
459
- const testPath = `${testDirectory}/${slug}.spec.ts`;
460
- const pagePath = `${pageObjectDirectory}/${className}.ts`;
461
- const pageImport = path.posix.relative(path.posix.dirname(testPath), pagePath).replace(/\.ts$/, '');
462
- const normalizedPageImport = pageImport.startsWith('.') ? pageImport : `./${pageImport}`;
463
-
464
- const assumptions: string[] = [];
465
- const warnings: string[] = [];
466
- if (namePlan.assumptions.length > 0) {
467
- assumptions.push(...namePlan.assumptions);
468
- }
469
- if (!namingLintReport.passed) {
470
- for (const v of namingLintReport.violations) {
471
- warnings.push(`Naming lint violation [${v.kind}]: ${v.message}`);
472
- }
473
- }
474
-
475
- if (input.mode === 'greenfield') {
476
- assumptions.push('The application will be available at the supplied base URL when tests execute.');
477
- assumptions.push('The generated workflow is a scaffold and requires review of business assertions and test data.');
478
- } else {
479
- if (!profile) warnings.push('No repository profile supplied; brownfield paths and conventions are conservative defaults.');
480
- if (profile?.locatorConventions.preferred === 'unknown') warnings.push('No dominant locator convention was detected; locator choices require review.');
481
- if (profile?.structure.playwrightTestFiles === 0 && profile.structure.configFiles.length === 0) {
482
- warnings.push('No Playwright test source or configuration was detected; generated files require manual integration review.');
483
- }
484
- if (testConfidence < 0.5) {
485
- warnings.push(`Test-directory convention has low confidence (${testConfidence}); defaulting to standard 'tests'.`);
486
- }
487
- if (pageConfidence < 0.5) {
488
- warnings.push(`Page-object directory convention has low confidence (${pageConfidence}); defaulting to standard '${testDirectory}/pages'.`);
489
- }
490
- assumptions.push('Existing project configuration, fixtures, authentication, and test data remain authoritative.');
491
- assumptions.push('Generated files are additive and do not replace existing files.');
492
- }
493
-
494
- const files: ArchitectureFile[] = [];
495
- const fixtureFileRelative = `${fixtureDirectory}/test.ts`;
496
- const pageImportFromFixture = path.posix.relative(path.posix.dirname(fixtureFileRelative), pagePath).replace(/\.ts$/, '');
497
- const normalizedPageImportFromFixture = pageImportFromFixture.startsWith('.') ? pageImportFromFixture : `./${pageImportFromFixture}`;
498
-
499
- const fixtureImport = path.posix.relative(path.posix.dirname(testPath), fixtureFileRelative).replace(/\.ts$/, '');
500
- const normalizedFixtureImport = fixtureImport.startsWith('.') ? fixtureImport : `./${fixtureImport}`;
501
-
502
- const usesCustomFixtures = input.mode === 'greenfield' || (profile?.structure.usesCustomFixtures ?? false);
503
-
504
- files.push(makeFile(pagePath, renderPageObject(className, baseUrl, steps, preferredLocator, namePlan), 'Feature Page Object with explicit locators and actions.', existingFiles));
505
- files.push(makeFile(testPath, renderSpec(featureName, className, normalizedPageImport, normalizedFixtureImport, steps, namePlan, input.requirements, usesCustomFixtures), 'Workflow spec with web-first assertions.', existingFiles));
506
-
507
- if (input.mode === 'greenfield') {
508
- files.push(makeFile('playwright.config.ts', renderPlaywrightConfig(baseUrl, testDirectory), 'Minimal reproducible Playwright configuration.', existingFiles));
509
- files.push(makeFile(fixtureFileRelative, renderFixtureFile(className, normalizedPageImportFromFixture), 'Composable fixture entry point.', existingFiles));
510
- if (input.includeCi !== false) files.push(makeFile('.github/workflows/playwright.yml', renderCiWorkflow(), 'Pull-request Playwright execution workflow.', existingFiles));
511
- } else if (profile?.structure.usesCustomFixtures) {
512
- files.push(makeFile(fixtureFileRelative, renderFixtureFile(className, normalizedPageImportFromFixture), 'Fixture extension point matching the profiled repository layout.', existingFiles));
513
- }
514
-
515
- const specFile = files.find((f) => f.relativePath.endsWith('.spec.ts'));
516
- if (specFile) {
517
- const qualityReport = AssertionQualityGate.evaluate(specFile.content, featureName);
518
- if (!qualityReport.passed) {
519
- for (const v of qualityReport.violations) {
520
- warnings.push(`Assertion quality violation: ${v}`);
521
- }
522
- }
523
- }
524
-
525
- const groundedSteps = steps.filter((s) => s.action !== 'navigate' && (s.name || s.selector || s.placeholder));
526
- const ungrounded = steps.length > 0 && groundedSteps.length === 0;
527
- if (ungrounded) {
528
- warnings.push('No grounded steps provided: every step must carry an accessible name or selector. Supply requirements or observed steps.');
529
- }
530
-
531
- const status: ArchitecturePlanStatus = ungrounded
532
- ? 'NEEDS_INPUT'
533
- : warnings.some((warning) => warning.includes('No repository profile')) ? 'PARTIAL' : 'READY';
534
- return {
535
- mode: input.mode,
536
- status,
537
- featureName,
538
- slug,
539
- conventions: {
540
- testDirectory,
541
- pageObjectDirectory,
542
- fixtureDirectory,
543
- preferredLocator,
544
- usesCustomFixtures: profile?.structure.usesCustomFixtures ?? false,
545
- usesStorageState: profile?.structure.usesStorageState ?? false,
546
- },
547
- assumptions,
548
- warnings,
549
- files,
550
- namePlan,
551
- namingLintReport,
552
- };
553
- }
554
-
555
- export async function writeArchitecturePlan(
556
- root: string,
557
- plan: ArchitecturePlan,
558
- ): Promise<ArchitectureWriteResult> {
559
- const projectRoot = path.resolve(root);
560
- const written: string[] = [];
561
- const skipped: string[] = [];
562
- for (const file of plan.files) {
563
- const target = path.resolve(projectRoot, file.relativePath);
564
- const relative = path.relative(projectRoot, target);
565
- if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`Architecture output path escapes project root: '${file.relativePath}'`);
566
- if (file.action === 'SKIP_EXISTING') {
567
- skipped.push(file.relativePath);
568
- continue;
569
- }
570
- try {
571
- await fs.access(target);
572
- skipped.push(file.relativePath);
573
- continue;
574
- } catch {
575
- // The file is intentionally create-only. Continue to mkdir/write below.
576
- }
577
- await fs.mkdir(path.dirname(target), { recursive: true });
578
- await fs.writeFile(target, file.content, 'utf8');
579
- written.push(file.relativePath);
580
- }
581
- return { written, skipped };
582
- }
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { GeneratedStep } from './test-generator.js';
4
+ import type { RepositoryConventionProfile } from '../intelligence/repository-profile.js';
5
+ import { NamePlanner, NamingLinter, type NamePlan, type NamingLintReport } from '../authoring/naming/index.js';
6
+ import type { AppModel } from '../perception/app-model.js';
7
+ import { AssertionQualityGate } from './assertion-quality-gate.js';
8
+
9
+ export type ArchitectureMode = 'greenfield' | 'brownfield';
10
+ export type ArchitecturePlanStatus = 'READY' | 'PARTIAL' | 'ABSTAIN' | 'NEEDS_INPUT';
11
+
12
+ export interface ArchitectureGenerationInput {
13
+ featureName: string;
14
+ mode: ArchitectureMode;
15
+ baseUrl?: string | undefined;
16
+ requirements?: string | undefined;
17
+ steps?: GeneratedStep[] | undefined;
18
+ profile?: RepositoryConventionProfile | undefined;
19
+ existingFiles?: string[] | undefined;
20
+ includeCi?: boolean | undefined;
21
+ namePlan?: NamePlan | undefined;
22
+ appModel?: AppModel | undefined;
23
+ }
24
+
25
+ export interface ArchitectureFile {
26
+ relativePath: string;
27
+ content: string;
28
+ purpose: string;
29
+ action: 'CREATE' | 'MODIFY' | 'SKIP_EXISTING';
30
+ }
31
+
32
+ export interface ArchitecturePlan {
33
+ mode: ArchitectureMode;
34
+ status: ArchitecturePlanStatus;
35
+ featureName: string;
36
+ slug: string;
37
+ conventions: {
38
+ testDirectory: string;
39
+ pageObjectDirectory: string;
40
+ fixtureDirectory: string;
41
+ preferredLocator: string;
42
+ usesCustomFixtures: boolean;
43
+ usesStorageState: boolean;
44
+ };
45
+ assumptions: string[];
46
+ warnings: string[];
47
+ files: ArchitectureFile[];
48
+ namePlan?: NamePlan | undefined;
49
+ namingLintReport?: NamingLintReport | undefined;
50
+ }
51
+
52
+ export interface ArchitectureWriteResult {
53
+ written: string[];
54
+ skipped: string[];
55
+ modified?: string[] | undefined;
56
+ }
57
+
58
+ function quote(value: string): string {
59
+ return JSON.stringify(value);
60
+ }
61
+
62
+ export function toSlug(value: string): string {
63
+ return value
64
+ .trim()
65
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
66
+ .replace(/[^a-zA-Z0-9]+/g, '-')
67
+ .replace(/^-+|-+$/g, '')
68
+ .toLowerCase() || 'feature';
69
+ }
70
+
71
+ export function toClassName(value: string): string {
72
+ const words = value
73
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
74
+ .match(/[A-Za-z0-9]+/g) ?? ['Feature'];
75
+ const className = words
76
+ .map((word) => word[0]!.toUpperCase() + word.slice(1).toLowerCase())
77
+ .join('');
78
+ return /^[A-Za-z_]/.test(className) ? className : `Feature${className}`;
79
+ }
80
+
81
+ function normalizeRelativeDirectory(value: string | undefined, fallback: string): string {
82
+ const normalized = (value ?? fallback).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
83
+ if (!normalized || normalized === '.') return fallback;
84
+ if (normalized.startsWith('/') || normalized.split('/').includes('..')) return fallback;
85
+ return normalized;
86
+ }
87
+
88
+ export function inferBehavioralSteps(featureName: string, appModel?: AppModel): GeneratedStep[] | undefined {
89
+ if (!appModel || !appModel.routes || appModel.routes.length === 0) return undefined;
90
+
91
+ const targetSlug = toSlug(featureName);
92
+ let route = appModel.routes.find((r) =>
93
+ r.route.toLowerCase().includes(targetSlug) || (r.title && r.title.toLowerCase().includes(featureName.toLowerCase()))
94
+ );
95
+ if (!route) {
96
+ route = appModel.routes.find((r) => r.elements && r.elements.length > 0) ?? appModel.routes[0];
97
+ }
98
+ if (!route || !route.elements || route.elements.length === 0) return undefined;
99
+
100
+ const elements = route.elements;
101
+ const inputEl = elements.find((el) =>
102
+ (el.tag === 'input' && el.type !== 'checkbox' && el.type !== 'radio' && el.type !== 'submit' && el.type !== 'button') ||
103
+ el.tag === 'textarea' ||
104
+ Boolean(el.placeholder) ||
105
+ (el.role === 'textbox' && el.type !== 'checkbox')
106
+ );
107
+
108
+ const submitBtn = elements.find((el) =>
109
+ (el.tag === 'button' || el.role === 'button' || el.type === 'submit') &&
110
+ el.accessibleName &&
111
+ !/delete|remove|close|cancel/i.test(el.accessibleName)
112
+ );
113
+
114
+ const testValue = `Test ${featureName.replace(/Page$/, '')}`;
115
+
116
+ if (inputEl) {
117
+ const steps: GeneratedStep[] = [
118
+ { action: 'navigate' },
119
+ {
120
+ action: 'fill',
121
+ role: inputEl.role ?? 'textbox',
122
+ name: inputEl.accessibleName,
123
+ placeholder: inputEl.placeholder,
124
+ selector: inputEl.testId,
125
+ value: testValue,
126
+ frame: inputEl.frameSelector,
127
+ frameSelector: inputEl.frameSelector,
128
+ },
129
+ ];
130
+
131
+ if (submitBtn) {
132
+ steps.push({
133
+ action: 'click',
134
+ role: 'button',
135
+ name: submitBtn.accessibleName,
136
+ frame: submitBtn.frameSelector,
137
+ frameSelector: submitBtn.frameSelector,
138
+ });
139
+ } else {
140
+ steps.push({
141
+ action: 'press',
142
+ name: inputEl.accessibleName,
143
+ placeholder: inputEl.placeholder,
144
+ value: 'Enter',
145
+ frame: inputEl.frameSelector,
146
+ frameSelector: inputEl.frameSelector,
147
+ });
148
+ }
149
+
150
+ steps.push({
151
+ action: 'assert-visible',
152
+ expectedText: testValue,
153
+ name: testValue,
154
+ });
155
+
156
+ return steps;
157
+ }
158
+
159
+ const actionEl = elements.find((el) => (el.tag === 'button' || el.role === 'button' || el.tag === 'a') && el.accessibleName);
160
+ if (actionEl) {
161
+ return [
162
+ { action: 'navigate' },
163
+ {
164
+ action: 'click',
165
+ role: actionEl.role ?? 'button',
166
+ name: actionEl.accessibleName,
167
+ frame: actionEl.frameSelector,
168
+ frameSelector: actionEl.frameSelector,
169
+ },
170
+ {
171
+ action: 'assert-visible',
172
+ role: 'heading',
173
+ name: featureName,
174
+ },
175
+ ];
176
+ }
177
+
178
+ return undefined;
179
+ }
180
+
181
+ function defaultSteps(input: ArchitectureGenerationInput): GeneratedStep[] {
182
+ if (input.steps && input.steps.length > 0) return input.steps;
183
+ if (input.appModel) {
184
+ const inferred = inferBehavioralSteps(input.featureName, input.appModel);
185
+ if (inferred && inferred.length > 0) return inferred;
186
+ }
187
+ return [
188
+ { action: 'navigate' },
189
+ { action: 'assert-visible', role: 'heading', name: input.featureName },
190
+ ];
191
+ }
192
+
193
+ export function renderLocator(step: GeneratedStep, preferredLocator: string, frameSelector?: string): string {
194
+ let inner: string;
195
+ if (step.placeholder) {
196
+ inner = `getByPlaceholder('${step.placeholder}')`;
197
+ } else if (step.action === 'click' && step.role && step.name) {
198
+ inner = `getByRole('${step.role}', { name: '${step.name}' })`;
199
+ } else if (step.action === 'fill' && step.name) {
200
+ if (preferredLocator === 'getByLabel') inner = `getByLabel('${step.name}')`;
201
+ else inner = `getByRole('textbox', { name: '${step.name}' })`;
202
+ } else if (step.selector && preferredLocator === 'getByTestId' && /^[A-Za-z0-9_-]+$/.test(step.selector)) {
203
+ inner = `getByTestId('${step.selector}')`;
204
+ } else {
205
+ inner = `locator('${step.selector ?? (step.action === 'fill' ? 'input' : 'button')}')`;
206
+ }
207
+
208
+ const frame = frameSelector ?? step.frame ?? step.frameSelector;
209
+ if (frame) {
210
+ return `this.page.frameLocator('${frame}').${inner}`;
211
+ }
212
+ return `this.page.${inner}`;
213
+ }
214
+
215
+ function renderPageObject(className: string, baseUrl: string, steps: GeneratedStep[], preferredLocator: string, namePlan: NamePlan): string {
216
+ const declarations: string[] = [];
217
+ const assignments: string[] = [];
218
+ const methods: string[] = [];
219
+ const seenProperties = new Set<string>();
220
+
221
+ steps.forEach((step, index) => {
222
+ if (step.action !== 'click' && step.action !== 'fill' && step.action !== 'press') return;
223
+ const symbol = namePlan.symbols.find((s) => s.ref === `step-${index + 1}`) ?? namePlan.symbols[index];
224
+ const property = symbol?.property ?? (step.action === 'click' ? `actionButton` : `inputField`);
225
+ const method = symbol?.method ?? (step.action === 'click' ? `clickAction` : step.action === 'press' ? `pressKey` : `fillInput`);
226
+ const frame = symbol?.frame ?? step.frame ?? step.frameSelector;
227
+
228
+ if (!seenProperties.has(property)) {
229
+ seenProperties.add(property);
230
+ declarations.push(` readonly ${property}: Locator;`);
231
+ assignments.push(` this.${property} = ${renderLocator(step, preferredLocator, frame)};`);
232
+ }
233
+
234
+ if (step.action === 'click') {
235
+ methods.push(` async ${method}(): Promise<void> {\n await this.${property}.click();\n }`);
236
+ } else if (step.action === 'press') {
237
+ methods.push(` async ${method}(key = ${quote(step.value ?? 'Enter')}): Promise<void> {\n await this.${property}.press(key);\n }`);
238
+ } else {
239
+ methods.push(` async ${method}(value: string): Promise<void> {\n await this.${property}.fill(value);\n }`);
240
+ }
241
+ });
242
+
243
+ return `import type { Page, Locator } from '@playwright/test';
244
+
245
+ export class ${className} {
246
+ readonly page: Page;
247
+ ${declarations.length > 0 ? `\n${declarations.join('\n')}\n` : ''}
248
+ constructor(page: Page) {
249
+ this.page = page;
250
+ ${assignments.length > 0 ? `${assignments.join('\n')}\n` : ''} }
251
+
252
+ async goto(): Promise<void> {
253
+ await this.page.goto(${quote(baseUrl)});
254
+ }
255
+ ${methods.length > 0 ? `\n${methods.join('\n\n')}\n` : ''}}
256
+ `;
257
+ }
258
+
259
+ function renderSpec(
260
+ featureName: string,
261
+ className: string,
262
+ pageImport: string,
263
+ fixtureImport: string | undefined,
264
+ steps: GeneratedStep[],
265
+ namePlan: NamePlan,
266
+ requirements?: string,
267
+ useFixtures = false,
268
+ ): string {
269
+ const variable = className[0]!.toLowerCase() + className.slice(1);
270
+ const lines: string[] = [];
271
+
272
+ if (!useFixtures) {
273
+ lines.push(` const ${variable} = new ${className}(page);`);
274
+ lines.push(` await ${variable}.goto();`);
275
+ }
276
+
277
+ steps.forEach((step, index) => {
278
+ const symbol = namePlan.symbols.find((s) => s.ref === `step-${index + 1}`) ?? namePlan.symbols[index];
279
+ const method = symbol?.method ?? (step.action === 'click' ? `clickAction` : step.action === 'press' ? `pressKey` : `fillInput`);
280
+ const action = String(step.action || '').replace(/([A-Z])/g, '-$1').toLowerCase();
281
+
282
+ if (action === 'navigate' && step.value) {
283
+ lines.push(` await page.goto(${quote(step.value)});`);
284
+ } else if (action === 'click') {
285
+ lines.push(` await ${variable}.${method}();`);
286
+ } else if (action === 'press') {
287
+ lines.push(` await ${variable}.${method}(${quote(step.value ?? 'Enter')});`);
288
+ } else if (action === 'fill') {
289
+ lines.push(` await ${variable}.${method}(${quote(step.value ?? 'test-data')});`);
290
+ } else if (action === 'assert-visible') {
291
+ if (step.expectedText) {
292
+ lines.push(` await expect(page.getByText(${quote(step.expectedText)})).toBeVisible();`);
293
+ } else if (step.role && step.name) {
294
+ lines.push(` await expect(page.getByRole('${step.role}', { name: '${step.name}' })).toBeVisible();`);
295
+ } else if (step.name) {
296
+ lines.push(` await expect(page.getByRole('${step.role ?? 'heading'}', { name: '${step.name}' })).toBeVisible();`);
297
+ } else if (step.selector) {
298
+ lines.push(` await expect(page.locator(${quote(step.selector)})).toBeVisible();`);
299
+ } else {
300
+ lines.push(` await expect(page.getByRole('${step.role ?? 'heading'}', { name: '${featureName}' })).toBeVisible();`);
301
+ }
302
+ } else if (action === 'assert-text') {
303
+ lines.push(` await expect(page.locator('${step.selector ?? 'body'}')).toContainText('${step.expectedText ?? ''}');`);
304
+ } else if (action === 'assert-count') {
305
+ lines.push(` await expect(page.locator('${step.selector ?? 'li'}')).toHaveCount(${step.expectedCount ?? 1});`);
306
+ } else if (action === 'assert-value') {
307
+ lines.push(` await expect(page.locator('${step.selector ?? 'input'}')).toHaveValue('${step.expectedValue ?? ''}');`);
308
+ }
309
+ });
310
+
311
+ const requirementComment = requirements?.trim()
312
+ ? `\n // Requirement: ${requirements.trim().replace(/\r?\n/g, ' ').replace(/\*\//g, '* /')}`
313
+ : '';
314
+
315
+ if (useFixtures && fixtureImport) {
316
+ return `import { test, expect } from ${quote(fixtureImport)};
317
+ import { ${className} } from ${quote(pageImport)};
318
+
319
+ test.describe(${quote(`${featureName} workflow`)}, () => {
320
+ test(${quote(`should complete ${featureName}`)}, async ({ ${variable}, page }) => {${requirementComment}
321
+ ${lines.join('\n')}
322
+ });
323
+ });
324
+ `;
325
+ }
326
+
327
+ return `import { test, expect } from '@playwright/test';
328
+ import { ${className} } from ${quote(pageImport)};
329
+
330
+ test.describe(${quote(`${featureName} workflow`)}, () => {
331
+ test(${quote(`should complete ${featureName}`)}, async ({ page }) => {${requirementComment}
332
+ ${lines.join('\n')}
333
+ });
334
+ });
335
+ `;
336
+ }
337
+
338
+ function renderFixtureFile(className?: string, pageImport?: string): string {
339
+ if (!className || !pageImport) {
340
+ return `import { test as base } from '@playwright/test';
341
+
342
+ export const test = base.extend({});
343
+ export { expect } from '@playwright/test';
344
+ `;
345
+ }
346
+ const variable = className[0]!.toLowerCase() + className.slice(1);
347
+ return `import { test as base } from '@playwright/test';
348
+ import { ${className} } from ${quote(pageImport)};
349
+
350
+ type ${className}Fixtures = {
351
+ ${variable}: ${className};
352
+ };
353
+
354
+ export const test = base.extend<${className}Fixtures>({
355
+ ${variable}: async ({ page }, use) => {
356
+ const ${variable} = new ${className}(page);
357
+ await ${variable}.goto();
358
+ await use(${variable});
359
+ },
360
+ });
361
+
362
+ export { expect } from '@playwright/test';
363
+ `;
364
+ }
365
+
366
+ function renderPlaywrightConfig(baseUrl: string, testDirectory: string): string {
367
+ return `import { defineConfig, devices } from '@playwright/test';
368
+
369
+ declare const process: { env: Record<string, string | undefined> };
370
+
371
+ export default defineConfig({
372
+ testDir: ${quote(`./${testDirectory}`)},
373
+ fullyParallel: true,
374
+ forbidOnly: !!process.env.CI,
375
+ retries: process.env.CI ? 2 : 0,
376
+ reporter: [['list'], ['html', { open: 'never' }]],
377
+ use: {
378
+ baseURL: ${quote(baseUrl === '/' ? 'http://localhost:3000' : baseUrl)},
379
+ trace: 'on-first-retry',
380
+ screenshot: 'only-on-failure',
381
+ video: 'retain-on-failure',
382
+ },
383
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
384
+ });
385
+ `;
386
+ }
387
+
388
+ function renderCiWorkflow(): string {
389
+ return `name: Playwright tests
390
+
391
+ on:
392
+ pull_request:
393
+ push:
394
+ branches: [main]
395
+
396
+ jobs:
397
+ test:
398
+ runs-on: ubuntu-latest
399
+ steps:
400
+ - uses: actions/checkout@v4
401
+ - uses: actions/setup-node@v4
402
+ with:
403
+ node-version: 20
404
+ cache: npm
405
+ - run: npm ci
406
+ - run: npx playwright install --with-deps chromium
407
+ - run: npx playwright test
408
+ `;
409
+ }
410
+
411
+ function fileExists(existingFiles: Set<string>, relativePath: string): boolean {
412
+ const normalized = relativePath.replace(/\\/g, '/').replace(/^\.\//, '');
413
+ return [...existingFiles].some((file) => file.replace(/\\/g, '/').replace(/^\.\//, '') === normalized);
414
+ }
415
+
416
+ function makeFile(relativePath: string, content: string, purpose: string, existingFiles: Set<string>): ArchitectureFile {
417
+ return { relativePath, content, purpose, action: fileExists(existingFiles, relativePath) ? 'SKIP_EXISTING' : 'CREATE' };
418
+ }
419
+
420
+ export function planArchitecture(input: ArchitectureGenerationInput): ArchitecturePlan {
421
+ const featureName = input.featureName.trim();
422
+ if (!featureName) throw new Error('featureName must be a non-empty string');
423
+ if (input.mode !== 'greenfield' && input.mode !== 'brownfield') throw new Error(`Unsupported architecture mode '${input.mode}'`);
424
+
425
+ const slug = toSlug(featureName);
426
+ const rawBaseName = toClassName(featureName);
427
+ const fallbackClassName = rawBaseName.endsWith('Page') ? rawBaseName : `${rawBaseName}Page`;
428
+ let steps = defaultSteps(input);
429
+ if (input.appModel && input.appModel.routes) {
430
+ const allElements = input.appModel.routes.flatMap((r) => r.elements);
431
+ steps = steps.map((s) => {
432
+ if (s.frame || s.frameSelector) return s;
433
+ const match = allElements.find((el) =>
434
+ el.frameSelector &&
435
+ ((s.name && (el.accessibleName === s.name || el.label === s.name)) ||
436
+ (s.role && el.role === s.role && s.name && el.accessibleName === s.name))
437
+ );
438
+ if (match?.frameSelector) {
439
+ return { ...s, frame: match.frameSelector, frameSelector: match.frameSelector };
440
+ }
441
+ return s;
442
+ });
443
+ }
444
+ const namePlan = input.namePlan ?? NamePlanner.planNames(featureName, steps, input.mode === 'greenfield' ? { existingPageClassName: fallbackClassName } : undefined);
445
+ const className = input.mode === 'brownfield' && namePlan.pageClassName ? namePlan.pageClassName : fallbackClassName;
446
+ const namingLintReport = NamingLinter.lint(namePlan);
447
+
448
+ const profile = input.profile;
449
+ const testConfidence = profile?.pathConfidence?.testDirectory ?? 1;
450
+ const pageConfidence = profile?.pathConfidence?.pageObjectDirectory ?? 1;
451
+ const rawTestDir = (testConfidence >= 0.5 && profile?.paths?.testDirectory) ? profile.paths.testDirectory : 'tests';
452
+ const rawPageDir = (pageConfidence >= 0.5 && profile?.paths?.pageObjectDirectory) ? profile.paths.pageObjectDirectory : `${rawTestDir}/pages`;
453
+ const testDirectory = normalizeRelativeDirectory(rawTestDir, 'tests');
454
+ const pageObjectDirectory = normalizeRelativeDirectory(rawPageDir, `${testDirectory}/pages`);
455
+ const fixtureDirectory = normalizeRelativeDirectory(profile?.paths?.fixtureDirectory, `${testDirectory}/fixtures`);
456
+ const preferredLocator = profile?.locatorConventions.preferred ?? 'getByRole';
457
+ const existingFiles = new Set((input.existingFiles ?? []).map((file) => file.replace(/\\/g, '/')));
458
+ const baseUrl = input.baseUrl ?? profile?.testConventions?.baseUrl ?? '/';
459
+ const testPath = `${testDirectory}/${slug}.spec.ts`;
460
+ const pagePath = `${pageObjectDirectory}/${className}.ts`;
461
+ const pageImport = path.posix.relative(path.posix.dirname(testPath), pagePath).replace(/\.ts$/, '');
462
+ const normalizedPageImport = pageImport.startsWith('.') ? pageImport : `./${pageImport}`;
463
+
464
+ const assumptions: string[] = [];
465
+ const warnings: string[] = [];
466
+ if (namePlan.assumptions.length > 0) {
467
+ assumptions.push(...namePlan.assumptions);
468
+ }
469
+ if (!namingLintReport.passed) {
470
+ for (const v of namingLintReport.violations) {
471
+ warnings.push(`Naming lint violation [${v.kind}]: ${v.message}`);
472
+ }
473
+ }
474
+
475
+ if (input.mode === 'greenfield') {
476
+ assumptions.push('The application will be available at the supplied base URL when tests execute.');
477
+ assumptions.push('The generated workflow is a scaffold and requires review of business assertions and test data.');
478
+ } else {
479
+ if (!profile) warnings.push('No repository profile supplied; brownfield paths and conventions are conservative defaults.');
480
+ if (profile?.locatorConventions.preferred === 'unknown') warnings.push('No dominant locator convention was detected; locator choices require review.');
481
+ if (profile?.structure.playwrightTestFiles === 0 && profile.structure.configFiles.length === 0) {
482
+ warnings.push('No Playwright test source or configuration was detected; generated files require manual integration review.');
483
+ }
484
+ if (testConfidence < 0.5) {
485
+ warnings.push(`Test-directory convention has low confidence (${testConfidence}); defaulting to standard 'tests'.`);
486
+ }
487
+ if (pageConfidence < 0.5) {
488
+ warnings.push(`Page-object directory convention has low confidence (${pageConfidence}); defaulting to standard '${testDirectory}/pages'.`);
489
+ }
490
+ assumptions.push('Existing project configuration, fixtures, authentication, and test data remain authoritative.');
491
+ assumptions.push('Generated files are additive and do not replace existing files.');
492
+ }
493
+
494
+ const files: ArchitectureFile[] = [];
495
+ const fixtureFileRelative = `${fixtureDirectory}/test.ts`;
496
+ const pageImportFromFixture = path.posix.relative(path.posix.dirname(fixtureFileRelative), pagePath).replace(/\.ts$/, '');
497
+ const normalizedPageImportFromFixture = pageImportFromFixture.startsWith('.') ? pageImportFromFixture : `./${pageImportFromFixture}`;
498
+
499
+ const fixtureImport = path.posix.relative(path.posix.dirname(testPath), fixtureFileRelative).replace(/\.ts$/, '');
500
+ const normalizedFixtureImport = fixtureImport.startsWith('.') ? fixtureImport : `./${fixtureImport}`;
501
+
502
+ const usesCustomFixtures = input.mode === 'greenfield' || (profile?.structure.usesCustomFixtures ?? false);
503
+
504
+ files.push(makeFile(pagePath, renderPageObject(className, baseUrl, steps, preferredLocator, namePlan), 'Feature Page Object with explicit locators and actions.', existingFiles));
505
+ files.push(makeFile(testPath, renderSpec(featureName, className, normalizedPageImport, normalizedFixtureImport, steps, namePlan, input.requirements, usesCustomFixtures), 'Workflow spec with web-first assertions.', existingFiles));
506
+
507
+ if (input.mode === 'greenfield') {
508
+ files.push(makeFile('playwright.config.ts', renderPlaywrightConfig(baseUrl, testDirectory), 'Minimal reproducible Playwright configuration.', existingFiles));
509
+ files.push(makeFile(fixtureFileRelative, renderFixtureFile(className, normalizedPageImportFromFixture), 'Composable fixture entry point.', existingFiles));
510
+ if (input.includeCi !== false) files.push(makeFile('.github/workflows/playwright.yml', renderCiWorkflow(), 'Pull-request Playwright execution workflow.', existingFiles));
511
+ } else if (profile?.structure.usesCustomFixtures) {
512
+ files.push(makeFile(fixtureFileRelative, renderFixtureFile(className, normalizedPageImportFromFixture), 'Fixture extension point matching the profiled repository layout.', existingFiles));
513
+ }
514
+
515
+ const specFile = files.find((f) => f.relativePath.endsWith('.spec.ts'));
516
+ if (specFile) {
517
+ const qualityReport = AssertionQualityGate.evaluate(specFile.content, featureName);
518
+ if (!qualityReport.passed) {
519
+ for (const v of qualityReport.violations) {
520
+ warnings.push(`Assertion quality violation: ${v}`);
521
+ }
522
+ }
523
+ }
524
+
525
+ const groundedSteps = steps.filter((s) => s.action !== 'navigate' && (s.name || s.selector || s.placeholder));
526
+ const ungrounded = steps.length > 0 && groundedSteps.length === 0;
527
+ if (ungrounded) {
528
+ warnings.push('No grounded steps provided: every step must carry an accessible name or selector. Supply requirements or observed steps.');
529
+ }
530
+
531
+ const status: ArchitecturePlanStatus = ungrounded
532
+ ? 'NEEDS_INPUT'
533
+ : warnings.some((warning) => warning.includes('No repository profile')) ? 'PARTIAL' : 'READY';
534
+ return {
535
+ mode: input.mode,
536
+ status,
537
+ featureName,
538
+ slug,
539
+ conventions: {
540
+ testDirectory,
541
+ pageObjectDirectory,
542
+ fixtureDirectory,
543
+ preferredLocator,
544
+ usesCustomFixtures: profile?.structure.usesCustomFixtures ?? false,
545
+ usesStorageState: profile?.structure.usesStorageState ?? false,
546
+ },
547
+ assumptions,
548
+ warnings,
549
+ files,
550
+ namePlan,
551
+ namingLintReport,
552
+ };
553
+ }
554
+
555
+ export async function writeArchitecturePlan(
556
+ root: string,
557
+ plan: ArchitecturePlan,
558
+ ): Promise<ArchitectureWriteResult> {
559
+ const projectRoot = path.resolve(root);
560
+ const written: string[] = [];
561
+ const skipped: string[] = [];
562
+ for (const file of plan.files) {
563
+ const target = path.resolve(projectRoot, file.relativePath);
564
+ const relative = path.relative(projectRoot, target);
565
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`Architecture output path escapes project root: '${file.relativePath}'`);
566
+ if (file.action === 'SKIP_EXISTING') {
567
+ skipped.push(file.relativePath);
568
+ continue;
569
+ }
570
+ try {
571
+ await fs.access(target);
572
+ skipped.push(file.relativePath);
573
+ continue;
574
+ } catch {
575
+ // The file is intentionally create-only. Continue to mkdir/write below.
576
+ }
577
+ await fs.mkdir(path.dirname(target), { recursive: true });
578
+ await fs.writeFile(target, file.content, 'utf8');
579
+ written.push(file.relativePath);
580
+ }
581
+ return { written, skipped };
582
+ }