@automattic/vip 4.0.3 → 4.0.5

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 (35) hide show
  1. package/AGENTS.md +2 -0
  2. package/dist/bin/vip-app-deploy-token-generate.js +102 -0
  3. package/dist/bin/vip-app-deploy-token.js +20 -0
  4. package/dist/bin/vip-app-deploy.js +7 -1
  5. package/dist/bin/vip-app.js +1 -1
  6. package/dist/bin/vip-dev-env-update.js +1 -5
  7. package/dist/commands/dev-env-import-sql.js +4 -1
  8. package/dist/commands/dev-env-sync-sql.js +3 -4
  9. package/dist/lib/analytics/clients/tracks.js +2 -2
  10. package/dist/lib/api/http.js +19 -14
  11. package/dist/lib/api.js +1 -2
  12. package/dist/lib/cli/command.js +37 -1
  13. package/dist/lib/cli/internal-bin-loader.js +2 -0
  14. package/dist/lib/client-file-uploader.js +33 -14
  15. package/dist/lib/custom-deploy/custom-deploy.js +37 -0
  16. package/dist/lib/dev-environment/dev-environment-core.js +5 -5
  17. package/dist/lib/dev-environment/dev-environment-lando.js +7 -3
  18. package/dist/lib/http/download-file.js +6 -1
  19. package/dist/lib/http/proxy-dispatcher.js +97 -0
  20. package/dist/lib/keychain/secure.js +4 -4
  21. package/dist/lib/keychain.js +1 -1
  22. package/dist/lib/media-import/status.js +6 -1
  23. package/dist/lib/retry.js +3 -2
  24. package/dist/lib/search-and-replace.js +3 -4
  25. package/dist/lib/validations/custom-deploy.js +60 -4
  26. package/dist/lib/validations/line-by-line.js +4 -9
  27. package/dist/lib/validations/sql.js +2 -2
  28. package/docs/COMMANDER-MIGRATION.md +1 -0
  29. package/docs/RELEASING.md +2 -0
  30. package/docs/TESTING.md +16 -1
  31. package/helpers/build-sea.js +2 -2
  32. package/helpers/release-smoke-test.js +869 -0
  33. package/npm-shrinkwrap.json +825 -1041
  34. package/package.json +22 -16
  35. package/test-utils/undici-mock.js +25 -0
@@ -0,0 +1,869 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Release Smoke Test Script
5
+ *
6
+ * Tests high-risk CLI parser/dispatch commands against built dist/bin binaries
7
+ * to catch regressions in option parsing, short-option equals syntax, and
8
+ * command routing before release.
9
+ *
10
+ * All tests are credential-free and use --help or other non-executing forms.
11
+ * Requires: npm run build (to generate dist/bin/*.js)
12
+ *
13
+ * Exit: 0 if all pass, non-zero if any fail
14
+ */
15
+
16
+ const { spawnSync } = require( 'node:child_process' );
17
+ const { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync } = require( 'node:fs' );
18
+ const { tmpdir } = require( 'node:os' );
19
+ const path = require( 'node:path' );
20
+
21
+ const projectRoot = path.resolve( __dirname, '..' );
22
+ const distBinDir = path.join( projectRoot, 'dist', 'bin' );
23
+ const nodeExec = process.execPath;
24
+
25
+ // Timeout for each test case (ms)
26
+ const TIMEOUT_MS = 5000;
27
+
28
+ const runtimeEnvKeys = [ 'PATH', 'SystemRoot', 'WINDIR', 'PATHEXT', 'COMSPEC' ];
29
+ const helpExpectation = {
30
+ exitCode: 0,
31
+ stdoutIncludes: 'Usage:',
32
+ };
33
+
34
+ /**
35
+ * Test result shape returned by validation and test functions
36
+ * @typedef {Object} TestResult
37
+ * @property {boolean} passed - Whether the validation or test passed
38
+ * @property {string} detail - Details of the result (error message or pass confirmation)
39
+ */
40
+
41
+ /**
42
+ * Test case definition
43
+ * @typedef {Object} TestCase
44
+ * @property {string} name - Human-readable test name
45
+ * @property {string} bin - Binary name (e.g., 'vip-logs.js')
46
+ * @property {string[]} [requiredChildBins] - Built child binaries that must be present before this case can run
47
+ * @property {string[]} args - Argument array (no shell interpolation)
48
+ * @property {Object} expectation - Expected outcome
49
+ * @property {number} [expectation.exitCode] - Expected exit code (default: 0)
50
+ * @property {string} [expectation.stdoutIncludes] - String that should appear in stdout
51
+ * @property {string} [expectation.stderrIncludes] - String that should appear in stderr
52
+ * @property {string} rationale - Why this test is safe and credential-free
53
+ */
54
+
55
+ /**
56
+ * @type {TestCase[]}
57
+ */
58
+ const testCases = [
59
+ // FR-2: vip logs high-risk shapes
60
+ {
61
+ name: 'vip logs: short-option equals syntax (-l=10)',
62
+ bin: 'vip-logs.js',
63
+ args: [ '-l=10', '--help' ],
64
+ expectation: helpExpectation,
65
+ rationale: 'Tests short-option equals parsing for -l (limit). --help prevents API calls/auth.',
66
+ },
67
+ {
68
+ name: 'vip logs: explicit default value match (--limit=500)',
69
+ bin: 'vip-logs.js',
70
+ args: [ '--limit=500', '--help' ],
71
+ expectation: helpExpectation,
72
+ rationale:
73
+ 'Tests explicit default value parsing. --help prevents API calls/auth; no credentials needed.',
74
+ },
75
+ {
76
+ name: 'vip logs: type option short syntax (-t=batch)',
77
+ bin: 'vip-logs.js',
78
+ args: [ '-t=batch', '--help' ],
79
+ expectation: helpExpectation,
80
+ rationale: 'Tests short-option equals for -t (type). --help is safe and non-executing.',
81
+ },
82
+
83
+ // FR-3: vip slowlogs high-risk shapes
84
+ {
85
+ name: 'vip slowlogs: short-option equals syntax (-l=50)',
86
+ bin: 'vip-slowlogs.js',
87
+ args: [ '-l=50', '--help' ],
88
+ expectation: helpExpectation,
89
+ rationale: 'Tests short-option equals parsing for -l (limit). --help prevents API calls/auth.',
90
+ },
91
+ {
92
+ name: 'vip slowlogs: explicit default value match (--limit=500)',
93
+ bin: 'vip-slowlogs.js',
94
+ args: [ '--limit=500', '--help' ],
95
+ expectation: helpExpectation,
96
+ rationale:
97
+ 'Tests explicit default value parsing. --help prevents API calls/auth; no credentials needed.',
98
+ },
99
+ {
100
+ name: 'vip slowlogs: format option short syntax (-f=json)',
101
+ bin: 'vip-slowlogs.js',
102
+ args: [ '-f=json', '--help' ],
103
+ expectation: helpExpectation,
104
+ rationale: 'Tests short-option equals for -f (format). --help is safe and non-executing.',
105
+ },
106
+
107
+ // FR-4: vip wp option-only and separator/subcommand shapes
108
+ {
109
+ name: 'vip wp: option-only help',
110
+ bin: 'vip-wp.js',
111
+ args: [ '--help' ],
112
+ expectation: helpExpectation,
113
+ rationale: 'Tests option-only path. --help bypasses auth/API/browser prompts.',
114
+ },
115
+ {
116
+ name: 'vip wp: yes option parsing',
117
+ bin: 'vip-wp.js',
118
+ args: [ '--yes', '--help' ],
119
+ expectation: helpExpectation,
120
+ rationale: 'Tests yes option parsing. --help prevents execution; credential-free.',
121
+ },
122
+ {
123
+ name: 'vip wp: help before separator (child parser)',
124
+ bin: 'vip-wp.js',
125
+ args: [ '--help', '--', 'option', 'get', 'home' ],
126
+ expectation: helpExpectation,
127
+ rationale:
128
+ 'Tests separator/subcommand parsing. --help before separator prevents execution; credential-free.',
129
+ },
130
+ {
131
+ name: 'vip wp: help before separator (root dispatcher)',
132
+ bin: 'vip.js',
133
+ requiredChildBins: [ 'vip-wp.js' ],
134
+ args: [ 'wp', '--help', '--', 'option', 'get', 'home' ],
135
+ expectation: helpExpectation,
136
+ rationale:
137
+ 'Tests root dispatcher with separator/subcommand. --help before separator prevents execution; credential-free.',
138
+ },
139
+ {
140
+ name: 'vip wp: missing separator guard (expected failure)',
141
+ bin: 'vip.js',
142
+ args: [ 'wp', 'post', 'list', '--help' ],
143
+ expectation: {
144
+ exitCode: 1,
145
+ stderrIncludes: 'A double dash',
146
+ },
147
+ rationale:
148
+ 'Tests separator guard without app/env context. Expected to fail with separator error; credential-free.',
149
+ },
150
+
151
+ // FR-5: vip dev-env shell parent option/dispatch
152
+ {
153
+ name: 'vip dev-env shell: help without env creation',
154
+ bin: 'vip-dev-env-shell.js',
155
+ args: [ '--help' ],
156
+ expectation: helpExpectation,
157
+ rationale: 'Tests parent command option parsing. --help prevents Docker/Lando/env creation.',
158
+ },
159
+ {
160
+ name: 'vip dev-env shell: slug option with help',
161
+ bin: 'vip-dev-env-shell.js',
162
+ args: [ '--slug=test-env', '--help' ],
163
+ expectation: helpExpectation,
164
+ rationale:
165
+ 'Tests slug option parsing. --help prevents env lookup/creation; no Docker/Lando needed.',
166
+ },
167
+ {
168
+ name: 'vip dev-env shell: root option with help',
169
+ bin: 'vip-dev-env-shell.js',
170
+ args: [ '--root', '--help' ],
171
+ expectation: helpExpectation,
172
+ rationale: 'Tests boolean root option. --help prevents shell creation; safe without Docker.',
173
+ },
174
+ ];
175
+
176
+ /**
177
+ * Extract error message from unknown caught value
178
+ * @param {unknown} err - The caught value
179
+ * @return {string} - Error message
180
+ */
181
+ function getErrorMessage( err ) {
182
+ if ( err instanceof Error ) {
183
+ return err.message;
184
+ }
185
+ if ( typeof err === 'object' && err !== null && 'message' in err ) {
186
+ const errWithMessage = /** @type {{ message: unknown }} */ ( err );
187
+ if ( typeof errWithMessage.message === 'string' ) {
188
+ return errWithMessage.message;
189
+ }
190
+ }
191
+ return String( err );
192
+ }
193
+
194
+ /**
195
+ * Copy only runtime variables required for process startup.
196
+ * @return {Object<string,string>}
197
+ */
198
+ function getRuntimeEnv() {
199
+ return runtimeEnvKeys.reduce( ( env, key ) => {
200
+ if ( process.env[ key ] !== undefined ) {
201
+ env[ key ] = process.env[ key ];
202
+ }
203
+
204
+ return env;
205
+ }, {} );
206
+ }
207
+
208
+ /**
209
+ * Create isolated home/profile/config directories for a single smoke test process.
210
+ * @return {{ root: string, env: Object<string,string> }}
211
+ */
212
+ function createCredentialScope() {
213
+ let root;
214
+
215
+ try {
216
+ root = mkdtempSync( path.join( tmpdir(), 'vip-cli-release-smoke-' ) );
217
+ const home = path.join( root, 'home' );
218
+ const appData = path.join( root, 'appdata' );
219
+ const localAppData = path.join( root, 'localappdata' );
220
+ const config = path.join( root, 'xdg-config' );
221
+ const temp = path.join( root, 'tmp' );
222
+
223
+ for ( const dirPath of [ home, appData, localAppData, config, temp ] ) {
224
+ mkdirSync( dirPath, { recursive: true } );
225
+ }
226
+
227
+ return {
228
+ root,
229
+ env: {
230
+ ...getRuntimeEnv(),
231
+ HOME: home,
232
+ USERPROFILE: home,
233
+ APPDATA: appData,
234
+ LOCALAPPDATA: localAppData,
235
+ XDG_CONFIG_HOME: config,
236
+ TMP: temp,
237
+ TEMP: temp,
238
+ NODE_ENV: 'test',
239
+ DO_NOT_TRACK: '1',
240
+ CI: 'true',
241
+ },
242
+ };
243
+ } catch ( err ) {
244
+ if ( root ) {
245
+ try {
246
+ cleanupCredentialScope( root );
247
+ } catch ( cleanupErr ) {
248
+ throw new Error(
249
+ `${ getErrorMessage( err ) }; cleanup failed: ${ getErrorMessage( cleanupErr ) }`
250
+ );
251
+ }
252
+ }
253
+
254
+ throw err;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Remove isolated credential directories.
260
+ * @param {string} root
261
+ */
262
+ function cleanupCredentialScope( root ) {
263
+ rmSync( root, { recursive: true, force: true } );
264
+ }
265
+
266
+ /**
267
+ * Format command string for logging without assuming args is an array
268
+ * @param {string} bin
269
+ * @param {*} args
270
+ * @return {string}
271
+ */
272
+ function formatCommandForLogging( bin, args ) {
273
+ if ( ! Array.isArray( args ) ) {
274
+ return `node ${ bin } [invalid args: ${ typeof args }]`;
275
+ }
276
+ const credentialFlagNames = new Set( [
277
+ '--token',
278
+ '--password',
279
+ '--secret',
280
+ '--key',
281
+ '--authorization',
282
+ '--cookie',
283
+ ] );
284
+ const isCredentialFlagArg = arg => {
285
+ if ( typeof arg !== 'string' ) {
286
+ return false;
287
+ }
288
+
289
+ const equalsIndex = arg.indexOf( '=' );
290
+ const flagName = equalsIndex === -1 ? arg : arg.slice( 0, equalsIndex );
291
+ return credentialFlagNames.has( flagName.toLowerCase() );
292
+ };
293
+ const formattedArgs = [];
294
+
295
+ for ( let index = 0; index < args.length; index++ ) {
296
+ const arg = args[ index ];
297
+ if ( typeof arg !== 'string' ) {
298
+ formattedArgs.push( `[invalid arg type: ${ typeof arg }]` );
299
+ continue;
300
+ }
301
+
302
+ const equalsIndex = arg.indexOf( '=' );
303
+ const flagName = equalsIndex === -1 ? arg : arg.slice( 0, equalsIndex );
304
+ const isCredentialFlag = credentialFlagNames.has( flagName.toLowerCase() );
305
+ if ( isCredentialFlag && equalsIndex !== -1 ) {
306
+ formattedArgs.push( `${ flagName }=[REDACTED]` );
307
+ continue;
308
+ }
309
+
310
+ formattedArgs.push( arg );
311
+ if ( isCredentialFlag && index + 1 < args.length ) {
312
+ // If the next token is itself a credential flag name (e.g. --token --password secret),
313
+ // we treat --token's position as a bare flag (no value to redact) and let --password
314
+ // be handled in the next iteration. This means the bare --token remains unredacted.
315
+ // In practice, valid tokens are never named after credential flags.
316
+ if ( isCredentialFlagArg( args[ index + 1 ] ) ) {
317
+ continue;
318
+ }
319
+
320
+ formattedArgs.push( '[REDACTED]' );
321
+ index++;
322
+ }
323
+ }
324
+
325
+ const argsStr = formattedArgs.join( ' ' );
326
+ return `node ${ bin } ${ argsStr }`;
327
+ }
328
+
329
+ /**
330
+ * Validate basic test case structure and required fields
331
+ * @param {*} testCase
332
+ * @return {TestResult|null} error result or null if valid
333
+ */
334
+ function validateTestCaseStructure( testCase ) {
335
+ // Guard: testCase must be a non-null object (not an array)
336
+ if ( ! testCase || typeof testCase !== 'object' || Array.isArray( testCase ) ) {
337
+ return {
338
+ passed: false,
339
+ detail: 'Invalid test case: testCase must be a non-null object',
340
+ };
341
+ }
342
+
343
+ // Validate name is a non-empty string
344
+ if ( typeof testCase.name !== 'string' || testCase.name === '' ) {
345
+ return {
346
+ passed: false,
347
+ detail: 'Invalid test case: name must be a non-empty string',
348
+ };
349
+ }
350
+
351
+ // Validate bin is a non-empty string
352
+ if ( typeof testCase.bin !== 'string' || ! testCase.bin ) {
353
+ return {
354
+ passed: false,
355
+ detail: 'Invalid test case: bin must be a non-empty string',
356
+ };
357
+ }
358
+
359
+ // Validate expectation exists and is an object
360
+ if (
361
+ ! testCase.expectation ||
362
+ typeof testCase.expectation !== 'object' ||
363
+ Array.isArray( testCase.expectation )
364
+ ) {
365
+ return {
366
+ passed: false,
367
+ detail: 'Invalid test case: expectation must be a non-null object',
368
+ };
369
+ }
370
+
371
+ // Validate rationale is a non-empty string
372
+ if ( typeof testCase.rationale !== 'string' || testCase.rationale === '' ) {
373
+ return {
374
+ passed: false,
375
+ detail: 'Invalid test case: rationale must be a non-empty string',
376
+ };
377
+ }
378
+
379
+ // Validate args is an array of strings
380
+ if ( ! Array.isArray( testCase.args ) ) {
381
+ return {
382
+ passed: false,
383
+ detail: 'Invalid test case: args must be an array',
384
+ };
385
+ }
386
+
387
+ for ( const arg of testCase.args ) {
388
+ if ( typeof arg !== 'string' ) {
389
+ return {
390
+ passed: false,
391
+ detail: `Invalid test case: all args must be strings, got: ${ typeof arg }`,
392
+ };
393
+ }
394
+ }
395
+
396
+ const childBinsError = validateRequiredChildBinsStructure( testCase.requiredChildBins );
397
+ if ( childBinsError ) {
398
+ return childBinsError;
399
+ }
400
+
401
+ return null;
402
+ }
403
+
404
+ /**
405
+ * Validate optional required child binary field shape
406
+ * @param {*} requiredChildBins
407
+ * @return {TestResult|null} error result or null if valid
408
+ */
409
+ function validateRequiredChildBinsStructure( requiredChildBins ) {
410
+ if ( requiredChildBins === undefined ) {
411
+ return null;
412
+ }
413
+
414
+ if ( ! Array.isArray( requiredChildBins ) ) {
415
+ return {
416
+ passed: false,
417
+ detail: 'Invalid test case: requiredChildBins must be an array if specified',
418
+ };
419
+ }
420
+
421
+ for ( const childBin of requiredChildBins ) {
422
+ if ( typeof childBin !== 'string' || childBin === '' ) {
423
+ return {
424
+ passed: false,
425
+ detail: 'Invalid test case: all requiredChildBins must be non-empty strings',
426
+ };
427
+ }
428
+ }
429
+
430
+ return null;
431
+ }
432
+
433
+ /**
434
+ * Validate binary path rules (basename only, .js extension)
435
+ * @param {string} bin
436
+ * @return {TestResult|null} error result or null if valid
437
+ */
438
+ function validateBinaryPathRules( bin ) {
439
+ // Validate bin is a basename-only .js file (no path separators)
440
+ if ( bin.includes( '/' ) || bin.includes( '\\' ) ) {
441
+ return {
442
+ passed: false,
443
+ detail: `Invalid test case: bin must be a basename-only file, got: ${ bin }`,
444
+ };
445
+ }
446
+
447
+ if ( ! bin.endsWith( '.js' ) ) {
448
+ return {
449
+ passed: false,
450
+ detail: `Invalid test case: bin must end with .js, got: ${ bin }`,
451
+ };
452
+ }
453
+
454
+ return null;
455
+ }
456
+
457
+ /**
458
+ * Validate safe flag requirement (credential-free/non-executing)
459
+ * @param {string[]} args
460
+ * @return {TestResult|null} error result or null if valid
461
+ */
462
+ function validateSafeFlag( args ) {
463
+ const safeFlags = [ '--help', '-h', '--version', '-v' ];
464
+ const separatorIndex = args.indexOf( '--' );
465
+ const argsBeforeSeparator = separatorIndex === -1 ? args : args.slice( 0, separatorIndex );
466
+ const hasSafeFlag = argsBeforeSeparator.some( arg => safeFlags.includes( arg ) );
467
+ if ( ! hasSafeFlag ) {
468
+ return {
469
+ passed: false,
470
+ detail: `Invalid test case: args must include a safe flag (${ safeFlags.join(
471
+ ', '
472
+ ) }) to prevent live execution`,
473
+ };
474
+ }
475
+
476
+ return null;
477
+ }
478
+
479
+ /**
480
+ * Validate expectation fields (extracted to reduce validateTestCase complexity)
481
+ * @param {*} expectation
482
+ * @return {TestResult|null} error result or null if valid
483
+ */
484
+ function validateExpectationFields( expectation ) {
485
+ // Validate stdoutIncludes is non-empty string if present
486
+ if (
487
+ expectation.stdoutIncludes !== undefined &&
488
+ ( typeof expectation.stdoutIncludes !== 'string' || expectation.stdoutIncludes === '' )
489
+ ) {
490
+ return {
491
+ passed: false,
492
+ detail: 'Invalid test case: stdoutIncludes must be a non-empty string if specified',
493
+ };
494
+ }
495
+
496
+ // Validate stderrIncludes is non-empty string if present
497
+ if (
498
+ expectation.stderrIncludes !== undefined &&
499
+ ( typeof expectation.stderrIncludes !== 'string' || expectation.stderrIncludes === '' )
500
+ ) {
501
+ return {
502
+ passed: false,
503
+ detail: 'Invalid test case: stderrIncludes must be a non-empty string if specified',
504
+ };
505
+ }
506
+
507
+ // Validate exitCode is an integer if present
508
+ if (
509
+ expectation.exitCode !== undefined &&
510
+ ( typeof expectation.exitCode !== 'number' || ! Number.isInteger( expectation.exitCode ) )
511
+ ) {
512
+ return {
513
+ passed: false,
514
+ detail: 'Invalid test case: exitCode must be an integer if specified',
515
+ };
516
+ }
517
+
518
+ return null;
519
+ }
520
+
521
+ /**
522
+ * Check if a filesystem entry exists, is not a symlink, and has the expected type.
523
+ * @param {string} entryPath
524
+ * @param {{ label: string, expectedType: 'file'|'directory' }} options
525
+ * @return {TestResult|null} error result or null if valid
526
+ */
527
+ function checkFileSystemEntry( entryPath, { label, expectedType } ) {
528
+ if ( ! existsSync( entryPath ) ) {
529
+ return {
530
+ passed: false,
531
+ detail: `${ label } not found: ${ entryPath }. Run 'npm run build' first.`,
532
+ };
533
+ }
534
+
535
+ try {
536
+ const lstats = lstatSync( entryPath );
537
+ if ( lstats.isSymbolicLink() ) {
538
+ return {
539
+ passed: false,
540
+ detail: `${ label } path is a symlink (rejected for security): ${ entryPath }`,
541
+ };
542
+ }
543
+
544
+ if ( expectedType === 'file' && ! lstats.isFile() ) {
545
+ return {
546
+ passed: false,
547
+ detail: `${ label } path exists but is not a regular file: ${ entryPath }`,
548
+ };
549
+ }
550
+
551
+ if ( expectedType === 'directory' && ! lstats.isDirectory() ) {
552
+ return {
553
+ passed: false,
554
+ detail: `${ label } path exists but is not a directory: ${ entryPath }`,
555
+ };
556
+ }
557
+ } catch ( err ) {
558
+ return {
559
+ passed: false,
560
+ detail: `Failed to stat ${ label.toLowerCase() }: ${ getErrorMessage( err ) }`,
561
+ };
562
+ }
563
+
564
+ return null;
565
+ }
566
+
567
+ /**
568
+ * Validate dist/bin before any binary path is discovered or executed.
569
+ * @return {TestResult|null} error result or null if valid
570
+ */
571
+ function checkDistBinDirectory() {
572
+ return checkFileSystemEntry( distBinDir, {
573
+ label: 'dist/bin directory',
574
+ expectedType: 'directory',
575
+ } );
576
+ }
577
+
578
+ /**
579
+ * Check if binary exists and is a regular file (rejects symlinks for defense-in-depth)
580
+ * @param {string} binPath
581
+ * @param {string} [label]
582
+ * @return {TestResult|null} error result or null if valid
583
+ */
584
+ function checkBinaryFileSystem( binPath, label = 'Binary' ) {
585
+ return checkFileSystemEntry( binPath, { label, expectedType: 'file' } );
586
+ }
587
+
588
+ /**
589
+ * Validate required built child binaries before root dispatch smoke cases run
590
+ * @param {string[]|undefined} requiredChildBins
591
+ * @return {TestResult|null} error result or null if valid
592
+ */
593
+ function validateRequiredChildBins( requiredChildBins ) {
594
+ if ( requiredChildBins === undefined ) {
595
+ return null;
596
+ }
597
+
598
+ for ( const childBin of requiredChildBins ) {
599
+ const binaryPathError = validateBinaryPathRules( childBin );
600
+ if ( binaryPathError ) {
601
+ return {
602
+ passed: false,
603
+ detail: binaryPathError.detail.replace( 'bin must', 'required child bin must' ),
604
+ };
605
+ }
606
+
607
+ const childBinPath = path.join( distBinDir, childBin );
608
+ const fileSystemError = checkBinaryFileSystem( childBinPath, 'Required child binary' );
609
+ if ( fileSystemError ) {
610
+ return fileSystemError;
611
+ }
612
+ }
613
+
614
+ return null;
615
+ }
616
+
617
+ /**
618
+ * Validate test case structure and binary path
619
+ * @param {TestCase} testCase
620
+ * @return {TestResult|null} error result or null if valid
621
+ */
622
+ function validateTestCase( testCase ) {
623
+ // Validate basic structure and required fields
624
+ const structureError = validateTestCaseStructure( testCase );
625
+ if ( structureError ) {
626
+ return structureError;
627
+ }
628
+
629
+ // Validate binary path rules
630
+ const binaryPathError = validateBinaryPathRules( testCase.bin );
631
+ if ( binaryPathError ) {
632
+ return binaryPathError;
633
+ }
634
+
635
+ // Validate safe flag requirement
636
+ const safeFlagError = validateSafeFlag( testCase.args );
637
+ if ( safeFlagError ) {
638
+ return safeFlagError;
639
+ }
640
+
641
+ // Validate expectation fields
642
+ const expectationError = validateExpectationFields( testCase.expectation );
643
+ if ( expectationError ) {
644
+ return expectationError;
645
+ }
646
+
647
+ const distBinError = checkDistBinDirectory();
648
+ if ( distBinError ) {
649
+ return distBinError;
650
+ }
651
+
652
+ // Check binary file system
653
+ const binPath = path.join( distBinDir, testCase.bin );
654
+ const fileSystemError = checkBinaryFileSystem( binPath );
655
+ if ( fileSystemError ) {
656
+ return fileSystemError;
657
+ }
658
+
659
+ const childBinsError = validateRequiredChildBins( testCase.requiredChildBins );
660
+ if ( childBinsError ) {
661
+ return childBinsError;
662
+ }
663
+
664
+ return null;
665
+ }
666
+
667
+ /**
668
+ * Evaluate spawnSync output against the test case expectation.
669
+ * @param {*} result
670
+ * @param {TestCase} testCase
671
+ * @return {TestResult}
672
+ */
673
+ function evaluateSpawnResult( result, testCase ) {
674
+ if ( result.error ) {
675
+ const spawnError = result.error;
676
+ if ( typeof spawnError === 'object' && spawnError !== null && 'code' in spawnError ) {
677
+ const errorWithCode = /** @type {{ code?: string, message: string }} */ ( spawnError );
678
+ if ( errorWithCode.code === 'ETIMEDOUT' ) {
679
+ return {
680
+ passed: false,
681
+ detail: `Timeout after ${ TIMEOUT_MS }ms`,
682
+ };
683
+ }
684
+ }
685
+
686
+ return {
687
+ passed: false,
688
+ detail: `Spawn error: ${ getErrorMessage( result.error ) }`,
689
+ };
690
+ }
691
+
692
+ if ( result.signal ) {
693
+ return {
694
+ passed: false,
695
+ detail: `Process killed by signal: ${ result.signal }`,
696
+ };
697
+ }
698
+
699
+ if ( result.status === null ) {
700
+ return {
701
+ passed: false,
702
+ detail: 'Process exit status is null (process may have failed to start)',
703
+ };
704
+ }
705
+
706
+ const expectedExitCode = testCase.expectation.exitCode ?? 0;
707
+ if ( result.status !== expectedExitCode ) {
708
+ return {
709
+ passed: false,
710
+ detail: `Exit code ${ result.status }, expected ${ expectedExitCode }`,
711
+ };
712
+ }
713
+
714
+ if (
715
+ testCase.expectation.stdoutIncludes &&
716
+ ! result.stdout.includes( testCase.expectation.stdoutIncludes )
717
+ ) {
718
+ return {
719
+ passed: false,
720
+ detail: `Stdout missing expected text: "${ testCase.expectation.stdoutIncludes }"`,
721
+ };
722
+ }
723
+
724
+ if (
725
+ testCase.expectation.stderrIncludes &&
726
+ ! result.stderr.includes( testCase.expectation.stderrIncludes )
727
+ ) {
728
+ return {
729
+ passed: false,
730
+ detail: `Stderr missing expected text: "${ testCase.expectation.stderrIncludes }"`,
731
+ };
732
+ }
733
+
734
+ return {
735
+ passed: true,
736
+ detail: 'PASS',
737
+ };
738
+ }
739
+
740
+ /**
741
+ * Run a single test case
742
+ * @param {TestCase} testCase
743
+ * @return {TestResult} result with passed and detail properties
744
+ */
745
+ function runTestCase( testCase ) {
746
+ const validationError = validateTestCase( testCase );
747
+ if ( validationError ) {
748
+ return validationError;
749
+ }
750
+
751
+ const binPath = path.join( distBinDir, testCase.bin );
752
+ let credentialScope;
753
+ let smokeResult;
754
+
755
+ try {
756
+ credentialScope = createCredentialScope();
757
+ } catch ( err ) {
758
+ return {
759
+ passed: false,
760
+ detail: `Failed to create credential scope: ${ getErrorMessage( err ) }`,
761
+ };
762
+ }
763
+
764
+ try {
765
+ const result = spawnSync( nodeExec, [ binPath, ...testCase.args ], {
766
+ cwd: projectRoot,
767
+ env: credentialScope.env,
768
+ timeout: TIMEOUT_MS,
769
+ encoding: 'utf8',
770
+ shell: false,
771
+ } );
772
+
773
+ smokeResult = evaluateSpawnResult( result, testCase );
774
+ } catch ( err ) {
775
+ smokeResult = {
776
+ passed: false,
777
+ detail: `Spawn error: ${ getErrorMessage( err ) }`,
778
+ };
779
+ }
780
+
781
+ try {
782
+ cleanupCredentialScope( credentialScope.root );
783
+ } catch ( err ) {
784
+ if ( ! smokeResult.passed ) {
785
+ return smokeResult;
786
+ }
787
+
788
+ return {
789
+ passed: false,
790
+ detail: `Failed to cleanup credential scope: ${ getErrorMessage( err ) }`,
791
+ };
792
+ }
793
+
794
+ return smokeResult;
795
+ }
796
+
797
+ /**
798
+ * Preflight check: dist/bin exists and is a directory
799
+ */
800
+ function preflightCheck() {
801
+ const distBinError = checkDistBinDirectory();
802
+ if ( distBinError ) {
803
+ console.error( 'ERROR: dist/bin directory is not usable.' );
804
+ console.error( 'Please run "npm run build" before running smoke tests.' );
805
+ console.error( `Expected directory: ${ distBinDir }` );
806
+ console.error( `Reason: ${ distBinError.detail }` );
807
+ return false;
808
+ }
809
+
810
+ return true;
811
+ }
812
+
813
+ /**
814
+ * Main execution
815
+ */
816
+ function main() {
817
+ console.log( '=== VIP CLI Release Smoke Test ===' );
818
+ console.log( `Node: ${ process.version }` );
819
+ console.log( `CWD: ${ projectRoot }` );
820
+ console.log( `Target: ${ distBinDir }` );
821
+ console.log( '' );
822
+
823
+ // Preflight
824
+ if ( ! preflightCheck() ) {
825
+ process.exit( 1 );
826
+ }
827
+
828
+ let passCount = 0;
829
+ let failCount = 0;
830
+
831
+ // Run all test cases
832
+ for ( const testCase of testCases ) {
833
+ const result = runTestCase( testCase );
834
+
835
+ if ( result.passed ) {
836
+ passCount++;
837
+ console.log( `[PASS] ${ testCase.name }` );
838
+ } else {
839
+ failCount++;
840
+ console.log( `[FAIL] ${ testCase.name }` );
841
+ console.log( ` Reason: ${ result.detail }` );
842
+ console.log( ` Command: ${ formatCommandForLogging( testCase.bin, testCase.args ) }` );
843
+ }
844
+ }
845
+
846
+ // Summary
847
+ console.log( '' );
848
+ console.log( '=== Summary ===' );
849
+ console.log( `Total: ${ testCases.length }` );
850
+ console.log( `Passed: ${ passCount }` );
851
+ console.log( `Failed: ${ failCount }` );
852
+ console.log( '' );
853
+
854
+ // Exit with appropriate code
855
+ if ( failCount > 0 ) {
856
+ console.error( 'Some tests failed. Please review and fix before releasing.' );
857
+ process.exit( 1 );
858
+ } else {
859
+ console.log( 'All tests passed!' );
860
+ process.exit( 0 );
861
+ }
862
+ }
863
+
864
+ // Execute if run directly
865
+ if ( require.main === module ) {
866
+ main();
867
+ }
868
+
869
+ module.exports = { runTestCase, testCases, formatCommandForLogging, preflightCheck };