@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.
- package/dist/commands/run.js +2 -0
- package/dist/commands/tdd.js +10 -2
- package/dist/reporter/reporter-bundle.iife.js +48 -48
- 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/project-service.js +136 -0
- package/package.json +1 -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,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
|
+
}
|