@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 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.
@@ -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
- // 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);
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
- // 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');
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
- // 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';
105
+ if (isScrollable && el.scrollHeight > el.clientHeight) {
106
+ if (el.scrollHeight > maxScrollHeight) {
107
+ maxScrollHeight = el.scrollHeight;
108
+ mainScrollable = el;
109
+ }
123
110
  }
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
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
- html.style.overflow = 'visible';
148
- html.style.height = 'auto';
149
- body.style.overflow = 'visible';
150
- body.style.height = 'auto';
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
- // 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;
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
- // 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;
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.6",
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
- 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
 
@@ -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
- // 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 = [];
64
-
65
- // Process all elements to find and fix viewport-constraining styles
66
- document.querySelectorAll('*').forEach(function(el) {
67
- var computed = window.getComputedStyle(el);
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
- 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
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
- // 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';
80
+ if (isScrollable && el.scrollHeight > el.clientHeight) {
81
+ if (el.scrollHeight > maxScrollHeight) {
82
+ maxScrollHeight = el.scrollHeight;
83
+ mainScrollable = el;
84
+ }
100
85
  }
101
-
102
- // Remove overflow constraints
103
- el.style.overflow = 'visible';
104
- el.style.overflowY = 'visible';
105
- el.style.maxHeight = 'none';
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
- // Also handle html and body elements specifically
110
- var html = document.documentElement;
111
- var body = document.body;
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
- 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
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
- html.style.overflow = 'visible';
125
- html.style.height = 'auto';
126
- body.style.overflow = 'visible';
127
- body.style.height = 'auto';
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
- // 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;
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`);