@testivai/witness-playwright 0.1.5 → 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 +140 -29
- package/package.json +2 -1
- package/progress.md +36 -1
- package/src/reporter.ts +8 -3
- package/src/snapshot.ts +154 -30
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,39 +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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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;
|
|
96
|
+
|
|
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
|
+
);
|
|
104
|
+
|
|
105
|
+
if (isScrollable && el.scrollHeight > el.clientHeight) {
|
|
106
|
+
if (el.scrollHeight > maxScrollHeight) {
|
|
107
|
+
maxScrollHeight = el.scrollHeight;
|
|
108
|
+
mainScrollable = el;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
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
|
+
};
|
|
96
125
|
}
|
|
97
|
-
|
|
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
|
+
})()
|
|
98
136
|
`);
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
});
|
|
152
|
+
}
|
|
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
|
+
}
|
|
111
223
|
}
|
|
112
|
-
`);
|
|
113
224
|
// 2. Dump full-page DOM
|
|
114
225
|
const domPath = path.join(outputDir, `${baseFilename}.html`);
|
|
115
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/progress.md
CHANGED
|
@@ -745,12 +745,47 @@ The Playwright SDK provides two main components that are production-ready:
|
|
|
745
745
|
|
|
746
746
|
---
|
|
747
747
|
|
|
748
|
+
## Full-Page Screenshot Fix (January 10, 2026)
|
|
749
|
+
|
|
750
|
+
### Issue: Windowed Screenshots Instead of Full-Page
|
|
751
|
+
|
|
752
|
+
**Problem**: Screenshots were capturing only the visible viewport instead of the full scrollable page content.
|
|
753
|
+
|
|
754
|
+
**Root Cause**: Modern SPA frameworks (React, Vue, Angular) commonly use fixed viewport layouts like:
|
|
755
|
+
```css
|
|
756
|
+
.container {
|
|
757
|
+
height: 100vh; /* Fixed to viewport height */
|
|
758
|
+
overflow: hidden; /* Prevents document scrolling */
|
|
759
|
+
}
|
|
760
|
+
.content {
|
|
761
|
+
overflow-y: auto; /* Internal scrolling container */
|
|
762
|
+
}
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
This pattern (e.g., TailwindCSS `h-screen overflow-hidden`) creates a scrollable container **inside** a fixed-height viewport. Playwright's `fullPage: true` captures the document height, but when the document is constrained to viewport height with internal scrolling, it only captures what's visible.
|
|
766
|
+
|
|
767
|
+
**Solution**: Enhanced the snapshot function to:
|
|
768
|
+
1. Detect and temporarily remove `overflow: hidden` (not just `auto`/`scroll`)
|
|
769
|
+
2. Detect and remove `height: 100vh` constraints
|
|
770
|
+
3. Remove `maxHeight` constraints that limit container expansion
|
|
771
|
+
4. Explicitly handle `html` and `body` element styles
|
|
772
|
+
5. Wait for layout to stabilize (300ms) before capturing
|
|
773
|
+
6. Restore all original styles after capture
|
|
774
|
+
|
|
775
|
+
**Files Modified**:
|
|
776
|
+
- `src/snapshot.ts` - Enhanced full-page capture logic
|
|
777
|
+
|
|
778
|
+
**Testing**: Run visual tests to verify full-page screenshots are now captured correctly.
|
|
779
|
+
|
|
780
|
+
---
|
|
781
|
+
|
|
748
782
|
**Last Updated**: January 10, 2026
|
|
749
783
|
**Status**: 🎉 PUBLISHED TO NPM ✅ - Publicly available
|
|
750
|
-
**NPM Package**: @testivai/witness-playwright@0.1.
|
|
784
|
+
**NPM Package**: @testivai/witness-playwright@0.1.5
|
|
751
785
|
**Core Features**: Evidence capture, batch upload, and performance monitoring fully functional
|
|
752
786
|
**Configuration**: ✅ COMPLETE - End-to-end flow working
|
|
753
787
|
**Performance Metrics**: ✅ COMPLETE - Basic timing + optional Lighthouse
|
|
788
|
+
**Full-Page Screenshots**: ✅ FIXED - Handles SPA fixed viewport layouts
|
|
754
789
|
**API Key Format**: tstvai-{secure-random-string}
|
|
755
790
|
**Known Issues**: Minor UX improvements (retry logic, progress reporting)
|
|
756
791
|
**Blocker**: None - All critical features implemented
|
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,43 +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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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;
|
|
71
|
+
|
|
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
|
+
);
|
|
79
|
+
|
|
80
|
+
if (isScrollable && el.scrollHeight > el.clientHeight) {
|
|
81
|
+
if (el.scrollHeight > maxScrollHeight) {
|
|
82
|
+
maxScrollHeight = el.scrollHeight;
|
|
83
|
+
mainScrollable = el;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
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
|
+
};
|
|
73
100
|
}
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
};
|
|
76
118
|
|
|
77
|
-
//
|
|
78
|
-
|
|
119
|
+
// Calculate number of screenshots needed
|
|
120
|
+
const totalHeight = scrollableInfo.scrollHeight;
|
|
121
|
+
const captureHeight = scrollableInfo.clientHeight;
|
|
122
|
+
const numCaptures = Math.ceil(totalHeight / captureHeight);
|
|
79
123
|
|
|
80
|
-
//
|
|
81
|
-
|
|
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
|
|
134
|
+
});
|
|
135
|
+
}
|
|
82
136
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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[] = [];
|
|
143
|
+
|
|
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
|
+
});
|
|
189
|
+
|
|
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 }
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Composite all screenshots
|
|
201
|
+
const stitchedImage = await finalImage
|
|
202
|
+
.composite(compositeInputs)
|
|
203
|
+
.png()
|
|
204
|
+
.toBuffer();
|
|
205
|
+
|
|
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})`);
|
|
91
215
|
}
|
|
92
|
-
|
|
216
|
+
}
|
|
93
217
|
|
|
94
218
|
// 2. Dump full-page DOM
|
|
95
219
|
const domPath = path.join(outputDir, `${baseFilename}.html`);
|