@testivai/witness-playwright 0.1.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 (43) hide show
  1. package/README.md +65 -0
  2. package/__tests__/.gitkeep +0 -0
  3. package/__tests__/config-integration.spec.ts +102 -0
  4. package/__tests__/snapshot.spec.d.ts +1 -0
  5. package/__tests__/snapshot.spec.js +81 -0
  6. package/__tests__/snapshot.spec.ts +58 -0
  7. package/__tests__/unit/ci.spec.d.ts +1 -0
  8. package/__tests__/unit/ci.spec.js +35 -0
  9. package/__tests__/unit/ci.spec.ts +40 -0
  10. package/__tests__/unit/reporter.spec.d.ts +1 -0
  11. package/__tests__/unit/reporter.spec.js +37 -0
  12. package/__tests__/unit/reporter.spec.ts +43 -0
  13. package/dist/ci.d.ts +10 -0
  14. package/dist/ci.js +35 -0
  15. package/dist/cli/init.d.ts +3 -0
  16. package/dist/cli/init.js +146 -0
  17. package/dist/config/loader.d.ts +29 -0
  18. package/dist/config/loader.js +232 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +24 -0
  21. package/dist/reporter-types.d.ts +2 -0
  22. package/dist/reporter-types.js +2 -0
  23. package/dist/reporter.d.ts +16 -0
  24. package/dist/reporter.js +155 -0
  25. package/dist/snapshot.d.ts +12 -0
  26. package/dist/snapshot.js +122 -0
  27. package/dist/types.d.ts +181 -0
  28. package/dist/types.js +10 -0
  29. package/jest.config.js +11 -0
  30. package/package.json +47 -0
  31. package/playwright.config.ts +11 -0
  32. package/progress.md +620 -0
  33. package/src/ci.ts +34 -0
  34. package/src/cli/init.ts +119 -0
  35. package/src/config/loader.ts +219 -0
  36. package/src/index.ts +9 -0
  37. package/src/reporter-types.ts +5 -0
  38. package/src/reporter.ts +148 -0
  39. package/src/snapshot.ts +103 -0
  40. package/src/types.ts +193 -0
  41. package/test-results/.last-run.json +4 -0
  42. package/tsconfig.jest.json +7 -0
  43. package/tsconfig.json +19 -0
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+
3
+ import * as fs from 'fs-extra';
4
+ import * as path from 'path';
5
+
6
+ /**
7
+ * Basic CLI init script for TestivAI Playwright SDK
8
+ * Creates a testivai.config.ts file with sensible defaults
9
+ */
10
+
11
+ const DEFAULT_CONFIG = `/**
12
+ * TestivAI Configuration File
13
+ *
14
+ * This file configures how TestivAI analyzes visual differences in your tests.
15
+ *
16
+ * Layout Settings:
17
+ * - sensitivity: 0-4 scale (0=strict/precise, 4=very lenient)
18
+ * - tolerance: Base pixel tolerance for layout differences
19
+ *
20
+ * AI Settings:
21
+ * - sensitivity: 0-4 scale (0=conservative, 4=aggressive)
22
+ * - confidence: 0.0-1.0 scale (minimum confidence required for AI_BUG verdict)
23
+ *
24
+ * Custom Configuration Examples:
25
+ *
26
+ * // For strict testing (critical components):
27
+ * export default {
28
+ * layout: { sensitivity: 0, tolerance: 0.5 },
29
+ * ai: { sensitivity: 0, confidence: 0.9 }
30
+ * };
31
+ *
32
+ * // For lenient testing (dynamic content):
33
+ * export default {
34
+ * layout: { sensitivity: 4, tolerance: 3.0 },
35
+ * ai: { sensitivity: 3, confidence: 0.6 }
36
+ * };
37
+ *
38
+ * // Per-selector tolerances (advanced):
39
+ * export default {
40
+ * layout: {
41
+ * sensitivity: 2,
42
+ * tolerance: 1.0,
43
+ * selectorTolerances: {
44
+ * '.carousel': 4.0, // Carousel components shift
45
+ * '.dropdown': 2.0, // Dropdowns can vary
46
+ * '.tooltip': 5.0, // Tooltips are highly variable
47
+ * '.submit-button': 0.5 // Critical buttons need precision
48
+ * }
49
+ * },
50
+ * ai: { sensitivity: 2, confidence: 0.7 }
51
+ * };
52
+ *
53
+ * // Environment-specific overrides:
54
+ * export default {
55
+ * layout: { sensitivity: 2, tolerance: 1.0 },
56
+ * ai: { sensitivity: 2, confidence: 0.7 },
57
+ * environments: {
58
+ * ci: {
59
+ * layout: { sensitivity: 1, tolerance: 0.5 }, // Stricter in CI
60
+ * ai: { sensitivity: 1, confidence: 0.8 }
61
+ * },
62
+ * development: {
63
+ * layout: { sensitivity: 3, tolerance: 2.0 }, // More lenient locally
64
+ * ai: { sensitivity: 3, confidence: 0.6 }
65
+ * }
66
+ * }
67
+ * };
68
+ */
69
+
70
+ export default {
71
+ layout: {
72
+ sensitivity: 2, // Balanced sensitivity (0-4 scale)
73
+ tolerance: 1.0, // 1 pixel base tolerance
74
+ },
75
+ ai: {
76
+ sensitivity: 2, // Balanced AI analysis (0-4 scale)
77
+ confidence: 0.7, // 70% confidence required for AI_BUG
78
+ }
79
+ };
80
+ `;
81
+
82
+ async function createConfigFile(): Promise<void> {
83
+ const configPath = path.join(process.cwd(), 'testivai.config.ts');
84
+
85
+ // Check if config already exists
86
+ if (await fs.pathExists(configPath)) {
87
+ console.log('❌ TestivAI config already exists at:', configPath);
88
+ console.log(' Delete it first if you want to regenerate it.');
89
+ process.exit(1);
90
+ }
91
+
92
+ try {
93
+ // Create the config file
94
+ await fs.writeFile(configPath, DEFAULT_CONFIG, 'utf8');
95
+ console.log('✅ TestivAI configuration created successfully!');
96
+ console.log('📁 Config file:', configPath);
97
+ console.log('');
98
+ console.log('📖 Next steps:');
99
+ console.log(' 1. Review and customize the configuration in testivai.config.ts');
100
+ console.log(' 2. Update your playwright.config.ts to use TestivAI reporter');
101
+ console.log(' 3. Run your tests with: npx playwright test');
102
+ console.log('');
103
+ console.log('💡 Need help? Check the comments in testivai.config.ts for examples');
104
+
105
+ } catch (error) {
106
+ console.error('❌ Failed to create configuration file:', error);
107
+ process.exit(1);
108
+ }
109
+ }
110
+
111
+ // Main execution
112
+ if (require.main === module) {
113
+ createConfigFile().catch(error => {
114
+ console.error('❌ CLI init failed:', error);
115
+ process.exit(1);
116
+ });
117
+ }
118
+
119
+ export { createConfigFile };
@@ -0,0 +1,219 @@
1
+ import * as fs from 'fs-extra';
2
+ import * as path from 'path';
3
+ import { TestivAIProjectConfig, TestivAIConfig, LayoutConfig, AIConfig } from '../types';
4
+
5
+ /**
6
+ * Default configuration when no config file is found
7
+ */
8
+ const DEFAULT_CONFIG: TestivAIProjectConfig = {
9
+ layout: {
10
+ sensitivity: 2, // Balanced sensitivity (0-4 scale)
11
+ tolerance: 1.0, // 1 pixel base tolerance
12
+ },
13
+ ai: {
14
+ sensitivity: 2, // Balanced AI analysis (0-4 scale)
15
+ confidence: 0.7, // 70% confidence required for AI_BUG
16
+ }
17
+ };
18
+
19
+ /**
20
+ * Load TestivAI configuration from file system
21
+ * Supports both .ts and .js config files
22
+ *
23
+ * @returns Promise<TestivAIProjectConfig> The loaded configuration or defaults
24
+ */
25
+ export async function loadConfig(): Promise<TestivAIProjectConfig> {
26
+ // Try TypeScript config first, then JavaScript
27
+ const tsConfigPath = path.join(process.cwd(), 'testivai.config.ts');
28
+ const jsConfigPath = path.join(process.cwd(), 'testivai.config.js');
29
+
30
+ try {
31
+ let configPath: string;
32
+ let configModule: any;
33
+
34
+ // Check for TypeScript config
35
+ if (await fs.pathExists(tsConfigPath)) {
36
+ configPath = tsConfigPath;
37
+ } else if (await fs.pathExists(jsConfigPath)) {
38
+ configPath = jsConfigPath;
39
+ } else {
40
+ console.log('⚠️ No testivai.config.ts or testivai.config.js found, using defaults');
41
+ return DEFAULT_CONFIG;
42
+ }
43
+
44
+ // Load configuration based on file type
45
+ if (configPath.endsWith('.js')) {
46
+ // For .js files, use require to get CommonJS module
47
+ // Clear require cache to ensure fresh load
48
+ delete require.cache[require.resolve(configPath)];
49
+ configModule = require(configPath);
50
+ } else {
51
+ // For .ts files, use dynamic import (ES module)
52
+ configModule = await import(configPath);
53
+ }
54
+
55
+ const config = configModule.default || configModule;
56
+
57
+ // Validate and merge with defaults
58
+ return validateAndMergeConfig(config);
59
+
60
+ } catch (error) {
61
+ console.warn('⚠️ Failed to load testivai config, using defaults:', error);
62
+ return DEFAULT_CONFIG;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Merge per-test configuration with project configuration
68
+ *
69
+ * @param projectConfig The project-level configuration
70
+ * @param testConfig Optional per-test configuration overrides
71
+ * @returns TestivAIConfig The effective configuration for this test
72
+ */
73
+ export function mergeTestConfig(
74
+ projectConfig: TestivAIProjectConfig,
75
+ testConfig?: TestivAIConfig
76
+ ): TestivAIConfig {
77
+ if (!testConfig) {
78
+ return {
79
+ layout: projectConfig.layout,
80
+ ai: projectConfig.ai
81
+ };
82
+ }
83
+
84
+ return {
85
+ layout: {
86
+ ...projectConfig.layout,
87
+ ...testConfig.layout
88
+ },
89
+ ai: {
90
+ ...projectConfig.ai,
91
+ ...testConfig.ai
92
+ },
93
+ selectors: testConfig.selectors
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Detect current environment (CI, development, production)
99
+ *
100
+ * @returns string The detected environment
101
+ */
102
+ export function detectEnvironment(): string {
103
+ // Check for common CI environment variables
104
+ if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) {
105
+ return 'ci';
106
+ }
107
+
108
+ // Check for production indicators
109
+ if (process.env.NODE_ENV === 'production') {
110
+ return 'production';
111
+ }
112
+
113
+ // Default to development
114
+ return 'development';
115
+ }
116
+
117
+ /**
118
+ * Apply environment-specific overrides to configuration
119
+ *
120
+ * @param config The base configuration
121
+ * @returns TestivAIProjectConfig Configuration with environment overrides applied
122
+ */
123
+ export function applyEnvironmentOverrides(config: TestivAIProjectConfig): TestivAIProjectConfig {
124
+ const environment = detectEnvironment();
125
+
126
+ if (!config.environments) {
127
+ return config;
128
+ }
129
+
130
+ // Type-safe environment override access
131
+ let envOverrides: { layout?: Partial<LayoutConfig>; ai?: Partial<AIConfig> } | undefined;
132
+
133
+ switch (environment) {
134
+ case 'ci':
135
+ envOverrides = config.environments.ci;
136
+ break;
137
+ case 'development':
138
+ envOverrides = config.environments.development;
139
+ break;
140
+ case 'production':
141
+ envOverrides = config.environments.production;
142
+ break;
143
+ }
144
+
145
+ if (!envOverrides) {
146
+ return config;
147
+ }
148
+
149
+ return {
150
+ layout: {
151
+ ...config.layout,
152
+ ...envOverrides.layout
153
+ },
154
+ ai: {
155
+ ...config.ai,
156
+ ...envOverrides.ai
157
+ },
158
+ environments: config.environments // Keep the original environments config
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Validate configuration values and merge with defaults
164
+ *
165
+ * @param config The configuration to validate
166
+ * @returns TestivAIProjectConfig Validated configuration
167
+ */
168
+ function validateAndMergeConfig(config: any): TestivAIProjectConfig {
169
+ // Guard against null/undefined config
170
+ if (!config) {
171
+ console.warn('⚠️ Config is null or undefined, using defaults');
172
+ return DEFAULT_CONFIG;
173
+ }
174
+
175
+ // Basic validation
176
+ const validatedConfig: TestivAIProjectConfig = {
177
+ layout: {
178
+ sensitivity: validateRange(config.layout?.sensitivity ?? DEFAULT_CONFIG.layout.sensitivity, 0, 4, 'layout.sensitivity', DEFAULT_CONFIG.layout.sensitivity),
179
+ tolerance: validateRange(config.layout?.tolerance ?? DEFAULT_CONFIG.layout.tolerance, 0, 100, 'layout.tolerance', DEFAULT_CONFIG.layout.tolerance),
180
+ selectorTolerances: config.layout?.selectorTolerances,
181
+ useRelativeTolerance: config.layout?.useRelativeTolerance,
182
+ relativeTolerance: config.layout?.relativeTolerance
183
+ },
184
+ ai: {
185
+ sensitivity: validateRange(config.ai?.sensitivity ?? DEFAULT_CONFIG.ai.sensitivity, 0, 4, 'ai.sensitivity', DEFAULT_CONFIG.ai.sensitivity),
186
+ confidence: validateRange(config.ai?.confidence ?? DEFAULT_CONFIG.ai.confidence, 0, 1, 'ai.confidence', DEFAULT_CONFIG.ai.confidence),
187
+ enableReasoning: config.ai?.enableReasoning
188
+ },
189
+ environments: config.environments
190
+ };
191
+
192
+ return applyEnvironmentOverrides(validatedConfig);
193
+ }
194
+
195
+ /**
196
+ * Validate that a number is within the expected range
197
+ *
198
+ * @param value The value to validate
199
+ * @param min Minimum allowed value
200
+ * @param max Maximum allowed value
201
+ * @param field Field name for error messages
202
+ * @param defaultValue Default value to use when validation fails
203
+ * @returns number The validated value
204
+ */
205
+ function validateRange(value: any, min: number, max: number, field: string, defaultValue: number): number {
206
+ const numValue = Number(value);
207
+
208
+ if (isNaN(numValue)) {
209
+ console.warn(`⚠️ Invalid ${field}: ${value}, using default: ${defaultValue}`);
210
+ return defaultValue;
211
+ }
212
+
213
+ if (numValue < min || numValue > max) {
214
+ console.warn(`⚠️ ${field} must be between ${min} and ${max}, got ${numValue}, using default: ${defaultValue}`);
215
+ return defaultValue;
216
+ }
217
+
218
+ return numValue;
219
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { snapshot } from './snapshot';
2
+
3
+ export const testivai = {
4
+ witness: snapshot,
5
+ };
6
+
7
+ export * from './types';
8
+ export * from './reporter';
9
+ export * from './ci';
@@ -0,0 +1,5 @@
1
+ // This file isolates the Playwright-specific types for the reporter
2
+ // to allow for clean mocking in the Jest test environment.
3
+ import type { Reporter, FullConfig, Suite, FullResult } from '@playwright/test/reporter';
4
+
5
+ export type { Reporter, FullConfig, Suite, FullResult };
@@ -0,0 +1,148 @@
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 } from './ci';
8
+
9
+ interface TestivaiReporterOptions {
10
+ apiUrl?: string;
11
+ apiKey?: string;
12
+ }
13
+
14
+ export class TestivAIPlaywrightReporter implements Reporter {
15
+ private options: TestivaiReporterOptions;
16
+ private gitInfo: GitInfo | null = null;
17
+ private browserInfo: BrowserInfo | null = null;
18
+ private runId: string | null = null;
19
+ private tempDir = path.join(process.cwd(), '.testivai', 'temp');
20
+
21
+ constructor(options: TestivaiReporterOptions = {}) {
22
+ this.options = {
23
+ apiUrl: options.apiUrl || process.env.TESTIVAI_API_URL,
24
+ apiKey: options.apiKey || process.env.TESTIVAI_API_KEY,
25
+ };
26
+ }
27
+
28
+ async onBegin(config: FullConfig, suite: Suite): Promise<void> {
29
+ if (!this.options.apiUrl || !this.options.apiKey) {
30
+ console.error('Testivai Reporter: API URL or API Key is not configured. Disabling reporter.');
31
+ this.options.apiUrl = undefined; // Disable reporter
32
+ return;
33
+ }
34
+
35
+ // 1. Clean temp directory
36
+ await fs.emptyDir(this.tempDir);
37
+
38
+ // 2. Capture Git metadata
39
+ try {
40
+ const git: SimpleGit = simpleGit();
41
+ const [branch, commit] = await Promise.all([
42
+ git.revparse(['--abbrev-ref', 'HEAD']),
43
+ git.revparse(['HEAD']),
44
+ ]);
45
+ this.gitInfo = { branch, commit };
46
+ } catch (error) {
47
+ console.error('Testivai Reporter: Could not get Git information.', error);
48
+ this.gitInfo = { branch: 'unknown', commit: 'unknown' };
49
+ }
50
+
51
+ // 3. Capture Browser info from the first project
52
+ const project = suite.suites[0]?.project();
53
+ if (project) {
54
+ this.browserInfo = {
55
+ name: project.use.browserName || 'unknown',
56
+ version: 'unknown', // Playwright does not easily expose browser version
57
+ viewportWidth: project.use.viewport?.width || 0,
58
+ viewportHeight: project.use.viewport?.height || 0,
59
+ userAgent: project.use.userAgent || 'unknown',
60
+ os: 'unknown',
61
+ };
62
+ }
63
+
64
+ // 4. Get CI Run ID
65
+ this.runId = getCiRunId();
66
+ if (this.runId) {
67
+ console.log(`Testivai Reporter: Detected CI environment. Run ID: ${this.runId}`);
68
+ }
69
+ }
70
+
71
+ async onEnd(result: FullResult): Promise<void> {
72
+ if (!this.options.apiUrl) {
73
+ return; // Reporter is disabled
74
+ }
75
+
76
+ console.log('Testivai Reporter: Test run finished. Preparing to upload evidence...');
77
+
78
+ try {
79
+ const snapshotFiles = await fs.readdir(this.tempDir);
80
+ const jsonFiles = snapshotFiles.filter(f => f.endsWith('.json'));
81
+
82
+ if (jsonFiles.length === 0) {
83
+ console.log('Testivai Reporter: No snapshots found to upload.');
84
+ return;
85
+ }
86
+
87
+ const snapshots: SnapshotPayload[] = [];
88
+ const filesToUpload: { filePath: string, contentType: string }[] = [];
89
+
90
+ for (const jsonFile of jsonFiles) {
91
+ const metadataPath = path.join(this.tempDir, jsonFile);
92
+ const metadata = await fs.readJson(metadataPath);
93
+
94
+ const domPath = metadata.files.dom;
95
+ const screenshotPath = metadata.files.screenshot;
96
+
97
+ const snapshotPayload: SnapshotPayload = {
98
+ ...metadata,
99
+ dom: { html: await fs.readFile(domPath, 'utf-8') },
100
+ layout: metadata.layout,
101
+ testivaiConfig: metadata.testivaiConfig
102
+ };
103
+ snapshots.push(snapshotPayload);
104
+
105
+ filesToUpload.push({ filePath: screenshotPath, contentType: 'image/png' });
106
+ }
107
+
108
+ const batchPayload: Omit<BatchPayload, 'batchId'> = {
109
+ git: this.gitInfo!,
110
+ browser: this.browserInfo!,
111
+ snapshots,
112
+ timestamp: Date.now(),
113
+ runId: this.runId,
114
+ };
115
+
116
+ // Start batch and get upload URLs
117
+ const startBatchResponse = await axios.post(`${this.options.apiUrl}/api/v1/ingest/start-batch`, batchPayload, {
118
+ headers: { 'Authorization': `Bearer ${this.options.apiKey}` },
119
+ });
120
+
121
+ const { batchId, uploadInstructions } = startBatchResponse.data;
122
+
123
+ // Upload files
124
+ const uploadPromises = filesToUpload.map((file, index) => {
125
+ const instruction = uploadInstructions[index];
126
+ return fs.readFile(file.filePath).then(buffer =>
127
+ axios.put(instruction.url, buffer, { headers: { 'Content-Type': file.contentType } })
128
+ );
129
+ });
130
+
131
+ await Promise.all(uploadPromises);
132
+
133
+ // Finalize batch
134
+ await axios.post(`${this.options.apiUrl}/api/v1/ingest/finish-batch/${batchId}`, {}, {
135
+ headers: { 'Authorization': `Bearer ${this.options.apiKey}` },
136
+ });
137
+
138
+ console.log(`Testivai Reporter: Successfully uploaded ${snapshots.length} snapshots with Batch ID: ${batchId}`);
139
+
140
+ // Clean up temp files
141
+ await fs.emptyDir(this.tempDir);
142
+ console.log('Testivai Reporter: Cleaned up temporary evidence files.');
143
+
144
+ } catch (error) {
145
+ console.error('Testivai Reporter: An error occurred during the onEnd hook:', error);
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,103 @@
1
+ import { Page, TestInfo } from '@playwright/test';
2
+ import * as fs from 'fs-extra';
3
+ import * as path from 'path';
4
+ import { URL } from 'url';
5
+ import { SnapshotPayload, LayoutData, TestivAIConfig } from './types';
6
+ import { loadConfig, mergeTestConfig } from './config/loader';
7
+
8
+ /**
9
+ * Generates a safe filename from a URL.
10
+ * @param pageUrl The URL of the page.
11
+ * @returns A sanitized string suitable for a filename.
12
+ */
13
+ function getSnapshotNameFromUrl(pageUrl: string): string {
14
+ // Handle data URIs, which are common in test environments
15
+ if (pageUrl.startsWith('data:')) {
16
+ return 'snapshot';
17
+ }
18
+
19
+ try {
20
+ const url = new URL(pageUrl);
21
+ const pathName = url.pathname.substring(1).replace(/\//g, '_'); // remove leading slash and replace others
22
+ return pathName || 'home';
23
+ } catch (error) {
24
+ // Fallback for invalid URLs
25
+ return 'snapshot';
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Captures a snapshot of the page, including a screenshot, DOM, and layout data.
31
+ * The evidence is stored in a temporary directory for the reporter to process later.
32
+ *
33
+ * @param page The Playwright Page object.
34
+ * @param testInfo The Playwright TestInfo object, passed from the test.
35
+ * @param name An optional unique name for the snapshot. If not provided, a name is generated from the URL.
36
+ * @param config Optional TestivAI configuration for this snapshot (overrides project defaults).
37
+ */
38
+ export async function snapshot(
39
+ page: Page,
40
+ testInfo: TestInfo,
41
+ name?: string,
42
+ config?: TestivAIConfig
43
+ ): Promise<void> {
44
+ // Load project configuration and merge with test-specific overrides
45
+ const projectConfig = await loadConfig();
46
+ const effectiveConfig = mergeTestConfig(projectConfig, config);
47
+
48
+ const outputDir = path.join(process.cwd(), '.testivai', 'temp');
49
+ await fs.ensureDir(outputDir);
50
+
51
+ const snapshotName = name || getSnapshotNameFromUrl(page.url());
52
+ const timestamp = Date.now();
53
+ const safeName = snapshotName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
54
+ const baseFilename = `${timestamp}_${safeName}`;
55
+
56
+ // 1. Capture full-page screenshot
57
+ const screenshotPath = path.join(outputDir, `${baseFilename}.png`);
58
+ await page.screenshot({ path: screenshotPath, fullPage: true });
59
+
60
+ // 2. Dump full-page DOM
61
+ const domPath = path.join(outputDir, `${baseFilename}.html`);
62
+ const htmlContent = await page.content();
63
+ await fs.writeFile(domPath, htmlContent);
64
+
65
+ // 3. Extract bounding boxes for requested selectors
66
+ const selectors = effectiveConfig.selectors ?? ['body'];
67
+ const layout: Record<string, LayoutData> = {};
68
+
69
+ for (const selector of selectors) {
70
+ const element = page.locator(selector).first();
71
+ const boundingBox = await element.boundingBox();
72
+ if (boundingBox) {
73
+ layout[selector] = {
74
+ ...boundingBox,
75
+ top: boundingBox.y,
76
+ left: boundingBox.x,
77
+ right: boundingBox.x + boundingBox.width,
78
+ bottom: boundingBox.y + boundingBox.height,
79
+ };
80
+ }
81
+ }
82
+
83
+ // 4. Save metadata with configuration
84
+ const metadataPath = path.join(outputDir, `${baseFilename}.json`);
85
+ const metadata: Partial<SnapshotPayload> = {
86
+ snapshotName,
87
+ testName: testInfo.title,
88
+ timestamp,
89
+ url: page.url(),
90
+ viewport: page.viewportSize() || undefined,
91
+ };
92
+
93
+ await fs.writeJson(metadataPath, {
94
+ ...metadata,
95
+ files: {
96
+ screenshot: screenshotPath,
97
+ dom: domPath,
98
+ },
99
+ layout,
100
+ // Store the effective configuration for the reporter
101
+ testivaiConfig: effectiveConfig
102
+ });
103
+ }