@vizzly-testing/cli 0.20.1-beta.1 → 0.21.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 +16 -18
- package/dist/commands/run.js +2 -0
- package/dist/commands/tdd.js +18 -1
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +204 -22
- package/dist/server/http-server.js +4 -1
- package/dist/server/routers/dashboard.js +21 -36
- package/dist/server/routers/events.js +134 -0
- package/dist/services/auth-service.js +117 -0
- package/dist/services/config-service.js +306 -0
- package/dist/services/project-service.js +136 -0
- package/package.json +2 -1
|
@@ -15,6 +15,7 @@ import { createBaselineRouter } from './routers/baseline.js';
|
|
|
15
15
|
import { createCloudProxyRouter } from './routers/cloud-proxy.js';
|
|
16
16
|
import { createConfigRouter } from './routers/config.js';
|
|
17
17
|
import { createDashboardRouter } from './routers/dashboard.js';
|
|
18
|
+
import { createEventsRouter } from './routers/events.js';
|
|
18
19
|
// Routers
|
|
19
20
|
import { createHealthRouter } from './routers/health.js';
|
|
20
21
|
import { createProjectsRouter } from './routers/projects.js';
|
|
@@ -46,7 +47,9 @@ export const createHttpServer = (port, screenshotHandler, services = {}) => {
|
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
// Initialize routers
|
|
49
|
-
const routers = [createHealthRouter(routerContext), createAssetsRouter(routerContext), createScreenshotRouter(routerContext), createBaselineRouter(routerContext), createConfigRouter(routerContext), createAuthRouter(routerContext), createProjectsRouter(routerContext), createCloudProxyRouter(routerContext),
|
|
50
|
+
const routers = [createHealthRouter(routerContext), createAssetsRouter(routerContext), createScreenshotRouter(routerContext), createBaselineRouter(routerContext), createConfigRouter(routerContext), createAuthRouter(routerContext), createProjectsRouter(routerContext), createCloudProxyRouter(routerContext), createEventsRouter(routerContext),
|
|
51
|
+
// SSE for real-time updates
|
|
52
|
+
createDashboardRouter(routerContext) // Catch-all for SPA routes - must be last
|
|
50
53
|
];
|
|
51
54
|
const handleRequest = async (req, res) => {
|
|
52
55
|
// Apply CORS middleware
|
|
@@ -21,6 +21,21 @@ export function createDashboardRouter(context) {
|
|
|
21
21
|
const {
|
|
22
22
|
workingDir = process.cwd()
|
|
23
23
|
} = context || {};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read baseline metadata from baselines/metadata.json
|
|
27
|
+
*/
|
|
28
|
+
const readBaselineMetadata = () => {
|
|
29
|
+
const metadataPath = join(workingDir, '.vizzly', 'baselines', 'metadata.json');
|
|
30
|
+
if (!existsSync(metadataPath)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(readFileSync(metadataPath, 'utf8'));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
24
39
|
return async function handleDashboardRoute(req, res, pathname) {
|
|
25
40
|
if (req.method !== 'GET') {
|
|
26
41
|
return false;
|
|
@@ -31,10 +46,12 @@ export function createDashboardRouter(context) {
|
|
|
31
46
|
const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
|
|
32
47
|
if (existsSync(reportDataPath)) {
|
|
33
48
|
try {
|
|
34
|
-
const data = readFileSync(reportDataPath, 'utf8');
|
|
49
|
+
const data = JSON.parse(readFileSync(reportDataPath, 'utf8'));
|
|
50
|
+
// Include baseline metadata for stats view
|
|
51
|
+
data.baseline = readBaselineMetadata();
|
|
35
52
|
res.setHeader('Content-Type', 'application/json');
|
|
36
53
|
res.statusCode = 200;
|
|
37
|
-
res.end(data);
|
|
54
|
+
res.end(JSON.stringify(data));
|
|
38
55
|
return true;
|
|
39
56
|
} catch (error) {
|
|
40
57
|
output.debug('Error reading report data:', {
|
|
@@ -52,40 +69,6 @@ export function createDashboardRouter(context) {
|
|
|
52
69
|
}
|
|
53
70
|
}
|
|
54
71
|
|
|
55
|
-
// API endpoint for real-time status
|
|
56
|
-
if (pathname === '/api/status') {
|
|
57
|
-
const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
|
|
58
|
-
const baselineMetadataPath = join(workingDir, '.vizzly', 'baselines', 'metadata.json');
|
|
59
|
-
let reportData = null;
|
|
60
|
-
let baselineInfo = null;
|
|
61
|
-
if (existsSync(reportDataPath)) {
|
|
62
|
-
try {
|
|
63
|
-
reportData = JSON.parse(readFileSync(reportDataPath, 'utf8'));
|
|
64
|
-
} catch {
|
|
65
|
-
// Ignore
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
if (existsSync(baselineMetadataPath)) {
|
|
69
|
-
try {
|
|
70
|
-
baselineInfo = JSON.parse(readFileSync(baselineMetadataPath, 'utf8'));
|
|
71
|
-
} catch {
|
|
72
|
-
// Ignore
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
sendSuccess(res, {
|
|
76
|
-
timestamp: Date.now(),
|
|
77
|
-
baseline: baselineInfo,
|
|
78
|
-
comparisons: reportData?.comparisons || [],
|
|
79
|
-
summary: reportData?.summary || {
|
|
80
|
-
total: 0,
|
|
81
|
-
passed: 0,
|
|
82
|
-
failed: 0,
|
|
83
|
-
errors: 0
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
72
|
// Serve React SPA for dashboard routes
|
|
90
73
|
if (SPA_ROUTES.includes(pathname) || pathname.startsWith('/comparison/')) {
|
|
91
74
|
const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
|
|
@@ -94,6 +77,8 @@ export function createDashboardRouter(context) {
|
|
|
94
77
|
try {
|
|
95
78
|
const data = readFileSync(reportDataPath, 'utf8');
|
|
96
79
|
reportData = JSON.parse(data);
|
|
80
|
+
// Include baseline metadata for stats view
|
|
81
|
+
reportData.baseline = readBaselineMetadata();
|
|
97
82
|
} catch (error) {
|
|
98
83
|
output.debug('Could not read report data:', {
|
|
99
84
|
error: error.message
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Events Router
|
|
3
|
+
* Server-Sent Events endpoint for real-time dashboard updates
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create events router for SSE
|
|
11
|
+
* @param {Object} context - Router context
|
|
12
|
+
* @param {string} context.workingDir - Working directory for report data
|
|
13
|
+
* @returns {Function} Route handler
|
|
14
|
+
*/
|
|
15
|
+
export function createEventsRouter(context) {
|
|
16
|
+
const {
|
|
17
|
+
workingDir = process.cwd()
|
|
18
|
+
} = context || {};
|
|
19
|
+
const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
|
|
20
|
+
const baselineMetadataPath = join(workingDir, '.vizzly', 'baselines', 'metadata.json');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read and parse baseline metadata, returning null on error
|
|
24
|
+
*/
|
|
25
|
+
const readBaselineMetadata = () => {
|
|
26
|
+
if (!existsSync(baselineMetadataPath)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(baselineMetadataPath, 'utf8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read and parse report data with baseline metadata included
|
|
38
|
+
*/
|
|
39
|
+
const readReportData = () => {
|
|
40
|
+
if (!existsSync(reportDataPath)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const data = JSON.parse(readFileSync(reportDataPath, 'utf8'));
|
|
45
|
+
// Include baseline metadata for stats view
|
|
46
|
+
data.baseline = readBaselineMetadata();
|
|
47
|
+
return data;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Send SSE event to response
|
|
55
|
+
*/
|
|
56
|
+
const sendEvent = (res, eventType, data) => {
|
|
57
|
+
if (res.writableEnded) return;
|
|
58
|
+
res.write(`event: ${eventType}\n`);
|
|
59
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
60
|
+
};
|
|
61
|
+
return async function handleEventsRoute(req, res, pathname) {
|
|
62
|
+
if (req.method !== 'GET' || pathname !== '/api/events') {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Set SSE headers
|
|
67
|
+
res.writeHead(200, {
|
|
68
|
+
'Content-Type': 'text/event-stream',
|
|
69
|
+
'Cache-Control': 'no-cache',
|
|
70
|
+
Connection: 'keep-alive',
|
|
71
|
+
'X-Accel-Buffering': 'no' // Disable nginx buffering
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Send initial data immediately
|
|
75
|
+
const initialData = readReportData();
|
|
76
|
+
if (initialData) {
|
|
77
|
+
sendEvent(res, 'reportData', initialData);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Debounce file change events (fs.watch can fire multiple times)
|
|
81
|
+
let debounceTimer = null;
|
|
82
|
+
let watcher = null;
|
|
83
|
+
const sendUpdate = () => {
|
|
84
|
+
const data = readReportData();
|
|
85
|
+
if (data) {
|
|
86
|
+
sendEvent(res, 'reportData', data);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Watch for file changes
|
|
91
|
+
const vizzlyDir = join(workingDir, '.vizzly');
|
|
92
|
+
if (existsSync(vizzlyDir)) {
|
|
93
|
+
try {
|
|
94
|
+
watcher = watch(vizzlyDir, {
|
|
95
|
+
recursive: false
|
|
96
|
+
}, (_eventType, filename) => {
|
|
97
|
+
// Only react to report-data.json changes
|
|
98
|
+
if (filename === 'report-data.json') {
|
|
99
|
+
// Debounce: wait 100ms after last change before sending
|
|
100
|
+
if (debounceTimer) {
|
|
101
|
+
clearTimeout(debounceTimer);
|
|
102
|
+
}
|
|
103
|
+
debounceTimer = setTimeout(sendUpdate, 100);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
// File watching not available, client will fall back to polling
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Heartbeat to keep connection alive (every 30 seconds)
|
|
112
|
+
const heartbeatInterval = setInterval(() => {
|
|
113
|
+
if (!res.writableEnded) {
|
|
114
|
+
sendEvent(res, 'heartbeat', {
|
|
115
|
+
timestamp: Date.now()
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}, 30000);
|
|
119
|
+
|
|
120
|
+
// Cleanup on connection close
|
|
121
|
+
const cleanup = () => {
|
|
122
|
+
if (debounceTimer) {
|
|
123
|
+
clearTimeout(debounceTimer);
|
|
124
|
+
}
|
|
125
|
+
clearInterval(heartbeatInterval);
|
|
126
|
+
if (watcher) {
|
|
127
|
+
watcher.close();
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
req.on('close', cleanup);
|
|
131
|
+
req.on('error', cleanup);
|
|
132
|
+
return true;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Service
|
|
3
|
+
* Wraps auth operations for use by the HTTP server
|
|
4
|
+
*
|
|
5
|
+
* Provides the interface expected by src/server/routers/auth.js:
|
|
6
|
+
* - isAuthenticated() - Returns boolean, false if no tokens or API call fails
|
|
7
|
+
* - whoami() - Throws if not authenticated or tokens invalid
|
|
8
|
+
* - initiateDeviceFlow() - Throws on API error
|
|
9
|
+
* - pollDeviceAuthorization(deviceCode) - Returns pending status or tokens, throws on error
|
|
10
|
+
* - completeDeviceFlow(tokens) - Saves tokens to global config
|
|
11
|
+
* - logout() - Clears local tokens, may warn if server revocation fails
|
|
12
|
+
* - authenticatedRequest(endpoint, options) - Throws 'Not authenticated' if no tokens
|
|
13
|
+
*
|
|
14
|
+
* Error handling:
|
|
15
|
+
* - isAuthenticated() never throws, returns false on any error
|
|
16
|
+
* - whoami() throws if tokens are missing/invalid (caller should check isAuthenticated first)
|
|
17
|
+
* - authenticatedRequest() throws 'Not authenticated' if no access token
|
|
18
|
+
* - Device flow methods throw on API errors (network, server errors)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createAuthClient } from '../auth/client.js';
|
|
22
|
+
import * as authOps from '../auth/operations.js';
|
|
23
|
+
import { getApiUrl } from '../utils/environment-config.js';
|
|
24
|
+
import { clearAuthTokens, getAuthTokens, saveAuthTokens } from '../utils/global-config.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create an auth service instance
|
|
28
|
+
* @param {Object} [options]
|
|
29
|
+
* @param {string} [options.apiUrl] - API base URL (defaults to VIZZLY_API_URL or https://app.vizzly.dev)
|
|
30
|
+
* @param {Object} [options.httpClient] - Injectable HTTP client (for testing)
|
|
31
|
+
* @param {Object} [options.tokenStore] - Injectable token store (for testing)
|
|
32
|
+
* @returns {Object} Auth service
|
|
33
|
+
*/
|
|
34
|
+
export function createAuthService(options = {}) {
|
|
35
|
+
let apiUrl = options.apiUrl || getApiUrl();
|
|
36
|
+
|
|
37
|
+
// Create HTTP client for API requests (uses auth client for proper auth handling)
|
|
38
|
+
// Allow injection for testing
|
|
39
|
+
let httpClient = options.httpClient || createAuthClient({
|
|
40
|
+
baseUrl: apiUrl
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Create token store adapter for global config
|
|
44
|
+
// Allow injection for testing
|
|
45
|
+
let tokenStore = options.tokenStore || {
|
|
46
|
+
getTokens: getAuthTokens,
|
|
47
|
+
saveTokens: saveAuthTokens,
|
|
48
|
+
clearTokens: clearAuthTokens
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
/**
|
|
52
|
+
* Check if user is authenticated
|
|
53
|
+
* @returns {Promise<boolean>}
|
|
54
|
+
*/
|
|
55
|
+
async isAuthenticated() {
|
|
56
|
+
return authOps.isAuthenticated(httpClient, tokenStore);
|
|
57
|
+
},
|
|
58
|
+
/**
|
|
59
|
+
* Get current user information
|
|
60
|
+
* @returns {Promise<Object>} User and organization data
|
|
61
|
+
*/
|
|
62
|
+
async whoami() {
|
|
63
|
+
return authOps.whoami(httpClient, tokenStore);
|
|
64
|
+
},
|
|
65
|
+
/**
|
|
66
|
+
* Initiate OAuth device flow
|
|
67
|
+
* @returns {Promise<Object>} Device code info
|
|
68
|
+
*/
|
|
69
|
+
async initiateDeviceFlow() {
|
|
70
|
+
return authOps.initiateDeviceFlow(httpClient);
|
|
71
|
+
},
|
|
72
|
+
/**
|
|
73
|
+
* Poll for device authorization
|
|
74
|
+
* @param {string} deviceCode
|
|
75
|
+
* @returns {Promise<Object>} Token data or pending status
|
|
76
|
+
*/
|
|
77
|
+
async pollDeviceAuthorization(deviceCode) {
|
|
78
|
+
return authOps.pollDeviceAuthorization(httpClient, deviceCode);
|
|
79
|
+
},
|
|
80
|
+
/**
|
|
81
|
+
* Complete device flow and save tokens
|
|
82
|
+
* @param {Object} tokens - Token data
|
|
83
|
+
* @returns {Promise<Object>}
|
|
84
|
+
*/
|
|
85
|
+
async completeDeviceFlow(tokens) {
|
|
86
|
+
return authOps.completeDeviceFlow(tokenStore, tokens);
|
|
87
|
+
},
|
|
88
|
+
/**
|
|
89
|
+
* Logout and revoke tokens
|
|
90
|
+
* @returns {Promise<void>}
|
|
91
|
+
*/
|
|
92
|
+
async logout() {
|
|
93
|
+
return authOps.logout(httpClient, tokenStore);
|
|
94
|
+
},
|
|
95
|
+
/**
|
|
96
|
+
* Refresh access token
|
|
97
|
+
* @returns {Promise<Object>} New tokens
|
|
98
|
+
*/
|
|
99
|
+
async refresh() {
|
|
100
|
+
return authOps.refresh(httpClient, tokenStore);
|
|
101
|
+
},
|
|
102
|
+
/**
|
|
103
|
+
* Make an authenticated request to the API
|
|
104
|
+
* Used by cloud-proxy router for proxying requests
|
|
105
|
+
* @param {string} endpoint - API endpoint
|
|
106
|
+
* @param {Object} options - Fetch options
|
|
107
|
+
* @returns {Promise<Object>} Response data
|
|
108
|
+
*/
|
|
109
|
+
async authenticatedRequest(endpoint, options = {}) {
|
|
110
|
+
let auth = await tokenStore.getTokens();
|
|
111
|
+
if (!auth?.accessToken) {
|
|
112
|
+
throw new Error('Not authenticated');
|
|
113
|
+
}
|
|
114
|
+
return httpClient.authenticatedRequest(endpoint, auth.accessToken, options);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Service
|
|
3
|
+
* Manages configuration for the TDD dashboard settings page
|
|
4
|
+
*
|
|
5
|
+
* Provides read/write access to:
|
|
6
|
+
* - Merged config (read-only combination of all sources)
|
|
7
|
+
* - Project config (vizzly.config.js in working directory)
|
|
8
|
+
* - Global config (~/.vizzly/config.json)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { writeFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { cosmiconfigSync } from 'cosmiconfig';
|
|
15
|
+
import { loadGlobalConfig, saveGlobalConfig } from '../utils/global-config.js';
|
|
16
|
+
import * as output from '../utils/output.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default configuration values
|
|
20
|
+
*/
|
|
21
|
+
let DEFAULT_CONFIG = {
|
|
22
|
+
comparison: {
|
|
23
|
+
threshold: 2.0
|
|
24
|
+
},
|
|
25
|
+
server: {
|
|
26
|
+
port: 47392,
|
|
27
|
+
timeout: 30000
|
|
28
|
+
},
|
|
29
|
+
build: {
|
|
30
|
+
name: 'Build {timestamp}',
|
|
31
|
+
environment: 'test'
|
|
32
|
+
},
|
|
33
|
+
tdd: {
|
|
34
|
+
openReport: false
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a config service instance
|
|
40
|
+
* @param {Object} options
|
|
41
|
+
* @param {string} options.workingDir - Working directory for project config
|
|
42
|
+
* @returns {Object} Config service with getConfig, updateConfig, validateConfig methods
|
|
43
|
+
*/
|
|
44
|
+
export function createConfigService({
|
|
45
|
+
workingDir
|
|
46
|
+
}) {
|
|
47
|
+
let projectConfigPath = null;
|
|
48
|
+
let projectConfigFormat = 'js'; // 'js' or 'json'
|
|
49
|
+
|
|
50
|
+
// Find project config file
|
|
51
|
+
let explorer = cosmiconfigSync('vizzly');
|
|
52
|
+
let searchResult = explorer.search(workingDir);
|
|
53
|
+
if (searchResult?.filepath) {
|
|
54
|
+
projectConfigPath = searchResult.filepath;
|
|
55
|
+
projectConfigFormat = searchResult.filepath.endsWith('.json') ? 'json' : 'js';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get configuration by type
|
|
60
|
+
* @param {'merged'|'project'|'global'} type
|
|
61
|
+
* @returns {Promise<Object>}
|
|
62
|
+
*/
|
|
63
|
+
async function getConfig(type) {
|
|
64
|
+
if (type === 'merged') {
|
|
65
|
+
return getMergedConfig();
|
|
66
|
+
} else if (type === 'project') {
|
|
67
|
+
return getProjectConfig();
|
|
68
|
+
} else if (type === 'global') {
|
|
69
|
+
return getGlobalConfigData();
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Unknown config type: ${type}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get merged configuration with source tracking
|
|
76
|
+
*/
|
|
77
|
+
async function getMergedConfig() {
|
|
78
|
+
let config = {
|
|
79
|
+
...DEFAULT_CONFIG
|
|
80
|
+
};
|
|
81
|
+
let sources = {};
|
|
82
|
+
|
|
83
|
+
// Layer 1: Global config
|
|
84
|
+
let globalConfig = await loadGlobalConfig();
|
|
85
|
+
if (globalConfig.settings) {
|
|
86
|
+
mergeWithTracking(config, globalConfig.settings, sources, 'global');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Layer 2: Project config
|
|
90
|
+
if (projectConfigPath && existsSync(projectConfigPath)) {
|
|
91
|
+
try {
|
|
92
|
+
let result = explorer.load(projectConfigPath);
|
|
93
|
+
let projectConfig = result?.config?.default || result?.config || {};
|
|
94
|
+
mergeWithTracking(config, projectConfig, sources, 'project');
|
|
95
|
+
} catch (error) {
|
|
96
|
+
output.debug('config-service', `Error loading project config: ${error.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Layer 3: Environment variables
|
|
101
|
+
if (process.env.VIZZLY_THRESHOLD) {
|
|
102
|
+
config.comparison.threshold = parseFloat(process.env.VIZZLY_THRESHOLD);
|
|
103
|
+
sources.comparison = 'env';
|
|
104
|
+
}
|
|
105
|
+
if (process.env.VIZZLY_PORT) {
|
|
106
|
+
config.server.port = parseInt(process.env.VIZZLY_PORT, 10);
|
|
107
|
+
sources.server = 'env';
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
config,
|
|
111
|
+
sources
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get project-level configuration only
|
|
117
|
+
*/
|
|
118
|
+
async function getProjectConfig() {
|
|
119
|
+
if (!projectConfigPath || !existsSync(projectConfigPath)) {
|
|
120
|
+
return {
|
|
121
|
+
config: {},
|
|
122
|
+
path: null
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
let result = explorer.load(projectConfigPath);
|
|
127
|
+
let config = result?.config?.default || result?.config || {};
|
|
128
|
+
return {
|
|
129
|
+
config,
|
|
130
|
+
path: projectConfigPath
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
output.debug('config-service', `Error loading project config: ${error.message}`);
|
|
134
|
+
return {
|
|
135
|
+
config: {},
|
|
136
|
+
path: projectConfigPath,
|
|
137
|
+
error: error.message
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get global configuration only
|
|
144
|
+
*/
|
|
145
|
+
async function getGlobalConfigData() {
|
|
146
|
+
let globalConfig = await loadGlobalConfig();
|
|
147
|
+
return {
|
|
148
|
+
config: globalConfig.settings || {},
|
|
149
|
+
path: join(process.env.VIZZLY_HOME || join(process.env.HOME || '', '.vizzly'), 'config.json')
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Update configuration by type
|
|
155
|
+
* @param {'project'|'global'} type
|
|
156
|
+
* @param {Object} updates - Config updates to apply
|
|
157
|
+
* @returns {Promise<Object>}
|
|
158
|
+
*/
|
|
159
|
+
async function updateConfig(type, updates) {
|
|
160
|
+
if (type === 'project') {
|
|
161
|
+
return updateProjectConfig(updates);
|
|
162
|
+
} else if (type === 'global') {
|
|
163
|
+
return updateGlobalConfig(updates);
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`Cannot update config type: ${type}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Update project configuration (vizzly.config.js)
|
|
170
|
+
*/
|
|
171
|
+
async function updateProjectConfig(updates) {
|
|
172
|
+
// If no project config exists, create one
|
|
173
|
+
if (!projectConfigPath) {
|
|
174
|
+
projectConfigPath = join(workingDir, 'vizzly.config.js');
|
|
175
|
+
projectConfigFormat = 'js';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Read existing config
|
|
179
|
+
let existingConfig = {};
|
|
180
|
+
if (existsSync(projectConfigPath)) {
|
|
181
|
+
try {
|
|
182
|
+
let result = explorer.load(projectConfigPath);
|
|
183
|
+
existingConfig = result?.config?.default || result?.config || {};
|
|
184
|
+
} catch {
|
|
185
|
+
// Start fresh if corrupted
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Merge updates
|
|
190
|
+
let newConfig = mergeDeep(existingConfig, updates);
|
|
191
|
+
|
|
192
|
+
// Write based on format
|
|
193
|
+
if (projectConfigFormat === 'json') {
|
|
194
|
+
await writeFile(projectConfigPath, JSON.stringify(newConfig, null, 2));
|
|
195
|
+
} else {
|
|
196
|
+
// Write as ES module
|
|
197
|
+
let content = `import { defineConfig } from '@vizzly-testing/cli/config';
|
|
198
|
+
|
|
199
|
+
export default defineConfig(${JSON.stringify(newConfig, null, 2)});
|
|
200
|
+
`;
|
|
201
|
+
await writeFile(projectConfigPath, content);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Clear cosmiconfig cache so next read gets fresh data
|
|
205
|
+
explorer.clearCaches();
|
|
206
|
+
return {
|
|
207
|
+
success: true,
|
|
208
|
+
path: projectConfigPath
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Update global configuration (~/.vizzly/config.json)
|
|
214
|
+
*/
|
|
215
|
+
async function updateGlobalConfig(updates) {
|
|
216
|
+
let globalConfig = await loadGlobalConfig();
|
|
217
|
+
if (!globalConfig.settings) {
|
|
218
|
+
globalConfig.settings = {};
|
|
219
|
+
}
|
|
220
|
+
globalConfig.settings = mergeDeep(globalConfig.settings, updates);
|
|
221
|
+
await saveGlobalConfig(globalConfig);
|
|
222
|
+
return {
|
|
223
|
+
success: true
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Validate configuration
|
|
229
|
+
* @param {Object} config - Config to validate
|
|
230
|
+
* @returns {Promise<Object>}
|
|
231
|
+
*/
|
|
232
|
+
async function validateConfig(config) {
|
|
233
|
+
let errors = [];
|
|
234
|
+
let warnings = [];
|
|
235
|
+
|
|
236
|
+
// Validate threshold
|
|
237
|
+
if (config.comparison?.threshold !== undefined) {
|
|
238
|
+
let threshold = config.comparison.threshold;
|
|
239
|
+
if (typeof threshold !== 'number' || threshold < 0) {
|
|
240
|
+
errors.push('comparison.threshold must be a non-negative number');
|
|
241
|
+
} else if (threshold > 100) {
|
|
242
|
+
warnings.push('comparison.threshold above 100 may cause all comparisons to pass');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Validate port
|
|
247
|
+
if (config.server?.port !== undefined) {
|
|
248
|
+
let port = config.server.port;
|
|
249
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
250
|
+
errors.push('server.port must be an integer between 1 and 65535');
|
|
251
|
+
} else if (port < 1024) {
|
|
252
|
+
warnings.push('server.port below 1024 may require elevated privileges');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Validate timeout
|
|
257
|
+
if (config.server?.timeout !== undefined) {
|
|
258
|
+
let timeout = config.server.timeout;
|
|
259
|
+
if (!Number.isInteger(timeout) || timeout < 0) {
|
|
260
|
+
errors.push('server.timeout must be a non-negative integer');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
valid: errors.length === 0,
|
|
265
|
+
errors,
|
|
266
|
+
warnings
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
getConfig,
|
|
271
|
+
updateConfig,
|
|
272
|
+
validateConfig
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Deep merge two objects
|
|
278
|
+
*/
|
|
279
|
+
function mergeDeep(target, source) {
|
|
280
|
+
let result = {
|
|
281
|
+
...target
|
|
282
|
+
};
|
|
283
|
+
for (let key in source) {
|
|
284
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
285
|
+
result[key] = mergeDeep(result[key] || {}, source[key]);
|
|
286
|
+
} else {
|
|
287
|
+
result[key] = source[key];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Merge config with source tracking
|
|
295
|
+
*/
|
|
296
|
+
function mergeWithTracking(target, source, sources, sourceName) {
|
|
297
|
+
for (let key in source) {
|
|
298
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
299
|
+
if (!target[key]) target[key] = {};
|
|
300
|
+
mergeWithTracking(target[key], source[key], sources, sourceName);
|
|
301
|
+
} else {
|
|
302
|
+
target[key] = source[key];
|
|
303
|
+
sources[key] = sourceName;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|