@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 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
- await fs.emptyDir(this.tempDir);
160
- console.log('Testivai Reporter: Cleaned up temporary evidence files.');
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
- // Temporarily disable overflow constraints and fixed heights to enable full-page capture
83
- // This handles common SPA patterns like h-screen overflow-hidden with internal scrolling
84
- /* eslint-disable no-eval */
85
- await page.evaluate(`
86
- window.__testivaiOriginalStyles = [];
87
-
88
- // Process all elements to find and fix viewport-constraining styles
89
- document.querySelectorAll('*').forEach(function(el) {
90
- var computed = window.getComputedStyle(el);
91
-
92
- // Check for overflow constraints
93
- var hasOverflowConstraint =
94
- computed.overflow === 'auto' ||
95
- computed.overflow === 'scroll' ||
96
- computed.overflow === 'hidden' ||
97
- computed.overflowY === 'auto' ||
98
- computed.overflowY === 'scroll' ||
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');
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
- 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
- });
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
- // For scrollable containers, expand to full scroll height
118
- if (hasOverflowConstraint && el.scrollHeight > el.clientHeight) {
119
- el.style.height = el.scrollHeight + 'px';
120
- el.style.minHeight = el.scrollHeight + 'px';
121
- } else if (hasHeightConstraint) {
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
- // Remove overflow constraints
126
- el.style.overflow = 'visible';
127
- el.style.overflowY = 'visible';
128
- el.style.maxHeight = 'none';
129
- }
130
- });
131
-
132
- // Also handle html and body elements specifically
133
- var html = document.documentElement;
134
- var body = document.body;
135
-
136
- window.__testivaiRootStyles = {
137
- html: {
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
- html.style.overflow = 'visible';
148
- html.style.height = 'auto';
149
- body.style.overflow = 'visible';
150
- body.style.height = 'auto';
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
- // Wait for layout to stabilize after style changes
153
- await page.waitForTimeout(300);
154
- // Take full-page screenshot
155
- await page.screenshot({ path: screenshotPath, fullPage: true });
156
- // Restore original styles
157
- await page.evaluate(`
158
- // Restore element styles
159
- if (window.__testivaiOriginalStyles) {
160
- window.__testivaiOriginalStyles.forEach(function(item) {
161
- item.element.style.overflow = item.overflow;
162
- item.element.style.overflowY = item.overflowY;
163
- item.element.style.height = item.height;
164
- item.element.style.maxHeight = item.maxHeight;
165
- item.element.style.minHeight = item.minHeight;
166
- });
167
- delete window.__testivaiOriginalStyles;
168
- }
169
-
170
- // Restore root element styles
171
- if (window.__testivaiRootStyles) {
172
- var html = document.documentElement;
173
- var body = document.body;
174
- html.style.overflow = window.__testivaiRootStyles.html.overflow;
175
- html.style.height = window.__testivaiRootStyles.html.height;
176
- body.style.overflow = window.__testivaiRootStyles.body.overflow;
177
- body.style.height = window.__testivaiRootStyles.body.height;
178
- delete window.__testivaiRootStyles;
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.6",
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
- await fs.emptyDir(this.tempDir);
152
- console.log('Testivai Reporter: Cleaned up temporary evidence files.');
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
- // Temporarily disable overflow constraints and fixed heights to enable full-page capture
60
- // This handles common SPA patterns like h-screen overflow-hidden with internal scrolling
61
- /* eslint-disable no-eval */
62
- await page.evaluate(`
63
- window.__testivaiOriginalStyles = [];
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
- // Process all elements to find and fix viewport-constraining styles
66
- document.querySelectorAll('*').forEach(function(el) {
67
- var computed = window.getComputedStyle(el);
67
+ try {
68
+ // Create a CDP session
69
+ const client = await page.context().newCDPSession(page);
68
70
 
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';
71
+ // Enable Page domain
72
+ await client.send('Page.enable');
77
73
 
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');
74
+ // Get layout metrics to determine full page size
75
+ const layoutMetrics = await client.send('Page.getLayoutMetrics');
83
76
 
84
- if (hasOverflowConstraint || hasHeightConstraint) {
85
- window.__testivaiOriginalStyles.push({
86
- element: el,
87
- overflow: el.style.overflow,
88
- overflowY: el.style.overflowY,
89
- height: el.style.height,
90
- maxHeight: el.style.maxHeight,
91
- minHeight: el.style.minHeight
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
- // Also handle html and body elements specifically
110
- var html = document.documentElement;
111
- var body = document.body;
120
+ // Get viewport dimensions
121
+ const viewport = page.viewportSize();
122
+ const viewportWidth = viewport?.width || 1280;
123
+ const viewportHeight = viewport?.height || 720;
112
124
 
113
- window.__testivaiRootStyles = {
114
- html: {
115
- overflow: html.style.overflow,
116
- height: html.style.height
117
- },
118
- body: {
119
- overflow: body.style.overflow,
120
- height: body.style.height
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
- html.style.overflow = 'visible';
125
- html.style.height = 'auto';
126
- body.style.overflow = 'visible';
127
- body.style.height = 'auto';
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
- // Wait for layout to stabilize after style changes
131
- await page.waitForTimeout(300);
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
- // Take full-page screenshot
134
- await page.screenshot({ path: screenshotPath, fullPage: true });
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
- // 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;
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
- // Restore root element styles
151
- if (window.__testivaiRootStyles) {
152
- var html = document.documentElement;
153
- var body = document.body;
154
- html.style.overflow = window.__testivaiRootStyles.html.overflow;
155
- html.style.height = window.__testivaiRootStyles.html.height;
156
- body.style.overflow = window.__testivaiRootStyles.body.overflow;
157
- body.style.height = window.__testivaiRootStyles.body.height;
158
- delete window.__testivaiRootStyles;
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
  /**