@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.
@@ -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,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
+ }