@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
package/src/reporter.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { Reporter, FullConfig, Suite, FullResult } from './reporter-types';
|
|
2
|
+
import * as fs from 'fs-extra';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import simpleGit, { SimpleGit } from 'simple-git';
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
import { BatchPayload, BrowserInfo, GitInfo, SnapshotPayload } from './types';
|
|
7
|
+
import { getCiRunId, getCiInfo, CiInfo } from './ci';
|
|
8
|
+
import { CompressionHelper, type CompressionOptions } from '@testivai/common';
|
|
9
|
+
|
|
10
|
+
interface TestivaiReporterOptions {
|
|
11
|
+
apiUrl?: string;
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
compression?: CompressionOptions;
|
|
14
|
+
debug?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class TestivAIPlaywrightReporter implements Reporter {
|
|
18
|
+
private options: TestivaiReporterOptions;
|
|
19
|
+
private gitInfo: GitInfo | null = null;
|
|
20
|
+
private browserInfo: BrowserInfo | null = null;
|
|
21
|
+
private runId: string | null = null;
|
|
22
|
+
private ciInfo: CiInfo | null = null;
|
|
23
|
+
private tempDir = path.join(process.cwd(), '.testivai', 'temp');
|
|
24
|
+
private compressionHelper: CompressionHelper;
|
|
25
|
+
|
|
26
|
+
constructor(options: TestivaiReporterOptions = {}) {
|
|
27
|
+
this.options = {
|
|
28
|
+
apiUrl: options.apiUrl || process.env.TESTIVAI_API_URL || 'https://core-api.testiv.ai',
|
|
29
|
+
apiKey: options.apiKey || process.env.TESTIVAI_API_KEY,
|
|
30
|
+
compression: options.compression || {},
|
|
31
|
+
debug: options.debug || process.env.TESTIVAI_DEBUG === 'true',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Initialize compression helper
|
|
35
|
+
this.compressionHelper = new CompressionHelper(this.options.compression);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async onBegin(config: FullConfig, suite: Suite): Promise<void> {
|
|
39
|
+
if (!this.options.apiKey) {
|
|
40
|
+
console.error('Testivai Reporter: API Key is not configured. Disabling reporter.');
|
|
41
|
+
console.error('Set TESTIVAI_API_KEY environment variable or pass apiKey in reporter options.');
|
|
42
|
+
this.options.apiUrl = undefined; // Disable reporter
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 1. Clean temp directory
|
|
47
|
+
await fs.emptyDir(this.tempDir);
|
|
48
|
+
|
|
49
|
+
// 2. Capture Git metadata
|
|
50
|
+
try {
|
|
51
|
+
const git: SimpleGit = simpleGit();
|
|
52
|
+
const [branch, commit] = await Promise.all([
|
|
53
|
+
git.revparse(['--abbrev-ref', 'HEAD']),
|
|
54
|
+
git.revparse(['HEAD']),
|
|
55
|
+
]);
|
|
56
|
+
this.gitInfo = { branch, commit };
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('Testivai Reporter: Could not get Git information.', error);
|
|
59
|
+
this.gitInfo = { branch: 'unknown', commit: 'unknown' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Capture Browser info from the first project
|
|
63
|
+
const project = suite.suites[0]?.project();
|
|
64
|
+
if (project) {
|
|
65
|
+
this.browserInfo = {
|
|
66
|
+
name: project.use.browserName || 'unknown',
|
|
67
|
+
version: 'unknown', // Playwright does not easily expose browser version
|
|
68
|
+
viewportWidth: project.use.viewport?.width || 0,
|
|
69
|
+
viewportHeight: project.use.viewport?.height || 0,
|
|
70
|
+
userAgent: project.use.userAgent || 'unknown',
|
|
71
|
+
os: 'unknown',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 4. Get CI Run ID and CI info
|
|
76
|
+
this.runId = getCiRunId();
|
|
77
|
+
this.ciInfo = getCiInfo();
|
|
78
|
+
if (this.runId && this.options.debug) {
|
|
79
|
+
console.log(`Testivai Reporter: [DEBUG] Detected CI environment. Run ID: ${this.runId}`);
|
|
80
|
+
if (this.ciInfo) {
|
|
81
|
+
console.log(`Testivai Reporter: [DEBUG] CI Info: provider=${this.ciInfo.provider}, PR=#${this.ciInfo.prNumber || 'N/A'}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async onEnd(result: FullResult): Promise<void> {
|
|
87
|
+
// Wrap entire reporter logic in try-catch to prevent crashes
|
|
88
|
+
try {
|
|
89
|
+
if (!this.options.apiUrl) {
|
|
90
|
+
return; // Reporter is disabled
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (this.options.debug) {
|
|
94
|
+
console.log('Testivai Reporter: [DEBUG] Test run finished. Preparing to upload evidence...');
|
|
95
|
+
}
|
|
96
|
+
const snapshotFiles = await fs.readdir(this.tempDir);
|
|
97
|
+
// Filter out .css.json files - they're not metadata files
|
|
98
|
+
const jsonFiles = snapshotFiles.filter(f => f.endsWith('.json') && !f.endsWith('.css.json'));
|
|
99
|
+
|
|
100
|
+
if (jsonFiles.length === 0) {
|
|
101
|
+
if (this.options.debug) {
|
|
102
|
+
console.log('Testivai Reporter: [DEBUG] No snapshots found to upload.');
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (this.options.debug) {
|
|
108
|
+
console.log(`Testivai Reporter: [DEBUG] Found ${jsonFiles.length} snapshot(s) to process`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Build snapshots array matching CDP SDK format
|
|
112
|
+
const snapshots: SnapshotPayload[] = [];
|
|
113
|
+
|
|
114
|
+
for (const jsonFile of jsonFiles) {
|
|
115
|
+
const metadataPath = path.join(this.tempDir, jsonFile);
|
|
116
|
+
const metadata = await fs.readJson(metadataPath);
|
|
117
|
+
|
|
118
|
+
// Null safety: ensure metadata has required structure
|
|
119
|
+
// @renamed: dom → structure, css → styles (IP protection)
|
|
120
|
+
const structurePath = metadata.files.structure;
|
|
121
|
+
const screenshotPath = metadata.files.screenshot;
|
|
122
|
+
const stylesPath = metadata.files.styles;
|
|
123
|
+
|
|
124
|
+
if (!metadata.files || !structurePath || !screenshotPath) {
|
|
125
|
+
console.warn(`Testivai Reporter: Invalid metadata structure in ${jsonFile}, skipping...`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Extract the first selector's layout data (usually 'body')
|
|
130
|
+
const layoutKeys = Object.keys(metadata.layout || {});
|
|
131
|
+
if (layoutKeys.length === 0) {
|
|
132
|
+
console.warn(`Testivai Reporter: No layout data found for ${metadata.snapshotName}, skipping...`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const firstSelector = layoutKeys[0];
|
|
136
|
+
const layoutData = metadata.layout[firstSelector];
|
|
137
|
+
|
|
138
|
+
// Read screenshot and encode to base64
|
|
139
|
+
const screenshotBuffer = await fs.readFile(screenshotPath);
|
|
140
|
+
const screenshotBase64 = screenshotBuffer.toString('base64');
|
|
141
|
+
|
|
142
|
+
// Read computed styles if available
|
|
143
|
+
// @renamed: cssData → stylesData (IP protection)
|
|
144
|
+
let stylesData: { computed_styles: Record<string, Record<string, string>> } | undefined;
|
|
145
|
+
if (stylesPath && await fs.pathExists(stylesPath)) {
|
|
146
|
+
try {
|
|
147
|
+
const stylesJson = await fs.readJson(stylesPath);
|
|
148
|
+
if (stylesJson.computed_styles && Object.keys(stylesJson.computed_styles).length > 0) {
|
|
149
|
+
stylesData = { computed_styles: stylesJson.computed_styles };
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (this.options.debug) {
|
|
153
|
+
console.warn(`Testivai Reporter: [DEBUG] Failed to read styles file for ${metadata.snapshotName}:`, err);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// @renamed: dom → structure, css → styles (IP protection)
|
|
159
|
+
const snapshotPayload: SnapshotPayload = {
|
|
160
|
+
snapshotName: metadata.snapshotName,
|
|
161
|
+
testName: metadata.testName,
|
|
162
|
+
timestamp: metadata.timestamp,
|
|
163
|
+
url: metadata.url,
|
|
164
|
+
viewport: metadata.viewport,
|
|
165
|
+
structure: { html: await fs.readFile(structurePath, 'utf-8') },
|
|
166
|
+
styles: stylesData,
|
|
167
|
+
layout: {
|
|
168
|
+
x: layoutData.x,
|
|
169
|
+
y: layoutData.y,
|
|
170
|
+
width: layoutData.width,
|
|
171
|
+
height: layoutData.height,
|
|
172
|
+
top: layoutData.y,
|
|
173
|
+
left: layoutData.x,
|
|
174
|
+
right: layoutData.x + layoutData.width,
|
|
175
|
+
bottom: layoutData.y + layoutData.height
|
|
176
|
+
},
|
|
177
|
+
testivaiConfig: metadata.testivaiConfig,
|
|
178
|
+
screenshotData: screenshotBase64
|
|
179
|
+
};
|
|
180
|
+
snapshots.push(snapshotPayload);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Build batch payload matching CDP SDK format
|
|
184
|
+
const batchPayload: Omit<BatchPayload, 'batchId'> = {
|
|
185
|
+
git: this.gitInfo!,
|
|
186
|
+
browser: this.browserInfo!,
|
|
187
|
+
snapshots,
|
|
188
|
+
timestamp: Date.now(),
|
|
189
|
+
runId: this.runId,
|
|
190
|
+
ci: this.ciInfo,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Compress and upload (same as CDP SDK)
|
|
194
|
+
const payloadJson = JSON.stringify(batchPayload);
|
|
195
|
+
const compressionResult = await this.compressionHelper.compress(payloadJson);
|
|
196
|
+
|
|
197
|
+
if (this.options.debug && compressionResult.compressionRatio > 0) {
|
|
198
|
+
this.compressionHelper.logCompressionResult(compressionResult);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const headers: Record<string, string> = {
|
|
202
|
+
'X-API-KEY': this.options.apiKey!,
|
|
203
|
+
'Content-Type': 'application/json',
|
|
204
|
+
...compressionResult.headers,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Upload to same endpoint as CDP SDK
|
|
208
|
+
const startBatchResponse = await axios.post(
|
|
209
|
+
`${this.options.apiUrl}/api/v1/ingest/start-batch`,
|
|
210
|
+
compressionResult.data,
|
|
211
|
+
{ headers }
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const batchId = startBatchResponse.data.batch_id || startBatchResponse.data.batchId;
|
|
215
|
+
|
|
216
|
+
// Show success message (brief in normal mode, detailed in debug mode)
|
|
217
|
+
if (this.options.debug) {
|
|
218
|
+
console.log(`Testivai Reporter: [DEBUG] ✓ Uploaded ${snapshots.length} snapshot(s) (Batch ID: ${batchId})`);
|
|
219
|
+
} else {
|
|
220
|
+
console.log(`Testivai Reporter: ✓ Uploaded ${snapshots.length} snapshot(s)`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Clean up temp files (skip if DEBUG mode is enabled)
|
|
224
|
+
if (this.options.debug) {
|
|
225
|
+
console.log(`Testivai Reporter: [DEBUG] Keeping temporary evidence files in: ${this.tempDir}`);
|
|
226
|
+
} else {
|
|
227
|
+
await fs.emptyDir(this.tempDir);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Finalize batch
|
|
231
|
+
await axios.post(`${this.options.apiUrl}/api/v1/ingest/finish-batch/${batchId}`, {}, {
|
|
232
|
+
headers: { 'X-API-KEY': this.options.apiKey },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
} catch (error: any) {
|
|
236
|
+
// Log error but don't throw - let tests complete normally
|
|
237
|
+
console.error('Testivai Reporter: ❌ Error:', error.message);
|
|
238
|
+
if (this.options.debug) {
|
|
239
|
+
console.error('Error stack:', error.stack);
|
|
240
|
+
if (error.response) {
|
|
241
|
+
console.error('Response status:', error.response.status);
|
|
242
|
+
console.error('Response data:', JSON.stringify(error.response.data, null, 2));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Don't throw - reporter errors should not crash the test run
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export default TestivAIPlaywrightReporter;
|