@vizzly-testing/cli 0.20.0 → 0.20.1-beta.1
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/api/client.js +134 -0
- package/dist/api/core.js +341 -0
- package/dist/api/endpoints.js +314 -0
- package/dist/api/index.js +19 -0
- package/dist/auth/client.js +91 -0
- package/dist/auth/core.js +176 -0
- package/dist/auth/index.js +30 -0
- package/dist/auth/operations.js +148 -0
- package/dist/cli.js +178 -3
- package/dist/client/index.js +144 -77
- package/dist/commands/doctor.js +121 -36
- package/dist/commands/finalize.js +49 -18
- package/dist/commands/init.js +13 -18
- package/dist/commands/login.js +49 -55
- package/dist/commands/logout.js +17 -9
- package/dist/commands/project.js +100 -71
- package/dist/commands/run.js +189 -95
- package/dist/commands/status.js +101 -66
- package/dist/commands/tdd-daemon.js +61 -32
- package/dist/commands/tdd.js +104 -98
- package/dist/commands/upload.js +78 -34
- package/dist/commands/whoami.js +44 -42
- package/dist/config/core.js +438 -0
- package/dist/config/index.js +13 -0
- package/dist/config/operations.js +327 -0
- package/dist/index.js +1 -1
- package/dist/project/core.js +295 -0
- package/dist/project/index.js +13 -0
- package/dist/project/operations.js +393 -0
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +16 -16
- package/dist/screenshot-server/core.js +157 -0
- package/dist/screenshot-server/index.js +11 -0
- package/dist/screenshot-server/operations.js +183 -0
- package/dist/sdk/index.js +3 -2
- package/dist/server/handlers/api-handler.js +14 -5
- package/dist/server/handlers/tdd-handler.js +191 -53
- package/dist/server/http-server.js +9 -3
- package/dist/server/routers/baseline.js +58 -0
- package/dist/server/routers/dashboard.js +10 -6
- package/dist/server/routers/screenshot.js +32 -0
- package/dist/server-manager/core.js +186 -0
- package/dist/server-manager/index.js +81 -0
- package/dist/server-manager/operations.js +209 -0
- package/dist/services/build-manager.js +2 -69
- package/dist/services/index.js +21 -48
- package/dist/services/screenshot-server.js +40 -74
- package/dist/services/server-manager.js +45 -80
- package/dist/services/test-runner.js +90 -250
- package/dist/services/uploader.js +56 -358
- package/dist/tdd/core/hotspot-coverage.js +112 -0
- package/dist/tdd/core/signature.js +101 -0
- package/dist/tdd/index.js +19 -0
- package/dist/tdd/metadata/baseline-metadata.js +103 -0
- package/dist/tdd/metadata/hotspot-metadata.js +93 -0
- package/dist/tdd/services/baseline-downloader.js +151 -0
- package/dist/tdd/services/baseline-manager.js +166 -0
- package/dist/tdd/services/comparison-service.js +230 -0
- package/dist/tdd/services/hotspot-service.js +71 -0
- package/dist/tdd/services/result-service.js +123 -0
- package/dist/tdd/tdd-service.js +1145 -0
- package/dist/test-runner/core.js +255 -0
- package/dist/test-runner/index.js +13 -0
- package/dist/test-runner/operations.js +483 -0
- package/dist/types/client.d.ts +25 -2
- package/dist/uploader/core.js +396 -0
- package/dist/uploader/index.js +11 -0
- package/dist/uploader/operations.js +412 -0
- package/dist/utils/colors.js +187 -39
- package/dist/utils/config-loader.js +3 -6
- package/dist/utils/context.js +228 -0
- package/dist/utils/output.js +449 -14
- package/docs/api-reference.md +173 -8
- package/docs/tui-elements.md +560 -0
- package/package.json +13 -13
- package/dist/services/api-service.js +0 -412
- package/dist/services/auth-service.js +0 -226
- package/dist/services/config-service.js +0 -369
- package/dist/services/html-report-generator.js +0 -455
- package/dist/services/project-service.js +0 -326
- package/dist/services/report-generator/report.css +0 -411
- package/dist/services/report-generator/viewer.js +0 -102
- package/dist/services/static-report-generator.js +0 -207
- package/dist/services/tdd-service.js +0 -1437
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Operations - Project operations with dependency injection
|
|
3
|
+
*
|
|
4
|
+
* Each operation takes its dependencies as parameters:
|
|
5
|
+
* - mappingStore: for reading/writing project mappings
|
|
6
|
+
* - httpClient: for making API requests (OAuth or API token based)
|
|
7
|
+
*
|
|
8
|
+
* This makes them trivially testable without mocking modules.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { buildBuildsUrl, buildMappingResult, buildNoApiServiceError, buildNoAuthError, buildOrgHeader, buildProjectFetchError, buildProjectUrl, buildTokenCreateError, buildTokenRevokeError, buildTokensFetchError, buildTokensUrl, enrichProjectsWithOrg, extractBuilds, extractOrganizations, extractProject, extractProjects, extractToken, extractTokens, mappingsToArray, validateDirectory, validateProjectData } from './core.js';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Mapping Operations
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* List all project mappings
|
|
19
|
+
* @param {Object} mappingStore - Store with getMappings method
|
|
20
|
+
* @returns {Promise<Array>} Array of project mappings with directory included
|
|
21
|
+
*/
|
|
22
|
+
export async function listMappings(mappingStore) {
|
|
23
|
+
let mappings = await mappingStore.getMappings();
|
|
24
|
+
return mappingsToArray(mappings);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get project mapping for a specific directory
|
|
29
|
+
* @param {Object} mappingStore - Store with getMapping method
|
|
30
|
+
* @param {string} directory - Directory path
|
|
31
|
+
* @returns {Promise<Object|null>} Project mapping or null
|
|
32
|
+
*/
|
|
33
|
+
export async function getMapping(mappingStore, directory) {
|
|
34
|
+
return mappingStore.getMapping(directory);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create or update project mapping
|
|
39
|
+
* @param {Object} mappingStore - Store with saveMapping method
|
|
40
|
+
* @param {string} directory - Directory path
|
|
41
|
+
* @param {Object} projectData - Project data
|
|
42
|
+
* @returns {Promise<Object>} Created mapping with directory included
|
|
43
|
+
*/
|
|
44
|
+
export async function createMapping(mappingStore, directory, projectData) {
|
|
45
|
+
let dirValidation = validateDirectory(directory);
|
|
46
|
+
if (!dirValidation.valid) {
|
|
47
|
+
throw dirValidation.error;
|
|
48
|
+
}
|
|
49
|
+
let dataValidation = validateProjectData(projectData);
|
|
50
|
+
if (!dataValidation.valid) {
|
|
51
|
+
throw dataValidation.error;
|
|
52
|
+
}
|
|
53
|
+
await mappingStore.saveMapping(directory, projectData);
|
|
54
|
+
return buildMappingResult(directory, projectData);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Remove project mapping
|
|
59
|
+
* @param {Object} mappingStore - Store with deleteMapping method
|
|
60
|
+
* @param {string} directory - Directory path
|
|
61
|
+
* @returns {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
export async function removeMapping(mappingStore, directory) {
|
|
64
|
+
let validation = validateDirectory(directory);
|
|
65
|
+
if (!validation.valid) {
|
|
66
|
+
throw validation.error;
|
|
67
|
+
}
|
|
68
|
+
await mappingStore.deleteMapping(directory);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Switch project for a directory (convenience wrapper for createMapping)
|
|
73
|
+
* @param {Object} mappingStore - Store with saveMapping method
|
|
74
|
+
* @param {string} directory - Directory path
|
|
75
|
+
* @param {string} projectSlug - Project slug
|
|
76
|
+
* @param {string} organizationSlug - Organization slug
|
|
77
|
+
* @param {string} token - Project token
|
|
78
|
+
* @returns {Promise<Object>} Updated mapping
|
|
79
|
+
*/
|
|
80
|
+
export async function switchProject(mappingStore, directory, projectSlug, organizationSlug, token) {
|
|
81
|
+
return createMapping(mappingStore, directory, {
|
|
82
|
+
projectSlug,
|
|
83
|
+
organizationSlug,
|
|
84
|
+
token
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// API Operations - List Projects
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* List all projects from API using OAuth authentication
|
|
94
|
+
* @param {Object} oauthClient - OAuth HTTP client with authenticatedRequest method
|
|
95
|
+
* @returns {Promise<Array>} Array of projects with organization info
|
|
96
|
+
*/
|
|
97
|
+
export async function listProjectsWithOAuth(oauthClient) {
|
|
98
|
+
// First get the user's organizations via whoami
|
|
99
|
+
let whoami = await oauthClient.authenticatedRequest('/api/auth/cli/whoami', {
|
|
100
|
+
method: 'GET'
|
|
101
|
+
});
|
|
102
|
+
let organizations = extractOrganizations(whoami);
|
|
103
|
+
if (organizations.length === 0) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fetch projects for each organization
|
|
108
|
+
let allProjects = [];
|
|
109
|
+
for (let org of organizations) {
|
|
110
|
+
try {
|
|
111
|
+
let response = await oauthClient.authenticatedRequest('/api/project', {
|
|
112
|
+
method: 'GET',
|
|
113
|
+
headers: buildOrgHeader(org.slug)
|
|
114
|
+
});
|
|
115
|
+
let projects = extractProjects(response);
|
|
116
|
+
let enriched = enrichProjectsWithOrg(projects, org);
|
|
117
|
+
allProjects.push(...enriched);
|
|
118
|
+
} catch {
|
|
119
|
+
// Silently skip failed orgs
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return allProjects;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* List all projects from API using API token authentication
|
|
127
|
+
* @param {Object} apiClient - API HTTP client with request method
|
|
128
|
+
* @returns {Promise<Array>} Array of projects
|
|
129
|
+
*/
|
|
130
|
+
export async function listProjectsWithApiToken(apiClient) {
|
|
131
|
+
let response = await apiClient.request('/api/project', {
|
|
132
|
+
method: 'GET'
|
|
133
|
+
});
|
|
134
|
+
return extractProjects(response);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* List all projects, trying OAuth first then falling back to API token
|
|
139
|
+
* @param {Object} options - Options
|
|
140
|
+
* @param {Object} [options.oauthClient] - OAuth HTTP client
|
|
141
|
+
* @param {Object} [options.apiClient] - API token HTTP client
|
|
142
|
+
* @returns {Promise<Array>} Array of projects
|
|
143
|
+
*/
|
|
144
|
+
export async function listProjects({
|
|
145
|
+
oauthClient,
|
|
146
|
+
apiClient
|
|
147
|
+
}) {
|
|
148
|
+
// Try OAuth-based request first
|
|
149
|
+
if (oauthClient) {
|
|
150
|
+
try {
|
|
151
|
+
return await listProjectsWithOAuth(oauthClient);
|
|
152
|
+
} catch {
|
|
153
|
+
// Fall back to API token
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Fall back to API token-based request
|
|
158
|
+
if (apiClient) {
|
|
159
|
+
try {
|
|
160
|
+
return await listProjectsWithApiToken(apiClient);
|
|
161
|
+
} catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// No authentication available
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ============================================================================
|
|
171
|
+
// API Operations - Get Project
|
|
172
|
+
// ============================================================================
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get project details using OAuth authentication
|
|
176
|
+
* @param {Object} oauthClient - OAuth HTTP client
|
|
177
|
+
* @param {string} projectSlug - Project slug
|
|
178
|
+
* @param {string} organizationSlug - Organization slug
|
|
179
|
+
* @returns {Promise<Object>} Project details
|
|
180
|
+
*/
|
|
181
|
+
export async function getProjectWithOAuth(oauthClient, projectSlug, organizationSlug) {
|
|
182
|
+
let response = await oauthClient.authenticatedRequest(buildProjectUrl(projectSlug), {
|
|
183
|
+
method: 'GET',
|
|
184
|
+
headers: buildOrgHeader(organizationSlug)
|
|
185
|
+
});
|
|
186
|
+
return extractProject(response);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get project details using API token authentication
|
|
191
|
+
* @param {Object} apiClient - API HTTP client
|
|
192
|
+
* @param {string} projectSlug - Project slug
|
|
193
|
+
* @param {string} organizationSlug - Organization slug
|
|
194
|
+
* @returns {Promise<Object>} Project details
|
|
195
|
+
*/
|
|
196
|
+
export async function getProjectWithApiToken(apiClient, projectSlug, organizationSlug) {
|
|
197
|
+
let response = await apiClient.request(buildProjectUrl(projectSlug), {
|
|
198
|
+
method: 'GET',
|
|
199
|
+
headers: buildOrgHeader(organizationSlug)
|
|
200
|
+
});
|
|
201
|
+
return extractProject(response);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get project details, trying OAuth first then falling back to API token
|
|
206
|
+
* @param {Object} options - Options
|
|
207
|
+
* @param {Object} [options.oauthClient] - OAuth HTTP client
|
|
208
|
+
* @param {Object} [options.apiClient] - API token HTTP client
|
|
209
|
+
* @param {string} options.projectSlug - Project slug
|
|
210
|
+
* @param {string} options.organizationSlug - Organization slug
|
|
211
|
+
* @returns {Promise<Object>} Project details
|
|
212
|
+
*/
|
|
213
|
+
export async function getProject({
|
|
214
|
+
oauthClient,
|
|
215
|
+
apiClient,
|
|
216
|
+
projectSlug,
|
|
217
|
+
organizationSlug
|
|
218
|
+
}) {
|
|
219
|
+
// Try OAuth-based request first
|
|
220
|
+
if (oauthClient) {
|
|
221
|
+
try {
|
|
222
|
+
return await getProjectWithOAuth(oauthClient, projectSlug, organizationSlug);
|
|
223
|
+
} catch {
|
|
224
|
+
// Fall back to API token
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Fall back to API token
|
|
229
|
+
if (apiClient) {
|
|
230
|
+
try {
|
|
231
|
+
return await getProjectWithApiToken(apiClient, projectSlug, organizationSlug);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
throw buildProjectFetchError(error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
throw buildNoAuthError();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ============================================================================
|
|
240
|
+
// API Operations - Recent Builds
|
|
241
|
+
// ============================================================================
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get recent builds for a project using OAuth authentication
|
|
245
|
+
* @param {Object} oauthClient - OAuth HTTP client
|
|
246
|
+
* @param {string} projectSlug - Project slug
|
|
247
|
+
* @param {string} organizationSlug - Organization slug
|
|
248
|
+
* @param {Object} options - Query options
|
|
249
|
+
* @returns {Promise<Array>} Array of builds
|
|
250
|
+
*/
|
|
251
|
+
export async function getRecentBuildsWithOAuth(oauthClient, projectSlug, organizationSlug, options = {}) {
|
|
252
|
+
let response = await oauthClient.authenticatedRequest(buildBuildsUrl(projectSlug, options), {
|
|
253
|
+
method: 'GET',
|
|
254
|
+
headers: buildOrgHeader(organizationSlug)
|
|
255
|
+
});
|
|
256
|
+
return extractBuilds(response);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Get recent builds for a project using API token authentication
|
|
261
|
+
* @param {Object} apiClient - API HTTP client
|
|
262
|
+
* @param {string} projectSlug - Project slug
|
|
263
|
+
* @param {string} organizationSlug - Organization slug
|
|
264
|
+
* @param {Object} options - Query options
|
|
265
|
+
* @returns {Promise<Array>} Array of builds
|
|
266
|
+
*/
|
|
267
|
+
export async function getRecentBuildsWithApiToken(apiClient, projectSlug, organizationSlug, options = {}) {
|
|
268
|
+
let response = await apiClient.request(buildBuildsUrl(projectSlug, options), {
|
|
269
|
+
method: 'GET',
|
|
270
|
+
headers: buildOrgHeader(organizationSlug)
|
|
271
|
+
});
|
|
272
|
+
return extractBuilds(response);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get recent builds for a project, trying OAuth first then falling back to API token
|
|
277
|
+
* @param {Object} options - Options
|
|
278
|
+
* @param {Object} [options.oauthClient] - OAuth HTTP client
|
|
279
|
+
* @param {Object} [options.apiClient] - API token HTTP client
|
|
280
|
+
* @param {string} options.projectSlug - Project slug
|
|
281
|
+
* @param {string} options.organizationSlug - Organization slug
|
|
282
|
+
* @param {number} [options.limit] - Number of builds to fetch
|
|
283
|
+
* @param {string} [options.branch] - Filter by branch
|
|
284
|
+
* @returns {Promise<Array>} Array of builds
|
|
285
|
+
*/
|
|
286
|
+
export async function getRecentBuilds({
|
|
287
|
+
oauthClient,
|
|
288
|
+
apiClient,
|
|
289
|
+
projectSlug,
|
|
290
|
+
organizationSlug,
|
|
291
|
+
limit,
|
|
292
|
+
branch
|
|
293
|
+
}) {
|
|
294
|
+
let queryOptions = {
|
|
295
|
+
limit,
|
|
296
|
+
branch
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// Try OAuth-based request first
|
|
300
|
+
if (oauthClient) {
|
|
301
|
+
try {
|
|
302
|
+
return await getRecentBuildsWithOAuth(oauthClient, projectSlug, organizationSlug, queryOptions);
|
|
303
|
+
} catch {
|
|
304
|
+
// Fall back to API token
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Fall back to API token-based request
|
|
309
|
+
if (apiClient) {
|
|
310
|
+
try {
|
|
311
|
+
return await getRecentBuildsWithApiToken(apiClient, projectSlug, organizationSlug, queryOptions);
|
|
312
|
+
} catch {
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// No authentication available
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ============================================================================
|
|
322
|
+
// API Operations - Project Tokens
|
|
323
|
+
// ============================================================================
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Create a project token
|
|
327
|
+
* @param {Object} apiClient - API HTTP client with request method
|
|
328
|
+
* @param {string} projectSlug - Project slug
|
|
329
|
+
* @param {string} organizationSlug - Organization slug
|
|
330
|
+
* @param {Object} tokenData - Token data
|
|
331
|
+
* @param {string} tokenData.name - Token name
|
|
332
|
+
* @param {string} [tokenData.description] - Token description
|
|
333
|
+
* @returns {Promise<Object>} Created token
|
|
334
|
+
*/
|
|
335
|
+
export async function createProjectToken(apiClient, projectSlug, organizationSlug, tokenData) {
|
|
336
|
+
if (!apiClient) {
|
|
337
|
+
throw buildNoApiServiceError();
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
let response = await apiClient.request(buildTokensUrl(organizationSlug, projectSlug), {
|
|
341
|
+
method: 'POST',
|
|
342
|
+
headers: {
|
|
343
|
+
'Content-Type': 'application/json'
|
|
344
|
+
},
|
|
345
|
+
body: JSON.stringify(tokenData)
|
|
346
|
+
});
|
|
347
|
+
return extractToken(response);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
throw buildTokenCreateError(error);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* List project tokens
|
|
355
|
+
* @param {Object} apiClient - API HTTP client with request method
|
|
356
|
+
* @param {string} projectSlug - Project slug
|
|
357
|
+
* @param {string} organizationSlug - Organization slug
|
|
358
|
+
* @returns {Promise<Array>} Array of tokens
|
|
359
|
+
*/
|
|
360
|
+
export async function listProjectTokens(apiClient, projectSlug, organizationSlug) {
|
|
361
|
+
if (!apiClient) {
|
|
362
|
+
throw buildNoApiServiceError();
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
let response = await apiClient.request(buildTokensUrl(organizationSlug, projectSlug), {
|
|
366
|
+
method: 'GET'
|
|
367
|
+
});
|
|
368
|
+
return extractTokens(response);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
throw buildTokensFetchError(error);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Revoke a project token
|
|
376
|
+
* @param {Object} apiClient - API HTTP client with request method
|
|
377
|
+
* @param {string} projectSlug - Project slug
|
|
378
|
+
* @param {string} organizationSlug - Organization slug
|
|
379
|
+
* @param {string} tokenId - Token ID to revoke
|
|
380
|
+
* @returns {Promise<void>}
|
|
381
|
+
*/
|
|
382
|
+
export async function revokeProjectToken(apiClient, projectSlug, organizationSlug, tokenId) {
|
|
383
|
+
if (!apiClient) {
|
|
384
|
+
throw buildNoApiServiceError();
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
await apiClient.request(buildTokensUrl(organizationSlug, projectSlug, tokenId), {
|
|
388
|
+
method: 'DELETE'
|
|
389
|
+
});
|
|
390
|
+
} catch (error) {
|
|
391
|
+
throw buildTokenRevokeError(error);
|
|
392
|
+
}
|
|
393
|
+
}
|