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.
- package/dist/src/core/auto/e2e-coverage.d.ts +248 -0
- package/dist/src/core/auto/e2e-coverage.d.ts.map +1 -0
- package/dist/src/core/auto/e2e-coverage.js +871 -0
- package/dist/src/core/auto/e2e-coverage.js.map +1 -0
- package/dist/src/core/auto/increment-planner.js +7 -1
- package/dist/src/core/auto/increment-planner.js.map +1 -1
- package/dist/src/core/auto/index.d.ts +2 -0
- package/dist/src/core/auto/index.d.ts.map +1 -1
- package/dist/src/core/auto/index.js +4 -0
- package/dist/src/core/auto/index.js.map +1 -1
- package/dist/src/core/auto/plan-approval.d.ts +104 -0
- package/dist/src/core/auto/plan-approval.d.ts.map +1 -0
- package/dist/src/core/auto/plan-approval.js +361 -0
- package/dist/src/core/auto/plan-approval.js.map +1 -0
- package/package.json +1 -1
- package/plugins/specweave/commands/auto.md +86 -0
- package/plugins/specweave/hooks/lib/resolve-package.sh +126 -0
- package/plugins/specweave/hooks/lib/sync-spec-content.sh +47 -3
- package/plugins/specweave/hooks/stop-auto.sh +119 -0
- package/plugins/specweave/hooks/v2/handlers/github-sync-handler.sh +8 -0
- package/plugins/specweave/hooks/v2/handlers/living-docs-handler.sh +10 -3
- package/plugins/specweave/hooks/v2/handlers/living-specs-handler.sh +18 -7
- package/plugins/specweave/hooks/v2/handlers/project-bridge-handler.sh +10 -3
- package/plugins/specweave/hooks/v2/session-end.sh +50 -2
- package/plugins/specweave/hooks/v2/session-start.sh +68 -4
- package/plugins/specweave/scripts/chunk-prompt.js +204 -0
- 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
|