@testivai/witness-playwright 0.1.6 → 0.1.7
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 +136 -93
- package/package.json +2 -1
- package/src/reporter.ts +8 -3
- package/src/snapshot.ts +148 -92
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.
|
|
@@ -77,107 +81,146 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
77
81
|
const timestamp = Date.now();
|
|
78
82
|
const safeName = snapshotName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
|
79
83
|
const baseFilename = `${timestamp}_${safeName}`;
|
|
80
|
-
// 1. Capture full-page screenshot
|
|
84
|
+
// 1. Capture full-page screenshot using scroll-and-stitch approach
|
|
85
|
+
// This is the same technique used by GoFullPage Chrome extension
|
|
81
86
|
const screenshotPath = path.join(outputDir, `${baseFilename}.png`);
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
var
|
|
87
|
+
// Get viewport dimensions
|
|
88
|
+
const viewport = page.viewportSize();
|
|
89
|
+
const viewportWidth = viewport?.width || 1280;
|
|
90
|
+
const viewportHeight = viewport?.height || 720;
|
|
91
|
+
// Find the main scrollable container and get its dimensions
|
|
92
|
+
const scrollableInfo = await page.evaluate(`
|
|
93
|
+
(function() {
|
|
94
|
+
var mainScrollable = null;
|
|
95
|
+
var maxScrollHeight = 0;
|
|
91
96
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
computed
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
computed.overflowY === 'hidden';
|
|
100
|
-
|
|
101
|
-
// Check for fixed height constraints (100vh, 100%, or specific pixel values on containers)
|
|
102
|
-
var hasHeightConstraint =
|
|
103
|
-
computed.height === '100vh' ||
|
|
104
|
-
(computed.height.endsWith('px') && el.scrollHeight > el.clientHeight) ||
|
|
105
|
-
(computed.maxHeight && computed.maxHeight !== 'none');
|
|
106
|
-
|
|
107
|
-
if (hasOverflowConstraint || hasHeightConstraint) {
|
|
108
|
-
window.__testivaiOriginalStyles.push({
|
|
109
|
-
element: el,
|
|
110
|
-
overflow: el.style.overflow,
|
|
111
|
-
overflowY: el.style.overflowY,
|
|
112
|
-
height: el.style.height,
|
|
113
|
-
maxHeight: el.style.maxHeight,
|
|
114
|
-
minHeight: el.style.minHeight
|
|
115
|
-
});
|
|
97
|
+
// Find the element with the most scrollable content
|
|
98
|
+
document.querySelectorAll('*').forEach(function(el) {
|
|
99
|
+
var computed = window.getComputedStyle(el);
|
|
100
|
+
var isScrollable = (
|
|
101
|
+
computed.overflowY === 'auto' ||
|
|
102
|
+
computed.overflowY === 'scroll'
|
|
103
|
+
);
|
|
116
104
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
el.style.height = 'auto';
|
|
105
|
+
if (isScrollable && el.scrollHeight > el.clientHeight) {
|
|
106
|
+
if (el.scrollHeight > maxScrollHeight) {
|
|
107
|
+
maxScrollHeight = el.scrollHeight;
|
|
108
|
+
mainScrollable = el;
|
|
109
|
+
}
|
|
123
110
|
}
|
|
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
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// If we found a scrollable container, add a temporary ID
|
|
114
|
+
if (mainScrollable) {
|
|
115
|
+
if (!mainScrollable.id) {
|
|
116
|
+
mainScrollable.id = '__testivai_scrollable_' + Date.now();
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
hasScrollable: true,
|
|
120
|
+
scrollableId: mainScrollable.id,
|
|
121
|
+
scrollHeight: mainScrollable.scrollHeight,
|
|
122
|
+
clientHeight: mainScrollable.clientHeight,
|
|
123
|
+
scrollTop: mainScrollable.scrollTop
|
|
124
|
+
};
|
|
144
125
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
126
|
+
|
|
127
|
+
// Fallback to document scroll
|
|
128
|
+
return {
|
|
129
|
+
hasScrollable: false,
|
|
130
|
+
scrollableId: null,
|
|
131
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
132
|
+
clientHeight: window.innerHeight,
|
|
133
|
+
scrollTop: window.scrollY
|
|
134
|
+
};
|
|
135
|
+
})()
|
|
151
136
|
`);
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
delete window.__testivaiOriginalStyles;
|
|
137
|
+
// Calculate number of screenshots needed
|
|
138
|
+
const totalHeight = scrollableInfo.scrollHeight;
|
|
139
|
+
const captureHeight = scrollableInfo.clientHeight;
|
|
140
|
+
const numCaptures = Math.ceil(totalHeight / captureHeight);
|
|
141
|
+
// Debug logging (only when TESTIVAI_DEBUG is enabled)
|
|
142
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
143
|
+
console.log(`[TestivAI] Scroll-and-stitch info:`, {
|
|
144
|
+
hasScrollable: scrollableInfo.hasScrollable,
|
|
145
|
+
scrollableId: scrollableInfo.scrollableId,
|
|
146
|
+
totalHeight,
|
|
147
|
+
captureHeight,
|
|
148
|
+
numCaptures,
|
|
149
|
+
viewportWidth,
|
|
150
|
+
viewportHeight
|
|
151
|
+
});
|
|
168
152
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
153
|
+
// If only one capture needed, just take a regular screenshot
|
|
154
|
+
if (numCaptures <= 1) {
|
|
155
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// Scroll-and-stitch approach
|
|
159
|
+
const screenshots = [];
|
|
160
|
+
for (let i = 0; i < numCaptures; i++) {
|
|
161
|
+
const scrollPosition = i * captureHeight;
|
|
162
|
+
// Scroll to position
|
|
163
|
+
if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
|
|
164
|
+
await page.evaluate(`
|
|
165
|
+
document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollPosition};
|
|
166
|
+
`);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
await page.evaluate(`window.scrollTo(0, ${scrollPosition})`);
|
|
170
|
+
}
|
|
171
|
+
// Wait for scroll and any lazy-loaded content
|
|
172
|
+
await page.waitForTimeout(100);
|
|
173
|
+
// Capture this viewport
|
|
174
|
+
const screenshotBuffer = await page.screenshot({ fullPage: false });
|
|
175
|
+
screenshots.push(screenshotBuffer);
|
|
176
|
+
}
|
|
177
|
+
// Stitch screenshots together using sharp
|
|
178
|
+
// Calculate the actual height of the last capture (may be partial)
|
|
179
|
+
const lastCaptureHeight = totalHeight - (captureHeight * (numCaptures - 1));
|
|
180
|
+
// Create composite image
|
|
181
|
+
const compositeInputs = screenshots.map((buffer, index) => {
|
|
182
|
+
const isLast = index === screenshots.length - 1;
|
|
183
|
+
const yOffset = index * captureHeight;
|
|
184
|
+
// For the last screenshot, we need to crop from the bottom
|
|
185
|
+
if (isLast && lastCaptureHeight < captureHeight) {
|
|
186
|
+
return {
|
|
187
|
+
input: buffer,
|
|
188
|
+
top: yOffset,
|
|
189
|
+
left: 0,
|
|
190
|
+
// We'll handle the cropping separately
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
input: buffer,
|
|
195
|
+
top: yOffset,
|
|
196
|
+
left: 0,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
// Create the final stitched image
|
|
200
|
+
const finalImage = (0, sharp_1.default)({
|
|
201
|
+
create: {
|
|
202
|
+
width: viewportWidth,
|
|
203
|
+
height: totalHeight,
|
|
204
|
+
channels: 4,
|
|
205
|
+
background: { r: 255, g: 255, b: 255, alpha: 1 }
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
// Composite all screenshots
|
|
209
|
+
const stitchedImage = await finalImage
|
|
210
|
+
.composite(compositeInputs)
|
|
211
|
+
.png()
|
|
212
|
+
.toBuffer();
|
|
213
|
+
await fs.writeFile(screenshotPath, stitchedImage);
|
|
214
|
+
// Restore original scroll position
|
|
215
|
+
if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
|
|
216
|
+
await page.evaluate(`
|
|
217
|
+
document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollableInfo.scrollTop};
|
|
218
|
+
`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
await page.evaluate(`window.scrollTo(0, ${scrollableInfo.scrollTop})`);
|
|
222
|
+
}
|
|
179
223
|
}
|
|
180
|
-
`);
|
|
181
224
|
// 2. Dump full-page DOM
|
|
182
225
|
const domPath = path.join(outputDir, `${baseFilename}.html`);
|
|
183
226
|
const htmlContent = await page.content();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testivai/witness-playwright",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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
|
|
|
@@ -53,111 +54,166 @@ export async function snapshot(
|
|
|
53
54
|
const safeName = snapshotName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
|
54
55
|
const baseFilename = `${timestamp}_${safeName}`;
|
|
55
56
|
|
|
56
|
-
// 1. Capture full-page screenshot
|
|
57
|
+
// 1. Capture full-page screenshot using scroll-and-stitch approach
|
|
58
|
+
// This is the same technique used by GoFullPage Chrome extension
|
|
57
59
|
const screenshotPath = path.join(outputDir, `${baseFilename}.png`);
|
|
58
60
|
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
var
|
|
68
|
-
|
|
69
|
-
// Check for overflow constraints
|
|
70
|
-
var hasOverflowConstraint =
|
|
71
|
-
computed.overflow === 'auto' ||
|
|
72
|
-
computed.overflow === 'scroll' ||
|
|
73
|
-
computed.overflow === 'hidden' ||
|
|
74
|
-
computed.overflowY === 'auto' ||
|
|
75
|
-
computed.overflowY === 'scroll' ||
|
|
76
|
-
computed.overflowY === 'hidden';
|
|
77
|
-
|
|
78
|
-
// Check for fixed height constraints (100vh, 100%, or specific pixel values on containers)
|
|
79
|
-
var hasHeightConstraint =
|
|
80
|
-
computed.height === '100vh' ||
|
|
81
|
-
(computed.height.endsWith('px') && el.scrollHeight > el.clientHeight) ||
|
|
82
|
-
(computed.maxHeight && computed.maxHeight !== 'none');
|
|
61
|
+
// Get viewport dimensions
|
|
62
|
+
const viewport = page.viewportSize();
|
|
63
|
+
const viewportWidth = viewport?.width || 1280;
|
|
64
|
+
const viewportHeight = viewport?.height || 720;
|
|
65
|
+
|
|
66
|
+
// Find the main scrollable container and get its dimensions
|
|
67
|
+
const scrollableInfo = await page.evaluate(`
|
|
68
|
+
(function() {
|
|
69
|
+
var mainScrollable = null;
|
|
70
|
+
var maxScrollHeight = 0;
|
|
83
71
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
overflowY
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
minHeight: el.style.minHeight
|
|
92
|
-
});
|
|
72
|
+
// Find the element with the most scrollable content
|
|
73
|
+
document.querySelectorAll('*').forEach(function(el) {
|
|
74
|
+
var computed = window.getComputedStyle(el);
|
|
75
|
+
var isScrollable = (
|
|
76
|
+
computed.overflowY === 'auto' ||
|
|
77
|
+
computed.overflowY === 'scroll'
|
|
78
|
+
);
|
|
93
79
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
el.style.height = 'auto';
|
|
80
|
+
if (isScrollable && el.scrollHeight > el.clientHeight) {
|
|
81
|
+
if (el.scrollHeight > maxScrollHeight) {
|
|
82
|
+
maxScrollHeight = el.scrollHeight;
|
|
83
|
+
mainScrollable = el;
|
|
84
|
+
}
|
|
100
85
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// If we found a scrollable container, add a temporary ID
|
|
89
|
+
if (mainScrollable) {
|
|
90
|
+
if (!mainScrollable.id) {
|
|
91
|
+
mainScrollable.id = '__testivai_scrollable_' + Date.now();
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
hasScrollable: true,
|
|
95
|
+
scrollableId: mainScrollable.id,
|
|
96
|
+
scrollHeight: mainScrollable.scrollHeight,
|
|
97
|
+
clientHeight: mainScrollable.clientHeight,
|
|
98
|
+
scrollTop: mainScrollable.scrollTop
|
|
99
|
+
};
|
|
106
100
|
}
|
|
101
|
+
|
|
102
|
+
// Fallback to document scroll
|
|
103
|
+
return {
|
|
104
|
+
hasScrollable: false,
|
|
105
|
+
scrollableId: null,
|
|
106
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
107
|
+
clientHeight: window.innerHeight,
|
|
108
|
+
scrollTop: window.scrollY
|
|
109
|
+
};
|
|
110
|
+
})()
|
|
111
|
+
`) as {
|
|
112
|
+
hasScrollable: boolean;
|
|
113
|
+
scrollableId: string | null;
|
|
114
|
+
scrollHeight: number;
|
|
115
|
+
clientHeight: number;
|
|
116
|
+
scrollTop: number;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Calculate number of screenshots needed
|
|
120
|
+
const totalHeight = scrollableInfo.scrollHeight;
|
|
121
|
+
const captureHeight = scrollableInfo.clientHeight;
|
|
122
|
+
const numCaptures = Math.ceil(totalHeight / captureHeight);
|
|
123
|
+
|
|
124
|
+
// Debug logging (only when TESTIVAI_DEBUG is enabled)
|
|
125
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
126
|
+
console.log(`[TestivAI] Scroll-and-stitch info:`, {
|
|
127
|
+
hasScrollable: scrollableInfo.hasScrollable,
|
|
128
|
+
scrollableId: scrollableInfo.scrollableId,
|
|
129
|
+
totalHeight,
|
|
130
|
+
captureHeight,
|
|
131
|
+
numCaptures,
|
|
132
|
+
viewportWidth,
|
|
133
|
+
viewportHeight
|
|
107
134
|
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// If only one capture needed, just take a regular screenshot
|
|
138
|
+
if (numCaptures <= 1) {
|
|
139
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
140
|
+
} else {
|
|
141
|
+
// Scroll-and-stitch approach
|
|
142
|
+
const screenshots: Buffer[] = [];
|
|
108
143
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
144
|
+
for (let i = 0; i < numCaptures; i++) {
|
|
145
|
+
const scrollPosition = i * captureHeight;
|
|
146
|
+
|
|
147
|
+
// Scroll to position
|
|
148
|
+
if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
|
|
149
|
+
await page.evaluate(`
|
|
150
|
+
document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollPosition};
|
|
151
|
+
`);
|
|
152
|
+
} else {
|
|
153
|
+
await page.evaluate(`window.scrollTo(0, ${scrollPosition})`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Wait for scroll and any lazy-loaded content
|
|
157
|
+
await page.waitForTimeout(100);
|
|
158
|
+
|
|
159
|
+
// Capture this viewport
|
|
160
|
+
const screenshotBuffer = await page.screenshot({ fullPage: false });
|
|
161
|
+
screenshots.push(screenshotBuffer);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Stitch screenshots together using sharp
|
|
165
|
+
// Calculate the actual height of the last capture (may be partial)
|
|
166
|
+
const lastCaptureHeight = totalHeight - (captureHeight * (numCaptures - 1));
|
|
167
|
+
|
|
168
|
+
// Create composite image
|
|
169
|
+
const compositeInputs = screenshots.map((buffer, index) => {
|
|
170
|
+
const isLast = index === screenshots.length - 1;
|
|
171
|
+
const yOffset = index * captureHeight;
|
|
172
|
+
|
|
173
|
+
// For the last screenshot, we need to crop from the bottom
|
|
174
|
+
if (isLast && lastCaptureHeight < captureHeight) {
|
|
175
|
+
return {
|
|
176
|
+
input: buffer,
|
|
177
|
+
top: yOffset,
|
|
178
|
+
left: 0,
|
|
179
|
+
// We'll handle the cropping separately
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
input: buffer,
|
|
185
|
+
top: yOffset,
|
|
186
|
+
left: 0,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
112
189
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
height: body.style.height
|
|
190
|
+
// Create the final stitched image
|
|
191
|
+
const finalImage = sharp({
|
|
192
|
+
create: {
|
|
193
|
+
width: viewportWidth,
|
|
194
|
+
height: totalHeight,
|
|
195
|
+
channels: 4,
|
|
196
|
+
background: { r: 255, g: 255, b: 255, alpha: 1 }
|
|
121
197
|
}
|
|
122
|
-
};
|
|
198
|
+
});
|
|
123
199
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
// Wait for layout to stabilize after style changes
|
|
131
|
-
await page.waitForTimeout(300);
|
|
132
|
-
|
|
133
|
-
// Take full-page screenshot
|
|
134
|
-
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
135
|
-
|
|
136
|
-
// Restore original styles
|
|
137
|
-
await page.evaluate(`
|
|
138
|
-
// Restore element styles
|
|
139
|
-
if (window.__testivaiOriginalStyles) {
|
|
140
|
-
window.__testivaiOriginalStyles.forEach(function(item) {
|
|
141
|
-
item.element.style.overflow = item.overflow;
|
|
142
|
-
item.element.style.overflowY = item.overflowY;
|
|
143
|
-
item.element.style.height = item.height;
|
|
144
|
-
item.element.style.maxHeight = item.maxHeight;
|
|
145
|
-
item.element.style.minHeight = item.minHeight;
|
|
146
|
-
});
|
|
147
|
-
delete window.__testivaiOriginalStyles;
|
|
148
|
-
}
|
|
200
|
+
// Composite all screenshots
|
|
201
|
+
const stitchedImage = await finalImage
|
|
202
|
+
.composite(compositeInputs)
|
|
203
|
+
.png()
|
|
204
|
+
.toBuffer();
|
|
149
205
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
206
|
+
await fs.writeFile(screenshotPath, stitchedImage);
|
|
207
|
+
|
|
208
|
+
// Restore original scroll position
|
|
209
|
+
if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
|
|
210
|
+
await page.evaluate(`
|
|
211
|
+
document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollableInfo.scrollTop};
|
|
212
|
+
`);
|
|
213
|
+
} else {
|
|
214
|
+
await page.evaluate(`window.scrollTo(0, ${scrollableInfo.scrollTop})`);
|
|
159
215
|
}
|
|
160
|
-
|
|
216
|
+
}
|
|
161
217
|
|
|
162
218
|
// 2. Dump full-page DOM
|
|
163
219
|
const domPath = path.join(outputDir, `${baseFilename}.html`);
|