webmystran-wasm 0.1.1

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.
@@ -0,0 +1,978 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { WebMYSTRAN } from "../index.js";
7
+
8
+ const RUNTIME_DECK_FILE_NAME = "WEBMYSTRAN.DAT";
9
+ const RUNTIME_DECK_BASE_NAME = "WEBMYSTRAN";
10
+ const RESULT_EXTENSIONS = ["F06", "PCH", "F04", "NEU", "OP2", "ANS", "BUG", "ERR"];
11
+ const PASS_OUTCOMES = new Set(["success", "solver_error"]);
12
+ const BUILD_IMAGE = "ghcr.io/r-wasm/flang-wasm:v20.1.4";
13
+ const RUNTIME_ABORT_REGEX =
14
+ /fatal Fortran runtime error|RuntimeError:\s*Aborted|Aborted\(\)|RuntimeError:\s*unreachable|RuntimeError:\s*memory access out of bounds|RuntimeError:\s*table index is out of bounds|out of memory/i;
15
+ const SOLVER_FAILURE_REGEX = /\*ERROR\s+[0-9A-Z]+|PROCESSING STOPPED|Processing terminated|CHECK F06 OUTPUT FILE/i;
16
+
17
+ function fileExists(filePath) {
18
+ try {
19
+ fs.accessSync(filePath, fs.constants.R_OK);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function tailLines(text, count) {
27
+ const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
28
+ return lines.slice(Math.max(0, lines.length - count)).join("\n");
29
+ }
30
+
31
+ function toPosixPath(value) {
32
+ return String(value || "").replace(/\\/g, "/");
33
+ }
34
+
35
+ function quoteBash(value) {
36
+ return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
37
+ }
38
+
39
+ function runDocker(args, options = {}) {
40
+ return spawnSync("docker", args, {
41
+ encoding: options.encoding || "utf8",
42
+ timeout: options.timeout,
43
+ maxBuffer: options.maxBuffer,
44
+ cwd: options.cwd,
45
+ env: options.env || process.env
46
+ });
47
+ }
48
+
49
+ function createNativeRunnerContext(repoRoot) {
50
+ const binaryPath = path.join(repoRoot, "sources", "MYSTRAN_native_ref", "Binaries", "mystran");
51
+ if (!fileExists(binaryPath)) {
52
+ throw new Error(`Missing native reference binary: ${binaryPath}. Run scripts/build.ps1 first.`);
53
+ }
54
+
55
+ const dockerVersion = runDocker(["version"], { timeout: 30000, maxBuffer: 1024 * 1024 });
56
+ if (dockerVersion.error || dockerVersion.status !== 0) {
57
+ const detail = dockerVersion.error ? ` (${dockerVersion.error.message})` : "";
58
+ throw new Error(`Docker engine is required for native compare smoke${detail}.`);
59
+ }
60
+
61
+ return { repoRoot };
62
+ }
63
+
64
+ function listDeckFilesRecursive(baseDir) {
65
+ const all = [];
66
+
67
+ function walk(currentDir) {
68
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
69
+ for (const entry of entries) {
70
+ const full = path.join(currentDir, entry.name);
71
+ if (entry.isDirectory()) {
72
+ walk(full);
73
+ } else if (entry.isFile() && /\.dat$/i.test(entry.name)) {
74
+ all.push(full);
75
+ }
76
+ }
77
+ }
78
+
79
+ walk(baseDir);
80
+ return all.sort((a, b) => a.localeCompare(b));
81
+ }
82
+
83
+ function toBenchRelativePath(benchDeckDir, deckAbsPath) {
84
+ return path.relative(benchDeckDir, deckAbsPath).split(path.sep).join("/");
85
+ }
86
+
87
+ function parseFullSmokeArgs(args) {
88
+ const options = {
89
+ decks: [],
90
+ timeoutSeconds: 1800
91
+ };
92
+
93
+ for (let index = 0; index < args.length; index += 1) {
94
+ const token = args[index];
95
+ const normalized = token.toLowerCase();
96
+
97
+ if (normalized === "-decks" || normalized === "--decks") {
98
+ let captured = false;
99
+ while (index + 1 < args.length && !args[index + 1].startsWith("-")) {
100
+ index += 1;
101
+ captured = true;
102
+ const parts = String(args[index])
103
+ .split(",")
104
+ .map((value) => value.trim())
105
+ .filter(Boolean);
106
+ options.decks.push(...parts);
107
+ }
108
+ if (!captured) {
109
+ throw new Error("Missing value for -Decks");
110
+ }
111
+ continue;
112
+ }
113
+
114
+ if (normalized === "-timeoutseconds" || normalized === "--timeoutseconds") {
115
+ if (index + 1 >= args.length) {
116
+ throw new Error("Missing value for -TimeoutSeconds");
117
+ }
118
+ const parsed = Number.parseInt(args[index + 1], 10);
119
+ if (!Number.isFinite(parsed) || parsed < 1) {
120
+ throw new Error(`Invalid -TimeoutSeconds value: ${args[index + 1]}`);
121
+ }
122
+ options.timeoutSeconds = parsed;
123
+ index += 1;
124
+ continue;
125
+ }
126
+
127
+ if (token.startsWith("-")) {
128
+ throw new Error(`Unknown smoke option: ${token}`);
129
+ }
130
+
131
+ options.decks.push(token);
132
+ }
133
+
134
+ return options;
135
+ }
136
+
137
+ function parseDeckWorkerArgs(args) {
138
+ const options = {
139
+ deckPath: "",
140
+ moduleJs: "",
141
+ moduleWasm: "",
142
+ workDir: "/work"
143
+ };
144
+
145
+ for (let index = 0; index < args.length; index += 1) {
146
+ const token = args[index];
147
+ const normalized = token.toLowerCase();
148
+
149
+ if (normalized === "--deck-path") {
150
+ options.deckPath = args[index + 1] || "";
151
+ index += 1;
152
+ continue;
153
+ }
154
+ if (normalized === "--module-js") {
155
+ options.moduleJs = args[index + 1] || "";
156
+ index += 1;
157
+ continue;
158
+ }
159
+ if (normalized === "--module-wasm") {
160
+ options.moduleWasm = args[index + 1] || "";
161
+ index += 1;
162
+ continue;
163
+ }
164
+ if (normalized === "--work-dir") {
165
+ options.workDir = args[index + 1] || "/work";
166
+ index += 1;
167
+ continue;
168
+ }
169
+ }
170
+
171
+ if (!options.deckPath || !options.moduleJs || !options.moduleWasm) {
172
+ throw new Error("Deck worker requires --deck-path, --module-js, and --module-wasm");
173
+ }
174
+
175
+ return options;
176
+ }
177
+
178
+ async function runDeckWorker(args) {
179
+ const options = parseDeckWorkerArgs(args);
180
+ const deckPath = path.resolve(options.deckPath);
181
+
182
+ let solver = null;
183
+ try {
184
+ const startedAt = process.hrtime.bigint();
185
+ const deckText = fs.readFileSync(deckPath, "utf8");
186
+ solver = await WebMYSTRAN.load({
187
+ moduleUrl: pathToFileURL(path.resolve(options.moduleJs)).href,
188
+ wasmUrl: pathToFileURL(path.resolve(options.moduleWasm)).href
189
+ });
190
+
191
+ const runResult = solver.run(deckText, {
192
+ workDir: options.workDir || "/work",
193
+ deckFileName: RUNTIME_DECK_FILE_NAME
194
+ });
195
+ const elapsedSec = Number(process.hrtime.bigint() - startedAt) / 1e9;
196
+
197
+ const stdout = runResult.raw?.stdout || "";
198
+ const stderr = runResult.raw?.stderr || "";
199
+ const consoleText = runResult.output?.text || `${stdout}${stderr}`;
200
+ const f06Text = runResult.outputs?.F06 ? solver.readOutput(runResult, "F06", "utf8") || "" : "";
201
+ const resultArtifacts = Array.isArray(runResult.files)
202
+ ? runResult.files.map((entry) => ({
203
+ extension: String(entry.extension || "").toUpperCase(),
204
+ length: Number(entry.size || 0)
205
+ }))
206
+ : [];
207
+ const nonEmptyResultFileCount = resultArtifacts.filter((entry) => entry.length > 0).length;
208
+ const hasNormalTermination =
209
+ /MYSTRAN terminated normally/i.test(f06Text) || /MYSTRAN terminated normally/i.test(consoleText);
210
+ const runtimeAbort = RUNTIME_ABORT_REGEX.test(consoleText);
211
+ const solverFailure = SOLVER_FAILURE_REGEX.test(f06Text) || SOLVER_FAILURE_REGEX.test(consoleText);
212
+ const errorCode = getFirstErrorCode(f06Text) || getFirstErrorCode(consoleText);
213
+ const f06Length = resultArtifacts.find((entry) => entry.extension === "F06")?.length || 0;
214
+
215
+ process.stdout.write(
216
+ `${JSON.stringify({
217
+ exitCode: Number.isInteger(runResult.raw?.exitCode) ? runResult.raw.exitCode : 1,
218
+ elapsedSec,
219
+ stdout,
220
+ stderr,
221
+ consoleText,
222
+ hasNormalTermination,
223
+ runtimeAbort,
224
+ solverFailure,
225
+ errorCode,
226
+ f06Present: Boolean(runResult.outputs?.F06),
227
+ f06Length,
228
+ nonEmptyResultFileCount,
229
+ resultArtifacts
230
+ })}\n`
231
+ );
232
+ process.exit(0);
233
+ } catch (error) {
234
+ const message = error && error.message ? error.message : String(error);
235
+ process.stdout.write(
236
+ `${JSON.stringify({
237
+ exitCode: 1,
238
+ elapsedSec: 0,
239
+ stdout: "",
240
+ stderr: `${message}\n`,
241
+ consoleText: `${message}\n`,
242
+ hasNormalTermination: false,
243
+ runtimeAbort: false,
244
+ solverFailure: false,
245
+ errorCode: null,
246
+ f06Present: false,
247
+ f06Length: 0,
248
+ nonEmptyResultFileCount: 0,
249
+ resultArtifacts: [],
250
+ workerError: message
251
+ })}\n`
252
+ );
253
+ process.exit(0);
254
+ } finally {
255
+ if (solver) {
256
+ solver.destroy();
257
+ }
258
+ }
259
+ }
260
+
261
+ function resolveDeckPath(deckSpec, benchDeckDir, allDeckAbs) {
262
+ if (!deckSpec) {
263
+ return null;
264
+ }
265
+
266
+ const direct = path.isAbsolute(deckSpec) ? deckSpec : path.join(benchDeckDir, deckSpec);
267
+ if (fileExists(direct)) {
268
+ return path.resolve(direct);
269
+ }
270
+
271
+ const deckSpecNormalized = deckSpec.replace(/\\/g, "/").toLowerCase();
272
+ const exactRelative = allDeckAbs.find((candidate) => {
273
+ const relative = toBenchRelativePath(benchDeckDir, candidate).toLowerCase();
274
+ return relative === deckSpecNormalized;
275
+ });
276
+ if (exactRelative) {
277
+ return exactRelative;
278
+ }
279
+
280
+ const byBaseName = allDeckAbs.find((candidate) => {
281
+ return path.basename(candidate).toLowerCase() === path.basename(deckSpec).toLowerCase();
282
+ });
283
+ if (byBaseName) {
284
+ return byBaseName;
285
+ }
286
+
287
+ return null;
288
+ }
289
+
290
+ function getFirstErrorCode(text) {
291
+ if (!text || !String(text).trim()) {
292
+ return null;
293
+ }
294
+ const match = String(text).match(/\*ERROR\s+([0-9A-Z]+)/);
295
+ return match ? match[1] : null;
296
+ }
297
+
298
+ function parseDeckWorkerPayload(stdoutText) {
299
+ const lines = String(stdoutText || "")
300
+ .split(/\r?\n/)
301
+ .map((line) => line.trim())
302
+ .filter(Boolean);
303
+
304
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
305
+ try {
306
+ const parsed = JSON.parse(lines[index]);
307
+ if (parsed && typeof parsed === "object") {
308
+ return parsed;
309
+ }
310
+ } catch {
311
+ // Keep scanning trailing lines for JSON payload.
312
+ }
313
+ }
314
+
315
+ return null;
316
+ }
317
+
318
+ function normalizeResultArtifacts(artifacts) {
319
+ if (!Array.isArray(artifacts)) {
320
+ return [];
321
+ }
322
+ return artifacts
323
+ .map((entry) => {
324
+ const extension = String(entry.extension || "").toUpperCase();
325
+ const length = Number(entry.length ?? entry.size ?? 0);
326
+ if (!extension) {
327
+ return null;
328
+ }
329
+ return {
330
+ extension,
331
+ length: Number.isFinite(length) && length > 0 ? length : 0
332
+ };
333
+ })
334
+ .filter(Boolean);
335
+ }
336
+
337
+ function evaluateRunStatus(deck, workerResult, processExitCode, timedOut, observedConsoleText) {
338
+ const exitCode = timedOut
339
+ ? 124
340
+ : Number.isInteger(workerResult?.exitCode)
341
+ ? workerResult.exitCode
342
+ : processExitCode;
343
+ const consoleText = String(workerResult?.consoleText || observedConsoleText || "");
344
+ const resultArtifacts = normalizeResultArtifacts(workerResult?.resultArtifacts);
345
+ const f06Length = Number.isFinite(workerResult?.f06Length) ? Math.max(0, workerResult.f06Length) : 0;
346
+ const nonEmptyResultFileCount = Number.isFinite(workerResult?.nonEmptyResultFileCount)
347
+ ? Math.max(0, workerResult.nonEmptyResultFileCount)
348
+ : resultArtifacts.filter((entry) => entry.length > 0).length;
349
+ const f06Present = typeof workerResult?.f06Present === "boolean" ? workerResult.f06Present : false;
350
+ const hasNormalTermination =
351
+ typeof workerResult?.hasNormalTermination === "boolean"
352
+ ? workerResult.hasNormalTermination
353
+ : /MYSTRAN terminated normally/i.test(consoleText);
354
+ const runtimeAbort =
355
+ typeof workerResult?.runtimeAbort === "boolean"
356
+ ? workerResult.runtimeAbort || RUNTIME_ABORT_REGEX.test(consoleText)
357
+ : RUNTIME_ABORT_REGEX.test(consoleText);
358
+ const solverFailure =
359
+ typeof workerResult?.solverFailure === "boolean"
360
+ ? workerResult.solverFailure || SOLVER_FAILURE_REGEX.test(consoleText)
361
+ : SOLVER_FAILURE_REGEX.test(consoleText);
362
+ const errorCode = workerResult?.errorCode || getFirstErrorCode(consoleText);
363
+
364
+ let outcome = "unknown";
365
+ if (timedOut) {
366
+ outcome = "timeout";
367
+ } else if (runtimeAbort) {
368
+ outcome = "runtime_abort";
369
+ } else if (exitCode !== 0) {
370
+ outcome = "process_error";
371
+ } else if (!f06Present) {
372
+ outcome = "missing_f06";
373
+ } else if (f06Length === 0) {
374
+ outcome = "empty_f06";
375
+ } else if (nonEmptyResultFileCount === 0) {
376
+ outcome = "missing_outputs";
377
+ } else if (solverFailure) {
378
+ outcome = "solver_error";
379
+ } else if (hasNormalTermination) {
380
+ outcome = "success";
381
+ } else {
382
+ outcome = "no_success_marker";
383
+ }
384
+
385
+ return {
386
+ deck,
387
+ outcome,
388
+ exitCode,
389
+ timedOut,
390
+ runtimeAbort,
391
+ hasNormalTermination,
392
+ solverFailure,
393
+ errorCode,
394
+ workerError: workerResult?.workerError || null,
395
+ f06Length,
396
+ nonEmptyResultFileCount,
397
+ resultArtifacts
398
+ };
399
+ }
400
+
401
+ function runWasmDeckCase(deckPath, runnerScript, timeoutSeconds, distJs, distWasm) {
402
+ const result = spawnSync(
403
+ "node",
404
+ [
405
+ runnerScript,
406
+ "__run_deck",
407
+ "--deck-path",
408
+ deckPath,
409
+ "--module-js",
410
+ distJs,
411
+ "--module-wasm",
412
+ distWasm,
413
+ "--work-dir",
414
+ "/work"
415
+ ],
416
+ {
417
+ cwd: path.dirname(deckPath),
418
+ encoding: "utf8",
419
+ timeout: timeoutSeconds * 1000,
420
+ maxBuffer: 16 * 1024 * 1024
421
+ }
422
+ );
423
+
424
+ const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT");
425
+ const processExitCode = timedOut ? 124 : Number.isInteger(result.status) ? result.status : 1;
426
+ const workerResult = parseDeckWorkerPayload(result.stdout || "");
427
+ const elapsedSec = Number.isFinite(workerResult?.elapsedSec) ? workerResult.elapsedSec : 0;
428
+ const stdout = workerResult ? String(workerResult.stdout || "") : String(result.stdout || "");
429
+ let stderr = String(workerResult?.stderr || result.stderr || "");
430
+
431
+ if (result.error && !timedOut) {
432
+ stderr = `${stderr}${stderr.endsWith("\n") || stderr.length === 0 ? "" : "\n"}${result.error.message}`;
433
+ }
434
+
435
+ const consoleText = String(workerResult?.consoleText || `${stdout}${stderr}`);
436
+ const exitCode =
437
+ timedOut || !workerResult
438
+ ? processExitCode
439
+ : Number.isInteger(workerResult.exitCode)
440
+ ? workerResult.exitCode
441
+ : processExitCode;
442
+
443
+ return {
444
+ exitCode,
445
+ timedOut,
446
+ elapsedSec,
447
+ consoleText,
448
+ workerResult
449
+ };
450
+ }
451
+
452
+ function createNativeDeckCommand(deckContainerPath, timeoutSeconds) {
453
+ const deckPathLiteral = quoteBash(deckContainerPath);
454
+ const timeoutSec = Math.max(1, Number.parseInt(String(timeoutSeconds), 10) || 1);
455
+ const abortPattern =
456
+ "fatal Fortran runtime error|RuntimeError:[[:space:]]*Aborted|Aborted\\(\\)|RuntimeError:[[:space:]]*unreachable|RuntimeError:[[:space:]]*memory access out of bounds|RuntimeError:[[:space:]]*table index is out of bounds|out of memory";
457
+ const solverPattern = "\\*ERROR[[:space:]]+[0-9A-Z]+|PROCESSING STOPPED|Processing terminated|CHECK F06 OUTPUT FILE";
458
+
459
+ return `
460
+ set -euo pipefail
461
+ deck_path=${deckPathLiteral}
462
+ native_bin='/work/sources/MYSTRAN_native_ref/Binaries/mystran'
463
+
464
+ if [ ! -f "$deck_path" ]; then
465
+ echo '{"exitCode":1,"elapsedSec":0,"timedOut":false,"stdout":"","stderr":"","consoleText":"","hasNormalTermination":false,"runtimeAbort":false,"solverFailure":false,"errorCode":null,"f06Present":false,"f06Length":0,"nonEmptyResultFileCount":0,"resultArtifacts":[],"workerError":"Deck not found in container"}'
466
+ exit 0
467
+ fi
468
+
469
+ if [ ! -f "$native_bin" ]; then
470
+ echo '{"exitCode":1,"elapsedSec":0,"timedOut":false,"stdout":"","stderr":"","consoleText":"","hasNormalTermination":false,"runtimeAbort":false,"solverFailure":false,"errorCode":null,"f06Present":false,"f06Length":0,"nonEmptyResultFileCount":0,"resultArtifacts":[],"workerError":"Missing native reference binary"}'
471
+ exit 0
472
+ fi
473
+
474
+ chmod +x "$native_bin" || true
475
+ tmpdir=$(mktemp -d /tmp/webmystran-native-smoke-XXXXXX)
476
+ cleanup() { rm -rf "$tmpdir"; }
477
+ trap cleanup EXIT
478
+
479
+ cp "$deck_path" "$tmpdir/${RUNTIME_DECK_FILE_NAME}"
480
+ : > "$tmpdir/MYSTRAN.INI"
481
+ printf '${RUNTIME_DECK_FILE_NAME}\\n' > "$tmpdir/stdin.txt"
482
+ : > "$tmpdir/native.stdout.log"
483
+ : > "$tmpdir/native.stderr.log"
484
+
485
+ start_ns=$(date +%s%N)
486
+ set +e
487
+ if command -v timeout >/dev/null 2>&1; then
488
+ (cd "$tmpdir" && timeout ${timeoutSec}s "$native_bin" < stdin.txt > native.stdout.log 2> native.stderr.log)
489
+ run_exit=$?
490
+ else
491
+ (cd "$tmpdir" && "$native_bin" < stdin.txt > native.stdout.log 2> native.stderr.log)
492
+ run_exit=$?
493
+ fi
494
+ set -e
495
+ end_ns=$(date +%s%N)
496
+ elapsed_sec=$(awk "BEGIN { printf \\"%.6f\\", ($end_ns-$start_ns)/1000000000 }")
497
+
498
+ timed_out=false
499
+ if [ "$run_exit" -eq 124 ] || [ "$run_exit" -eq 137 ] || [ "$run_exit" -eq 143 ]; then
500
+ timed_out=true
501
+ fi
502
+
503
+ f06_path="$tmpdir/${RUNTIME_DECK_BASE_NAME}.F06"
504
+ f06_present=false
505
+ f06_length=0
506
+ if [ -f "$f06_path" ]; then
507
+ f06_present=true
508
+ f06_length=$(wc -c < "$f06_path" | tr -d '[:space:]')
509
+ fi
510
+
511
+ has_normal=false
512
+ if [ -f "$f06_path" ] && grep -Eiq 'MYSTRAN terminated normally' "$f06_path"; then
513
+ has_normal=true
514
+ elif grep -Eiq 'MYSTRAN terminated normally' "$tmpdir/native.stdout.log" "$tmpdir/native.stderr.log"; then
515
+ has_normal=true
516
+ fi
517
+
518
+ runtime_abort=false
519
+ if grep -Eiq '${abortPattern}' "$tmpdir/native.stdout.log" "$tmpdir/native.stderr.log"; then
520
+ runtime_abort=true
521
+ fi
522
+
523
+ solver_failure=false
524
+ if [ -f "$f06_path" ] && grep -Eiq '${solverPattern}' "$f06_path"; then
525
+ solver_failure=true
526
+ elif grep -Eiq '${solverPattern}' "$tmpdir/native.stdout.log" "$tmpdir/native.stderr.log"; then
527
+ solver_failure=true
528
+ fi
529
+
530
+ error_code=$( (grep -Eo '\\*ERROR[[:space:]]+[0-9A-Z]+' "$f06_path" "$tmpdir/native.stdout.log" "$tmpdir/native.stderr.log" 2>/dev/null || true) | head -n 1 | awk '{print $2}' )
531
+ error_json=null
532
+ if [ -n "$error_code" ]; then
533
+ error_json="\\"$error_code\\""
534
+ fi
535
+
536
+ artifacts_json=""
537
+ non_empty_count=0
538
+ for ext in ${RESULT_EXTENSIONS.join(" ")}; do
539
+ candidate="$tmpdir/${RUNTIME_DECK_BASE_NAME}.$ext"
540
+ if [ -f "$candidate" ]; then
541
+ length=$(wc -c < "$candidate" | tr -d '[:space:]')
542
+ if [ -z "$length" ]; then
543
+ length=0
544
+ fi
545
+ if [ "$length" -gt 0 ] 2>/dev/null; then
546
+ non_empty_count=$((non_empty_count + 1))
547
+ fi
548
+ entry="{\\"extension\\":\\"$ext\\",\\"length\\":$length}"
549
+ if [ -z "$artifacts_json" ]; then
550
+ artifacts_json="$entry"
551
+ else
552
+ artifacts_json="$artifacts_json,$entry"
553
+ fi
554
+ fi
555
+ done
556
+
557
+ echo "{\\"exitCode\\":$run_exit,\\"elapsedSec\\":$elapsed_sec,\\"timedOut\\":$timed_out,\\"stdout\\":\\"\\",\\"stderr\\":\\"\\",\\"consoleText\\":\\"\\",\\"hasNormalTermination\\":$has_normal,\\"runtimeAbort\\":$runtime_abort,\\"solverFailure\\":$solver_failure,\\"errorCode\\":$error_json,\\"f06Present\\":$f06_present,\\"f06Length\\":$f06_length,\\"nonEmptyResultFileCount\\":$non_empty_count,\\"resultArtifacts\\":[$artifacts_json]}"
558
+ `;
559
+ }
560
+
561
+ function runNativeDeckCase(nativeContext, deckPath, timeoutSeconds) {
562
+ const deckRelPath = toPosixPath(path.relative(nativeContext.repoRoot, deckPath));
563
+ const deckContainerPath = `/work/${deckRelPath}`;
564
+ const command = createNativeDeckCommand(deckContainerPath, timeoutSeconds);
565
+
566
+ const result = runDocker(
567
+ [
568
+ "run",
569
+ "--rm",
570
+ "--volume",
571
+ `${nativeContext.repoRoot}:/work`,
572
+ "--workdir",
573
+ "/work",
574
+ BUILD_IMAGE,
575
+ "/bin/bash",
576
+ "-lc",
577
+ command
578
+ ],
579
+ {
580
+ encoding: "utf8",
581
+ timeout: (Math.max(1, timeoutSeconds) + 120) * 1000,
582
+ maxBuffer: 16 * 1024 * 1024
583
+ }
584
+ );
585
+
586
+ const workerResult = parseDeckWorkerPayload(result.stdout || "");
587
+ const timedOut =
588
+ Boolean(workerResult?.timedOut) || Boolean(result.error && result.error.code === "ETIMEDOUT");
589
+ const processExitCode = timedOut ? 124 : Number.isInteger(result.status) ? result.status : 1;
590
+ const exitCode =
591
+ timedOut || !workerResult
592
+ ? processExitCode
593
+ : Number.isInteger(workerResult.exitCode)
594
+ ? workerResult.exitCode
595
+ : processExitCode;
596
+ const elapsedSec = Number.isFinite(workerResult?.elapsedSec) ? workerResult.elapsedSec : 0;
597
+ const consoleText = String(workerResult?.consoleText || `${result.stdout || ""}${result.stderr || ""}`);
598
+
599
+ return {
600
+ exitCode,
601
+ timedOut,
602
+ elapsedSec,
603
+ consoleText,
604
+ workerResult
605
+ };
606
+ }
607
+
608
+ function hasNonEmptyArtifact(status, extension) {
609
+ const normalized = String(extension || "").toUpperCase();
610
+ if (!normalized) {
611
+ return false;
612
+ }
613
+ return status.resultArtifacts.some(
614
+ (entry) => String(entry.extension || "").toUpperCase() === normalized && Number(entry.length || 0) > 0
615
+ );
616
+ }
617
+
618
+ function evaluateNativeCompare(wasmStatus, nativeStatus) {
619
+ const wasmPass = PASS_OUTCOMES.has(wasmStatus.outcome);
620
+ const nativePass = PASS_OUTCOMES.has(nativeStatus.outcome);
621
+ const wasmOutcome = String(wasmStatus.outcome || "");
622
+ const nativeOutcome = String(nativeStatus.outcome || "");
623
+ const bothSuccess = wasmOutcome === "success" && nativeOutcome === "success";
624
+ const bothSolverError = wasmOutcome === "solver_error" && nativeOutcome === "solver_error";
625
+
626
+ if (wasmPass !== nativePass) {
627
+ return { match: false, reason: "pass_mismatch" };
628
+ }
629
+
630
+ if (wasmPass && nativePass) {
631
+ if (bothSolverError) {
632
+ return { match: true, reason: null };
633
+ }
634
+
635
+ if (!bothSuccess) {
636
+ return { match: false, reason: "pass_outcome_mismatch" };
637
+ }
638
+
639
+ const wasmErrorCode = wasmStatus.errorCode || null;
640
+ const nativeErrorCode = nativeStatus.errorCode || null;
641
+ if (wasmErrorCode !== nativeErrorCode) {
642
+ return { match: false, reason: "error_code_mismatch" };
643
+ }
644
+
645
+ if (wasmStatus.hasNormalTermination !== nativeStatus.hasNormalTermination) {
646
+ return { match: false, reason: "termination_marker_mismatch" };
647
+ }
648
+ const wasmHasOp2 = hasNonEmptyArtifact(wasmStatus, "OP2");
649
+ const nativeHasOp2 = hasNonEmptyArtifact(nativeStatus, "OP2");
650
+ if (wasmHasOp2 !== nativeHasOp2) {
651
+ return { match: false, reason: "op2_presence_mismatch" };
652
+ }
653
+ } else if (wasmOutcome !== nativeOutcome) {
654
+ return { match: false, reason: "outcome_mismatch" };
655
+ }
656
+
657
+ return { match: true, reason: null };
658
+ }
659
+
660
+ async function runQuickSmoke(repoRoot) {
661
+ const distJs = path.join(repoRoot, "dist", "mystran.js");
662
+ const distWasm = path.join(repoRoot, "dist", "mystran.wasm");
663
+ const deckLabel = "Bush_Bar/bar_01.DAT";
664
+ const deckPath = path.join(
665
+ repoRoot,
666
+ "sources",
667
+ "MYSTRAN_Benchmark",
668
+ "Benchmark_Decks",
669
+ "Bush_Bar",
670
+ "bar_01.DAT"
671
+ );
672
+
673
+ if (!fileExists(distJs)) {
674
+ console.error("[smoke] Missing dist/mystran.js. Build first:");
675
+ console.error(" scripts\\build.ps1");
676
+ process.exit(2);
677
+ }
678
+ if (!fileExists(distWasm)) {
679
+ console.error("[smoke] Missing dist/mystran.wasm. Build first:");
680
+ console.error(" scripts\\build.ps1");
681
+ process.exit(2);
682
+ }
683
+ if (!fileExists(deckPath)) {
684
+ console.error("[smoke] Missing benchmark deck:");
685
+ console.error(` ${deckPath}`);
686
+ console.error(" Run scripts\\fetch_deps.ps1 first.");
687
+ process.exit(2);
688
+ }
689
+
690
+ const nativeContext = createNativeRunnerContext(repoRoot);
691
+ let mystran = null;
692
+ let combinedText = "";
693
+
694
+ try {
695
+ const deckText = fs.readFileSync(deckPath, "utf8");
696
+ const startedAt = process.hrtime.bigint();
697
+ mystran = await WebMYSTRAN.load({
698
+ moduleUrl: pathToFileURL(distJs).href,
699
+ wasmUrl: pathToFileURL(distWasm).href
700
+ });
701
+
702
+ const result = mystran.run(deckText, {
703
+ workDir: "/work",
704
+ deckFileName: RUNTIME_DECK_FILE_NAME
705
+ });
706
+ const wasmElapsedSec = Number(process.hrtime.bigint() - startedAt) / 1e9;
707
+
708
+ const problems = [];
709
+ const { raw, output } = result;
710
+ combinedText = output.text || "";
711
+
712
+ if (raw.exitCode !== 0) {
713
+ problems.push(`exit code ${raw.exitCode}`);
714
+ }
715
+ if (!output.hasNormalTermination) {
716
+ problems.push("missing normal termination marker");
717
+ }
718
+ if (output.errorCodes.length > 0) {
719
+ problems.push(`solver error code(s): ${output.errorCodes.join(", ")}`);
720
+ }
721
+ if (!result.outputs.F06) {
722
+ problems.push("missing F06 output");
723
+ }
724
+ if (!result.outputs.OP2) {
725
+ problems.push("missing OP2 output");
726
+ }
727
+
728
+ let f06Text = "";
729
+ if (result.outputs.F06) {
730
+ f06Text = mystran.readOutput(result, "F06", "utf8");
731
+ if (!/MYSTRAN END/i.test(f06Text)) {
732
+ problems.push("F06 missing MYSTRAN END marker");
733
+ }
734
+ if (!/Total cpu time/i.test(f06Text)) {
735
+ problems.push("F06 missing total CPU time marker");
736
+ }
737
+ }
738
+
739
+ const resultArtifacts = Array.isArray(result.files)
740
+ ? result.files.map((entry) => ({
741
+ extension: String(entry.extension || "").toUpperCase(),
742
+ length: Number(entry.size || 0)
743
+ }))
744
+ : [];
745
+ const wasmWorkerResult = {
746
+ exitCode: Number.isInteger(raw.exitCode) ? raw.exitCode : 1,
747
+ elapsedSec: wasmElapsedSec,
748
+ stdout: raw.stdout || "",
749
+ stderr: raw.stderr || "",
750
+ consoleText: output.text || "",
751
+ hasNormalTermination: Boolean(output.hasNormalTermination),
752
+ runtimeAbort: Boolean(output.hasRuntimeAbort),
753
+ solverFailure: Boolean(output.hasSolverError),
754
+ errorCode: output.firstErrorCode || null,
755
+ f06Present: Boolean(result.outputs.F06),
756
+ f06Length: f06Text.length,
757
+ nonEmptyResultFileCount: resultArtifacts.filter((entry) => entry.length > 0).length,
758
+ resultArtifacts
759
+ };
760
+ const wasmStatus = evaluateRunStatus(deckLabel, wasmWorkerResult, raw.exitCode, false, output.text || "");
761
+
762
+ const nativeRun = runNativeDeckCase(nativeContext, deckPath, 1800);
763
+ const nativeStatus = evaluateRunStatus(
764
+ deckLabel,
765
+ nativeRun.workerResult,
766
+ nativeRun.exitCode,
767
+ nativeRun.timedOut,
768
+ nativeRun.consoleText
769
+ );
770
+
771
+ const comparison = evaluateNativeCompare(wasmStatus, nativeStatus);
772
+ if (!PASS_OUTCOMES.has(nativeStatus.outcome)) {
773
+ problems.push(`native outcome ${nativeStatus.outcome}`);
774
+ }
775
+ if (!comparison.match) {
776
+ problems.push(`native compare mismatch (${comparison.reason})`);
777
+ }
778
+
779
+ const isPass = problems.length === 0;
780
+ const lineState = isPass ? "PASS" : "FAIL";
781
+ const cmpToken = comparison.match ? "match" : `mismatch:${comparison.reason}`;
782
+ const codeToken =
783
+ wasmStatus.errorCode || nativeStatus.errorCode
784
+ ? ` error=${wasmStatus.errorCode ?? "none"}/${nativeStatus.errorCode ?? "none"}`
785
+ : "";
786
+ console.log(
787
+ `[smoke][0001/0001] ${lineState} deck=${deckLabel} wasm=${wasmStatus.outcome} native=${nativeStatus.outcome} cmp=${cmpToken} exit=${wasmStatus.exitCode}/${nativeStatus.exitCode} time=${wasmElapsedSec.toFixed(3)}s/${nativeRun.elapsedSec.toFixed(3)}s f06=${wasmStatus.f06Length}/${nativeStatus.f06Length} files=${wasmStatus.nonEmptyResultFileCount}/${nativeStatus.nonEmptyResultFileCount}${codeToken}`
788
+ );
789
+ console.log(
790
+ `Smoke summary: total=1 pass=${isPass ? 1 : 0} fail=${isPass ? 0 : 1} wasmWall=${wasmElapsedSec.toFixed(3)}s nativeWall=${nativeRun.elapsedSec.toFixed(3)}s`
791
+ );
792
+
793
+ if (!isPass) {
794
+ for (const problem of problems) {
795
+ console.error(` - ${problem}`);
796
+ }
797
+ console.error("");
798
+ console.error("--- tail of wasm combined console output ---");
799
+ console.error(tailLines(combinedText, 120));
800
+ console.error("-------------------------------------------");
801
+ process.exit(1);
802
+ }
803
+ } finally {
804
+ if (mystran) {
805
+ mystran.destroy();
806
+ }
807
+ }
808
+ }
809
+
810
+ function runFullSmoke(repoRoot, forwardedArgs) {
811
+ const options = parseFullSmokeArgs(forwardedArgs);
812
+ const distDir = path.join(repoRoot, "dist");
813
+ const benchDeckDir = path.join(repoRoot, "sources", "MYSTRAN_Benchmark", "Benchmark_Decks");
814
+ const runnerScript = fileURLToPath(import.meta.url);
815
+
816
+ const distJs = path.join(distDir, "mystran.js");
817
+ const webmystranWasm = path.join(distDir, "mystran.wasm");
818
+ for (const requiredPath of [distJs, webmystranWasm, benchDeckDir]) {
819
+ if (!fileExists(requiredPath)) {
820
+ throw new Error(`Missing required path: ${requiredPath}`);
821
+ }
822
+ }
823
+
824
+ const allDeckAbs = listDeckFilesRecursive(benchDeckDir);
825
+ const deckSpecs =
826
+ options.decks.length > 0
827
+ ? options.decks
828
+ : allDeckAbs.map((deckPath) => toBenchRelativePath(benchDeckDir, deckPath));
829
+
830
+ if (deckSpecs.length === 0) {
831
+ throw new Error(`No benchmark decks found under ${benchDeckDir}`);
832
+ }
833
+
834
+ const nativeContext = createNativeRunnerContext(repoRoot);
835
+ const runnerFailures = [];
836
+ const outcomeFailures = [];
837
+ let pairPassCount = 0;
838
+ let wasmPassCount = 0;
839
+ let nativePassCount = 0;
840
+ let ignoredBothNonSuccessCount = 0;
841
+ let wasmTotalWallSec = 0;
842
+ let nativeTotalWallSec = 0;
843
+
844
+ let deckIndex = 0;
845
+ const totalDecks = deckSpecs.length;
846
+ for (const deckSpec of deckSpecs) {
847
+ deckIndex += 1;
848
+ const deckPrefix = `[smoke][${String(deckIndex).padStart(4, "0")}/${String(totalDecks).padStart(4, "0")}]`;
849
+ const deckPath = resolveDeckPath(deckSpec, benchDeckDir, allDeckAbs);
850
+ if (!deckPath) {
851
+ const message = `Deck not found: ${deckSpec}`;
852
+ runnerFailures.push(message);
853
+ console.log(`${deckPrefix} FAIL deck=${deckSpec} wasm=deck_not_found native=deck_not_found cmp=mismatch:deck_not_found exit=1/1 time=0.000s/0.000s f06=0/0 files=0/0`);
854
+ continue;
855
+ }
856
+
857
+ const deckLabel = toBenchRelativePath(benchDeckDir, deckPath);
858
+ const wasmRun = runWasmDeckCase(
859
+ deckPath,
860
+ runnerScript,
861
+ options.timeoutSeconds,
862
+ distJs,
863
+ webmystranWasm
864
+ );
865
+ const nativeRun = runNativeDeckCase(nativeContext, deckPath, options.timeoutSeconds);
866
+ wasmTotalWallSec += wasmRun.elapsedSec;
867
+ nativeTotalWallSec += nativeRun.elapsedSec;
868
+
869
+ const wasmStatus = evaluateRunStatus(
870
+ deckLabel,
871
+ wasmRun.workerResult,
872
+ wasmRun.exitCode,
873
+ wasmRun.timedOut,
874
+ wasmRun.consoleText
875
+ );
876
+ const nativeStatus = evaluateRunStatus(
877
+ deckLabel,
878
+ nativeRun.workerResult,
879
+ nativeRun.exitCode,
880
+ nativeRun.timedOut,
881
+ nativeRun.consoleText
882
+ );
883
+
884
+ const comparison = evaluateNativeCompare(wasmStatus, nativeStatus);
885
+ const wasmPass = PASS_OUTCOMES.has(wasmStatus.outcome);
886
+ const nativePass = PASS_OUTCOMES.has(nativeStatus.outcome);
887
+ if (wasmPass) {
888
+ wasmPassCount += 1;
889
+ }
890
+ if (nativePass) {
891
+ nativePassCount += 1;
892
+ }
893
+
894
+ const bothNonSuccess = wasmStatus.outcome !== "success" && nativeStatus.outcome !== "success";
895
+ const isPass = bothNonSuccess || (wasmPass && nativePass && comparison.match);
896
+ if (isPass) {
897
+ pairPassCount += 1;
898
+ if (bothNonSuccess) {
899
+ ignoredBothNonSuccessCount += 1;
900
+ }
901
+ } else {
902
+ outcomeFailures.push(
903
+ `${deckLabel} failed: wasm=${wasmStatus.outcome} native=${nativeStatus.outcome} cmp=${comparison.match ? "match" : comparison.reason} wasmExit=${wasmStatus.exitCode} nativeExit=${nativeStatus.exitCode} wasmError=${wasmStatus.errorCode ?? "none"} nativeError=${nativeStatus.errorCode ?? "none"}`
904
+ );
905
+ }
906
+
907
+ const lineState = isPass ? "PASS" : "FAIL";
908
+ const cmpToken = bothNonSuccess
909
+ ? "ignored:both_non_success"
910
+ : comparison.match
911
+ ? "match"
912
+ : `mismatch:${comparison.reason}`;
913
+ const lineError =
914
+ wasmStatus.errorCode || nativeStatus.errorCode
915
+ ? ` error=${wasmStatus.errorCode ?? "none"}/${nativeStatus.errorCode ?? "none"}`
916
+ : "";
917
+ console.log(
918
+ `${deckPrefix} ${lineState} deck=${deckLabel} wasm=${wasmStatus.outcome} native=${nativeStatus.outcome} cmp=${cmpToken} exit=${wasmStatus.exitCode}/${nativeStatus.exitCode} time=${wasmRun.elapsedSec.toFixed(3)}s/${nativeRun.elapsedSec.toFixed(3)}s f06=${wasmStatus.f06Length}/${nativeStatus.f06Length} files=${wasmStatus.nonEmptyResultFileCount}/${nativeStatus.nonEmptyResultFileCount}${lineError}`
919
+ );
920
+ }
921
+
922
+ const hasFailures = runnerFailures.length > 0 || outcomeFailures.length > 0;
923
+ if (runnerFailures.length > 0) {
924
+ console.log("");
925
+ console.log("Run infrastructure failures:");
926
+ for (const failure of runnerFailures) {
927
+ console.log(` - ${failure}`);
928
+ }
929
+ }
930
+ if (outcomeFailures.length > 0) {
931
+ console.log("");
932
+ console.log("Outcome failures:");
933
+ for (const failure of outcomeFailures) {
934
+ console.log(` - ${failure}`);
935
+ }
936
+ }
937
+
938
+ const failedCount = deckSpecs.length - pairPassCount;
939
+ console.log("");
940
+ console.log(
941
+ `Smoke summary: total=${deckSpecs.length} pass=${pairPassCount} fail=${failedCount} wasmPass=${wasmPassCount} nativePass=${nativePassCount} ignoredBothNonSuccess=${ignoredBothNonSuccessCount} wasmWall=${Number(wasmTotalWallSec.toFixed(3))}s nativeWall=${Number(nativeTotalWallSec.toFixed(3))}s`
942
+ );
943
+
944
+ if (hasFailures) {
945
+ throw new Error(
946
+ `Smoke test failed (pairPass=${pairPassCount}, pairFail=${failedCount}, wasmPass=${wasmPassCount}, nativePass=${nativePassCount}, ignoredBothNonSuccess=${ignoredBothNonSuccessCount}).`
947
+ );
948
+ }
949
+ }
950
+
951
+ async function main() {
952
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
953
+ const repoRoot = path.resolve(__dirname, "..");
954
+ const args = process.argv.slice(2);
955
+ const mode = (args[0] || "quick").toLowerCase();
956
+
957
+ if (mode === "__run_deck") {
958
+ await runDeckWorker(args.slice(1));
959
+ return;
960
+ }
961
+
962
+ if (mode === "quick") {
963
+ await runQuickSmoke(repoRoot);
964
+ return;
965
+ }
966
+ if (mode === "full") {
967
+ runFullSmoke(repoRoot, args.slice(1));
968
+ return;
969
+ }
970
+
971
+ throw new Error(`Unknown command "${args[0]}". Supported: quick, full`);
972
+ }
973
+
974
+ main().catch((error) => {
975
+ console.error("[smoke] FAIL");
976
+ console.error(error && error.message ? error.message : error);
977
+ process.exit(1);
978
+ });