@automattic/vip 4.0.3 → 4.0.6
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/AGENTS.md +2 -0
- package/dist/bin/vip-app-deploy-token-generate.js +102 -0
- package/dist/bin/vip-app-deploy-token.js +20 -0
- package/dist/bin/vip-app-deploy.js +7 -1
- package/dist/bin/vip-app.js +1 -1
- package/dist/bin/vip-dev-env-update.js +1 -5
- package/dist/commands/dev-env-import-sql.js +4 -1
- package/dist/commands/dev-env-sync-sql.js +3 -4
- package/dist/lib/analytics/clients/tracks.js +2 -2
- package/dist/lib/api/http.js +19 -14
- package/dist/lib/api.js +1 -2
- package/dist/lib/cli/command.js +37 -1
- package/dist/lib/cli/internal-bin-loader.js +2 -0
- package/dist/lib/client-file-uploader.js +110 -52
- package/dist/lib/custom-deploy/custom-deploy.js +37 -0
- package/dist/lib/dev-environment/dev-environment-core.js +5 -5
- package/dist/lib/dev-environment/dev-environment-lando.js +7 -3
- package/dist/lib/http/download-file.js +6 -1
- package/dist/lib/http/proxy-dispatcher.js +97 -0
- package/dist/lib/keychain/secure.js +4 -4
- package/dist/lib/keychain.js +1 -1
- package/dist/lib/media-import/status.js +6 -1
- package/dist/lib/retry.js +3 -2
- package/dist/lib/search-and-replace.js +3 -4
- package/dist/lib/validations/custom-deploy.js +60 -4
- package/dist/lib/validations/line-by-line.js +4 -9
- package/dist/lib/validations/sql.js +2 -2
- package/docs/COMMANDER-MIGRATION.md +1 -0
- package/docs/RELEASING.md +2 -0
- package/docs/TESTING.md +16 -1
- package/helpers/build-sea.js +2 -2
- package/helpers/release-smoke-test.js +869 -0
- package/npm-shrinkwrap.json +873 -1043
- package/package.json +23 -17
- 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 };
|