@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.
- package/__tests__/.gitkeep +0 -0
- package/__tests__/config-integration.spec.ts +102 -0
- package/__tests__/snapshot.spec.d.ts +1 -0
- package/__tests__/snapshot.spec.js +81 -0
- package/__tests__/snapshot.spec.ts +58 -0
- package/__tests__/unit/ci.spec.d.ts +1 -0
- package/__tests__/unit/ci.spec.js +35 -0
- package/__tests__/unit/ci.spec.ts +40 -0
- package/__tests__/unit/reporter.spec.d.ts +1 -0
- package/__tests__/unit/reporter.spec.js +37 -0
- package/__tests__/unit/reporter.spec.ts +43 -0
- package/__tests__/unit/structureAnalyzer.spec.js +212 -0
- package/__tests__/unit/types.spec.ts +179 -0
- package/dist/__tests__/unit/ci.spec.d.ts +1 -0
- package/dist/__tests__/unit/ci.spec.js +226 -0
- package/dist/__tests__/unit/compression.spec.d.ts +4 -0
- package/dist/__tests__/unit/compression.spec.js +46 -0
- package/dist/ci.d.ts +30 -0
- package/dist/ci.js +117 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +47 -0
- package/dist/cli/init.d.ts +3 -0
- package/dist/cli/init.js +158 -0
- package/dist/config/loader.d.ts +29 -0
- package/dist/config/loader.js +251 -0
- package/dist/domAnalyzer.d.ts +10 -0
- package/dist/domAnalyzer.js +285 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +11 -0
- package/dist/reporter-entry.d.ts +2 -0
- package/dist/reporter-entry.js +5 -0
- package/dist/reporter-types.d.ts +2 -0
- package/dist/reporter-types.js +2 -0
- package/dist/reporter.d.ts +21 -0
- package/dist/reporter.js +249 -0
- package/dist/snapshot.d.ts +12 -0
- package/dist/snapshot.js +601 -0
- package/dist/structureAnalyzer.d.ts +12 -0
- package/dist/structureAnalyzer.js +288 -0
- package/dist/types.d.ts +368 -0
- package/dist/types.js +10 -0
- package/examples/structure-analysis-example.spec.ts +118 -0
- package/examples/structure-analysis.config.ts +159 -0
- package/jest.config.js +8 -0
- package/package.json +51 -0
- package/playwright.config.ts +11 -0
- package/src/__tests__/unit/ci.spec.ts +257 -0
- package/src/__tests__/unit/compression.spec.ts +52 -0
- package/src/ci.ts +140 -0
- package/src/cli/index.ts +49 -0
- package/src/cli/init.ts +131 -0
- package/src/config/loader.ts +238 -0
- package/src/index.ts +14 -0
- package/src/reporter-entry.ts +6 -0
- package/src/reporter-types.ts +5 -0
- package/src/reporter.ts +251 -0
- package/src/snapshot.ts +632 -0
- package/src/structureAnalyzer.ts +338 -0
- package/src/types.ts +388 -0
- package/tsconfig.jest.json +7 -0
- 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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,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;
|
package/dist/reporter.js
ADDED
|
@@ -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>;
|