mmt-testlight 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/cli.js +407 -0
- package/dist/cli.js.map +1 -0
- package/dist/loadTest.js +36 -0
- package/dist/loadTest.js.map +1 -0
- package/dist/mockRunner.js +189 -0
- package/dist/mockRunner.js.map +1 -0
- package/dist/runArgs.js +304 -0
- package/dist/runArgs.js.map +1 -0
- package/dist/runArgs.test.js +83 -0
- package/dist/runArgs.test.js.map +1 -0
- package/dist/runTest.js +187 -0
- package/dist/runTest.js.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist-cjs/cli.js +445 -0
- package/dist-cjs/cli.js.map +1 -0
- package/dist-cjs/loadTest.js +44 -0
- package/dist-cjs/loadTest.js.map +1 -0
- package/dist-cjs/mockRunner.js +229 -0
- package/dist-cjs/mockRunner.js.map +1 -0
- package/dist-cjs/runArgs.js +345 -0
- package/dist-cjs/runArgs.js.map +1 -0
- package/dist-cjs/types.js +3 -0
- package/dist-cjs/types.js.map +1 -0
- package/package.json +51 -0
- package/src/cli-bridge.mjs +41 -0
- package/src/cli.ts +450 -0
- package/src/loadTest.ts +38 -0
- package/src/mockRunner.ts +212 -0
- package/src/pkg-entry.cjs +1096 -0
- package/src/runArgs.ts +353 -0
- package/src/types.ts +11 -0
- package/tsconfig.cjs.json +13 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
// CommonJS entrypoint for pkg.
|
|
2
|
+
//
|
|
3
|
+
// This file is executed by pkg's CJS loader. To avoid ESM/CJS interop issues
|
|
4
|
+
// (and snapshot path quirks), we implement a minimal CLI here.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Walk up from startPath looking for multimeter.mmt.
|
|
11
|
+
* Returns the directory containing it, or undefined.
|
|
12
|
+
*/
|
|
13
|
+
function findProjectRootForPkg(startPath) {
|
|
14
|
+
let currentDir = path.dirname(startPath);
|
|
15
|
+
const visited = new Set();
|
|
16
|
+
while (currentDir && !visited.has(currentDir)) {
|
|
17
|
+
visited.add(currentDir);
|
|
18
|
+
const markerPath = path.join(currentDir, 'multimeter.mmt');
|
|
19
|
+
try {
|
|
20
|
+
if (fs.existsSync(markerPath)) {
|
|
21
|
+
return currentDir;
|
|
22
|
+
}
|
|
23
|
+
} catch { /* continue */ }
|
|
24
|
+
const parentDir = path.dirname(currentDir);
|
|
25
|
+
if (parentDir === currentDir || !parentDir) {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
currentDir = parentDir;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createPkgJsRunner() {
|
|
34
|
+
// In pkg, `mmt-core` resolves to the workspace path (`/snapshot/mmt/core/dist/index.js`).
|
|
35
|
+
// But `mmt-core/jsRunner` may fail because it tries `/snapshot/mmt/node_modules/...`.
|
|
36
|
+
// Load jsRunner relative to the resolved core dist entry instead.
|
|
37
|
+
// Infrastructure reality with pkg:
|
|
38
|
+
// - `require('mmt-core')` works (pkg bundles it as workspace `/snapshot/mmt/core/...`).
|
|
39
|
+
// - `require('mmt-core/jsRunner')` fails because pkg expects `/snapshot/mmt/node_modules/...`.
|
|
40
|
+
// To avoid brittle snapshot paths, implement the minimal `runJSCode` here.
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line global-require
|
|
43
|
+
const mmtCore = require('mmt-core');
|
|
44
|
+
// networkCore is not in the default export (browser-safe). Load it from the
|
|
45
|
+
// compiled core dist. Use a static string so pkg can resolve it at build time.
|
|
46
|
+
let networkCore;
|
|
47
|
+
try {
|
|
48
|
+
// eslint-disable-next-line global-require
|
|
49
|
+
networkCore = require('../../core/dist/networkCoreNode.js');
|
|
50
|
+
} catch {
|
|
51
|
+
try {
|
|
52
|
+
// eslint-disable-next-line global-require
|
|
53
|
+
networkCore = require('../../core/dist/networkCore.js');
|
|
54
|
+
} catch { /* give up */ }
|
|
55
|
+
}
|
|
56
|
+
const outputExtractor = mmtCore.outputExtractor;
|
|
57
|
+
const Random = mmtCore.Random;
|
|
58
|
+
const testHelper = mmtCore.testHelper;
|
|
59
|
+
|
|
60
|
+
if (!networkCore || !outputExtractor || !Random || !testHelper) {
|
|
61
|
+
return {
|
|
62
|
+
runJSCode: async ({ title, logger }) => {
|
|
63
|
+
logger('error', 'Packaged runner missing required mmt-core modules (networkCore/outputExtractor/Random/testHelper).');
|
|
64
|
+
logger('error', 'Fix: export these from `core/src/index.ts`, or avoid pkg for this build.');
|
|
65
|
+
throw new Error('Internal error: packaged JS runner is incomplete');
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
runJSCode: async ({ js: code, title, logger, fileLoader, serverRunner, reporter, runId, id, traceSend, abortSignal }) => {
|
|
72
|
+
const startTime = Date.now();
|
|
73
|
+
const lg = (level, msg) => logger(level, msg);
|
|
74
|
+
const customConsole = {
|
|
75
|
+
trace: (...args) => lg('trace', args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')),
|
|
76
|
+
debug: (...args) => lg('debug', args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')),
|
|
77
|
+
log: (...args) => lg('info', args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')),
|
|
78
|
+
warn: (...args) => lg('warn', args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')),
|
|
79
|
+
error: (...args) => lg('error', args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')),
|
|
80
|
+
};
|
|
81
|
+
// Set file loader, abort signal and server runner before executing
|
|
82
|
+
if (typeof testHelper.setFileLoader_ === 'function') {
|
|
83
|
+
testHelper.setFileLoader_(typeof fileLoader === 'function' ? fileLoader : undefined);
|
|
84
|
+
}
|
|
85
|
+
if (typeof testHelper.setAbortSignal_ === 'function') {
|
|
86
|
+
testHelper.setAbortSignal_(abortSignal);
|
|
87
|
+
}
|
|
88
|
+
if (typeof testHelper.setServerRunner_ === 'function') {
|
|
89
|
+
testHelper.setServerRunner_(serverRunner);
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const helperDecls = Object.keys(testHelper)
|
|
93
|
+
.filter((name) => name !== 'report_' && name !== 'setenv_')
|
|
94
|
+
.map((name) => `const ${name} = mmtHelper["${name}"];`)
|
|
95
|
+
.join('\n');
|
|
96
|
+
const randomDecls = Object.keys(Random)
|
|
97
|
+
.filter((name) => typeof Random[name] === 'function')
|
|
98
|
+
.map((name) => `const ${name} = Random["${name}"];`)
|
|
99
|
+
.join('\n');
|
|
100
|
+
|
|
101
|
+
const reporterFn = typeof reporter === 'function' ? reporter : () => {};
|
|
102
|
+
const mmtRandom = mmtCore.Random || {};
|
|
103
|
+
const mmtCurrent = mmtCore.Current || {};
|
|
104
|
+
|
|
105
|
+
const fn = new Function(
|
|
106
|
+
'mmtHelper',
|
|
107
|
+
'console',
|
|
108
|
+
'send_',
|
|
109
|
+
'extractOutputs_',
|
|
110
|
+
'Random',
|
|
111
|
+
'__reporter',
|
|
112
|
+
'__runId',
|
|
113
|
+
'__id',
|
|
114
|
+
'__mmt_random',
|
|
115
|
+
'__mmt_current',
|
|
116
|
+
`${helperDecls}\n${randomDecls}\n` +
|
|
117
|
+
`const report_ = (...args) => mmtHelper.reportWithContext_ ? mmtHelper.reportWithContext_(__reporter, __runId, __id, ...args) : undefined;\n` +
|
|
118
|
+
`const setenv_ = (name, value) => mmtHelper.setenvWithContext_ ? mmtHelper.setenvWithContext_(__reporter, __runId, __id, name, value) : undefined;\n` +
|
|
119
|
+
`${code}`,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Wrap send_ with trace-level logging when requested
|
|
123
|
+
const sendFn = traceSend ? async (req) => {
|
|
124
|
+
const reqSummary = req ? `${(req.method || 'GET').toUpperCase()} ${req.url || ''}` : 'unknown';
|
|
125
|
+
lg('trace', `Request: ${reqSummary}`);
|
|
126
|
+
try {
|
|
127
|
+
const res = await networkCore.send(req);
|
|
128
|
+
const status = res && typeof res.status === 'number' ? res.status : '?';
|
|
129
|
+
const duration = res && typeof res.duration === 'number' ? ` (${res.duration}ms)` : '';
|
|
130
|
+
lg('trace', `Response: ${status}${duration}`);
|
|
131
|
+
return res;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
lg('trace', `Response: error - ${err?.message || String(err)}`);
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
} : networkCore.send;
|
|
137
|
+
|
|
138
|
+
await fn(
|
|
139
|
+
testHelper,
|
|
140
|
+
customConsole,
|
|
141
|
+
sendFn,
|
|
142
|
+
outputExtractor.extractOutputs,
|
|
143
|
+
Random,
|
|
144
|
+
reporterFn,
|
|
145
|
+
runId || '',
|
|
146
|
+
id || '',
|
|
147
|
+
mmtRandom,
|
|
148
|
+
mmtCurrent,
|
|
149
|
+
);
|
|
150
|
+
} finally {
|
|
151
|
+
// Clean up
|
|
152
|
+
if (typeof testHelper.setServerRunner_ === 'function') {
|
|
153
|
+
testHelper.setServerRunner_(undefined);
|
|
154
|
+
}
|
|
155
|
+
if (typeof testHelper.setAbortSignal_ === 'function') {
|
|
156
|
+
testHelper.setAbortSignal_(undefined);
|
|
157
|
+
}
|
|
158
|
+
const elapsed = Date.now() - startTime;
|
|
159
|
+
lg('info', `Test ${title ? title + ' ' : ''}finished in ${elapsed} ms`);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
setRunnerNetworkConfig: networkCore.setRunnerNetworkConfig,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Create a server runner for pkg builds that starts mock servers from .mmt files.
|
|
168
|
+
*/
|
|
169
|
+
function createPkgServerRunner(envVars) {
|
|
170
|
+
const http = require('http');
|
|
171
|
+
const https = require('https');
|
|
172
|
+
const pathMod = require('path');
|
|
173
|
+
const yaml = require('js-yaml');
|
|
174
|
+
const mmtCore = require('mmt-core');
|
|
175
|
+
const mockParsePack = mmtCore.mockParsePack;
|
|
176
|
+
const mockServer = mmtCore.mockServer;
|
|
177
|
+
const variableReplacer = mmtCore.variableReplacer;
|
|
178
|
+
|
|
179
|
+
if (!mockParsePack || !mockServer || !variableReplacer) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const activeServers = new Map();
|
|
184
|
+
|
|
185
|
+
function resolveFilePath(relative, basePath) {
|
|
186
|
+
if (pathMod.isAbsolute(relative)) {
|
|
187
|
+
return relative;
|
|
188
|
+
}
|
|
189
|
+
return pathMod.resolve(pathMod.dirname(basePath), relative);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const serverRunner = async (alias, filePath) => {
|
|
193
|
+
// Stop existing server on this path if any
|
|
194
|
+
const existing = activeServers.get(filePath);
|
|
195
|
+
if (existing) {
|
|
196
|
+
existing.dispose();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
|
200
|
+
let parsed;
|
|
201
|
+
try {
|
|
202
|
+
parsed = yaml.load(rawContent);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
throw new Error(`Mock server: YAML parse error in ${pathMod.basename(filePath)}: ${err.message}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const result = mockParsePack.parseMockData(parsed);
|
|
208
|
+
if (result.errors.length > 0 || !result.data) {
|
|
209
|
+
const msg = result.errors.map(e => e.message).join('; ');
|
|
210
|
+
throw new Error(`Mock server validation errors in ${pathMod.basename(filePath)}: ${msg}`);
|
|
211
|
+
}
|
|
212
|
+
const data = result.data;
|
|
213
|
+
|
|
214
|
+
// Check if a server is already running on this port
|
|
215
|
+
for (const [, handle] of activeServers) {
|
|
216
|
+
if (handle.port === data.port) {
|
|
217
|
+
return () => {};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Create token resolver
|
|
222
|
+
const tokenResolver = (value) => {
|
|
223
|
+
variableReplacer.resetRandomTokenCache();
|
|
224
|
+
variableReplacer.resetCurrentTokenCache();
|
|
225
|
+
return variableReplacer.resolveEmbeddedTokens(value, envVars);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Resolve tokens in global headers
|
|
229
|
+
if (data.headers) {
|
|
230
|
+
for (const [k, v] of Object.entries(data.headers)) {
|
|
231
|
+
if (typeof v === 'string') {
|
|
232
|
+
data.headers[k] = String(variableReplacer.resolveEmbeddedTokens(v, envVars));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const router = mockServer.createMockRouter(data, tokenResolver);
|
|
238
|
+
|
|
239
|
+
const requestHandler = (req, res) => {
|
|
240
|
+
const method = (req.method || 'GET').toLowerCase();
|
|
241
|
+
const urlStr = req.url || '/';
|
|
242
|
+
|
|
243
|
+
if (data.cors) {
|
|
244
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
245
|
+
res.setHeader('Access-Control-Allow-Methods', '*');
|
|
246
|
+
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
247
|
+
if (method === 'options') {
|
|
248
|
+
res.statusCode = 204;
|
|
249
|
+
res.end();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let body = '';
|
|
255
|
+
req.on('data', chunk => { body += chunk; });
|
|
256
|
+
req.on('end', async () => {
|
|
257
|
+
let pathname = urlStr;
|
|
258
|
+
const queryObj = {};
|
|
259
|
+
const qIdx = urlStr.indexOf('?');
|
|
260
|
+
if (qIdx >= 0) {
|
|
261
|
+
pathname = urlStr.slice(0, qIdx);
|
|
262
|
+
const searchParams = new URLSearchParams(urlStr.slice(qIdx + 1));
|
|
263
|
+
searchParams.forEach((v, k) => { queryObj[k] = v; });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let parsedBody;
|
|
267
|
+
try {
|
|
268
|
+
parsedBody = JSON.parse(body);
|
|
269
|
+
} catch {
|
|
270
|
+
parsedBody = body || undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const mockReq = {
|
|
274
|
+
method,
|
|
275
|
+
path: pathname,
|
|
276
|
+
headers: req.headers || {},
|
|
277
|
+
query: queryObj,
|
|
278
|
+
body: parsedBody,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
let mockRes;
|
|
282
|
+
try {
|
|
283
|
+
mockRes = router(mockReq);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
res.statusCode = 500;
|
|
286
|
+
res.end(JSON.stringify({ error: 'Mock router error', message: err.message }));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (mockRes.delay && mockRes.delay > 0) {
|
|
291
|
+
await new Promise(resolve => setTimeout(resolve, mockRes.delay));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (mockRes.headers) {
|
|
295
|
+
for (const [k, v] of Object.entries(mockRes.headers)) {
|
|
296
|
+
if (typeof v === 'string') {
|
|
297
|
+
res.setHeader(k, String(variableReplacer.resolveEmbeddedTokens(v, envVars)));
|
|
298
|
+
} else {
|
|
299
|
+
res.setHeader(k, v);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
res.statusCode = mockRes.status;
|
|
305
|
+
const responseBody = mockRes.body !== undefined ? (
|
|
306
|
+
typeof mockRes.body === 'string' ? mockRes.body : JSON.stringify(mockRes.body)
|
|
307
|
+
) : '';
|
|
308
|
+
res.end(responseBody);
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
let server;
|
|
313
|
+
const protocol = data.protocol || 'http';
|
|
314
|
+
if (protocol === 'https' && data.tls) {
|
|
315
|
+
const certPath = resolveFilePath(data.tls.cert, filePath);
|
|
316
|
+
const keyPath = resolveFilePath(data.tls.key, filePath);
|
|
317
|
+
const tlsOptions = {
|
|
318
|
+
cert: fs.readFileSync(certPath),
|
|
319
|
+
key: fs.readFileSync(keyPath),
|
|
320
|
+
};
|
|
321
|
+
if (data.tls.ca) {
|
|
322
|
+
tlsOptions.ca = fs.readFileSync(resolveFilePath(data.tls.ca, filePath));
|
|
323
|
+
}
|
|
324
|
+
if (data.tls.requestCert) {
|
|
325
|
+
tlsOptions.requestCert = true;
|
|
326
|
+
tlsOptions.rejectUnauthorized = false;
|
|
327
|
+
}
|
|
328
|
+
server = https.createServer(tlsOptions, requestHandler);
|
|
329
|
+
} else {
|
|
330
|
+
server = http.createServer(requestHandler);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return new Promise((resolve, reject) => {
|
|
334
|
+
server.on('listening', () => {
|
|
335
|
+
const dispose = () => {
|
|
336
|
+
try { server.close(); } catch { /* ignore */ }
|
|
337
|
+
activeServers.delete(filePath);
|
|
338
|
+
};
|
|
339
|
+
activeServers.set(filePath, { server, port: data.port, dispose });
|
|
340
|
+
resolve(dispose);
|
|
341
|
+
});
|
|
342
|
+
server.on('error', (err) => {
|
|
343
|
+
activeServers.delete(filePath);
|
|
344
|
+
if (err.code === 'EADDRINUSE') {
|
|
345
|
+
reject(new Error(`Mock server: port ${data.port} is already in use.`));
|
|
346
|
+
} else {
|
|
347
|
+
reject(new Error(`Mock server error: ${err.message}`));
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
server.listen(data.port);
|
|
351
|
+
});
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const stopAll = () => {
|
|
355
|
+
for (const [, handle] of activeServers) {
|
|
356
|
+
handle.dispose();
|
|
357
|
+
}
|
|
358
|
+
activeServers.clear();
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
return { serverRunner, stopAll };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function printHelp() {
|
|
365
|
+
const out = [
|
|
366
|
+
'Usage: testlight [options] <command>',
|
|
367
|
+
'',
|
|
368
|
+
'Commands:',
|
|
369
|
+
' run <file> Run a .mmt file',
|
|
370
|
+
' print-js <file> Print generated JS for a test',
|
|
371
|
+
' debug-resolve Print pkg module resolution info',
|
|
372
|
+
'',
|
|
373
|
+
'Options:',
|
|
374
|
+
' -h, --help Show help',
|
|
375
|
+
' -v, --version Show version',
|
|
376
|
+
' --log-level <level> Set log level (error|warn|info|debug|trace)',
|
|
377
|
+
].join('\n');
|
|
378
|
+
process.stdout.write(out + '\n');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function printRunHelp() {
|
|
382
|
+
const out = [
|
|
383
|
+
'Usage: testlight run [options] <file>',
|
|
384
|
+
'',
|
|
385
|
+
'Run a .mmt file',
|
|
386
|
+
'',
|
|
387
|
+
'Options:',
|
|
388
|
+
' -h, --help Show help for run',
|
|
389
|
+
' -q, --quiet Minimal output',
|
|
390
|
+
' -o, --out <file> Write result JSON to file',
|
|
391
|
+
' -i, --input <k=v...> Input variables (repeatable; key=val or key val)',
|
|
392
|
+
' -e, --env <k=v...> Env variables (repeatable; key=val or key val)',
|
|
393
|
+
' --env-file <path> Env file (.mmt/.yaml) to read variables from',
|
|
394
|
+
' --preset <name> Preset name from env file (e.g. runner.dev)',
|
|
395
|
+
' --example <name|#n> Run a named example or index (#1 is first)',
|
|
396
|
+
' --print-js Print generated JS before executing',
|
|
397
|
+
' --debug-env Print resolved env vars (debug)',
|
|
398
|
+
' --log-level <level> Set log level (error|warn|info|debug|trace)',
|
|
399
|
+
].join('\n');
|
|
400
|
+
process.stdout.write(out + '\n');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function printPrintJsHelp() {
|
|
404
|
+
const out = [
|
|
405
|
+
'Usage: testlight print-js [options] <file>',
|
|
406
|
+
'',
|
|
407
|
+
'Convert a test definition file to executable JS and print to stdout',
|
|
408
|
+
'',
|
|
409
|
+
'Options:',
|
|
410
|
+
' -h, --help Show help for print-js',
|
|
411
|
+
' -i, --input <k=v...> Input variables (repeatable; key=val or key val)',
|
|
412
|
+
' -e, --env <k=v...> Env variables (repeatable; key=val or key val)',
|
|
413
|
+
' --env-file <path> Env file (.mmt/.yaml) to read variables from',
|
|
414
|
+
' --preset <name> Preset name from env file (e.g. runner.dev)',
|
|
415
|
+
' --example <name|#n> Select example before JS generation',
|
|
416
|
+
].join('\n');
|
|
417
|
+
process.stdout.write(out + '\n');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function coerceCliValue(v) {
|
|
421
|
+
const t = String(v ?? '').trim();
|
|
422
|
+
if (/^(true|false)$/i.test(t)) {
|
|
423
|
+
return /^true$/i.test(t);
|
|
424
|
+
}
|
|
425
|
+
if (/^[-+]?\d+$/.test(t)) {
|
|
426
|
+
return Number(t);
|
|
427
|
+
}
|
|
428
|
+
if (/^[-+]?\d*\.\d+$/.test(t)) {
|
|
429
|
+
return Number(t);
|
|
430
|
+
}
|
|
431
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
432
|
+
return t.slice(1, -1);
|
|
433
|
+
}
|
|
434
|
+
return t;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function parsePairs(tokens) {
|
|
438
|
+
const out = {};
|
|
439
|
+
const arr = Array.isArray(tokens) ? tokens : [];
|
|
440
|
+
for (let i = 0; i < arr.length; i++) {
|
|
441
|
+
const token = String(arr[i] ?? '');
|
|
442
|
+
const eq = token.indexOf('=');
|
|
443
|
+
if (eq > 0) {
|
|
444
|
+
const k = token.slice(0, eq).trim();
|
|
445
|
+
const v = token.slice(eq + 1);
|
|
446
|
+
if (k) {
|
|
447
|
+
out[k] = coerceCliValue(v);
|
|
448
|
+
}
|
|
449
|
+
} else if (i + 1 < arr.length) {
|
|
450
|
+
const k = token.trim();
|
|
451
|
+
const v = arr[++i];
|
|
452
|
+
if (k) {
|
|
453
|
+
out[k] = coerceCliValue(v);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function loadEnvDoc(envPath) {
|
|
461
|
+
try {
|
|
462
|
+
// eslint-disable-next-line global-require
|
|
463
|
+
const yaml = require('js-yaml');
|
|
464
|
+
const txt = fs.readFileSync(envPath, 'utf8');
|
|
465
|
+
const data = yaml.load(txt);
|
|
466
|
+
if (!data || typeof data !== 'object') {
|
|
467
|
+
return {};
|
|
468
|
+
}
|
|
469
|
+
if (data.type && String(data.type) !== 'env') {
|
|
470
|
+
return {};
|
|
471
|
+
}
|
|
472
|
+
return { variables: data.variables || {}, presets: data.presets || {} };
|
|
473
|
+
} catch {
|
|
474
|
+
return {};
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function parseExampleFlag(raw) {
|
|
479
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
480
|
+
return { exampleIndex: undefined, exampleName: undefined };
|
|
481
|
+
}
|
|
482
|
+
const trimmed = raw.trim();
|
|
483
|
+
const numeric = trimmed.match(/^#?(\d+)$/);
|
|
484
|
+
if (numeric) {
|
|
485
|
+
const parsed = Number(numeric[1]);
|
|
486
|
+
return { exampleIndex: parsed > 0 ? parsed - 1 : 0, exampleName: undefined };
|
|
487
|
+
}
|
|
488
|
+
return { exampleIndex: undefined, exampleName: trimmed };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function parseRunArgv(argv) {
|
|
492
|
+
// argv includes: ['run', ...]
|
|
493
|
+
const opts = {
|
|
494
|
+
input: [],
|
|
495
|
+
env: [],
|
|
496
|
+
envFile: undefined,
|
|
497
|
+
preset: undefined,
|
|
498
|
+
example: undefined,
|
|
499
|
+
printJs: false,
|
|
500
|
+
debugEnv: false,
|
|
501
|
+
quiet: false,
|
|
502
|
+
out: undefined,
|
|
503
|
+
logLevel: parseLogLevel(argv),
|
|
504
|
+
};
|
|
505
|
+
const consumed = new Set();
|
|
506
|
+
const consume = (i) => { consumed.add(i); };
|
|
507
|
+
for (let i = 1; i < argv.length; i++) {
|
|
508
|
+
if (consumed.has(i)) {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const a = argv[i];
|
|
512
|
+
if (!a) {
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (a === '-h' || a === '--help') {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (a === '--log-level') {
|
|
519
|
+
consume(i + 1);
|
|
520
|
+
i += 1;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (a === '-q' || a === '--quiet') {
|
|
524
|
+
opts.quiet = true;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (a === '-o' || a === '--out') {
|
|
528
|
+
consume(i + 1);
|
|
529
|
+
opts.out = argv[++i];
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (a === '--print-js') {
|
|
533
|
+
opts.printJs = true;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (a === '--debug-env') {
|
|
537
|
+
opts.debugEnv = true;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (a === '-i' || a === '--input') {
|
|
541
|
+
// Collect one or two tokens (key=val OR key val). Repeatable.
|
|
542
|
+
consume(i + 1);
|
|
543
|
+
const first = argv[++i];
|
|
544
|
+
if (first != null) {
|
|
545
|
+
opts.input.push(first);
|
|
546
|
+
if (!String(first).includes('=') && i + 1 < argv.length && !String(argv[i + 1]).startsWith('-')) {
|
|
547
|
+
consume(i + 1);
|
|
548
|
+
opts.input.push(argv[++i]);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (a === '-e' || a === '--env') {
|
|
554
|
+
consume(i + 1);
|
|
555
|
+
const first = argv[++i];
|
|
556
|
+
if (first != null) {
|
|
557
|
+
opts.env.push(first);
|
|
558
|
+
if (!String(first).includes('=') && i + 1 < argv.length && !String(argv[i + 1]).startsWith('-')) {
|
|
559
|
+
consume(i + 1);
|
|
560
|
+
opts.env.push(argv[++i]);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (a === '--env-file') {
|
|
566
|
+
consume(i + 1);
|
|
567
|
+
opts.envFile = argv[++i];
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (a === '--preset') {
|
|
571
|
+
consume(i + 1);
|
|
572
|
+
opts.preset = argv[++i];
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (a === '--example') {
|
|
576
|
+
consume(i + 1);
|
|
577
|
+
opts.example = argv[++i];
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (String(a).startsWith('-')) {
|
|
581
|
+
// Unknown flag; ignore for now.
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
let filePath;
|
|
586
|
+
for (let i = 1; i < argv.length; i++) {
|
|
587
|
+
if (consumed.has(i)) {
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const a = argv[i];
|
|
591
|
+
if (!a) {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (String(a).startsWith('-')) {
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
filePath = a;
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
return { filePath, opts };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function parsePrintJsArgv(argv) {
|
|
604
|
+
// argv includes: ['print-js', ...]
|
|
605
|
+
const opts = {
|
|
606
|
+
input: [],
|
|
607
|
+
env: [],
|
|
608
|
+
envFile: undefined,
|
|
609
|
+
preset: undefined,
|
|
610
|
+
example: undefined,
|
|
611
|
+
};
|
|
612
|
+
let filePath;
|
|
613
|
+
for (let i = 1; i < argv.length; i++) {
|
|
614
|
+
const a = argv[i];
|
|
615
|
+
if (!a) {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (a === '-h' || a === '--help') {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (a === '-i' || a === '--input') {
|
|
622
|
+
const first = argv[++i];
|
|
623
|
+
if (first != null) {
|
|
624
|
+
opts.input.push(first);
|
|
625
|
+
if (!String(first).includes('=') && i + 1 < argv.length && !String(argv[i + 1]).startsWith('-')) {
|
|
626
|
+
opts.input.push(argv[++i]);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (a === '-e' || a === '--env') {
|
|
632
|
+
const first = argv[++i];
|
|
633
|
+
if (first != null) {
|
|
634
|
+
opts.env.push(first);
|
|
635
|
+
if (!String(first).includes('=') && i + 1 < argv.length && !String(argv[i + 1]).startsWith('-')) {
|
|
636
|
+
opts.env.push(argv[++i]);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (a === '--env-file') {
|
|
642
|
+
opts.envFile = argv[++i];
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (a === '--preset') {
|
|
646
|
+
opts.preset = argv[++i];
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
if (a === '--example') {
|
|
650
|
+
opts.example = argv[++i];
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (String(a).startsWith('-')) {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (!filePath) {
|
|
657
|
+
filePath = a;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return { filePath, opts };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function parseLogLevel(argv) {
|
|
664
|
+
const idx = argv.indexOf('--log-level');
|
|
665
|
+
if (idx < 0) {
|
|
666
|
+
return 'info';
|
|
667
|
+
}
|
|
668
|
+
const v = argv[idx + 1];
|
|
669
|
+
if (!v) {
|
|
670
|
+
return 'info';
|
|
671
|
+
}
|
|
672
|
+
const level = String(v).toLowerCase();
|
|
673
|
+
if (['error', 'warn', 'info', 'debug', 'trace'].includes(level)) {
|
|
674
|
+
return level;
|
|
675
|
+
}
|
|
676
|
+
return 'info';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function createLevelLogger(minLevel) {
|
|
680
|
+
const order = { error: 0, warn: 1, info: 2, debug: 3, trace: 4 };
|
|
681
|
+
const min = order[minLevel] ?? order.info;
|
|
682
|
+
return (level, msg) => {
|
|
683
|
+
const lvl = order[level] ?? order.info;
|
|
684
|
+
if (lvl > min) {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
process.stdout.write(String(msg) + '\n');
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function main() {
|
|
692
|
+
const argv = process.argv.slice(2);
|
|
693
|
+
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
694
|
+
printHelp();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (argv.includes('-v') || argv.includes('--version')) {
|
|
699
|
+
// Best-effort: try reading packaged mmtcli package.json.
|
|
700
|
+
try {
|
|
701
|
+
// eslint-disable-next-line global-require
|
|
702
|
+
const pkgJson = require('./package.json');
|
|
703
|
+
process.stdout.write(String(pkgJson.version || '') + '\n');
|
|
704
|
+
} catch {
|
|
705
|
+
process.stdout.write('\n');
|
|
706
|
+
}
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const command = argv[0];
|
|
711
|
+
if (command === 'debug-resolve') {
|
|
712
|
+
const msgs = [];
|
|
713
|
+
msgs.push(`execPath: ${process.execPath}`);
|
|
714
|
+
try {
|
|
715
|
+
msgs.push(`mmt-core: ${require.resolve('mmt-core')}`);
|
|
716
|
+
} catch (e) {
|
|
717
|
+
msgs.push(`mmt-core: (not resolved) ${String(e && e.message ? e.message : e)}`);
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
msgs.push(`mmt-core/jsRunner: ${require.resolve('mmt-core/jsRunner')}`);
|
|
721
|
+
} catch (e) {
|
|
722
|
+
msgs.push(`mmt-core/jsRunner: (not resolved) ${String(e && e.message ? e.message : e)}`);
|
|
723
|
+
}
|
|
724
|
+
// Check networkCore resolution
|
|
725
|
+
const pathMod = require('path');
|
|
726
|
+
try {
|
|
727
|
+
const coreDir = pathMod.dirname(require.resolve('mmt-core'));
|
|
728
|
+
msgs.push(`mmt-core dir: ${coreDir}`);
|
|
729
|
+
const ncPath = pathMod.join(coreDir, 'networkCoreNode.js');
|
|
730
|
+
msgs.push(`networkCoreNode path: ${ncPath}`);
|
|
731
|
+
const nc = require(ncPath);
|
|
732
|
+
msgs.push(`networkCoreNode.send: ${typeof nc.send}`);
|
|
733
|
+
} catch (e) {
|
|
734
|
+
msgs.push(`networkCoreNode: (failed) ${String(e && e.message ? e.message : e)}`);
|
|
735
|
+
}
|
|
736
|
+
// Check jsRunner
|
|
737
|
+
const jsRunner = createPkgJsRunner();
|
|
738
|
+
msgs.push(`jsRunner.runJSCode: ${typeof jsRunner.runJSCode}`);
|
|
739
|
+
process.stdout.write(msgs.join('\n') + '\n');
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (command !== 'run' && command !== 'print-js') {
|
|
743
|
+
process.stderr.write(`Unknown command: ${command}\n`);
|
|
744
|
+
printHelp();
|
|
745
|
+
process.exitCode = 2;
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (command === 'run') {
|
|
750
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
751
|
+
printRunHelp();
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
} else if (command === 'print-js') {
|
|
755
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
756
|
+
printPrintJsHelp();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const logLevel = parseLogLevel(argv);
|
|
762
|
+
const levelLogger = createLevelLogger(logLevel);
|
|
763
|
+
|
|
764
|
+
const parsed = command === 'run' ? parseRunArgv(argv) : parsePrintJsArgv(argv);
|
|
765
|
+
let filePath = parsed.filePath;
|
|
766
|
+
if (!filePath) {
|
|
767
|
+
process.stderr.write('Missing <file> argument.\n');
|
|
768
|
+
(command === 'run' ? printRunHelp() : printPrintJsHelp());
|
|
769
|
+
process.exitCode = 2;
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
// Resolve to absolute path so that all subsequent relative path resolution
|
|
773
|
+
// produces absolute paths readable from disk (pkg can't read relative paths).
|
|
774
|
+
const pathMod = require('path');
|
|
775
|
+
filePath = pathMod.resolve(process.cwd(), filePath);
|
|
776
|
+
|
|
777
|
+
// Run directly using the shared core runner.
|
|
778
|
+
// eslint-disable-next-line global-require
|
|
779
|
+
const { runner } = require('mmt-core');
|
|
780
|
+
let JSer;
|
|
781
|
+
try {
|
|
782
|
+
// eslint-disable-next-line global-require
|
|
783
|
+
JSer = require('mmt-core/JSer');
|
|
784
|
+
} catch {
|
|
785
|
+
JSer = null;
|
|
786
|
+
}
|
|
787
|
+
const jsRunner = createPkgJsRunner();
|
|
788
|
+
|
|
789
|
+
const file = fs.readFileSync(filePath, 'utf8');
|
|
790
|
+
const baseDir = pathMod.dirname(filePath);
|
|
791
|
+
|
|
792
|
+
const manualInputs = parsePairs(parsed.opts.input);
|
|
793
|
+
const manualEnvvars = parsePairs(parsed.opts.env);
|
|
794
|
+
const { exampleIndex, exampleName } = parseExampleFlag(parsed.opts.example);
|
|
795
|
+
let envvar = undefined;
|
|
796
|
+
try {
|
|
797
|
+
const runConfig = require('mmt-core').runConfig;
|
|
798
|
+
if (runConfig && typeof runConfig.mergeEnv === 'function') {
|
|
799
|
+
if (parsed.opts.envFile) {
|
|
800
|
+
const envFileRaw = String(parsed.opts.envFile);
|
|
801
|
+
let p = pathMod.isAbsolute(envFileRaw) ? envFileRaw : pathMod.resolve(process.cwd(), envFileRaw);
|
|
802
|
+
if (!fs.existsSync(p)) {
|
|
803
|
+
const alt = pathMod.isAbsolute(envFileRaw) ? envFileRaw : pathMod.join(baseDir, envFileRaw);
|
|
804
|
+
if (fs.existsSync(alt)) {
|
|
805
|
+
p = alt;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const doc = loadEnvDoc(p);
|
|
809
|
+
let baseEnv = undefined;
|
|
810
|
+
if (runConfig && typeof runConfig.resolvePresetEnv === 'function') {
|
|
811
|
+
baseEnv = runConfig.resolvePresetEnv(doc, parsed.opts.preset);
|
|
812
|
+
} else if (doc && doc.variables) {
|
|
813
|
+
baseEnv = doc.variables;
|
|
814
|
+
}
|
|
815
|
+
envvar = runConfig.mergeEnv({ envvar: baseEnv, manualEnvvars });
|
|
816
|
+
if (command === 'run' && parsed.opts.debugEnv) {
|
|
817
|
+
try {
|
|
818
|
+
process.stdout.write(`debug envFile raw: ${envFileRaw}\n`);
|
|
819
|
+
process.stdout.write(`debug envFile resolved: ${p}\n`);
|
|
820
|
+
process.stdout.write(`debug preset: ${parsed.opts.preset || '(none)'}\n`);
|
|
821
|
+
process.stdout.write(`debug envDoc variables keys: ${doc && doc.variables ? Object.keys(doc.variables).join(',') : '(none)'}\n`);
|
|
822
|
+
process.stdout.write(`debug envDoc presets keys: ${doc && doc.presets ? Object.keys(doc.presets).join(',') : '(none)'}\n`);
|
|
823
|
+
process.stdout.write(`debug baseEnv keys: ${baseEnv ? Object.keys(baseEnv).join(',') : '(none)'}\n`);
|
|
824
|
+
} catch {
|
|
825
|
+
// ignore
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
envvar = runConfig.mergeEnv({ envvar: undefined, manualEnvvars });
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
} catch {
|
|
833
|
+
envvar = manualEnvvars && Object.keys(manualEnvvars).length ? manualEnvvars : undefined;
|
|
834
|
+
}
|
|
835
|
+
if (command === 'run' && parsed.opts.debugEnv) {
|
|
836
|
+
try {
|
|
837
|
+
process.stdout.write(`debug envvar keys: ${envvar ? Object.keys(envvar).join(',') : '(none)'}\n`);
|
|
838
|
+
} catch {
|
|
839
|
+
process.stdout.write('debug envvar keys: (error)\n');
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// (Keep docType debug off by default; use `debug-resolve` for pkg debugging.)
|
|
843
|
+
if (command === 'print-js') {
|
|
844
|
+
const js = await runner.generateTestJs({
|
|
845
|
+
rawText: file,
|
|
846
|
+
name: pathMod.basename(filePath).replace(/[^a-zA-Z0-9_]/g, '_'),
|
|
847
|
+
inputs: manualInputs,
|
|
848
|
+
envVars: envvar || {},
|
|
849
|
+
fileLoader: async (p) => fs.promises.readFile(p, 'utf8'),
|
|
850
|
+
});
|
|
851
|
+
process.stdout.write(js.trim() + '\n');
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
let res;
|
|
856
|
+
// Create server runner for mock servers
|
|
857
|
+
const mockRunnerInstance = createPkgServerRunner(envvar || {});
|
|
858
|
+
const pkgServerRunner = mockRunnerInstance ? mockRunnerInstance.serverRunner : undefined;
|
|
859
|
+
|
|
860
|
+
// Detect if running a suite file and build suite bundle for proper export handling
|
|
861
|
+
let suiteBundleArg;
|
|
862
|
+
let mergedEnvvar = envvar; // Default to CLI-resolved envvar
|
|
863
|
+
const yaml = require('js-yaml');
|
|
864
|
+
try {
|
|
865
|
+
const parsedYaml = yaml.load(file);
|
|
866
|
+
if (parsedYaml && parsedYaml.type === 'suite') {
|
|
867
|
+
const mmtCore = require('mmt-core');
|
|
868
|
+
const { suiteHierarchy, suiteBundle, runConfig: coreRunConfig } = mmtCore;
|
|
869
|
+
if (suiteHierarchy && suiteBundle) {
|
|
870
|
+
const fileLoader = async (p) => fs.promises.readFile(p, 'utf8');
|
|
871
|
+
const tree = await suiteHierarchy.buildSuiteHierarchyFromSuiteFile({
|
|
872
|
+
suiteFilePath: filePath,
|
|
873
|
+
suiteRawText: file,
|
|
874
|
+
fileLoader,
|
|
875
|
+
});
|
|
876
|
+
suiteBundleArg = suiteBundle.createSuiteBundle({
|
|
877
|
+
rootSuitePath: filePath,
|
|
878
|
+
hierarchy: tree,
|
|
879
|
+
servers: tree.servers,
|
|
880
|
+
environment: tree.environment,
|
|
881
|
+
export: tree.export,
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// Merge suite environment with CLI env vars
|
|
885
|
+
// Priority: CLI -e > suite variables > suite preset > CLI --env-file/--preset
|
|
886
|
+
if (tree.environment && coreRunConfig && typeof coreRunConfig.mergeSuiteEnv === 'function') {
|
|
887
|
+
let suitePresetEnv = {};
|
|
888
|
+
const suiteEnv = tree.environment;
|
|
889
|
+
|
|
890
|
+
// Resolve suite preset from env file
|
|
891
|
+
if (suiteEnv.preset) {
|
|
892
|
+
let envFileForPreset = null;
|
|
893
|
+
if (suiteEnv.file) {
|
|
894
|
+
// Suite specifies its own env file
|
|
895
|
+
const suiteEnvFile = suiteEnv.file.startsWith('+/')
|
|
896
|
+
? pathMod.resolve(baseDir, suiteEnv.file.slice(2))
|
|
897
|
+
: pathMod.resolve(baseDir, suiteEnv.file);
|
|
898
|
+
if (fs.existsSync(suiteEnvFile)) {
|
|
899
|
+
envFileForPreset = loadEnvDoc(suiteEnvFile);
|
|
900
|
+
}
|
|
901
|
+
} else if (parsed.opts.envFile) {
|
|
902
|
+
// Use CLI --env-file for preset resolution
|
|
903
|
+
const envFileRaw = String(parsed.opts.envFile);
|
|
904
|
+
let p = pathMod.isAbsolute(envFileRaw) ? envFileRaw : pathMod.resolve(process.cwd(), envFileRaw);
|
|
905
|
+
if (!fs.existsSync(p)) {
|
|
906
|
+
const alt = pathMod.isAbsolute(envFileRaw) ? envFileRaw : pathMod.join(baseDir, envFileRaw);
|
|
907
|
+
if (fs.existsSync(alt)) {
|
|
908
|
+
p = alt;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (fs.existsSync(p)) {
|
|
912
|
+
envFileForPreset = loadEnvDoc(p);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (envFileForPreset && coreRunConfig.resolvePresetEnv) {
|
|
916
|
+
suitePresetEnv = coreRunConfig.resolvePresetEnv(envFileForPreset, suiteEnv.preset) || {};
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
mergedEnvvar = coreRunConfig.mergeSuiteEnv({
|
|
921
|
+
baseEnv: envvar || {},
|
|
922
|
+
suiteEnv: suiteEnv,
|
|
923
|
+
suitePresetEnv: suitePresetEnv,
|
|
924
|
+
manualEnvvars: manualEnvvars || {},
|
|
925
|
+
cliOverridesSuiteEnv: true, // CLI -e takes precedence
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
if (command === 'run' && parsed.opts.debugEnv) {
|
|
929
|
+
process.stdout.write(`debug suite environment: ${JSON.stringify(suiteEnv)}\n`);
|
|
930
|
+
process.stdout.write(`debug suite preset env keys: ${Object.keys(suitePresetEnv).join(',') || '(none)'}\n`);
|
|
931
|
+
process.stdout.write(`debug merged envvar keys: ${Object.keys(mergedEnvvar).join(',') || '(none)'}\n`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
} catch (e) {
|
|
937
|
+
// If YAML parsing fails or suite bundle creation fails, continue without bundle
|
|
938
|
+
// The runner will still attempt to run the file and report appropriate errors
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Update mockRunnerInstance with merged env vars for suite runs
|
|
942
|
+
if (suiteBundleArg && mergedEnvvar !== envvar) {
|
|
943
|
+
// Re-create server runner with merged env vars
|
|
944
|
+
const mergedMockRunnerInstance = createPkgServerRunner(mergedEnvvar || {});
|
|
945
|
+
if (mergedMockRunnerInstance) {
|
|
946
|
+
// Stop original mock runner to avoid conflicts
|
|
947
|
+
if (mockRunnerInstance) {
|
|
948
|
+
mockRunnerInstance.stopAll();
|
|
949
|
+
}
|
|
950
|
+
// Use the merged one for suite execution
|
|
951
|
+
Object.assign(mockRunnerInstance || {}, { serverRunner: mergedMockRunnerInstance.serverRunner, stopAll: mergedMockRunnerInstance.stopAll });
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const projectRoot = findProjectRootForPkg(filePath);
|
|
956
|
+
|
|
957
|
+
try {
|
|
958
|
+
res = await runner.runFile({
|
|
959
|
+
file,
|
|
960
|
+
fileType: 'raw',
|
|
961
|
+
filePath,
|
|
962
|
+
projectRoot,
|
|
963
|
+
inputs: { type: 'manual', manualInputs },
|
|
964
|
+
envvar: mergedEnvvar,
|
|
965
|
+
manualInputs,
|
|
966
|
+
manualEnvvars,
|
|
967
|
+
exampleIndex,
|
|
968
|
+
exampleName,
|
|
969
|
+
fileLoader: async (p) => fs.promises.readFile(p, 'utf8'),
|
|
970
|
+
jsRunner: (ctx) => jsRunner.runJSCode({ ...ctx, serverRunner: pkgServerRunner }),
|
|
971
|
+
logger: (lvl, msg) => levelLogger(lvl, msg),
|
|
972
|
+
serverRunner: pkgServerRunner,
|
|
973
|
+
suiteBundle: suiteBundleArg,
|
|
974
|
+
});
|
|
975
|
+
} catch (e) {
|
|
976
|
+
process.stderr.write(`runner.runFile threw: ${String(e && e.message ? e.message : e)}\n`);
|
|
977
|
+
throw e;
|
|
978
|
+
} finally {
|
|
979
|
+
// Always stop mock servers after run completes
|
|
980
|
+
if (mockRunnerInstance) {
|
|
981
|
+
mockRunnerInstance.stopAll();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (parsed.opts.debugEnv) {
|
|
985
|
+
try {
|
|
986
|
+
process.stdout.write(`debug envVarsUsed: ${JSON.stringify(res.envVarsUsed || {})}\n`);
|
|
987
|
+
} catch {
|
|
988
|
+
process.stdout.write('debug envVarsUsed: (error)\n');
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (parsed.opts.printJs && res && typeof res.js === 'string' && res.js.trim()) {
|
|
993
|
+
process.stdout.write(res.js.trim() + '\n');
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (!parsed.opts.quiet) {
|
|
997
|
+
process.stdout.write(`Loaded: ${filePath}\n`);
|
|
998
|
+
process.stdout.write(`Success: ${String(res.result?.success)}\n`);
|
|
999
|
+
if (Array.isArray(res.result?.errors) && res.result.errors.length) {
|
|
1000
|
+
process.stdout.write('Errors:\n');
|
|
1001
|
+
for (const err of res.result.errors) {
|
|
1002
|
+
process.stdout.write(`- ${String(err)}\n`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (parsed.opts.out) {
|
|
1008
|
+
const outPath = pathMod.isAbsolute(String(parsed.opts.out)) ? String(parsed.opts.out) : pathMod.join(process.cwd(), String(parsed.opts.out));
|
|
1009
|
+
fs.writeFileSync(outPath, JSON.stringify(res.result, null, 2), 'utf8');
|
|
1010
|
+
if (!parsed.opts.quiet) {
|
|
1011
|
+
process.stdout.write(`Result written: ${outPath}\n`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Handle suite exports (from suite file's export: field)
|
|
1016
|
+
const suiteExports = res.suiteExports;
|
|
1017
|
+
if (suiteExports && Array.isArray(suiteExports.paths) && suiteExports.collectedResults) {
|
|
1018
|
+
const suiteDir = pathMod.dirname(filePath);
|
|
1019
|
+
// eslint-disable-next-line global-require
|
|
1020
|
+
const mmtCore = require('mmt-core');
|
|
1021
|
+
|
|
1022
|
+
for (const exportPath of suiteExports.paths) {
|
|
1023
|
+
try {
|
|
1024
|
+
// Resolve path relative to suite file or +/ project root
|
|
1025
|
+
let resolvedPath;
|
|
1026
|
+
if (exportPath.startsWith('+/')) {
|
|
1027
|
+
// Project root not supported in standalone binary; skip
|
|
1028
|
+
if (!parsed.opts.quiet) {
|
|
1029
|
+
process.stderr.write(`Cannot resolve +/ path in standalone binary: ${exportPath}\n`);
|
|
1030
|
+
}
|
|
1031
|
+
continue;
|
|
1032
|
+
} else {
|
|
1033
|
+
resolvedPath = pathMod.resolve(suiteDir, exportPath);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Determine format from extension
|
|
1037
|
+
const ext = pathMod.extname(resolvedPath).toLowerCase();
|
|
1038
|
+
const formatForExt = {
|
|
1039
|
+
'.xml': 'junit',
|
|
1040
|
+
'.html': 'html',
|
|
1041
|
+
'.md': 'md',
|
|
1042
|
+
'.mmt': 'mmt',
|
|
1043
|
+
};
|
|
1044
|
+
const format = formatForExt[ext];
|
|
1045
|
+
if (!format) {
|
|
1046
|
+
if (!parsed.opts.quiet) {
|
|
1047
|
+
process.stderr.write(`Unknown export format for extension ${ext}: ${exportPath}\n`);
|
|
1048
|
+
}
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Generate report content
|
|
1053
|
+
const exportSerializers = {
|
|
1054
|
+
junit: mmtCore.junitXml?.generateJunitXml,
|
|
1055
|
+
mmt: mmtCore.mmtReport?.generateMmtReport,
|
|
1056
|
+
html: mmtCore.reportHtml?.generateReportHtml,
|
|
1057
|
+
md: mmtCore.reportMarkdown?.generateReportMarkdown,
|
|
1058
|
+
};
|
|
1059
|
+
const serializer = exportSerializers[format];
|
|
1060
|
+
if (typeof serializer !== 'function') {
|
|
1061
|
+
if (!parsed.opts.quiet) {
|
|
1062
|
+
process.stderr.write(`Export serializer not available for format: ${format}\n`);
|
|
1063
|
+
}
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (!parsed.opts.quiet) {
|
|
1068
|
+
process.stdout.write(`Exporting results to ${resolvedPath}\n`);
|
|
1069
|
+
}
|
|
1070
|
+
const content = serializer(suiteExports.collectedResults);
|
|
1071
|
+
|
|
1072
|
+
// Create parent directories if they don't exist
|
|
1073
|
+
const parentDir = pathMod.dirname(resolvedPath);
|
|
1074
|
+
if (!fs.existsSync(parentDir)) {
|
|
1075
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
fs.writeFileSync(resolvedPath, content, 'utf8');
|
|
1079
|
+
if (!parsed.opts.quiet) {
|
|
1080
|
+
process.stdout.write(`Suite export written: ${resolvedPath}\n`);
|
|
1081
|
+
}
|
|
1082
|
+
} catch (e) {
|
|
1083
|
+
if (!parsed.opts.quiet) {
|
|
1084
|
+
process.stderr.write(`Failed to write suite export ${exportPath}: ${e?.message || e}\n`);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
process.exitCode = res.result?.success ? 0 : 1;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
main().catch((e) => {
|
|
1094
|
+
process.stderr.write(String(e?.stack || e) + '\n');
|
|
1095
|
+
process.exitCode = 1;
|
|
1096
|
+
});
|