@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.
- package/LICENSE +21 -0
- package/README.md +363 -0
- package/bin/vizzly.js +3 -0
- package/dist/cli.js +104 -0
- package/dist/client/index.js +237 -0
- package/dist/commands/doctor.js +158 -0
- package/dist/commands/init.js +102 -0
- package/dist/commands/run.js +224 -0
- package/dist/commands/status.js +164 -0
- package/dist/commands/tdd.js +212 -0
- package/dist/commands/upload.js +181 -0
- package/dist/container/index.js +184 -0
- package/dist/errors/vizzly-error.js +149 -0
- package/dist/index.js +31 -0
- package/dist/screenshot-wrapper.js +68 -0
- package/dist/sdk/index.js +364 -0
- package/dist/server/index.js +522 -0
- package/dist/services/api-service.js +215 -0
- package/dist/services/base-service.js +154 -0
- package/dist/services/build-manager.js +214 -0
- package/dist/services/screenshot-server.js +96 -0
- package/dist/services/server-manager.js +61 -0
- package/dist/services/service-utils.js +171 -0
- package/dist/services/tdd-service.js +444 -0
- package/dist/services/test-runner.js +210 -0
- package/dist/services/uploader.js +413 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/client/index.d.ts +76 -0
- package/dist/types/commands/doctor.d.ts +11 -0
- package/dist/types/commands/init.d.ts +14 -0
- package/dist/types/commands/run.d.ts +13 -0
- package/dist/types/commands/status.d.ts +13 -0
- package/dist/types/commands/tdd.d.ts +13 -0
- package/dist/types/commands/upload.d.ts +13 -0
- package/dist/types/container/index.d.ts +61 -0
- package/dist/types/errors/vizzly-error.d.ts +75 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.js +153 -0
- package/dist/types/screenshot-wrapper.d.ts +27 -0
- package/dist/types/sdk/index.d.ts +108 -0
- package/dist/types/server/index.d.ts +38 -0
- package/dist/types/services/api-service.d.ts +77 -0
- package/dist/types/services/base-service.d.ts +72 -0
- package/dist/types/services/build-manager.d.ts +68 -0
- package/dist/types/services/screenshot-server.d.ts +10 -0
- package/dist/types/services/server-manager.d.ts +8 -0
- package/dist/types/services/service-utils.d.ts +45 -0
- package/dist/types/services/tdd-service.d.ts +55 -0
- package/dist/types/services/test-runner.d.ts +25 -0
- package/dist/types/services/uploader.d.ts +34 -0
- package/dist/types/types/index.d.ts +373 -0
- package/dist/types/utils/colors.d.ts +12 -0
- package/dist/types/utils/config-helpers.d.ts +6 -0
- package/dist/types/utils/config-loader.d.ts +22 -0
- package/dist/types/utils/console-ui.d.ts +61 -0
- package/dist/types/utils/diagnostics.d.ts +69 -0
- package/dist/types/utils/environment-config.d.ts +54 -0
- package/dist/types/utils/environment.d.ts +36 -0
- package/dist/types/utils/error-messages.d.ts +42 -0
- package/dist/types/utils/fetch-utils.d.ts +1 -0
- package/dist/types/utils/framework-detector.d.ts +5 -0
- package/dist/types/utils/git.d.ts +44 -0
- package/dist/types/utils/help.d.ts +11 -0
- package/dist/types/utils/image-comparison.d.ts +42 -0
- package/dist/types/utils/logger-factory.d.ts +26 -0
- package/dist/types/utils/logger.d.ts +79 -0
- package/dist/types/utils/package-info.d.ts +15 -0
- package/dist/types/utils/package.d.ts +1 -0
- package/dist/types/utils/project-detection.d.ts +19 -0
- package/dist/types/utils/ui-helpers.d.ts +23 -0
- package/dist/utils/colors.js +66 -0
- package/dist/utils/config-helpers.js +8 -0
- package/dist/utils/config-loader.js +120 -0
- package/dist/utils/console-ui.js +226 -0
- package/dist/utils/diagnostics.js +184 -0
- package/dist/utils/environment-config.js +93 -0
- package/dist/utils/environment.js +109 -0
- package/dist/utils/error-messages.js +34 -0
- package/dist/utils/fetch-utils.js +9 -0
- package/dist/utils/framework-detector.js +40 -0
- package/dist/utils/git.js +226 -0
- package/dist/utils/help.js +66 -0
- package/dist/utils/image-comparison.js +172 -0
- package/dist/utils/logger-factory.js +76 -0
- package/dist/utils/logger.js +231 -0
- package/dist/utils/package-info.js +38 -0
- package/dist/utils/package.js +9 -0
- package/dist/utils/project-detection.js +145 -0
- package/dist/utils/ui-helpers.js +86 -0
- 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
|
+
}
|