specweave 1.0.71 → 1.0.72

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 (27) hide show
  1. package/dist/src/core/auto/e2e-coverage.d.ts +248 -0
  2. package/dist/src/core/auto/e2e-coverage.d.ts.map +1 -0
  3. package/dist/src/core/auto/e2e-coverage.js +871 -0
  4. package/dist/src/core/auto/e2e-coverage.js.map +1 -0
  5. package/dist/src/core/auto/increment-planner.js +7 -1
  6. package/dist/src/core/auto/increment-planner.js.map +1 -1
  7. package/dist/src/core/auto/index.d.ts +2 -0
  8. package/dist/src/core/auto/index.d.ts.map +1 -1
  9. package/dist/src/core/auto/index.js +4 -0
  10. package/dist/src/core/auto/index.js.map +1 -1
  11. package/dist/src/core/auto/plan-approval.d.ts +104 -0
  12. package/dist/src/core/auto/plan-approval.d.ts.map +1 -0
  13. package/dist/src/core/auto/plan-approval.js +361 -0
  14. package/dist/src/core/auto/plan-approval.js.map +1 -0
  15. package/package.json +1 -1
  16. package/plugins/specweave/commands/auto.md +86 -0
  17. package/plugins/specweave/hooks/lib/resolve-package.sh +126 -0
  18. package/plugins/specweave/hooks/lib/sync-spec-content.sh +47 -3
  19. package/plugins/specweave/hooks/stop-auto.sh +119 -0
  20. package/plugins/specweave/hooks/v2/handlers/github-sync-handler.sh +8 -0
  21. package/plugins/specweave/hooks/v2/handlers/living-docs-handler.sh +10 -3
  22. package/plugins/specweave/hooks/v2/handlers/living-specs-handler.sh +18 -7
  23. package/plugins/specweave/hooks/v2/handlers/project-bridge-handler.sh +10 -3
  24. package/plugins/specweave/hooks/v2/session-end.sh +50 -2
  25. package/plugins/specweave/hooks/v2/session-start.sh +68 -4
  26. package/plugins/specweave/scripts/chunk-prompt.js +204 -0
  27. package/plugins/specweave/scripts/setup-auto.sh +66 -0
@@ -0,0 +1,871 @@
1
+ /**
2
+ * E2E Coverage Manifest
3
+ *
4
+ * Tracks which routes, actions, and viewports have E2E test coverage.
5
+ * Auto-generates manifest from project routes (Next.js, React Router, etc.).
6
+ *
7
+ * @module e2e-coverage
8
+ */
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ /**
12
+ * Default viewports for responsive testing
13
+ */
14
+ export const DEFAULT_VIEWPORTS = {
15
+ mobile: { width: 375, height: 667 },
16
+ tablet: { width: 768, height: 1024 },
17
+ desktop: { width: 1280, height: 720 },
18
+ };
19
+ /**
20
+ * Detect which frontend framework is being used
21
+ *
22
+ * @param projectPath - Project root path
23
+ * @returns Detected framework type
24
+ */
25
+ export function detectFramework(projectPath) {
26
+ // Check for Next.js
27
+ const nextConfigPath = path.join(projectPath, 'next.config.js');
28
+ const nextConfigMjsPath = path.join(projectPath, 'next.config.mjs');
29
+ const nextConfigTsPath = path.join(projectPath, 'next.config.ts');
30
+ if (fs.existsSync(nextConfigPath) ||
31
+ fs.existsSync(nextConfigMjsPath) ||
32
+ fs.existsSync(nextConfigTsPath)) {
33
+ // Check for App Router vs Pages Router
34
+ const appDir = path.join(projectPath, 'app');
35
+ const srcAppDir = path.join(projectPath, 'src/app');
36
+ if (fs.existsSync(appDir) || fs.existsSync(srcAppDir)) {
37
+ return 'nextjs-app';
38
+ }
39
+ return 'nextjs-pages';
40
+ }
41
+ // Check for Remix
42
+ const remixConfig = path.join(projectPath, 'remix.config.js');
43
+ if (fs.existsSync(remixConfig)) {
44
+ return 'remix';
45
+ }
46
+ // Check for SvelteKit
47
+ const svelteConfig = path.join(projectPath, 'svelte.config.js');
48
+ if (fs.existsSync(svelteConfig)) {
49
+ return 'svelte-kit';
50
+ }
51
+ // Check for Vue Router (nuxt or vue-router)
52
+ const nuxtConfig = path.join(projectPath, 'nuxt.config.js');
53
+ const nuxtConfigTs = path.join(projectPath, 'nuxt.config.ts');
54
+ if (fs.existsSync(nuxtConfig) || fs.existsSync(nuxtConfigTs)) {
55
+ return 'vue-router';
56
+ }
57
+ // Check package.json for React Router
58
+ const packageJsonPath = path.join(projectPath, 'package.json');
59
+ if (fs.existsSync(packageJsonPath)) {
60
+ try {
61
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
62
+ const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
63
+ if (deps['react-router'] || deps['react-router-dom']) {
64
+ return 'react-router';
65
+ }
66
+ }
67
+ catch {
68
+ // Ignore parse errors
69
+ }
70
+ }
71
+ return 'unknown';
72
+ }
73
+ /**
74
+ * Extract routes from Next.js Pages Router
75
+ */
76
+ function extractNextJsPagesRoutes(projectPath) {
77
+ const routes = [];
78
+ const pagesDir = fs.existsSync(path.join(projectPath, 'pages')) ?
79
+ path.join(projectPath, 'pages')
80
+ : path.join(projectPath, 'src/pages');
81
+ if (!fs.existsSync(pagesDir)) {
82
+ return routes;
83
+ }
84
+ function walkDir(dir, baseRoute = '') {
85
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
86
+ for (const entry of entries) {
87
+ const fullPath = path.join(dir, entry.name);
88
+ const baseName = entry.name.replace(/\.(tsx?|jsx?|mdx?)$/, '');
89
+ // Skip API routes, _app, _document, etc.
90
+ if (baseName.startsWith('_') || baseName === 'api') {
91
+ continue;
92
+ }
93
+ if (entry.isDirectory()) {
94
+ // Handle dynamic routes [slug]
95
+ const routeSegment = baseName.startsWith('[') ? `:${baseName.slice(1, -1)}` : baseName;
96
+ walkDir(fullPath, `${baseRoute}/${routeSegment}`);
97
+ }
98
+ else if (/\.(tsx?|jsx?|mdx?)$/.test(entry.name)) {
99
+ // Handle index files
100
+ if (baseName === 'index') {
101
+ routes.push(baseRoute || '/');
102
+ }
103
+ else {
104
+ // Handle dynamic routes [slug]
105
+ const routeSegment = baseName.startsWith('[') ? `:${baseName.slice(1, -1)}` : baseName;
106
+ routes.push(`${baseRoute}/${routeSegment}`);
107
+ }
108
+ }
109
+ }
110
+ }
111
+ walkDir(pagesDir);
112
+ return routes;
113
+ }
114
+ /**
115
+ * Extract routes from Next.js App Router
116
+ */
117
+ function extractNextJsAppRoutes(projectPath) {
118
+ const routes = [];
119
+ const appDir = fs.existsSync(path.join(projectPath, 'app')) ?
120
+ path.join(projectPath, 'app')
121
+ : path.join(projectPath, 'src/app');
122
+ if (!fs.existsSync(appDir)) {
123
+ return routes;
124
+ }
125
+ function walkDir(dir, baseRoute = '') {
126
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
127
+ // Check if this directory has a page.tsx/page.js
128
+ const hasPage = entries.some((e) => !e.isDirectory() && /^page\.(tsx?|jsx?)$/.test(e.name));
129
+ if (hasPage) {
130
+ routes.push(baseRoute || '/');
131
+ }
132
+ for (const entry of entries) {
133
+ if (!entry.isDirectory())
134
+ continue;
135
+ const baseName = entry.name;
136
+ // Skip private folders, API routes, and special Next.js folders
137
+ if (baseName.startsWith('_') ||
138
+ baseName.startsWith('.') ||
139
+ baseName === 'api' ||
140
+ baseName.startsWith('@')) {
141
+ continue;
142
+ }
143
+ // Handle route groups (ignored in URL)
144
+ if (baseName.startsWith('(') && baseName.endsWith(')')) {
145
+ walkDir(path.join(dir, baseName), baseRoute);
146
+ continue;
147
+ }
148
+ // Handle dynamic routes [slug]
149
+ const routeSegment = baseName.startsWith('[') ? `:${baseName.slice(1, -1)}` : baseName;
150
+ walkDir(path.join(dir, baseName), `${baseRoute}/${routeSegment}`);
151
+ }
152
+ }
153
+ walkDir(appDir);
154
+ return routes;
155
+ }
156
+ /**
157
+ * Extract routes from React Router configuration
158
+ * Note: This is a best-effort approach since React Router config is runtime
159
+ */
160
+ function extractReactRouterRoutes(projectPath) {
161
+ const routes = [];
162
+ const patterns = ['src/routes', 'src/router', 'src/app/routes'];
163
+ for (const pattern of patterns) {
164
+ const routesDir = path.join(projectPath, pattern);
165
+ if (fs.existsSync(routesDir)) {
166
+ // Look for route files
167
+ const files = fs.readdirSync(routesDir);
168
+ for (const file of files) {
169
+ if (/\.(tsx?|jsx?)$/.test(file)) {
170
+ const routeName = file.replace(/\.(tsx?|jsx?)$/, '').toLowerCase();
171
+ if (routeName === 'index' || routeName === 'root') {
172
+ routes.push('/');
173
+ }
174
+ else {
175
+ routes.push(`/${routeName}`);
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ // Fallback: scan for <Route path="..." /> patterns in source files
182
+ if (routes.length === 0) {
183
+ const srcDir = path.join(projectPath, 'src');
184
+ if (fs.existsSync(srcDir)) {
185
+ const routePattern = /<Route[^>]*path=["']([^"']+)["']/g;
186
+ scanForPatterns(srcDir, routePattern, routes);
187
+ }
188
+ }
189
+ return routes;
190
+ }
191
+ /**
192
+ * Scan files for route patterns
193
+ */
194
+ function scanForPatterns(dir, pattern, routes) {
195
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
196
+ for (const entry of entries) {
197
+ const fullPath = path.join(dir, entry.name);
198
+ if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
199
+ scanForPatterns(fullPath, pattern, routes);
200
+ }
201
+ else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
202
+ try {
203
+ const content = fs.readFileSync(fullPath, 'utf-8');
204
+ let match;
205
+ while ((match = pattern.exec(content)) !== null) {
206
+ if (match[1] && !routes.includes(match[1])) {
207
+ routes.push(match[1]);
208
+ }
209
+ }
210
+ }
211
+ catch {
212
+ // Skip files that can't be read
213
+ }
214
+ }
215
+ }
216
+ }
217
+ /**
218
+ * Extract routes from project based on detected framework
219
+ *
220
+ * @param projectPath - Project root path
221
+ * @param framework - Optional framework override
222
+ * @returns Array of route paths
223
+ */
224
+ export function extractRoutes(projectPath, framework) {
225
+ const detectedFramework = framework || detectFramework(projectPath);
226
+ switch (detectedFramework) {
227
+ case 'nextjs-pages':
228
+ return extractNextJsPagesRoutes(projectPath);
229
+ case 'nextjs-app':
230
+ return extractNextJsAppRoutes(projectPath);
231
+ case 'react-router':
232
+ return extractReactRouterRoutes(projectPath);
233
+ case 'remix':
234
+ // Remix uses file-based routing similar to Next.js
235
+ return extractNextJsPagesRoutes(projectPath);
236
+ case 'svelte-kit':
237
+ // SvelteKit uses routes/ directory
238
+ return extractSvelteKitRoutes(projectPath);
239
+ case 'vue-router':
240
+ return extractVueRoutes(projectPath);
241
+ default:
242
+ // Fallback: try all methods and combine
243
+ const routes = new Set();
244
+ extractNextJsPagesRoutes(projectPath).forEach((r) => routes.add(r));
245
+ extractNextJsAppRoutes(projectPath).forEach((r) => routes.add(r));
246
+ extractReactRouterRoutes(projectPath).forEach((r) => routes.add(r));
247
+ return Array.from(routes);
248
+ }
249
+ }
250
+ /**
251
+ * Extract routes from SvelteKit
252
+ */
253
+ function extractSvelteKitRoutes(projectPath) {
254
+ const routes = [];
255
+ const routesDir = path.join(projectPath, 'src/routes');
256
+ if (!fs.existsSync(routesDir)) {
257
+ return routes;
258
+ }
259
+ function walkDir(dir, baseRoute = '') {
260
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
261
+ // Check if this directory has a +page.svelte
262
+ const hasPage = entries.some((e) => !e.isDirectory() && e.name === '+page.svelte');
263
+ if (hasPage) {
264
+ routes.push(baseRoute || '/');
265
+ }
266
+ for (const entry of entries) {
267
+ if (!entry.isDirectory())
268
+ continue;
269
+ const baseName = entry.name;
270
+ // Skip private folders and layout groups
271
+ if (baseName.startsWith('_') || baseName.startsWith('.')) {
272
+ continue;
273
+ }
274
+ // Handle route groups (ignored in URL)
275
+ if (baseName.startsWith('(') && baseName.endsWith(')')) {
276
+ walkDir(path.join(dir, baseName), baseRoute);
277
+ continue;
278
+ }
279
+ // Handle dynamic routes [slug]
280
+ const routeSegment = baseName.startsWith('[') ? `:${baseName.slice(1, -1)}` : baseName;
281
+ walkDir(path.join(dir, baseName), `${baseRoute}/${routeSegment}`);
282
+ }
283
+ }
284
+ walkDir(routesDir);
285
+ return routes;
286
+ }
287
+ /**
288
+ * Extract routes from Vue/Nuxt
289
+ */
290
+ function extractVueRoutes(projectPath) {
291
+ const routes = [];
292
+ const pagesDir = path.join(projectPath, 'pages');
293
+ if (!fs.existsSync(pagesDir)) {
294
+ return routes;
295
+ }
296
+ function walkDir(dir, baseRoute = '') {
297
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
298
+ for (const entry of entries) {
299
+ const fullPath = path.join(dir, entry.name);
300
+ const baseName = entry.name.replace(/\.vue$/, '');
301
+ if (entry.isDirectory()) {
302
+ const routeSegment = baseName.startsWith('_') ? `:${baseName.slice(1)}` : baseName;
303
+ walkDir(fullPath, `${baseRoute}/${routeSegment}`);
304
+ }
305
+ else if (entry.name.endsWith('.vue')) {
306
+ if (baseName === 'index') {
307
+ routes.push(baseRoute || '/');
308
+ }
309
+ else {
310
+ const routeSegment = baseName.startsWith('_') ? `:${baseName.slice(1)}` : baseName;
311
+ routes.push(`${baseRoute}/${routeSegment}`);
312
+ }
313
+ }
314
+ }
315
+ }
316
+ walkDir(pagesDir);
317
+ return routes;
318
+ }
319
+ /**
320
+ * Load manual routes override from routes.json
321
+ */
322
+ export function loadManualRoutes(projectPath) {
323
+ const routesJsonPath = path.join(projectPath, 'routes.json');
324
+ const specweaveRoutesPath = path.join(projectPath, '.specweave/routes.json');
325
+ const routesPath = fs.existsSync(specweaveRoutesPath) ? specweaveRoutesPath : routesJsonPath;
326
+ if (!fs.existsSync(routesPath)) {
327
+ return null;
328
+ }
329
+ try {
330
+ const content = fs.readFileSync(routesPath, 'utf-8');
331
+ const data = JSON.parse(content);
332
+ return Array.isArray(data.routes) ? data.routes : data;
333
+ }
334
+ catch {
335
+ return null;
336
+ }
337
+ }
338
+ /**
339
+ * Generate E2E coverage manifest
340
+ *
341
+ * @param projectPath - Project root path
342
+ * @param options - Generation options
343
+ * @returns Generated manifest
344
+ *
345
+ * @example
346
+ * ```typescript
347
+ * const manifest = generateCoverageManifest('/path/to/project');
348
+ * console.log(manifest.coverage.routes); // Coverage percentage
349
+ * ```
350
+ */
351
+ export function generateCoverageManifest(projectPath, options = {}) {
352
+ // Try manual routes first
353
+ let routes = loadManualRoutes(projectPath);
354
+ // Auto-detect if no manual routes
355
+ if (!routes || routes.length === 0) {
356
+ routes = extractRoutes(projectPath, options.framework);
357
+ }
358
+ // Build routes object
359
+ const routesObj = {};
360
+ for (const route of routes) {
361
+ routesObj[route] = {
362
+ path: route,
363
+ tested: false,
364
+ viewports: [],
365
+ };
366
+ }
367
+ // Generate manifest
368
+ const manifest = {
369
+ version: '1.0.0',
370
+ generatedAt: new Date().toISOString(),
371
+ framework: detectFramework(projectPath),
372
+ routes: routesObj,
373
+ criticalActions: {},
374
+ viewportsCovered: {
375
+ mobile: false,
376
+ tablet: false,
377
+ desktop: false,
378
+ },
379
+ coverage: {
380
+ routes: 0,
381
+ actions: 0,
382
+ viewports: 0,
383
+ },
384
+ };
385
+ return manifest;
386
+ }
387
+ /**
388
+ * Save manifest to state directory
389
+ */
390
+ export function saveManifest(projectPath, manifest) {
391
+ const stateDir = path.join(projectPath, '.specweave/state');
392
+ if (!fs.existsSync(stateDir)) {
393
+ fs.mkdirSync(stateDir, { recursive: true });
394
+ }
395
+ const manifestPath = path.join(stateDir, 'e2e-manifest.json');
396
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
397
+ }
398
+ /**
399
+ * Load existing manifest from state directory
400
+ */
401
+ export function loadManifest(projectPath) {
402
+ const manifestPath = path.join(projectPath, '.specweave/state/e2e-manifest.json');
403
+ if (!fs.existsSync(manifestPath)) {
404
+ return null;
405
+ }
406
+ try {
407
+ const content = fs.readFileSync(manifestPath, 'utf-8');
408
+ return JSON.parse(content);
409
+ }
410
+ catch {
411
+ return null;
412
+ }
413
+ }
414
+ /**
415
+ * Update route coverage in manifest
416
+ */
417
+ export function updateRouteCoverage(manifest, route, viewports = []) {
418
+ if (manifest.routes[route]) {
419
+ manifest.routes[route].tested = true;
420
+ manifest.routes[route].viewports = [
421
+ ...new Set([...manifest.routes[route].viewports, ...viewports]),
422
+ ];
423
+ manifest.routes[route].lastTested = new Date().toISOString();
424
+ }
425
+ // Update viewport coverage
426
+ for (const viewport of viewports) {
427
+ if (viewport in manifest.viewportsCovered) {
428
+ manifest.viewportsCovered[viewport] = true;
429
+ }
430
+ }
431
+ // Recalculate coverage stats
432
+ manifest.coverage = calculateCoverage(manifest);
433
+ return manifest;
434
+ }
435
+ /**
436
+ * Calculate coverage statistics
437
+ */
438
+ export function calculateCoverage(manifest) {
439
+ const routes = Object.values(manifest.routes);
440
+ const actions = Object.values(manifest.criticalActions);
441
+ const viewports = Object.values(manifest.viewportsCovered);
442
+ const testedRoutes = routes.filter((r) => r.tested).length;
443
+ const testedActions = actions.filter((a) => a.tested).length;
444
+ const testedViewports = viewports.filter((v) => v).length;
445
+ return {
446
+ routes: routes.length > 0 ? Math.round((testedRoutes / routes.length) * 100) : 0,
447
+ actions: actions.length > 0 ? Math.round((testedActions / actions.length) * 100) : 0,
448
+ viewports: Math.round((testedViewports / 3) * 100),
449
+ };
450
+ }
451
+ /**
452
+ * Get untested routes
453
+ */
454
+ export function getUntestedRoutes(manifest) {
455
+ return Object.values(manifest.routes)
456
+ .filter((r) => !r.tested)
457
+ .map((r) => r.path);
458
+ }
459
+ /**
460
+ * Get routes missing viewport coverage
461
+ */
462
+ export function getRoutesWithMissingViewports(manifest) {
463
+ const allViewports = ['mobile', 'tablet', 'desktop'];
464
+ return Object.values(manifest.routes)
465
+ .filter((r) => r.tested && r.viewports.length < 3)
466
+ .map((r) => ({
467
+ route: r.path,
468
+ missingViewports: allViewports.filter((v) => !r.viewports.includes(v)),
469
+ }));
470
+ }
471
+ /**
472
+ * Parse Playwright test output to extract visited routes
473
+ *
474
+ * Looks for patterns like:
475
+ * - page.goto('http://localhost:3000/login')
476
+ * - await page.goto('/dashboard')
477
+ * - navigating to "http://localhost:3000/products"
478
+ * - → /api/users (XHR) - skip API routes
479
+ *
480
+ * @param output - Test output (stdout/stderr combined)
481
+ * @param baseUrl - Optional base URL to normalize routes
482
+ * @returns Array of route visits
483
+ */
484
+ export function parseRouteVisits(output, baseUrl) {
485
+ const visits = [];
486
+ const seenRoutes = new Set();
487
+ // Normalize base URL (remove trailing slash)
488
+ const normalizedBaseUrl = baseUrl?.replace(/\/$/, '') || 'http://localhost:3000';
489
+ // Pattern 1: page.goto('url') or page.goto("url")
490
+ const gotoPattern = /page\.goto\s*\(\s*['"`]([^'"`]+)['"`]/gi;
491
+ // Pattern 2: navigating to "url"
492
+ const navigatingPattern = /navigating to\s*['"`]?([^'"`\s]+)['"`]?/gi;
493
+ // Pattern 3: Playwright trace output: GET http://... (document)
494
+ const tracePattern = /(?:GET|POST)\s+(https?:\/\/[^\s]+)\s*\(document\)/gi;
495
+ // Pattern 4: [chromium|webkit|firefox] › path/to/test.spec.ts:line
496
+ // This helps identify which viewport/project ran
497
+ const projectPattern = /\[(chromium|webkit|firefox|Mobile\s*\w+|Desktop\s*\w+|mobile|tablet|desktop)\]/gi;
498
+ // Track current viewport from project name
499
+ let currentViewport;
500
+ // Extract viewport from project patterns in the output
501
+ const lines = output.split('\n');
502
+ for (const line of lines) {
503
+ // Check for viewport/project indicator
504
+ const projectMatch = projectPattern.exec(line);
505
+ if (projectMatch) {
506
+ const project = projectMatch[1].toLowerCase();
507
+ if (project.includes('mobile') || project === 'webkit') {
508
+ currentViewport = 'mobile';
509
+ }
510
+ else if (project.includes('tablet')) {
511
+ currentViewport = 'tablet';
512
+ }
513
+ else if (project.includes('desktop') || project === 'chromium' || project === 'firefox') {
514
+ currentViewport = 'desktop';
515
+ }
516
+ }
517
+ // Reset regex lastIndex for each line
518
+ gotoPattern.lastIndex = 0;
519
+ navigatingPattern.lastIndex = 0;
520
+ tracePattern.lastIndex = 0;
521
+ let match;
522
+ // Pattern 1: page.goto()
523
+ while ((match = gotoPattern.exec(line)) !== null) {
524
+ const route = normalizeRoute(match[1], normalizedBaseUrl);
525
+ if (route && !route.startsWith('/api') && !seenRoutes.has(route + currentViewport)) {
526
+ seenRoutes.add(route + currentViewport);
527
+ visits.push({ route, viewport: currentViewport });
528
+ }
529
+ }
530
+ // Pattern 2: navigating to
531
+ while ((match = navigatingPattern.exec(line)) !== null) {
532
+ const route = normalizeRoute(match[1], normalizedBaseUrl);
533
+ if (route && !route.startsWith('/api') && !seenRoutes.has(route + currentViewport)) {
534
+ seenRoutes.add(route + currentViewport);
535
+ visits.push({ route, viewport: currentViewport });
536
+ }
537
+ }
538
+ // Pattern 3: trace GET/POST (document)
539
+ while ((match = tracePattern.exec(line)) !== null) {
540
+ const route = normalizeRoute(match[1], normalizedBaseUrl);
541
+ if (route && !route.startsWith('/api') && !seenRoutes.has(route + currentViewport)) {
542
+ seenRoutes.add(route + currentViewport);
543
+ visits.push({ route, viewport: currentViewport });
544
+ }
545
+ }
546
+ }
547
+ return visits;
548
+ }
549
+ /**
550
+ * Normalize a URL or path to a route
551
+ */
552
+ function normalizeRoute(urlOrPath, baseUrl) {
553
+ try {
554
+ // Handle full URLs
555
+ if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) {
556
+ const url = new URL(urlOrPath);
557
+ // Skip external domains
558
+ const baseUrlHost = new URL(baseUrl).host;
559
+ if (url.host !== baseUrlHost && !url.host.includes('localhost')) {
560
+ return null;
561
+ }
562
+ return url.pathname || '/';
563
+ }
564
+ // Handle relative paths
565
+ if (urlOrPath.startsWith('/')) {
566
+ return urlOrPath.split('?')[0]; // Remove query string
567
+ }
568
+ // Handle paths without leading slash
569
+ return '/' + urlOrPath.split('?')[0];
570
+ }
571
+ catch {
572
+ return null;
573
+ }
574
+ }
575
+ /**
576
+ * Match a visited route against manifest routes (handling dynamic segments)
577
+ *
578
+ * @param visitedRoute - The actual route visited (e.g., "/products/123")
579
+ * @param manifestRoutes - Routes in manifest (may include :params like "/products/:id")
580
+ * @returns Matched manifest route or null
581
+ */
582
+ export function matchRouteToManifest(visitedRoute, manifestRoutes) {
583
+ // Exact match first
584
+ if (manifestRoutes.includes(visitedRoute)) {
585
+ return visitedRoute;
586
+ }
587
+ // Try dynamic route matching
588
+ for (const manifestRoute of manifestRoutes) {
589
+ if (manifestRoute.includes(':')) {
590
+ // Convert "/products/:id" to regex "/products/[^/]+"
591
+ const pattern = manifestRoute
592
+ .replace(/:[^/]+/g, '[^/]+')
593
+ .replace(/\//g, '\\/');
594
+ const regex = new RegExp(`^${pattern}$`);
595
+ if (regex.test(visitedRoute)) {
596
+ return manifestRoute;
597
+ }
598
+ }
599
+ }
600
+ return null;
601
+ }
602
+ /**
603
+ * Track route coverage from test output
604
+ *
605
+ * Parses test output, extracts route visits, and updates manifest.
606
+ *
607
+ * @param projectPath - Project root path
608
+ * @param testOutput - Raw test output
609
+ * @param options - Tracking options
610
+ * @returns Tracking result with updated manifest
611
+ *
612
+ * @example
613
+ * ```typescript
614
+ * const result = trackRouteCoverage('/path/to/project', testOutput);
615
+ * console.log(`Tested ${result.newlyTested.length} new routes`);
616
+ * ```
617
+ */
618
+ export function trackRouteCoverage(projectPath, testOutput, options = {}) {
619
+ // Load or generate manifest
620
+ let manifest = loadManifest(projectPath);
621
+ if (!manifest) {
622
+ manifest = generateCoverageManifest(projectPath);
623
+ }
624
+ const visits = parseRouteVisits(testOutput, options.baseUrl);
625
+ const manifestRoutes = Object.keys(manifest.routes);
626
+ const newlyTested = [];
627
+ const viewportUpdates = [];
628
+ for (const visit of visits) {
629
+ // Match visited route to manifest route
630
+ const matchedRoute = matchRouteToManifest(visit.route, manifestRoutes);
631
+ if (matchedRoute && manifest.routes[matchedRoute]) {
632
+ const routeEntry = manifest.routes[matchedRoute];
633
+ const wasTestedBefore = routeEntry.tested;
634
+ // Determine viewport (from visit, options, or default)
635
+ const viewport = visit.viewport || options.viewport || 'desktop';
636
+ // Track viewport updates BEFORE updating (to know what's new)
637
+ const hadViewport = routeEntry.viewports.includes(viewport);
638
+ if (viewport && !hadViewport) {
639
+ viewportUpdates.push({ route: matchedRoute, viewport });
640
+ }
641
+ // Update route coverage
642
+ const viewports = viewport ? [viewport] : [];
643
+ manifest = updateRouteCoverage(manifest, matchedRoute, viewports);
644
+ // Track newly tested routes
645
+ if (!wasTestedBefore) {
646
+ newlyTested.push(matchedRoute);
647
+ }
648
+ }
649
+ }
650
+ // Save updated manifest
651
+ saveManifest(projectPath, manifest);
652
+ return {
653
+ visits,
654
+ manifest,
655
+ newlyTested,
656
+ viewportUpdates,
657
+ };
658
+ }
659
+ /**
660
+ * Parse viewport from Playwright project name
661
+ *
662
+ * @param projectName - Playwright project name from config or output
663
+ * @returns Normalized viewport name
664
+ */
665
+ export function parseViewportFromProject(projectName) {
666
+ const name = projectName.toLowerCase();
667
+ // Common mobile indicators
668
+ if (name.includes('mobile') ||
669
+ name.includes('iphone') ||
670
+ name.includes('pixel') ||
671
+ name.includes('android') ||
672
+ name === 'webkit') {
673
+ return 'mobile';
674
+ }
675
+ // Tablet indicators
676
+ if (name.includes('tablet') || name.includes('ipad')) {
677
+ return 'tablet';
678
+ }
679
+ // Desktop (default for chromium/firefox/edge)
680
+ return 'desktop';
681
+ }
682
+ /**
683
+ * Parse Playwright config to extract viewport/project configuration
684
+ *
685
+ * @param projectPath - Project root path
686
+ * @returns Parsed viewport configuration
687
+ *
688
+ * @example
689
+ * ```typescript
690
+ * const config = parsePlaywrightConfig('/path/to/project');
691
+ * console.log(config.viewports.mobile); // true if mobile viewport configured
692
+ * ```
693
+ */
694
+ export function parsePlaywrightConfig(projectPath) {
695
+ const projects = [];
696
+ const viewports = { mobile: false, tablet: false, desktop: false };
697
+ // Try different config file names
698
+ const configNames = [
699
+ 'playwright.config.ts',
700
+ 'playwright.config.js',
701
+ 'playwright.config.mjs',
702
+ ];
703
+ let configPath;
704
+ let configContent;
705
+ for (const name of configNames) {
706
+ const fullPath = path.join(projectPath, name);
707
+ if (fs.existsSync(fullPath)) {
708
+ configPath = fullPath;
709
+ configContent = fs.readFileSync(fullPath, 'utf-8');
710
+ break;
711
+ }
712
+ }
713
+ if (!configContent) {
714
+ return { projects, viewports };
715
+ }
716
+ // Parse projects array from config
717
+ // Pattern: projects: [ { name: '...', use: { viewport: { width: X, height: Y } } } ]
718
+ const projectPattern = /name:\s*['"`]([^'"`]+)['"`][^}]*viewport:\s*\{[^}]*width:\s*(\d+)/gi;
719
+ let match;
720
+ while ((match = projectPattern.exec(configContent)) !== null) {
721
+ const name = match[1];
722
+ const width = parseInt(match[2], 10);
723
+ // Categorize by width
724
+ let viewport;
725
+ if (width <= 480) {
726
+ viewport = 'mobile';
727
+ viewports.mobile = true;
728
+ }
729
+ else if (width <= 768) {
730
+ viewport = 'tablet';
731
+ viewports.tablet = true;
732
+ }
733
+ else {
734
+ viewport = 'desktop';
735
+ viewports.desktop = true;
736
+ }
737
+ projects.push({ name, viewport, width });
738
+ }
739
+ // Alternative pattern: devices like 'iPhone 12', 'iPad Pro', etc.
740
+ const devicePattern = /devices\s*\[\s*['"`]([^'"`]+)['"`]\s*\]/gi;
741
+ while ((match = devicePattern.exec(configContent)) !== null) {
742
+ const device = match[1];
743
+ let viewport;
744
+ if (/iphone|pixel|android|mobile/i.test(device)) {
745
+ viewport = 'mobile';
746
+ viewports.mobile = true;
747
+ }
748
+ else if (/ipad|tablet/i.test(device)) {
749
+ viewport = 'tablet';
750
+ viewports.tablet = true;
751
+ }
752
+ else {
753
+ viewport = 'desktop';
754
+ viewports.desktop = true;
755
+ }
756
+ projects.push({ name: device, viewport });
757
+ }
758
+ // Check for common project names
759
+ const projectNamePattern = /name:\s*['"`](mobile|tablet|desktop|Mobile\s*\w+|Desktop\s*\w+|chromium|webkit|firefox)['"`]/gi;
760
+ while ((match = projectNamePattern.exec(configContent)) !== null) {
761
+ const name = match[1].toLowerCase();
762
+ if (name.includes('mobile') || name === 'webkit') {
763
+ viewports.mobile = true;
764
+ if (!projects.find((p) => p.name.toLowerCase() === name)) {
765
+ projects.push({ name: match[1], viewport: 'mobile' });
766
+ }
767
+ }
768
+ else if (name.includes('tablet')) {
769
+ viewports.tablet = true;
770
+ if (!projects.find((p) => p.name.toLowerCase() === name)) {
771
+ projects.push({ name: match[1], viewport: 'tablet' });
772
+ }
773
+ }
774
+ else if (name.includes('desktop') || name === 'chromium' || name === 'firefox') {
775
+ viewports.desktop = true;
776
+ if (!projects.find((p) => p.name.toLowerCase() === name)) {
777
+ projects.push({ name: match[1], viewport: 'desktop' });
778
+ }
779
+ }
780
+ }
781
+ // If we found chromium/firefox/webkit but no explicit viewports, default to desktop
782
+ if (projects.length > 0 && !viewports.mobile && !viewports.tablet && !viewports.desktop) {
783
+ viewports.desktop = true;
784
+ }
785
+ return { projects, viewports, configPath };
786
+ }
787
+ /**
788
+ * Get required viewports from config or defaults
789
+ *
790
+ * @param projectPath - Project root path
791
+ * @returns Array of required viewport names
792
+ */
793
+ export function getRequiredViewports(projectPath) {
794
+ const config = parsePlaywrightConfig(projectPath);
795
+ // If config has explicit viewports, use those
796
+ if (config.projects.length > 0) {
797
+ const viewports = new Set();
798
+ for (const project of config.projects) {
799
+ viewports.add(project.viewport);
800
+ }
801
+ return Array.from(viewports);
802
+ }
803
+ // Default: require all three viewports
804
+ return ['mobile', 'tablet', 'desktop'];
805
+ }
806
+ /**
807
+ * Check if viewport coverage meets requirements
808
+ *
809
+ * @param manifest - E2E coverage manifest
810
+ * @param projectPath - Project root path
811
+ * @returns Coverage check result
812
+ */
813
+ export function checkViewportCoverage(manifest, projectPath) {
814
+ const required = getRequiredViewports(projectPath);
815
+ const covered = [];
816
+ const missing = [];
817
+ for (const viewport of required) {
818
+ if (manifest.viewportsCovered[viewport]) {
819
+ covered.push(viewport);
820
+ }
821
+ else {
822
+ missing.push(viewport);
823
+ }
824
+ }
825
+ return {
826
+ complete: missing.length === 0,
827
+ required,
828
+ covered,
829
+ missing,
830
+ };
831
+ }
832
+ export function generateCoverageReport(manifest) {
833
+ const lines = [];
834
+ const coverage = calculateCoverage(manifest);
835
+ lines.push('📊 E2E Coverage Report');
836
+ lines.push('═'.repeat(50));
837
+ lines.push('');
838
+ lines.push(`Framework: ${manifest.framework || 'Unknown'}`);
839
+ lines.push(`Generated: ${manifest.generatedAt}`);
840
+ lines.push('');
841
+ lines.push('Coverage Summary:');
842
+ lines.push(` Routes: ${coverage.routes}%`);
843
+ lines.push(` Viewports: ${coverage.viewports}%`);
844
+ lines.push(` Actions: ${coverage.actions}%`);
845
+ lines.push('');
846
+ // Untested routes
847
+ const untested = getUntestedRoutes(manifest);
848
+ if (untested.length > 0) {
849
+ lines.push('❌ Untested Routes:');
850
+ for (const route of untested) {
851
+ lines.push(` ${route}`);
852
+ }
853
+ lines.push('');
854
+ }
855
+ // Incomplete viewport coverage
856
+ const incomplete = getRoutesWithMissingViewports(manifest);
857
+ if (incomplete.length > 0) {
858
+ lines.push('⚠️ Incomplete Viewport Coverage:');
859
+ for (const { route, missingViewports } of incomplete) {
860
+ lines.push(` ${route} → missing: ${missingViewports.join(', ')}`);
861
+ }
862
+ lines.push('');
863
+ }
864
+ // Viewport summary
865
+ lines.push('Viewport Coverage:');
866
+ lines.push(` 📱 Mobile: ${manifest.viewportsCovered.mobile ? '✅' : '❌'}`);
867
+ lines.push(` 📱 Tablet: ${manifest.viewportsCovered.tablet ? '✅' : '❌'}`);
868
+ lines.push(` 🖥️ Desktop: ${manifest.viewportsCovered.desktop ? '✅' : '❌'}`);
869
+ return lines.join('\n');
870
+ }
871
+ //# sourceMappingURL=e2e-coverage.js.map