@vizzly-testing/cli 0.20.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.
@@ -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), createDashboardRouter(routerContext) // Catch-all for SPA routes - must be last
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,136 @@
1
+ /**
2
+ * Project Service
3
+ * Wraps project operations for use by the HTTP server
4
+ *
5
+ * Provides the interface expected by src/server/routers/projects.js:
6
+ * - listProjects() - Returns [] if not authenticated
7
+ * - listMappings() - Returns [] if no mappings
8
+ * - getMapping(directory) - Returns null if not found
9
+ * - createMapping(directory, projectData) - Throws on invalid input
10
+ * - removeMapping(directory) - Throws on invalid directory
11
+ * - getRecentBuilds(projectSlug, organizationSlug, options) - Returns [] if not authenticated
12
+ *
13
+ * Error handling:
14
+ * - API methods (listProjects, getRecentBuilds) return empty arrays when not authenticated
15
+ * - Local methods (listMappings, getMapping) never require authentication
16
+ * - Validation errors (createMapping, removeMapping) throw with descriptive messages
17
+ */
18
+
19
+ import { createAuthClient } from '../auth/client.js';
20
+ import * as projectOps from '../project/operations.js';
21
+ import { getApiUrl } from '../utils/environment-config.js';
22
+ import { deleteProjectMapping, getAuthTokens, getProjectMapping, getProjectMappings, saveProjectMapping } from '../utils/global-config.js';
23
+
24
+ /**
25
+ * Create a project service instance
26
+ * @param {Object} [options]
27
+ * @param {string} [options.apiUrl] - API base URL (defaults to VIZZLY_API_URL or https://app.vizzly.dev)
28
+ * @param {Object} [options.httpClient] - Injectable HTTP client (for testing)
29
+ * @param {Object} [options.mappingStore] - Injectable mapping store (for testing)
30
+ * @param {Function} [options.getAuthTokens] - Injectable token getter (for testing)
31
+ * @returns {Object} Project service
32
+ */
33
+ export function createProjectService(options = {}) {
34
+ let apiUrl = options.apiUrl || getApiUrl();
35
+
36
+ // Create HTTP client once at service creation (not per-request)
37
+ // Allow injection for testing
38
+ let httpClient = options.httpClient || createAuthClient({
39
+ baseUrl: apiUrl
40
+ });
41
+
42
+ // Create mapping store adapter for global config
43
+ // Allow injection for testing
44
+ let mappingStore = options.mappingStore || {
45
+ getMappings: getProjectMappings,
46
+ getMapping: getProjectMapping,
47
+ saveMapping: saveProjectMapping,
48
+ deleteMapping: deleteProjectMapping
49
+ };
50
+
51
+ // Allow injection of getAuthTokens for testing
52
+ let tokenGetter = options.getAuthTokens || getAuthTokens;
53
+
54
+ /**
55
+ * Create an OAuth client with current access token
56
+ * @returns {Promise<Object|null>} OAuth client or null if not authenticated
57
+ */
58
+ async function createOAuthClient() {
59
+ let auth = await tokenGetter();
60
+ if (!auth?.accessToken) {
61
+ return null;
62
+ }
63
+
64
+ // Wrap authenticatedRequest to auto-inject the access token
65
+ return {
66
+ authenticatedRequest: (endpoint, fetchOptions = {}) => httpClient.authenticatedRequest(endpoint, auth.accessToken, fetchOptions)
67
+ };
68
+ }
69
+ return {
70
+ /**
71
+ * List all projects from API
72
+ * Returns empty array if not authenticated (projectOps handles null oauthClient)
73
+ * @returns {Promise<Array>} Array of projects, empty if not authenticated
74
+ */
75
+ async listProjects() {
76
+ let oauthClient = await createOAuthClient();
77
+ // projectOps.listProjects handles null oauthClient by returning []
78
+ return projectOps.listProjects({
79
+ oauthClient,
80
+ apiClient: null
81
+ });
82
+ },
83
+ /**
84
+ * List all project mappings
85
+ * @returns {Promise<Array>} Array of project mappings
86
+ */
87
+ async listMappings() {
88
+ return projectOps.listMappings(mappingStore);
89
+ },
90
+ /**
91
+ * Get project mapping for a specific directory
92
+ * @param {string} directory - Directory path
93
+ * @returns {Promise<Object|null>} Project mapping or null
94
+ */
95
+ async getMapping(directory) {
96
+ return projectOps.getMapping(mappingStore, directory);
97
+ },
98
+ /**
99
+ * Create or update project mapping
100
+ * @param {string} directory - Directory path
101
+ * @param {Object} projectData - Project data
102
+ * @returns {Promise<Object>} Created mapping
103
+ */
104
+ async createMapping(directory, projectData) {
105
+ return projectOps.createMapping(mappingStore, directory, projectData);
106
+ },
107
+ /**
108
+ * Remove project mapping
109
+ * @param {string} directory - Directory path
110
+ * @returns {Promise<void>}
111
+ */
112
+ async removeMapping(directory) {
113
+ return projectOps.removeMapping(mappingStore, directory);
114
+ },
115
+ /**
116
+ * Get recent builds for a project
117
+ * Returns empty array if not authenticated (projectOps handles null oauthClient)
118
+ * @param {string} projectSlug - Project slug
119
+ * @param {string} organizationSlug - Organization slug
120
+ * @param {Object} options - Query options
121
+ * @returns {Promise<Array>} Array of builds, empty if not authenticated
122
+ */
123
+ async getRecentBuilds(projectSlug, organizationSlug, options = {}) {
124
+ let oauthClient = await createOAuthClient();
125
+ // projectOps.getRecentBuilds handles null oauthClient by returning []
126
+ return projectOps.getRecentBuilds({
127
+ oauthClient,
128
+ apiClient: null,
129
+ projectSlug,
130
+ organizationSlug,
131
+ limit: options.limit,
132
+ branch: options.branch
133
+ });
134
+ }
135
+ };
136
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",