jscd 0.2.1

Reverse bytenode-compiled .jsc (V8 code cache) back to JavaScript
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
//! CLI 行为验收:help / 版本 / 语言切换 / 文件与目录两种默认路径 / 错误码。
//!
//! 需要 `.jsc` 的用例会现场用 `node scripts/mkcorpus.js` 编译一份(对齐 bytenode 的做法);
//! 环境里没有 `node` 时这些用例**跳过**而不是失败 —— CI 里只跑 `cargo test` 也能过。

use std::path::{Path, PathBuf};
use std::process::{Command, Output};

fn jscd(args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_jscd"))
        .args(args)
        .output()
        .expect("run jscd")
}

/// 带环境变量的调用(语言相关的用例靠它,不动进程级 env)。
fn jscd_env(args: &[&str], envs: &[(&str, &str)]) -> Output {
    let mut c = Command::new(env!("CARGO_BIN_EXE_jscd"));
    c.args(args);
    for (k, v) in envs {
        c.env(k, v);
    }
    c.output().expect("run jscd")
}

fn out(o: &Output) -> String {
    String::from_utf8_lossy(&o.stdout).to_string()
}

fn err(o: &Output) -> String {
    String::from_utf8_lossy(&o.stderr).to_string()
}

const REPO: &str = "https://github.com/ejfkdev/jscd";

#[test]
fn empty_args_print_help() {
    let o = jscd(&[]);
    assert!(o.status.success(), "空参数应退出 0");
    let s = out(&o);
    assert!(s.contains("Usage:") || s.contains("用法:"), "help 里要有 Usage:{s}");
    assert!(s.contains(REPO), "help 里要有仓库地址");
    assert!(s.contains("jscd "), "help 里要有名字与版本");
    assert!(s.contains("Examples:") || s.contains("示例:"), "help 里要有示例");
}

#[test]
fn help_flags_and_subcommand_help() {
    for args in [
        vec!["-h"],
        vec!["--help"],
        vec!["help"],
        vec!["help", "info"],
        vec!["info", "--help"],
    ] {
        let o = jscd(&args);
        assert!(o.status.success(), "{args:?} 应退出 0");
        assert!(out(&o).contains(REPO), "{args:?} 要带仓库地址");
    }
    // 子命令帮助要收缩到该子命令
    let s = out(&jscd(&["help", "info"]));
    assert!(s.contains("info"), "子命令帮助要提到 info");
    assert!(s.contains("用法:") || s.contains("Usage:"), "子命令帮助要有用法行");
}

#[test]
fn version_flags() {
    for args in [vec!["-v"], vec!["-V"], vec!["--version"], vec!["version"]] {
        let o = jscd(&args);
        assert!(o.status.success(), "{args:?} 应退出 0");
        let s = out(&o);
        assert!(s.contains("jscd "), "{args:?} 要打印名字");
        assert!(s.contains(REPO), "{args:?} 要打印仓库地址");
    }
}

#[test]
fn language_follows_env() {
    // 显式中文
    let zh = out(&jscd_env(&["-v"], &[("JSCD_LANG", "zh"), ("LC_ALL", "")]));
    assert!(zh.contains("仓库:"), "JSCD_LANG=zh → 中文:{zh}");
    // 显式英文压过 LANG=zh
    let en = out(&jscd_env(
        &["-v"],
        &[("JSCD_LANG", "en"), ("LANG", "zh_CN.UTF-8"), ("LC_ALL", "")],
    ));
    assert!(en.contains("repo:"), "JSCD_LANG=en → 英文:{en}");
    // 繁体/港澳也算中文
    for loc in ["zh_TW.UTF-8", "zh_HK", "zh_MO", "zh_SG"] {
        let s = out(&jscd_env(&["-v"], &[("LC_ALL", loc), ("JSCD_LANG", "")]));
        assert!(s.contains("仓库:"), "LC_ALL={loc} → 中文:{s}");
    }
    // 其它语言 → 英文
    for loc in ["fr_FR.UTF-8", "ja_JP", "en_US.UTF-8"] {
        let s = out(&jscd_env(&["-v"], &[("LC_ALL", loc), ("JSCD_LANG", "")]));
        assert!(s.contains("repo:"), "LC_ALL={loc} → 英文:{s}");
    }
}

#[test]
fn usage_errors_are_localized_and_exit_2() {
    let o = jscd(&["--definitely-not-a-flag"]);
    assert_eq!(o.status.code(), Some(2), "未知选项应退出 2");
    assert!(err(&o).contains("--definitely-not-a-flag"), "错误里要点名:{}", err(&o));

    let o = jscd_env(&["--definitely-not-a-flag"], &[("JSCD_LANG", "en")]);
    assert!(err(&o).contains("unknown option"), "英文错误:{}", err(&o));

    let o = jscd_env(&["--definitely-not-a-flag"], &[("JSCD_LANG", "zh")]);
    assert!(err(&o).contains("未知选项"), "中文错误:{}", err(&o));
}

#[test]
fn output_flag_conflict_exits_2() {
    let o = jscd(&["a.jsc", "-o", "x.js", "y.js"]);
    assert_eq!(o.status.code(), Some(2));
    assert!(err(&o).contains("冲突") || err(&o).contains("conflict"), "{}", err(&o));
}

#[test]
fn missing_input_exits_1() {
    let o = jscd(&["/definitely/not/here.jsc"]);
    assert_eq!(o.status.code(), Some(1), "读不了输入应退出 1");
}

#[test]
fn empty_directory_reports_no_jsc() {
    let dir = temp_dir("empty");
    std::fs::create_dir_all(&dir).unwrap();
    let o = jscd(&[dir.to_str().unwrap()]);
    assert_eq!(o.status.code(), Some(1));
    let e = err(&o);
    assert!(e.contains(".jsc"), "错误里要说明没找到 .jsc:{e}");
    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn directory_with_dash_output_is_rejected() {
    let dir = temp_dir("dash");
    std::fs::create_dir_all(&dir).unwrap();
    let o = jscd(&[dir.to_str().unwrap(), "-"]);
    assert_ne!(o.status.code(), Some(0), "目录 + '-' 应报错");
    let _ = std::fs::remove_dir_all(&dir);
}

// ── 需要 node 编译 fixture 的用例:没有 node 就跳过 ──────────────────────────

fn node() -> Option<String> {
    let o = Command::new("node").arg("--version").output().ok()?;
    if o.status.success() {
        Some("node".to_string())
    } else {
        None
    }
}

fn temp_dir(tag: &str) -> PathBuf {
    std::env::temp_dir().join(format!("jscd-cli-{tag}-{}", std::process::id()))
}

fn compile_fixture(node: &str, src_rel: &str, dest: &Path) -> bool {
    let root = Path::new(env!("CARGO_MANIFEST_DIR"));
    if let Some(p) = dest.parent() {
        let _ = std::fs::create_dir_all(p);
    }
    Command::new(node)
        .arg(root.join("scripts/mkcorpus.js"))
        .arg(root.join(src_rel))
        .arg(dest)
        .output()
        .map(|o| o.status.success() && dest.exists())
        .unwrap_or(false)
}

#[test]
fn file_input_defaults_to_stdout_and_writes_files() {
    let Some(node) = node() else {
        eprintln!("跳过:环境里没有 node");
        return;
    };
    let dir = temp_dir("file");
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let jsc = dir.join("app.jsc");
    if !compile_fixture(&node, "tests/fixtures/behav/arith.js", &jsc) {
        eprintln!("跳过:fixture 编译失败");
        return;
    }

    // 默认 → stdout
    let o = jscd(&[jsc.to_str().unwrap()]);
    assert!(o.status.success(), "{}", err(&o));
    let s = out(&o);
    assert!(s.contains("function"), "stdout 应是重建的 JS:{}", &s[..s.len().min(200)]);
    assert!(err(&o).is_empty(), "stdout 模式不该有诊断输出:{}", err(&o));

    // 指定文件
    let dest = dir.join("app.js");
    let o = jscd(&[jsc.to_str().unwrap(), dest.to_str().unwrap(), "--quiet"]);
    assert!(o.status.success(), "{}", err(&o));
    assert!(dest.exists(), "应写出 app.js");
    assert_eq!(out(&o), "", "写文件时 stdout 应为空");

    // '-' 显式 stdout
    let o = jscd(&[jsc.to_str().unwrap(), "-"]);
    assert!(o.status.success());
    assert!(out(&o).contains("function"));

    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn electron_pointer_compressed_cache_decompiles() {
    // issue #1:Electron 19(V8 10.2.154.15)是**指针压缩**构建 —— V8 的 `ReadObject`
    // 里对象 size 的单位是 tagged(4 字节),字符串/BytecodeArray 头的字节偏移也随之
    // 变化。与 Node 在同一 V8 minor 上的非压缩构建不同,早先直接解析失败。
    let jsc = "tests/fixtures/jsc/electron19-v8_10.2.154.15.jsc";
    let o = jscd(&[jsc]);
    assert!(o.status.success(), "{}", err(&o));
    let s = out(&o);
    assert!(s.contains("module.exports = add"), "顶层语句应还原:{s}");
    assert!(s.contains("a0 + a1"), "函数体应还原:{s}");

    let o = jscd(&["info", jsc]);
    let info = out(&o);
    assert!(info.contains("10.2.154.15"), "{info}");
    assert!(info.contains("supported:     yes"), "{info}");
}

#[test]
fn electron_pointer_compressed_names_resolve_with_a_build_ro_map() {
    // 压缩构建(Electron / Chrome)从只读堆里取名字,且只读堆的坐标是**逐构建**的:
    // 给一份同构建探针生成的 --ro-map 才能还原真名(issue #1 的延伸)。
    let jsc = "tests/fixtures/jsc/electron19-call_once.jsc";
    let ro = "tests/fixtures/jsc/electron19-ro-map.json";
    let o = jscd(&["--ro-map", ro, jsc]);
    assert!(o.status.success(), "{}", err(&o));
    let s = out(&o);
    assert!(!s.contains("<ro0_"), "配了同构建名表就不该有占位:{s}");
    assert!(s.contains("join"), "属性名应还原:{s}");
}

#[test]
fn ro_map_reports_build_specific_string_roots() {
    // 属性名也可能是 **root**("0"、"length" 这类常驻字符串),而 `String:` 段在不同构建里
    // 会整体位移:Electron 19 的 `String:0` 是 root 571,Node 侧名表把 571 记成 `years`。
    // `jscd ro-map` 必须把探针认领到的根一并写出来,否则压缩构建的字符串常量会静默取错值。
    let probe = "tests/fixtures/jsc/electron19-roots-probe.jsc";
    let o = jscd(&["ro-map", probe]);
    assert!(o.status.success(), "{}", err(&o));
    let v: serde_json::Value = serde_json::from_str(&out(&o)).expect("ro-map 输出应是 JSON");
    let roots = v
        .get("roots")
        .and_then(|r| r.as_object())
        .expect("ro-map 要有 roots 段");
    assert_eq!(
        roots.get("571").and_then(|s| s.as_str()),
        Some("0"),
        "探针 p_0 的键就是该构建的 String:0:{roots:?}"
    );
}

#[test]
fn electron_pointer_compressed_string_roots_follow_the_build_ro_map() {
    // 同一份 `"0"` 字面量:Electron 19 存成 root 571,Node 侧名表在那个下标上写的是
    // `years`。带上同构建的 --ro-map 后必须还原成 "0"。
    let jsc = "tests/fixtures/jsc/electron19-string_regex.jsc";
    let ro = "tests/fixtures/jsc/electron19-ro-map.json";
    let o = jscd(&["--ro-map", ro, jsc]);
    assert!(o.status.success(), "{}", err(&o));
    let s = out(&o);
    assert!(s.contains("padStart(4, \"0\")"), "字符串根应按本构建名表还原:{s}");
    assert!(!s.contains("\"years\""), "不该套用别的构建的名字:{s}");
}

#[test]
fn electron22_pointer_compressed_cache_ignores_node_ro_map() {
    // Electron 22 与 Node 19.9 共用 V8 10.8.168.25,但 Electron 是指针压缩构建 ——
    // 只读堆坐标完全不同。内嵌名表(Node 探针建的)**不能**套用:错名字是静默的。
    let jsc = "tests/fixtures/jsc/electron22-v8_10.8.168.25.jsc";
    let o = jscd(&[jsc]);
    assert!(o.status.success(), "{}", err(&o));
    let s = out(&o);
    assert!(s.contains("<ro0_"), "压缩构建应保留占位而不是套用 Node 的名字:{s}");
    assert!(s.contains("function tick"), "结构仍要能解:{s}");

    let info = out(&jscd(&["info", jsc]));
    assert!(
        info.contains("non-pointer-compressed") || info.contains("指针压缩"),
        "info 要说明内嵌表为什么不套用:{info}"
    );
}

#[test]
fn electron22_ro_map_resolves_names_and_string_roots() {
    // 同构建探针建的表要同时修两类坐标:只读堆偏移(entries)与字符串根(roots)。
    let jsc = "tests/fixtures/jsc/electron22-v8_10.8.168.25.jsc";
    let ro = "tests/fixtures/jsc/electron22-ro-map.json";
    let o = jscd(&["--ro-map", ro, jsc]);
    assert!(o.status.success(), "{}", err(&o));
    let s = out(&o);
    // 探针覆盖到的名字都要还原(没探到的字符串常量留占位属于预期)
    for off in ["16508", "16524", "16540", "35916"] {
        assert!(!s.contains(&format!("<ro0_{off}>")), "该偏移应已还原:{s}");
    }
    assert!(s.contains("a: void 0"), "对象字面量键应还原:{s}");
    assert!(s.contains("\"c\""), "计算键里的字符串应还原:{s}");
    assert!(s.contains("\"join\"") || s.contains(".join"), "方法名应还原:{s}");

    // 同一张表里的 roots 段:探针 `p_0` 认领该构建的 `"0"` 根。
    let probe = "tests/fixtures/jsc/electron22-roots-probe.jsc";
    let o = jscd(&["ro-map", probe]);
    assert!(o.status.success(), "{}", err(&o));
    let v: serde_json::Value = serde_json::from_str(&out(&o)).expect("ro-map 输出应是 JSON");
    let roots = v.get("roots").and_then(|r| r.as_object()).expect("要有 roots");
    assert_eq!(roots.get("593").and_then(|s| s.as_str()), Some("0"), "{roots:?}");
}

#[test]
fn electron_20_to_45_closures_decompile() {
    // Electron 20–45(V8 10.4–15.6;28 起带 V8 sandbox,20–26 不带):
    // 20–26 的 torque map 段分别是 25/34 条(默认 36 会让 protector 根错位),
    // 12.0 的版本哈希还是右折叠、ScopeType 新枚举从 12.8 开始 —— 都走这条链。
    // 表按 Chromium DEPS 的 v8_revision + Electron 的补丁集校准,这里锁住闭包这条链 ——
    // 15.x 的 ScopeInfo.has_outer 位(15.0 是 22、15.2+ 是 21)、裸 int32 的数组长度、
    // TDZ hole 重命名、`GeneratorYieldResult` 的 yield 形态都走这一路。
    // Electron 41 与 Node 26 共用 14.6.202.34 的 version_hash 但沙箱布局不同 ——
    // 这条用例同时锁住"多候选表按字节码量打分挑表"。
    for (jsc, ro, label) in [
        ("electron20-closure.jsc", "electron20-ro-map.json", "electron 20.3.12"),
        ("electron21-closure.jsc", "electron21-ro-map.json", "electron 21.4.4"),
        ("electron22-closure.jsc", "electron22-closure-ro-map.json", "electron 22.3.27"),
        ("electron23-closure.jsc", "electron23-ro-map.json", "electron 23.3.13"),
        ("electron24-closure.jsc", "electron24-ro-map.json", "electron 24.8.8"),
        ("electron25-closure.jsc", "electron25-ro-map.json", "electron 25.9.8"),
        ("electron26-closure.jsc", "electron26-ro-map.json", "electron 26.6.10"),
        ("electron27-closure.jsc", "electron27-closure-ro-map.json", "electron 27.3.11"),
        ("electron28-closure.jsc", "electron28-ro-map.json", "electron 28.3.3"),
        ("electron29-closure.jsc", "electron29-ro-map.json", "electron 29.4.6"),
        ("electron30-closure.jsc", "electron30-ro-map.json", "electron 30.5.1"),
        ("electron31-closure.jsc", "electron31-ro-map.json", "electron 31.7.7"),
        ("electron32-closure.jsc", "electron32-ro-map.json", "electron 32.3.3"),
        ("electron33-closure.jsc", "electron33-ro-map.json", "electron 33.4.11"),
        ("electron34-closure.jsc", "electron34-ro-map.json", "electron 34.5.8"),
        ("electron35-closure.jsc", "electron35-ro-map.json", "electron 35.7.5"),
        ("electron36-closure.jsc", "electron36-ro-map.json", "electron 36.9.5"),
        (
            "electron37-closure.jsc",
            "electron37-ro-map.json",
            "electron 37.10.3",
        ),
        (
            "electron38-closure.jsc",
            "electron38-ro-map.json",
            "electron 38.8.6",
        ),
        (
            "electron39-closure.jsc",
            "electron39-ro-map.json",
            "electron 39.8.10",
        ),
        (
            "electron40-closure.jsc",
            "electron40-ro-map.json",
            "electron 40.10.6",
        ),
        (
            "electron41-closure.jsc",
            "electron41-ro-map.json",
            "electron 41.10.7",
        ),
        (
            "electron42-v8_14.8.178.38.jsc",
            "electron42-ro-map.json",
            "electron 42.11.18",
        ),
        (
            "electron43-closure.jsc",
            "electron43-ro-map.json",
            "electron 43.8.3",
        ),
        (
            "electron44-v8_15.2.124.43.jsc",
            "electron44-ro-map.json",
            "electron 44.9.3",
        ),
        (
            "electron45-v8_15.6.75.19.jsc",
            "electron45-ro-map.json",
            "electron 45.0.0",
        ),
    ] {
        let j = format!("tests/fixtures/jsc/{jsc}");
        let r = format!("tests/fixtures/jsc/{ro}");
        let o = jscd(&["--ro-map", &r, &j]);
        assert!(o.status.success(), "{jsc}: {}", err(&o));
        let s = out(&o);
        assert!(s.contains("n = a0"), "{jsc}: 上下文赋值丢了:{s}");
        assert!(s.contains("function inc"), "{jsc}: 闭包名没还原:{s}");
        assert!(s.contains("return n = ++n"), "{jsc}: 捕获变量没绑定:{s}");

        let info = out(&jscd(&["info", &j]));
        assert!(info.contains(label), "{jsc}: 版本标签应是 {label}:{info}");
        assert!(info.contains("supported:     yes") || info.contains("supported: yes"), "{info}");
    }
}

#[test]
fn directory_input_mirrors_tree_into_input_out() {
    let Some(node) = node() else {
        eprintln!("跳过:环境里没有 node");
        return;
    };
    let base = temp_dir("dir");
    let _ = std::fs::remove_dir_all(&base);
    let src = base.join("dist");
    std::fs::create_dir_all(src.join("sub/deep")).unwrap();
    for (rel, fixture) in [
        ("a.jsc", "tests/fixtures/behav/arith.js"),
        ("sub/b.jsc", "tests/fixtures/behav/strings.js"),
        ("sub/deep/c.jsc", "tests/fixtures/behav/closure.js"),
    ] {
        if !compile_fixture(&node, fixture, &src.join(rel)) {
            eprintln!("跳过:fixture 编译失败");
            return;
        }
    }
    // 无关文件不应被处理
    std::fs::write(src.join("note.txt"), "x").unwrap();

    let o = jscd(&[src.to_str().unwrap(), "--quiet"]);
    assert!(o.status.success(), "{}", err(&o));
    let out_root = base.join("dist-out");
    for rel in ["a.js", "sub/b.js", "sub/deep/c.js"] {
        assert!(out_root.join(rel).exists(), "应有 {rel}");
    }
    assert!(!out_root.join("note.txt").exists(), "非 .jsc 不该被处理");

    // 指定输出目录
    let mine = base.join("mine");
    let o = jscd(&[
        src.to_str().unwrap(),
        mine.to_str().unwrap(),
        "--quiet",
    ]);
    assert!(o.status.success(), "{}", err(&o));
    assert!(mine.join("sub/deep/c.js").exists(), "指定目录也要保层级");

    let _ = std::fs::remove_dir_all(&base);
}
/// bytenode 的 `.jsc` 是 CommonJS 源码被 `Module.wrap` 包一层后编译的:
/// 顶层是"闭包工厂",运行时真正调用的是包装函数 `(exports, require, module,
/// __filename, __dirname)`。摊平它,产物才是"加载即执行"的模块。
///
/// 这里刻意用**顶层赋值给 var**(而不是 `console.log`):属性的名字在 13.x 上可能
/// 落在只读堆里、要 `--ro-map` 才解得出,而这条用例要验证的是"顶层代码有没有被摊平"。
#[test]
fn cjs_wrapped_jsc_runs_top_level_code() {
    let Some(node) = node() else {
        eprintln!("跳过:环境里没有 node");
        return;
    };
    let dir = temp_dir("wrapper");
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let src = dir.join("hello.js");
    // 显式写到一个自定义全局名上:自定义属性名在常量池里(不像 `log` 这类内建名
    // 在 13.x 会落到只读堆、需要 --ro-map),所以这条用例与版本无关。
    std::fs::write(
        &src,
        "function greet(name) { return \"hello \" + name; }\n\
         globalThis.__jscd_hello = greet(\"world\");\n",
    )
    .unwrap();

    // --module = Module.wrap 包一层(与 bytenode --compile 同形)
    let root = Path::new(env!("CARGO_MANIFEST_DIR"));
    let jsc = dir.join("hello.jsc");
    let ok = Command::new(&node)
        .arg(root.join("scripts/mkcorpus.js"))
        .arg(&src)
        .arg(&jsc)
        .arg("--module")
        .output()
        .map(|o| o.status.success() && jsc.exists())
        .unwrap_or(false);
    if !ok {
        eprintln!("跳过:fixture 编译失败");
        return;
    }

    let dest = dir.join("hello.out.js");
    // 产物要拿去跑 → 带上可运行前导(默认输出只给原始代码)
    let o = jscd(&[jsc.to_str().unwrap(), dest.to_str().unwrap(), "--quiet", "--runtime"]);
    assert!(o.status.success(), "{}", err(&o));
    let js = std::fs::read_to_string(&dest).unwrap();
    assert!(
        js.contains("// ── 模块/脚本顶层代码"),
        "顶层(包装函数体)应被摊平到文件级,而不是包成没人调用的函数"
    );

    // 把产物在 vm 里跑起来,读它留下的顶层变量
    let script = format!(
        "const fs=require('fs'),vm=require('vm');\n\
         const ctx=vm.createContext({{}});\n\
         vm.runInContext(fs.readFileSync({0:?},'utf8'),ctx,{{timeout:5000}});\n\
         process.stdout.write(String(ctx.__jscd_hello));\n",
        dest.to_str().unwrap()
    );
    let run = Command::new(&node).arg("-e").arg(&script).output().expect("run product");
    assert!(run.status.success(), "产物应能跑:{}", String::from_utf8_lossy(&run.stderr));
    assert_eq!(
        String::from_utf8_lossy(&run.stdout),
        "hello world",
        "顶层语句要真的执行"
    );

    let _ = std::fs::remove_dir_all(&dir);
}

/// Node 26 的二进制:`mise exec node@26.10.0`(V8 14.6)。没有就跳过(CI 里没有)。
fn node26() -> Option<String> {
    let o = Command::new("mise")
        .args(["exec", "node@26.10.0", "--", "which", "node"])
        .output()
        .ok()?;
    if !o.status.success() {
        return None;
    }
    let p = String::from_utf8_lossy(&o.stdout).trim().to_string();
    (!p.is_empty() && Path::new(&p).exists()).then_some(p)
}

#[test]
fn node26_product_is_recognised_and_decompiled() {
    // Node 26(V8 14.6)现在有表:认得出(exact)且能反编译出顶层函数。
    let Some(node26) = node26() else {
        eprintln!("跳过:环境里没有 node@26.10.0");
        return;
    };
    let dir = temp_dir("node26");
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let src = dir.join("greet.js");
    std::fs::write(&src, "function greet(n){ return 'hello ' + n; }\ngreet('world');\n").unwrap();
    let jsc = dir.join("greet.jsc");
    if !compile_fixture(&node26, src.to_str().unwrap(), &jsc) {
        eprintln!("跳过:fixture 编译失败");
        return;
    }
    let i = jscd(&["info", jsc.to_str().unwrap()]);
    let s = out(&i);
    assert!(s.contains("14.6.202.34"), "{s}");
    assert!(s.contains("supported:     yes"), "{s}");
    let _ = std::fs::remove_dir_all(&dir);
}


#[test]
fn runtime_prelude_is_stripped_by_default_and_opt_in_via_runtime_flag() {
    let Some(node) = node() else {
        eprintln!("跳过:环境里没有 node");
        return;
    };
    let dir = temp_dir("runtime-flag");
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let src = dir.join("greet.js");
    std::fs::write(&src, "function greet(n){ return 'hello ' + n; }\ngreet('world');\n").unwrap();
    let jsc = dir.join("greet.jsc");
    if !compile_fixture(&node, src.to_str().unwrap(), &jsc) {
        eprintln!("跳过:fixture 编译失败");
        return;
    }

    // 默认:只有原始代码,没有前导**定义**(正文里的 `__runtime.X(...)` 调用点是忠实于
    // 字节码的,不算前导)
    let plain = out(&jscd(&[jsc.to_str().unwrap()]));
    assert!(
        !plain.contains("var __runtime = new Proxy"),
        "默认不该带运行时前导:{plain}"
    );
    assert!(!plain.contains("var __intrinsic = new Proxy"), "{plain}");
    assert!(!plain.contains("// DefineClass(boilerplate"), "{plain}");
    assert!(plain.contains("function greet"), "原始代码要在:{plain}");
    assert!(plain.len() < 1200, "默认输出应只剩下原始代码:{} 字节", plain.len());

    // `--runtime`:补上可运行前导
    let full = out(&jscd(&[jsc.to_str().unwrap(), "--runtime"]));
    assert!(full.contains("var __runtime = new Proxy"), "--runtime 要带前导");
    assert!(full.contains("var __intrinsic = new Proxy"));
    assert!(full.len() > plain.len() + 3000, "--runtime 明显更长");

    let _ = std::fs::remove_dir_all(&dir);
}