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/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,11 @@
1
+ export interface CliRunResult {
2
+ success: boolean;
3
+ durationMs: number;
4
+ errors: string[];
5
+ }
6
+
7
+ export interface NormalizedTest {
8
+ raw: any;
9
+ steps: any[];
10
+ stages?: any[];
11
+ }
@@ -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
+ }