@testivai/witness-playwright 0.1.6 → 0.1.8
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/reporter.js +9 -3
- package/dist/snapshot.js +188 -93
- package/dist/types.d.ts +2 -0
- package/package.json +2 -1
- package/src/reporter.ts +8 -3
- package/src/snapshot.ts +203 -87
- package/src/types.ts +2 -0
package/dist/reporter.js
CHANGED
|
@@ -155,9 +155,15 @@ class TestivAIPlaywrightReporter {
|
|
|
155
155
|
headers: { 'X-API-KEY': this.options.apiKey },
|
|
156
156
|
});
|
|
157
157
|
console.log(`Testivai Reporter: Successfully uploaded ${snapshots.length} snapshots with Batch ID: ${batchId}`);
|
|
158
|
-
// Clean up temp files
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
// Clean up temp files (skip if DEBUG mode is enabled)
|
|
159
|
+
const debugMode = process.env.TESTIVAI_DEBUG === 'true';
|
|
160
|
+
if (debugMode) {
|
|
161
|
+
console.log('Testivai Reporter: DEBUG mode enabled - keeping temporary evidence files in:', this.tempDir);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
await fs.emptyDir(this.tempDir);
|
|
165
|
+
console.log('Testivai Reporter: Cleaned up temporary evidence files.');
|
|
166
|
+
}
|
|
161
167
|
}
|
|
162
168
|
catch (error) {
|
|
163
169
|
console.error('Testivai Reporter: An error occurred during the onEnd hook:', error.message);
|
package/dist/snapshot.js
CHANGED
|
@@ -32,11 +32,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
35
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
39
|
exports.snapshot = snapshot;
|
|
37
40
|
const fs = __importStar(require("fs-extra"));
|
|
38
41
|
const path = __importStar(require("path"));
|
|
39
42
|
const url_1 = require("url");
|
|
43
|
+
const sharp_1 = __importDefault(require("sharp"));
|
|
40
44
|
const loader_1 = require("./config/loader");
|
|
41
45
|
/**
|
|
42
46
|
* Generates a safe filename from a URL.
|
|
@@ -79,105 +83,196 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
79
83
|
const baseFilename = `${timestamp}_${safeName}`;
|
|
80
84
|
// 1. Capture full-page screenshot
|
|
81
85
|
const screenshotPath = path.join(outputDir, `${baseFilename}.png`);
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
86
|
+
// Check if CDP approach is enabled
|
|
87
|
+
if (effectiveConfig.useCDP) {
|
|
88
|
+
// Use Chrome DevTools Protocol for full-page capture
|
|
89
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
90
|
+
console.log('[TestivAI] Using CDP approach for full-page screenshot');
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
// Create a CDP session
|
|
94
|
+
const client = await page.context().newCDPSession(page);
|
|
95
|
+
// Enable Page domain
|
|
96
|
+
await client.send('Page.enable');
|
|
97
|
+
// Get layout metrics to determine full page size
|
|
98
|
+
const layoutMetrics = await client.send('Page.getLayoutMetrics');
|
|
99
|
+
// Calculate full page dimensions
|
|
100
|
+
const pageWidth = Math.ceil(layoutMetrics.contentSize.width);
|
|
101
|
+
const pageHeight = Math.ceil(layoutMetrics.contentSize.height);
|
|
102
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
103
|
+
console.log('[TestivAI] CDP Layout metrics:', {
|
|
104
|
+
pageWidth,
|
|
105
|
+
pageHeight,
|
|
106
|
+
viewportWidth: layoutMetrics.layoutViewport.clientWidth,
|
|
107
|
+
viewportHeight: layoutMetrics.layoutViewport.clientHeight
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Capture screenshot with captureBeyondViewport: true
|
|
111
|
+
const screenshot = await client.send('Page.captureScreenshot', {
|
|
112
|
+
format: 'png',
|
|
113
|
+
captureBeyondViewport: true,
|
|
114
|
+
clip: {
|
|
115
|
+
x: 0,
|
|
116
|
+
y: 0,
|
|
117
|
+
width: pageWidth,
|
|
118
|
+
height: pageHeight,
|
|
119
|
+
scale: 1
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
// Save the screenshot
|
|
123
|
+
await fs.writeFile(screenshotPath, Buffer.from(screenshot.data, 'base64'));
|
|
124
|
+
// Close CDP session
|
|
125
|
+
await client.detach();
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
console.error('[TestivAI] CDP screenshot failed:', error.message);
|
|
129
|
+
// Fallback to regular screenshot
|
|
130
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// Use scroll-and-stitch approach (default)
|
|
135
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
136
|
+
console.log('[TestivAI] Using scroll-and-stitch approach for full-page screenshot');
|
|
137
|
+
}
|
|
138
|
+
// Get viewport dimensions
|
|
139
|
+
const viewport = page.viewportSize();
|
|
140
|
+
const viewportWidth = viewport?.width || 1280;
|
|
141
|
+
const viewportHeight = viewport?.height || 720;
|
|
142
|
+
// Find the main scrollable container and get its dimensions
|
|
143
|
+
const scrollableInfo = await page.evaluate(`
|
|
144
|
+
(function() {
|
|
145
|
+
var mainScrollable = null;
|
|
146
|
+
var maxScrollHeight = 0;
|
|
106
147
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
overflowY
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
minHeight: el.style.minHeight
|
|
115
|
-
});
|
|
148
|
+
// Find the element with the most scrollable content
|
|
149
|
+
document.querySelectorAll('*').forEach(function(el) {
|
|
150
|
+
var computed = window.getComputedStyle(el);
|
|
151
|
+
var isScrollable = (
|
|
152
|
+
computed.overflowY === 'auto' ||
|
|
153
|
+
computed.overflowY === 'scroll'
|
|
154
|
+
);
|
|
116
155
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
el.style.height = 'auto';
|
|
156
|
+
if (isScrollable && el.scrollHeight > el.clientHeight) {
|
|
157
|
+
if (el.scrollHeight > maxScrollHeight) {
|
|
158
|
+
maxScrollHeight = el.scrollHeight;
|
|
159
|
+
mainScrollable = el;
|
|
160
|
+
}
|
|
123
161
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
overflow: html.style.overflow,
|
|
139
|
-
height: html.style.height
|
|
140
|
-
},
|
|
141
|
-
body: {
|
|
142
|
-
overflow: body.style.overflow,
|
|
143
|
-
height: body.style.height
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// If we found a scrollable container, add a temporary ID
|
|
165
|
+
if (mainScrollable) {
|
|
166
|
+
if (!mainScrollable.id) {
|
|
167
|
+
mainScrollable.id = '__testivai_scrollable_' + Date.now();
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
hasScrollable: true,
|
|
171
|
+
scrollableId: mainScrollable.id,
|
|
172
|
+
scrollHeight: mainScrollable.scrollHeight,
|
|
173
|
+
clientHeight: mainScrollable.clientHeight,
|
|
174
|
+
scrollTop: mainScrollable.scrollTop
|
|
175
|
+
};
|
|
144
176
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
177
|
+
|
|
178
|
+
// Fallback to document scroll
|
|
179
|
+
return {
|
|
180
|
+
hasScrollable: false,
|
|
181
|
+
scrollableId: null,
|
|
182
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
183
|
+
clientHeight: window.innerHeight,
|
|
184
|
+
scrollTop: window.scrollY
|
|
185
|
+
};
|
|
186
|
+
})()
|
|
151
187
|
`);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
188
|
+
// Calculate number of screenshots needed
|
|
189
|
+
const totalHeight = scrollableInfo.scrollHeight;
|
|
190
|
+
const captureHeight = scrollableInfo.clientHeight;
|
|
191
|
+
const numCaptures = Math.ceil(totalHeight / captureHeight);
|
|
192
|
+
// Debug logging (only when TESTIVAI_DEBUG is enabled)
|
|
193
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
194
|
+
console.log(`[TestivAI] Scroll-and-stitch info:`, {
|
|
195
|
+
hasScrollable: scrollableInfo.hasScrollable,
|
|
196
|
+
scrollableId: scrollableInfo.scrollableId,
|
|
197
|
+
totalHeight,
|
|
198
|
+
captureHeight,
|
|
199
|
+
numCaptures,
|
|
200
|
+
viewportWidth,
|
|
201
|
+
viewportHeight
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// If only one capture needed, just take a regular screenshot
|
|
205
|
+
if (numCaptures <= 1) {
|
|
206
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
// Scroll-and-stitch approach
|
|
210
|
+
const screenshots = [];
|
|
211
|
+
for (let i = 0; i < numCaptures; i++) {
|
|
212
|
+
const scrollPosition = i * captureHeight;
|
|
213
|
+
// Scroll to position
|
|
214
|
+
if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
|
|
215
|
+
await page.evaluate(`
|
|
216
|
+
document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollPosition};
|
|
217
|
+
`);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
await page.evaluate(`window.scrollTo(0, ${scrollPosition})`);
|
|
221
|
+
}
|
|
222
|
+
// Wait for scroll and any lazy-loaded content
|
|
223
|
+
await page.waitForTimeout(100);
|
|
224
|
+
// Capture this viewport
|
|
225
|
+
const screenshotBuffer = await page.screenshot({ fullPage: false });
|
|
226
|
+
screenshots.push(screenshotBuffer);
|
|
227
|
+
}
|
|
228
|
+
// Stitch screenshots together using sharp
|
|
229
|
+
// Calculate the actual height of the last capture (may be partial)
|
|
230
|
+
const lastCaptureHeight = totalHeight - (captureHeight * (numCaptures - 1));
|
|
231
|
+
// Create composite image
|
|
232
|
+
const compositeInputs = screenshots.map((buffer, index) => {
|
|
233
|
+
const isLast = index === screenshots.length - 1;
|
|
234
|
+
const yOffset = index * captureHeight;
|
|
235
|
+
// For the last screenshot, we need to crop from the bottom
|
|
236
|
+
if (isLast && lastCaptureHeight < captureHeight) {
|
|
237
|
+
return {
|
|
238
|
+
input: buffer,
|
|
239
|
+
top: yOffset,
|
|
240
|
+
left: 0,
|
|
241
|
+
// We'll handle the cropping separately
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
input: buffer,
|
|
246
|
+
top: yOffset,
|
|
247
|
+
left: 0,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
// Create the final stitched image
|
|
251
|
+
const finalImage = (0, sharp_1.default)({
|
|
252
|
+
create: {
|
|
253
|
+
width: viewportWidth,
|
|
254
|
+
height: totalHeight,
|
|
255
|
+
channels: 4,
|
|
256
|
+
background: { r: 255, g: 255, b: 255, alpha: 1 }
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
// Composite all screenshots
|
|
260
|
+
const stitchedImage = await finalImage
|
|
261
|
+
.composite(compositeInputs)
|
|
262
|
+
.png()
|
|
263
|
+
.toBuffer();
|
|
264
|
+
await fs.writeFile(screenshotPath, stitchedImage);
|
|
265
|
+
// Restore original scroll position
|
|
266
|
+
if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
|
|
267
|
+
await page.evaluate(`
|
|
268
|
+
document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollableInfo.scrollTop};
|
|
269
|
+
`);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
await page.evaluate(`window.scrollTo(0, ${scrollableInfo.scrollTop})`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
179
275
|
}
|
|
180
|
-
`);
|
|
181
276
|
// 2. Dump full-page DOM
|
|
182
277
|
const domPath = path.join(outputDir, `${baseFilename}.html`);
|
|
183
278
|
const htmlContent = await page.content();
|
package/dist/types.d.ts
CHANGED
|
@@ -133,6 +133,8 @@ export interface TestivAIConfig {
|
|
|
133
133
|
performance?: Partial<PerformanceConfig>;
|
|
134
134
|
/** Element selectors to capture (existing option) */
|
|
135
135
|
selectors?: string[];
|
|
136
|
+
/** Use Chrome DevTools Protocol for full-page capture (default: false - uses scroll-and-stitch) */
|
|
137
|
+
useCDP?: boolean;
|
|
136
138
|
}
|
|
137
139
|
/**
|
|
138
140
|
* Layout/Bounding box data for an element
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testivai/witness-playwright",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Playwright sensor for Testivai Visual Regression Test system",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"cross-fetch": "^4.0.0",
|
|
34
34
|
"fs-extra": "^11.2.0",
|
|
35
35
|
"playwright-lighthouse": "^4.0.0",
|
|
36
|
+
"sharp": "^0.34.5",
|
|
36
37
|
"simple-git": "^3.21.0"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
package/src/reporter.ts
CHANGED
|
@@ -147,9 +147,14 @@ export class TestivAIPlaywrightReporter implements Reporter {
|
|
|
147
147
|
|
|
148
148
|
console.log(`Testivai Reporter: Successfully uploaded ${snapshots.length} snapshots with Batch ID: ${batchId}`);
|
|
149
149
|
|
|
150
|
-
// Clean up temp files
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
// Clean up temp files (skip if DEBUG mode is enabled)
|
|
151
|
+
const debugMode = process.env.TESTIVAI_DEBUG === 'true';
|
|
152
|
+
if (debugMode) {
|
|
153
|
+
console.log('Testivai Reporter: DEBUG mode enabled - keeping temporary evidence files in:', this.tempDir);
|
|
154
|
+
} else {
|
|
155
|
+
await fs.emptyDir(this.tempDir);
|
|
156
|
+
console.log('Testivai Reporter: Cleaned up temporary evidence files.');
|
|
157
|
+
}
|
|
153
158
|
|
|
154
159
|
} catch (error: any) {
|
|
155
160
|
console.error('Testivai Reporter: An error occurred during the onEnd hook:', error.message);
|
package/src/snapshot.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Page, TestInfo } from '@playwright/test';
|
|
|
2
2
|
import * as fs from 'fs-extra';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { URL } from 'url';
|
|
5
|
+
import sharp from 'sharp';
|
|
5
6
|
import { SnapshotPayload, LayoutData, TestivAIConfig, PerformanceTimings, LighthouseResults } from './types';
|
|
6
7
|
import { loadConfig, mergeTestConfig } from './config/loader';
|
|
7
8
|
|
|
@@ -56,108 +57,223 @@ export async function snapshot(
|
|
|
56
57
|
// 1. Capture full-page screenshot
|
|
57
58
|
const screenshotPath = path.join(outputDir, `${baseFilename}.png`);
|
|
58
59
|
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
// Check if CDP approach is enabled
|
|
61
|
+
if (effectiveConfig.useCDP) {
|
|
62
|
+
// Use Chrome DevTools Protocol for full-page capture
|
|
63
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
64
|
+
console.log('[TestivAI] Using CDP approach for full-page screenshot');
|
|
65
|
+
}
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
try {
|
|
68
|
+
// Create a CDP session
|
|
69
|
+
const client = await page.context().newCDPSession(page);
|
|
68
70
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
computed.overflow === 'auto' ||
|
|
72
|
-
computed.overflow === 'scroll' ||
|
|
73
|
-
computed.overflow === 'hidden' ||
|
|
74
|
-
computed.overflowY === 'auto' ||
|
|
75
|
-
computed.overflowY === 'scroll' ||
|
|
76
|
-
computed.overflowY === 'hidden';
|
|
71
|
+
// Enable Page domain
|
|
72
|
+
await client.send('Page.enable');
|
|
77
73
|
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
computed.height === '100vh' ||
|
|
81
|
-
(computed.height.endsWith('px') && el.scrollHeight > el.clientHeight) ||
|
|
82
|
-
(computed.maxHeight && computed.maxHeight !== 'none');
|
|
74
|
+
// Get layout metrics to determine full page size
|
|
75
|
+
const layoutMetrics = await client.send('Page.getLayoutMetrics');
|
|
83
76
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
77
|
+
// Calculate full page dimensions
|
|
78
|
+
const pageWidth = Math.ceil(layoutMetrics.contentSize.width);
|
|
79
|
+
const pageHeight = Math.ceil(layoutMetrics.contentSize.height);
|
|
80
|
+
|
|
81
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
82
|
+
console.log('[TestivAI] CDP Layout metrics:', {
|
|
83
|
+
pageWidth,
|
|
84
|
+
pageHeight,
|
|
85
|
+
viewportWidth: layoutMetrics.layoutViewport.clientWidth,
|
|
86
|
+
viewportHeight: layoutMetrics.layoutViewport.clientHeight
|
|
92
87
|
});
|
|
93
|
-
|
|
94
|
-
// For scrollable containers, expand to full scroll height
|
|
95
|
-
if (hasOverflowConstraint && el.scrollHeight > el.clientHeight) {
|
|
96
|
-
el.style.height = el.scrollHeight + 'px';
|
|
97
|
-
el.style.minHeight = el.scrollHeight + 'px';
|
|
98
|
-
} else if (hasHeightConstraint) {
|
|
99
|
-
el.style.height = 'auto';
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Remove overflow constraints
|
|
103
|
-
el.style.overflow = 'visible';
|
|
104
|
-
el.style.overflowY = 'visible';
|
|
105
|
-
el.style.maxHeight = 'none';
|
|
106
88
|
}
|
|
107
|
-
|
|
89
|
+
|
|
90
|
+
// Capture screenshot with captureBeyondViewport: true
|
|
91
|
+
const screenshot = await client.send('Page.captureScreenshot', {
|
|
92
|
+
format: 'png',
|
|
93
|
+
captureBeyondViewport: true,
|
|
94
|
+
clip: {
|
|
95
|
+
x: 0,
|
|
96
|
+
y: 0,
|
|
97
|
+
width: pageWidth,
|
|
98
|
+
height: pageHeight,
|
|
99
|
+
scale: 1
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Save the screenshot
|
|
104
|
+
await fs.writeFile(screenshotPath, Buffer.from(screenshot.data, 'base64'));
|
|
105
|
+
|
|
106
|
+
// Close CDP session
|
|
107
|
+
await client.detach();
|
|
108
|
+
|
|
109
|
+
} catch (error: any) {
|
|
110
|
+
console.error('[TestivAI] CDP screenshot failed:', error.message);
|
|
111
|
+
// Fallback to regular screenshot
|
|
112
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
// Use scroll-and-stitch approach (default)
|
|
116
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
117
|
+
console.log('[TestivAI] Using scroll-and-stitch approach for full-page screenshot');
|
|
118
|
+
}
|
|
108
119
|
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
120
|
+
// Get viewport dimensions
|
|
121
|
+
const viewport = page.viewportSize();
|
|
122
|
+
const viewportWidth = viewport?.width || 1280;
|
|
123
|
+
const viewportHeight = viewport?.height || 720;
|
|
112
124
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
// Find the main scrollable container and get its dimensions
|
|
126
|
+
const scrollableInfo = await page.evaluate(`
|
|
127
|
+
(function() {
|
|
128
|
+
var mainScrollable = null;
|
|
129
|
+
var maxScrollHeight = 0;
|
|
130
|
+
|
|
131
|
+
// Find the element with the most scrollable content
|
|
132
|
+
document.querySelectorAll('*').forEach(function(el) {
|
|
133
|
+
var computed = window.getComputedStyle(el);
|
|
134
|
+
var isScrollable = (
|
|
135
|
+
computed.overflowY === 'auto' ||
|
|
136
|
+
computed.overflowY === 'scroll'
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (isScrollable && el.scrollHeight > el.clientHeight) {
|
|
140
|
+
if (el.scrollHeight > maxScrollHeight) {
|
|
141
|
+
maxScrollHeight = el.scrollHeight;
|
|
142
|
+
mainScrollable = el;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// If we found a scrollable container, add a temporary ID
|
|
148
|
+
if (mainScrollable) {
|
|
149
|
+
if (!mainScrollable.id) {
|
|
150
|
+
mainScrollable.id = '__testivai_scrollable_' + Date.now();
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
hasScrollable: true,
|
|
154
|
+
scrollableId: mainScrollable.id,
|
|
155
|
+
scrollHeight: mainScrollable.scrollHeight,
|
|
156
|
+
clientHeight: mainScrollable.clientHeight,
|
|
157
|
+
scrollTop: mainScrollable.scrollTop
|
|
158
|
+
};
|
|
121
159
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
160
|
+
|
|
161
|
+
// Fallback to document scroll
|
|
162
|
+
return {
|
|
163
|
+
hasScrollable: false,
|
|
164
|
+
scrollableId: null,
|
|
165
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
166
|
+
clientHeight: window.innerHeight,
|
|
167
|
+
scrollTop: window.scrollY
|
|
168
|
+
};
|
|
169
|
+
})()
|
|
170
|
+
`) as {
|
|
171
|
+
hasScrollable: boolean;
|
|
172
|
+
scrollableId: string | null;
|
|
173
|
+
scrollHeight: number;
|
|
174
|
+
clientHeight: number;
|
|
175
|
+
scrollTop: number;
|
|
176
|
+
};
|
|
129
177
|
|
|
130
|
-
//
|
|
131
|
-
|
|
178
|
+
// Calculate number of screenshots needed
|
|
179
|
+
const totalHeight = scrollableInfo.scrollHeight;
|
|
180
|
+
const captureHeight = scrollableInfo.clientHeight;
|
|
181
|
+
const numCaptures = Math.ceil(totalHeight / captureHeight);
|
|
132
182
|
|
|
133
|
-
//
|
|
134
|
-
|
|
183
|
+
// Debug logging (only when TESTIVAI_DEBUG is enabled)
|
|
184
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
185
|
+
console.log(`[TestivAI] Scroll-and-stitch info:`, {
|
|
186
|
+
hasScrollable: scrollableInfo.hasScrollable,
|
|
187
|
+
scrollableId: scrollableInfo.scrollableId,
|
|
188
|
+
totalHeight,
|
|
189
|
+
captureHeight,
|
|
190
|
+
numCaptures,
|
|
191
|
+
viewportWidth,
|
|
192
|
+
viewportHeight
|
|
193
|
+
});
|
|
194
|
+
}
|
|
135
195
|
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
196
|
+
// If only one capture needed, just take a regular screenshot
|
|
197
|
+
if (numCaptures <= 1) {
|
|
198
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
199
|
+
} else {
|
|
200
|
+
// Scroll-and-stitch approach
|
|
201
|
+
const screenshots: Buffer[] = [];
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < numCaptures; i++) {
|
|
204
|
+
const scrollPosition = i * captureHeight;
|
|
205
|
+
|
|
206
|
+
// Scroll to position
|
|
207
|
+
if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
|
|
208
|
+
await page.evaluate(`
|
|
209
|
+
document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollPosition};
|
|
210
|
+
`);
|
|
211
|
+
} else {
|
|
212
|
+
await page.evaluate(`window.scrollTo(0, ${scrollPosition})`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Wait for scroll and any lazy-loaded content
|
|
216
|
+
await page.waitForTimeout(100);
|
|
217
|
+
|
|
218
|
+
// Capture this viewport
|
|
219
|
+
const screenshotBuffer = await page.screenshot({ fullPage: false });
|
|
220
|
+
screenshots.push(screenshotBuffer);
|
|
148
221
|
}
|
|
149
222
|
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
223
|
+
// Stitch screenshots together using sharp
|
|
224
|
+
// Calculate the actual height of the last capture (may be partial)
|
|
225
|
+
const lastCaptureHeight = totalHeight - (captureHeight * (numCaptures - 1));
|
|
226
|
+
|
|
227
|
+
// Create composite image
|
|
228
|
+
const compositeInputs = screenshots.map((buffer, index) => {
|
|
229
|
+
const isLast = index === screenshots.length - 1;
|
|
230
|
+
const yOffset = index * captureHeight;
|
|
231
|
+
|
|
232
|
+
// For the last screenshot, we need to crop from the bottom
|
|
233
|
+
if (isLast && lastCaptureHeight < captureHeight) {
|
|
234
|
+
return {
|
|
235
|
+
input: buffer,
|
|
236
|
+
top: yOffset,
|
|
237
|
+
left: 0,
|
|
238
|
+
// We'll handle the cropping separately
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
input: buffer,
|
|
244
|
+
top: yOffset,
|
|
245
|
+
left: 0,
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Create the final stitched image
|
|
250
|
+
const finalImage = sharp({
|
|
251
|
+
create: {
|
|
252
|
+
width: viewportWidth,
|
|
253
|
+
height: totalHeight,
|
|
254
|
+
channels: 4,
|
|
255
|
+
background: { r: 255, g: 255, b: 255, alpha: 1 }
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Composite all screenshots
|
|
260
|
+
const stitchedImage = await finalImage
|
|
261
|
+
.composite(compositeInputs)
|
|
262
|
+
.png()
|
|
263
|
+
.toBuffer();
|
|
264
|
+
|
|
265
|
+
await fs.writeFile(screenshotPath, stitchedImage);
|
|
266
|
+
|
|
267
|
+
// Restore original scroll position
|
|
268
|
+
if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
|
|
269
|
+
await page.evaluate(`
|
|
270
|
+
document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollableInfo.scrollTop};
|
|
271
|
+
`);
|
|
272
|
+
} else {
|
|
273
|
+
await page.evaluate(`window.scrollTo(0, ${scrollableInfo.scrollTop})`);
|
|
274
|
+
}
|
|
159
275
|
}
|
|
160
|
-
|
|
276
|
+
}
|
|
161
277
|
|
|
162
278
|
// 2. Dump full-page DOM
|
|
163
279
|
const domPath = path.join(outputDir, `${baseFilename}.html`);
|
package/src/types.ts
CHANGED
|
@@ -141,6 +141,8 @@ export interface TestivAIConfig {
|
|
|
141
141
|
performance?: Partial<PerformanceConfig>;
|
|
142
142
|
/** Element selectors to capture (existing option) */
|
|
143
143
|
selectors?: string[];
|
|
144
|
+
/** Use Chrome DevTools Protocol for full-page capture (default: false - uses scroll-and-stitch) */
|
|
145
|
+
useCDP?: boolean;
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
/**
|