@vizzly-testing/cli 0.20.1-beta.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.
Files changed (36) hide show
  1. package/dist/cli.js +177 -2
  2. package/dist/client/index.js +144 -77
  3. package/dist/commands/doctor.js +118 -33
  4. package/dist/commands/finalize.js +8 -3
  5. package/dist/commands/init.js +13 -18
  6. package/dist/commands/login.js +42 -49
  7. package/dist/commands/logout.js +13 -5
  8. package/dist/commands/project.js +95 -67
  9. package/dist/commands/run.js +32 -6
  10. package/dist/commands/status.js +81 -50
  11. package/dist/commands/tdd-daemon.js +61 -32
  12. package/dist/commands/tdd.js +4 -25
  13. package/dist/commands/upload.js +18 -9
  14. package/dist/commands/whoami.js +40 -38
  15. package/dist/reporter/reporter-bundle.css +1 -1
  16. package/dist/reporter/reporter-bundle.iife.js +11 -11
  17. package/dist/server/handlers/tdd-handler.js +113 -7
  18. package/dist/server/http-server.js +9 -3
  19. package/dist/server/routers/baseline.js +58 -0
  20. package/dist/server/routers/dashboard.js +10 -6
  21. package/dist/server/routers/screenshot.js +32 -0
  22. package/dist/server-manager/core.js +5 -2
  23. package/dist/server-manager/operations.js +2 -1
  24. package/dist/tdd/tdd-service.js +190 -126
  25. package/dist/types/client.d.ts +25 -2
  26. package/dist/utils/colors.js +187 -39
  27. package/dist/utils/config-loader.js +3 -6
  28. package/dist/utils/context.js +228 -0
  29. package/dist/utils/output.js +449 -14
  30. package/docs/api-reference.md +173 -8
  31. package/docs/tui-elements.md +560 -0
  32. package/package.json +12 -7
  33. package/dist/report-generator/core.js +0 -315
  34. package/dist/report-generator/index.js +0 -8
  35. package/dist/report-generator/operations.js +0 -196
  36. package/dist/services/static-report-generator.js +0 -65
package/dist/cli.js CHANGED
@@ -16,10 +16,178 @@ import { validateWhoamiOptions, whoamiCommand } from './commands/whoami.js';
16
16
  import { createPluginServices } from './plugin-api.js';
17
17
  import { loadPlugins } from './plugin-loader.js';
18
18
  import { createServices } from './services/index.js';
19
+ import { colors } from './utils/colors.js';
19
20
  import { loadConfig } from './utils/config-loader.js';
21
+ import { getContext } from './utils/context.js';
20
22
  import * as output from './utils/output.js';
21
23
  import { getPackageVersion } from './utils/package-info.js';
22
- program.name('vizzly').description('Vizzly CLI for visual regression testing').version(getPackageVersion()).option('-c, --config <path>', 'Config file path').option('--token <token>', 'Vizzly API token').option('-v, --verbose', 'Verbose output (shorthand for --log-level debug)').option('--log-level <level>', 'Log level: debug, info, warn, error (default: info, or VIZZLY_LOG_LEVEL env var)').option('--json', 'Machine-readable output').option('--no-color', 'Disable colored output');
24
+
25
+ // Custom help formatting with Observatory design system
26
+ const formatHelp = (cmd, helper) => {
27
+ let c = colors;
28
+ let lines = [];
29
+ let isRootCommand = !cmd.parent;
30
+ let version = getPackageVersion();
31
+
32
+ // Branded header with grizzly bear
33
+ lines.push('');
34
+ if (isRootCommand) {
35
+ // Cute grizzly bear mascot with square eyes (like the Vizzly logo!)
36
+ lines.push(c.brand.amber(' ʕ□ᴥ□ʔ'));
37
+ lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.dim(`v${version}`)}`);
38
+ lines.push(` ${c.gray('Visual regression testing for UI teams')}`);
39
+ } else {
40
+ // Compact header for subcommands
41
+ lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.white(cmd.name())}`);
42
+ let desc = cmd.description();
43
+ if (desc) {
44
+ lines.push(` ${c.gray(desc)}`);
45
+ }
46
+ }
47
+ lines.push('');
48
+
49
+ // Usage
50
+ let usage = helper.commandUsage(cmd).replace('Usage: ', '');
51
+ lines.push(` ${c.dim('Usage')} ${c.white(usage)}`);
52
+ lines.push('');
53
+
54
+ // Get all subcommands
55
+ let commands = helper.visibleCommands(cmd);
56
+ if (commands.length > 0) {
57
+ if (isRootCommand) {
58
+ // Group commands by category for root help with icons
59
+ let categories = [{
60
+ key: 'core',
61
+ icon: '▸',
62
+ title: 'Core',
63
+ names: ['run', 'tdd', 'upload', 'status', 'finalize']
64
+ }, {
65
+ key: 'setup',
66
+ icon: '▸',
67
+ title: 'Setup',
68
+ names: ['init', 'doctor']
69
+ }, {
70
+ key: 'auth',
71
+ icon: '▸',
72
+ title: 'Account',
73
+ names: ['login', 'logout', 'whoami']
74
+ }, {
75
+ key: 'project',
76
+ icon: '▸',
77
+ title: 'Projects',
78
+ names: ['project:select', 'project:list', 'project:token', 'project:remove']
79
+ }];
80
+ let grouped = {
81
+ core: [],
82
+ setup: [],
83
+ auth: [],
84
+ project: [],
85
+ other: []
86
+ };
87
+ for (let command of commands) {
88
+ let name = command.name();
89
+ if (name === 'help') continue;
90
+ let found = false;
91
+ for (let cat of categories) {
92
+ if (cat.names.includes(name)) {
93
+ grouped[cat.key].push(command);
94
+ found = true;
95
+ break;
96
+ }
97
+ }
98
+ if (!found) grouped.other.push(command);
99
+ }
100
+ for (let cat of categories) {
101
+ let cmds = grouped[cat.key];
102
+ if (cmds.length === 0) continue;
103
+ lines.push(` ${c.brand.amber(cat.icon)} ${c.bold(cat.title)}`);
104
+ for (let command of cmds) {
105
+ let name = command.name();
106
+ let desc = command.description() || '';
107
+ // Truncate long descriptions
108
+ if (desc.length > 48) desc = `${desc.substring(0, 45)}...`;
109
+ lines.push(` ${c.white(name.padEnd(18))} ${c.gray(desc)}`);
110
+ }
111
+ lines.push('');
112
+ }
113
+
114
+ // Plugins (other commands from plugins)
115
+ if (grouped.other.length > 0) {
116
+ lines.push(` ${c.brand.amber('▸')} ${c.bold('Plugins')}`);
117
+ for (let command of grouped.other) {
118
+ let name = command.name();
119
+ let desc = command.description() || '';
120
+ if (desc.length > 48) desc = `${desc.substring(0, 45)}...`;
121
+ lines.push(` ${c.white(name.padEnd(18))} ${c.gray(desc)}`);
122
+ }
123
+ lines.push('');
124
+ }
125
+ } else {
126
+ // For subcommands, simple list
127
+ lines.push(` ${c.brand.amber('▸')} ${c.bold('Commands')}`);
128
+ for (let command of commands) {
129
+ let name = command.name();
130
+ if (name === 'help') continue;
131
+ let desc = command.description() || '';
132
+ if (desc.length > 48) desc = `${desc.substring(0, 45)}...`;
133
+ lines.push(` ${c.white(name.padEnd(18))} ${c.gray(desc)}`);
134
+ }
135
+ lines.push('');
136
+ }
137
+ }
138
+
139
+ // Options - use dimmer styling for less visual weight
140
+ let options = helper.visibleOptions(cmd);
141
+ if (options.length > 0) {
142
+ lines.push(` ${c.brand.amber('▸')} ${c.bold('Options')}`);
143
+ for (let option of options) {
144
+ let flags = option.flags;
145
+ let desc = option.description || '';
146
+ if (desc.length > 40) desc = `${desc.substring(0, 37)}...`;
147
+ lines.push(` ${c.cyan(flags.padEnd(22))} ${c.dim(desc)}`);
148
+ }
149
+ lines.push('');
150
+ }
151
+
152
+ // Quick start examples (only for root command)
153
+ if (isRootCommand) {
154
+ lines.push(` ${c.brand.amber('▸')} ${c.bold('Quick Start')}`);
155
+ lines.push('');
156
+ lines.push(` ${c.dim('# Local visual testing')}`);
157
+ lines.push(` ${c.gray('$')} ${c.white('vizzly tdd start')}`);
158
+ lines.push('');
159
+ lines.push(` ${c.dim('# CI pipeline')}`);
160
+ lines.push(` ${c.gray('$')} ${c.white('vizzly run "npm test" --wait')}`);
161
+ lines.push('');
162
+ }
163
+
164
+ // Dynamic context section (only for root)
165
+ if (isRootCommand) {
166
+ let contextItems = getContext();
167
+ if (contextItems.length > 0) {
168
+ lines.push(` ${c.dim('─'.repeat(52))}`);
169
+ for (let item of contextItems) {
170
+ if (item.type === 'success') {
171
+ lines.push(` ${c.green('✓')} ${c.gray(item.label)} ${c.white(item.value)}`);
172
+ } else if (item.type === 'warning') {
173
+ lines.push(` ${c.yellow('!')} ${c.gray(item.label)} ${c.yellow(item.value)}`);
174
+ } else {
175
+ lines.push(` ${c.dim('○')} ${c.gray(item.label)} ${c.dim(item.value)}`);
176
+ }
177
+ }
178
+ lines.push('');
179
+ }
180
+ }
181
+
182
+ // Footer with links
183
+ lines.push(` ${c.dim('─'.repeat(52))}`);
184
+ lines.push(` ${c.dim('Docs')} ${c.cyan(c.underline('docs.vizzly.dev'))} ${c.dim('GitHub')} ${c.cyan(c.underline('github.com/vizzly-testing/cli'))}`);
185
+ lines.push('');
186
+ return lines.join('\n');
187
+ };
188
+ program.name('vizzly').description('Vizzly CLI for visual regression testing').version(getPackageVersion()).option('-c, --config <path>', 'Config file path').option('--token <token>', 'Vizzly API token').option('-v, --verbose', 'Verbose output (shorthand for --log-level debug)').option('--log-level <level>', 'Log level: debug, info, warn, error (default: info, or VIZZLY_LOG_LEVEL env var)').option('--json', 'Machine-readable output').option('--color', 'Force colored output (even in non-TTY)').option('--no-color', 'Disable colored output').configureHelp({
189
+ formatHelp
190
+ });
23
191
 
24
192
  // Load plugins before defining commands
25
193
  // We need to manually parse to get the config option early
@@ -40,10 +208,17 @@ for (let i = 0; i < process.argv.length; i++) {
40
208
 
41
209
  // Configure output early
42
210
  // Priority: --log-level > --verbose > VIZZLY_LOG_LEVEL env var > default ('info')
211
+ // Color priority: --no-color (off) > --color (on) > auto-detect
212
+ let colorOverride;
213
+ if (process.argv.includes('--no-color')) {
214
+ colorOverride = false;
215
+ } else if (process.argv.includes('--color')) {
216
+ colorOverride = true;
217
+ }
43
218
  output.configure({
44
219
  logLevel: logLevelArg,
45
220
  verbose: verboseMode,
46
- color: !process.argv.includes('--no-color'),
221
+ color: colorOverride,
47
222
  json: process.argv.includes('--json')
48
223
  });
49
224
  const config = await loadConfig(configPath, {});
@@ -4,17 +4,40 @@
4
4
  */
5
5
 
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
+ import { request as httpRequest } from 'node:http';
8
+ import { request as httpsRequest } from 'node:https';
7
9
  import { dirname, join, parse } from 'node:path';
8
10
  import { getBuildId, getServerUrl, isTddMode, setVizzlyEnabled } from '../utils/environment-config.js';
9
11
 
10
12
  // Internal client state
11
13
  let currentClient = null;
12
14
  let isDisabled = false;
13
- let hasLoggedWarning = false;
14
15
 
15
16
  // Default timeout for screenshot requests (30 seconds)
16
17
  const DEFAULT_TIMEOUT_MS = 30000;
17
18
 
19
+ // Log levels for client SDK output control
20
+ export const LOG_LEVELS = {
21
+ debug: 0,
22
+ info: 1,
23
+ warn: 2,
24
+ error: 3
25
+ };
26
+
27
+ /**
28
+ * Check if client should log at the given level
29
+ * Respects VIZZLY_CLIENT_LOG_LEVEL env var (default: 'error' - only show errors)
30
+ * @param {string} level - Log level to check
31
+ * @param {string} [configuredLevel] - Configured log level (defaults to env var)
32
+ * @returns {boolean} Whether to log at this level
33
+ */
34
+ export function shouldLogClient(level, configuredLevel) {
35
+ let configLevel = configuredLevel || process.env.VIZZLY_CLIENT_LOG_LEVEL?.toLowerCase() || 'error';
36
+ let levelValue = LOG_LEVELS[level] ?? 0;
37
+ let configValue = LOG_LEVELS[configLevel] ?? 3;
38
+ return levelValue >= configValue;
39
+ }
40
+
18
41
  /**
19
42
  * Check if Vizzly is currently disabled
20
43
  * @private
@@ -28,31 +51,32 @@ function isVizzlyDisabled() {
28
51
  /**
29
52
  * Disable Vizzly SDK for the current session
30
53
  * @private
31
- * @param {string} [reason] - Optional reason for disabling
32
54
  */
33
- function disableVizzly(reason = 'disabled') {
55
+ function disableVizzly() {
34
56
  isDisabled = true;
35
57
  currentClient = null;
36
- if (reason !== 'disabled') {
37
- console.warn(`Vizzly SDK disabled due to ${reason}. Screenshots will be skipped for the remainder of this session.`);
38
- }
39
58
  }
40
59
 
41
60
  /**
42
61
  * Auto-discover local TDD server by checking for server.json
43
- * @private
62
+ * @param {string} [startDir] - Directory to start search from (defaults to cwd)
63
+ * @param {Object} [deps] - Injectable dependencies for testing
44
64
  * @returns {string|null} Server URL if found
45
65
  */
46
- function autoDiscoverTddServer() {
66
+ export function autoDiscoverTddServer(startDir, deps = {}) {
67
+ let {
68
+ exists = existsSync,
69
+ readFile = readFileSync
70
+ } = deps;
47
71
  try {
48
72
  // Look for .vizzly/server.json in current directory and parent directories
49
- let currentDir = process.cwd();
73
+ let currentDir = startDir || process.cwd();
50
74
  const root = parse(currentDir).root;
51
75
  while (currentDir !== root) {
52
76
  const serverJsonPath = join(currentDir, '.vizzly', 'server.json');
53
- if (existsSync(serverJsonPath)) {
77
+ if (exists(serverJsonPath)) {
54
78
  try {
55
- const serverInfo = JSON.parse(readFileSync(serverJsonPath, 'utf8'));
79
+ const serverInfo = JSON.parse(readFile(serverJsonPath, 'utf8'));
56
80
  if (serverInfo.port) {
57
81
  const url = `http://localhost:${serverInfo.port}`;
58
82
  return url;
@@ -97,6 +121,63 @@ function getClient() {
97
121
  return currentClient;
98
122
  }
99
123
 
124
+ /**
125
+ * Make HTTP/HTTPS request without keep-alive (so process can exit promptly)
126
+ * @private
127
+ * @param {string} url - Full URL to POST to (http or https)
128
+ * @param {object} body - JSON-serializable request body
129
+ * @param {number} timeoutMs - Request timeout in milliseconds
130
+ * @returns {Promise<{status: number, json: any}>} Response status and parsed JSON body
131
+ */
132
+ function httpPost(url, body, timeoutMs) {
133
+ return new Promise((resolve, reject) => {
134
+ let parsedUrl = new URL(url);
135
+ let data = JSON.stringify(body);
136
+ let isHttps = parsedUrl.protocol === 'https:';
137
+ let request = isHttps ? httpsRequest : httpRequest;
138
+ let req = request({
139
+ hostname: parsedUrl.hostname,
140
+ port: parsedUrl.port || (isHttps ? 443 : 80),
141
+ path: parsedUrl.pathname,
142
+ method: 'POST',
143
+ headers: {
144
+ 'Content-Type': 'application/json',
145
+ 'Content-Length': Buffer.byteLength(data),
146
+ Connection: 'close'
147
+ },
148
+ agent: false // Disable keep-alive agent so process can exit promptly
149
+ }, res => {
150
+ let chunks = [];
151
+ res.on('data', chunk => chunks.push(chunk));
152
+ res.on('end', () => {
153
+ let responseBody = Buffer.concat(chunks).toString();
154
+ let json = null;
155
+ try {
156
+ json = JSON.parse(responseBody);
157
+ } catch (err) {
158
+ if (shouldLogClient('debug')) {
159
+ console.debug(`[vizzly] Failed to parse response: ${err.message}`);
160
+ }
161
+ json = {
162
+ error: responseBody
163
+ };
164
+ }
165
+ resolve({
166
+ status: res.statusCode,
167
+ json
168
+ });
169
+ });
170
+ });
171
+ req.on('error', reject);
172
+ req.setTimeout(timeoutMs, () => {
173
+ req.destroy();
174
+ reject(new Error('Request timeout'));
175
+ });
176
+ req.write(data);
177
+ req.end();
178
+ });
179
+ }
180
+
100
181
  /**
101
182
  * Create a simple HTTP client for screenshots
102
183
  * @private
@@ -104,50 +185,29 @@ function getClient() {
104
185
  function createSimpleClient(serverUrl) {
105
186
  return {
106
187
  async screenshot(name, imageBuffer, options = {}) {
107
- const controller = new AbortController();
108
- const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
109
188
  try {
110
189
  // If it's a string, assume it's a file path and send directly
111
190
  // Otherwise it's a Buffer, so convert to base64
112
191
  const image = typeof imageBuffer === 'string' ? imageBuffer : imageBuffer.toString('base64');
113
- const response = await fetch(`${serverUrl}/screenshot`, {
114
- method: 'POST',
115
- headers: {
116
- 'Content-Type': 'application/json'
117
- },
118
- body: JSON.stringify({
119
- buildId: getBuildId(),
120
- name,
121
- image,
122
- properties: options,
123
- fullPage: options.fullPage || false
124
- }),
125
- signal: controller.signal
126
- });
127
- if (!response.ok) {
128
- const errorData = await response.json().catch(async () => {
129
- const errorText = await response.text().catch(() => 'Unknown error');
130
- return {
131
- error: errorText
132
- };
133
- });
134
-
135
- // In TDD mode, if we get 422 (visual difference), log but DON'T throw
192
+ const {
193
+ status,
194
+ json
195
+ } = await httpPost(`${serverUrl}/screenshot`, {
196
+ buildId: getBuildId(),
197
+ name,
198
+ image,
199
+ properties: options,
200
+ fullPage: options.fullPage || false
201
+ }, DEFAULT_TIMEOUT_MS);
202
+ if (status < 200 || status >= 300) {
203
+ // In TDD mode, if we get 422 (visual difference), don't throw
136
204
  // This allows all screenshots in the test to be captured and compared
137
- if (response.status === 422 && errorData.tddMode && errorData.comparison) {
138
- const comp = errorData.comparison;
139
- const diffPercent = comp.diffPercentage ? comp.diffPercentage.toFixed(2) : '0.00';
140
-
141
- // Extract port from serverUrl (e.g., "http://localhost:47392" -> "47392")
142
- const urlMatch = serverUrl.match(/:(\d+)/);
143
- const port = urlMatch ? urlMatch[1] : '47392';
144
- const dashboardUrl = `http://localhost:${port}/dashboard`;
145
-
146
- // Just log warning - don't throw by default in TDD mode
147
- // This allows all screenshots to be captured
148
- console.warn(`⚠️ Visual diff: ${comp.name} (${diffPercent}%) → ${dashboardUrl}`);
205
+ // The summary will show all failures at the end
206
+ if (status === 422 && json.tddMode && json.comparison) {
207
+ let comp = json.comparison;
149
208
 
150
209
  // Return success so test continues and captures remaining screenshots
210
+ // Visual diff details will be shown in the summary after tests complete
151
211
  return {
152
212
  success: true,
153
213
  status: 'failed',
@@ -155,46 +215,56 @@ function createSimpleClient(serverUrl) {
155
215
  diffPercentage: comp.diffPercentage
156
216
  };
157
217
  }
158
- throw new Error(`Screenshot failed: ${response.status} ${response.statusText} - ${errorData.error || 'Unknown error'}`);
218
+ throw new Error(`Screenshot failed: ${status} - ${json.error || 'Unknown error'}`);
159
219
  }
160
- clearTimeout(timeoutId);
161
- return await response.json();
220
+ return json;
162
221
  } catch (error) {
163
- clearTimeout(timeoutId);
164
-
165
- // Handle timeout (AbortError)
166
- if (error.name === 'AbortError') {
167
- console.error(`Vizzly screenshot timed out for "${name}" after ${DEFAULT_TIMEOUT_MS / 1000}s`);
168
- console.error('The server may be overloaded or unresponsive. Check server health.');
169
- disableVizzly('timeout');
222
+ // Handle timeout
223
+ if (error.message === 'Request timeout') {
224
+ if (shouldLogClient('error')) {
225
+ console.error(`[vizzly] Screenshot timed out for "${name}" after ${DEFAULT_TIMEOUT_MS / 1000}s`);
226
+ }
227
+ disableVizzly();
170
228
  return null;
171
229
  }
172
230
 
173
231
  // In TDD mode with visual differences, throw the error to fail the test
174
232
  if (error.message?.toLowerCase().includes('visual diff')) {
175
- // Clean output for TDD mode - don't spam with additional logs
176
233
  throw error;
177
234
  }
178
- console.error(`Vizzly screenshot failed for ${name}:`, error.message);
179
- if (error.message?.includes('fetch') || error.code === 'ECONNREFUSED') {
180
- console.error(`Server URL: ${serverUrl}/screenshot`);
181
- console.error('This usually means the Vizzly server is not running or not accessible');
182
- console.error('Check that the server is started and the port is correct');
183
- } else if (error.message?.includes('404') || error.message?.includes('Not Found')) {
184
- console.error(`Server URL: ${serverUrl}/screenshot`);
185
- console.error('The screenshot endpoint was not found - check server configuration');
235
+
236
+ // Log connection errors (these indicate setup problems)
237
+ if (shouldLogClient('error')) {
238
+ if (error.code === 'ECONNREFUSED') {
239
+ console.error(`[vizzly] Server not accessible at ${serverUrl}/screenshot`);
240
+ } else if (error.message?.includes('404') || error.message?.includes('Not Found')) {
241
+ console.error(`[vizzly] Screenshot endpoint not found at ${serverUrl}/screenshot`);
242
+ } else {
243
+ console.error(`[vizzly] Screenshot failed for ${name}: ${error.message}`);
244
+ }
186
245
  }
187
246
 
188
247
  // Disable the SDK after first failure to prevent spam
189
- disableVizzly('failure');
248
+ disableVizzly();
190
249
 
191
- // Don't throw - just return silently to not break tests (except TDD mode)
250
+ // Don't throw - just return silently to not break tests
192
251
  return null;
193
252
  }
194
253
  },
195
254
  async flush() {
196
- // Simple client doesn't need explicit flushing
197
- return Promise.resolve();
255
+ // Call the /flush endpoint to signal test completion and trigger summary output
256
+ try {
257
+ let {
258
+ status,
259
+ json
260
+ } = await httpPost(`${serverUrl}/flush`, {}, DEFAULT_TIMEOUT_MS);
261
+ if (status >= 200 && status < 300) {
262
+ return json;
263
+ }
264
+ } catch {
265
+ // Silently ignore flush errors - server may not be running
266
+ }
267
+ return null;
198
268
  }
199
269
  };
200
270
  }
@@ -240,13 +310,10 @@ export async function vizzlyScreenshot(name, imageBuffer, options = {}) {
240
310
  if (isVizzlyDisabled()) {
241
311
  return; // Silently skip when disabled
242
312
  }
243
- const client = getClient();
313
+ let client = getClient();
244
314
  if (!client) {
245
- if (!hasLoggedWarning) {
246
- console.warn('Vizzly client not initialized. Screenshots will be skipped.');
247
- hasLoggedWarning = true;
248
- disableVizzly();
249
- }
315
+ // Silently disable - no server running, nothing to do
316
+ disableVizzly();
250
317
  return;
251
318
  }
252
319