@vizzly-testing/cli 0.9.1 → 0.10.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 +77 -20
- package/dist/cli.js +46 -1
- package/dist/client/index.js +0 -2
- package/dist/commands/init.js +121 -14
- package/dist/plugin-loader.js +204 -0
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +13 -13
- package/dist/types/client/index.d.ts +0 -2
- package/dist/types/commands/init.d.ts +20 -1
- package/dist/types/plugin-loader.d.ts +8 -0
- package/dist/types/utils/config-loader.d.ts +1 -1
- package/dist/types/utils/config-schema.d.ts +217 -0
- package/dist/utils/config-loader.js +23 -12
- package/dist/utils/config-schema.js +134 -0
- package/docs/api-reference.md +1 -2
- package/docs/plugins.md +496 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
## What is Vizzly?
|
|
9
9
|
|
|
10
|
-
Vizzly is a visual review platform designed for how modern teams work. Instead of recreating your
|
|
10
|
+
Vizzly is a visual review platform designed for how modern teams work. Instead of recreating your
|
|
11
|
+
components in a sandboxed environment, Vizzly captures screenshots directly from your functional
|
|
12
|
+
tests. This means you test the *real thing*, not a snapshot.
|
|
11
13
|
|
|
12
|
-
It's fast because we don't render anything—we process the images you provide from any source. Bring
|
|
14
|
+
It's fast because we don't render anything—we process the images you provide from any source. Bring
|
|
15
|
+
screenshots from web apps, mobile apps, or even design mockups, and use our collaborative dashboard
|
|
16
|
+
to streamline the review process between developers and designers.
|
|
13
17
|
|
|
14
18
|
## Features
|
|
15
19
|
|
|
@@ -73,7 +77,9 @@ await vizzlyScreenshot('homepage', screenshot, {
|
|
|
73
77
|
});
|
|
74
78
|
```
|
|
75
79
|
|
|
76
|
-
> **Multi-Language Support**: Currently available as a JavaScript/Node.js SDK with Python, Ruby, and
|
|
80
|
+
> **Multi-Language Support**: Currently available as a JavaScript/Node.js SDK with Python, Ruby, and
|
|
81
|
+
> other language bindings coming soon. The client SDK is lightweight and simply POSTs screenshot
|
|
82
|
+
> data to the CLI for processing.
|
|
77
83
|
|
|
78
84
|
## Commands
|
|
79
85
|
|
|
@@ -174,7 +180,8 @@ vizzly doctor --api # Include API connectivity checks
|
|
|
174
180
|
```
|
|
175
181
|
|
|
176
182
|
#### Init Command
|
|
177
|
-
Creates a basic `vizzly.config.js` configuration file with sensible defaults. No interactive
|
|
183
|
+
Creates a basic `vizzly.config.js` configuration file with sensible defaults. No interactive
|
|
184
|
+
prompts - just generates a clean config you can customize.
|
|
178
185
|
|
|
179
186
|
```bash
|
|
180
187
|
vizzly init # Create config file
|
|
@@ -217,7 +224,8 @@ VIZZLY_TOKEN=your-token vizzly doctor --api
|
|
|
217
224
|
vizzly doctor --json
|
|
218
225
|
```
|
|
219
226
|
|
|
220
|
-
The dedicated `tdd` command provides fast local development with immediate visual feedback. See the
|
|
227
|
+
The dedicated `tdd` command provides fast local development with immediate visual feedback. See the
|
|
228
|
+
[TDD Mode Guide](./docs/tdd-mode.md) for complete details on local visual testing.
|
|
221
229
|
|
|
222
230
|
## Configuration
|
|
223
231
|
|
|
@@ -225,20 +233,10 @@ Create a `vizzly.config.js` file with `vizzly init` or manually:
|
|
|
225
233
|
|
|
226
234
|
```javascript
|
|
227
235
|
export default {
|
|
228
|
-
// API configuration
|
|
229
|
-
// Set VIZZLY_TOKEN environment variable or uncomment and set here:
|
|
230
|
-
// apiToken: 'your-token-here',
|
|
231
|
-
|
|
232
|
-
// Screenshot configuration
|
|
233
|
-
screenshots: {
|
|
234
|
-
directory: './screenshots',
|
|
235
|
-
formats: ['png']
|
|
236
|
-
},
|
|
237
|
-
|
|
238
236
|
// Server configuration
|
|
239
237
|
server: {
|
|
240
238
|
port: 47392,
|
|
241
|
-
|
|
239
|
+
timeout: 30000
|
|
242
240
|
},
|
|
243
241
|
|
|
244
242
|
// Comparison configuration
|
|
@@ -368,14 +366,71 @@ Send a screenshot to Vizzly.
|
|
|
368
366
|
### `isVizzlyEnabled()`
|
|
369
367
|
Check if Vizzly is enabled in the current environment.
|
|
370
368
|
|
|
369
|
+
## Plugin Ecosystem
|
|
370
|
+
|
|
371
|
+
Vizzly supports a powerful plugin system that allows you to extend the CLI with custom
|
|
372
|
+
commands. Plugins are automatically discovered from `@vizzly-testing/*` packages or can be
|
|
373
|
+
explicitly configured.
|
|
374
|
+
|
|
375
|
+
### Official Plugins
|
|
376
|
+
|
|
377
|
+
- **[@vizzly-testing/storybook](https://npmjs.com/package/@vizzly-testing/storybook)** *(coming
|
|
378
|
+
soon)* - Capture screenshots from Storybook builds
|
|
379
|
+
|
|
380
|
+
### Using Plugins
|
|
381
|
+
|
|
382
|
+
Plugins under the `@vizzly-testing/*` scope are auto-discovered:
|
|
383
|
+
|
|
384
|
+
```bash
|
|
385
|
+
# Install plugin
|
|
386
|
+
npm install @vizzly-testing/storybook
|
|
387
|
+
|
|
388
|
+
# Use immediately - commands are automatically available!
|
|
389
|
+
vizzly storybook ./storybook-static
|
|
390
|
+
|
|
391
|
+
# Plugin commands show in help
|
|
392
|
+
vizzly --help
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Creating Plugins
|
|
396
|
+
|
|
397
|
+
You can create your own plugins to add custom commands:
|
|
398
|
+
|
|
399
|
+
```javascript
|
|
400
|
+
// plugin.js
|
|
401
|
+
export default {
|
|
402
|
+
name: 'my-plugin',
|
|
403
|
+
version: '1.0.0',
|
|
404
|
+
register(program, { config, logger, services }) {
|
|
405
|
+
program
|
|
406
|
+
.command('my-command')
|
|
407
|
+
.description('My custom command')
|
|
408
|
+
.action(async () => {
|
|
409
|
+
logger.info('Running my command!');
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Add to your `vizzly.config.js`:
|
|
416
|
+
|
|
417
|
+
```javascript
|
|
418
|
+
export default {
|
|
419
|
+
plugins: ['./plugin.js']
|
|
420
|
+
};
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
See the [Plugin Development Guide](./docs/plugins.md) for complete documentation and examples.
|
|
424
|
+
|
|
371
425
|
## Documentation
|
|
372
426
|
|
|
373
427
|
- [Getting Started](./docs/getting-started.md)
|
|
374
428
|
- [Upload Command Guide](./docs/upload-command.md)
|
|
375
429
|
- [Test Integration Guide](./docs/test-integration.md)
|
|
376
430
|
- [TDD Mode Guide](./docs/tdd-mode.md)
|
|
377
|
-
|
|
378
|
-
|
|
431
|
+
- [Plugin Development](./docs/plugins.md)
|
|
432
|
+
- [API Reference](./docs/api-reference.md)
|
|
433
|
+
- [Doctor Command](./docs/doctor-command.md)
|
|
379
434
|
|
|
380
435
|
## Environment Variables
|
|
381
436
|
|
|
@@ -408,7 +463,8 @@ These variables take highest priority over both CLI arguments and automatic git
|
|
|
408
463
|
|
|
409
464
|
## Contributing
|
|
410
465
|
|
|
411
|
-
We welcome contributions! Whether you're fixing bugs, adding features, or improving documentation,
|
|
466
|
+
We welcome contributions! Whether you're fixing bugs, adding features, or improving documentation,
|
|
467
|
+
your help makes Vizzly better for everyone.
|
|
412
468
|
|
|
413
469
|
### Getting Started
|
|
414
470
|
|
|
@@ -437,7 +493,8 @@ Found a bug or have a feature request? Please [open an issue](https://github.com
|
|
|
437
493
|
|
|
438
494
|
### Development Setup
|
|
439
495
|
|
|
440
|
-
The CLI is built with modern JavaScript and requires Node.js 20+ (LTS). See the development scripts
|
|
496
|
+
The CLI is built with modern JavaScript and requires Node.js 20+ (LTS). See the development scripts
|
|
497
|
+
in `package.json` for available commands.
|
|
441
498
|
|
|
442
499
|
## License
|
|
443
500
|
|
package/dist/cli.js
CHANGED
|
@@ -10,12 +10,57 @@ import { statusCommand, validateStatusOptions } from './commands/status.js';
|
|
|
10
10
|
import { finalizeCommand, validateFinalizeOptions } from './commands/finalize.js';
|
|
11
11
|
import { doctorCommand, validateDoctorOptions } from './commands/doctor.js';
|
|
12
12
|
import { getPackageVersion } from './utils/package-info.js';
|
|
13
|
+
import { loadPlugins } from './plugin-loader.js';
|
|
14
|
+
import { loadConfig } from './utils/config-loader.js';
|
|
15
|
+
import { createComponentLogger } from './utils/logger-factory.js';
|
|
16
|
+
import { createServiceContainer } from './container/index.js';
|
|
13
17
|
program.name('vizzly').description('Vizzly CLI for visual regression testing').version(getPackageVersion()).option('-c, --config <path>', 'Config file path').option('--token <token>', 'Vizzly API token').option('-v, --verbose', 'Verbose output').option('--json', 'Machine-readable output').option('--no-color', 'Disable colored output');
|
|
18
|
+
|
|
19
|
+
// Load plugins before defining commands
|
|
20
|
+
// We need to manually parse to get the config option early
|
|
21
|
+
let configPath = null;
|
|
22
|
+
let verboseMode = false;
|
|
23
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
24
|
+
if ((process.argv[i] === '-c' || process.argv[i] === '--config') && process.argv[i + 1]) {
|
|
25
|
+
configPath = process.argv[i + 1];
|
|
26
|
+
}
|
|
27
|
+
if (process.argv[i] === '-v' || process.argv[i] === '--verbose') {
|
|
28
|
+
verboseMode = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
let config = await loadConfig(configPath, {});
|
|
32
|
+
let logger = createComponentLogger('CLI', {
|
|
33
|
+
level: config.logLevel || (verboseMode ? 'debug' : 'warn'),
|
|
34
|
+
verbose: verboseMode || false
|
|
35
|
+
});
|
|
36
|
+
let container = await createServiceContainer(config);
|
|
37
|
+
let plugins = [];
|
|
38
|
+
try {
|
|
39
|
+
plugins = await loadPlugins(configPath, config, logger);
|
|
40
|
+
for (let plugin of plugins) {
|
|
41
|
+
try {
|
|
42
|
+
// Add timeout protection for plugin registration (5 seconds)
|
|
43
|
+
let registerPromise = plugin.register(program, {
|
|
44
|
+
config,
|
|
45
|
+
logger,
|
|
46
|
+
services: container
|
|
47
|
+
});
|
|
48
|
+
let timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Plugin registration timeout (5s)')), 5000));
|
|
49
|
+
await Promise.race([registerPromise, timeoutPromise]);
|
|
50
|
+
logger.debug(`Registered plugin: ${plugin.name}`);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
logger.warn(`Failed to register plugin ${plugin.name}: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logger.debug(`Plugin loading failed: ${error.message}`);
|
|
57
|
+
}
|
|
14
58
|
program.command('init').description('Initialize Vizzly in your project').option('--force', 'Overwrite existing configuration').action(async options => {
|
|
15
59
|
const globalOptions = program.opts();
|
|
16
60
|
await init({
|
|
17
61
|
...globalOptions,
|
|
18
|
-
...options
|
|
62
|
+
...options,
|
|
63
|
+
plugins
|
|
19
64
|
});
|
|
20
65
|
});
|
|
21
66
|
program.command('upload').description('Upload screenshots to Vizzly').argument('<path>', 'Path to screenshots directory or file').option('-b, --build-name <name>', 'Build name for grouping').option('-m, --metadata <json>', 'Additional metadata as JSON').option('--batch-size <n>', 'Upload batch size', v => parseInt(v, 10)).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', v => parseInt(v, 10)).option('--branch <branch>', 'Git branch').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (path, options) => {
|
package/dist/client/index.js
CHANGED
|
@@ -113,7 +113,6 @@ function createSimpleClient(serverUrl) {
|
|
|
113
113
|
image: imageBuffer.toString('base64'),
|
|
114
114
|
properties: options,
|
|
115
115
|
threshold: options.threshold || 0,
|
|
116
|
-
variant: options.variant,
|
|
117
116
|
fullPage: options.fullPage || false
|
|
118
117
|
})
|
|
119
118
|
});
|
|
@@ -189,7 +188,6 @@ function createSimpleClient(serverUrl) {
|
|
|
189
188
|
* @param {Object} [options] - Optional configuration
|
|
190
189
|
* @param {Record<string, any>} [options.properties] - Additional properties to attach to the screenshot
|
|
191
190
|
* @param {number} [options.threshold=0] - Pixel difference threshold (0-100)
|
|
192
|
-
* @param {string} [options.variant] - Variant name for organizing screenshots
|
|
193
191
|
* @param {boolean} [options.fullPage=false] - Whether this is a full page screenshot
|
|
194
192
|
*
|
|
195
193
|
* @returns {Promise<void>}
|
package/dist/commands/init.js
CHANGED
|
@@ -3,15 +3,19 @@ import fs from 'fs/promises';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { VizzlyError } from '../errors/vizzly-error.js';
|
|
5
5
|
import { createComponentLogger } from '../utils/logger-factory.js';
|
|
6
|
+
import { loadPlugins } from '../plugin-loader.js';
|
|
7
|
+
import { loadConfig } from '../utils/config-loader.js';
|
|
8
|
+
import { z } from 'zod';
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
11
|
* Simple configuration setup for Vizzly CLI
|
|
9
12
|
*/
|
|
10
13
|
export class InitCommand {
|
|
11
|
-
constructor(logger) {
|
|
14
|
+
constructor(logger, plugins = []) {
|
|
12
15
|
this.logger = logger || createComponentLogger('INIT', {
|
|
13
16
|
level: 'info'
|
|
14
17
|
});
|
|
18
|
+
this.plugins = plugins;
|
|
15
19
|
}
|
|
16
20
|
async run(options = {}) {
|
|
17
21
|
this.logger.info('🎯 Initializing Vizzly configuration...\n');
|
|
@@ -37,16 +41,11 @@ export class InitCommand {
|
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
43
|
async generateConfigFile(configPath) {
|
|
40
|
-
|
|
41
|
-
// API configuration
|
|
42
|
-
// Set VIZZLY_TOKEN environment variable or uncomment and set here:
|
|
43
|
-
// apiKey: 'your-token-here',
|
|
44
|
-
|
|
44
|
+
let coreConfig = `export default {
|
|
45
45
|
// Server configuration (for run command)
|
|
46
46
|
server: {
|
|
47
47
|
port: 47392,
|
|
48
|
-
timeout: 30000
|
|
49
|
-
screenshotPath: '/screenshot'
|
|
48
|
+
timeout: 30000
|
|
50
49
|
},
|
|
51
50
|
|
|
52
51
|
// Build configuration
|
|
@@ -70,11 +69,103 @@ export class InitCommand {
|
|
|
70
69
|
// TDD configuration
|
|
71
70
|
tdd: {
|
|
72
71
|
openReport: false // Whether to auto-open HTML report in browser
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
}`;
|
|
73
|
+
|
|
74
|
+
// Add plugin configurations
|
|
75
|
+
let pluginConfigs = this.generatePluginConfigs();
|
|
76
|
+
if (pluginConfigs) {
|
|
77
|
+
coreConfig += ',\n\n' + pluginConfigs;
|
|
78
|
+
}
|
|
79
|
+
coreConfig += '\n};\n';
|
|
80
|
+
await fs.writeFile(configPath, coreConfig, 'utf8');
|
|
77
81
|
this.logger.info(`📄 Created vizzly.config.js`);
|
|
82
|
+
|
|
83
|
+
// Log discovered plugins
|
|
84
|
+
let pluginsWithConfig = this.plugins.filter(p => p.configSchema);
|
|
85
|
+
if (pluginsWithConfig.length > 0) {
|
|
86
|
+
this.logger.info(` Added config for ${pluginsWithConfig.length} plugin(s):`);
|
|
87
|
+
pluginsWithConfig.forEach(p => {
|
|
88
|
+
this.logger.info(` - ${p.name}`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generate configuration sections for plugins
|
|
95
|
+
* @returns {string} Plugin config sections as formatted string
|
|
96
|
+
*/
|
|
97
|
+
generatePluginConfigs() {
|
|
98
|
+
let sections = [];
|
|
99
|
+
for (let plugin of this.plugins) {
|
|
100
|
+
if (plugin.configSchema) {
|
|
101
|
+
let configStr = this.formatPluginConfig(plugin);
|
|
102
|
+
if (configStr) {
|
|
103
|
+
sections.push(configStr);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return sections.length > 0 ? sections.join(',\n\n') : '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Format a plugin's config schema as JavaScript code
|
|
112
|
+
* @param {Object} plugin - Plugin with configSchema
|
|
113
|
+
* @returns {string} Formatted config string
|
|
114
|
+
*/
|
|
115
|
+
formatPluginConfig(plugin) {
|
|
116
|
+
try {
|
|
117
|
+
// Validate config schema structure with Zod (defensive check)
|
|
118
|
+
let configValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(configValueSchema), z.record(configValueSchema)]));
|
|
119
|
+
let configSchemaValidator = z.record(configValueSchema);
|
|
120
|
+
configSchemaValidator.parse(plugin.configSchema);
|
|
121
|
+
let configEntries = [];
|
|
122
|
+
for (let [key, value] of Object.entries(plugin.configSchema)) {
|
|
123
|
+
let formattedValue = this.formatValue(value, 1);
|
|
124
|
+
configEntries.push(` // ${plugin.name} plugin configuration\n ${key}: ${formattedValue}`);
|
|
125
|
+
}
|
|
126
|
+
return configEntries.join(',\n\n');
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof z.ZodError) {
|
|
129
|
+
let messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
|
|
130
|
+
this.logger.warn(`Invalid config schema for plugin ${plugin.name}: ${messages.join(', ')}`);
|
|
131
|
+
} else {
|
|
132
|
+
this.logger.warn(`Failed to format config for plugin ${plugin.name}: ${error.message}`);
|
|
133
|
+
}
|
|
134
|
+
return '';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Format a JavaScript value with proper indentation
|
|
140
|
+
* @param {*} value - Value to format
|
|
141
|
+
* @param {number} depth - Current indentation depth
|
|
142
|
+
* @returns {string} Formatted value
|
|
143
|
+
*/
|
|
144
|
+
formatValue(value, depth = 0) {
|
|
145
|
+
let indent = ' '.repeat(depth);
|
|
146
|
+
let nextIndent = ' '.repeat(depth + 1);
|
|
147
|
+
if (value === null) return 'null';
|
|
148
|
+
if (value === undefined) return 'undefined';
|
|
149
|
+
if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
|
|
150
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
151
|
+
if (Array.isArray(value)) {
|
|
152
|
+
if (value.length === 0) return '[]';
|
|
153
|
+
let items = value.map(item => {
|
|
154
|
+
let formatted = this.formatValue(item, depth + 1);
|
|
155
|
+
return `${nextIndent}${formatted}`;
|
|
156
|
+
});
|
|
157
|
+
return `[\n${items.join(',\n')}\n${indent}]`;
|
|
158
|
+
}
|
|
159
|
+
if (typeof value === 'object') {
|
|
160
|
+
let entries = Object.entries(value);
|
|
161
|
+
if (entries.length === 0) return '{}';
|
|
162
|
+
let props = entries.map(([k, v]) => {
|
|
163
|
+
let formatted = this.formatValue(v, depth + 1);
|
|
164
|
+
return `${nextIndent}${k}: ${formatted}`;
|
|
165
|
+
});
|
|
166
|
+
return `{\n${props.join(',\n')}\n${indent}}`;
|
|
167
|
+
}
|
|
168
|
+
return String(value);
|
|
78
169
|
}
|
|
79
170
|
showNextSteps() {
|
|
80
171
|
this.logger.info('\n📚 Next steps:');
|
|
@@ -97,12 +188,28 @@ export class InitCommand {
|
|
|
97
188
|
|
|
98
189
|
// Export factory function for CLI
|
|
99
190
|
export function createInitCommand(options) {
|
|
100
|
-
const command = new InitCommand(options.logger);
|
|
191
|
+
const command = new InitCommand(options.logger, options.plugins);
|
|
101
192
|
return () => command.run(options);
|
|
102
193
|
}
|
|
103
194
|
|
|
104
195
|
// Simple export for direct CLI usage
|
|
105
196
|
export async function init(options = {}) {
|
|
106
|
-
|
|
197
|
+
let plugins = [];
|
|
198
|
+
|
|
199
|
+
// Try to load plugins if not provided
|
|
200
|
+
if (!options.plugins) {
|
|
201
|
+
try {
|
|
202
|
+
let config = await loadConfig(options.config, {});
|
|
203
|
+
let logger = createComponentLogger('INIT', {
|
|
204
|
+
level: 'debug'
|
|
205
|
+
});
|
|
206
|
+
plugins = await loadPlugins(options.config, config, logger);
|
|
207
|
+
} catch {
|
|
208
|
+
// Silent fail - plugins are optional for init
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
plugins = options.plugins;
|
|
212
|
+
}
|
|
213
|
+
const command = new InitCommand(null, plugins);
|
|
107
214
|
return await command.run(options);
|
|
108
215
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { glob } from 'glob';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { resolve, dirname } from 'path';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Load and register plugins from node_modules and config
|
|
9
|
+
* @param {string|null} configPath - Path to config file
|
|
10
|
+
* @param {Object} config - Loaded configuration
|
|
11
|
+
* @param {Object} logger - Logger instance
|
|
12
|
+
* @returns {Promise<Array>} Array of loaded plugins
|
|
13
|
+
*/
|
|
14
|
+
export async function loadPlugins(configPath, config, logger) {
|
|
15
|
+
let plugins = [];
|
|
16
|
+
let loadedNames = new Set();
|
|
17
|
+
|
|
18
|
+
// 1. Auto-discover plugins from @vizzly-testing/* packages
|
|
19
|
+
let discoveredPlugins = await discoverInstalledPlugins(logger);
|
|
20
|
+
for (let pluginInfo of discoveredPlugins) {
|
|
21
|
+
try {
|
|
22
|
+
let plugin = await loadPlugin(pluginInfo.path, logger);
|
|
23
|
+
if (plugin && !loadedNames.has(plugin.name)) {
|
|
24
|
+
plugins.push(plugin);
|
|
25
|
+
loadedNames.add(plugin.name);
|
|
26
|
+
logger.debug(`Loaded plugin: ${plugin.name}@${plugin.version || 'unknown'}`);
|
|
27
|
+
}
|
|
28
|
+
} catch (error) {
|
|
29
|
+
logger.warn(`Failed to load auto-discovered plugin from ${pluginInfo.packageName}: ${error.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. Load explicit plugins from config
|
|
34
|
+
if (config?.plugins && Array.isArray(config.plugins)) {
|
|
35
|
+
for (let pluginSpec of config.plugins) {
|
|
36
|
+
try {
|
|
37
|
+
let pluginPath = resolvePluginPath(pluginSpec, configPath);
|
|
38
|
+
let plugin = await loadPlugin(pluginPath, logger);
|
|
39
|
+
if (plugin && !loadedNames.has(plugin.name)) {
|
|
40
|
+
plugins.push(plugin);
|
|
41
|
+
loadedNames.add(plugin.name);
|
|
42
|
+
logger.debug(`Loaded plugin from config: ${plugin.name}@${plugin.version || 'unknown'}`);
|
|
43
|
+
} else if (plugin && loadedNames.has(plugin.name)) {
|
|
44
|
+
let existingPlugin = plugins.find(p => p.name === plugin.name);
|
|
45
|
+
logger.warn(`Plugin ${plugin.name} already loaded (v${existingPlugin.version || 'unknown'}), ` + `skipping v${plugin.version || 'unknown'} from config`);
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
logger.warn(`Failed to load plugin from config (${pluginSpec}): ${error.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return plugins;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Discover installed plugins from node_modules/@vizzly-testing/*
|
|
57
|
+
* @param {Object} logger - Logger instance
|
|
58
|
+
* @returns {Promise<Array>} Array of plugin info objects
|
|
59
|
+
*/
|
|
60
|
+
async function discoverInstalledPlugins(logger) {
|
|
61
|
+
let plugins = [];
|
|
62
|
+
try {
|
|
63
|
+
// Find all @vizzly-testing packages
|
|
64
|
+
let packageJsonPaths = await glob('node_modules/@vizzly-testing/*/package.json', {
|
|
65
|
+
cwd: process.cwd(),
|
|
66
|
+
absolute: true
|
|
67
|
+
});
|
|
68
|
+
for (let pkgPath of packageJsonPaths) {
|
|
69
|
+
try {
|
|
70
|
+
let packageJson = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
71
|
+
|
|
72
|
+
// Check if package has a plugin field
|
|
73
|
+
if (packageJson.vizzly?.plugin) {
|
|
74
|
+
let pluginRelativePath = packageJson.vizzly.plugin;
|
|
75
|
+
|
|
76
|
+
// Security: Ensure plugin path is relative and doesn't traverse up
|
|
77
|
+
if (pluginRelativePath.startsWith('/') || pluginRelativePath.includes('..')) {
|
|
78
|
+
logger.warn(`Invalid plugin path in ${packageJson.name}: path must be relative and cannot traverse directories`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Resolve plugin path relative to package directory
|
|
83
|
+
let packageDir = dirname(pkgPath);
|
|
84
|
+
let pluginPath = resolve(packageDir, pluginRelativePath);
|
|
85
|
+
|
|
86
|
+
// Additional security: Ensure resolved path is still within package directory
|
|
87
|
+
if (!pluginPath.startsWith(packageDir)) {
|
|
88
|
+
logger.warn(`Plugin path escapes package directory: ${packageJson.name}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
plugins.push({
|
|
92
|
+
packageName: packageJson.name,
|
|
93
|
+
path: pluginPath
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
logger.warn(`Failed to parse package.json at ${pkgPath}: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
logger.debug(`Failed to discover plugins: ${error.message}`);
|
|
102
|
+
}
|
|
103
|
+
return plugins;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Load a plugin from a file path
|
|
108
|
+
* @param {string} pluginPath - Path to plugin file
|
|
109
|
+
* @returns {Promise<Object|null>} Loaded plugin or null
|
|
110
|
+
*/
|
|
111
|
+
async function loadPlugin(pluginPath) {
|
|
112
|
+
try {
|
|
113
|
+
// Convert to file URL for ESM import
|
|
114
|
+
let pluginUrl = pathToFileURL(pluginPath).href;
|
|
115
|
+
|
|
116
|
+
// Dynamic import
|
|
117
|
+
let pluginModule = await import(pluginUrl);
|
|
118
|
+
|
|
119
|
+
// Get the default export
|
|
120
|
+
let plugin = pluginModule.default || pluginModule;
|
|
121
|
+
|
|
122
|
+
// Validate plugin structure
|
|
123
|
+
validatePluginStructure(plugin);
|
|
124
|
+
return plugin;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
let newError = new Error(`Failed to load plugin from ${pluginPath}: ${error.message}`);
|
|
127
|
+
newError.cause = error;
|
|
128
|
+
throw newError;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Zod schema for validating plugin structure
|
|
134
|
+
*/
|
|
135
|
+
const pluginSchema = z.object({
|
|
136
|
+
name: z.string().min(1, 'Plugin name is required'),
|
|
137
|
+
version: z.string().optional(),
|
|
138
|
+
register: z.function(z.tuple([z.any(), z.any()]), z.void()),
|
|
139
|
+
configSchema: z.record(z.any()).optional()
|
|
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)]));
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Validate plugin has required structure
|
|
150
|
+
* @param {Object} plugin - Plugin object
|
|
151
|
+
* @throws {Error} If plugin structure is invalid
|
|
152
|
+
*/
|
|
153
|
+
function validatePluginStructure(plugin) {
|
|
154
|
+
try {
|
|
155
|
+
// Validate basic plugin structure
|
|
156
|
+
pluginSchema.parse(plugin);
|
|
157
|
+
|
|
158
|
+
// If configSchema exists, validate it contains valid config values
|
|
159
|
+
if (plugin.configSchema) {
|
|
160
|
+
let configSchemaValidator = z.record(configValueSchema);
|
|
161
|
+
configSchemaValidator.parse(plugin.configSchema);
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error instanceof z.ZodError) {
|
|
165
|
+
let messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
|
|
166
|
+
throw new Error(`Invalid plugin structure: ${messages.join(', ')}`);
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve plugin path from config
|
|
174
|
+
* @param {string} pluginSpec - Plugin specifier (package name or path)
|
|
175
|
+
* @param {string|null} configPath - Path to config file
|
|
176
|
+
* @returns {string} Resolved plugin path
|
|
177
|
+
*/
|
|
178
|
+
function resolvePluginPath(pluginSpec, configPath) {
|
|
179
|
+
// If it's a package name (starts with @ or is alphanumeric), try to resolve from node_modules
|
|
180
|
+
if (pluginSpec.startsWith('@') || /^[a-zA-Z0-9-]+$/.test(pluginSpec)) {
|
|
181
|
+
// Try to resolve as a package
|
|
182
|
+
try {
|
|
183
|
+
let packageJsonPath = resolve(process.cwd(), 'node_modules', pluginSpec, 'package.json');
|
|
184
|
+
let packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
185
|
+
if (packageJson.vizzly?.plugin) {
|
|
186
|
+
let packageDir = dirname(packageJsonPath);
|
|
187
|
+
return resolve(packageDir, packageJson.vizzly.plugin);
|
|
188
|
+
}
|
|
189
|
+
throw new Error('Package does not specify a vizzly.plugin field');
|
|
190
|
+
} catch (error) {
|
|
191
|
+
throw new Error(`Cannot resolve plugin package ${pluginSpec}: ${error.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Otherwise treat as a file path
|
|
196
|
+
if (configPath) {
|
|
197
|
+
// Resolve relative to config file
|
|
198
|
+
let configDir = dirname(configPath);
|
|
199
|
+
return resolve(configDir, pluginSpec);
|
|
200
|
+
} else {
|
|
201
|
+
// Resolve relative to cwd
|
|
202
|
+
return resolve(process.cwd(), pluginSpec);
|
|
203
|
+
}
|
|
204
|
+
}
|