@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 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,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
- // Temporarily disable overflow constraints to enable full-page capture
83
- await page.evaluate(`
84
- window.__testivaiOriginalStyles = [];
85
- document.querySelectorAll('*').forEach((el) => {
86
- const computed = window.getComputedStyle(el);
87
- if (computed.overflow === 'auto' || computed.overflow === 'scroll' ||
88
- computed.overflowY === 'auto' || computed.overflowY === 'scroll') {
89
- window.__testivaiOriginalStyles.push({
90
- element: el,
91
- overflow: el.style.overflow,
92
- height: el.style.height
93
- });
94
- el.style.overflow = 'visible';
95
- el.style.height = 'auto';
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
- // Wait for layout to stabilize
100
- await page.waitForTimeout(200);
101
- // Take full-page screenshot
102
- await page.screenshot({ path: screenshotPath, fullPage: true });
103
- // Restore original styles
104
- await page.evaluate(`
105
- if (window.__testivaiOriginalStyles) {
106
- window.__testivaiOriginalStyles.forEach((item) => {
107
- item.element.style.overflow = item.overflow;
108
- item.element.style.height = item.height;
109
- });
110
- 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
+ });
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.5",
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.2
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
- 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,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
- // Temporarily disable overflow constraints to enable full-page capture
60
- await page.evaluate(`
61
- window.__testivaiOriginalStyles = [];
62
- document.querySelectorAll('*').forEach((el) => {
63
- const computed = window.getComputedStyle(el);
64
- if (computed.overflow === 'auto' || computed.overflow === 'scroll' ||
65
- computed.overflowY === 'auto' || computed.overflowY === 'scroll') {
66
- window.__testivaiOriginalStyles.push({
67
- element: el,
68
- overflow: el.style.overflow,
69
- height: el.style.height
70
- });
71
- el.style.overflow = 'visible';
72
- el.style.height = 'auto';
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
- // Wait for layout to stabilize
78
- await page.waitForTimeout(200);
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
- // Take full-page screenshot
81
- await page.screenshot({ path: screenshotPath, fullPage: true });
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
- // Restore original styles
84
- await page.evaluate(`
85
- if (window.__testivaiOriginalStyles) {
86
- window.__testivaiOriginalStyles.forEach((item) => {
87
- item.element.style.overflow = item.overflow;
88
- item.element.style.height = item.height;
89
- });
90
- delete window.__testivaiOriginalStyles;
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`);