@vizzly-testing/cli 0.10.3 → 0.11.1
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/README.md +168 -8
- package/claude-plugin/.claude-plugin/.mcp.json +8 -0
- package/claude-plugin/.claude-plugin/README.md +114 -0
- package/claude-plugin/.claude-plugin/marketplace.json +28 -0
- package/claude-plugin/.claude-plugin/plugin.json +14 -0
- package/claude-plugin/commands/debug-diff.md +153 -0
- package/claude-plugin/commands/setup.md +137 -0
- package/claude-plugin/commands/suggest-screenshots.md +111 -0
- package/claude-plugin/commands/tdd-status.md +43 -0
- package/claude-plugin/mcp/vizzly-server/cloud-api-provider.js +354 -0
- package/claude-plugin/mcp/vizzly-server/index.js +861 -0
- package/claude-plugin/mcp/vizzly-server/local-tdd-provider.js +422 -0
- package/claude-plugin/mcp/vizzly-server/token-resolver.js +185 -0
- package/dist/cli.js +64 -0
- package/dist/client/index.js +13 -3
- package/dist/commands/login.js +195 -0
- package/dist/commands/logout.js +71 -0
- package/dist/commands/project.js +351 -0
- package/dist/commands/run.js +30 -0
- package/dist/commands/whoami.js +162 -0
- package/dist/plugin-loader.js +9 -15
- package/dist/sdk/index.js +16 -4
- package/dist/services/api-service.js +50 -7
- package/dist/services/auth-service.js +226 -0
- package/dist/types/client/index.d.ts +9 -3
- package/dist/types/commands/login.d.ts +11 -0
- package/dist/types/commands/logout.d.ts +11 -0
- package/dist/types/commands/project.d.ts +28 -0
- package/dist/types/commands/whoami.d.ts +11 -0
- package/dist/types/sdk/index.d.ts +9 -4
- package/dist/types/services/api-service.d.ts +2 -1
- package/dist/types/services/auth-service.d.ts +59 -0
- package/dist/types/utils/browser.d.ts +6 -0
- package/dist/types/utils/config-loader.d.ts +1 -1
- package/dist/types/utils/config-schema.d.ts +8 -174
- package/dist/types/utils/file-helpers.d.ts +18 -0
- package/dist/types/utils/global-config.d.ts +84 -0
- package/dist/utils/browser.js +44 -0
- package/dist/utils/config-loader.js +69 -3
- package/dist/utils/file-helpers.js +64 -0
- package/dist/utils/global-config.js +259 -0
- package/docs/api-reference.md +177 -6
- package/docs/authentication.md +334 -0
- package/docs/getting-started.md +21 -2
- package/docs/plugins.md +27 -0
- package/docs/test-integration.md +60 -10
- package/package.json +5 -3
package/dist/commands/run.js
CHANGED
|
@@ -57,8 +57,30 @@ export async function runCommand(testCommand, options = {}, globalOptions = {})
|
|
|
57
57
|
...globalOptions,
|
|
58
58
|
...options
|
|
59
59
|
};
|
|
60
|
+
|
|
61
|
+
// Debug: Check options before loadConfig
|
|
62
|
+
if (process.env.DEBUG_CONFIG) {
|
|
63
|
+
console.log('[RUN] allOptions.token:', allOptions.token ? allOptions.token.substring(0, 8) + '***' : 'NONE');
|
|
64
|
+
}
|
|
60
65
|
const config = await loadConfig(globalOptions.config, allOptions);
|
|
61
66
|
|
|
67
|
+
// Debug: Check config immediately after loadConfig
|
|
68
|
+
if (process.env.DEBUG_CONFIG) {
|
|
69
|
+
console.log('[RUN] Config after loadConfig:', {
|
|
70
|
+
hasApiKey: !!config.apiKey,
|
|
71
|
+
apiKeyPrefix: config.apiKey ? config.apiKey.substring(0, 8) + '***' : 'NONE'
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (globalOptions.verbose) {
|
|
75
|
+
ui.info('Token check:', {
|
|
76
|
+
hasApiKey: !!config.apiKey,
|
|
77
|
+
apiKeyType: typeof config.apiKey,
|
|
78
|
+
apiKeyPrefix: typeof config.apiKey === 'string' && config.apiKey ? config.apiKey.substring(0, 10) + '...' : 'none',
|
|
79
|
+
projectSlug: config.projectSlug || 'none',
|
|
80
|
+
organizationSlug: config.organizationSlug || 'none'
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
// Validate API token (unless --allow-no-token is set)
|
|
63
85
|
if (!config.apiKey && !config.allowNoToken) {
|
|
64
86
|
ui.error('API token required. Use --token, set VIZZLY_TOKEN environment variable, or use --allow-no-token to run without uploading');
|
|
@@ -92,6 +114,14 @@ export async function runCommand(testCommand, options = {}, globalOptions = {})
|
|
|
92
114
|
verbose: globalOptions.verbose,
|
|
93
115
|
uploadAll: options.uploadAll || false
|
|
94
116
|
};
|
|
117
|
+
|
|
118
|
+
// Debug: Check config before creating container
|
|
119
|
+
if (process.env.DEBUG_CONFIG) {
|
|
120
|
+
console.log('[RUN] Config before container:', {
|
|
121
|
+
hasApiKey: !!configWithVerbose.apiKey,
|
|
122
|
+
apiKeyPrefix: configWithVerbose.apiKey ? configWithVerbose.apiKey.substring(0, 8) + '***' : 'NONE'
|
|
123
|
+
});
|
|
124
|
+
}
|
|
95
125
|
const command = 'run';
|
|
96
126
|
const container = await createServiceContainer(configWithVerbose, command);
|
|
97
127
|
testRunner = await container.get('testRunner'); // Assign to outer scope variable
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whoami command implementation
|
|
3
|
+
* Shows current user and authentication status
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ConsoleUI } from '../utils/console-ui.js';
|
|
7
|
+
import { AuthService } from '../services/auth-service.js';
|
|
8
|
+
import { getApiUrl } from '../utils/environment-config.js';
|
|
9
|
+
import { getAuthTokens } from '../utils/global-config.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whoami command implementation
|
|
13
|
+
* @param {Object} options - Command options
|
|
14
|
+
* @param {Object} globalOptions - Global CLI options
|
|
15
|
+
*/
|
|
16
|
+
export async function whoamiCommand(options = {}, globalOptions = {}) {
|
|
17
|
+
// Create UI handler
|
|
18
|
+
let ui = new ConsoleUI({
|
|
19
|
+
json: globalOptions.json,
|
|
20
|
+
verbose: globalOptions.verbose,
|
|
21
|
+
color: !globalOptions.noColor
|
|
22
|
+
});
|
|
23
|
+
try {
|
|
24
|
+
// Check if user is logged in
|
|
25
|
+
let auth = await getAuthTokens();
|
|
26
|
+
if (!auth || !auth.accessToken) {
|
|
27
|
+
if (globalOptions.json) {
|
|
28
|
+
ui.data({
|
|
29
|
+
authenticated: false
|
|
30
|
+
});
|
|
31
|
+
} else {
|
|
32
|
+
ui.info('You are not logged in');
|
|
33
|
+
console.log(''); // Empty line for spacing
|
|
34
|
+
ui.info('Run "vizzly login" to authenticate');
|
|
35
|
+
}
|
|
36
|
+
ui.cleanup();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Get current user info
|
|
41
|
+
ui.startSpinner('Fetching user information...');
|
|
42
|
+
let authService = new AuthService({
|
|
43
|
+
baseUrl: options.apiUrl || getApiUrl()
|
|
44
|
+
});
|
|
45
|
+
let response = await authService.whoami();
|
|
46
|
+
ui.stopSpinner();
|
|
47
|
+
|
|
48
|
+
// Output in JSON mode
|
|
49
|
+
if (globalOptions.json) {
|
|
50
|
+
ui.data({
|
|
51
|
+
authenticated: true,
|
|
52
|
+
user: response.user,
|
|
53
|
+
organizations: response.organizations || [],
|
|
54
|
+
tokenExpiresAt: auth.expiresAt
|
|
55
|
+
});
|
|
56
|
+
ui.cleanup();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Human-readable output
|
|
61
|
+
ui.success('Authenticated');
|
|
62
|
+
console.log(''); // Empty line for spacing
|
|
63
|
+
|
|
64
|
+
// Show user info
|
|
65
|
+
if (response.user) {
|
|
66
|
+
ui.info(`User: ${response.user.name || response.user.username}`);
|
|
67
|
+
ui.info(`Email: ${response.user.email}`);
|
|
68
|
+
if (response.user.username) {
|
|
69
|
+
ui.info(`Username: ${response.user.username}`);
|
|
70
|
+
}
|
|
71
|
+
if (globalOptions.verbose && response.user.id) {
|
|
72
|
+
ui.info(`User ID: ${response.user.id}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Show organizations
|
|
77
|
+
if (response.organizations && response.organizations.length > 0) {
|
|
78
|
+
console.log(''); // Empty line for spacing
|
|
79
|
+
ui.info('Organizations:');
|
|
80
|
+
for (let org of response.organizations) {
|
|
81
|
+
let orgInfo = ` - ${org.name}`;
|
|
82
|
+
if (org.slug) {
|
|
83
|
+
orgInfo += ` (@${org.slug})`;
|
|
84
|
+
}
|
|
85
|
+
if (org.role) {
|
|
86
|
+
orgInfo += ` [${org.role}]`;
|
|
87
|
+
}
|
|
88
|
+
console.log(orgInfo);
|
|
89
|
+
if (globalOptions.verbose && org.id) {
|
|
90
|
+
console.log(` ID: ${org.id}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Show token expiry info
|
|
96
|
+
if (auth.expiresAt) {
|
|
97
|
+
console.log(''); // Empty line for spacing
|
|
98
|
+
let expiresAt = new Date(auth.expiresAt);
|
|
99
|
+
let now = new Date();
|
|
100
|
+
let msUntilExpiry = expiresAt.getTime() - now.getTime();
|
|
101
|
+
let daysUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60 * 24));
|
|
102
|
+
let hoursUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60));
|
|
103
|
+
let minutesUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60));
|
|
104
|
+
if (msUntilExpiry <= 0) {
|
|
105
|
+
ui.warning('Token has expired');
|
|
106
|
+
console.log(''); // Empty line for spacing
|
|
107
|
+
ui.info('Run "vizzly login" to refresh your authentication');
|
|
108
|
+
} else if (daysUntilExpiry > 0) {
|
|
109
|
+
ui.info(`Token expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''} (${expiresAt.toLocaleDateString()})`);
|
|
110
|
+
} else if (hoursUntilExpiry > 0) {
|
|
111
|
+
ui.info(`Token expires in ${hoursUntilExpiry} hour${hoursUntilExpiry !== 1 ? 's' : ''} (${expiresAt.toLocaleString()})`);
|
|
112
|
+
} else if (minutesUntilExpiry > 0) {
|
|
113
|
+
ui.info(`Token expires in ${minutesUntilExpiry} minute${minutesUntilExpiry !== 1 ? 's' : ''}`);
|
|
114
|
+
} else {
|
|
115
|
+
ui.warning('Token expires in less than a minute');
|
|
116
|
+
console.log(''); // Empty line for spacing
|
|
117
|
+
ui.info('Run "vizzly login" to refresh your authentication');
|
|
118
|
+
}
|
|
119
|
+
if (globalOptions.verbose) {
|
|
120
|
+
ui.info(`Token expires at: ${expiresAt.toISOString()}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
ui.cleanup();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
ui.stopSpinner();
|
|
126
|
+
|
|
127
|
+
// Handle authentication errors with helpful messages
|
|
128
|
+
if (error.name === 'AuthError') {
|
|
129
|
+
if (globalOptions.json) {
|
|
130
|
+
ui.data({
|
|
131
|
+
authenticated: false,
|
|
132
|
+
error: error.message
|
|
133
|
+
});
|
|
134
|
+
} else {
|
|
135
|
+
ui.error('Authentication token is invalid or expired', error, 0);
|
|
136
|
+
console.log(''); // Empty line for spacing
|
|
137
|
+
ui.info('Run "vizzly login" to authenticate again');
|
|
138
|
+
}
|
|
139
|
+
ui.cleanup();
|
|
140
|
+
process.exit(1);
|
|
141
|
+
} else {
|
|
142
|
+
ui.error('Failed to get user information', error, 0);
|
|
143
|
+
if (globalOptions.verbose && error.stack) {
|
|
144
|
+
console.error(''); // Empty line for spacing
|
|
145
|
+
console.error(error.stack);
|
|
146
|
+
}
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validate whoami options
|
|
154
|
+
* @param {Object} options - Command options
|
|
155
|
+
*/
|
|
156
|
+
export function validateWhoamiOptions() {
|
|
157
|
+
let errors = [];
|
|
158
|
+
|
|
159
|
+
// No specific validation needed for whoami command
|
|
160
|
+
|
|
161
|
+
return errors;
|
|
162
|
+
}
|
package/dist/plugin-loader.js
CHANGED
|
@@ -131,19 +131,16 @@ async function loadPlugin(pluginPath) {
|
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
133
|
* Zod schema for validating plugin structure
|
|
134
|
+
* Note: Using passthrough() to allow configSchema without validating its structure
|
|
135
|
+
* to avoid Zod version conflicts when plugins have nested config objects
|
|
134
136
|
*/
|
|
135
137
|
const pluginSchema = z.object({
|
|
136
138
|
name: z.string().min(1, 'Plugin name is required'),
|
|
137
139
|
version: z.string().optional(),
|
|
138
|
-
register: z.
|
|
139
|
-
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Zod schema for validating plugin config values
|
|
144
|
-
* Supports: string, number, boolean, null, arrays, and nested objects
|
|
145
|
-
*/
|
|
146
|
-
const configValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(configValueSchema), z.record(configValueSchema)]));
|
|
140
|
+
register: z.custom(val => typeof val === 'function', {
|
|
141
|
+
message: 'register must be a function'
|
|
142
|
+
})
|
|
143
|
+
}).passthrough();
|
|
147
144
|
|
|
148
145
|
/**
|
|
149
146
|
* Validate plugin has required structure
|
|
@@ -155,14 +152,11 @@ function validatePluginStructure(plugin) {
|
|
|
155
152
|
// Validate basic plugin structure
|
|
156
153
|
pluginSchema.parse(plugin);
|
|
157
154
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
let configSchemaValidator = z.record(configValueSchema);
|
|
161
|
-
configSchemaValidator.parse(plugin.configSchema);
|
|
162
|
-
}
|
|
155
|
+
// Skip deep validation of configSchema to avoid Zod version conflicts
|
|
156
|
+
// configSchema is optional and primarily for documentation
|
|
163
157
|
} catch (error) {
|
|
164
158
|
if (error instanceof z.ZodError) {
|
|
165
|
-
let messages = error.
|
|
159
|
+
let messages = error.issues.map(e => `${e.path.join('.')}: ${e.message}`);
|
|
166
160
|
throw new Error(`Invalid plugin structure: ${messages.join(', ')}`);
|
|
167
161
|
}
|
|
168
162
|
throw error;
|
package/dist/sdk/index.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { EventEmitter } from 'events';
|
|
14
|
+
import { resolveImageBuffer } from '../utils/file-helpers.js';
|
|
14
15
|
import { createUploader } from '../services/uploader.js';
|
|
15
16
|
import { createTDDService } from '../services/tdd-service.js';
|
|
16
17
|
import { ScreenshotServer } from '../services/screenshot-server.js';
|
|
@@ -226,21 +227,27 @@ export class VizzlySDK extends EventEmitter {
|
|
|
226
227
|
/**
|
|
227
228
|
* Capture a screenshot
|
|
228
229
|
* @param {string} name - Screenshot name
|
|
229
|
-
* @param {Buffer} imageBuffer - Image data
|
|
230
|
+
* @param {Buffer|string} imageBuffer - Image data as a Buffer, or a file path to an image
|
|
230
231
|
* @param {import('../types').ScreenshotOptions} [options] - Options
|
|
231
232
|
* @returns {Promise<void>}
|
|
233
|
+
* @throws {VizzlyError} When server is not running
|
|
234
|
+
* @throws {VizzlyError} When file path is provided but file doesn't exist
|
|
235
|
+
* @throws {VizzlyError} When file cannot be read due to permissions or I/O errors
|
|
232
236
|
*/
|
|
233
237
|
async screenshot(name, imageBuffer, options = {}) {
|
|
234
238
|
if (!this.server || !this.server.isRunning()) {
|
|
235
239
|
throw new VizzlyError('Server not running. Call start() first.', 'SERVER_NOT_RUNNING');
|
|
236
240
|
}
|
|
237
241
|
|
|
242
|
+
// Resolve Buffer or file path using shared utility
|
|
243
|
+
const buffer = resolveImageBuffer(imageBuffer, 'screenshot');
|
|
244
|
+
|
|
238
245
|
// Generate or use provided build ID
|
|
239
246
|
const buildId = options.buildId || this.currentBuildId || 'default';
|
|
240
247
|
this.currentBuildId = buildId;
|
|
241
248
|
|
|
242
249
|
// Convert Buffer to base64 for JSON transport
|
|
243
|
-
const imageBase64 =
|
|
250
|
+
const imageBase64 = buffer.toString('base64');
|
|
244
251
|
const screenshotData = {
|
|
245
252
|
buildId,
|
|
246
253
|
name,
|
|
@@ -331,8 +338,10 @@ export class VizzlySDK extends EventEmitter {
|
|
|
331
338
|
/**
|
|
332
339
|
* Run local comparison in TDD mode
|
|
333
340
|
* @param {string} name - Screenshot name
|
|
334
|
-
* @param {Buffer} imageBuffer - Current image
|
|
341
|
+
* @param {Buffer|string} imageBuffer - Current image as a Buffer, or a file path to an image
|
|
335
342
|
* @returns {Promise<import('../types').ComparisonResult>} Comparison result
|
|
343
|
+
* @throws {VizzlyError} When file path is provided but file doesn't exist
|
|
344
|
+
* @throws {VizzlyError} When file cannot be read due to permissions or I/O errors
|
|
336
345
|
*/
|
|
337
346
|
async compare(name, imageBuffer) {
|
|
338
347
|
if (!this.services?.tddService) {
|
|
@@ -341,8 +350,11 @@ export class VizzlySDK extends EventEmitter {
|
|
|
341
350
|
logger: this.logger
|
|
342
351
|
});
|
|
343
352
|
}
|
|
353
|
+
|
|
354
|
+
// Resolve Buffer or file path using shared utility
|
|
355
|
+
const buffer = resolveImageBuffer(imageBuffer, 'compare');
|
|
344
356
|
try {
|
|
345
|
-
const result = await this.services.tddService.compareScreenshot(name,
|
|
357
|
+
const result = await this.services.tddService.compareScreenshot(name, buffer);
|
|
346
358
|
this.emit('comparison:completed', result);
|
|
347
359
|
return result;
|
|
348
360
|
} catch (error) {
|
|
@@ -8,23 +8,26 @@ import { VizzlyError, AuthError } from '../errors/vizzly-error.js';
|
|
|
8
8
|
import crypto from 'crypto';
|
|
9
9
|
import { getPackageVersion } from '../utils/package-info.js';
|
|
10
10
|
import { getApiUrl, getApiToken, getUserAgent } from '../utils/environment-config.js';
|
|
11
|
+
import { getAuthTokens, saveAuthTokens } from '../utils/global-config.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* ApiService class for direct API communication
|
|
14
15
|
*/
|
|
15
16
|
export class ApiService {
|
|
16
17
|
constructor(options = {}) {
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// Accept config as-is, no fallbacks to environment
|
|
19
|
+
// Config-loader handles all env/file resolution
|
|
20
|
+
this.baseUrl = options.apiUrl || options.baseUrl || getApiUrl();
|
|
21
|
+
this.token = options.apiKey || options.token || getApiToken(); // Accept both apiKey and token
|
|
19
22
|
this.uploadAll = options.uploadAll || false;
|
|
20
23
|
|
|
21
24
|
// Build User-Agent string
|
|
22
|
-
const command = options.command || 'run';
|
|
25
|
+
const command = options.command || 'run';
|
|
23
26
|
const baseUserAgent = `vizzly-cli/${getPackageVersion()} (${command})`;
|
|
24
27
|
const sdkUserAgent = options.userAgent || getUserAgent();
|
|
25
28
|
this.userAgent = sdkUserAgent ? `${baseUserAgent} ${sdkUserAgent}` : baseUserAgent;
|
|
26
29
|
if (!this.token && !options.allowNoToken) {
|
|
27
|
-
throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable.');
|
|
30
|
+
throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable or run "vizzly login".');
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -32,9 +35,10 @@ export class ApiService {
|
|
|
32
35
|
* Make an API request
|
|
33
36
|
* @param {string} endpoint - API endpoint
|
|
34
37
|
* @param {Object} options - Fetch options
|
|
38
|
+
* @param {boolean} isRetry - Internal flag to prevent infinite retry loops
|
|
35
39
|
* @returns {Promise<Object>} Response data
|
|
36
40
|
*/
|
|
37
|
-
async request(endpoint, options = {}) {
|
|
41
|
+
async request(endpoint, options = {}, isRetry = false) {
|
|
38
42
|
const url = `${this.baseUrl}${endpoint}`;
|
|
39
43
|
const headers = {
|
|
40
44
|
'User-Agent': this.userAgent,
|
|
@@ -59,9 +63,48 @@ export class ApiService {
|
|
|
59
63
|
// ignore
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
// Handle authentication errors with
|
|
66
|
+
// Handle authentication errors with automatic token refresh
|
|
67
|
+
if (response.status === 401 && !isRetry) {
|
|
68
|
+
// Attempt to refresh token if we have refresh token in global config
|
|
69
|
+
let auth = await getAuthTokens();
|
|
70
|
+
if (auth && auth.refreshToken) {
|
|
71
|
+
try {
|
|
72
|
+
// Attempt token refresh
|
|
73
|
+
let refreshResponse = await fetch(`${this.baseUrl}/api/auth/cli/refresh`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'User-Agent': this.userAgent
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
refreshToken: auth.refreshToken
|
|
81
|
+
})
|
|
82
|
+
});
|
|
83
|
+
if (refreshResponse.ok) {
|
|
84
|
+
let refreshData = await refreshResponse.json();
|
|
85
|
+
|
|
86
|
+
// Save new tokens to global config
|
|
87
|
+
await saveAuthTokens({
|
|
88
|
+
accessToken: refreshData.accessToken,
|
|
89
|
+
refreshToken: refreshData.refreshToken,
|
|
90
|
+
expiresAt: refreshData.expiresAt,
|
|
91
|
+
user: auth.user // Keep existing user data
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Update token for this service instance
|
|
95
|
+
this.token = refreshData.accessToken;
|
|
96
|
+
|
|
97
|
+
// Retry the original request with new token
|
|
98
|
+
return this.request(endpoint, options, true);
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Token refresh failed, fall through to auth error
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw new AuthError('Invalid or expired API token. Please run "vizzly login" to authenticate.');
|
|
105
|
+
}
|
|
63
106
|
if (response.status === 401) {
|
|
64
|
-
throw new AuthError('Invalid or expired API token. Please
|
|
107
|
+
throw new AuthError('Invalid or expired API token. Please run "vizzly login" to authenticate.');
|
|
65
108
|
}
|
|
66
109
|
throw new VizzlyError(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''} (URL: ${url})`);
|
|
67
110
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Service for Vizzly CLI
|
|
3
|
+
* Handles authentication flows with the Vizzly API
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { AuthError, VizzlyError } from '../errors/vizzly-error.js';
|
|
7
|
+
import { getApiUrl } from '../utils/environment-config.js';
|
|
8
|
+
import { getPackageVersion } from '../utils/package-info.js';
|
|
9
|
+
import { saveAuthTokens, clearAuthTokens, getAuthTokens } from '../utils/global-config.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* AuthService class for CLI authentication
|
|
13
|
+
*/
|
|
14
|
+
export class AuthService {
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.baseUrl = options.baseUrl || getApiUrl();
|
|
17
|
+
this.userAgent = `vizzly-cli/${getPackageVersion()} (auth)`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Make an unauthenticated API request
|
|
22
|
+
* @param {string} endpoint - API endpoint
|
|
23
|
+
* @param {Object} options - Fetch options
|
|
24
|
+
* @returns {Promise<Object>} Response data
|
|
25
|
+
*/
|
|
26
|
+
async request(endpoint, options = {}) {
|
|
27
|
+
let url = `${this.baseUrl}${endpoint}`;
|
|
28
|
+
let headers = {
|
|
29
|
+
'User-Agent': this.userAgent,
|
|
30
|
+
...options.headers
|
|
31
|
+
};
|
|
32
|
+
let response = await fetch(url, {
|
|
33
|
+
...options,
|
|
34
|
+
headers
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
let errorText = '';
|
|
38
|
+
let errorData = null;
|
|
39
|
+
try {
|
|
40
|
+
let contentType = response.headers.get('content-type');
|
|
41
|
+
if (contentType && contentType.includes('application/json')) {
|
|
42
|
+
errorData = await response.json();
|
|
43
|
+
errorText = errorData.error || errorData.message || '';
|
|
44
|
+
} else {
|
|
45
|
+
errorText = await response.text();
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
errorText = response.statusText || '';
|
|
49
|
+
}
|
|
50
|
+
if (response.status === 401) {
|
|
51
|
+
throw new AuthError(errorText || 'Invalid credentials. Please check your email/username and password.');
|
|
52
|
+
}
|
|
53
|
+
if (response.status === 429) {
|
|
54
|
+
throw new VizzlyError('Too many login attempts. Please try again later.', 'RATE_LIMIT_ERROR');
|
|
55
|
+
}
|
|
56
|
+
throw new VizzlyError(`Authentication request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}`, 'AUTH_REQUEST_ERROR');
|
|
57
|
+
}
|
|
58
|
+
return response.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Make an authenticated API request
|
|
63
|
+
* @param {string} endpoint - API endpoint
|
|
64
|
+
* @param {Object} options - Fetch options
|
|
65
|
+
* @returns {Promise<Object>} Response data
|
|
66
|
+
*/
|
|
67
|
+
async authenticatedRequest(endpoint, options = {}) {
|
|
68
|
+
let auth = await getAuthTokens();
|
|
69
|
+
if (!auth || !auth.accessToken) {
|
|
70
|
+
throw new AuthError('No authentication token found. Please run "vizzly login" first.');
|
|
71
|
+
}
|
|
72
|
+
let url = `${this.baseUrl}${endpoint}`;
|
|
73
|
+
let headers = {
|
|
74
|
+
'User-Agent': this.userAgent,
|
|
75
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
76
|
+
...options.headers
|
|
77
|
+
};
|
|
78
|
+
let response = await fetch(url, {
|
|
79
|
+
...options,
|
|
80
|
+
headers
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
let errorText = '';
|
|
84
|
+
try {
|
|
85
|
+
let contentType = response.headers.get('content-type');
|
|
86
|
+
if (contentType && contentType.includes('application/json')) {
|
|
87
|
+
let errorData = await response.json();
|
|
88
|
+
errorText = errorData.error || errorData.message || '';
|
|
89
|
+
} else {
|
|
90
|
+
errorText = await response.text();
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
errorText = response.statusText || '';
|
|
94
|
+
}
|
|
95
|
+
if (response.status === 401) {
|
|
96
|
+
throw new AuthError('Authentication token is invalid or expired. Please run "vizzly login" again.');
|
|
97
|
+
}
|
|
98
|
+
throw new VizzlyError(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}`, 'API_REQUEST_ERROR');
|
|
99
|
+
}
|
|
100
|
+
return response.json();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Initiate OAuth device flow
|
|
105
|
+
* @returns {Promise<Object>} Device code, user code, verification URL
|
|
106
|
+
*/
|
|
107
|
+
async initiateDeviceFlow() {
|
|
108
|
+
return this.request('/api/auth/cli/device/initiate', {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: {
|
|
111
|
+
'Content-Type': 'application/json'
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Poll for device authorization
|
|
118
|
+
* @param {string} deviceCode - Device code from initiate
|
|
119
|
+
* @returns {Promise<Object>} Token data or pending status
|
|
120
|
+
*/
|
|
121
|
+
async pollDeviceAuthorization(deviceCode) {
|
|
122
|
+
return this.request('/api/auth/cli/device/poll', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: {
|
|
125
|
+
'Content-Type': 'application/json'
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
device_code: deviceCode
|
|
129
|
+
})
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Complete device flow and save tokens
|
|
135
|
+
* @param {Object} tokenData - Token response from poll
|
|
136
|
+
* @returns {Promise<Object>} Token data with user info
|
|
137
|
+
*/
|
|
138
|
+
async completeDeviceFlow(tokenData) {
|
|
139
|
+
// Save tokens to global config
|
|
140
|
+
await saveAuthTokens({
|
|
141
|
+
accessToken: tokenData.accessToken,
|
|
142
|
+
refreshToken: tokenData.refreshToken,
|
|
143
|
+
expiresAt: tokenData.expiresAt,
|
|
144
|
+
user: tokenData.user
|
|
145
|
+
});
|
|
146
|
+
return tokenData;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Refresh access token using refresh token
|
|
151
|
+
* @returns {Promise<Object>} New tokens
|
|
152
|
+
*/
|
|
153
|
+
async refresh() {
|
|
154
|
+
let auth = await getAuthTokens();
|
|
155
|
+
if (!auth || !auth.refreshToken) {
|
|
156
|
+
throw new AuthError('No refresh token found. Please run "vizzly login" first.');
|
|
157
|
+
}
|
|
158
|
+
let response = await this.request('/api/auth/cli/refresh', {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: {
|
|
161
|
+
'Content-Type': 'application/json'
|
|
162
|
+
},
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
refreshToken: auth.refreshToken
|
|
165
|
+
})
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Update tokens in global config
|
|
169
|
+
await saveAuthTokens({
|
|
170
|
+
accessToken: response.accessToken,
|
|
171
|
+
refreshToken: response.refreshToken,
|
|
172
|
+
expiresAt: response.expiresAt,
|
|
173
|
+
user: auth.user // Keep existing user data
|
|
174
|
+
});
|
|
175
|
+
return response;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Logout and revoke tokens
|
|
180
|
+
* @returns {Promise<void>}
|
|
181
|
+
*/
|
|
182
|
+
async logout() {
|
|
183
|
+
let auth = await getAuthTokens();
|
|
184
|
+
if (auth && auth.refreshToken) {
|
|
185
|
+
try {
|
|
186
|
+
// Attempt to revoke tokens on server
|
|
187
|
+
await this.request('/api/auth/cli/logout', {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: {
|
|
190
|
+
'Content-Type': 'application/json'
|
|
191
|
+
},
|
|
192
|
+
body: JSON.stringify({
|
|
193
|
+
refreshToken: auth.refreshToken
|
|
194
|
+
})
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
// If server request fails, still clear local tokens
|
|
198
|
+
console.warn('Warning: Failed to revoke tokens on server:', error.message);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Clear tokens from global config
|
|
203
|
+
await clearAuthTokens();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get current user information
|
|
208
|
+
* @returns {Promise<Object>} User and organization data
|
|
209
|
+
*/
|
|
210
|
+
async whoami() {
|
|
211
|
+
return this.authenticatedRequest('/api/auth/cli/whoami');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Check if user is authenticated
|
|
216
|
+
* @returns {Promise<boolean>} True if authenticated
|
|
217
|
+
*/
|
|
218
|
+
async isAuthenticated() {
|
|
219
|
+
try {
|
|
220
|
+
await this.whoami();
|
|
221
|
+
return true;
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Take a screenshot for visual regression testing
|
|
3
3
|
*
|
|
4
4
|
* @param {string} name - Unique name for the screenshot
|
|
5
|
-
* @param {Buffer} imageBuffer - PNG image data as a Buffer
|
|
5
|
+
* @param {Buffer|string} imageBuffer - PNG image data as a Buffer, or a file path to an image
|
|
6
6
|
* @param {Object} [options] - Optional configuration
|
|
7
7
|
* @param {Record<string, any>} [options.properties] - Additional properties to attach to the screenshot
|
|
8
8
|
* @param {number} [options.threshold=0] - Pixel difference threshold (0-100)
|
|
@@ -11,13 +11,17 @@
|
|
|
11
11
|
* @returns {Promise<void>}
|
|
12
12
|
*
|
|
13
13
|
* @example
|
|
14
|
-
* // Basic usage
|
|
14
|
+
* // Basic usage with Buffer
|
|
15
15
|
* import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
|
|
16
16
|
*
|
|
17
17
|
* const screenshot = await page.screenshot();
|
|
18
18
|
* await vizzlyScreenshot('homepage', screenshot);
|
|
19
19
|
*
|
|
20
20
|
* @example
|
|
21
|
+
* // Basic usage with file path
|
|
22
|
+
* await vizzlyScreenshot('homepage', './screenshots/homepage.png');
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
21
25
|
* // With properties and threshold
|
|
22
26
|
* await vizzlyScreenshot('checkout-form', screenshot, {
|
|
23
27
|
* properties: {
|
|
@@ -28,8 +32,10 @@
|
|
|
28
32
|
* });
|
|
29
33
|
*
|
|
30
34
|
* @throws {VizzlyError} When screenshot capture fails or client is not initialized
|
|
35
|
+
* @throws {VizzlyError} When file path is provided but file doesn't exist
|
|
36
|
+
* @throws {VizzlyError} When file cannot be read due to permissions or I/O errors
|
|
31
37
|
*/
|
|
32
|
-
export function vizzlyScreenshot(name: string, imageBuffer: Buffer, options?: {
|
|
38
|
+
export function vizzlyScreenshot(name: string, imageBuffer: Buffer | string, options?: {
|
|
33
39
|
properties?: Record<string, any>;
|
|
34
40
|
threshold?: number;
|
|
35
41
|
fullPage?: boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login command implementation using OAuth device flow
|
|
3
|
+
* @param {Object} options - Command options
|
|
4
|
+
* @param {Object} globalOptions - Global CLI options
|
|
5
|
+
*/
|
|
6
|
+
export function loginCommand(options?: any, globalOptions?: any): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Validate login options
|
|
9
|
+
* @param {Object} options - Command options
|
|
10
|
+
*/
|
|
11
|
+
export function validateLoginOptions(): any[];
|