@vizzly-testing/cli 0.9.0 → 0.10.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/README.md +80 -23
- package/dist/cli.js +51 -2
- package/dist/client/index.js +0 -2
- package/dist/commands/init.js +1 -6
- package/dist/commands/tdd-daemon.js +77 -27
- package/dist/plugin-loader.js +183 -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/tdd-daemon.d.ts +6 -0
- 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/docs/tdd-mode.md +10 -8
- package/package.json +7 -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
|
|
|
@@ -125,10 +131,10 @@ vizzly run "npm test" --parallel-id "ci-run-123" # For parallel CI builds
|
|
|
125
131
|
For local visual testing with immediate feedback, use the dedicated `tdd` command:
|
|
126
132
|
|
|
127
133
|
```bash
|
|
128
|
-
# Start interactive TDD dashboard
|
|
134
|
+
# Start interactive TDD dashboard (runs in background)
|
|
129
135
|
vizzly tdd start
|
|
130
136
|
|
|
131
|
-
# Run your tests in watch mode
|
|
137
|
+
# Run your tests in watch mode (same terminal or new one)
|
|
132
138
|
npm test -- --watch
|
|
133
139
|
|
|
134
140
|
# View the dashboard at http://localhost:47392
|
|
@@ -160,7 +166,7 @@ vizzly tdd stop
|
|
|
160
166
|
- `--threshold <number>` - Comparison threshold (0-1, default: 0.1)
|
|
161
167
|
- `--port <port>` - Server port (default: 47392)
|
|
162
168
|
- `--timeout <ms>` - Server timeout (default: 30000)
|
|
163
|
-
- `--
|
|
169
|
+
- `--open` - Auto-open dashboard in browser (start command only)
|
|
164
170
|
|
|
165
171
|
### Setup and Status Commands
|
|
166
172
|
```bash
|
|
@@ -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
|
@@ -5,12 +5,55 @@ import { init } from './commands/init.js';
|
|
|
5
5
|
import { uploadCommand, validateUploadOptions } from './commands/upload.js';
|
|
6
6
|
import { runCommand, validateRunOptions } from './commands/run.js';
|
|
7
7
|
import { tddCommand, validateTddOptions } from './commands/tdd.js';
|
|
8
|
-
import { tddStartCommand, tddStopCommand, tddStatusCommand } from './commands/tdd-daemon.js';
|
|
8
|
+
import { tddStartCommand, tddStopCommand, tddStatusCommand, runDaemonChild } from './commands/tdd-daemon.js';
|
|
9
9
|
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
|
+
try {
|
|
38
|
+
let plugins = await loadPlugins(configPath, config, logger);
|
|
39
|
+
for (let plugin of plugins) {
|
|
40
|
+
try {
|
|
41
|
+
// Add timeout protection for plugin registration (5 seconds)
|
|
42
|
+
let registerPromise = plugin.register(program, {
|
|
43
|
+
config,
|
|
44
|
+
logger,
|
|
45
|
+
services: container
|
|
46
|
+
});
|
|
47
|
+
let timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Plugin registration timeout (5s)')), 5000));
|
|
48
|
+
await Promise.race([registerPromise, timeoutPromise]);
|
|
49
|
+
logger.debug(`Registered plugin: ${plugin.name}`);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
logger.warn(`Failed to register plugin ${plugin.name}: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
logger.debug(`Plugin loading failed: ${error.message}`);
|
|
56
|
+
}
|
|
14
57
|
program.command('init').description('Initialize Vizzly in your project').option('--force', 'Overwrite existing configuration').action(async options => {
|
|
15
58
|
const globalOptions = program.opts();
|
|
16
59
|
await init({
|
|
@@ -35,8 +78,14 @@ program.command('upload').description('Upload screenshots to Vizzly').argument('
|
|
|
35
78
|
const tddCmd = program.command('tdd').description('Run tests in TDD mode with local visual comparisons');
|
|
36
79
|
|
|
37
80
|
// TDD Start - Background server
|
|
38
|
-
tddCmd.command('start').description('Start background TDD server').option('--port <port>', 'Port for screenshot server', '47392').option('--open', 'Open dashboard in browser').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--token <token>', 'API token override').action(async options => {
|
|
81
|
+
tddCmd.command('start').description('Start background TDD server').option('--port <port>', 'Port for screenshot server', '47392').option('--open', 'Open dashboard in browser').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--token <token>', 'API token override').option('--daemon-child', 'Internal: run as daemon child process').action(async options => {
|
|
39
82
|
const globalOptions = program.opts();
|
|
83
|
+
|
|
84
|
+
// If this is a daemon child process, run the server directly
|
|
85
|
+
if (options.daemonChild) {
|
|
86
|
+
await runDaemonChild(options, globalOptions);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
40
89
|
await tddStartCommand(options, globalOptions);
|
|
41
90
|
});
|
|
42
91
|
|
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
|
@@ -38,15 +38,10 @@ export class InitCommand {
|
|
|
38
38
|
}
|
|
39
39
|
async generateConfigFile(configPath) {
|
|
40
40
|
const configContent = `export default {
|
|
41
|
-
// API configuration
|
|
42
|
-
// Set VIZZLY_TOKEN environment variable or uncomment and set here:
|
|
43
|
-
// apiKey: 'your-token-here',
|
|
44
|
-
|
|
45
41
|
// Server configuration (for run command)
|
|
46
42
|
server: {
|
|
47
43
|
port: 47392,
|
|
48
|
-
timeout: 30000
|
|
49
|
-
screenshotPath: '/screenshot'
|
|
44
|
+
timeout: 30000
|
|
50
45
|
},
|
|
51
46
|
|
|
52
47
|
// Build configuration
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync } from 'fs';
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync, openSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { spawn } from 'child_process';
|
|
4
4
|
import { ConsoleUI } from '../utils/console-ui.js';
|
|
@@ -36,17 +36,76 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
|
|
|
36
36
|
}
|
|
37
37
|
const port = options.port || 47392;
|
|
38
38
|
|
|
39
|
-
//
|
|
39
|
+
// Prepare log files for daemon output
|
|
40
|
+
const logFile = join(vizzlyDir, 'daemon.log');
|
|
41
|
+
const errorFile = join(vizzlyDir, 'daemon-error.log');
|
|
42
|
+
|
|
43
|
+
// Spawn detached child process to run the server
|
|
44
|
+
const child = spawn(process.execPath, [process.argv[1],
|
|
45
|
+
// CLI entry point
|
|
46
|
+
'tdd', 'start', '--daemon-child',
|
|
47
|
+
// Special flag for child process
|
|
48
|
+
'--port', port.toString(), ...(options.open ? ['--open'] : []), ...(options.baselineBuild ? ['--baseline-build', options.baselineBuild] : []), ...(options.baselineComparison ? ['--baseline-comparison', options.baselineComparison] : []), ...(options.environment ? ['--environment', options.environment] : []), ...(options.threshold !== undefined ? ['--threshold', options.threshold.toString()] : []), ...(options.timeout ? ['--timeout', options.timeout] : []), ...(options.token ? ['--token', options.token] : []), ...(globalOptions.json ? ['--json'] : []), ...(globalOptions.verbose ? ['--verbose'] : []), ...(globalOptions.noColor ? ['--no-color'] : [])], {
|
|
49
|
+
detached: true,
|
|
50
|
+
stdio: ['ignore', openSync(logFile, 'a'), openSync(errorFile, 'a')],
|
|
51
|
+
cwd: process.cwd()
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Unref so parent can exit
|
|
55
|
+
child.unref();
|
|
56
|
+
|
|
57
|
+
// Verify server started with retries
|
|
58
|
+
const maxRetries = 10;
|
|
59
|
+
const retryDelay = 200; // Start with 200ms
|
|
60
|
+
let running = false;
|
|
61
|
+
for (let i = 0; i < maxRetries && !running; i++) {
|
|
62
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay * (i + 1)));
|
|
63
|
+
running = await isServerRunning(port);
|
|
64
|
+
}
|
|
65
|
+
if (!running) {
|
|
66
|
+
ui.error('Failed to start TDD server - server not responding to health checks');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
ui.success(`TDD server started at http://localhost:${port}`);
|
|
70
|
+
ui.info('');
|
|
71
|
+
ui.info('Dashboard URLs:');
|
|
72
|
+
ui.info(` Comparisons: http://localhost:${port}/`);
|
|
73
|
+
ui.info(` Stats: http://localhost:${port}/stats`);
|
|
74
|
+
ui.info('');
|
|
75
|
+
ui.info('Next steps:');
|
|
76
|
+
ui.info(' 1. Run your tests (any test runner)');
|
|
77
|
+
ui.info(' 2. Open the dashboard in your browser');
|
|
78
|
+
ui.info(' 3. Manage baselines in the Stats view');
|
|
79
|
+
ui.info('');
|
|
80
|
+
ui.info('Stop server: npx vizzly tdd stop');
|
|
81
|
+
if (options.open) {
|
|
82
|
+
openDashboard(port);
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
ui.error('Failed to start TDD daemon', error);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Internal function to run server in child process
|
|
92
|
+
* This is called when --daemon-child flag is present
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
export async function runDaemonChild(options = {}, globalOptions = {}) {
|
|
96
|
+
const vizzlyDir = join(process.cwd(), '.vizzly');
|
|
97
|
+
const port = options.port || 47392;
|
|
98
|
+
try {
|
|
99
|
+
// Use existing tddCommand but with daemon mode
|
|
40
100
|
const {
|
|
41
101
|
cleanup
|
|
42
102
|
} = await tddCommand(null,
|
|
43
103
|
// No test command - server only
|
|
44
104
|
{
|
|
45
105
|
...options,
|
|
46
|
-
daemon: true
|
|
106
|
+
daemon: true
|
|
47
107
|
}, globalOptions);
|
|
48
108
|
|
|
49
|
-
// The server is now running in this process
|
|
50
109
|
// Store our PID for the stop command
|
|
51
110
|
const pidFile = join(vizzlyDir, 'server.pid');
|
|
52
111
|
writeFileSync(pidFile, process.pid.toString());
|
|
@@ -56,25 +115,9 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
|
|
|
56
115
|
startTime: Date.now()
|
|
57
116
|
};
|
|
58
117
|
writeFileSync(join(vizzlyDir, 'server.json'), JSON.stringify(serverInfo, null, 2));
|
|
59
|
-
ui.success(`TDD server started at http://localhost:${port}`);
|
|
60
|
-
ui.info('');
|
|
61
|
-
ui.info('Dashboard URLs:');
|
|
62
|
-
ui.info(` Comparisons: http://localhost:${port}/`);
|
|
63
|
-
ui.info(` Stats: http://localhost:${port}/stats`);
|
|
64
|
-
ui.info('');
|
|
65
|
-
ui.info('Next steps:');
|
|
66
|
-
ui.info(' 1. Run your tests (any test runner)');
|
|
67
|
-
ui.info(' 2. Open the dashboard in your browser');
|
|
68
|
-
ui.info(' 3. Manage baselines in the Stats view');
|
|
69
|
-
ui.info('');
|
|
70
|
-
ui.info('Stop server: npx vizzly tdd stop');
|
|
71
|
-
if (options.open) {
|
|
72
|
-
openDashboard(port);
|
|
73
|
-
}
|
|
74
118
|
|
|
75
119
|
// Set up graceful shutdown
|
|
76
|
-
const handleShutdown = async
|
|
77
|
-
ui.info(`\nReceived ${signal}, shutting down gracefully...`);
|
|
120
|
+
const handleShutdown = async () => {
|
|
78
121
|
try {
|
|
79
122
|
// Clean up PID files
|
|
80
123
|
if (existsSync(pidFile)) unlinkSync(pidFile);
|
|
@@ -83,21 +126,28 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
|
|
|
83
126
|
|
|
84
127
|
// Use the cleanup function from tddCommand
|
|
85
128
|
await cleanup();
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
ui.error('Error during shutdown:', error);
|
|
129
|
+
} catch {
|
|
130
|
+
// Silent cleanup in daemon
|
|
89
131
|
}
|
|
90
132
|
process.exit(0);
|
|
91
133
|
};
|
|
92
134
|
|
|
93
135
|
// Register signal handlers
|
|
94
|
-
process.on('SIGINT', () => handleShutdown(
|
|
95
|
-
process.on('SIGTERM', () => handleShutdown(
|
|
136
|
+
process.on('SIGINT', () => handleShutdown());
|
|
137
|
+
process.on('SIGTERM', () => handleShutdown());
|
|
96
138
|
|
|
97
139
|
// Keep process alive
|
|
98
140
|
process.stdin.resume();
|
|
99
141
|
} catch (error) {
|
|
100
|
-
|
|
142
|
+
// Log error to file for debugging
|
|
143
|
+
const logFile = join(vizzlyDir, 'daemon-error.log');
|
|
144
|
+
try {
|
|
145
|
+
writeFileSync(logFile, `[${new Date().toISOString()}] ${error.stack || error}\n`, {
|
|
146
|
+
flag: 'a'
|
|
147
|
+
});
|
|
148
|
+
} catch {
|
|
149
|
+
// Silent failure if we can't write log
|
|
150
|
+
}
|
|
101
151
|
process.exit(1);
|
|
102
152
|
}
|
|
103
153
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { glob } from 'glob';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { resolve, dirname } from 'path';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load and register plugins from node_modules and config
|
|
8
|
+
* @param {string|null} configPath - Path to config file
|
|
9
|
+
* @param {Object} config - Loaded configuration
|
|
10
|
+
* @param {Object} logger - Logger instance
|
|
11
|
+
* @returns {Promise<Array>} Array of loaded plugins
|
|
12
|
+
*/
|
|
13
|
+
export async function loadPlugins(configPath, config, logger) {
|
|
14
|
+
let plugins = [];
|
|
15
|
+
let loadedNames = new Set();
|
|
16
|
+
|
|
17
|
+
// 1. Auto-discover plugins from @vizzly-testing/* packages
|
|
18
|
+
let discoveredPlugins = await discoverInstalledPlugins(logger);
|
|
19
|
+
for (let pluginInfo of discoveredPlugins) {
|
|
20
|
+
try {
|
|
21
|
+
let plugin = await loadPlugin(pluginInfo.path, logger);
|
|
22
|
+
if (plugin && !loadedNames.has(plugin.name)) {
|
|
23
|
+
plugins.push(plugin);
|
|
24
|
+
loadedNames.add(plugin.name);
|
|
25
|
+
logger.debug(`Loaded plugin: ${plugin.name}@${plugin.version || 'unknown'}`);
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
logger.warn(`Failed to load auto-discovered plugin from ${pluginInfo.packageName}: ${error.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 2. Load explicit plugins from config
|
|
33
|
+
if (config?.plugins && Array.isArray(config.plugins)) {
|
|
34
|
+
for (let pluginSpec of config.plugins) {
|
|
35
|
+
try {
|
|
36
|
+
let pluginPath = resolvePluginPath(pluginSpec, configPath);
|
|
37
|
+
let plugin = await loadPlugin(pluginPath, logger);
|
|
38
|
+
if (plugin && !loadedNames.has(plugin.name)) {
|
|
39
|
+
plugins.push(plugin);
|
|
40
|
+
loadedNames.add(plugin.name);
|
|
41
|
+
logger.debug(`Loaded plugin from config: ${plugin.name}@${plugin.version || 'unknown'}`);
|
|
42
|
+
} else if (plugin && loadedNames.has(plugin.name)) {
|
|
43
|
+
let existingPlugin = plugins.find(p => p.name === plugin.name);
|
|
44
|
+
logger.warn(`Plugin ${plugin.name} already loaded (v${existingPlugin.version || 'unknown'}), ` + `skipping v${plugin.version || 'unknown'} from config`);
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
logger.warn(`Failed to load plugin from config (${pluginSpec}): ${error.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return plugins;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Discover installed plugins from node_modules/@vizzly-testing/*
|
|
56
|
+
* @param {Object} logger - Logger instance
|
|
57
|
+
* @returns {Promise<Array>} Array of plugin info objects
|
|
58
|
+
*/
|
|
59
|
+
async function discoverInstalledPlugins(logger) {
|
|
60
|
+
let plugins = [];
|
|
61
|
+
try {
|
|
62
|
+
// Find all @vizzly-testing packages
|
|
63
|
+
let packageJsonPaths = await glob('node_modules/@vizzly-testing/*/package.json', {
|
|
64
|
+
cwd: process.cwd(),
|
|
65
|
+
absolute: true
|
|
66
|
+
});
|
|
67
|
+
for (let pkgPath of packageJsonPaths) {
|
|
68
|
+
try {
|
|
69
|
+
let packageJson = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
70
|
+
|
|
71
|
+
// Check if package has a plugin field
|
|
72
|
+
if (packageJson.vizzly?.plugin) {
|
|
73
|
+
let pluginRelativePath = packageJson.vizzly.plugin;
|
|
74
|
+
|
|
75
|
+
// Security: Ensure plugin path is relative and doesn't traverse up
|
|
76
|
+
if (pluginRelativePath.startsWith('/') || pluginRelativePath.includes('..')) {
|
|
77
|
+
logger.warn(`Invalid plugin path in ${packageJson.name}: path must be relative and cannot traverse directories`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Resolve plugin path relative to package directory
|
|
82
|
+
let packageDir = dirname(pkgPath);
|
|
83
|
+
let pluginPath = resolve(packageDir, pluginRelativePath);
|
|
84
|
+
|
|
85
|
+
// Additional security: Ensure resolved path is still within package directory
|
|
86
|
+
if (!pluginPath.startsWith(packageDir)) {
|
|
87
|
+
logger.warn(`Plugin path escapes package directory: ${packageJson.name}`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
plugins.push({
|
|
91
|
+
packageName: packageJson.name,
|
|
92
|
+
path: pluginPath
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logger.warn(`Failed to parse package.json at ${pkgPath}: ${error.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
logger.debug(`Failed to discover plugins: ${error.message}`);
|
|
101
|
+
}
|
|
102
|
+
return plugins;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Load a plugin from a file path
|
|
107
|
+
* @param {string} pluginPath - Path to plugin file
|
|
108
|
+
* @returns {Promise<Object|null>} Loaded plugin or null
|
|
109
|
+
*/
|
|
110
|
+
async function loadPlugin(pluginPath) {
|
|
111
|
+
try {
|
|
112
|
+
// Convert to file URL for ESM import
|
|
113
|
+
let pluginUrl = pathToFileURL(pluginPath).href;
|
|
114
|
+
|
|
115
|
+
// Dynamic import
|
|
116
|
+
let pluginModule = await import(pluginUrl);
|
|
117
|
+
|
|
118
|
+
// Get the default export
|
|
119
|
+
let plugin = pluginModule.default || pluginModule;
|
|
120
|
+
|
|
121
|
+
// Validate plugin structure
|
|
122
|
+
validatePluginStructure(plugin);
|
|
123
|
+
return plugin;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
let newError = new Error(`Failed to load plugin from ${pluginPath}: ${error.message}`);
|
|
126
|
+
newError.cause = error;
|
|
127
|
+
throw newError;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validate plugin has required structure
|
|
133
|
+
* @param {Object} plugin - Plugin object
|
|
134
|
+
* @throws {Error} If plugin structure is invalid
|
|
135
|
+
*/
|
|
136
|
+
function validatePluginStructure(plugin) {
|
|
137
|
+
if (!plugin || typeof plugin !== 'object') {
|
|
138
|
+
throw new Error('Plugin must export an object');
|
|
139
|
+
}
|
|
140
|
+
if (!plugin.name || typeof plugin.name !== 'string') {
|
|
141
|
+
throw new Error('Plugin must have a name (string)');
|
|
142
|
+
}
|
|
143
|
+
if (!plugin.register || typeof plugin.register !== 'function') {
|
|
144
|
+
throw new Error('Plugin must have a register function');
|
|
145
|
+
}
|
|
146
|
+
if (plugin.version && typeof plugin.version !== 'string') {
|
|
147
|
+
throw new Error('Plugin version must be a string');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolve plugin path from config
|
|
153
|
+
* @param {string} pluginSpec - Plugin specifier (package name or path)
|
|
154
|
+
* @param {string|null} configPath - Path to config file
|
|
155
|
+
* @returns {string} Resolved plugin path
|
|
156
|
+
*/
|
|
157
|
+
function resolvePluginPath(pluginSpec, configPath) {
|
|
158
|
+
// If it's a package name (starts with @ or is alphanumeric), try to resolve from node_modules
|
|
159
|
+
if (pluginSpec.startsWith('@') || /^[a-zA-Z0-9-]+$/.test(pluginSpec)) {
|
|
160
|
+
// Try to resolve as a package
|
|
161
|
+
try {
|
|
162
|
+
let packageJsonPath = resolve(process.cwd(), 'node_modules', pluginSpec, 'package.json');
|
|
163
|
+
let packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
164
|
+
if (packageJson.vizzly?.plugin) {
|
|
165
|
+
let packageDir = dirname(packageJsonPath);
|
|
166
|
+
return resolve(packageDir, packageJson.vizzly.plugin);
|
|
167
|
+
}
|
|
168
|
+
throw new Error('Package does not specify a vizzly.plugin field');
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new Error(`Cannot resolve plugin package ${pluginSpec}: ${error.message}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Otherwise treat as a file path
|
|
175
|
+
if (configPath) {
|
|
176
|
+
// Resolve relative to config file
|
|
177
|
+
let configDir = dirname(configPath);
|
|
178
|
+
return resolve(configDir, pluginSpec);
|
|
179
|
+
} else {
|
|
180
|
+
// Resolve relative to cwd
|
|
181
|
+
return resolve(process.cwd(), pluginSpec);
|
|
182
|
+
}
|
|
183
|
+
}
|