@testivai/witness-playwright 1.0.0

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.
Files changed (61) hide show
  1. package/__tests__/.gitkeep +0 -0
  2. package/__tests__/config-integration.spec.ts +102 -0
  3. package/__tests__/snapshot.spec.d.ts +1 -0
  4. package/__tests__/snapshot.spec.js +81 -0
  5. package/__tests__/snapshot.spec.ts +58 -0
  6. package/__tests__/unit/ci.spec.d.ts +1 -0
  7. package/__tests__/unit/ci.spec.js +35 -0
  8. package/__tests__/unit/ci.spec.ts +40 -0
  9. package/__tests__/unit/reporter.spec.d.ts +1 -0
  10. package/__tests__/unit/reporter.spec.js +37 -0
  11. package/__tests__/unit/reporter.spec.ts +43 -0
  12. package/__tests__/unit/structureAnalyzer.spec.js +212 -0
  13. package/__tests__/unit/types.spec.ts +179 -0
  14. package/dist/__tests__/unit/ci.spec.d.ts +1 -0
  15. package/dist/__tests__/unit/ci.spec.js +226 -0
  16. package/dist/__tests__/unit/compression.spec.d.ts +4 -0
  17. package/dist/__tests__/unit/compression.spec.js +46 -0
  18. package/dist/ci.d.ts +30 -0
  19. package/dist/ci.js +117 -0
  20. package/dist/cli/index.d.ts +2 -0
  21. package/dist/cli/index.js +47 -0
  22. package/dist/cli/init.d.ts +3 -0
  23. package/dist/cli/init.js +158 -0
  24. package/dist/config/loader.d.ts +29 -0
  25. package/dist/config/loader.js +251 -0
  26. package/dist/domAnalyzer.d.ts +10 -0
  27. package/dist/domAnalyzer.js +285 -0
  28. package/dist/index.d.ts +7 -0
  29. package/dist/index.js +11 -0
  30. package/dist/reporter-entry.d.ts +2 -0
  31. package/dist/reporter-entry.js +5 -0
  32. package/dist/reporter-types.d.ts +2 -0
  33. package/dist/reporter-types.js +2 -0
  34. package/dist/reporter.d.ts +21 -0
  35. package/dist/reporter.js +249 -0
  36. package/dist/snapshot.d.ts +12 -0
  37. package/dist/snapshot.js +601 -0
  38. package/dist/structureAnalyzer.d.ts +12 -0
  39. package/dist/structureAnalyzer.js +288 -0
  40. package/dist/types.d.ts +368 -0
  41. package/dist/types.js +10 -0
  42. package/examples/structure-analysis-example.spec.ts +118 -0
  43. package/examples/structure-analysis.config.ts +159 -0
  44. package/jest.config.js +8 -0
  45. package/package.json +51 -0
  46. package/playwright.config.ts +11 -0
  47. package/src/__tests__/unit/ci.spec.ts +257 -0
  48. package/src/__tests__/unit/compression.spec.ts +52 -0
  49. package/src/ci.ts +140 -0
  50. package/src/cli/index.ts +49 -0
  51. package/src/cli/init.ts +131 -0
  52. package/src/config/loader.ts +238 -0
  53. package/src/index.ts +14 -0
  54. package/src/reporter-entry.ts +6 -0
  55. package/src/reporter-types.ts +5 -0
  56. package/src/reporter.ts +251 -0
  57. package/src/snapshot.ts +632 -0
  58. package/src/structureAnalyzer.ts +338 -0
  59. package/src/types.ts +388 -0
  60. package/tsconfig.jest.json +7 -0
  61. package/tsconfig.json +20 -0
@@ -0,0 +1,285 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.analyzeDOM = analyzeDOM;
4
+ exports.compareDOMAnalysis = compareDOMAnalysis;
5
+ /**
6
+ * Default configuration for DOM analysis
7
+ */
8
+ const DEFAULT_CONFIG = {
9
+ enableFingerprint: true,
10
+ enableStructure: true,
11
+ enableSemantic: true,
12
+ ignoreAttributes: [
13
+ 'data-testid',
14
+ 'data-reactid',
15
+ 'data-reactroot',
16
+ 'ng-version',
17
+ 'ng-version',
18
+ 'ng-reflect-router-outlet',
19
+ 'data-ng-version',
20
+ 'style', // Inline styles change often
21
+ 'class', // CSS classes can be dynamic
22
+ ],
23
+ ignoreElements: [
24
+ 'script',
25
+ 'style',
26
+ 'noscript',
27
+ 'meta',
28
+ 'link',
29
+ 'title',
30
+ ],
31
+ ignoreContentPatterns: [
32
+ /\d{4}-\d{2}-\d{2}/, // Dates
33
+ /\d{1,2}:\d{2}(:\d{2})?/, // Times
34
+ /\b\d{4}\b/, // Years
35
+ /\b\d+\b/, // Pure numbers
36
+ /uuid-/i, // UUIDs
37
+ /_\d+/, // Number suffixes
38
+ /\$\d+\.?\d*/, // Currency
39
+ ],
40
+ };
41
+ /**
42
+ * Analyzes DOM structure and generates fingerprint
43
+ */
44
+ async function analyzeDOM(page, config = {}) {
45
+ const finalConfig = { ...DEFAULT_CONFIG, ...config };
46
+ return await page.evaluate((cfg) => {
47
+ // Helper functions
48
+ const normalizeSelector = (el) => {
49
+ let selector = el.nodeName.toLowerCase();
50
+ // Add ID if present and not a dynamic ID
51
+ if (el.id && !cfg.ignoreAttributes.includes('id')) {
52
+ // Skip if ID looks dynamic
53
+ if (!/^(ember|react|ng|vue|_)/.test(el.id)) {
54
+ selector += `#${el.id}`;
55
+ }
56
+ }
57
+ // Add meaningful classes only
58
+ if (el.className && !cfg.ignoreAttributes.includes('class')) {
59
+ const classes = el.className.toString()
60
+ .split(' ')
61
+ .filter((c) => c &&
62
+ !/^(ember|react|ng|vue|css|active|hover|focus)/.test(c) &&
63
+ !c.includes('_') &&
64
+ !/\d/.test(c))
65
+ .slice(0, 3); // Limit to 3 most meaningful classes
66
+ if (classes.length > 0) {
67
+ selector += `.${classes.join('.')}`;
68
+ }
69
+ }
70
+ return selector;
71
+ };
72
+ const getElementPath = (el, maxDepth = 10) => {
73
+ const path = [];
74
+ let current = el;
75
+ let depth = 0;
76
+ while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) {
77
+ path.unshift(normalizeSelector(current));
78
+ current = current.parentElement;
79
+ depth++;
80
+ }
81
+ return path.join(' > ');
82
+ };
83
+ const shouldIgnoreElement = (el) => {
84
+ return cfg.ignoreElements.includes(el.nodeName.toLowerCase());
85
+ };
86
+ const shouldIgnoreAttribute = (attr) => {
87
+ return cfg.ignoreAttributes.includes(attr.toLowerCase());
88
+ };
89
+ const shouldIgnoreContent = (content) => {
90
+ return cfg.ignoreContentPatterns.some((pattern) => pattern.test(content));
91
+ };
92
+ const normalizeText = (text) => {
93
+ if (!text)
94
+ return '';
95
+ // Replace ignored patterns with placeholders
96
+ let normalized = text.trim();
97
+ cfg.ignoreContentPatterns.forEach((pattern) => {
98
+ normalized = normalized.replace(pattern, '[DYNAMIC]');
99
+ });
100
+ // Normalize whitespace
101
+ normalized = normalized.replace(/\s+/g, ' ');
102
+ return normalized;
103
+ };
104
+ // Get all meaningful elements
105
+ const allElements = Array.from(document.querySelectorAll('*'))
106
+ .filter(el => !shouldIgnoreElement(el));
107
+ const analysis = {};
108
+ // 1. Generate fingerprint
109
+ if (cfg.enableFingerprint) {
110
+ const paths = allElements.map((el) => {
111
+ const path = getElementPath(el);
112
+ const text = normalizeText(el.textContent || '');
113
+ const attrs = [];
114
+ // Add important attributes
115
+ for (const attr of el.attributes) {
116
+ if (!shouldIgnoreAttribute(attr.name) && attr.value) {
117
+ attrs.push(`${attr.name}="${attr.value}"`);
118
+ }
119
+ }
120
+ return `${path}${text ? `|${text}` : ''}${attrs.length ? `|${attrs.join(',')}` : ''}`;
121
+ });
122
+ // Create hash from sorted paths
123
+ const sortedPaths = paths.sort().join('|||');
124
+ analysis.fingerprint = btoa(sortedPaths).substring(0, 32);
125
+ }
126
+ // 2. Structural analysis
127
+ if (cfg.enableStructure) {
128
+ const elementTypes = {};
129
+ let maxDepth = 0;
130
+ allElements.forEach((el) => {
131
+ const tag = el.nodeName.toLowerCase();
132
+ elementTypes[tag] = (elementTypes[tag] || 0) + 1;
133
+ // Calculate depth
134
+ let depth = 0;
135
+ let current = el;
136
+ while (current.parentElement) {
137
+ depth++;
138
+ current = current.parentElement;
139
+ }
140
+ maxDepth = Math.max(maxDepth, depth);
141
+ });
142
+ analysis.structure = {
143
+ totalElements: allElements.length,
144
+ elementTypes,
145
+ maxDepth,
146
+ interactiveElements: {
147
+ buttons: document.querySelectorAll('button').length,
148
+ inputs: document.querySelectorAll('input').length,
149
+ links: document.querySelectorAll('a[href]').length,
150
+ forms: document.querySelectorAll('form').length,
151
+ },
152
+ };
153
+ }
154
+ // 3. Semantic analysis
155
+ if (cfg.enableSemantic) {
156
+ const headings = {};
157
+ for (let i = 1; i <= 6; i++) {
158
+ headings[`h${i}`] = document.querySelectorAll(`h${i}`).length;
159
+ }
160
+ analysis.semantic = {
161
+ headings,
162
+ landmarks: {
163
+ hasHeader: !!document.querySelector('header'),
164
+ hasNav: !!document.querySelector('nav'),
165
+ hasMain: !!document.querySelector('main'),
166
+ hasFooter: !!document.querySelector('footer'),
167
+ hasAside: !!document.querySelector('aside'),
168
+ },
169
+ lists: {
170
+ ordered: document.querySelectorAll('ol').length,
171
+ unordered: document.querySelectorAll('ul').length,
172
+ },
173
+ tables: document.querySelectorAll('table').length,
174
+ images: document.querySelectorAll('img').length,
175
+ };
176
+ }
177
+ // 4. Component analysis (framework-agnostic)
178
+ const components = {};
179
+ allElements.forEach((el) => {
180
+ // Try various component detection strategies
181
+ const componentName = el.getAttribute('data-testid')?.replace(/-/g, '') ||
182
+ el.getAttribute('data-component') ||
183
+ el.getAttribute('data-testid') ||
184
+ el.className?.toString().match(/(\w+Container|\w+Component|\w+Page|\w+Card|\w+Modal)/i)?.[1];
185
+ if (componentName && componentName.length > 2) {
186
+ if (!components[componentName]) {
187
+ components[componentName] = [];
188
+ }
189
+ const attrs = {};
190
+ for (const attr of el.attributes) {
191
+ if (!shouldIgnoreAttribute(attr.name) && attr.value) {
192
+ attrs[attr.name] = attr.value;
193
+ }
194
+ }
195
+ components[componentName].push({
196
+ selector: getElementPath(el),
197
+ text: normalizeText(el.textContent || '').substring(0, 100),
198
+ attributes: attrs,
199
+ });
200
+ }
201
+ });
202
+ if (Object.keys(components).length > 0) {
203
+ analysis.components = components;
204
+ }
205
+ return analysis;
206
+ }, finalConfig);
207
+ }
208
+ /**
209
+ * Compare two DOM analyses and identify changes
210
+ */
211
+ function compareDOMAnalysis(baseline, current) {
212
+ const changes = [];
213
+ // Compare fingerprints
214
+ if (baseline.fingerprint && current.fingerprint) {
215
+ if (baseline.fingerprint !== current.fingerprint) {
216
+ changes.push({
217
+ type: 'fingerprint',
218
+ severity: 'high',
219
+ description: 'DOM structure has changed',
220
+ details: {
221
+ baseline: baseline.fingerprint,
222
+ current: current.fingerprint,
223
+ },
224
+ });
225
+ }
226
+ }
227
+ // Compare structure
228
+ if (baseline.structure && current.structure) {
229
+ if (baseline.structure.totalElements !== current.structure.totalElements) {
230
+ changes.push({
231
+ type: 'structure',
232
+ severity: 'medium',
233
+ description: `Element count changed from ${baseline.structure.totalElements} to ${current.structure.totalElements}`,
234
+ });
235
+ }
236
+ // Check for new/removed element types
237
+ const baselineTypes = Object.keys(baseline.structure.elementTypes);
238
+ const currentTypes = Object.keys(current.structure.elementTypes);
239
+ const removed = baselineTypes.filter(t => !currentTypes.includes(t));
240
+ const added = currentTypes.filter(t => !baselineTypes.includes(t));
241
+ if (removed.length > 0) {
242
+ changes.push({
243
+ type: 'structure',
244
+ severity: 'medium',
245
+ description: `Removed element types: ${removed.join(', ')}`,
246
+ });
247
+ }
248
+ if (added.length > 0) {
249
+ changes.push({
250
+ type: 'structure',
251
+ severity: 'medium',
252
+ description: `Added element types: ${added.join(', ')}`,
253
+ });
254
+ }
255
+ }
256
+ // Compare semantic structure
257
+ if (baseline.semantic && current.semantic) {
258
+ // Check heading changes
259
+ const baselineHeadings = baseline.semantic.headings;
260
+ const currentHeadings = current.semantic.headings;
261
+ for (const level in baselineHeadings) {
262
+ if (baselineHeadings[level] !== currentHeadings[level]) {
263
+ changes.push({
264
+ type: 'semantic',
265
+ severity: 'low',
266
+ description: `${level} count changed from ${baselineHeadings[level]} to ${currentHeadings[level]}`,
267
+ });
268
+ }
269
+ }
270
+ // Check landmark changes
271
+ const baselineLandmarks = baseline.semantic.landmarks;
272
+ const currentLandmarks = current.semantic.landmarks;
273
+ for (const landmark in baselineLandmarks) {
274
+ if (baselineLandmarks[landmark] !==
275
+ currentLandmarks[landmark]) {
276
+ changes.push({
277
+ type: 'semantic',
278
+ severity: 'medium',
279
+ description: `Landmark ${landmark} ${baselineLandmarks[landmark] ? 'removed' : 'added'}`,
280
+ });
281
+ }
282
+ }
283
+ }
284
+ return changes;
285
+ }
@@ -0,0 +1,7 @@
1
+ import { snapshot } from './snapshot';
2
+ import { getCiRunId } from './ci';
3
+ export declare const testivai: {
4
+ witness: typeof snapshot;
5
+ ci: typeof getCiRunId;
6
+ };
7
+ export type { TestivAIConfig, TestivAIProjectConfig, StructureAnalysisConfig, StructureAnalysis } from './types';
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.testivai = void 0;
4
+ const snapshot_1 = require("./snapshot");
5
+ const ci_1 = require("./ci");
6
+ exports.testivai = {
7
+ witness: snapshot_1.snapshot,
8
+ ci: ci_1.getCiRunId,
9
+ };
10
+ // Structure analyzer is now handled on the backend
11
+ // The types are kept for backwards compatibility
@@ -0,0 +1,2 @@
1
+ import { TestivAIPlaywrightReporter } from './reporter';
2
+ export = TestivAIPlaywrightReporter;
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ // Entry point for Playwright reporter
3
+ // Playwright expects: module.exports = ReporterClass
4
+ const reporter_1 = require("./reporter");
5
+ module.exports = reporter_1.TestivAIPlaywrightReporter;
@@ -0,0 +1,2 @@
1
+ import type { Reporter, FullConfig, Suite, FullResult } from '@playwright/test/reporter';
2
+ export type { Reporter, FullConfig, Suite, FullResult };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,21 @@
1
+ import { Reporter, FullConfig, Suite, FullResult } from './reporter-types';
2
+ import { type CompressionOptions } from '@testivai/common';
3
+ interface TestivaiReporterOptions {
4
+ apiUrl?: string;
5
+ apiKey?: string;
6
+ compression?: CompressionOptions;
7
+ debug?: boolean;
8
+ }
9
+ export declare class TestivAIPlaywrightReporter implements Reporter {
10
+ private options;
11
+ private gitInfo;
12
+ private browserInfo;
13
+ private runId;
14
+ private ciInfo;
15
+ private tempDir;
16
+ private compressionHelper;
17
+ constructor(options?: TestivaiReporterOptions);
18
+ onBegin(config: FullConfig, suite: Suite): Promise<void>;
19
+ onEnd(result: FullResult): Promise<void>;
20
+ }
21
+ export default TestivAIPlaywrightReporter;
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.TestivAIPlaywrightReporter = void 0;
40
+ const fs = __importStar(require("fs-extra"));
41
+ const path = __importStar(require("path"));
42
+ const simple_git_1 = __importDefault(require("simple-git"));
43
+ const axios_1 = __importDefault(require("axios"));
44
+ const ci_1 = require("./ci");
45
+ const common_1 = require("@testivai/common");
46
+ class TestivAIPlaywrightReporter {
47
+ constructor(options = {}) {
48
+ this.gitInfo = null;
49
+ this.browserInfo = null;
50
+ this.runId = null;
51
+ this.ciInfo = null;
52
+ this.tempDir = path.join(process.cwd(), '.testivai', 'temp');
53
+ this.options = {
54
+ apiUrl: options.apiUrl || process.env.TESTIVAI_API_URL || 'https://core-api.testiv.ai',
55
+ apiKey: options.apiKey || process.env.TESTIVAI_API_KEY,
56
+ compression: options.compression || {},
57
+ debug: options.debug || process.env.TESTIVAI_DEBUG === 'true',
58
+ };
59
+ // Initialize compression helper
60
+ this.compressionHelper = new common_1.CompressionHelper(this.options.compression);
61
+ }
62
+ async onBegin(config, suite) {
63
+ if (!this.options.apiKey) {
64
+ console.error('Testivai Reporter: API Key is not configured. Disabling reporter.');
65
+ console.error('Set TESTIVAI_API_KEY environment variable or pass apiKey in reporter options.');
66
+ this.options.apiUrl = undefined; // Disable reporter
67
+ return;
68
+ }
69
+ // 1. Clean temp directory
70
+ await fs.emptyDir(this.tempDir);
71
+ // 2. Capture Git metadata
72
+ try {
73
+ const git = (0, simple_git_1.default)();
74
+ const [branch, commit] = await Promise.all([
75
+ git.revparse(['--abbrev-ref', 'HEAD']),
76
+ git.revparse(['HEAD']),
77
+ ]);
78
+ this.gitInfo = { branch, commit };
79
+ }
80
+ catch (error) {
81
+ console.error('Testivai Reporter: Could not get Git information.', error);
82
+ this.gitInfo = { branch: 'unknown', commit: 'unknown' };
83
+ }
84
+ // 3. Capture Browser info from the first project
85
+ const project = suite.suites[0]?.project();
86
+ if (project) {
87
+ this.browserInfo = {
88
+ name: project.use.browserName || 'unknown',
89
+ version: 'unknown', // Playwright does not easily expose browser version
90
+ viewportWidth: project.use.viewport?.width || 0,
91
+ viewportHeight: project.use.viewport?.height || 0,
92
+ userAgent: project.use.userAgent || 'unknown',
93
+ os: 'unknown',
94
+ };
95
+ }
96
+ // 4. Get CI Run ID and CI info
97
+ this.runId = (0, ci_1.getCiRunId)();
98
+ this.ciInfo = (0, ci_1.getCiInfo)();
99
+ if (this.runId && this.options.debug) {
100
+ console.log(`Testivai Reporter: [DEBUG] Detected CI environment. Run ID: ${this.runId}`);
101
+ if (this.ciInfo) {
102
+ console.log(`Testivai Reporter: [DEBUG] CI Info: provider=${this.ciInfo.provider}, PR=#${this.ciInfo.prNumber || 'N/A'}`);
103
+ }
104
+ }
105
+ }
106
+ async onEnd(result) {
107
+ // Wrap entire reporter logic in try-catch to prevent crashes
108
+ try {
109
+ if (!this.options.apiUrl) {
110
+ return; // Reporter is disabled
111
+ }
112
+ if (this.options.debug) {
113
+ console.log('Testivai Reporter: [DEBUG] Test run finished. Preparing to upload evidence...');
114
+ }
115
+ const snapshotFiles = await fs.readdir(this.tempDir);
116
+ // Filter out .css.json files - they're not metadata files
117
+ const jsonFiles = snapshotFiles.filter(f => f.endsWith('.json') && !f.endsWith('.css.json'));
118
+ if (jsonFiles.length === 0) {
119
+ if (this.options.debug) {
120
+ console.log('Testivai Reporter: [DEBUG] No snapshots found to upload.');
121
+ }
122
+ return;
123
+ }
124
+ if (this.options.debug) {
125
+ console.log(`Testivai Reporter: [DEBUG] Found ${jsonFiles.length} snapshot(s) to process`);
126
+ }
127
+ // Build snapshots array matching CDP SDK format
128
+ const snapshots = [];
129
+ for (const jsonFile of jsonFiles) {
130
+ const metadataPath = path.join(this.tempDir, jsonFile);
131
+ const metadata = await fs.readJson(metadataPath);
132
+ // Null safety: ensure metadata has required structure
133
+ // @renamed: dom → structure, css → styles (IP protection)
134
+ const structurePath = metadata.files.structure;
135
+ const screenshotPath = metadata.files.screenshot;
136
+ const stylesPath = metadata.files.styles;
137
+ if (!metadata.files || !structurePath || !screenshotPath) {
138
+ console.warn(`Testivai Reporter: Invalid metadata structure in ${jsonFile}, skipping...`);
139
+ continue;
140
+ }
141
+ // Extract the first selector's layout data (usually 'body')
142
+ const layoutKeys = Object.keys(metadata.layout || {});
143
+ if (layoutKeys.length === 0) {
144
+ console.warn(`Testivai Reporter: No layout data found for ${metadata.snapshotName}, skipping...`);
145
+ continue;
146
+ }
147
+ const firstSelector = layoutKeys[0];
148
+ const layoutData = metadata.layout[firstSelector];
149
+ // Read screenshot and encode to base64
150
+ const screenshotBuffer = await fs.readFile(screenshotPath);
151
+ const screenshotBase64 = screenshotBuffer.toString('base64');
152
+ // Read computed styles if available
153
+ // @renamed: cssData → stylesData (IP protection)
154
+ let stylesData;
155
+ if (stylesPath && await fs.pathExists(stylesPath)) {
156
+ try {
157
+ const stylesJson = await fs.readJson(stylesPath);
158
+ if (stylesJson.computed_styles && Object.keys(stylesJson.computed_styles).length > 0) {
159
+ stylesData = { computed_styles: stylesJson.computed_styles };
160
+ }
161
+ }
162
+ catch (err) {
163
+ if (this.options.debug) {
164
+ console.warn(`Testivai Reporter: [DEBUG] Failed to read styles file for ${metadata.snapshotName}:`, err);
165
+ }
166
+ }
167
+ }
168
+ // @renamed: dom → structure, css → styles (IP protection)
169
+ const snapshotPayload = {
170
+ snapshotName: metadata.snapshotName,
171
+ testName: metadata.testName,
172
+ timestamp: metadata.timestamp,
173
+ url: metadata.url,
174
+ viewport: metadata.viewport,
175
+ structure: { html: await fs.readFile(structurePath, 'utf-8') },
176
+ styles: stylesData,
177
+ layout: {
178
+ x: layoutData.x,
179
+ y: layoutData.y,
180
+ width: layoutData.width,
181
+ height: layoutData.height,
182
+ top: layoutData.y,
183
+ left: layoutData.x,
184
+ right: layoutData.x + layoutData.width,
185
+ bottom: layoutData.y + layoutData.height
186
+ },
187
+ testivaiConfig: metadata.testivaiConfig,
188
+ screenshotData: screenshotBase64
189
+ };
190
+ snapshots.push(snapshotPayload);
191
+ }
192
+ // Build batch payload matching CDP SDK format
193
+ const batchPayload = {
194
+ git: this.gitInfo,
195
+ browser: this.browserInfo,
196
+ snapshots,
197
+ timestamp: Date.now(),
198
+ runId: this.runId,
199
+ ci: this.ciInfo,
200
+ };
201
+ // Compress and upload (same as CDP SDK)
202
+ const payloadJson = JSON.stringify(batchPayload);
203
+ const compressionResult = await this.compressionHelper.compress(payloadJson);
204
+ if (this.options.debug && compressionResult.compressionRatio > 0) {
205
+ this.compressionHelper.logCompressionResult(compressionResult);
206
+ }
207
+ const headers = {
208
+ 'X-API-KEY': this.options.apiKey,
209
+ 'Content-Type': 'application/json',
210
+ ...compressionResult.headers,
211
+ };
212
+ // Upload to same endpoint as CDP SDK
213
+ const startBatchResponse = await axios_1.default.post(`${this.options.apiUrl}/api/v1/ingest/start-batch`, compressionResult.data, { headers });
214
+ const batchId = startBatchResponse.data.batch_id || startBatchResponse.data.batchId;
215
+ // Show success message (brief in normal mode, detailed in debug mode)
216
+ if (this.options.debug) {
217
+ console.log(`Testivai Reporter: [DEBUG] ✓ Uploaded ${snapshots.length} snapshot(s) (Batch ID: ${batchId})`);
218
+ }
219
+ else {
220
+ console.log(`Testivai Reporter: ✓ Uploaded ${snapshots.length} snapshot(s)`);
221
+ }
222
+ // Clean up temp files (skip if DEBUG mode is enabled)
223
+ if (this.options.debug) {
224
+ console.log(`Testivai Reporter: [DEBUG] Keeping temporary evidence files in: ${this.tempDir}`);
225
+ }
226
+ else {
227
+ await fs.emptyDir(this.tempDir);
228
+ }
229
+ // Finalize batch
230
+ await axios_1.default.post(`${this.options.apiUrl}/api/v1/ingest/finish-batch/${batchId}`, {}, {
231
+ headers: { 'X-API-KEY': this.options.apiKey },
232
+ });
233
+ }
234
+ catch (error) {
235
+ // Log error but don't throw - let tests complete normally
236
+ console.error('Testivai Reporter: ❌ Error:', error.message);
237
+ if (this.options.debug) {
238
+ console.error('Error stack:', error.stack);
239
+ if (error.response) {
240
+ console.error('Response status:', error.response.status);
241
+ console.error('Response data:', JSON.stringify(error.response.data, null, 2));
242
+ }
243
+ }
244
+ // Don't throw - reporter errors should not crash the test run
245
+ }
246
+ }
247
+ }
248
+ exports.TestivAIPlaywrightReporter = TestivAIPlaywrightReporter;
249
+ exports.default = TestivAIPlaywrightReporter;
@@ -0,0 +1,12 @@
1
+ import { Page, TestInfo } from '@playwright/test';
2
+ import { TestivAIConfig } from './types';
3
+ /**
4
+ * Captures a snapshot of the page, including a screenshot, DOM, and layout data.
5
+ * The evidence is stored in a temporary directory for the reporter to process later.
6
+ *
7
+ * @param page The Playwright Page object.
8
+ * @param testInfo The Playwright TestInfo object, passed from the test.
9
+ * @param name An optional unique name for the snapshot. If not provided, a name is generated from the URL.
10
+ * @param config Optional TestivAI configuration for this snapshot (overrides project defaults).
11
+ */
12
+ export declare function snapshot(page: Page, testInfo: TestInfo, name?: string, config?: TestivAIConfig): Promise<void>;