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
package/src/runArgs.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import {findProjectRootSync} from 'mmt-core/fileHelper';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import * as mmtcore from 'mmt-core';
|
|
5
|
+
import type {RunFileOptions, RunReporterMessage} from 'mmt-core/runConfig';
|
|
6
|
+
import type {NetworkConfig, EnvCertificateSettings} from 'mmt-core/NetworkData';
|
|
7
|
+
import {DEFAULT_NETWORK_CONFIG, resolvePassphrase} from 'mmt-core/NetworkData';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
export type ReportFormat = 'junit' | 'mmt' | 'html' | 'md';
|
|
11
|
+
|
|
12
|
+
const {mergeEnv, resolvePresetEnv, resolveEnvFromDoc} =
|
|
13
|
+
((mmtcore as any).runConfig || {}) as any;
|
|
14
|
+
|
|
15
|
+
type AnyOpts = Record<string, any>;
|
|
16
|
+
|
|
17
|
+
function coerceCliValue(v: string): any {
|
|
18
|
+
const t = (v ?? '').trim();
|
|
19
|
+
if (/^(true|false)$/i.test(t)) {
|
|
20
|
+
return /^true$/i.test(t);
|
|
21
|
+
}
|
|
22
|
+
if (/^[-+]?\d+$/.test(t)) {
|
|
23
|
+
return Number(t);
|
|
24
|
+
}
|
|
25
|
+
if (/^[-+]?\d*\.\d+$/.test(t)) {
|
|
26
|
+
return Number(t);
|
|
27
|
+
}
|
|
28
|
+
if ((t.startsWith('"') && t.endsWith('"')) ||
|
|
29
|
+
(t.startsWith('\'') && t.endsWith('\''))) {
|
|
30
|
+
return t.slice(1, -1);
|
|
31
|
+
}
|
|
32
|
+
return t;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parsePairs(list: string[]|undefined): Record<string, any> {
|
|
36
|
+
const out: Record<string, any> = {};
|
|
37
|
+
const arr = Array.isArray(list) ? list : [];
|
|
38
|
+
for (let i = 0; i < arr.length; i++) {
|
|
39
|
+
const token = arr[i] ?? '';
|
|
40
|
+
const eq = token.indexOf('=');
|
|
41
|
+
if (eq > 0) {
|
|
42
|
+
const k = token.slice(0, eq).trim();
|
|
43
|
+
const v = token.slice(eq + 1);
|
|
44
|
+
if (k) {
|
|
45
|
+
out[k] = coerceCliValue(v);
|
|
46
|
+
}
|
|
47
|
+
} else if (i + 1 < arr.length) {
|
|
48
|
+
const k = token.trim();
|
|
49
|
+
const v = arr[++i];
|
|
50
|
+
if (k) {
|
|
51
|
+
out[k] = coerceCliValue(v);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface EnvDocResult {
|
|
59
|
+
variables?: Record<string, any>;
|
|
60
|
+
presets?: Record<string, any>;
|
|
61
|
+
certificates?: EnvCertificateSettings;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function loadEnvDoc(envPath: string): EnvDocResult {
|
|
65
|
+
try {
|
|
66
|
+
const txt = fs.readFileSync(envPath, 'utf8');
|
|
67
|
+
const data = yaml.load(txt) as any;
|
|
68
|
+
if (!data || typeof data !== 'object') {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
if (data.type && String(data.type) !== 'env') {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
variables: data.variables || {},
|
|
76
|
+
presets: data.presets || {},
|
|
77
|
+
certificates: data.certificates || undefined,
|
|
78
|
+
};
|
|
79
|
+
} catch {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Resolve certificate path relative to env file directory
|
|
85
|
+
function resolveCertPath(certPath: string, envFileDir: string): string {
|
|
86
|
+
if (!certPath) {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
if (path.isAbsolute(certPath)) {
|
|
90
|
+
return certPath;
|
|
91
|
+
}
|
|
92
|
+
return path.resolve(envFileDir, certPath);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Build NetworkConfig from certificate settings in env file
|
|
96
|
+
// Note: Boolean settings (sslValidation, allowSelfSigned, enabled) default to sensible values
|
|
97
|
+
// since they are not stored in YAML
|
|
98
|
+
export function buildNetworkConfigFromEnv(
|
|
99
|
+
certSettings: EnvCertificateSettings | undefined,
|
|
100
|
+
envFileDir: string,
|
|
101
|
+
envVars?: Record<string, any>): NetworkConfig {
|
|
102
|
+
if (!certSettings) {
|
|
103
|
+
return {...DEFAULT_NETWORK_CONFIG};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Load CA certs (multiple paths)
|
|
107
|
+
const caCertDataList: Buffer[] = [];
|
|
108
|
+
const caPaths = certSettings.ca?.paths || [];
|
|
109
|
+
// CA is enabled if there are paths defined
|
|
110
|
+
const caEnabled = caPaths.length > 0;
|
|
111
|
+
for (const caPath of caPaths) {
|
|
112
|
+
if (caPath) {
|
|
113
|
+
try {
|
|
114
|
+
const resolvedPath = resolveCertPath(caPath, envFileDir);
|
|
115
|
+
caCertDataList.push(fs.readFileSync(resolvedPath));
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.warn(`Failed to load CA certificate from ${caPath}: ${e}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Load client certs (use snake_case fields from YAML)
|
|
123
|
+
const clients = (certSettings.clients || []).map((client, idx) => {
|
|
124
|
+
let certData: Buffer | undefined = undefined;
|
|
125
|
+
let keyData: Buffer | undefined = undefined;
|
|
126
|
+
// Client is enabled by default (boolean not in YAML)
|
|
127
|
+
const clientEnabled = true;
|
|
128
|
+
const certPath = client.cert_path || '';
|
|
129
|
+
const keyPath = client.key_path || '';
|
|
130
|
+
if (certPath && keyPath) {
|
|
131
|
+
try {
|
|
132
|
+
const certResolvedPath = resolveCertPath(certPath, envFileDir);
|
|
133
|
+
const keyResolvedPath = resolveCertPath(keyPath, envFileDir);
|
|
134
|
+
certData = fs.readFileSync(certResolvedPath);
|
|
135
|
+
keyData = fs.readFileSync(keyResolvedPath);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
console.warn(`Failed to load client certificate for ${client.host || 'unknown'}: ${e}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const passphrase = resolvePassphrase(
|
|
141
|
+
client.passphrase_plain, client.passphrase_env, envVars, process.env);
|
|
142
|
+
return {
|
|
143
|
+
id: `client-${idx}`,
|
|
144
|
+
name: client.name || '',
|
|
145
|
+
host: client.host || '*',
|
|
146
|
+
cert_path: certPath,
|
|
147
|
+
key_path: keyPath,
|
|
148
|
+
passphrase_plain: passphrase,
|
|
149
|
+
certData,
|
|
150
|
+
keyData,
|
|
151
|
+
enabled: clientEnabled,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
ca: {enabled: caEnabled, certPaths: caPaths, certData: caCertDataList.length > 0 ? caCertDataList : undefined},
|
|
157
|
+
clients,
|
|
158
|
+
sslValidation: true, // Default true (not stored in YAML)
|
|
159
|
+
allowSelfSigned: false, // Default false (not stored in YAML)
|
|
160
|
+
timeout: 30000,
|
|
161
|
+
autoFormat: false,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Find the project root by walking up from startPath looking for multimeter.mmt.
|
|
167
|
+
* Returns the directory containing multimeter.mmt, or undefined if not found.
|
|
168
|
+
*/
|
|
169
|
+
function findProjectRootForCli(startPath: string): string | undefined {
|
|
170
|
+
return findProjectRootSync(startPath, fs.existsSync, path.dirname, path.join) ?? undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ParsedCliRunArgs {
|
|
174
|
+
runFileOptions: RunFileOptions;
|
|
175
|
+
networkConfig?: NetworkConfig;
|
|
176
|
+
quiet: boolean;
|
|
177
|
+
outFile?: string;
|
|
178
|
+
printJs: boolean;
|
|
179
|
+
reportFormat?: ReportFormat;
|
|
180
|
+
reportFile?: string;
|
|
181
|
+
getReportResults?: () => import('mmt-core/reportCollector').CollectedResults;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function buildCliRunArgs(file: string, opts: AnyOpts): ParsedCliRunArgs {
|
|
185
|
+
const full = path.resolve(process.cwd(), file);
|
|
186
|
+
const dir = path.dirname(full);
|
|
187
|
+
const rawText = fs.readFileSync(full, 'utf8');
|
|
188
|
+
|
|
189
|
+
const manualInputs = parsePairs(opts.input);
|
|
190
|
+
const manualEnvvars = parsePairs(opts.env);
|
|
191
|
+
|
|
192
|
+
const exampleOptRaw = typeof (opts as any).example === 'string' ?
|
|
193
|
+
String((opts as any).example) :
|
|
194
|
+
undefined;
|
|
195
|
+
let exampleIndexOpt: number|undefined = undefined;
|
|
196
|
+
let exampleNameOpt: string|undefined = undefined;
|
|
197
|
+
if (exampleOptRaw && exampleOptRaw.trim()) {
|
|
198
|
+
const trimmed = exampleOptRaw.trim();
|
|
199
|
+
const numeric = trimmed.match(/^#?(\d+)$/);
|
|
200
|
+
if (numeric) {
|
|
201
|
+
const parsed = Number(numeric[1]);
|
|
202
|
+
exampleIndexOpt = parsed > 0 ? parsed - 1 : 0;
|
|
203
|
+
} else {
|
|
204
|
+
exampleNameOpt = trimmed;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let envvar: Record<string, any>|undefined = undefined;
|
|
209
|
+
let networkConfig: NetworkConfig|undefined = undefined;
|
|
210
|
+
const envFileOpt = opts.envFile as string | undefined;
|
|
211
|
+
const presetName = opts.preset as string | undefined;
|
|
212
|
+
let envFileDir = dir;
|
|
213
|
+
|
|
214
|
+
// Detect if this is a suite file and check for suite environment config
|
|
215
|
+
let suiteEnvConfig: {preset?: string; file?: string; variables?: Record<string, unknown>} | undefined;
|
|
216
|
+
try {
|
|
217
|
+
const parsed = yaml.load(rawText) as any;
|
|
218
|
+
if (parsed && parsed.type === 'suite' && parsed.environment) {
|
|
219
|
+
suiteEnvConfig = parsed.environment;
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// Not valid YAML or not a suite, continue normally
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (envFileOpt) {
|
|
226
|
+
let p = String(envFileOpt);
|
|
227
|
+
if (!path.isAbsolute(p)) {
|
|
228
|
+
const fromCwd = path.resolve(process.cwd(), p);
|
|
229
|
+
if (fs.existsSync(fromCwd)) {
|
|
230
|
+
p = fromCwd;
|
|
231
|
+
} else {
|
|
232
|
+
p = path.resolve(dir, p);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
envFileDir = path.dirname(p);
|
|
236
|
+
const doc = loadEnvDoc(p);
|
|
237
|
+
if (typeof resolveEnvFromDoc === 'function') {
|
|
238
|
+
envvar = resolveEnvFromDoc({doc, presetName, manualEnvvars});
|
|
239
|
+
} else {
|
|
240
|
+
const presetEnv = resolvePresetEnv(doc, presetName);
|
|
241
|
+
envvar = mergeEnv({envvar: presetEnv, manualEnvvars});
|
|
242
|
+
}
|
|
243
|
+
// Build network config from certificates in env file
|
|
244
|
+
if (doc.certificates) {
|
|
245
|
+
networkConfig = buildNetworkConfigFromEnv(doc.certificates, envFileDir, envvar);
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
envvar = mergeEnv({envvar: undefined, manualEnvvars});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Merge suite environment for CLI
|
|
252
|
+
// Priority: CLI -e > suite environment.variables > suite preset > --env-file/--preset > defaults
|
|
253
|
+
if (suiteEnvConfig) {
|
|
254
|
+
const projectRoot = findProjectRootForCli(full);
|
|
255
|
+
|
|
256
|
+
// Resolve suite preset if specified
|
|
257
|
+
let suitePresetEnv: Record<string, any> = {};
|
|
258
|
+
if (suiteEnvConfig.preset) {
|
|
259
|
+
let suiteEnvFilePath: string | undefined;
|
|
260
|
+
if (suiteEnvConfig.file) {
|
|
261
|
+
// Resolve relative to suite file or project root for +/ paths
|
|
262
|
+
if (suiteEnvConfig.file.startsWith('+/')) {
|
|
263
|
+
suiteEnvFilePath = projectRoot ? path.join(projectRoot, suiteEnvConfig.file.slice(2)) : undefined;
|
|
264
|
+
} else {
|
|
265
|
+
suiteEnvFilePath = path.resolve(dir, suiteEnvConfig.file);
|
|
266
|
+
}
|
|
267
|
+
} else if (projectRoot) {
|
|
268
|
+
// Use multimeter.mmt in project root
|
|
269
|
+
suiteEnvFilePath = path.join(projectRoot, 'multimeter.mmt');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (suiteEnvFilePath && fs.existsSync(suiteEnvFilePath)) {
|
|
273
|
+
const suiteEnvDoc = loadEnvDoc(suiteEnvFilePath);
|
|
274
|
+
suitePresetEnv = resolvePresetEnv(suiteEnvDoc, suiteEnvConfig.preset);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Merge: base (--env-file/--preset) < suite preset < suite variables < CLI -e
|
|
279
|
+
const baseEnv = {...(envvar || {})};
|
|
280
|
+
// Remove CLI -e from base (it will be applied at highest priority)
|
|
281
|
+
for (const key of Object.keys(manualEnvvars)) {
|
|
282
|
+
delete baseEnv[key];
|
|
283
|
+
}
|
|
284
|
+
const suiteVariables = suiteEnvConfig.variables ? {...suiteEnvConfig.variables} : {};
|
|
285
|
+
envvar = {...baseEnv, ...suitePresetEnv, ...suiteVariables, ...manualEnvvars};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const runFileOptions: RunFileOptions&{
|
|
289
|
+
fileLoader: (path: string) => Promise<string>;
|
|
290
|
+
jsRunner: (
|
|
291
|
+
code: string, title: string,
|
|
292
|
+
logger: (level: any, msg: string) => void) => Promise<void>;
|
|
293
|
+
logger: (level: any, msg: string) => void;
|
|
294
|
+
reporter: (message: RunReporterMessage) => void;
|
|
295
|
+
}
|
|
296
|
+
= {
|
|
297
|
+
file: rawText,
|
|
298
|
+
fileType: 'raw',
|
|
299
|
+
filePath: full,
|
|
300
|
+
exampleIndex: exampleIndexOpt,
|
|
301
|
+
exampleName: exampleNameOpt,
|
|
302
|
+
manualInputs,
|
|
303
|
+
envvar,
|
|
304
|
+
manualEnvvars,
|
|
305
|
+
fileLoader: async (p: string) => {
|
|
306
|
+
const rel = path.isAbsolute(p) ? p : path.join(dir, p);
|
|
307
|
+
if (!fs.existsSync(rel)) {
|
|
308
|
+
return '';
|
|
309
|
+
}
|
|
310
|
+
return fs.readFileSync(rel, 'utf8');
|
|
311
|
+
},
|
|
312
|
+
jsRunner: async () => {},
|
|
313
|
+
logger: (level: any, msg: string) => {
|
|
314
|
+
console.log(`[${level}] ${msg}`);
|
|
315
|
+
},
|
|
316
|
+
reporter: (_message: RunReporterMessage) => {},
|
|
317
|
+
projectRoot: findProjectRootForCli(full),
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Wire collecting reporter when --report is requested
|
|
321
|
+
const reportFormat = opts.report as ReportFormat | undefined;
|
|
322
|
+
let getReportResults: (() => any) | undefined;
|
|
323
|
+
if (reportFormat) {
|
|
324
|
+
const {createReportCollector} = (mmtcore as any).reportCollector || {};
|
|
325
|
+
if (typeof createReportCollector === 'function') {
|
|
326
|
+
const collector = createReportCollector();
|
|
327
|
+
runFileOptions.reporter = collector.reporter;
|
|
328
|
+
getReportResults = collector.getResults;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const defaultReportFiles: Record<ReportFormat, string> = {
|
|
333
|
+
junit: 'test-results.xml',
|
|
334
|
+
mmt: 'test-results.mmt',
|
|
335
|
+
html: 'test-results.html',
|
|
336
|
+
md: 'test-results.md',
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
runFileOptions,
|
|
341
|
+
networkConfig,
|
|
342
|
+
quiet: !!opts.quiet,
|
|
343
|
+
outFile: opts.out ? String(opts.out) : undefined,
|
|
344
|
+
printJs: !!opts.printJs,
|
|
345
|
+
reportFormat,
|
|
346
|
+
reportFile: reportFormat
|
|
347
|
+
? (opts.reportFile ? String(opts.reportFile) : defaultReportFiles[reportFormat])
|
|
348
|
+
: undefined,
|
|
349
|
+
getReportResults,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export {parsePairs, coerceCliValue};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"module": "CommonJS",
|
|
5
|
+
"moduleResolution": "Node",
|
|
6
|
+
"outDir": "dist-cjs",
|
|
7
|
+
"declaration": false,
|
|
8
|
+
"types": ["node"],
|
|
9
|
+
"lib": ["ES2022"]
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*"],
|
|
12
|
+
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/__tests__/**"]
|
|
13
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "src",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"module": "NodeNext",
|
|
7
|
+
"target": "ES2022",
|
|
8
|
+
"moduleResolution": "NodeNext",
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"declaration": false,
|
|
13
|
+
"types": ["node"],
|
|
14
|
+
"lib": ["ES2022"],
|
|
15
|
+
"baseUrl": "..",
|
|
16
|
+
"paths": {
|
|
17
|
+
"mmt-core": ["core/dist"],
|
|
18
|
+
"mmt-core/*": ["core/dist/*"]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"include": ["src/**/*"]
|
|
22
|
+
}
|