@testivai/witness-playwright 1.0.0
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/__tests__/.gitkeep +0 -0
- package/__tests__/config-integration.spec.ts +102 -0
- package/__tests__/snapshot.spec.d.ts +1 -0
- package/__tests__/snapshot.spec.js +81 -0
- package/__tests__/snapshot.spec.ts +58 -0
- package/__tests__/unit/ci.spec.d.ts +1 -0
- package/__tests__/unit/ci.spec.js +35 -0
- package/__tests__/unit/ci.spec.ts +40 -0
- package/__tests__/unit/reporter.spec.d.ts +1 -0
- package/__tests__/unit/reporter.spec.js +37 -0
- package/__tests__/unit/reporter.spec.ts +43 -0
- package/__tests__/unit/structureAnalyzer.spec.js +212 -0
- package/__tests__/unit/types.spec.ts +179 -0
- package/dist/__tests__/unit/ci.spec.d.ts +1 -0
- package/dist/__tests__/unit/ci.spec.js +226 -0
- package/dist/__tests__/unit/compression.spec.d.ts +4 -0
- package/dist/__tests__/unit/compression.spec.js +46 -0
- package/dist/ci.d.ts +30 -0
- package/dist/ci.js +117 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +47 -0
- package/dist/cli/init.d.ts +3 -0
- package/dist/cli/init.js +158 -0
- package/dist/config/loader.d.ts +29 -0
- package/dist/config/loader.js +251 -0
- package/dist/domAnalyzer.d.ts +10 -0
- package/dist/domAnalyzer.js +285 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +11 -0
- package/dist/reporter-entry.d.ts +2 -0
- package/dist/reporter-entry.js +5 -0
- package/dist/reporter-types.d.ts +2 -0
- package/dist/reporter-types.js +2 -0
- package/dist/reporter.d.ts +21 -0
- package/dist/reporter.js +249 -0
- package/dist/snapshot.d.ts +12 -0
- package/dist/snapshot.js +601 -0
- package/dist/structureAnalyzer.d.ts +12 -0
- package/dist/structureAnalyzer.js +288 -0
- package/dist/types.d.ts +368 -0
- package/dist/types.js +10 -0
- package/examples/structure-analysis-example.spec.ts +118 -0
- package/examples/structure-analysis.config.ts +159 -0
- package/jest.config.js +8 -0
- package/package.json +51 -0
- package/playwright.config.ts +11 -0
- package/src/__tests__/unit/ci.spec.ts +257 -0
- package/src/__tests__/unit/compression.spec.ts +52 -0
- package/src/ci.ts +140 -0
- package/src/cli/index.ts +49 -0
- package/src/cli/init.ts +131 -0
- package/src/config/loader.ts +238 -0
- package/src/index.ts +14 -0
- package/src/reporter-entry.ts +6 -0
- package/src/reporter-types.ts +5 -0
- package/src/reporter.ts +251 -0
- package/src/snapshot.ts +632 -0
- package/src/structureAnalyzer.ts +338 -0
- package/src/types.ts +388 -0
- package/tsconfig.jest.json +7 -0
- package/tsconfig.json +20 -0
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.snapshot = snapshot;
|
|
40
|
+
const fs = __importStar(require("fs-extra"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const url_1 = require("url");
|
|
43
|
+
const sharp_1 = __importDefault(require("sharp"));
|
|
44
|
+
const loader_1 = require("./config/loader");
|
|
45
|
+
/**
|
|
46
|
+
* Generates a safe filename from a URL.
|
|
47
|
+
* @param pageUrl The URL of the page.
|
|
48
|
+
* @returns A sanitized string suitable for a filename.
|
|
49
|
+
*/
|
|
50
|
+
function getSnapshotNameFromUrl(pageUrl) {
|
|
51
|
+
// Handle data URIs, which are common in test environments
|
|
52
|
+
if (pageUrl.startsWith('data:')) {
|
|
53
|
+
return 'snapshot';
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const url = new url_1.URL(pageUrl);
|
|
57
|
+
const pathName = url.pathname.substring(1).replace(/\//g, '_'); // remove leading slash and replace others
|
|
58
|
+
return pathName || 'home';
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
// Fallback for invalid URLs
|
|
62
|
+
return 'snapshot';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Captures a snapshot of the page, including a screenshot, DOM, and layout data.
|
|
67
|
+
* The evidence is stored in a temporary directory for the reporter to process later.
|
|
68
|
+
*
|
|
69
|
+
* @param page The Playwright Page object.
|
|
70
|
+
* @param testInfo The Playwright TestInfo object, passed from the test.
|
|
71
|
+
* @param name An optional unique name for the snapshot. If not provided, a name is generated from the URL.
|
|
72
|
+
* @param config Optional TestivAI configuration for this snapshot (overrides project defaults).
|
|
73
|
+
*/
|
|
74
|
+
async function snapshot(page, testInfo, name, config) {
|
|
75
|
+
// Load project configuration and merge with test-specific overrides
|
|
76
|
+
const projectConfig = await (0, loader_1.loadConfig)();
|
|
77
|
+
const effectiveConfig = (0, loader_1.mergeTestConfig)(projectConfig, config);
|
|
78
|
+
// Debug: Log config
|
|
79
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
80
|
+
console.log('[TestivAI] Config:', {
|
|
81
|
+
projectConfig,
|
|
82
|
+
testConfig: config,
|
|
83
|
+
effectiveConfig
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const outputDir = path.join(process.cwd(), '.testivai', 'temp');
|
|
87
|
+
await fs.ensureDir(outputDir);
|
|
88
|
+
const snapshotName = name || getSnapshotNameFromUrl(page.url());
|
|
89
|
+
const timestamp = Date.now();
|
|
90
|
+
const safeName = snapshotName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
|
91
|
+
const baseFilename = `${timestamp}_${safeName}`;
|
|
92
|
+
// 1. Capture full-page screenshot
|
|
93
|
+
const screenshotPath = path.join(outputDir, `${baseFilename}.png`);
|
|
94
|
+
// Check if scroll-and-stitch is explicitly requested (backup method)
|
|
95
|
+
if (effectiveConfig.useCDP === false) {
|
|
96
|
+
// Use scroll-and-stitch approach (backup method)
|
|
97
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
98
|
+
console.log('[TestivAI] Using scroll-and-stitch approach (backup method)');
|
|
99
|
+
}
|
|
100
|
+
// Get viewport dimensions
|
|
101
|
+
const viewport = page.viewportSize();
|
|
102
|
+
const viewportWidth = viewport?.width || 1280;
|
|
103
|
+
const viewportHeight = viewport?.height || 720;
|
|
104
|
+
// Find the main scrollable container and get its dimensions
|
|
105
|
+
const scrollableInfo = await page.evaluate(`
|
|
106
|
+
(function() {
|
|
107
|
+
var mainScrollable = null;
|
|
108
|
+
var maxScrollHeight = 0;
|
|
109
|
+
|
|
110
|
+
// Find the element with the most scrollable content
|
|
111
|
+
document.querySelectorAll('*').forEach(function(el) {
|
|
112
|
+
var computed = window.getComputedStyle(el);
|
|
113
|
+
var isScrollable = (
|
|
114
|
+
computed.overflowY === 'auto' ||
|
|
115
|
+
computed.overflowY === 'scroll'
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (isScrollable && el.scrollHeight > el.clientHeight) {
|
|
119
|
+
if (el.scrollHeight > maxScrollHeight) {
|
|
120
|
+
maxScrollHeight = el.scrollHeight;
|
|
121
|
+
mainScrollable = el;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// If we found a scrollable container, add a temporary ID
|
|
127
|
+
if (mainScrollable) {
|
|
128
|
+
if (!mainScrollable.id) {
|
|
129
|
+
mainScrollable.id = '__testivai_scrollable_' + Date.now();
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
hasScrollable: true,
|
|
133
|
+
scrollableId: mainScrollable.id,
|
|
134
|
+
scrollHeight: mainScrollable.scrollHeight,
|
|
135
|
+
clientHeight: mainScrollable.clientHeight,
|
|
136
|
+
scrollTop: mainScrollable.scrollTop
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Fallback to document scroll
|
|
141
|
+
return {
|
|
142
|
+
hasScrollable: false,
|
|
143
|
+
scrollableId: null,
|
|
144
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
145
|
+
clientHeight: window.innerHeight,
|
|
146
|
+
scrollTop: window.scrollY
|
|
147
|
+
};
|
|
148
|
+
})()
|
|
149
|
+
`);
|
|
150
|
+
// Calculate number of screenshots needed
|
|
151
|
+
const totalHeight = scrollableInfo.scrollHeight;
|
|
152
|
+
const captureHeight = scrollableInfo.clientHeight;
|
|
153
|
+
const numCaptures = Math.ceil(totalHeight / captureHeight);
|
|
154
|
+
// Debug logging (only when TESTIVAI_DEBUG is enabled)
|
|
155
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
156
|
+
console.log(`[TestivAI] Scroll-and-stitch info:`, {
|
|
157
|
+
hasScrollable: scrollableInfo.hasScrollable,
|
|
158
|
+
scrollableId: scrollableInfo.scrollableId,
|
|
159
|
+
totalHeight,
|
|
160
|
+
captureHeight,
|
|
161
|
+
numCaptures,
|
|
162
|
+
viewportWidth,
|
|
163
|
+
viewportHeight
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// If only one capture needed, just take a regular screenshot
|
|
167
|
+
if (numCaptures <= 1) {
|
|
168
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
// Scroll-and-stitch approach
|
|
172
|
+
const screenshots = [];
|
|
173
|
+
for (let i = 0; i < numCaptures; i++) {
|
|
174
|
+
const scrollPosition = i * captureHeight;
|
|
175
|
+
// Scroll to position
|
|
176
|
+
if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
|
|
177
|
+
await page.evaluate(`
|
|
178
|
+
document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollPosition};
|
|
179
|
+
`);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
await page.evaluate(`window.scrollTo(0, ${scrollPosition})`);
|
|
183
|
+
}
|
|
184
|
+
// Wait for scroll and any lazy-loaded content
|
|
185
|
+
await page.waitForTimeout(100);
|
|
186
|
+
// Capture this viewport
|
|
187
|
+
const screenshotBuffer = await page.screenshot({ fullPage: false });
|
|
188
|
+
screenshots.push(screenshotBuffer);
|
|
189
|
+
}
|
|
190
|
+
// Stitch screenshots together using sharp
|
|
191
|
+
// Calculate the actual height of the last capture (may be partial)
|
|
192
|
+
const lastCaptureHeight = totalHeight - (captureHeight * (numCaptures - 1));
|
|
193
|
+
// Create composite image
|
|
194
|
+
const compositeInputs = screenshots.map((buffer, index) => {
|
|
195
|
+
const isLast = index === screenshots.length - 1;
|
|
196
|
+
const yOffset = index * captureHeight;
|
|
197
|
+
// For the last screenshot, we need to crop from the bottom
|
|
198
|
+
if (isLast && lastCaptureHeight < captureHeight) {
|
|
199
|
+
return {
|
|
200
|
+
input: buffer,
|
|
201
|
+
top: yOffset,
|
|
202
|
+
left: 0,
|
|
203
|
+
// We'll handle the cropping separately
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
input: buffer,
|
|
208
|
+
top: yOffset,
|
|
209
|
+
left: 0,
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
// Create the final stitched image
|
|
213
|
+
const finalImage = (0, sharp_1.default)({
|
|
214
|
+
create: {
|
|
215
|
+
width: viewportWidth,
|
|
216
|
+
height: totalHeight,
|
|
217
|
+
channels: 4,
|
|
218
|
+
background: { r: 255, g: 255, b: 255, alpha: 1 }
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
// Composite all screenshots
|
|
222
|
+
const stitchedImage = await finalImage
|
|
223
|
+
.composite(compositeInputs)
|
|
224
|
+
.png()
|
|
225
|
+
.toBuffer();
|
|
226
|
+
await fs.writeFile(screenshotPath, stitchedImage);
|
|
227
|
+
// Restore original scroll position
|
|
228
|
+
if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
|
|
229
|
+
await page.evaluate(`
|
|
230
|
+
document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollableInfo.scrollTop};
|
|
231
|
+
`);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
await page.evaluate(`window.scrollTo(0, ${scrollableInfo.scrollTop})`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
// Use CDP approach (default)
|
|
240
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
241
|
+
console.log('[TestivAI] Using CDP approach (default) for full-page screenshot');
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
// Create a CDP session
|
|
245
|
+
const client = await page.context().newCDPSession(page);
|
|
246
|
+
// Enable Page domain
|
|
247
|
+
await client.send('Page.enable');
|
|
248
|
+
// Temporarily remove height constraints to get the full scrollable content
|
|
249
|
+
await page.addStyleTag({
|
|
250
|
+
content: `
|
|
251
|
+
html, body {
|
|
252
|
+
height: auto !important;
|
|
253
|
+
min-height: auto !important;
|
|
254
|
+
max-height: none !important;
|
|
255
|
+
}
|
|
256
|
+
#testivai-layout-root, [class*="h-screen"] {
|
|
257
|
+
height: auto !important;
|
|
258
|
+
min-height: auto !important;
|
|
259
|
+
max-height: none !important;
|
|
260
|
+
overflow: visible !important;
|
|
261
|
+
}
|
|
262
|
+
`
|
|
263
|
+
});
|
|
264
|
+
// Wait a bit for styles to apply
|
|
265
|
+
await page.waitForTimeout(100);
|
|
266
|
+
// Get layout metrics to determine full page size
|
|
267
|
+
const layoutMetrics = await client.send('Page.getLayoutMetrics');
|
|
268
|
+
// Calculate full page dimensions
|
|
269
|
+
const pageWidth = Math.ceil(layoutMetrics.contentSize.width);
|
|
270
|
+
const pageHeight = Math.ceil(layoutMetrics.contentSize.height);
|
|
271
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
272
|
+
console.log('[TestivAI] CDP Layout metrics:', {
|
|
273
|
+
pageWidth,
|
|
274
|
+
pageHeight,
|
|
275
|
+
viewportWidth: layoutMetrics.layoutViewport.clientWidth,
|
|
276
|
+
viewportHeight: layoutMetrics.layoutViewport.clientHeight
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
// Capture screenshot with captureBeyondViewport: true
|
|
280
|
+
const screenshot = await client.send('Page.captureScreenshot', {
|
|
281
|
+
format: 'png',
|
|
282
|
+
captureBeyondViewport: true,
|
|
283
|
+
clip: {
|
|
284
|
+
x: 0,
|
|
285
|
+
y: 0,
|
|
286
|
+
width: pageWidth,
|
|
287
|
+
height: pageHeight,
|
|
288
|
+
scale: 1
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
// Save the screenshot
|
|
292
|
+
await fs.writeFile(screenshotPath, Buffer.from(screenshot.data, 'base64'));
|
|
293
|
+
// Remove the temporary style tag
|
|
294
|
+
await page.evaluate(`
|
|
295
|
+
const styleTags = document.querySelectorAll('style');
|
|
296
|
+
// Remove the last added style tag (our temporary one)
|
|
297
|
+
if (styleTags.length > 0) {
|
|
298
|
+
styleTags[styleTags.length - 1].remove();
|
|
299
|
+
}
|
|
300
|
+
`);
|
|
301
|
+
// Close CDP session
|
|
302
|
+
await client.detach();
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
console.error('[TestivAI] CDP screenshot failed:', error.message);
|
|
306
|
+
// Fallback to regular screenshot
|
|
307
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// 2. Dump page structure (HTML)
|
|
311
|
+
// @renamed: domPath → structurePath (IP protection)
|
|
312
|
+
const structurePath = path.join(outputDir, `${baseFilename}.html`);
|
|
313
|
+
const htmlContent = await page.content();
|
|
314
|
+
await fs.writeFile(structurePath, htmlContent);
|
|
315
|
+
// 2.5. Capture computed styles using CDP
|
|
316
|
+
// @renamed: cssPath → stylesPath (IP protection)
|
|
317
|
+
const stylesPath = path.join(outputDir, `${baseFilename}.css.json`);
|
|
318
|
+
try {
|
|
319
|
+
const cdpSession = await page.context().newCDPSession(page);
|
|
320
|
+
// Enable DOM and CSS domains
|
|
321
|
+
await cdpSession.send('DOM.enable');
|
|
322
|
+
await cdpSession.send('CSS.enable');
|
|
323
|
+
// Get all elements and their computed styles
|
|
324
|
+
const computedStyles = {};
|
|
325
|
+
// Visual properties we care about
|
|
326
|
+
const visualProperties = [
|
|
327
|
+
'color', 'background-color', 'background-image',
|
|
328
|
+
'font-size', 'font-weight', 'font-family',
|
|
329
|
+
'width', 'height', 'padding', 'margin',
|
|
330
|
+
'border', 'border-radius', 'box-shadow',
|
|
331
|
+
'display', 'position', 'top', 'left', 'right', 'bottom',
|
|
332
|
+
'transform', 'opacity', 'visibility', 'z-index'
|
|
333
|
+
];
|
|
334
|
+
// Execute script to get all elements with unique identifiers
|
|
335
|
+
const elementsData = await cdpSession.send('Runtime.evaluate', {
|
|
336
|
+
expression: `
|
|
337
|
+
(function() {
|
|
338
|
+
// Helper to get stable CSS selector path for an element
|
|
339
|
+
function getElementPath(element) {
|
|
340
|
+
if (element.id) {
|
|
341
|
+
return '#' + element.id;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const path = [];
|
|
345
|
+
let current = element;
|
|
346
|
+
|
|
347
|
+
while (current && current !== document.body) {
|
|
348
|
+
let selector = current.tagName.toLowerCase();
|
|
349
|
+
|
|
350
|
+
// Add up to 3 CSS classes for better uniqueness
|
|
351
|
+
// e.g., button.button.primary-button instead of just button.button
|
|
352
|
+
if (current.className && typeof current.className === 'string') {
|
|
353
|
+
const classes = current.className.trim().split(/\\s+/).filter(Boolean);
|
|
354
|
+
const maxClasses = Math.min(classes.length, 3);
|
|
355
|
+
for (let c = 0; c < maxClasses; c++) {
|
|
356
|
+
selector += '.' + classes[c];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Get nth-child position for uniqueness
|
|
361
|
+
if (current.parentNode) {
|
|
362
|
+
const siblings = Array.from(current.parentNode.children);
|
|
363
|
+
const sameTagSiblings = siblings.filter(s => s.tagName === current.tagName);
|
|
364
|
+
if (sameTagSiblings.length > 1) {
|
|
365
|
+
const index = sameTagSiblings.indexOf(current) + 1;
|
|
366
|
+
selector += \`:nth-of-type(\${index})\`;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
path.unshift(selector);
|
|
371
|
+
current = current.parentElement;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return path.join(' > ');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const elements = document.querySelectorAll('*');
|
|
378
|
+
const result = [];
|
|
379
|
+
elements.forEach((el, index) => {
|
|
380
|
+
const selectorPath = getElementPath(el);
|
|
381
|
+
const tagName = el.tagName.toLowerCase();
|
|
382
|
+
const className = el.className || '';
|
|
383
|
+
result.push({
|
|
384
|
+
selectorPath,
|
|
385
|
+
tagName,
|
|
386
|
+
className,
|
|
387
|
+
index
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
return result;
|
|
391
|
+
})()
|
|
392
|
+
`,
|
|
393
|
+
returnByValue: true
|
|
394
|
+
});
|
|
395
|
+
if (elementsData.result.value) {
|
|
396
|
+
const elements = elementsData.result.value;
|
|
397
|
+
// Get computed styles for each element (sample first 100 to avoid performance issues)
|
|
398
|
+
const sampleSize = Math.min(elements.length, 100);
|
|
399
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
400
|
+
const element = elements[i];
|
|
401
|
+
try {
|
|
402
|
+
const styleResult = await cdpSession.send('Runtime.evaluate', {
|
|
403
|
+
expression: `
|
|
404
|
+
(function() {
|
|
405
|
+
const el = document.querySelectorAll('*')[${element.index}];
|
|
406
|
+
if (!el) return null;
|
|
407
|
+
const styles = window.getComputedStyle(el);
|
|
408
|
+
const result = {};
|
|
409
|
+
${JSON.stringify(visualProperties)}.forEach(prop => {
|
|
410
|
+
result[prop] = styles.getPropertyValue(prop);
|
|
411
|
+
});
|
|
412
|
+
return result;
|
|
413
|
+
})()
|
|
414
|
+
`,
|
|
415
|
+
returnByValue: true
|
|
416
|
+
});
|
|
417
|
+
if (styleResult.result.value) {
|
|
418
|
+
// Use stable selector path as element ID instead of unstable index
|
|
419
|
+
// Deduplicate: if key already exists, append numeric suffix to prevent overwriting
|
|
420
|
+
let uniqueKey = element.selectorPath;
|
|
421
|
+
if (computedStyles[uniqueKey]) {
|
|
422
|
+
let suffix = 2;
|
|
423
|
+
while (computedStyles[`${element.selectorPath}[${suffix}]`]) {
|
|
424
|
+
suffix++;
|
|
425
|
+
}
|
|
426
|
+
uniqueKey = `${element.selectorPath}[${suffix}]`;
|
|
427
|
+
}
|
|
428
|
+
computedStyles[uniqueKey] = styleResult.result.value;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
// Skip elements that fail
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Disable domains and close session
|
|
438
|
+
await cdpSession.send('CSS.disable');
|
|
439
|
+
await cdpSession.send('DOM.disable');
|
|
440
|
+
await cdpSession.detach();
|
|
441
|
+
// Save computed styles to file
|
|
442
|
+
await fs.writeJson(stylesPath, {
|
|
443
|
+
computed_styles: computedStyles,
|
|
444
|
+
timestamp: Date.now(),
|
|
445
|
+
sample_size: Object.keys(computedStyles).length
|
|
446
|
+
});
|
|
447
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
448
|
+
console.log(`[TestivAI] Captured ${Object.keys(computedStyles).length} element styles`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
console.warn('[TestivAI] Failed to capture CSS via CDP:', error);
|
|
453
|
+
// Continue without CSS data
|
|
454
|
+
}
|
|
455
|
+
// 3. Extract bounding boxes for requested selectors
|
|
456
|
+
const selectors = effectiveConfig.selectors ?? ['body'];
|
|
457
|
+
const layout = {};
|
|
458
|
+
for (const selector of selectors) {
|
|
459
|
+
const element = page.locator(selector).first();
|
|
460
|
+
const boundingBox = await element.boundingBox();
|
|
461
|
+
if (boundingBox) {
|
|
462
|
+
layout[selector] = {
|
|
463
|
+
...boundingBox,
|
|
464
|
+
top: boundingBox.y,
|
|
465
|
+
left: boundingBox.x,
|
|
466
|
+
right: boundingBox.x + boundingBox.width,
|
|
467
|
+
bottom: boundingBox.y + boundingBox.height,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// 4. Capture performance metrics using CDP (if enabled)
|
|
472
|
+
let performanceMetrics = undefined;
|
|
473
|
+
const metricsEnabled = effectiveConfig.performanceMetrics?.enabled ?? true; // Default: enabled
|
|
474
|
+
if (metricsEnabled) {
|
|
475
|
+
try {
|
|
476
|
+
// Get CDP session from Playwright page
|
|
477
|
+
const cdpSession = await page.context().newCDPSession(page);
|
|
478
|
+
// Enable Performance domain
|
|
479
|
+
await cdpSession.send('Performance.enable');
|
|
480
|
+
// Get CDP performance metrics
|
|
481
|
+
const cdpMetrics = await cdpSession.send('Performance.getMetrics');
|
|
482
|
+
// Convert metrics array to object
|
|
483
|
+
const cdpMetricsObj = {};
|
|
484
|
+
cdpMetrics.metrics.forEach((metric) => {
|
|
485
|
+
cdpMetricsObj[metric.name] = metric.value;
|
|
486
|
+
});
|
|
487
|
+
// Get navigation timing and Web Vitals via page.evaluate
|
|
488
|
+
const timingData = await page.evaluate(() => {
|
|
489
|
+
const timing = window.performance.timing;
|
|
490
|
+
const navigation = window.performance.navigation;
|
|
491
|
+
// Get paint entries
|
|
492
|
+
const paintEntries = window.performance.getEntriesByType('paint');
|
|
493
|
+
const fcp = paintEntries.find(e => e.name === 'first-contentful-paint')?.startTime;
|
|
494
|
+
// Get LCP
|
|
495
|
+
const lcpEntries = window.performance.getEntriesByType('largest-contentful-paint');
|
|
496
|
+
const lcp = lcpEntries[lcpEntries.length - 1]?.startTime;
|
|
497
|
+
// Get CLS
|
|
498
|
+
let cls = 0;
|
|
499
|
+
try {
|
|
500
|
+
const clsEntries = window.performance.getEntriesByType('layout-shift');
|
|
501
|
+
clsEntries.forEach((entry) => {
|
|
502
|
+
if (!entry.hadRecentInput) {
|
|
503
|
+
cls += entry.value;
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
catch (e) {
|
|
508
|
+
// CLS might not be available
|
|
509
|
+
}
|
|
510
|
+
// Get FID (requires PerformanceObserver)
|
|
511
|
+
let fid = null;
|
|
512
|
+
try {
|
|
513
|
+
const fidEntries = window.performance.getEntriesByType('first-input');
|
|
514
|
+
if (fidEntries.length > 0) {
|
|
515
|
+
fid = fidEntries[0].processingStart - fidEntries[0].startTime;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
// FID might not be available
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
navigation: {
|
|
523
|
+
type: navigation.type,
|
|
524
|
+
redirectCount: navigation.redirectCount
|
|
525
|
+
},
|
|
526
|
+
timing: {
|
|
527
|
+
navigationStart: timing.navigationStart,
|
|
528
|
+
unloadEventStart: timing.unloadEventStart,
|
|
529
|
+
unloadEventEnd: timing.unloadEventEnd,
|
|
530
|
+
redirectStart: timing.redirectStart,
|
|
531
|
+
redirectEnd: timing.redirectEnd,
|
|
532
|
+
fetchStart: timing.fetchStart,
|
|
533
|
+
domainLookupStart: timing.domainLookupStart,
|
|
534
|
+
domainLookupEnd: timing.domainLookupEnd,
|
|
535
|
+
connectStart: timing.connectStart,
|
|
536
|
+
connectEnd: timing.connectEnd,
|
|
537
|
+
secureConnectionStart: timing.secureConnectionStart,
|
|
538
|
+
requestStart: timing.requestStart,
|
|
539
|
+
responseStart: timing.responseStart,
|
|
540
|
+
responseEnd: timing.responseEnd,
|
|
541
|
+
domLoading: timing.domLoading,
|
|
542
|
+
domInteractive: timing.domInteractive,
|
|
543
|
+
domContentLoadedEventStart: timing.domContentLoadedEventStart,
|
|
544
|
+
domContentLoadedEventEnd: timing.domContentLoadedEventEnd,
|
|
545
|
+
domComplete: timing.domComplete,
|
|
546
|
+
loadEventStart: timing.loadEventStart,
|
|
547
|
+
loadEventEnd: timing.loadEventEnd
|
|
548
|
+
},
|
|
549
|
+
webVitals: {
|
|
550
|
+
firstContentfulPaint: fcp,
|
|
551
|
+
largestContentfulPaint: lcp,
|
|
552
|
+
cumulativeLayoutShift: cls,
|
|
553
|
+
firstInputDelay: fid
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
});
|
|
557
|
+
// Disable Performance domain
|
|
558
|
+
await cdpSession.send('Performance.disable');
|
|
559
|
+
await cdpSession.detach();
|
|
560
|
+
// Structure identical to CDP SDK
|
|
561
|
+
performanceMetrics = {
|
|
562
|
+
cdp: cdpMetricsObj,
|
|
563
|
+
timing: timingData,
|
|
564
|
+
timestamp: Date.now()
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
console.warn('Failed to capture performance metrics:', err);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// 5. Structure analysis is now handled on the backend
|
|
572
|
+
// The SDK just captures the HTML and sends it with the configuration
|
|
573
|
+
// @renamed: domAnalysis → structureAnalysis (IP protection)
|
|
574
|
+
const structureAnalysis = undefined; // Will be populated by backend
|
|
575
|
+
// 6. Save metadata with configuration and performance data
|
|
576
|
+
const metadataPath = path.join(outputDir, `${baseFilename}.json`);
|
|
577
|
+
const metadata = {
|
|
578
|
+
snapshotName,
|
|
579
|
+
testName: testInfo.title,
|
|
580
|
+
timestamp,
|
|
581
|
+
url: page.url(),
|
|
582
|
+
viewport: page.viewportSize() || undefined,
|
|
583
|
+
};
|
|
584
|
+
await fs.writeJson(metadataPath, {
|
|
585
|
+
...metadata,
|
|
586
|
+
files: {
|
|
587
|
+
screenshot: screenshotPath,
|
|
588
|
+
// @renamed: dom → structure, css → styles (IP protection)
|
|
589
|
+
structure: structurePath,
|
|
590
|
+
styles: stylesPath,
|
|
591
|
+
},
|
|
592
|
+
layout,
|
|
593
|
+
// Store the effective configuration for the reporter
|
|
594
|
+
testivaiConfig: effectiveConfig,
|
|
595
|
+
// Store unified performance metrics if captured
|
|
596
|
+
performanceMetrics,
|
|
597
|
+
// Store structure analysis if captured
|
|
598
|
+
// @renamed: domAnalysis → structureAnalysis (IP protection)
|
|
599
|
+
structureAnalysis
|
|
600
|
+
});
|
|
601
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Page } from '@playwright/test';
|
|
2
|
+
import { StructureAnalysisConfig, StructureAnalysis, StructureChange } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Analyzes page structure and generates fingerprint
|
|
5
|
+
* @renamed Was `analyzeDOM` — renamed to conceal internal layer terminology (IP protection)
|
|
6
|
+
*/
|
|
7
|
+
export declare function analyzeStructure(page: Page, config?: StructureAnalysisConfig): Promise<StructureAnalysis>;
|
|
8
|
+
/**
|
|
9
|
+
* Compare two structure analyses and identify changes
|
|
10
|
+
* @renamed Was `compareDOMAnalysis` — renamed to conceal internal layer terminology (IP protection)
|
|
11
|
+
*/
|
|
12
|
+
export declare function compareStructureAnalysis(baseline: StructureAnalysis, current: StructureAnalysis): StructureChange[];
|