@vizzly-testing/cli 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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +363 -0
  3. package/bin/vizzly.js +3 -0
  4. package/dist/cli.js +104 -0
  5. package/dist/client/index.js +237 -0
  6. package/dist/commands/doctor.js +158 -0
  7. package/dist/commands/init.js +102 -0
  8. package/dist/commands/run.js +224 -0
  9. package/dist/commands/status.js +164 -0
  10. package/dist/commands/tdd.js +212 -0
  11. package/dist/commands/upload.js +181 -0
  12. package/dist/container/index.js +184 -0
  13. package/dist/errors/vizzly-error.js +149 -0
  14. package/dist/index.js +31 -0
  15. package/dist/screenshot-wrapper.js +68 -0
  16. package/dist/sdk/index.js +364 -0
  17. package/dist/server/index.js +522 -0
  18. package/dist/services/api-service.js +215 -0
  19. package/dist/services/base-service.js +154 -0
  20. package/dist/services/build-manager.js +214 -0
  21. package/dist/services/screenshot-server.js +96 -0
  22. package/dist/services/server-manager.js +61 -0
  23. package/dist/services/service-utils.js +171 -0
  24. package/dist/services/tdd-service.js +444 -0
  25. package/dist/services/test-runner.js +210 -0
  26. package/dist/services/uploader.js +413 -0
  27. package/dist/types/cli.d.ts +2 -0
  28. package/dist/types/client/index.d.ts +76 -0
  29. package/dist/types/commands/doctor.d.ts +11 -0
  30. package/dist/types/commands/init.d.ts +14 -0
  31. package/dist/types/commands/run.d.ts +13 -0
  32. package/dist/types/commands/status.d.ts +13 -0
  33. package/dist/types/commands/tdd.d.ts +13 -0
  34. package/dist/types/commands/upload.d.ts +13 -0
  35. package/dist/types/container/index.d.ts +61 -0
  36. package/dist/types/errors/vizzly-error.d.ts +75 -0
  37. package/dist/types/index.d.ts +10 -0
  38. package/dist/types/index.js +153 -0
  39. package/dist/types/screenshot-wrapper.d.ts +27 -0
  40. package/dist/types/sdk/index.d.ts +108 -0
  41. package/dist/types/server/index.d.ts +38 -0
  42. package/dist/types/services/api-service.d.ts +77 -0
  43. package/dist/types/services/base-service.d.ts +72 -0
  44. package/dist/types/services/build-manager.d.ts +68 -0
  45. package/dist/types/services/screenshot-server.d.ts +10 -0
  46. package/dist/types/services/server-manager.d.ts +8 -0
  47. package/dist/types/services/service-utils.d.ts +45 -0
  48. package/dist/types/services/tdd-service.d.ts +55 -0
  49. package/dist/types/services/test-runner.d.ts +25 -0
  50. package/dist/types/services/uploader.d.ts +34 -0
  51. package/dist/types/types/index.d.ts +373 -0
  52. package/dist/types/utils/colors.d.ts +12 -0
  53. package/dist/types/utils/config-helpers.d.ts +6 -0
  54. package/dist/types/utils/config-loader.d.ts +22 -0
  55. package/dist/types/utils/console-ui.d.ts +61 -0
  56. package/dist/types/utils/diagnostics.d.ts +69 -0
  57. package/dist/types/utils/environment-config.d.ts +54 -0
  58. package/dist/types/utils/environment.d.ts +36 -0
  59. package/dist/types/utils/error-messages.d.ts +42 -0
  60. package/dist/types/utils/fetch-utils.d.ts +1 -0
  61. package/dist/types/utils/framework-detector.d.ts +5 -0
  62. package/dist/types/utils/git.d.ts +44 -0
  63. package/dist/types/utils/help.d.ts +11 -0
  64. package/dist/types/utils/image-comparison.d.ts +42 -0
  65. package/dist/types/utils/logger-factory.d.ts +26 -0
  66. package/dist/types/utils/logger.d.ts +79 -0
  67. package/dist/types/utils/package-info.d.ts +15 -0
  68. package/dist/types/utils/package.d.ts +1 -0
  69. package/dist/types/utils/project-detection.d.ts +19 -0
  70. package/dist/types/utils/ui-helpers.d.ts +23 -0
  71. package/dist/utils/colors.js +66 -0
  72. package/dist/utils/config-helpers.js +8 -0
  73. package/dist/utils/config-loader.js +120 -0
  74. package/dist/utils/console-ui.js +226 -0
  75. package/dist/utils/diagnostics.js +184 -0
  76. package/dist/utils/environment-config.js +93 -0
  77. package/dist/utils/environment.js +109 -0
  78. package/dist/utils/error-messages.js +34 -0
  79. package/dist/utils/fetch-utils.js +9 -0
  80. package/dist/utils/framework-detector.js +40 -0
  81. package/dist/utils/git.js +226 -0
  82. package/dist/utils/help.js +66 -0
  83. package/dist/utils/image-comparison.js +172 -0
  84. package/dist/utils/logger-factory.js +76 -0
  85. package/dist/utils/logger.js +231 -0
  86. package/dist/utils/package-info.js +38 -0
  87. package/dist/utils/package.js +9 -0
  88. package/dist/utils/project-detection.js +145 -0
  89. package/dist/utils/ui-helpers.js +86 -0
  90. package/package.json +103 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Environment detection utilities for handling CI/interactive environments
3
+ */
4
+
5
+ /**
6
+ * Check if running in a CI environment
7
+ * Based on common CI environment variables
8
+ * @returns {boolean} True if running in CI
9
+ */
10
+ export function isCI() {
11
+ return Boolean(process.env.CI ||
12
+ // Generic CI flag
13
+ process.env.CONTINUOUS_INTEGRATION ||
14
+ // TravisCI, CircleCI
15
+ process.env.BUILD_NUMBER ||
16
+ // Jenkins
17
+ process.env.GITHUB_ACTIONS ||
18
+ // GitHub Actions
19
+ process.env.GITLAB_CI ||
20
+ // GitLab CI
21
+ process.env.CIRCLECI ||
22
+ // CircleCI
23
+ process.env.TRAVIS ||
24
+ // TravisCI
25
+ process.env.APPVEYOR ||
26
+ // AppVeyor
27
+ process.env.CODEBUILD_BUILD_ID ||
28
+ // AWS CodeBuild
29
+ process.env.TEAMCITY_VERSION ||
30
+ // TeamCity
31
+ process.env.TF_BUILD ||
32
+ // Azure DevOps
33
+ process.env.DRONE ||
34
+ // Drone CI
35
+ process.env.BITBUCKET_BUILD_NUMBER // Bitbucket Pipelines
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Check if stdout supports interactive features
41
+ * @returns {boolean} True if interactive features are supported
42
+ */
43
+ export function isInteractiveTerminal() {
44
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY && (!process.env.TERM || process.env.TERM !== 'dumb'));
45
+ }
46
+
47
+ /**
48
+ * Determine if interactive mode should be enabled
49
+ * Takes into account CI detection, TTY support, and explicit flags
50
+ * @param {Object} options
51
+ * @param {boolean} [options.noInteractive] - Explicit flag to disable interactive mode
52
+ * @param {boolean} [options.interactive] - Explicit flag to force interactive mode
53
+ * @returns {boolean} True if interactive mode should be enabled
54
+ */
55
+ export function shouldUseInteractiveMode(options = {}) {
56
+ // Explicit flags take priority
57
+ // Commander.js sets interactive=false when --no-interactive is used
58
+ if (options.interactive === false) {
59
+ return false;
60
+ }
61
+ if (options.interactive === true) {
62
+ return true;
63
+ }
64
+
65
+ // Legacy support for explicit noInteractive flag
66
+ if (options.noInteractive === true) {
67
+ return false;
68
+ }
69
+
70
+ // Auto-detect based on environment
71
+ return !isCI() && isInteractiveTerminal();
72
+ }
73
+
74
+ /**
75
+ * Get environment type for logging/debugging
76
+ * @returns {string} Environment type description
77
+ */
78
+ export function getEnvironmentType() {
79
+ if (isCI()) {
80
+ return 'CI';
81
+ }
82
+ if (!isInteractiveTerminal()) {
83
+ return 'non-interactive';
84
+ }
85
+ return 'interactive';
86
+ }
87
+
88
+ /**
89
+ * Get detailed environment info for debugging
90
+ * @returns {Object} Environment details
91
+ */
92
+ export function getEnvironmentDetails() {
93
+ return {
94
+ isCI: isCI(),
95
+ isInteractiveTerminal: isInteractiveTerminal(),
96
+ environmentType: getEnvironmentType(),
97
+ stdoutIsTTY: Boolean(process.stdout.isTTY),
98
+ stdinIsTTY: Boolean(process.stdin.isTTY),
99
+ ciVars: {
100
+ CI: process.env.CI,
101
+ CONTINUOUS_INTEGRATION: process.env.CONTINUOUS_INTEGRATION,
102
+ GITHUB_ACTIONS: process.env.GITHUB_ACTIONS,
103
+ GITLAB_CI: process.env.GITLAB_CI,
104
+ CIRCLECI: process.env.CIRCLECI,
105
+ TRAVIS: process.env.TRAVIS
106
+ },
107
+ termType: process.env.TERM
108
+ };
109
+ }
@@ -0,0 +1,34 @@
1
+ export const ERROR_MESSAGES = {
2
+ NO_API_TOKEN: {
3
+ message: 'No API token provided',
4
+ hint: 'Set the VIZZLY_TOKEN environment variable or pass --token flag',
5
+ docs: 'https://github.com/vizzly-testing/cli#set-up-your-api-token'
6
+ },
7
+ BUILD_CREATION_FAILED: {
8
+ message: 'Failed to create build',
9
+ hint: 'Check your API token permissions and network connection',
10
+ docs: 'https://github.com/vizzly-testing/cli/blob/main/docs/upload-command.md#troubleshooting'
11
+ },
12
+ NO_SCREENSHOTS_FOUND: {
13
+ message: 'No screenshots found',
14
+ hint: 'Make sure your test code calls vizzlyScreenshot() or check the screenshots directory',
15
+ docs: 'https://github.com/vizzly-testing/cli/blob/main/docs/getting-started.md'
16
+ },
17
+ PORT_IN_USE: {
18
+ message: 'Port is already in use',
19
+ hint: 'Try a different port with --port flag or stop the process using this port',
20
+ docs: 'https://github.com/vizzly-testing/cli/blob/main/docs/test-integration.md#troubleshooting'
21
+ }
22
+ };
23
+ export function formatError(errorCode, context = {}) {
24
+ const errorInfo = ERROR_MESSAGES[errorCode];
25
+ if (!errorInfo) return {
26
+ message: errorCode
27
+ };
28
+ return {
29
+ message: errorInfo.message,
30
+ hint: errorInfo.hint,
31
+ docs: errorInfo.docs,
32
+ context
33
+ };
34
+ }
@@ -0,0 +1,9 @@
1
+ function fetchWithTimeout(url, opts = {}, ms = 300000) {
2
+ let ctrl = new AbortController();
3
+ let id = setTimeout(() => ctrl.abort(), ms);
4
+ return fetch(url, {
5
+ ...opts,
6
+ signal: ctrl.signal
7
+ }).finally(() => clearTimeout(id));
8
+ }
9
+ export { fetchWithTimeout };
@@ -0,0 +1,40 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Detect testing framework from project dependencies
6
+ * @returns {Promise<string|null>} Detected framework or null
7
+ */
8
+ export async function detectFramework() {
9
+ try {
10
+ const packageJsonPath = path.join(process.cwd(), 'package.json');
11
+ const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
12
+ const dependencies = {
13
+ ...(packageJson.dependencies || {}),
14
+ ...(packageJson.devDependencies || {})
15
+ };
16
+
17
+ // Check for common testing frameworks
18
+ if (dependencies['@playwright/test'] || dependencies.playwright) {
19
+ return 'playwright';
20
+ }
21
+ if (dependencies.cypress) {
22
+ return 'cypress';
23
+ }
24
+ if (dependencies.puppeteer) {
25
+ return 'puppeteer';
26
+ }
27
+
28
+ // Check for config files
29
+ const files = await fs.readdir(process.cwd());
30
+ if (files.some(f => f.includes('playwright.config'))) {
31
+ return 'playwright';
32
+ }
33
+ if (files.some(f => f.includes('cypress.config'))) {
34
+ return 'cypress';
35
+ }
36
+ return null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
@@ -0,0 +1,226 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ const execAsync = promisify(exec);
4
+ export async function getCommonAncestor(commit1, commit2, cwd = process.cwd()) {
5
+ try {
6
+ const {
7
+ stdout
8
+ } = await execAsync(`git merge-base ${commit1} ${commit2}`, {
9
+ cwd
10
+ });
11
+ return stdout.trim();
12
+ } catch {
13
+ // If merge-base fails (e.g., no common ancestor), return null
14
+ return null;
15
+ }
16
+ }
17
+ export async function getCurrentCommitSha(cwd = process.cwd()) {
18
+ try {
19
+ const {
20
+ stdout
21
+ } = await execAsync('git rev-parse HEAD', {
22
+ cwd
23
+ });
24
+ return stdout.trim();
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+ export async function getCurrentBranch(cwd = process.cwd()) {
30
+ try {
31
+ const {
32
+ stdout
33
+ } = await execAsync('git rev-parse --abbrev-ref HEAD', {
34
+ cwd
35
+ });
36
+ return stdout.trim();
37
+ } catch {
38
+ // Fallback strategy: use a simple, non-recursive approach
39
+ // to avoid circular dependency with getDefaultBranch()
40
+ return getCurrentBranchFallback(cwd);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Fallback strategy for getCurrentBranch that doesn't depend on getDefaultBranch()
46
+ * to avoid circular dependencies. Uses a simple heuristic approach.
47
+ */
48
+ async function getCurrentBranchFallback(cwd = process.cwd()) {
49
+ // Try common default branches in order of likelihood
50
+ const commonBranches = ['main', 'master', 'develop', 'dev'];
51
+ for (const branch of commonBranches) {
52
+ try {
53
+ await execAsync(`git show-ref --verify --quiet refs/heads/${branch}`, {
54
+ cwd
55
+ });
56
+ return branch;
57
+ } catch {
58
+ // Branch doesn't exist, try next one
59
+ continue;
60
+ }
61
+ }
62
+
63
+ // If none of the common branches exist, try to get any local branch
64
+ try {
65
+ const {
66
+ stdout
67
+ } = await execAsync('git branch --format="%(refname:short)"', {
68
+ cwd
69
+ });
70
+ const branches = stdout.trim().split('\n').filter(b => b.trim());
71
+ if (branches.length > 0) {
72
+ return branches[0]; // Return the first available branch
73
+ }
74
+ } catch {
75
+ // Git branch command failed
76
+ }
77
+
78
+ // Last resort: return null to indicate we couldn't determine the branch
79
+ // This allows calling code to handle the situation (e.g., prompt user or use 'main')
80
+ return null;
81
+ }
82
+ export async function getDefaultBranch(cwd = process.cwd()) {
83
+ try {
84
+ // Try to get the default branch from remote origin
85
+ const {
86
+ stdout
87
+ } = await execAsync('git symbolic-ref refs/remotes/origin/HEAD', {
88
+ cwd
89
+ });
90
+ const defaultBranch = stdout.trim().replace('refs/remotes/origin/', '');
91
+ return defaultBranch;
92
+ } catch {
93
+ try {
94
+ // Fallback: try to get default branch from git config
95
+ const {
96
+ stdout
97
+ } = await execAsync('git config --get init.defaultBranch', {
98
+ cwd
99
+ });
100
+ return stdout.trim();
101
+ } catch {
102
+ try {
103
+ // Fallback: check if main exists
104
+ await execAsync('git show-ref --verify --quiet refs/heads/main', {
105
+ cwd
106
+ });
107
+ return 'main';
108
+ } catch {
109
+ try {
110
+ // Fallback: check if master exists
111
+ await execAsync('git show-ref --verify --quiet refs/heads/master', {
112
+ cwd
113
+ });
114
+ return 'master';
115
+ } catch {
116
+ // If we're not in a git repo or no branches exist, return null
117
+ // This allows the calling code to handle the situation appropriately
118
+ return null;
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ export function generateBuildName() {
125
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
126
+ return `Build ${timestamp}`;
127
+ }
128
+
129
+ /**
130
+ * Get the current commit message
131
+ * @param {string} cwd - Working directory
132
+ * @returns {Promise<string|null>} Commit message or null if not available
133
+ */
134
+ export async function getCommitMessage(cwd = process.cwd()) {
135
+ try {
136
+ const {
137
+ stdout
138
+ } = await execAsync('git log -1 --pretty=%B', {
139
+ cwd
140
+ });
141
+ return stdout.trim();
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Check if the working directory is a git repository
149
+ * @param {string} cwd - Working directory
150
+ * @returns {Promise<boolean>}
151
+ */
152
+ export async function isGitRepository(cwd = process.cwd()) {
153
+ try {
154
+ await execAsync('git rev-parse --git-dir', {
155
+ cwd
156
+ });
157
+ return true;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Get git status information
165
+ * @param {string} cwd - Working directory
166
+ * @returns {Promise<Object|null>} Git status info or null
167
+ */
168
+ export async function getGitStatus(cwd = process.cwd()) {
169
+ try {
170
+ const {
171
+ stdout
172
+ } = await execAsync('git status --porcelain', {
173
+ cwd
174
+ });
175
+ const changes = stdout.trim().split('\n').filter(line => line);
176
+ return {
177
+ hasChanges: changes.length > 0,
178
+ changes: changes.map(line => ({
179
+ status: line.substring(0, 2),
180
+ file: line.substring(3)
181
+ }))
182
+ };
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Detect branch with override support
190
+ * @param {string} override - Branch override from CLI
191
+ * @param {string} cwd - Working directory
192
+ * @returns {Promise<string>}
193
+ */
194
+ export async function detectBranch(override = null, cwd = process.cwd()) {
195
+ if (override) return override;
196
+ const currentBranch = await getCurrentBranch(cwd);
197
+ return currentBranch || 'unknown';
198
+ }
199
+
200
+ /**
201
+ * Detect commit SHA with override support
202
+ * @param {string} override - Commit override from CLI
203
+ * @param {string} cwd - Working directory
204
+ * @returns {Promise<string|null>}
205
+ */
206
+ export async function detectCommit(override = null, cwd = process.cwd()) {
207
+ if (override) return override;
208
+ return await getCurrentCommitSha(cwd);
209
+ }
210
+
211
+ /**
212
+ * Generate build name with git information
213
+ * @param {string} override - Build name override from CLI
214
+ * @param {string} cwd - Working directory
215
+ * @returns {Promise<string>}
216
+ */
217
+ export async function generateBuildNameWithGit(override = null, cwd = process.cwd()) {
218
+ if (override) return override;
219
+ const branch = await getCurrentBranch(cwd);
220
+ const shortSha = await getCurrentCommitSha(cwd);
221
+ if (branch && shortSha) {
222
+ const shortCommit = shortSha.substring(0, 7);
223
+ return `${branch}-${shortCommit}`;
224
+ }
225
+ return generateBuildName();
226
+ }
@@ -0,0 +1,66 @@
1
+ import { createColors } from './colors.js';
2
+
3
+ /**
4
+ * Display help information for the CLI
5
+ * @param {Object} globalOptions - Global CLI options
6
+ */
7
+ export function showHelp(globalOptions = {}) {
8
+ const colors = createColors({
9
+ useColor: !globalOptions.noColor
10
+ });
11
+ if (globalOptions.json) {
12
+ console.log(JSON.stringify({
13
+ status: 'info',
14
+ message: 'Vizzly CLI Help',
15
+ commands: [{
16
+ name: 'run',
17
+ description: 'Run tests with Vizzly visual testing'
18
+ }, {
19
+ name: 'upload',
20
+ description: 'Upload screenshots to Vizzly'
21
+ }, {
22
+ name: 'init',
23
+ description: 'Initialize Vizzly configuration'
24
+ }],
25
+ timestamp: new Date().toISOString()
26
+ }));
27
+ return;
28
+ }
29
+ console.log('');
30
+ console.log(colors.cyan(colors.bold('🔍 Vizzly CLI')));
31
+ console.log(colors.dim('Visual testing tool for UI developers'));
32
+ console.log('');
33
+ console.log(colors.yellow(colors.bold('Available Commands:')));
34
+ console.log('');
35
+ console.log(` ${colors.green(colors.bold('run'))} Run tests with Vizzly visual testing`);
36
+ console.log(` ${colors.green(colors.bold('upload'))} Upload screenshots to Vizzly`);
37
+ console.log(` ${colors.green(colors.bold('init'))} Initialize Vizzly configuration`);
38
+ console.log('');
39
+ console.log(colors.dim('Use ') + colors.cyan('vizzly <command> --help') + colors.dim(' for command-specific options'));
40
+ console.log('');
41
+ }
42
+
43
+ /**
44
+ * Show error for unknown command
45
+ * @param {string} command - Unknown command name
46
+ * @param {Object} globalOptions - Global CLI options
47
+ */
48
+ export function showUnknownCommand(command, globalOptions = {}) {
49
+ const colors = createColors({
50
+ useColor: !globalOptions.noColor
51
+ });
52
+ if (globalOptions.json) {
53
+ console.error(JSON.stringify({
54
+ status: 'error',
55
+ message: `Unknown command: ${command}`,
56
+ availableCommands: ['run', 'upload', 'init'],
57
+ timestamp: new Date().toISOString()
58
+ }));
59
+ process.exit(1);
60
+ }
61
+ console.error(colors.red(`✖ Unknown command: ${command}`));
62
+ console.error('');
63
+ console.error(colors.dim('Available commands: run, upload, init'));
64
+ console.error('');
65
+ process.exit(1);
66
+ }
@@ -0,0 +1,172 @@
1
+ import { compare } from 'odiff-bin';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import crypto from 'crypto';
6
+ import { VizzlyError } from '../errors/vizzly-error.js';
7
+
8
+ /**
9
+ * Compare two images and return the difference
10
+ * @param {Buffer} imageBuffer1 - First image buffer
11
+ * @param {Buffer} imageBuffer2 - Second image buffer
12
+ * @param {Object} options - Comparison options
13
+ * @param {number} options.threshold - Matching threshold (0-1)
14
+ * @param {boolean} options.ignoreAntialiasing - Ignore antialiasing
15
+ * @param {boolean} options.ignoreColors - Ignore colors (not supported by odiff)
16
+ * @param {Array} options.ignoreRegions - Regions to ignore in comparison
17
+ * @returns {Promise<Object>} Comparison result
18
+ */
19
+ export async function compareImages(imageBuffer1, imageBuffer2, options = {}) {
20
+ // Create temporary files for odiff
21
+ const tempDir = os.tmpdir();
22
+ const tempId = crypto.randomBytes(8).toString('hex');
23
+ const basePath = path.join(tempDir, `vizzly-base-${tempId}.png`);
24
+ const comparePath = path.join(tempDir, `vizzly-compare-${tempId}.png`);
25
+ const diffPath = path.join(tempDir, `vizzly-diff-${tempId}.png`);
26
+ try {
27
+ // Write buffers to temporary files
28
+ await fs.writeFile(basePath, imageBuffer1);
29
+ await fs.writeFile(comparePath, imageBuffer2);
30
+
31
+ // Configure odiff options
32
+ const odiffOptions = {
33
+ threshold: options.threshold || 0.1,
34
+ antialiasing: options.ignoreAntialiasing !== false,
35
+ outputDiffMask: true,
36
+ failOnLayoutDiff: false,
37
+ noFailOnFsErrors: false,
38
+ diffColor: '#ff0000',
39
+ // Red for differences
40
+ captureDiffLines: true,
41
+ reduceRamUsage: false,
42
+ ignoreRegions: options.ignoreRegions || []
43
+ };
44
+
45
+ // Run odiff comparison
46
+ const result = await compare(basePath, comparePath, diffPath, odiffOptions);
47
+
48
+ // Process results
49
+ if (result.match) {
50
+ return {
51
+ misMatchPercentage: 0,
52
+ diffPixels: 0,
53
+ totalPixels: 0,
54
+ dimensionDifference: {
55
+ width: 0,
56
+ height: 0
57
+ },
58
+ diffBuffer: null
59
+ };
60
+ }
61
+
62
+ // Handle different failure reasons
63
+ switch (result.reason) {
64
+ case 'layout-diff':
65
+ {
66
+ return {
67
+ misMatchPercentage: 100,
68
+ dimensionDifference: {
69
+ width: 'unknown',
70
+ height: 'unknown'
71
+ },
72
+ error: 'Image dimensions do not match',
73
+ diffBuffer: null
74
+ };
75
+ }
76
+ case 'pixel-diff':
77
+ {
78
+ // Read the diff image
79
+ const diffBuffer = await fs.readFile(diffPath);
80
+ return {
81
+ misMatchPercentage: result.diffPercentage,
82
+ diffPixels: result.diffCount,
83
+ totalPixels: Math.round(result.diffCount / (result.diffPercentage / 100)),
84
+ dimensionDifference: {
85
+ width: 0,
86
+ height: 0
87
+ },
88
+ diffBuffer,
89
+ diffLines: result.diffLines
90
+ };
91
+ }
92
+ case 'file-not-exists':
93
+ throw new VizzlyError(`Image file not found: ${result.file}`, 'IMAGE_NOT_FOUND', {
94
+ file: result.file
95
+ });
96
+ default:
97
+ throw new VizzlyError('Unknown comparison result', 'COMPARISON_UNKNOWN', {
98
+ result
99
+ });
100
+ }
101
+ } catch (error) {
102
+ // Re-throw VizzlyErrors
103
+ if (error instanceof VizzlyError) {
104
+ throw error;
105
+ }
106
+ throw new VizzlyError('Failed to compare images', 'IMAGE_COMPARISON_FAILED', {
107
+ error: error.message
108
+ });
109
+ } finally {
110
+ // Clean up temporary files
111
+ await Promise.all([fs.unlink(basePath).catch(() => {}), fs.unlink(comparePath).catch(() => {}), fs.unlink(diffPath).catch(() => {})]);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Check if buffer is a valid PNG image
117
+ * @param {Buffer} buffer - Image buffer
118
+ * @returns {boolean} True if valid PNG
119
+ */
120
+ export function isValidPNG(buffer) {
121
+ // Check PNG signature
122
+ if (!buffer || buffer.length < 8) {
123
+ return false;
124
+ }
125
+
126
+ // PNG signature: 137 80 78 71 13 10 26 10
127
+ const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
128
+ return buffer.subarray(0, 8).equals(pngSignature);
129
+ }
130
+
131
+ /**
132
+ * Get image dimensions from PNG buffer
133
+ * @param {Buffer} buffer - Image buffer
134
+ * @returns {Object|null} Dimensions or null if invalid
135
+ */
136
+ export function getImageDimensions(buffer) {
137
+ if (!isValidPNG(buffer)) {
138
+ return null;
139
+ }
140
+ try {
141
+ // PNG dimensions are stored in the IHDR chunk
142
+ // Skip PNG signature (8 bytes) + chunk length (4 bytes) + chunk type (4 bytes)
143
+ const width = buffer.readUInt32BE(16);
144
+ const height = buffer.readUInt32BE(20);
145
+ return {
146
+ width,
147
+ height
148
+ };
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Compare images with ignore regions
156
+ * @param {Buffer} imageBuffer1 - First image buffer
157
+ * @param {Buffer} imageBuffer2 - Second image buffer
158
+ * @param {Array<{x: number, y: number, width: number, height: number}>} ignoreRegions - Regions to ignore
159
+ * @returns {Promise<Object>} Comparison result
160
+ */
161
+ export async function compareImagesWithIgnoreRegions(imageBuffer1, imageBuffer2, ignoreRegions = []) {
162
+ // Convert ignore regions to odiff format
163
+ const odiffIgnoreRegions = ignoreRegions.map(region => ({
164
+ x1: region.x,
165
+ y1: region.y,
166
+ x2: region.x + region.width,
167
+ y2: region.y + region.height
168
+ }));
169
+ return compareImages(imageBuffer1, imageBuffer2, {
170
+ ignoreRegions: odiffIgnoreRegions
171
+ });
172
+ }