From fb9f730034090ecca01c4a17adc8a40a539884e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 12 Jun 2026 10:50:26 +0200 Subject: [PATCH 01/42] add package exports/imports resolution --- README.md | 3 +- crates/wasm-rquickjs/skeleton/Cargo.lock | 284 +++++----- crates/wasm-rquickjs/skeleton/Cargo.toml_ | 4 +- .../skeleton/src/builtin/module.js | 228 +++++++- crates/wasm-rquickjs/skeleton/src/internal.rs | 509 ++++++++++++++++-- .../runtime/cjs-require/src/cjs-require.js | 189 +++++++ .../runtime/cjs-require/wit/cjs-require.wit | 3 + .../src/module-resolution.js | 143 +++++ .../wit/module-resolution.wit | 5 + tests/node_compat/config.jsonc | 6 +- tests/node_compat/report.md | 24 +- tests/runtime/cjs_require.rs | 51 ++ tests/runtime/main.rs | 2 + tests/runtime/module_resolution.rs | 29 + 14 files changed, 1291 insertions(+), 189 deletions(-) create mode 100644 examples/runtime/module-resolution/src/module-resolution.js create mode 100644 examples/runtime/module-resolution/wit/module-resolution.wit create mode 100644 tests/runtime/module_resolution.rs diff --git a/README.md b/README.md index 56269266..65851493 100644 --- a/README.md +++ b/README.md @@ -554,7 +554,8 @@ Compatibility stubs — no V8 inspector in WASM.
node:module -- `require`, `createRequire`, `builtinModules`, `isBuiltin`, `runMain`, `_nodeModulePaths` +- `require`, `require.resolve`, `createRequire`, `builtinModules`, `isBuiltin`, `runMain`, `_nodeModulePaths` +- Package resolution supports `package.json` `main`, exact `exports` root/subpath maps, and exact `imports` maps. CJS resolution recognizes `golem`, `node`, `require`, `module-sync`, and `default` conditions; ESM resolution recognizes `golem`, `node`, `import`, and `default`. Package `imports` can target relative files, external packages, and `node:` builtins.
diff --git a/crates/wasm-rquickjs/skeleton/Cargo.lock b/crates/wasm-rquickjs/skeleton/Cargo.lock index 54b26a34..8da5cab8 100644 --- a/crates/wasm-rquickjs/skeleton/Cargo.lock +++ b/crates/wasm-rquickjs/skeleton/Cargo.lock @@ -104,9 +104,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base16ct" @@ -142,15 +142,15 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", + "shlex 1.3.0", "syn", ] [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -203,9 +203,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -230,12 +230,12 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -291,9 +291,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "num-traits", ] @@ -470,9 +470,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -533,9 +533,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -616,9 +616,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "ff" @@ -767,7 +767,7 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand 2.3.0", + "fastrand 2.4.1", "futures-core", "futures-io", "parking", @@ -952,6 +952,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -978,9 +984,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1011,12 +1017,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1024,9 +1031,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1037,9 +1044,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1051,15 +1058,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1071,15 +1078,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1115,9 +1122,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1125,12 +1132,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1171,11 +1178,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -1217,9 +1225,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -1250,15 +1258,15 @@ dependencies = [ [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "md-5" @@ -1272,9 +1280,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "mime" @@ -1319,7 +1327,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "serde", "smallvec", "zeroize", @@ -1442,18 +1450,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -1506,9 +1514,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "poly1305" @@ -1535,9 +1543,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1611,9 +1619,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1622,9 +1630,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -1670,9 +1678,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -1693,9 +1701,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -1768,6 +1776,7 @@ dependencies = [ "golem-websocket", "hkdf", "hmac", + "indexmap", "k256", "md-5", "num-bigint-dig", @@ -1776,7 +1785,7 @@ dependencies = [ "p384", "pbkdf2", "pkcs8", - "rand 0.9.2", + "rand 0.9.4", "rand_core 0.6.4", "ripemd", "rquickjs", @@ -1784,6 +1793,8 @@ dependencies = [ "rusqlite", "scrypt", "sec1", + "serde", + "serde_json", "sha1", "sha2", "sha3", @@ -1861,9 +1872,9 @@ dependencies = [ [[package]] name = "rsqlite-vfs" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", "thiserror", @@ -1945,9 +1956,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1956,6 +1967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -1980,9 +1992,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2027,9 +2039,9 @@ dependencies = [ [[package]] name = "sha3" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ "digest", "keccak", @@ -2041,6 +2053,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signature" version = "2.2.0" @@ -2059,9 +2077,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -2071,9 +2089,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "spin" @@ -2093,9 +2111,9 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.2" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ "cc", "js-sys", @@ -2159,9 +2177,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2178,9 +2196,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.9+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime", @@ -2190,18 +2208,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -2211,9 +2229,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -2251,9 +2269,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -2295,11 +2313,11 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen 0.57.1", ] [[package]] @@ -2313,9 +2331,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -2326,9 +2344,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2336,9 +2354,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -2349,9 +2367,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] @@ -2441,9 +2459,9 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -2464,10 +2482,18 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "bitflags", "wit-bindgen-rust-macro 0.51.0", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags", +] + [[package]] name = "wit-bindgen-core" version = "0.42.1" @@ -2637,9 +2663,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wstd" @@ -2675,9 +2701,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2686,9 +2712,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -2698,18 +2724,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -2718,18 +2744,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -2745,9 +2771,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2756,9 +2782,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2767,9 +2793,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/crates/wasm-rquickjs/skeleton/Cargo.toml_ b/crates/wasm-rquickjs/skeleton/Cargo.toml_ index 1735b89c..d23e2435 100644 --- a/crates/wasm-rquickjs/skeleton/Cargo.toml_ +++ b/crates/wasm-rquickjs/skeleton/Cargo.toml_ @@ -96,6 +96,9 @@ ecdsa = { version = "0.16", default-features = false, features = ["signing", "ve signature = { version = "2", optional = true } futures = { version = "0.3.31", features = [] } futures-concurrency = "7.6.3" +indexmap = { version = "2", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" url = "2.5.7" uuid = { version = "1.18.1", features = ["v4"] } rand = "0.9.2" @@ -132,4 +135,3 @@ golem-websocket = { version = "0.0.2", optional = true } [patch.crates-io] rusqlite = { git = "https://github.com/golemcloud/rusqlite", branch = "v0.38.0-patched" } libsqlite3-sys = { git = "https://github.com/golemcloud/rusqlite", branch = "v0.38.0-patched" } - diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index c4d9f4a5..6691e84e 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -638,6 +638,189 @@ function loadAsDirectory(candidate, id, parentDir, seen) { return null; } +const cjsPackageConditions = new Set(['golem', 'node', 'require', 'module-sync', 'default']); +const packageTargetNoMatch = { __packageTargetNoMatch: true }; +const packageTargetBlocked = { __packageTargetBlocked: true }; + +function makePackagePathNotExportedError(packageName, subpath) { + const suffix = subpath ? './' + subpath : '.'; + const err = new Error('Package subpath ' + JSON.stringify(suffix) + ' is not defined by "exports" in package ' + packageName); + err.code = 'ERR_PACKAGE_PATH_NOT_EXPORTED'; + return err; +} + +function makePackageImportNotDefinedError(specifier) { + const err = new Error('Package import specifier ' + JSON.stringify(specifier) + ' is not defined'); + err.code = 'ERR_PACKAGE_IMPORT_NOT_DEFINED'; + return err; +} + +function makeInvalidPackageTargetError(target) { + const err = new Error('Invalid package target ' + JSON.stringify(target)); + err.code = 'ERR_INVALID_PACKAGE_TARGET'; + return err; +} + +function makeModuleNotFoundError(id) { + const err = new Error("Cannot find module '" + id + "'"); + err.code = 'MODULE_NOT_FOUND'; + return err; +} + +function isBarePackageSpecifier(target) { + return typeof target === 'string' && + target.length > 0 && + !target.startsWith('.') && + !target.startsWith('/') && + !target.startsWith('#') && + !target.includes(':'); +} + +function isInvalidPackageTargetSegment(segment) { + if (segment === '.' || segment === '..' || segment === 'node_modules') return true; + let decoded = segment; + try { + decoded = decodeURIComponent(segment); + } catch (_) { + // Keep the raw segment when percent decoding fails; invalid escapes are + // handled by the normal module-not-found path for now. + } + decoded = decoded.toLowerCase(); + return decoded === '.' || decoded === '..' || decoded === 'node_modules'; +} + +function validatePackageTargetPath(target) { + const rest = target.slice(2); + const parts = rest.split('/'); + if (parts.length === 0) return false; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part === '') continue; + if (isInvalidPackageTargetSegment(part)) return false; + } + return true; +} + +function resolveExactPackageFile(filename) { + const content = tryReadFile(filename); + if (content !== null) return { filename, content }; + throw makeModuleNotFoundError(filename); +} + +function resolvePackageTargetValue(packageDir, target, conditions, seen, allowBareTarget) { + seen = seen || new Set(); + if (target === null || target === false) return packageTargetBlocked; + + if (typeof target === 'string') { + if (allowBareTarget && target.startsWith('node:') && builtinModuleMap[target] !== undefined) { + return { builtin: target }; + } + if (allowBareTarget && isBarePackageSpecifier(target)) { + const resolved = resolveFromNodeModules(target, packageDir, pathModule.join(packageDir, 'package.json')); + if (resolved !== null) return resolved; + throw makeModuleNotFoundError(target); + } + if (!target.startsWith('./')) { + throw makeInvalidPackageTargetError(target); + } + if (!validatePackageTargetPath(target)) { + throw makeInvalidPackageTargetError(target); + } + const candidate = pathModule.resolve(packageDir, target); + const relative = pathModule.relative(packageDir, candidate); + if (relative === '' || relative.startsWith('..') || pathModule.isAbsolute(relative)) { + throw makeInvalidPackageTargetError(target); + } + return resolveExactPackageFile(candidate); + } + + if (Array.isArray(target)) { + for (let i = 0; i < target.length; i++) { + try { + const resolved = resolvePackageTargetValue(packageDir, target[i], conditions, seen, allowBareTarget); + if (resolved === packageTargetBlocked) return resolved; + if (resolved !== packageTargetNoMatch) return resolved; + } catch (err) { + if (!err || err.code !== 'ERR_INVALID_PACKAGE_TARGET') throw err; + } + } + return packageTargetNoMatch; + } + + if (target && typeof target === 'object') { + if (seen.has(target)) return null; + seen.add(target); + const keys = Object.keys(target); + for (let i = 0; i < keys.length; i++) { + const condition = keys[i]; + if (conditions.has(condition)) { + const resolved = resolvePackageTargetValue(packageDir, target[condition], conditions, seen, allowBareTarget); + if (resolved === packageTargetNoMatch) continue; + return resolved; + } + } + return packageTargetNoMatch; + } + + throw makeInvalidPackageTargetError(target); +} + +function isPackageExportsConditionsObject(exportsField) { + if (!exportsField || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false; + const keys = Object.keys(exportsField); + return keys.length > 0 && !keys.some((key) => key.startsWith('.')); +} + +function resolvePackageExports(packageName, packageDir, pkg, subpath, conditions) { + if (!pkg || !Object.prototype.hasOwnProperty.call(pkg, 'exports')) return undefined; + const key = subpath ? './' + subpath : '.'; + const exportsField = pkg.exports; + let resolved = null; + + if (typeof exportsField === 'string' || Array.isArray(exportsField) || isPackageExportsConditionsObject(exportsField)) { + if (key === '.') { + resolved = resolvePackageTargetValue(packageDir, exportsField, conditions, undefined, false); + } + } else if (exportsField && typeof exportsField === 'object') { + if (Object.prototype.hasOwnProperty.call(exportsField, key)) { + resolved = resolvePackageTargetValue(packageDir, exportsField[key], conditions, undefined, false); + } + } else if (exportsField !== null) { + throw makeInvalidPackageTargetError(exportsField); + } + + if (resolved !== null && resolved !== packageTargetNoMatch && resolved !== packageTargetBlocked) return resolved; + throw makePackagePathNotExportedError(packageName, subpath); +} + +function findPackageScope(startDir) { + let dir = pathModule.resolve(startDir || '/'); + while (true) { + if (pathModule.basename(dir) === 'node_modules') return null; + const pkgJsonPath = pathModule.join(dir, 'package.json'); + const pkgJson = tryReadFile(pkgJsonPath); + if (pkgJson !== null) { + return { dir, pkg: JSON.parse(pkgJson) }; + } + const parent = pathModule.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function resolvePackageImports(id, parentDir, conditions) { + const scope = findPackageScope(parentDir); + if (!scope || !scope.pkg || !scope.pkg.imports || typeof scope.pkg.imports !== 'object') { + throw makePackageImportNotDefinedError(id); + } + if (!Object.prototype.hasOwnProperty.call(scope.pkg.imports, id)) { + throw makePackageImportNotDefinedError(id); + } + const resolved = resolvePackageTargetValue(scope.dir, scope.pkg.imports[id], conditions, undefined, true); + if (resolved !== packageTargetNoMatch && resolved !== packageTargetBlocked) return resolved; + throw makePackageImportNotDefinedError(id); +} + function resolveFilename(id, parentDir) { const hasTrailingSlash = /\/$/.test(id); const forceDirectory = hasTrailingSlash || /(?:^|\/)\.\.?$/.test(id); @@ -1234,9 +1417,33 @@ function resolveFromNodeModules(id, parentDir, parentFilename) { const hasSubpath = parts.subpath.length > 0; for (let i = 0; i < dirs.length; i++) { + const pkgDir = pathModule.join(dirs[i], parts.name); + const pkgJsonPath = pathModule.join(pkgDir, 'package.json'); + const pkgJson = tryReadFile(pkgJsonPath); + let pkg = null; + + if (pkgJson !== null) { + try { + pkg = JSON.parse(pkgJson); + const exportsResolved = resolvePackageExports(parts.name, pkgDir, pkg, parts.subpath, cjsPackageConditions); + if (exportsResolved !== undefined) return exportsResolved; + } catch (e) { + if (e && e.code) { + throw e; + } + const fromPart = parentFilename || parentDir; + const pkgErr = new Error( + 'Invalid package config ' + pkgJsonPath + + ' while importing "' + id + '" from ' + fromPart + '.' + + (e.message ? ' ' + e.message : '') + ); + pkgErr.code = 'ERR_INVALID_PACKAGE_CONFIG'; + throw pkgErr; + } + } + // If there's a subpath, try resolving it relative to the package directory if (hasSubpath) { - const pkgDir = pathModule.join(dirs[i], parts.name); const subCandidate = pathModule.join(pkgDir, parts.subpath); // Try exact subpath let content = tryReadFile(subCandidate); @@ -1255,14 +1462,11 @@ function resolveFromNodeModules(id, parentDir, parentFilename) { if (content !== null) return { filename: pathModule.join(subCandidate, 'index.json'), content: content }; } - const candidate = pathModule.join(dirs[i], id); + const candidate = pkgDir; // Try as directory: check package.json "main" field - const pkgJsonPath = pathModule.join(candidate, 'package.json'); - const pkgJson = tryReadFile(pkgJsonPath); - if (pkgJson !== null) { + if (pkg !== null) { try { - const pkg = JSON.parse(pkgJson); if (Object.prototype.hasOwnProperty.call(pkg, 'main') && typeof pkg.main === 'string') { const mainPath = pathModule.resolve(candidate, pkg.main); const mainCandidates = [ @@ -1370,6 +1574,13 @@ function makeRequire(parentDir, parentModule, parentFilenameOverride) { return mod.exports; } + if (id.startsWith('#')) { + const importsResolved = resolvePackageImports(id, parentDir, cjsPackageConditions); + if (importsResolved.builtin) return builtinModuleMap[importsResolved.builtin]; + const mod = loadModule(importsResolved.filename, importsResolved.content, parentModule || null); + return mod.exports; + } + // node_modules resolution for bare specifiers const nmResolved = resolveFromNodeModules(id, parentDir, parentFilename); if (nmResolved) { @@ -1435,6 +1646,11 @@ function makeRequire(parentDir, parentModule, parentFilenameOverride) { const resolved = resolveFilename(id, parentDir); return resolved.filename; } + if (id.startsWith('#')) { + const importsResolved = resolvePackageImports(id, parentDir, cjsPackageConditions); + if (importsResolved.builtin) return importsResolved.builtin; + return importsResolved.filename; + } // node_modules resolution for bare specifiers const nmResolved = resolveFromNodeModules(id, parentDir, parentFilename); if (nmResolved) { diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index d4561064..8c7473c3 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -1,5 +1,6 @@ use futures::future::AbortHandle; use futures_concurrency::future::Join; +use indexmap::IndexMap; use rquickjs::function::{Args, Constructor}; use rquickjs::loader::{BuiltinLoader, BuiltinResolver, FileResolver, Loader, Resolver}; use rquickjs::{ @@ -7,6 +8,7 @@ use rquickjs::{ Object, Promise, Value, async_with, }; use rquickjs::{CaughtError, prelude::*}; +use serde::Deserialize; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::future::Future; @@ -1023,51 +1025,109 @@ impl Resolver for NodeModuleErrorResolver { } } +enum NodePackageResolveError { + PackagePathNotExported { package_name: String, subpath: String }, + PackageImportNotDefined { specifier: String }, + InvalidPackageTarget { kind: &'static str, target: String }, + InvalidPackageConfig { path: String }, + ModuleNotFound { request: String }, +} + +enum PackageTargetResolution { + Resolved(String), + NoMatch, + Blocked, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum PackageTarget { + String(String), + Array(Vec), + Object(IndexMap), + Bool(bool), + Null, + Invalid(serde_json::Value), +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct PackageJson { + main: Option, + exports: Option, + imports: Option, +} + struct NodeModulesResolver; impl NodeModulesResolver { - fn try_resolve(&self, base: &str, name: &str) -> Option { + fn try_resolve( + &self, + base: &str, + name: &str, + ) -> Result, NodePackageResolveError> { use std::path::{Path, PathBuf}; + if name.starts_with('#') { + return self.try_resolve_package_import(base, name); + } + // Only handle bare specifiers (not relative, absolute, or URL) if name.starts_with('.') || name.starts_with('/') || name.contains("://") { - return None; + return Ok(None); } + let Some((package_name, subpath)) = Self::split_package_name(name) else { + return Ok(None); + }; + // Extract directory from base module path - let base_dir = Path::new(base).parent()?; + let Some(base_dir) = Path::new(base).parent() else { + return Ok(None); + }; // Walk up directory tree looking for node_modules let mut dir = base_dir.to_path_buf(); loop { - let nm_dir = dir.join("node_modules").join(name); + let nm_dir = dir.join("node_modules").join(package_name); if nm_dir.is_dir() { - // Try package.json main field let pkg_path = nm_dir.join("package.json"); - if let Ok(pkg_content) = std::fs::read_to_string(&pkg_path) - && let Some(main) = Self::extract_json_string_field(&pkg_content, "main") - { - // Try the main entry with various extensions - let main_path = nm_dir.join(&main); - let candidates = [ - main_path.clone(), - main_path.with_extension("mjs"), - main_path.with_extension("js"), - main_path.join("index.mjs"), - main_path.join("index.js"), - ]; - for candidate in &candidates { - if candidate.is_file() { - return Some(candidate.to_string_lossy().into_owned()); + if let Ok(pkg_content) = std::fs::read_to_string(&pkg_path) { + let package: PackageJson = serde_json::from_str(&pkg_content).map_err(|_| { + NodePackageResolveError::InvalidPackageConfig { + path: pkg_path.to_string_lossy().into_owned(), } + })?; + + if let Some(exports_field) = package.exports.as_ref() { + return Self::resolve_package_exports( + package_name, + &nm_dir, + exports_field, + subpath, + ) + .map(Some); + } + + if subpath.is_empty() + && let Some(main) = package.main.as_ref() + && let Some(resolved) = Self::resolve_package_target(&nm_dir, main) + { + return Ok(Some(resolved)); } } + if !subpath.is_empty() + && let Some(resolved) = Self::resolve_package_target(&nm_dir, subpath) + { + return Ok(Some(resolved)); + } + // Fallback: index.mjs, index.js let fallbacks: [PathBuf; 2] = [nm_dir.join("index.mjs"), nm_dir.join("index.js")]; for fallback in &fallbacks { if fallback.is_file() { - return Some(fallback.to_string_lossy().into_owned()); + return Ok(Some(fallback.to_string_lossy().into_owned())); } } } @@ -1077,32 +1137,411 @@ impl NodeModulesResolver { } } + Ok(None) + } + + fn try_resolve_package_import( + &self, + base: &str, + name: &str, + ) -> Result, NodePackageResolveError> { + use std::path::Path; + + let Some(parent) = Path::new(base).parent() else { + return Ok(None); + }; + let mut dir = parent.to_path_buf(); + loop { + if dir.file_name().is_some_and(|name| name == "node_modules") { + return Err(NodePackageResolveError::PackageImportNotDefined { + specifier: name.to_string(), + }); + } + + let pkg_path = dir.join("package.json"); + if let Ok(pkg_content) = std::fs::read_to_string(&pkg_path) { + let package: PackageJson = serde_json::from_str(&pkg_content).map_err(|_| { + NodePackageResolveError::InvalidPackageConfig { + path: pkg_path.to_string_lossy().into_owned(), + } + })?; + let Some(imports) = package.imports.as_ref() else { + return Err(NodePackageResolveError::PackageImportNotDefined { + specifier: name.to_string(), + }); + }; + return Self::resolve_package_import(&dir, imports, name).map(Some); + } + + if !dir.pop() { + break; + } + } + + Err(NodePackageResolveError::PackageImportNotDefined { + specifier: name.to_string(), + }) + } + + fn split_package_name(name: &str) -> Option<(&str, &str)> { + if name.starts_with('@') { + let first = name.find('/')?; + let rest = &name[first + 1..]; + if rest.is_empty() { + return None; + } + if let Some(second_rel) = rest.find('/') { + let second = first + 1 + second_rel; + Some((&name[..second], &name[second + 1..])) + } else { + Some((name, "")) + } + } else if let Some(idx) = name.find('/') { + Some((&name[..idx], &name[idx + 1..])) + } else { + Some((name, "")) + } + } + + fn resolve_package_target(package_dir: &std::path::Path, target: &str) -> Option { + let target_path = package_dir.join(target.strip_prefix("./").unwrap_or(target)); + let mut candidates = vec![target_path.clone()]; + if target_path.extension().is_none() { + candidates.push(target_path.with_extension("mjs")); + candidates.push(target_path.with_extension("js")); + candidates.push(target_path.with_extension("cjs")); + candidates.push(target_path.with_extension("json")); + } + candidates.push(target_path.join("index.mjs")); + candidates.push(target_path.join("index.js")); + candidates.push(target_path.join("index.cjs")); + candidates.push(target_path.join("index.json")); + + for candidate in &candidates { + if candidate.is_file() { + return Some(candidate.to_string_lossy().into_owned()); + } + } + None } - /// Extract a simple string field value from a JSON object string. - fn extract_json_string_field(json: &str, field: &str) -> Option { - let pattern = format!("\"{}\"", field); - let idx = json.find(&pattern)?; - let after_key = &json[idx + pattern.len()..]; - let after_colon = after_key.trim_start(); - let after_colon = after_colon.strip_prefix(':')?; - let after_colon = after_colon.trim_start(); - let after_colon = after_colon.strip_prefix('"')?; - let end = after_colon.find('"')?; - Some(after_colon[..end].to_string()) + fn resolve_package_exports( + package_name: &str, + package_dir: &std::path::Path, + exports: &PackageTarget, + subpath: &str, + ) -> Result { + let key = if subpath.is_empty() { + ".".to_string() + } else { + format!("./{}", subpath) + }; + + if matches!(exports, PackageTarget::String(_) | PackageTarget::Array(_)) + || Self::is_conditions_object(exports) + { + if key != "." { + return Err(NodePackageResolveError::PackagePathNotExported { + package_name: package_name.to_string(), + subpath: subpath.to_string(), + }); + } + return Self::resolve_package_target_value( + package_dir, + exports, + false, + "exports", + ) + .and_then(|resolution| { + Self::target_resolution_to_export_result(resolution, package_name, subpath) + }); + } + + if let PackageTarget::Object(map) = exports { + if let Some(target) = map.get(&key) { + return Self::resolve_package_target_value( + package_dir, + target, + false, + "exports", + ) + .and_then(|resolution| { + Self::target_resolution_to_export_result(resolution, package_name, subpath) + }); + } + } + + Err(NodePackageResolveError::PackagePathNotExported { + package_name: package_name.to_string(), + subpath: subpath.to_string(), + }) + } + + fn resolve_package_import( + package_dir: &std::path::Path, + imports: &PackageTarget, + specifier: &str, + ) -> Result { + if let PackageTarget::Object(map) = imports + && let Some(target) = map.get(specifier) + { + return Self::resolve_package_target_value(package_dir, target, true, "imports").and_then( + |resolution| Self::target_resolution_to_import_result(resolution, specifier), + ); + } + Err(NodePackageResolveError::PackageImportNotDefined { + specifier: specifier.to_string(), + }) + } + + fn is_conditions_object(value: &PackageTarget) -> bool { + matches!( + value, + PackageTarget::Object(map) if !map.is_empty() && !map.iter().any(|(key, _)| key.starts_with('.')) + ) + } + + fn resolve_package_target_value( + package_dir: &std::path::Path, + target: &PackageTarget, + allow_bare_target: bool, + kind: &'static str, + ) -> Result { + match target { + PackageTarget::Null | PackageTarget::Bool(false) => { + return Ok(PackageTargetResolution::Blocked); + } + PackageTarget::Bool(true) => { + return Err(NodePackageResolveError::InvalidPackageTarget { + kind, + target: "true".to_string(), + }); + } + PackageTarget::Invalid(value) => { + return Err(NodePackageResolveError::InvalidPackageTarget { + kind, + target: value.to_string(), + }); + } + PackageTarget::String(target_str) => { + if allow_bare_target && Self::is_bare_package_specifier(target_str) { + let base = package_dir.join("package.json"); + let base_str = base.to_string_lossy(); + let resolver = NodeModulesResolver; + if let Some(resolved) = resolver.try_resolve(&base_str, target_str)? { + return Ok(PackageTargetResolution::Resolved(resolved)); + } + return Err(NodePackageResolveError::ModuleNotFound { + request: target_str.to_string(), + }); + } + if allow_bare_target && target_str.starts_with("node:") { + return Ok(PackageTargetResolution::Resolved(target_str.clone())); + } + if !target_str.starts_with("./") { + return Err(NodePackageResolveError::InvalidPackageTarget { + kind, + target: target_str.to_string(), + }); + } + let Some(candidate) = Self::resolve_valid_package_target_path(package_dir, target_str) else { + return Err(NodePackageResolveError::InvalidPackageTarget { + kind, + target: target_str.clone(), + }); + }; + if candidate.is_file() { + return Ok(PackageTargetResolution::Resolved( + candidate.to_string_lossy().into_owned(), + )); + } + return Err(NodePackageResolveError::ModuleNotFound { + request: candidate.to_string_lossy().into_owned(), + }); + } + PackageTarget::Array(array) => { + for item in array { + match Self::resolve_package_target_value(package_dir, item, allow_bare_target, kind) { + Ok(PackageTargetResolution::Resolved(path)) => { + return Ok(PackageTargetResolution::Resolved(path)); + } + Ok(PackageTargetResolution::Blocked) => { + return Ok(PackageTargetResolution::Blocked); + } + Ok(PackageTargetResolution::NoMatch) => continue, + Err(NodePackageResolveError::InvalidPackageTarget { .. }) => continue, + Err(err) => return Err(err), + } + } + return Ok(PackageTargetResolution::NoMatch); + } + PackageTarget::Object(map) => { + for (condition, value) in map { + if matches!(condition.as_str(), "golem" | "node" | "import" | "default") { + match Self::resolve_package_target_value( + package_dir, + value, + allow_bare_target, + kind, + )? { + PackageTargetResolution::NoMatch => continue, + resolution => return Ok(resolution), + } + } + } + Ok(PackageTargetResolution::NoMatch) + } + } + } + + fn target_resolution_to_export_result( + resolution: PackageTargetResolution, + package_name: &str, + subpath: &str, + ) -> Result { + match resolution { + PackageTargetResolution::Resolved(path) => Ok(path), + PackageTargetResolution::NoMatch | PackageTargetResolution::Blocked => { + Err(NodePackageResolveError::PackagePathNotExported { + package_name: package_name.to_string(), + subpath: subpath.to_string(), + }) + } + } + } + + fn target_resolution_to_import_result( + resolution: PackageTargetResolution, + specifier: &str, + ) -> Result { + match resolution { + PackageTargetResolution::Resolved(path) => Ok(path), + PackageTargetResolution::NoMatch | PackageTargetResolution::Blocked => { + Err(NodePackageResolveError::PackageImportNotDefined { + specifier: specifier.to_string(), + }) + } + } + } + + fn is_bare_package_specifier(target: &str) -> bool { + !target.is_empty() + && !target.starts_with('.') + && !target.starts_with('/') + && !target.starts_with('#') + && !target.contains(':') + } + + fn resolve_valid_package_target_path( + package_dir: &std::path::Path, + target: &str, + ) -> Option { + let mut relative_parts = Vec::<&str>::new(); + for part in target.strip_prefix("./")?.split('/') { + match part { + "" => {} + part if Self::is_invalid_package_target_segment(part) => return None, + part => relative_parts.push(part), + } + } + if relative_parts.is_empty() { + return None; + } + let mut candidate = package_dir.to_path_buf(); + for part in relative_parts { + candidate.push(part); + } + Some(candidate) + } + + fn is_invalid_package_target_segment(segment: &str) -> bool { + if matches!(segment, "." | ".." | "node_modules") { + return true; + } + let decoded = percent_decode(segment).unwrap_or_else(|| segment.to_string()); + matches!(decoded.to_ascii_lowercase().as_str(), "." | ".." | "node_modules") + } +} + +fn percent_decode(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let (Some(hi), Some(lo)) = ( + FileUrlResolver::hex_val(bytes[i + 1]), + FileUrlResolver::hex_val(bytes[i + 2]), + ) + { + decoded.push(hi << 4 | lo); + i += 3; + continue; + } + decoded.push(bytes[i]); + i += 1; } + String::from_utf8(decoded).ok() +} + +fn throw_node_package_resolve_error<'js>( + ctx: &Ctx<'js>, + err: NodePackageResolveError, +) -> rquickjs::Result { + let (code, message) = match err { + NodePackageResolveError::PackagePathNotExported { + package_name, + subpath, + } => { + let subpath = if subpath.is_empty() { + ".".to_string() + } else { + format!("./{}", subpath) + }; + ( + "ERR_PACKAGE_PATH_NOT_EXPORTED", + format!("Package subpath '{}' is not defined by \"exports\" in package {}", subpath, package_name), + ) + } + NodePackageResolveError::PackageImportNotDefined { specifier } => ( + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + format!("Package import specifier '{}' is not defined", specifier), + ), + NodePackageResolveError::InvalidPackageTarget { kind, target } => ( + "ERR_INVALID_PACKAGE_TARGET", + format!("Invalid \"{}\" target '{}'", kind, target), + ), + NodePackageResolveError::InvalidPackageConfig { path } => ( + "ERR_INVALID_PACKAGE_CONFIG", + format!("Invalid package config {}", path), + ), + NodePackageResolveError::ModuleNotFound { request } => ( + "ERR_MODULE_NOT_FOUND", + format!("Cannot find module '{}'", request), + ), + }; + + let globals = ctx.globals(); + let error_ctor: Function = globals.get("Error")?; + let error_obj: Object = error_ctor.call((message,))?; + error_obj.set("code", code)?; + Err(ctx.throw(error_obj.into_value())) } impl Resolver for NodeModulesResolver { fn resolve<'js>( &mut self, - _ctx: &Ctx<'js>, + ctx: &Ctx<'js>, base: &str, name: &str, ) -> rquickjs::Result { - self.try_resolve(base, name) - .ok_or_else(|| Error::new_resolving(base, name)) + match self.try_resolve(base, name) { + Ok(Some(resolved)) => Ok(resolved), + Ok(None) => Err(Error::new_resolving(base, name)), + Err(err) => throw_node_package_resolve_error(ctx, err), + } } } diff --git a/examples/runtime/cjs-require/src/cjs-require.js b/examples/runtime/cjs-require/src/cjs-require.js index b0e4700e..3d8b9e5c 100644 --- a/examples/runtime/cjs-require/src/cjs-require.js +++ b/examples/runtime/cjs-require/src/cjs-require.js @@ -188,3 +188,192 @@ export const testRequireModuleNotFound = () => { return false; } }; + +export const testRequirePackageExports = () => { + try { + const assert = require('assert'); + const fs = require('fs'); + + fs.mkdirSync('/exports-app/node_modules/conditional-pkg', { recursive: true }); + fs.writeFileSync('/exports-app/node_modules/conditional-pkg/package.json', JSON.stringify({ + exports: { + '.': { + import: './esm.mjs', + require: './cjs.cjs', + default: './default.js', + }, + './feature': { + require: './feature.cjs', + default: './feature-default.js', + }, + './import-only': { + import: './import-only.mjs', + }, + }, + })); + fs.writeFileSync('/exports-app/node_modules/conditional-pkg/esm.mjs', 'export default { mode: "esm" };'); + fs.writeFileSync('/exports-app/node_modules/conditional-pkg/cjs.cjs', 'module.exports = { mode: "cjs" };'); + fs.writeFileSync('/exports-app/node_modules/conditional-pkg/default.js', 'module.exports = { mode: "default" };'); + fs.writeFileSync('/exports-app/node_modules/conditional-pkg/feature.cjs', 'module.exports = { feature: "cjs" };'); + fs.writeFileSync('/exports-app/node_modules/conditional-pkg/feature-default.js', 'module.exports = { feature: "default" };'); + fs.writeFileSync('/exports-app/node_modules/conditional-pkg/import-only.mjs', 'export default { mode: "import" };'); + + const appRequire = require('module').createRequire('/exports-app/app.js'); + assert.deepStrictEqual(appRequire('conditional-pkg'), { mode: 'cjs' }); + assert.deepStrictEqual(appRequire('conditional-pkg/feature'), { feature: 'cjs' }); + + assert.throws(() => appRequire('conditional-pkg/import-only'), { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }); + assert.throws(() => appRequire('conditional-pkg/private'), { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }); + + assert.strictEqual(appRequire.resolve('conditional-pkg'), '/exports-app/node_modules/conditional-pkg/cjs.cjs'); + assert.strictEqual(appRequire.resolve('conditional-pkg/feature'), '/exports-app/node_modules/conditional-pkg/feature.cjs'); + + return true; + } catch (e) { + console.error(e); + return false; + } +}; + +export const testRequirePackageImports = () => { + try { + const assert = require('assert'); + const fs = require('fs'); + + fs.mkdirSync('/imports-app', { recursive: true }); + fs.writeFileSync('/imports-app/package.json', JSON.stringify({ + imports: { + '#dep': { + require: './dep.cjs', + default: './dep-default.js', + }, + '#default-only': { + default: './default-only.js', + }, + '#import-only': { + import: './import-only.mjs', + }, + }, + })); + fs.writeFileSync('/imports-app/dep.cjs', 'module.exports = { mode: "require" };'); + fs.writeFileSync('/imports-app/dep-default.js', 'module.exports = { mode: "default" };'); + fs.writeFileSync('/imports-app/default-only.js', 'module.exports = { mode: "default-only" };'); + fs.writeFileSync('/imports-app/import-only.mjs', 'export default { mode: "import" };'); + fs.writeFileSync('/imports-app/main.cjs', [ + 'exports.dep = require("#dep");', + 'exports.defaultOnly = require("#default-only");', + 'exports.missing = function() { return require("#missing"); };', + 'exports.importOnly = function() { return require("#import-only"); };', + ].join('\n')); + + const appRequire = require('module').createRequire('/imports-app/main.cjs'); + const mod = appRequire('./main.cjs'); + assert.deepStrictEqual(mod.dep, { mode: 'require' }); + assert.deepStrictEqual(mod.defaultOnly, { mode: 'default-only' }); + assert.throws(() => mod.missing(), { code: 'ERR_PACKAGE_IMPORT_NOT_DEFINED' }); + assert.throws(() => mod.importOnly(), { code: 'ERR_PACKAGE_IMPORT_NOT_DEFINED' }); + assert.strictEqual(appRequire.resolve('#dep'), '/imports-app/dep.cjs'); + + return true; + } catch (e) { + console.error(e); + return false; + } +}; + +export const testRequirePackageMapEdgeCases = () => { + try { + const assert = require('assert'); + const fs = require('fs'); + const { createRequire } = require('module'); + + fs.mkdirSync('/package-map-edge-app/node_modules/exported-pkg', { recursive: true }); + fs.writeFileSync('/package-map-edge-app/outside.js', 'module.exports = { escaped: true };'); + fs.writeFileSync('/package-map-edge-app/node_modules/exported-pkg/package.json', JSON.stringify({ + main: './main.js', + exports: { + './public': './public.js', + './missing-selected': { + require: './missing.cjs', + default: './default.js', + }, + './escape': './../outside.js', + './nested-escape': './sub/../../outside.js', + './node-modules-target': './sub/../node_modules/other/index.js', + './dot-segment-target': './sub/../public.js', + './encoded-dot-target': './%2e%2e/outside.js', + './blocked-null': null, + './blocked-false': false, + './array-fallback': [ + { browser: './browser.js' }, + './public.js', + ], + './array-blocked': [ + null, + './public.js', + ], + './array-invalid-fallback': [ + '../outside.js', + './public.js', + ], + './condition-no-match-fallback': { + node: { browser: './browser.js' }, + default: './public.js', + }, + './no-ext': './real', + }, + })); + fs.writeFileSync('/package-map-edge-app/node_modules/exported-pkg/main.js', 'module.exports = { main: true };'); + fs.writeFileSync('/package-map-edge-app/node_modules/exported-pkg/private.js', 'module.exports = { private: true };'); + fs.writeFileSync('/package-map-edge-app/node_modules/exported-pkg/public.js', 'module.exports = { public: true };'); + fs.writeFileSync('/package-map-edge-app/node_modules/exported-pkg/default.js', 'module.exports = { defaulted: true };'); + fs.writeFileSync('/package-map-edge-app/node_modules/exported-pkg/real.js', 'module.exports = { extensionFallback: true };'); + + const appRequire = createRequire('/package-map-edge-app/app.js'); + assert.deepStrictEqual(appRequire('exported-pkg/public'), { public: true }); + assert.throws(() => appRequire('exported-pkg'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); + assert.throws(() => appRequire('exported-pkg/private.js'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); + assert.throws(() => appRequire('exported-pkg/missing-selected'), { code: 'MODULE_NOT_FOUND' }); + assert.throws(() => appRequire('exported-pkg/escape'), { code: 'ERR_INVALID_PACKAGE_TARGET' }); + assert.throws(() => appRequire('exported-pkg/nested-escape'), { code: 'ERR_INVALID_PACKAGE_TARGET' }); + assert.throws(() => appRequire('exported-pkg/node-modules-target'), { code: 'ERR_INVALID_PACKAGE_TARGET' }); + assert.throws(() => appRequire('exported-pkg/dot-segment-target'), { code: 'ERR_INVALID_PACKAGE_TARGET' }); + assert.throws(() => appRequire('exported-pkg/encoded-dot-target'), { code: 'ERR_INVALID_PACKAGE_TARGET' }); + assert.throws(() => appRequire('exported-pkg/blocked-null'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); + assert.throws(() => appRequire('exported-pkg/blocked-false'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); + assert.deepStrictEqual(appRequire('exported-pkg/array-fallback'), { public: true }); + assert.throws(() => appRequire('exported-pkg/array-blocked'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); + assert.deepStrictEqual(appRequire('exported-pkg/array-invalid-fallback'), { public: true }); + assert.deepStrictEqual(appRequire('exported-pkg/condition-no-match-fallback'), { public: true }); + assert.throws(() => appRequire('exported-pkg/no-ext'), { code: 'MODULE_NOT_FOUND' }); + + fs.mkdirSync('/package-map-edge-app/node_modules/external-pkg', { recursive: true }); + fs.writeFileSync('/package-map-edge-app/node_modules/external-pkg/index.js', 'module.exports = { external: true };'); + fs.mkdirSync('/package-map-edge-app/node_modules/dep', { recursive: true }); + fs.writeFileSync('/package-map-edge-app/package.json', JSON.stringify({ + imports: { + '#app-alias': './app-alias.js', + '#external': 'external-pkg', + '#fs': 'node:fs', + }, + })); + fs.writeFileSync('/package-map-edge-app/app-alias.js', 'module.exports = { appAlias: true };'); + fs.writeFileSync('/package-map-edge-app/node_modules/dep/index.js', [ + 'exports.loadAppAlias = function() { return require("#app-alias"); };', + ].join('\n')); + + assert.deepStrictEqual(appRequire('#external'), { external: true }); + assert.strictEqual(typeof appRequire('#fs').readFileSync, 'function'); + const dep = appRequire('dep'); + assert.throws(() => dep.loadAppAlias(), { code: 'ERR_PACKAGE_IMPORT_NOT_DEFINED' }); + + return true; + } catch (e) { + console.error(e); + return false; + } +}; diff --git a/examples/runtime/cjs-require/wit/cjs-require.wit b/examples/runtime/cjs-require/wit/cjs-require.wit index 8b847291..7315f107 100644 --- a/examples/runtime/cjs-require/wit/cjs-require.wit +++ b/examples/runtime/cjs-require/wit/cjs-require.wit @@ -10,4 +10,7 @@ world cjs-require { export test-require-json: func() -> bool; export test-require-module-exports-function: func() -> bool; export test-require-module-not-found: func() -> bool; + export test-require-package-exports: func() -> bool; + export test-require-package-imports: func() -> bool; + export test-require-package-map-edge-cases: func() -> bool; } diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js new file mode 100644 index 00000000..6e2f5729 --- /dev/null +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -0,0 +1,143 @@ +import assert from 'node:assert'; +import fs from 'node:fs'; + +async function expectImportError(specifier, code) { + let thrown = false; + try { + await import(specifier); + } catch (error) { + thrown = true; + assert.strictEqual(error && error.code, code, error && error.stack ? error.stack : String(error)); + } + if (!thrown) { + throw new Error(`Expected import(${specifier}) to throw ${code}`); + } +} + +function writeImportEntry(path, specifier) { + fs.writeFileSync(path, `export default await import(${JSON.stringify(specifier)});`); +} + +export const testEsmPackageMapEdgeCases = async () => { + try { + fs.mkdirSync('/esm-package-map-edge-app/node_modules/exported-pkg', { recursive: true }); + fs.writeFileSync('/esm-package-map-edge-app/outside.mjs', 'export default { escaped: true };'); + fs.writeFileSync('/esm-package-map-edge-app/node_modules/exported-pkg/package.json', JSON.stringify({ + type: 'module', + main: './main.mjs', + exports: { + './public': './public.mjs', + './condition-order': { + default: './default.mjs', + import: './import.mjs', + }, + './escape': './../outside.mjs', + './nested-escape': './sub/../../outside.mjs', + './node-modules-target': './sub/../node_modules/other/index.mjs', + './dot-segment-target': './sub/../public.mjs', + './encoded-dot-target': './%2e%2e/outside.mjs', + './blocked-null': null, + './blocked-false': false, + './array-fallback': [ + { browser: './browser.mjs' }, + './public.mjs', + ], + './array-blocked': [ + null, + './public.mjs', + ], + './array-invalid-fallback': [ + '../outside.mjs', + './public.mjs', + ], + './condition-no-match-fallback': { + node: { browser: './browser.mjs' }, + default: './public.mjs', + }, + './no-ext': './real', + }, + })); + fs.writeFileSync('/esm-package-map-edge-app/node_modules/exported-pkg/main.mjs', 'export default { main: true };'); + fs.writeFileSync('/esm-package-map-edge-app/node_modules/exported-pkg/private.mjs', 'export default { private: true };'); + fs.writeFileSync('/esm-package-map-edge-app/node_modules/exported-pkg/public.mjs', 'export default { public: true };'); + fs.writeFileSync('/esm-package-map-edge-app/node_modules/exported-pkg/default.mjs', 'export default { condition: "default" };'); + fs.writeFileSync('/esm-package-map-edge-app/node_modules/exported-pkg/import.mjs', 'export default { condition: "import" };'); + fs.writeFileSync('/esm-package-map-edge-app/node_modules/exported-pkg/real.mjs', 'export default { extensionFallback: true };'); + + fs.writeFileSync('/esm-package-map-edge-app/entry.mjs', [ + 'export const publicValue = (await import("exported-pkg/public")).default;', + 'export const conditionOrder = (await import("exported-pkg/condition-order")).default;', + 'export const arrayFallback = (await import("exported-pkg/array-fallback")).default;', + 'export const arrayInvalidFallback = (await import("exported-pkg/array-invalid-fallback")).default;', + 'export const conditionNoMatchFallback = (await import("exported-pkg/condition-no-match-fallback")).default;', + ].join('\n')); + + const entry = await import('/esm-package-map-edge-app/entry.mjs'); + assert.deepStrictEqual(entry.publicValue, { public: true }); + assert.deepStrictEqual(entry.conditionOrder, { condition: 'default' }); + assert.deepStrictEqual(entry.arrayFallback, { public: true }); + assert.deepStrictEqual(entry.arrayInvalidFallback, { public: true }); + assert.deepStrictEqual(entry.conditionNoMatchFallback, { public: true }); + + writeImportEntry('/esm-package-map-edge-app/missing-root.mjs', 'exported-pkg'); + writeImportEntry('/esm-package-map-edge-app/private-subpath.mjs', 'exported-pkg/private.mjs'); + writeImportEntry('/esm-package-map-edge-app/escape-subpath.mjs', 'exported-pkg/escape'); + writeImportEntry('/esm-package-map-edge-app/nested-escape-subpath.mjs', 'exported-pkg/nested-escape'); + writeImportEntry('/esm-package-map-edge-app/node-modules-target-subpath.mjs', 'exported-pkg/node-modules-target'); + writeImportEntry('/esm-package-map-edge-app/dot-segment-target-subpath.mjs', 'exported-pkg/dot-segment-target'); + writeImportEntry('/esm-package-map-edge-app/encoded-dot-target-subpath.mjs', 'exported-pkg/encoded-dot-target'); + writeImportEntry('/esm-package-map-edge-app/blocked-null-subpath.mjs', 'exported-pkg/blocked-null'); + writeImportEntry('/esm-package-map-edge-app/blocked-false-subpath.mjs', 'exported-pkg/blocked-false'); + writeImportEntry('/esm-package-map-edge-app/array-blocked-subpath.mjs', 'exported-pkg/array-blocked'); + writeImportEntry('/esm-package-map-edge-app/no-ext-subpath.mjs', 'exported-pkg/no-ext'); + + await expectImportError('/esm-package-map-edge-app/missing-root.mjs', 'ERR_PACKAGE_PATH_NOT_EXPORTED'); + await expectImportError('/esm-package-map-edge-app/private-subpath.mjs', 'ERR_PACKAGE_PATH_NOT_EXPORTED'); + await expectImportError('/esm-package-map-edge-app/escape-subpath.mjs', 'ERR_INVALID_PACKAGE_TARGET'); + await expectImportError('/esm-package-map-edge-app/nested-escape-subpath.mjs', 'ERR_INVALID_PACKAGE_TARGET'); + await expectImportError('/esm-package-map-edge-app/node-modules-target-subpath.mjs', 'ERR_INVALID_PACKAGE_TARGET'); + await expectImportError('/esm-package-map-edge-app/dot-segment-target-subpath.mjs', 'ERR_INVALID_PACKAGE_TARGET'); + await expectImportError('/esm-package-map-edge-app/encoded-dot-target-subpath.mjs', 'ERR_INVALID_PACKAGE_TARGET'); + await expectImportError('/esm-package-map-edge-app/blocked-null-subpath.mjs', 'ERR_PACKAGE_PATH_NOT_EXPORTED'); + await expectImportError('/esm-package-map-edge-app/blocked-false-subpath.mjs', 'ERR_PACKAGE_PATH_NOT_EXPORTED'); + await expectImportError('/esm-package-map-edge-app/array-blocked-subpath.mjs', 'ERR_PACKAGE_PATH_NOT_EXPORTED'); + await expectImportError('/esm-package-map-edge-app/no-ext-subpath.mjs', 'ERR_MODULE_NOT_FOUND'); + + fs.mkdirSync('/esm-package-map-edge-app/node_modules/external-pkg', { recursive: true }); + fs.writeFileSync('/esm-package-map-edge-app/node_modules/external-pkg/package.json', JSON.stringify({ + type: 'module', + exports: './index.mjs', + })); + fs.writeFileSync('/esm-package-map-edge-app/node_modules/external-pkg/index.mjs', 'export default { external: true };'); + fs.mkdirSync('/esm-package-map-edge-app/node_modules/dep', { recursive: true }); + fs.writeFileSync('/esm-package-map-edge-app/package.json', JSON.stringify({ + imports: { + '#app-alias': './app-alias.mjs', + '#external': 'external-pkg', + '#fs': 'node:fs', + }, + })); + fs.writeFileSync('/esm-package-map-edge-app/app-alias.mjs', 'export default { appAlias: true };'); + fs.writeFileSync('/esm-package-map-edge-app/imports-entry.mjs', [ + 'import external from "#external";', + 'import fs from "#fs";', + 'export default external;', + 'export const readFileSyncType = typeof fs.readFileSync;', + ].join('\n')); + fs.writeFileSync('/esm-package-map-edge-app/node_modules/dep/index.mjs', [ + 'import appAlias from "#app-alias";', + 'export default appAlias;', + ].join('\n')); + fs.writeFileSync('/esm-package-map-edge-app/imports-boundary-entry.mjs', 'export default await import("dep");'); + + const importsEntry = await import('/esm-package-map-edge-app/imports-entry.mjs'); + assert.deepStrictEqual(importsEntry.default, { external: true }); + assert.strictEqual(importsEntry.readFileSyncType, 'function'); + await expectImportError('/esm-package-map-edge-app/imports-boundary-entry.mjs', 'ERR_PACKAGE_IMPORT_NOT_DEFINED'); + + return true; + } catch (error) { + console.error(error); + return false; + } +}; diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit new file mode 100644 index 00000000..5ae40c1d --- /dev/null +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -0,0 +1,5 @@ +package quickjs:module-resolution; + +world module-resolution { + export test-esm-package-map-edge-cases: func() -> bool; +} diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index bc78cbc4..eafbbc29 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5864,10 +5864,8 @@ "es-module/test-esm-windows.js": {}, "es-module/test-loaders-hidden-from-users.js": { "category": "node-internals", "reason": "uses --expose-internals plus Node internals (require('internal/...'), process.binding('natives'))" }, "es-module/test-require-module-cached-tla.js": {}, - "es-module/test-require-module-conditional-exports-module.js": { "category": "known-gap", "reason": "node:module does not implement package.json exports condition resolution (module-sync/require/import/default)" }, + "es-module/test-require-module-conditional-exports-module.js": {}, "es-module/test-require-module-conditional-exports.js": { - "category": "known-gap", - "reason": "node:module does not implement package.json exports condition resolution (require/import/default)", "split": true, "subtests": { "block_00_if_only_require_exports_are_defined_return_require_exports": {}, @@ -5973,7 +5971,7 @@ "block_01_test_esm_that_import_esm": { "category": "runnable" }, "block_02_test_esm_that_import_cjs": { "category": "known-gap", "reason": "CJS named export analysis for ESM/CJS interop is incomplete (missing named exports like π)" }, "block_03_test_esm_that_require_cjs": { "category": "runnable" }, - "block_04_also_test_default_export": { "category": "known-gap", "reason": "package resolution from ESM (node_modules dependency without package.json) is incomplete" }, + "block_04_also_test_default_export": { "category": "runnable" }, "block_05_test_data_import": { "category": "runnable" } } }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 2fc4b106..4dc34a68 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -1,6 +1,6 @@ # Node.js v22.14.0 Compatibility Inventory -Generated: 2026-05-20 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) +Generated: 2026-06-11 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) This report is generated from `config.jsonc` only. It does **not** run the vendored tests itself. Entries classified as `runnable` are reported as passing because the `node_compat` PR test executes runnable entries and fails CI if any of them fail. @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3085/4295 (71.8%) +**Primary compatibility (CI-enforced):** 3089/4295 (71.9%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3085 | 71.8% | 55.0% | 45.8% | -| 🧩 known gap | 1210 | 28.2% | 21.6% | 18.0% | +| ✅ passing (runnable) | 3089 | 71.9% | 55.1% | 45.9% | +| 🧩 known gap | 1206 | 28.1% | 21.5% | 17.9% | | 🚫 WASI-impossible (excluded) | 1153 | — | 20.6% | 17.1% | | ⚙️ engine difference (excluded) | 162 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1121 | — | — | 16.7% | | **Total** | **6731** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3085/5610 (55.0%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3089/5610 (55.1%)**. ## Inventory by Module @@ -50,10 +50,10 @@ Secondary full-public compatibility, including public tests that are currently e | fs | 482 | 374 | 12 | 20 | 5 | 0 | 71 | 96.9% | 91.0% | | global | 11 | 4 | 5 | 0 | 0 | 0 | 2 | 44.4% | 44.4% | | heap | 22 | 0 | 0 | 15 | 7 | 0 | 0 | 0.0% | 0.0% | -| http | 898 | 244 | 305 | 267 | 2 | 0 | 80 | 44.4% | 29.8% | +| http | 898 | 243 | 306 | 267 | 2 | 0 | 80 | 44.3% | 29.7% | | inspector | 95 | 1 | 0 | 93 | 0 | 0 | 1 | 100.0% | 1.1% | | internal | 53 | 1 | 0 | 0 | 0 | 0 | 52 | 100.0% | 100.0% | -| module | 184 | 102 | 62 | 7 | 1 | 0 | 12 | 62.2% | 59.3% | +| module | 184 | 107 | 57 | 7 | 1 | 0 | 12 | 65.2% | 62.2% | | net | 223 | 150 | 36 | 19 | 1 | 0 | 17 | 80.6% | 72.8% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | @@ -93,7 +93,7 @@ Secondary full-public compatibility, including public tests that are currently e | File | Subtests | Passing | Gap | WASI-impossible | Engine diff | Unevaluated | Internals | |------|----------|----------|-----|-----------------|-------------|-------------|-----------| | `test-esm-loader-modulemap.js` | 5 | 0 | 0 | 0 | 0 | 0 | 5 | -| `test-require-module-conditional-exports.js` | 3 | 0 | 3 | 0 | 0 | 0 | 0 | +| `test-require-module-conditional-exports.js` | 3 | 3 | 0 | 0 | 0 | 0 | 0 | | `test-require-module-cycle-esm-cjs-esm-esm.js` | 3 | 0 | 3 | 0 | 0 | 0 | 0 | | `test-require-module-cycle-esm-cjs-esm.js` | 4 | 0 | 4 | 0 | 0 | 0 | 0 | | `test-require-module-cycle-esm-esm-cjs-esm-esm.js` | 4 | 0 | 4 | 0 | 0 | 0 | 0 | @@ -101,7 +101,7 @@ Secondary full-public compatibility, including public tests that are currently e | `test-require-module-defined-esmodule.js` | 2 | 2 | 0 | 0 | 0 | 0 | 0 | | `test-require-module-tla.js` | 2 | 1 | 1 | 0 | 0 | 0 | 0 | | `test-require-module-with-detection.js` | 2 | 0 | 2 | 0 | 0 | 0 | 0 | -| `test-require-module.js` | 6 | 4 | 2 | 0 | 0 | 0 | 0 | +| `test-require-module.js` | 6 | 5 | 1 | 0 | 0 | 0 | 0 | | `test-abortcontroller.js` | 19 | 19 | 0 | 0 | 0 | 0 | 0 | | `test-aborted-util.js` | 5 | 4 | 0 | 1 | 0 | 0 | 0 | | `test-abortsignal-cloneable.js` | 3 | 3 | 0 | 0 | 0 | 0 | 0 | @@ -680,7 +680,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1210) +### known gap (1206) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -753,7 +753,6 @@ Secondary full-public compatibility, including public tests that are currently e | inherited: server parser accepts bare-LF header separators instead of replying 400 and closing | 3 | `parallel/test-http-missing-header-separator-lf.js#block_00_block_00`, `parallel/test-http-missing-header-separator-lf.js#block_01_block_01`, `parallel/test-http-missing-header-separator-lf.js#block_02_block_02` | | inherited: setServers argument validation (ERR_INVALID_ARG_TYPE details) is incomplete for dns and dns/promises | 3 | `parallel/test-dns-setservers-type-check.js#block_00_block_00`, `parallel/test-dns-setservers-type-check.js#block_01_block_01`, `parallel/test-dns-setservers-type-check.js#block_02_this_test_for_dns_promises` | | net edge case not yet handled | 3 | `parallel/test-net-autoselectfamily.js#block_01_test_that_only_the_last_successful_connection_is_established`, `parallel/test-net-connect-reset.js`, `parallel/test-net-pingpong.js` | -| node:module does not implement package.json exports condition resolution (require/import/default) | 3 | `es-module/test-require-module-conditional-exports.js#block_00_if_only_require_exports_are_defined_return_require_exports`, `es-module/test-require-module-conditional-exports.js#block_01_if_both_are_defined_require_is_used`, `es-module/test-require-module-conditional-exports.js#block_02_if_import_and_default_are_defined_default_is_used` | | node:readline Interface constructor/options are not implemented | 3 | `parallel/test-readline-interface-escapecodetimeout.js`, `parallel/test-readline-interface-no-trailing-newline.js`, `parallel/test-readline-interface-recursive-writes.js` | | node:test concurrency scheduling/completion semantics are incomplete | 3 | `parallel/test-runner-concurrency.js#test_00_concurrency_option_boolean_true`, `parallel/test-runner-concurrency.js#test_01_concurrency_option_boolean_false`, `parallel/test-runner-concurrency.js#test_02_concurrency_true_implies_infinity` | | node_compat common shim is missing ../common/wpt harness | 3 | `parallel/test-whatwg-events-event-constructors.js`, `parallel/test-whatwg-events-eventtarget-this-of-listener.js`, `parallel/test-whatwg-url-custom-searchparams-sort.js` | @@ -897,6 +896,7 @@ Secondary full-public compatibility, including public tests that are currently e | HTTP server incorrectly emits chunked terminator semantics for 204/304 responses | 1 | `parallel/test-http-chunked-304.js` | | HTTP server parser does not emit Node-compatible HPE_HEADER_OVERFLOW/431 behavior for oversized headers | 1 | `parallel/test-http-header-overflow.js` | | HTTP server socket.setEncoding('') error path (ERR_HTTP_SOCKET_ENCODING) is not Node-compatible | 1 | `parallel/test-http-socket-encoding-error.js` | +| HTTP/1.0 keep-alive client/server framing is not Node-compatible; consistently fails on CI even with retries | 1 | `parallel/test-http-1.0-keep-alive.js` | | HTTP/1.0 keep-alive response connection-closing semantics are not Node-compatible | 1 | `parallel/test-http-wget.js` | | Happy Eyeballs autoSelectFamily over custom dual-stack DNS is not wired through wasi:http transport | 1 | `parallel/test-http-autoselectfamily.js` | | Host header generation ignores globalAgent.defaultPort and incorrectly includes the port | 1 | `parallel/test-http-default-port.js` | @@ -1168,7 +1168,6 @@ Secondary full-public compatibility, including public tests that are currently e | node:http client socketPath transport flow is incomplete (unix-socket request hangs) | 1 | `parallel/test-http-client-pipe-end.js` | | node:https Agent constructor compatibility is incomplete (call without new) | 1 | `parallel/test-https-agent-constructor.js` | | node:https Agent#getName TLS option keying is incomplete | 1 | `parallel/test-https-agent-getname.js` | -| node:module does not implement package.json exports condition resolution (module-sync/require/import/default) | 1 | `es-module/test-require-module-conditional-exports-module.js` | | node:module.findPackageJSON API behavior is incomplete | 1 | `parallel/test-find-package-json.js` | | node:sqlite applyChangeset conflict-resolution behavior is incomplete | 1 | `parallel/test-sqlite-session.js#test_05_conflict_resolution` | | node:sqlite rejects mixed named+positional parameters where Node accepts them | 1 | `parallel/test-sqlite-statement-sync.js#test_06_statementsync_prototype_expandedsql` | @@ -1184,7 +1183,6 @@ Secondary full-public compatibility, including public tests that are currently e | node_compat test fixture module ../common/process-exit-code-cases is not resolved in this runtime | 1 | `parallel/test-process-exit-code.js` | | non-writable global property semantics in vm contexts are incomplete | 1 | `parallel/test-vm-global-non-writable-properties.js` | | options.agent validation/lifecycle is not fully Node-compatible | 1 | `parallel/test-http-client-reject-unexpected-agent.js` | -| package resolution from ESM (node_modules dependency without package.json) is incomplete | 1 | `es-module/test-require-module.js#block_04_also_test_default_export` | | passive listener semantics are incomplete (test currently self-skips) | 1 | `parallel/test-whatwg-events-add-event-listener-options-passive.js#block_01_block_01` | | per-context Symbol/global binding behavior is incomplete in vm contexts | 1 | `parallel/test-vm-harmony-symbols.js` | | perf_hooks HTTP PerformanceEntry emission/detail fields are incomplete | 1 | `parallel/test-http-perf_hooks.js` | diff --git a/tests/runtime/cjs_require.rs b/tests/runtime/cjs_require.rs index 90a5e8c7..572f923c 100644 --- a/tests/runtime/cjs_require.rs +++ b/tests/runtime/cjs_require.rs @@ -145,3 +145,54 @@ async fn cjs_require_module_not_found( assert_eq!(r, Some(Val::Bool(true))); Ok(()) } + +#[test] +async fn cjs_require_package_exports( + #[tagged_as("cjs_require")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-require-package-exports", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn cjs_require_package_imports( + #[tagged_as("cjs_require")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-require-package-imports", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn cjs_require_package_map_edge_cases( + #[tagged_as("cjs_require")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-require-package-map-edge-cases", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} diff --git a/tests/runtime/main.rs b/tests/runtime/main.rs index 5107fecf..0a65d8bd 100644 --- a/tests/runtime/main.rs +++ b/tests/runtime/main.rs @@ -28,6 +28,7 @@ mod fetch; mod fs; mod imports; mod intl; +mod module_resolution; mod node_http; mod os; mod path; @@ -75,6 +76,7 @@ tag_suite!(sqlite, group6); tag_suite!(url, group7); tag_suite!(cjs_require, group7); +tag_suite!(module_resolution, group7); tag_suite!(timeout, group7); tag_suite!(buffer, group7); tag_suite!(bigint_roundtrip, group7); diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs new file mode 100644 index 00000000..b18869ce --- /dev/null +++ b/tests/runtime/module_resolution.rs @@ -0,0 +1,29 @@ +use crate::common::{CompiledTest, invoke_and_capture_output}; +use camino::Utf8Path; +use test_r::{test, test_dep}; +use wasmtime::component::Val; + +#[test_dep(tagged_as = "module_resolution", scope = Cloneable)] +async fn compiled_module_resolution() -> CompiledTest { + let path = Utf8Path::new("examples/runtime/module-resolution"); + CompiledTest::new(path, true) + .await + .expect("Failed to compile module_resolution") +} + +#[test] +async fn esm_package_map_edge_cases( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-esm-package-map-edge-cases", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} From 0edc3f9ca346ff97a2668e350897e3b494b570dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 12 Jun 2026 15:58:26 +0200 Subject: [PATCH 02/42] add CJS named export analysis for ESM interop --- crates/wasm-rquickjs/skeleton/src/internal.rs | 1195 ++++++++++++++++- .../src/module-resolution.js | 281 ++++ .../wit/module-resolution.wit | 4 + tests/node_compat/config.jsonc | 10 +- tests/node_compat/report.md | 20 +- tests/runtime/module_resolution.rs | 68 + 6 files changed, 1552 insertions(+), 26 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 8c7473c3..8378153b 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -1549,6 +1549,1001 @@ impl Resolver for NodeModulesResolver { /// This enables ESM modules to import CJS packages from `node_modules`. struct CjsCompatLoader; +#[derive(Default)] +struct CjsExportAnalysis { + exports: Vec, + reexports: Vec, + is_cjs: bool, +} + +fn add_unique(items: &mut Vec, item: String) { + if !items.iter().any(|existing| existing == &item) { + items.push(item); + } +} + +fn is_ident_start(byte: u8) -> bool { + byte == b'_' || byte == b'$' || byte.is_ascii_alphabetic() || byte >= 0x80 +} + +fn is_ident_continue(byte: u8) -> bool { + is_ident_start(byte) || byte.is_ascii_digit() +} + +fn is_ident_boundary(source: &[u8], pos: usize) -> bool { + pos >= source.len() || !is_ident_continue(source[pos]) +} + +fn is_ident_start_boundary(source: &[u8], pos: usize) -> bool { + pos == 0 || !is_ident_continue(source[pos - 1]) +} + +fn is_free_ident_start(source: &[u8], pos: usize) -> bool { + is_ident_start_boundary(source, pos) && (pos == 0 || source[pos - 1] != b'.') +} + +fn skip_ws_comments(source: &str, mut pos: usize) -> usize { + let bytes = source.as_bytes(); + loop { + while pos < bytes.len() && bytes[pos].is_ascii_whitespace() { + pos += 1; + } + if pos + 1 < bytes.len() && bytes[pos] == b'/' && bytes[pos + 1] == b'/' { + pos += 2; + while pos < bytes.len() && !matches!(bytes[pos], b'\n' | b'\r') { + pos += 1; + } + continue; + } + if pos + 1 < bytes.len() && bytes[pos] == b'/' && bytes[pos + 1] == b'*' { + pos += 2; + while pos + 1 < bytes.len() && !(bytes[pos] == b'*' && bytes[pos + 1] == b'/') { + pos += 1; + } + pos = (pos + 2).min(bytes.len()); + continue; + } + return pos; + } +} + +fn read_ident(source: &str, mut pos: usize) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + if pos >= bytes.len() || !is_ident_start(bytes[pos]) { + return None; + } + let start = pos; + pos += 1; + while pos < bytes.len() && is_ident_continue(bytes[pos]) { + pos += 1; + } + Some((source[start..pos].to_string(), pos)) +} + +fn read_js_string(source: &str, pos: usize) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + if pos >= bytes.len() || !matches!(bytes[pos], b'\'' | b'"') { + return None; + } + let quote = bytes[pos]; + let mut units = Vec::::new(); + let mut i = pos + 1; + while i < bytes.len() { + let byte = bytes[i]; + if byte == quote { + return String::from_utf16(&units).ok().map(|s| (s, i + 1)); + } + if byte == b'\\' { + i += 1; + if i >= bytes.len() { + return None; + } + match bytes[i] { + b'n' => units.push(b'\n' as u16), + b'r' => units.push(b'\r' as u16), + b't' => units.push(b'\t' as u16), + b'b' => units.push(8), + b'f' => units.push(12), + b'v' => units.push(11), + b'x' if i + 2 < bytes.len() + && bytes[i + 1].is_ascii_hexdigit() + && bytes[i + 2].is_ascii_hexdigit() => + { + let value = hex_byte(bytes[i + 1])? * 16 + hex_byte(bytes[i + 2])?; + units.push(value as u16); + i += 2; + } + b'x' => return None, + b'u' if i + 1 < bytes.len() && bytes[i + 1] == b'{' => { + let start = i + 2; + let end = source[start..].find('}')? + start; + let code = u32::from_str_radix(&source[start..end], 16).ok()?; + if code <= 0xFFFF { + units.push(code as u16); + } else { + let code = code - 0x1_0000; + units.push(0xD800 | ((code >> 10) as u16)); + units.push(0xDC00 | ((code & 0x3FF) as u16)); + } + i = end; + } + b'u' if i + 4 < bytes.len() + && bytes[i + 1].is_ascii_hexdigit() + && bytes[i + 2].is_ascii_hexdigit() + && bytes[i + 3].is_ascii_hexdigit() + && bytes[i + 4].is_ascii_hexdigit() => + { + let value = u16::from(hex_byte(bytes[i + 1])?) << 12 + | u16::from(hex_byte(bytes[i + 2])?) << 8 + | u16::from(hex_byte(bytes[i + 3])?) << 4 + | u16::from(hex_byte(bytes[i + 4])?); + units.push(value); + i += 4; + } + b'u' => return None, + other => units.push(other as u16), + } + i += 1; + continue; + } + if byte == b'\n' || byte == b'\r' { + return None; + } + let ch = source[i..].chars().next()?; + let mut buf = [0u16; 2]; + units.extend_from_slice(ch.encode_utf16(&mut buf)); + i += ch.len_utf8(); + } + None +} + +fn hex_byte(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn skip_string_or_template(source: &str, pos: usize) -> usize { + let bytes = source.as_bytes(); + if pos >= bytes.len() { + return pos; + } + let quote = bytes[pos]; + let mut i = pos + 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + i += 2; + } else if bytes[i] == quote { + return i + 1; + } else { + i += 1; + } + } + i +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CjsExportTarget { + Exports, + ModuleExports, +} + +fn parse_exports_target(source: &str, pos: usize) -> Option<(CjsExportTarget, usize)> { + let bytes = source.as_bytes(); + if is_free_ident_start(bytes, pos) + && source[pos..].starts_with("exports") + && is_ident_boundary(bytes, pos + 7) + { + return Some((CjsExportTarget::Exports, pos + 7)); + } + if is_free_ident_start(bytes, pos) + && source[pos..].starts_with("module") + && is_ident_boundary(bytes, pos + 6) + { + let mut i = skip_ws_comments(source, pos + 6); + if i < bytes.len() && bytes[i] == b'.' { + i = skip_ws_comments(source, i + 1); + if source[i..].starts_with("exports") && is_ident_boundary(bytes, i + 7) { + return Some((CjsExportTarget::ModuleExports, i + 7)); + } + } + } + None +} + +fn parse_export_member(source: &str, pos: usize) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + let (_, mut i) = parse_exports_target(source, pos)?; + i = skip_ws_comments(source, i); + let name; + if i < bytes.len() && bytes[i] == b'.' { + i = skip_ws_comments(source, i + 1); + let (ident, next) = read_ident(source, i)?; + name = ident; + i = next; + } else if i < bytes.len() && bytes[i] == b'[' { + i = skip_ws_comments(source, i + 1); + let (string_name, next) = read_js_string(source, i)?; + i = skip_ws_comments(source, next); + if i >= bytes.len() || bytes[i] != b']' { + return None; + } + name = string_name; + i += 1; + } else { + return None; + } + i = skip_ws_comments(source, i); + if i < bytes.len() + && bytes[i] == b'=' + && (i + 1 >= bytes.len() || !matches!(bytes[i + 1], b'=' | b'>')) + { + Some((name, i + 1)) + } else { + None + } +} + +fn parse_require_string(source: &str, pos: usize) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + if !is_free_ident_start(bytes, pos) + || !source[pos..].starts_with("require") + || !is_ident_boundary(bytes, pos + 7) + { + return None; + } + let mut i = skip_ws_comments(source, pos + 7); + if i >= bytes.len() || bytes[i] != b'(' { + return None; + } + i = skip_ws_comments(source, i + 1); + let (specifier, next) = read_js_string(source, i)?; + i = skip_ws_comments(source, next); + if i < bytes.len() && bytes[i] == b')' { + Some((specifier, i + 1)) + } else { + None + } +} + +fn parse_define_property_export(source: &str, pos: usize) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + if !is_free_ident_start(bytes, pos) + || !source[pos..].starts_with("Object") + || !is_ident_boundary(bytes, pos + 6) + { + return None; + } + let mut i = skip_ws_comments(source, pos + 6); + if i >= bytes.len() || bytes[i] != b'.' { + return None; + } + i = skip_ws_comments(source, i + 1); + if !source[i..].starts_with("defineProperty") || !is_ident_boundary(bytes, i + 14) { + return None; + } + i = skip_ws_comments(source, i + 14); + if i >= bytes.len() || bytes[i] != b'(' { + return None; + } + i = skip_ws_comments(source, i + 1); + let (_, next) = parse_exports_target(source, i)?; + i = next; + i = skip_ws_comments(source, i); + if i >= bytes.len() || bytes[i] != b',' { + return None; + } + i = skip_ws_comments(source, i + 1); + let (name, next) = read_js_string(source, i)?; + i = skip_ws_comments(source, next); + if i >= bytes.len() || bytes[i] != b',' { + return None; + } + let descriptor_start = i + 1; + let end = find_matching_paren(source, pos)?; + let descriptor = &source[descriptor_start..end]; + if descriptor_has_value_property(descriptor) || is_safe_getter_descriptor(descriptor) { + Some((name, end + 1)) + } else { + None + } +} + +fn descriptor_has_value_property(descriptor: &str) -> bool { + let bytes = descriptor.as_bytes(); + let mut i = 0usize; + let mut depth = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(descriptor, i); + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { + i += 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + b'/' if is_regex_literal_start(descriptor, i) => { + i = skip_regex_literal(descriptor, i); + continue; + } + b'{' => { + depth += 1; + i += 1; + } + b'}' => { + depth = depth.saturating_sub(1); + i += 1; + } + b'v' if depth == 1 + && is_free_ident_start(bytes, i) + && descriptor[i..].starts_with("value") + && is_ident_boundary(bytes, i + 5) => + { + let next = skip_ws_comments(descriptor, i + 5); + return next < bytes.len() && bytes[next] == b':'; + } + _ => i += 1, + } + } + false +} + +fn find_matching_paren(source: &str, start: usize) -> Option { + let bytes = source.as_bytes(); + let mut i = source[start..].find('(')? + start; + let mut depth = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => i = skip_string_or_template(source, i), + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { + i += 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + } + b'/' if is_regex_literal_start(source, i) => { + i = skip_regex_literal(source, i); + } + b'(' => { + depth += 1; + i += 1; + } + b')' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(i); + } + i += 1; + } + _ => i += 1, + } + } + None +} + +fn find_matching_brace(source: &str, start: usize) -> Option { + let bytes = source.as_bytes(); + let mut i = start; + let mut depth = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => i = skip_string_or_template(source, i), + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { + i += 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + } + b'/' if is_regex_literal_start(source, i) => { + i = skip_regex_literal(source, i); + } + b'{' => { + depth += 1; + i += 1; + } + b'}' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(i); + } + i += 1; + } + _ => i += 1, + } + } + None +} + + +fn is_safe_getter_descriptor(descriptor: &str) -> bool { + let Some((body_start, body_end)) = find_getter_body(descriptor) else { + return false; + }; + is_simple_getter_body(&descriptor[body_start..body_end]) +} + +fn find_getter_body(source: &str) -> Option<(usize, usize)> { + let bytes = source.as_bytes(); + let mut i = 0usize; + let mut depth = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(source, i); + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { + i += 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + b'/' if is_regex_literal_start(source, i) => { + i = skip_regex_literal(source, i); + continue; + } + b'{' => { + depth += 1; + i += 1; + continue; + } + b'}' => { + depth = depth.saturating_sub(1); + i += 1; + continue; + } + b'g' if depth == 1 + && is_free_ident_start(bytes, i) + && source[i..].starts_with("get") + && is_ident_boundary(bytes, i + 3) => + { + let mut j = skip_ws_comments(source, i + 3); + if j < bytes.len() && bytes[j] == b'(' { + let params_end = find_matching_paren(source, j)?; + j = skip_ws_comments(source, params_end + 1); + if j < bytes.len() && bytes[j] == b'{' { + let body_end = find_matching_brace(source, j)?; + return Some((j + 1, body_end)); + } + } else if j < bytes.len() && bytes[j] == b':' { + j = skip_ws_comments(source, j + 1); + if !source[j..].starts_with("function") || !is_ident_boundary(bytes, j + 8) { + i += 1; + continue; + } + j = skip_ws_comments(source, j + 8); + if let Some((_, next)) = read_ident(source, j) { + j = skip_ws_comments(source, next); + } + if j >= bytes.len() || bytes[j] != b'(' { + i += 1; + continue; + } + let params_end = find_matching_paren(source, j)?; + j = skip_ws_comments(source, params_end + 1); + if j < bytes.len() && bytes[j] == b'{' { + let body_end = find_matching_brace(source, j)?; + return Some((j + 1, body_end)); + } + } + } + _ => {} + } + i += 1; + } + None +} + +fn is_simple_getter_body(body: &str) -> bool { + let return_pos = skip_ws_comments(body, 0); + if !body[return_pos..].starts_with("return") + || !is_free_ident_start(body.as_bytes(), return_pos) + || !is_ident_boundary(body.as_bytes(), return_pos + 6) + { + return false; + } + let mut i = skip_ws_comments(body, return_pos + 6); + let Some((_, next)) = read_ident(body, i) else { + return false; + }; + i = skip_ws_comments(body, next); + if i < body.len() && body.as_bytes()[i] == b'.' { + i = skip_ws_comments(body, i + 1); + let Some((_, next)) = read_ident(body, i) else { + return false; + }; + i = next; + } else if i < body.len() && body.as_bytes()[i] == b'[' { + i = skip_ws_comments(body, i + 1); + let Some((_, next)) = read_js_string(body, i) else { + return false; + }; + i = skip_ws_comments(body, next); + if i >= body.len() || body.as_bytes()[i] != b']' { + return false; + } + i += 1; + } + i = skip_ws_comments(body, i); + if i < body.len() && body.as_bytes()[i] == b';' { + i = skip_ws_comments(body, i + 1); + } + i >= body.len() +} + + +fn parse_require_binding(source: &str, pos: usize) -> Option<(String, String, usize)> { + for keyword in ["var", "let", "const"] { + if is_free_ident_start(source.as_bytes(), pos) + && source[pos..].starts_with(keyword) + && is_ident_boundary(source.as_bytes(), pos + keyword.len()) + { + let mut i = skip_ws_comments(source, pos + keyword.len()); + let (name, next) = read_ident(source, i)?; + i = skip_ws_comments(source, next); + if i >= source.len() || source.as_bytes()[i] != b'=' { + return None; + } + i = skip_ws_comments(source, i + 1); + let (specifier, next) = parse_require_string(source, i)?; + let after_require = skip_ws_comments(source, next); + if !is_statement_boundary(source, after_require) { + return None; + } + return Some((name, specifier, next)); + } + } + None +} + +fn is_statement_boundary(source: &str, pos: usize) -> bool { + pos >= source.len() || matches!(source.as_bytes()[pos], b';' | b'}') +} + +fn parse_module_exports_reexport(source: &str, pos: usize) -> Option<(String, usize)> { + let (target, mut i) = parse_exports_target(source, pos)?; + if target != CjsExportTarget::ModuleExports { + return None; + } + i = skip_ws_comments(source, i); + if i >= source.len() || source.as_bytes()[i] != b'=' { + return None; + } + let (specifier, next) = parse_require_string(source, skip_ws_comments(source, i + 1))?; + let after_require = skip_ws_comments(source, next); + if is_statement_boundary(source, after_require) { + Some((specifier, after_require.min(source.len()))) + } else { + None + } +} + +fn parse_module_exports_assignment(source: &str, pos: usize) -> Option { + let bytes = source.as_bytes(); + let (target, mut i) = parse_exports_target(source, pos)?; + if target != CjsExportTarget::ModuleExports { + return None; + } + i = skip_ws_comments(source, i); + if i < bytes.len() + && bytes[i] == b'=' + && (i + 1 >= bytes.len() || !matches!(bytes[i + 1], b'=' | b'>')) + { + Some(i + 1) + } else { + None + } +} + +fn parse_object_keys_reexport(source: &str, pos: usize, bindings: &HashMap) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + if !is_free_ident_start(bytes, pos) + || !source[pos..].starts_with("Object") + || !is_ident_boundary(bytes, pos + 6) + { + return None; + } + let mut i = skip_ws_comments(source, pos + 6); + if i >= bytes.len() || bytes[i] != b'.' { + return None; + } + i = skip_ws_comments(source, i + 1); + if !source[i..].starts_with("keys") || !is_ident_boundary(bytes, i + 4) { + return None; + } + i = skip_ws_comments(source, i + 4); + if i >= bytes.len() || bytes[i] != b'(' { + return None; + } + i = skip_ws_comments(source, i + 1); + let (binding, next) = read_ident(source, i)?; + let specifier = bindings.get(&binding)?.clone(); + i = skip_ws_comments(source, next); + if i >= bytes.len() || bytes[i] != b')' { + return None; + } + let after_keys = skip_ws_comments(source, i + 1); + if after_keys >= bytes.len() || bytes[after_keys] != b'.' { + return None; + } + let for_each_pos = skip_ws_comments(source, after_keys + 1); + if !source[for_each_pos..].starts_with("forEach") || !is_ident_boundary(bytes, for_each_pos + 7) { + return None; + } + let end = find_matching_paren(source, for_each_pos + 7).unwrap_or(for_each_pos + 7); + let callback = &source[for_each_pos..end]; + if callback_has_transpiler_reexport(callback, &binding) { + Some((specifier, end + 1)) + } else { + None + } +} + +fn callback_has_transpiler_reexport(callback: &str, binding: &str) -> bool { + let bytes = callback.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(callback, i); + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { + i += 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + b'/' if is_regex_literal_start(callback, i) => { + i = skip_regex_literal(callback, i); + continue; + } + _ => {} + } + if parse_define_property_reexport(callback, i, binding).is_some() { + return true; + } + i = next_char_boundary(callback, i); + } + false +} + +fn parse_define_property_reexport(source: &str, pos: usize, binding: &str) -> Option { + let bytes = source.as_bytes(); + if !is_free_ident_start(bytes, pos) + || !source[pos..].starts_with("Object") + || !is_ident_boundary(bytes, pos + 6) + { + return None; + } + let mut i = skip_ws_comments(source, pos + 6); + if i >= bytes.len() || bytes[i] != b'.' { + return None; + } + i = skip_ws_comments(source, i + 1); + if !source[i..].starts_with("defineProperty") || !is_ident_boundary(bytes, i + 14) { + return None; + } + i = skip_ws_comments(source, i + 14); + if i >= bytes.len() || bytes[i] != b'(' { + return None; + } + i = skip_ws_comments(source, i + 1); + let (target, next) = parse_exports_target(source, i)?; + if target != CjsExportTarget::Exports { + return None; + } + i = skip_ws_comments(source, next); + if i >= bytes.len() || bytes[i] != b',' { + return None; + } + i = skip_ws_comments(source, i + 1); + let (key, next) = read_ident(source, i)?; + i = skip_ws_comments(source, next); + if i >= bytes.len() || bytes[i] != b',' { + return None; + } + let descriptor_start = i + 1; + let end = find_matching_paren(source, pos)?; + let descriptor = &source[descriptor_start..end]; + if descriptor_getter_returns_binding_key(descriptor, binding, &key) { + Some(end + 1) + } else { + None + } +} + +fn descriptor_getter_returns_binding_key(descriptor: &str, binding: &str, key: &str) -> bool { + let Some((body_start, body_end)) = find_getter_body(descriptor) else { + return false; + }; + getter_body_returns_binding_key(&descriptor[body_start..body_end], binding, key) +} + +fn getter_body_returns_binding_key(body: &str, binding: &str, key: &str) -> bool { + let bytes = body.as_bytes(); + let mut i = skip_ws_comments(body, 0); + if !body[i..].starts_with("return") + || !is_free_ident_start(bytes, i) + || !is_ident_boundary(bytes, i + 6) + { + return false; + } + i = skip_ws_comments(body, i + 6); + if !body[i..].starts_with(binding) + || !is_free_ident_start(bytes, i) + || !is_ident_boundary(bytes, i + binding.len()) + { + return false; + } + i = skip_ws_comments(body, i + binding.len()); + if i >= bytes.len() || bytes[i] != b'[' { + return false; + } + i = skip_ws_comments(body, i + 1); + if !body[i..].starts_with(key) + || !is_free_ident_start(bytes, i) + || !is_ident_boundary(bytes, i + key.len()) + { + return false; + } + i = skip_ws_comments(body, i + key.len()); + if i >= bytes.len() || bytes[i] != b']' { + return false; + } + i = skip_ws_comments(body, i + 1); + if i < bytes.len() && bytes[i] == b';' { + i = skip_ws_comments(body, i + 1); + } + i >= bytes.len() +} + +fn next_char_boundary(source: &str, pos: usize) -> usize { + if pos >= source.len() { + return source.len(); + } + pos + source[pos..].chars().next().map_or(1, char::len_utf8) +} + +fn previous_significant_byte(source: &str, pos: usize) -> Option { + let bytes = source.as_bytes(); + let mut i = pos; + while i > 0 { + i -= 1; + if !bytes[i].is_ascii_whitespace() { + return Some(bytes[i]); + } + } + None +} + +fn is_regex_literal_start(source: &str, pos: usize) -> bool { + if matches!( + previous_significant_byte(source, pos), + None | Some(b'(' | b'{' | b'[' | b'=' | b':' | b',' | b';' | b'!' | b'?' | b'&' | b'|' | b'+' | b'-' | b'*' | b'~' | b'^' | b'%' | b'>') + ) { + return true; + } + + let bytes = source.as_bytes(); + let mut end = pos; + while end > 0 && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + let mut start = end; + while start > 0 && is_ident_continue(bytes[start - 1]) { + start -= 1; + } + matches!(&source[start..end], "return" | "throw" | "case" | "yield") +} + +fn skip_regex_literal(source: &str, pos: usize) -> usize { + let bytes = source.as_bytes(); + let mut i = pos + 1; + let mut in_class = false; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + b'[' => { + in_class = true; + i += 1; + } + b']' => { + in_class = false; + i += 1; + } + b'/' if !in_class => { + i += 1; + while i < bytes.len() && bytes[i].is_ascii_alphabetic() { + i += 1; + } + return i; + } + b'\n' | b'\r' => return pos + 1, + _ => i += 1, + } + } + pos + 1 +} + +fn analyze_cjs_exports(source: &str) -> CjsExportAnalysis { + let bytes = source.as_bytes(); + let mut analysis = CjsExportAnalysis::default(); + let mut require_bindings = HashMap::::new(); + let mut i = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(source, i); + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { + i += 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + b'/' if is_regex_literal_start(source, i) => { + i = skip_regex_literal(source, i); + continue; + } + _ => {} + } + if let Some((name, next)) = parse_export_member(source, i) { + analysis.is_cjs = true; + add_unique(&mut analysis.exports, name); + i = next; + continue; + } + if let Some((name, next)) = parse_define_property_export(source, i) { + analysis.is_cjs = true; + add_unique(&mut analysis.exports, name); + i = next; + continue; + } + if let Some((binding, specifier, next)) = parse_require_binding(source, i) { + require_bindings.insert(binding, specifier); + i = next; + continue; + } + if let Some((specifier, next)) = parse_module_exports_reexport(source, i) { + analysis.is_cjs = true; + analysis.reexports.clear(); + add_unique(&mut analysis.reexports, specifier); + i = next; + continue; + } + if let Some(next) = parse_module_exports_assignment(source, i) { + analysis.is_cjs = true; + i = next; + continue; + } + if let Some((specifier, next)) = parse_object_keys_reexport(source, i, &require_bindings) { + analysis.is_cjs = true; + add_unique(&mut analysis.reexports, specifier); + i = next; + continue; + } + i = next_char_boundary(source, i); + } + analysis +} + +fn resolve_cjs_reexport_path(filename: &str, specifier: &str) -> Option { + if !specifier.starts_with("./") && !specifier.starts_with("../") && !specifier.starts_with('/') { + return None; + } + let base = if specifier.starts_with('/') { + std::path::PathBuf::from(specifier) + } else { + std::path::Path::new(filename).parent()?.join(specifier) + }; + let candidates = [ + base.clone(), + base.with_extension("js"), + base.with_extension("cjs"), + base.join("index.js"), + base.join("index.cjs"), + ]; + for candidate in candidates { + if candidate.is_file() { + return Some(candidate.to_string_lossy().into_owned()); + } + } + None +} + +fn analyze_cjs_exports_for_file(filename: &str, source: &str, seen: &mut HashSet) -> CjsExportAnalysis { + let mut analysis = analyze_cjs_exports(source); + if !seen.insert(filename.to_string()) { + return analysis; + } + let reexports = analysis.reexports.clone(); + for reexport in reexports { + if let Some(path) = resolve_cjs_reexport_path(filename, &reexport) + && !seen.contains(&path) + && let Ok(source) = std::fs::read_to_string(&path) + { + let child = analyze_cjs_exports_for_file(&path, &source, seen); + for name in child.exports { + add_unique(&mut analysis.exports, name); + } + } + } + analysis +} + +fn cjs_named_export_source(names: &[String]) -> String { + let mut out = String::new(); + for (index, name) in names.iter().enumerate() { + if name == "default" { + continue; + } + let local = format!("__cjs_export_{}", index); + let escaped = escape_js_string(name); + out.push_str(&format!( + "var {local} = __cjs_default[\"{escaped}\"];\nexport {{ {local} as \"{escaped}\" }};\n" + )); + } + out +} + impl Loader for CjsCompatLoader { fn load<'js>( &mut self, @@ -1578,6 +2573,8 @@ impl Loader for CjsCompatLoader { let filename = Some(abs_path.clone()); let dirname = std_path.parent().map(|p| p.to_string_lossy().into_owned()); let url = path_to_file_url(path); + let escaped_filename = escape_js_string(&abs_path); + let escaped_dirname = dirname.as_deref().map(escape_js_string).unwrap_or_default(); let init = ImportMetaInit { url, @@ -1586,11 +2583,14 @@ impl Loader for CjsCompatLoader { include_resolve: true, }; - // .cjs files are always CommonJS; for .js files, detect CJS patterns + let detected_analysis = analyze_cjs_exports_for_file(&abs_path, &source, &mut HashSet::new()); + + // .cjs files are always CommonJS; for .js files, use the analyzer so + // comments, strings, templates, and regex literals do not force CJS. let is_cjs = is_cjs_ext - || source.contains("module.exports") - || source.contains("exports.") - || (source.contains("require(") && !source.contains("import ")); + || detected_analysis.is_cjs + || !detected_analysis.exports.is_empty() + || !detected_analysis.reexports.is_empty(); if !is_cjs { // Treat as ESM — inject import.meta prologue (handles shebangs) @@ -1611,24 +2611,40 @@ impl Loader for CjsCompatLoader { String::new() } } else { - source + source.clone() + }; + + let analysis = if cjs_source == source { + detected_analysis + } else { + analyze_cjs_exports_for_file(&abs_path, &cjs_source, &mut HashSet::new()) }; + let named_exports = cjs_named_export_source(&analysis.exports); // Wrap CJS source in ESM-compatible wrapper, with import.meta prologue before the wrapper let prologue = inject_import_meta_prologue(&init, ""); let wrapped = format!( - r#"{} + r#"import {{ createRequire as __wasm_rquickjs_createRequire }} from 'node:module'; +{} var module = {{ exports: {{}} }}; var exports = module.exports; -(function(module, exports) {{ +var require = __wasm_rquickjs_createRequire("{}"); +var __filename = "{}"; +var __dirname = "{}"; +var global = globalThis; +(function(module, exports, require, __filename, __dirname) {{ {} -}})(module, exports); +}})(module, exports, require, __filename, __dirname); var __cjs_default = module.exports; export default __cjs_default; -export var __esModule = __cjs_default && __cjs_default.__esModule; +{} "#, prologue.trim(), - cjs_source + escaped_filename, + escaped_filename, + escaped_dirname, + cjs_source, + named_exports ); Module::declare(ctx.clone(), path, wrapped.as_bytes().to_vec()) @@ -2999,6 +4015,165 @@ pub fn format_caught_error(caught: CaughtError) -> String { } } +#[cfg(test)] +mod cjs_export_analyzer_tests { + use super::*; + + fn assert_analysis( + source: &str, + is_cjs: bool, + exports: &[&str], + reexports: &[&str], + ) { + let analysis = analyze_cjs_exports(source); + assert_eq!(analysis.is_cjs, is_cjs, "is_cjs mismatch for {source}"); + assert_eq!(analysis.exports, exports, "exports mismatch for {source}"); + assert_eq!( + analysis.reexports, reexports, + "reexports mismatch for {source}" + ); + } + + #[test] + fn detects_supported_cjs_export_patterns() { + assert_analysis( + r#" + exports.foo = 1; + module.exports.bar = 2; + exports["baz"] = 3; + Object.defineProperty(exports, "valueExport", { value: 4 }); + Object.defineProperty(module.exports, "getterExport", { get() { return dep.value; } }); + Object.defineProperty(exports, "functionGetter", { get: function () { return dep["other"]; } }); + "#, + true, + &["foo", "bar", "baz", "valueExport", "getterExport", "functionGetter"], + &[], + ); + } + + #[test] + fn malformed_non_ascii_escapes_do_not_panic() { + assert_analysis(r#"exports["\xaé"] = 1;"#, false, &[], &[]); + assert_analysis(r#"exports["\uabcé"] = 1;"#, false, &[], &[]); + } + + #[test] + fn detects_module_exports_assignments_with_comments() { + assert_analysis(r#"module /*x*/ . /*y*/ exports = {};"#, true, &[], &[]); + assert_analysis( + r#"module /*x*/ . /*y*/ exports = require("./dep.cjs");"#, + true, + &[], + &["./dep.cjs"], + ); + assert_analysis( + r#"module.exports = require("./dep.cjs").nested;"#, + true, + &[], + &[], + ); + assert_analysis( + r#"module.exports = require("./dep.cjs")();"#, + true, + &[], + &[], + ); + assert_analysis( + r#" + var dep = require("./dep.cjs").nested; + Object.keys(dep).forEach(function (key) { + Object.defineProperty(exports, key, { get: function () { return dep[key]; } }); + }); + exports.own = "own"; + "#, + true, + &["own"], + &[], + ); + } + + #[test] + fn require_binding_alone_does_not_classify_esm_as_cjs() { + assert_analysis( + r#" + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const dep = require("./dep.cjs"); + export const value = dep.value; + "#, + false, + &[], + &[], + ); + } + + #[test] + fn ignores_false_positive_assignments_and_define_property_descriptors() { + assert_analysis( + r#" + if (module.exports === undefined) {} + if (exports.fake == "no") {} + Object.defineProperty(exports, "setterOnly", { set(v) { return dep.value; } }); + Object.defineProperty(exports, "unrelated", { other: function () { return dep.value; } }); + Object.defineProperty(exports, "regexDescriptor", { enumerable: /value:/ }); + Object.defineProperty(exports, "multipleReturn", { get() { return dep.value; return dynamic(); } }); + Object.defineProperty(exports, "conditionalReturn", { get() { if (dep) return dep.value; return dynamic(); } }); + "#, + false, + &[], + &[], + ); + } + + #[test] + fn detects_only_real_transpiler_reexport_callbacks() { + assert_analysis( + r#" + var _dep = require("./dep.cjs"); + Object.keys(_dep).forEach(function (key) { + const π = 1; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { return _dep[key]; } + }); + }); + exports.own = "own"; + "#, + true, + &["own"], + &["./dep.cjs"], + ); + + assert_analysis( + r#" + var _dep = require("./dep.cjs"); + Object.keys(_dep).forEach(function (key) { + const msg = "Object.defineProperty(exports, key, { get: function () { return _dep[key]; } })"; + }); + exports.own = "own"; + "#, + true, + &["own"], + &[], + ); + + assert_analysis( + r#" + var _dep = require("./dep.cjs"); + Object.keys(_dep).forEach(function (key) { + Object.defineProperty(other, key, { value: 1 }); + exports; + function unrelated() { return _dep[key]; } + }); + exports.own = "own"; + "#, + true, + &["own"], + &[], + ); + } +} + /// Wizer pre-initialization entry point: full initialization including user module. /// After Wizer snapshots this state, the runtime is ready to handle exports immediately. #[allow(static_mut_refs)] diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 6e2f5729..526b3b9a 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -141,3 +141,284 @@ export const testEsmPackageMapEdgeCases = async () => { return false; } }; + +export const testCjsDirectNamedExports = async () => { + try { + fs.mkdirSync('/cjs-named-export-app', { recursive: true }); + fs.writeFileSync('/cjs-named-export-app/direct.cjs', [ + 'exports.foo = "foo";', + 'module.exports.bar = "bar";', + 'exports["baz"] = "baz";', + 'module.exports["π"] = "pi";', + 'exports["invalid identifier"] = "invalid";', + 'module.exports["?invalid"] = "question";', + 'exports.package = "reserved";', + '// exports.commentOnly = "no";', + '/* module.exports.blockCommentOnly = "no"; */', + 'const text = "exports.stringOnly = no";', + ].join('\n')); + fs.writeFileSync('/cjs-named-export-app/bracket-only.js', [ + 'exports["bracketOnly"] = "bracket";', + ].join('\n')); + fs.writeFileSync('/cjs-named-export-app/define-only.js', [ + 'Object.defineProperty(exports, "definedOnly", { value: "defined" });', + ].join('\n')); + fs.writeFileSync('/cjs-named-export-app/false-positives.cjs', [ + 'const myexports = {};', + 'myexports.fake1 = "no";', + 'const obj = { exports: {} };', + 'obj.exports.fake2 = "no";', + 'const notmodule = {};', + 'notmodule.exports = {};', + 'notmodule.exports.fake3 = "no";', + 'if (exports.fake4 === "no") {}', + 'if (module.exports.fake5 == "no") {}', + 'const re = /exports.fake6 = "no"/;', + 'exports.real = "yes";', + ].join('\n')); + fs.writeFileSync('/cjs-named-export-app/direct-entry.mjs', [ + 'import def, { foo, bar, baz, π, package as packageExport } from "./direct.cjs";', + 'import { bracketOnly } from "./bracket-only.js";', + 'import { definedOnly } from "./define-only.js";', + 'import * as ns from "./direct.cjs";', + 'import * as fp from "./false-positives.cjs";', + 'export default {', + ' def, foo, bar, baz, pi: π, packageExport, bracketOnly, definedOnly,', + ' invalidIdentifier: ns["invalid identifier"],', + ' questionInvalid: ns["?invalid"],', + ' hasCommentOnly: Object.prototype.hasOwnProperty.call(ns, "commentOnly"),', + ' hasBlockCommentOnly: Object.prototype.hasOwnProperty.call(ns, "blockCommentOnly"),', + ' hasStringOnly: Object.prototype.hasOwnProperty.call(ns, "stringOnly"),', + ' falsePositiveKeys: Object.keys(fp).filter((key) => key !== "default" && key !== "real"),', + ' real: fp.real,', + '};', + ].join('\n')); + + const result = (await import('/cjs-named-export-app/direct-entry.mjs')).default; + assert.strictEqual(result.foo, 'foo'); + assert.strictEqual(result.bar, 'bar'); + assert.strictEqual(result.baz, 'baz'); + assert.strictEqual(result.pi, 'pi'); + assert.strictEqual(result.packageExport, 'reserved'); + assert.strictEqual(result.bracketOnly, 'bracket'); + assert.strictEqual(result.definedOnly, 'defined'); + assert.strictEqual(result.invalidIdentifier, 'invalid'); + assert.strictEqual(result.questionInvalid, 'question'); + assert.strictEqual(result.def.foo, 'foo'); + assert.strictEqual(result.def['π'], 'pi'); + assert.deepStrictEqual(result.falsePositiveKeys, []); + assert.strictEqual(result.real, 'yes'); + assert.strictEqual(result.hasCommentOnly, false); + assert.strictEqual(result.hasBlockCommentOnly, false); + assert.strictEqual(result.hasStringOnly, false); + return true; + } catch (error) { + console.error(error); + return false; + } +}; + +export const testCjsDefinePropertyNamedExports = async () => { + try { + fs.mkdirSync('/cjs-define-export-app', { recursive: true }); + fs.writeFileSync('/cjs-define-export-app/define.cjs', [ + 'const dep = { value: "getter-value" };', + 'Object.defineProperty(exports, "valueExport", { value: "value" });', + 'Object.defineProperty(exports, "getterExport", { enumerable: true, get: function () { return dep.value; } });', + 'Object.defineProperty(module.exports, "moduleGetter", { enumerable: true, get() { return dep.value; } });', + 'Object.defineProperty(exports, "unsafe", { enumerable: true, get() { return dynamic(); } });', + 'Object.defineProperty(exports, "unsafeValueWord", { enumerable: true, get() { return value(); } });', + ].join('\n')); + fs.writeFileSync('/cjs-define-export-app/define-entry.mjs', [ + 'import { valueExport, getterExport, moduleGetter } from "./define.cjs";', + 'import * as ns from "./define.cjs";', + 'export default {', + ' valueExport, getterExport, moduleGetter,', + ' hasUnsafe: Object.prototype.hasOwnProperty.call(ns, "unsafe"),', + ' hasUnsafeValueWord: Object.prototype.hasOwnProperty.call(ns, "unsafeValueWord"),', + '};', + ].join('\n')); + + const result = (await import('/cjs-define-export-app/define-entry.mjs')).default; + assert.strictEqual(result.valueExport, 'value'); + assert.strictEqual(result.getterExport, 'getter-value'); + assert.strictEqual(result.moduleGetter, 'getter-value'); + assert.strictEqual(result.hasUnsafe, false); + assert.strictEqual(result.hasUnsafeValueWord, false); + return true; + } catch (error) { + console.error(error); + return false; + } +}; + +export const testCjsReexportNamedExports = async () => { + try { + fs.mkdirSync('/cjs-reexport-app', { recursive: true }); + fs.writeFileSync('/cjs-reexport-app/dep.cjs', [ + 'exports.alpha = "alpha";', + 'exports.beta = "beta";', + ].join('\n')); + fs.writeFileSync('/cjs-reexport-app/reexport.cjs', 'module.exports = require("./dep.cjs");'); + fs.writeFileSync('/cjs-reexport-app/transpiler.cjs', [ + 'var _dep = require("./dep.cjs");', + 'Object.keys(_dep).forEach(function (key) {', + ' if (key === "default" || key === "__esModule") return;', + ' Object.defineProperty(exports, key, {', + ' enumerable: true,', + ' get: function () { return _dep[key]; }', + ' });', + '});', + ].join('\n')); + fs.writeFileSync('/cjs-reexport-app/not-reexport.cjs', [ + 'var _dep = require("./dep.cjs");', + 'Object.keys(_dep).forEach(console.log);', + 'exports.own = "own";', + ].join('\n')); + fs.writeFileSync('/cjs-reexport-app/reexport-entry.mjs', [ + 'import { alpha, beta } from "./reexport.cjs";', + 'import { alpha as transAlpha, beta as transBeta } from "./transpiler.cjs";', + 'import * as nonReexport from "./not-reexport.cjs";', + 'export default {', + ' alpha, beta, transAlpha, transBeta,', + ' nonReexportKeys: Object.keys(nonReexport).filter((key) => key !== "default" && key !== "own"),', + ' nonReexportOwn: nonReexport.own,', + '};', + ].join('\n')); + + const result = (await import('/cjs-reexport-app/reexport-entry.mjs')).default; + assert.deepStrictEqual(result, { + alpha: 'alpha', + beta: 'beta', + transAlpha: 'alpha', + transBeta: 'beta', + nonReexportKeys: [], + nonReexportOwn: 'own', + }); + return true; + } catch (error) { + console.error(error); + return false; + } +}; + +export const testCjsAnalyzerFalsePositiveGuards = async () => { + try { + fs.mkdirSync('/cjs-analyzer-guards-app', { recursive: true }); + fs.writeFileSync('/cjs-analyzer-guards-app/esm-with-cjs-text.js', [ + '// exports.commentOnly = "no";', + 'const text = "module.exports = {}; require(";', + 'const re = /exports.regexOnly = "no"/;', + 'const fn = () => /module.exports.arrowRegexOnly = "no"/;', + 'if (typeof module !== "undefined" && module.exports === undefined) {}', + 'const require = () => ({ value: 64 });', + 'const dep = require("./dep.cjs");', + 'export const value = 42;', + 'export const requireValue = dep.value;', + ].join('\n')); + fs.writeFileSync('/cjs-analyzer-guards-app/esm-entry.mjs', [ + 'import { value, requireValue } from "./esm-with-cjs-text.js";', + 'export default { value, requireValue };', + ].join('\n')); + assert.deepStrictEqual((await import('/cjs-analyzer-guards-app/esm-entry.mjs')).default, { value: 42, requireValue: 64 }); + + fs.writeFileSync('/cjs-analyzer-guards-app/whitespace-module.js', 'module /*x*/ . /*y*/ exports = { value: "module" };'); + fs.writeFileSync('/cjs-analyzer-guards-app/whitespace-entry.mjs', [ + 'import mod from "./whitespace-module.js";', + 'export default mod.value;', + ].join('\n')); + assert.strictEqual((await import('/cjs-analyzer-guards-app/whitespace-entry.mjs')).default, 'module'); + + fs.writeFileSync('/cjs-analyzer-guards-app/false-positives.cjs', [ + 'const myexports = {};', + 'myexports.fake1 = "no";', + 'const obj = { exports: {} };', + 'obj.exports.fake2 = "no";', + 'const notmodule = {};', + 'notmodule.exports = {};', + 'notmodule.exports.fake3 = "no";', + 'if (exports.fake4 === "no") {}', + 'if (module.exports.fake5 == "no") {}', + 'function f() { return /exports.fake6 = "no"/; }', + 'const g = () => /module.exports.fake7 = "no"/;', + 'exports.real = "yes";', + ].join('\n')); + fs.writeFileSync('/cjs-analyzer-guards-app/unsafe-define.cjs', [ + 'Object.defineProperty(exports, "unsafeStringReturn", { enumerable: true, get() { const s = "return dep.value"; return dynamic(); } });', + 'Object.defineProperty(exports, "unsafeRegexValue", { enumerable: true, get() { return /value:/; } });', + 'Object.defineProperty(exports, "unsafeRegexDescriptor", { enumerable: /value:/ });', + 'Object.defineProperty(exports, "unsafeNestedValue", { enumerable: true, get() { return { value: dynamic() }; } });', + 'Object.defineProperty(exports, "unsafeMultipleReturn", { enumerable: true, get() { return dep.value; return dynamic(); } });', + 'Object.defineProperty(exports, "unsafeConditionalReturn", { enumerable: true, get() { if (dep) return dep.value; return dynamic(); } });', + 'exports.safe = "yes";', + ].join('\n')); + fs.writeFileSync('/cjs-analyzer-guards-app/dep.cjs', 'exports.alpha = "alpha";'); + fs.writeFileSync('/cjs-analyzer-guards-app/dep-nested.cjs', 'exports.nested = { beta: "beta" };'); + fs.writeFileSync('/cjs-analyzer-guards-app/not-reexport.cjs', [ + 'var _dep = require("./dep.cjs");', + 'var other = {};', + 'Object.keys(_dep).forEach(function (key) {', + ' const msg = "Object.defineProperty(exports, key, { get: function () { return _dep[key]; } })";', + '});', + 'Object.keys(_dep).forEach(function (key) {', + ' Object.defineProperty(other, key, { value: 1 });', + ' exports;', + ' function unrelated() { return _dep[key]; }', + '});', + 'exports.own = "own";', + ].join('\n')); + fs.writeFileSync('/cjs-analyzer-guards-app/unicode-reexport.cjs', [ + 'var _dep = require("./dep.cjs");', + 'Object.keys(_dep).forEach(function (key) {', + ' const π = 1;', + ' Object.defineProperty(exports, key, { enumerable: true, get: function () { return _dep[key]; } });', + '});', + ].join('\n')); + fs.writeFileSync('/cjs-analyzer-guards-app/continuation.cjs', [ + 'module.exports = require("./dep.cjs").nested;', + ].join('\n')); + fs.writeFileSync('/cjs-analyzer-guards-app/binding-continuation.cjs', [ + 'var dep = require("./dep-nested.cjs").nested;', + 'Object.keys(dep).forEach(function (key) {', + ' Object.defineProperty(exports, key, { enumerable: true, get: function () { return dep[key]; } });', + '});', + 'exports.own = "own";', + ].join('\n')); + fs.writeFileSync('/cjs-analyzer-guards-app/guards-entry.mjs', [ + 'import * as fp from "./false-positives.cjs";', + 'import * as unsafe from "./unsafe-define.cjs";', + 'import * as nonReexport from "./not-reexport.cjs";', + 'import * as unicodeReexport from "./unicode-reexport.cjs";', + 'import * as continuation from "./continuation.cjs";', + 'import * as bindingContinuation from "./binding-continuation.cjs";', + 'export default {', + ' fpKeys: Object.keys(fp).filter((key) => key !== "default" && key !== "real"),', + ' real: fp.real,', + ' unsafeKeys: Object.keys(unsafe).filter((key) => key !== "default" && key !== "safe"),', + ' safe: unsafe.safe,', + ' nonReexportKeys: Object.keys(nonReexport).filter((key) => key !== "default" && key !== "own"),', + ' own: nonReexport.own,', + ' unicodeAlpha: unicodeReexport.alpha,', + ' continuationKeys: Object.keys(continuation).filter((key) => key !== "default"),', + ' bindingContinuationKeys: Object.keys(bindingContinuation).filter((key) => key !== "default" && key !== "own"),', + ' bindingContinuationOwn: bindingContinuation.own,', + '};', + ].join('\n')); + + const result = (await import('/cjs-analyzer-guards-app/guards-entry.mjs')).default; + assert.deepStrictEqual(result.fpKeys, []); + assert.strictEqual(result.real, 'yes'); + assert.deepStrictEqual(result.unsafeKeys, []); + assert.strictEqual(result.safe, 'yes'); + assert.deepStrictEqual(result.nonReexportKeys, []); + assert.strictEqual(result.own, 'own'); + assert.strictEqual(result.unicodeAlpha, 'alpha'); + assert.deepStrictEqual(result.continuationKeys, []); + assert.deepStrictEqual(result.bindingContinuationKeys, []); + assert.strictEqual(result.bindingContinuationOwn, 'own'); + return true; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index 5ae40c1d..35ce912d 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -2,4 +2,8 @@ package quickjs:module-resolution; world module-resolution { export test-esm-package-map-edge-cases: func() -> bool; + export test-cjs-direct-named-exports: func() -> bool; + export test-cjs-define-property-named-exports: func() -> bool; + export test-cjs-reexport-named-exports: func() -> bool; + export test-cjs-analyzer-false-positive-guards: func() -> bool; } diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index eafbbc29..5a651e01 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5822,7 +5822,7 @@ "es-module/test-disable-require-module-with-detection.js": {}, "es-module/test-esm-assertionless-json-import.js": { "category": "known-gap", "reason": "custom ESM loader hooks (--experimental-loader) and assertionless JSON import behavior are not implemented" }, "es-module/test-esm-cjs-builtins.js": {}, - "es-module/test-esm-cjs-exports.js": { "category": "known-gap", "reason": "ESM<->CJS export interop semantics (including __esModule/default/named export behavior and related errors) are not Node-compatible yet" }, + "es-module/test-esm-cjs-exports.js": { "category": "known-gap", "reason": "child_process execPath emulation does not yet support this ESM/CJS fixture runner path; direct CJS named export interop is covered by test-require-module.js" }, "es-module/test-esm-cjs-main.js": {}, "es-module/test-esm-data-urls.js": {}, "es-module/test-esm-dynamic-import-attribute.js": {}, @@ -5928,8 +5928,8 @@ "es-module/test-require-module-detect-entry-point-aou.js": {}, "es-module/test-require-module-detect-entry-point.js": {}, "es-module/test-require-module-dont-detect-cjs.js": {}, - "es-module/test-require-module-dynamic-import-1.js": { "category": "known-gap", "reason": "requires CJS named export analysis (cjs-module-lexer) for ESM import of CJS modules" }, - "es-module/test-require-module-dynamic-import-2.js": { "category": "known-gap", "reason": "requires CJS named export analysis (cjs-module-lexer) for ESM import of CJS modules" }, + "es-module/test-require-module-dynamic-import-1.js": {}, + "es-module/test-require-module-dynamic-import-2.js": {}, "es-module/test-require-module-dynamic-import-3.js": {}, "es-module/test-require-module-dynamic-import-4.js": {}, "es-module/test-require-module-error-catching.js": { "category": "known-gap", "reason": "QuickJS require(esm) bridge reports async-module semantics before surfacing synchronous ESM evaluation errors" }, @@ -5951,7 +5951,7 @@ } }, "es-module/test-require-module-transpiled.js": {}, - "es-module/test-require-module-twice.js": { "category": "known-gap", "reason": "CJS named export analysis for ESM/CJS interop is incomplete (missing named exports like π)" }, + "es-module/test-require-module-twice.js": {}, "es-module/test-require-module-warning.js": { "category": "known-gap", "reason": "child_process execPath emulation does not implement --trace-require-module warning output" }, "es-module/test-require-module-with-detection.js": { "category": "known-gap", @@ -5969,7 +5969,7 @@ "subtests": { "block_00_test_named_exports": { "category": "runnable" }, "block_01_test_esm_that_import_esm": { "category": "runnable" }, - "block_02_test_esm_that_import_cjs": { "category": "known-gap", "reason": "CJS named export analysis for ESM/CJS interop is incomplete (missing named exports like π)" }, + "block_02_test_esm_that_import_cjs": { "category": "runnable" }, "block_03_test_esm_that_require_cjs": { "category": "runnable" }, "block_04_also_test_default_export": { "category": "runnable" }, "block_05_test_data_import": { "category": "runnable" } diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 4dc34a68..483555ce 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -1,6 +1,6 @@ # Node.js v22.14.0 Compatibility Inventory -Generated: 2026-06-11 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) +Generated: 2026-06-12 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) This report is generated from `config.jsonc` only. It does **not** run the vendored tests itself. Entries classified as `runnable` are reported as passing because the `node_compat` PR test executes runnable entries and fails CI if any of them fail. @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3089/4295 (71.9%) +**Primary compatibility (CI-enforced):** 3093/4295 (72.0%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3089 | 71.9% | 55.1% | 45.9% | -| 🧩 known gap | 1206 | 28.1% | 21.5% | 17.9% | +| ✅ passing (runnable) | 3093 | 72.0% | 55.1% | 46.0% | +| 🧩 known gap | 1202 | 28.0% | 21.4% | 17.9% | | 🚫 WASI-impossible (excluded) | 1153 | — | 20.6% | 17.1% | | ⚙️ engine difference (excluded) | 162 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1121 | — | — | 16.7% | | **Total** | **6731** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3089/5610 (55.1%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3093/5610 (55.1%)**. ## Inventory by Module @@ -53,7 +53,7 @@ Secondary full-public compatibility, including public tests that are currently e | http | 898 | 243 | 306 | 267 | 2 | 0 | 80 | 44.3% | 29.7% | | inspector | 95 | 1 | 0 | 93 | 0 | 0 | 1 | 100.0% | 1.1% | | internal | 53 | 1 | 0 | 0 | 0 | 0 | 52 | 100.0% | 100.0% | -| module | 184 | 107 | 57 | 7 | 1 | 0 | 12 | 65.2% | 62.2% | +| module | 184 | 111 | 53 | 7 | 1 | 0 | 12 | 67.7% | 64.5% | | net | 223 | 150 | 36 | 19 | 1 | 0 | 17 | 80.6% | 72.8% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | @@ -101,7 +101,7 @@ Secondary full-public compatibility, including public tests that are currently e | `test-require-module-defined-esmodule.js` | 2 | 2 | 0 | 0 | 0 | 0 | 0 | | `test-require-module-tla.js` | 2 | 1 | 1 | 0 | 0 | 0 | 0 | | `test-require-module-with-detection.js` | 2 | 0 | 2 | 0 | 0 | 0 | 0 | -| `test-require-module.js` | 6 | 5 | 1 | 0 | 0 | 0 | 0 | +| `test-require-module.js` | 6 | 6 | 0 | 0 | 0 | 0 | 0 | | `test-abortcontroller.js` | 19 | 19 | 0 | 0 | 0 | 0 | 0 | | `test-aborted-util.js` | 5 | 4 | 0 | 1 | 0 | 0 | 0 | | `test-abortsignal-cloneable.js` | 3 | 3 | 0 | 0 | 0 | 0 | 0 | @@ -680,7 +680,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1206) +### known gap (1202) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -761,7 +761,6 @@ Secondary full-public compatibility, including public tests that are currently e | setUncaughtExceptionCaptureCallback does not fully intercept thrown uncaught exceptions | 3 | `parallel/test-process-exception-capture-should-abort-on-uncaught-setflagsfromstring.js`, `parallel/test-process-exception-capture-should-abort-on-uncaught.js`, `parallel/test-process-exception-capture.js` | | spawn() stdio validation/pipe semantics are not Node-compatible in WASM emulation | 3 | `parallel/test-child-process-stdio.js#block_00_test_stdio_piping`, `parallel/test-child-process-stdio.js#block_02_asset_options_invariance`, `parallel/test-child-process-stdio.js#block_03_test_stdout_buffering` | | test runner edge case | 3 | `parallel/test-runner-filetest-location.js`, `parallel/test-runner-root-after-with-refed-handles.js`, `parallel/test-runner-todo-skip-tests.js` | -| CJS named export analysis for ESM/CJS interop is incomplete (missing named exports like π) | 2 | `es-module/test-require-module-twice.js`, `es-module/test-require-module.js#block_02_test_esm_that_import_cjs` | | CLI/NODE_OPTIONS max-http-header-size propagation in child process emulation is incomplete | 2 | `parallel/test-set-http-max-http-headers.js#test_01_test_01`, `parallel/test-set-http-max-http-headers.js#test_02_same_checks_using_node_options_if_it_is_supported` | | DSA keygen currently supports only modern key sizes; legacy 512-bit variant fails | 2 | `parallel/test-crypto-keygen-async-dsa-key-object.js`, `parallel/test-crypto-keygen-async-dsa.js` | | ESM loader does not correctly recover/reuse cached module state after require() ERR_REQUIRE_ASYNC_MODULE | 2 | `es-module/test-require-module-tla-retry-import-2.js`, `es-module/test-require-module-tla-retry-import.js` | @@ -802,7 +801,6 @@ Secondary full-public compatibility, including public tests that are currently e | process.permission worker-thread restrictions are incomplete | 2 | `parallel/test-permission-dc-worker-threads.js`, `parallel/test-permission-worker-threads-cli.js` | | process.report.writeReport and permission-model integration are missing | 2 | `parallel/test-permission-fs-write-report.js#block_00_block_00`, `parallel/test-permission-fs-write-report.js#block_01_block_01` | | promisified exec()/execFile() rejection errors miss stdout/stderr fields | 2 | `parallel/test-child-process-promisified.js#block_04_block_04`, `parallel/test-child-process-promisified.js#block_05_block_05` | -| requires CJS named export analysis (cjs-module-lexer) for ESM import of CJS modules | 2 | `es-module/test-require-module-dynamic-import-1.js`, `es-module/test-require-module-dynamic-import-2.js` | | spawn() timeout/killSignal behavior is not Node-compatible in WASM emulation | 2 | `parallel/test-child-process-spawn-timeout-kill-signal.js#block_00_block_00`, `parallel/test-child-process-spawn-timeout-kill-signal.js#block_01_block_01` | | tls.connect() stub throws instead of constructing a TLSSocket for allowHalfOpen option checks | 2 | `parallel/test-tls-connect-allow-half-open-option.js#block_00_block_00`, `parallel/test-tls-connect-allow-half-open-option.js#block_01_block_01` | | uncaughtExceptionMonitor event behavior in child_process flows is incomplete | 2 | `parallel/test-process-uncaught-exception-monitor.js#block_00_block_00`, `parallel/test-process-uncaught-exception-monitor.js#block_01_block_01` | @@ -864,7 +862,6 @@ Secondary full-public compatibility, including public tests that are currently e | ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG behavior is not implemented | 1 | `parallel/test-vm-dynamic-import-callback-missing-flag.js` | | ESM diagnostics for require/exports globals and package type=module .js error messaging do not match Node yet | 1 | `es-module/test-esm-undefined-cjs-global-like-variables.js` | | ESM directory import errors do not match Node ERR_UNSUPPORTED_DIR_IMPORT behavior | 1 | `parallel/test-directory-import.js` | -| ESM<->CJS export interop semantics (including __esModule/default/named export behavior and related errors) are not Node-compatible yet | 1 | `es-module/test-esm-cjs-exports.js` | | EdDSA sign/verify vector compatibility is incomplete | 1 | `parallel/test-webcrypto-sign-verify-eddsa.js` | | Error.prepareStackTrace default behavior is incomplete | 1 | `parallel/test-error-prepare-stack-trace.js` | | EventEmitter captureRejections option validation/behavior is incomplete | 1 | `parallel/test-event-capture-rejections.js` | @@ -1015,6 +1012,7 @@ Secondary full-public compatibility, including public tests that are currently e | child_process execPath emulation does not implement --completion-bash output | 1 | `parallel/test-bash-completion.js` | | child_process execPath emulation does not implement --experimental-print-required-tla diagnostics output | 1 | `es-module/test-require-module-tla.js#block_01_block_01` | | child_process execPath emulation does not yet match Node CLI argument validation/exit codes | 1 | `parallel/test-cli-bad-options.js` | +| child_process execPath emulation does not yet support this ESM/CJS fixture runner path; direct CJS named export interop is covered by test-require-module.js | 1 | `es-module/test-esm-cjs-exports.js` | | child_process execPath emulation has incomplete --require preload/argv handling | 1 | `parallel/test-preload-print-process-argv.js` | | child_process execPath emulation lacks full --import/--require preload semantics | 1 | `es-module/test-require-module-preload.js` | | child_process execPath emulation lacks full NODE_OPTIONS and CLI flag semantics | 1 | `parallel/test-cli-node-options.js` | diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index b18869ce..90a4e80d 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -27,3 +27,71 @@ async fn esm_package_map_edge_cases( assert_eq!(r, Some(Val::Bool(true))); Ok(()) } + +#[test] +async fn cjs_direct_named_exports( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-direct-named-exports", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn cjs_define_property_named_exports( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-define-property-named-exports", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn cjs_reexport_named_exports( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-reexport-named-exports", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn cjs_analyzer_false_positive_guards( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-analyzer-false-positive-guards", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} From ed04d775e3d8cc19430ae3e40533422b308a885f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Mon, 15 Jun 2026 11:20:58 +0200 Subject: [PATCH 03/42] share CJS loader for ESM CJS facades --- .../skeleton/src/builtin/module.js | 1 + crates/wasm-rquickjs/skeleton/src/internal.rs | 79 ++++++------ .../src/module-resolution.js | 121 ++++++++++++++++++ .../wit/module-resolution.wit | 1 + tests/runtime/module_resolution.rs | 17 +++ 5 files changed, 177 insertions(+), 42 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 6691e84e..41d0de20 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -554,6 +554,7 @@ function findLongestRegisteredExtension(filename) { function getPackageScopeType(filename) { let dir = pathModule.dirname(filename); while (true) { + if (pathModule.basename(dir) === 'node_modules') return 'commonjs'; const pkgPath = pathModule.join(dir, 'package.json'); const pkgContent = tryReadFile(pkgPath); if (pkgContent !== null) { diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 8378153b..7e095f38 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -1056,6 +1056,8 @@ struct PackageJson { main: Option, exports: Option, imports: Option, + #[serde(rename = "type")] + package_type: Option, } struct NodeModulesResolver; @@ -2529,6 +2531,29 @@ fn analyze_cjs_exports_for_file(filename: &str, source: &str, seen: &mut HashSet analysis } +fn package_scope_type(filename: &str) -> Option { + let mut dir = std::path::Path::new(filename).parent()?.to_path_buf(); + loop { + if dir.file_name().is_some_and(|name| name == "node_modules") { + return None; + } + let pkg_path = dir.join("package.json"); + if let Ok(pkg_content) = std::fs::read_to_string(&pkg_path) + && let Ok(package) = serde_json::from_str::(&pkg_content) + { + return package.package_type; + } + if !dir.pop() { + break; + } + } + None +} + +fn is_js_in_module_package_scope(filename: &str) -> bool { + filename.ends_with(".js") && package_scope_type(filename).as_deref() == Some("module") +} + fn cjs_named_export_source(names: &[String]) -> String { let mut out = String::new(); for (index, name) in names.iter().enumerate() { @@ -2569,17 +2594,16 @@ impl Loader for CjsCompatLoader { }; let abs_path = ensure_absolute_path(path); - let std_path = std::path::Path::new(&abs_path); let filename = Some(abs_path.clone()); - let dirname = std_path.parent().map(|p| p.to_string_lossy().into_owned()); let url = path_to_file_url(path); let escaped_filename = escape_js_string(&abs_path); - let escaped_dirname = dirname.as_deref().map(escape_js_string).unwrap_or_default(); let init = ImportMetaInit { url, filename, - dirname, + dirname: std::path::Path::new(&abs_path) + .parent() + .map(|p| p.to_string_lossy().into_owned()), include_resolve: true, }; @@ -2588,9 +2612,10 @@ impl Loader for CjsCompatLoader { // .cjs files are always CommonJS; for .js files, use the analyzer so // comments, strings, templates, and regex literals do not force CJS. let is_cjs = is_cjs_ext - || detected_analysis.is_cjs - || !detected_analysis.exports.is_empty() - || !detected_analysis.reexports.is_empty(); + || (!is_js_in_module_package_scope(&abs_path) + && (detected_analysis.is_cjs + || !detected_analysis.exports.is_empty() + || !detected_analysis.reexports.is_empty())); if !is_cjs { // Treat as ESM — inject import.meta prologue (handles shebangs) @@ -2598,52 +2623,22 @@ impl Loader for CjsCompatLoader { return Module::declare(ctx.clone(), path, injected.as_bytes().to_vec()); } - // Strip shebang before wrapping in IIFE (it would be invalid inside the wrapper) - let cjs_source = if let Some(rest) = source.strip_prefix("#!") { - if let Some(newline_pos) = rest.find('\n') { - // Replace shebang with a comment to preserve line numbers - format!( - "//{}{}", - &source[2..2 + newline_pos + 1], - &source[2 + newline_pos + 1..] - ) - } else { - String::new() - } - } else { - source.clone() - }; - - let analysis = if cjs_source == source { - detected_analysis - } else { - analyze_cjs_exports_for_file(&abs_path, &cjs_source, &mut HashSet::new()) - }; - let named_exports = cjs_named_export_source(&analysis.exports); + let named_exports = cjs_named_export_source(&detected_analysis.exports); - // Wrap CJS source in ESM-compatible wrapper, with import.meta prologue before the wrapper + // Let the existing CommonJS loader execute and cache the module. The + // facade only exposes the shared module.exports object to ESM. let prologue = inject_import_meta_prologue(&init, ""); let wrapped = format!( r#"import {{ createRequire as __wasm_rquickjs_createRequire }} from 'node:module'; {} -var module = {{ exports: {{}} }}; -var exports = module.exports; -var require = __wasm_rquickjs_createRequire("{}"); -var __filename = "{}"; -var __dirname = "{}"; -var global = globalThis; -(function(module, exports, require, __filename, __dirname) {{ -{} -}})(module, exports, require, __filename, __dirname); -var __cjs_default = module.exports; +var __wasm_rquickjs_require = __wasm_rquickjs_createRequire("{}"); +var __cjs_default = __wasm_rquickjs_require("{}"); export default __cjs_default; {} "#, prologue.trim(), escaped_filename, escaped_filename, - escaped_dirname, - cjs_source, named_exports ); diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 526b3b9a..6bdfa855 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -422,3 +422,124 @@ export const testCjsAnalyzerFalsePositiveGuards = async () => { throw error; } }; + +export const testCjsSharedLoaderIdentity = async () => { + try { + fs.mkdirSync('/cjs-shared-loader-app', { recursive: true }); + fs.writeFileSync('/cjs-shared-loader-app/shared.cjs', [ + 'globalThis.__sharedLoaderCount = (globalThis.__sharedLoaderCount || 0) + 1;', + 'exports.count = globalThis.__sharedLoaderCount;', + 'exports.marker = "shared";', + ].join('\n')); + fs.writeFileSync('/cjs-shared-loader-app/named.cjs', [ + 'globalThis.__sharedNamedCount = (globalThis.__sharedNamedCount || 0) + 1;', + 'exports.alpha = "alpha";', + 'module.exports.beta = "beta";', + 'exports.count = globalThis.__sharedNamedCount;', + ].join('\n')); + fs.writeFileSync('/cjs-shared-loader-app/esm-first.mjs', [ + 'import { createRequire } from "node:module";', + 'import shared from "./shared.cjs";', + 'const require = createRequire(import.meta.url);', + 'const required = require("./shared.cjs");', + 'required.fromRequire = "mutated";', + 'const resolved = require.resolve("./shared.cjs");', + 'export default {', + ' same: shared === required,', + ' count: globalThis.__sharedLoaderCount,', + ' sharedFromRequire: shared.fromRequire,', + ' cacheExportsSame: require.cache[resolved].exports === shared,', + '};', + ].join('\n')); + fs.writeFileSync('/cjs-shared-loader-app/cjs-first.cjs', [ + 'exports.run = async function () {', + ' const required = require("./shared.cjs");', + ' required.fromCjsFirst = "yes";', + ' const imported = await import("./shared.cjs");', + ' const resolved = require.resolve("./shared.cjs");', + ' return {', + ' same: imported.default === required,', + ' count: globalThis.__sharedLoaderCount,', + ' importedMutation: imported.default.fromCjsFirst,', + ' cacheExportsSame: require.cache[resolved].exports === imported.default,', + ' };', + '};', + ].join('\n')); + fs.writeFileSync('/cjs-shared-loader-app/named-entry.mjs', [ + 'import { createRequire } from "node:module";', + 'import namedDefault, { alpha, beta, count } from "./named.cjs";', + 'const require = createRequire(import.meta.url);', + 'const required = require("./named.cjs");', + 'export default {', + ' same: namedDefault === required,', + ' alpha, beta, count,', + ' loadCount: globalThis.__sharedNamedCount,', + '};', + ].join('\n')); + fs.mkdirSync('/cjs-shared-loader-app/type-module/node_modules/dep-without-package-json', { recursive: true }); + fs.writeFileSync('/cjs-shared-loader-app/type-module/package.json', JSON.stringify({ type: 'module' })); + fs.writeFileSync('/cjs-shared-loader-app/type-module/index.js', [ + 'import dep from "dep-without-package-json/dep.js";', + 'export default { esm: true, dep };', + ].join('\n')); + fs.writeFileSync('/cjs-shared-loader-app/type-module/node_modules/dep-without-package-json/dep.js', [ + 'globalThis.__sharedBoundaryCount = (globalThis.__sharedBoundaryCount || 0) + 1;', + 'module.exports = { cjs: true, count: globalThis.__sharedBoundaryCount };', + ].join('\n')); + fs.writeFileSync('/cjs-shared-loader-app/handled.js', 'exports.source = "source";'); + + globalThis.__sharedLoaderCount = 0; + globalThis.__sharedNamedCount = 0; + globalThis.__sharedBoundaryCount = 0; + + const esmFirst = (await import('/cjs-shared-loader-app/esm-first.mjs')).default; + assert.deepStrictEqual(esmFirst, { + same: true, + count: 1, + sharedFromRequire: 'mutated', + cacheExportsSame: true, + }); + + const cjsFirst = await (await import('/cjs-shared-loader-app/cjs-first.cjs')).default.run(); + assert.deepStrictEqual(cjsFirst, { + same: true, + count: 1, + importedMutation: 'yes', + cacheExportsSame: true, + }); + + const named = (await import('/cjs-shared-loader-app/named-entry.mjs')).default; + assert.deepStrictEqual(named, { + same: true, + alpha: 'alpha', + beta: 'beta', + count: 1, + loadCount: 1, + }); + + const { createRequire } = await import('node:module'); + const require = createRequire('/cjs-shared-loader-app/main.cjs'); + const originalJsHandler = require.extensions['.js']; + try { + require.extensions['.js'] = (module) => { + module.exports = { fromExtension: true }; + }; + const handled = (await import('/cjs-shared-loader-app/handled.js')).default; + assert.deepStrictEqual(handled, { fromExtension: true }); + assert.strictEqual(require('/cjs-shared-loader-app/handled.js'), handled); + } finally { + require.extensions['.js'] = originalJsHandler; + } + + const boundary = (await import('/cjs-shared-loader-app/type-module/index.js')).default; + assert.deepStrictEqual(boundary, { + esm: true, + dep: { cjs: true, count: 1 }, + }); + + return true; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index 35ce912d..4a755467 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -6,4 +6,5 @@ world module-resolution { export test-cjs-define-property-named-exports: func() -> bool; export test-cjs-reexport-named-exports: func() -> bool; export test-cjs-analyzer-false-positive-guards: func() -> bool; + export test-cjs-shared-loader-identity: func() -> bool; } diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index 90a4e80d..87fecdce 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -95,3 +95,20 @@ async fn cjs_analyzer_false_positive_guards( assert_eq!(r, Some(Val::Bool(true))); Ok(()) } + +#[test] +async fn cjs_shared_loader_identity( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-shared-loader-identity", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} From 370d3b330c35c20f4f7f90e22f57439ad444ae10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Mon, 15 Jun 2026 14:08:05 +0200 Subject: [PATCH 04/42] improve module syntax detection --- .../skeleton/src/builtin/module.js | 142 ++++++- crates/wasm-rquickjs/skeleton/src/internal.rs | 381 ++++++++++++++++-- .../src/module-resolution.js | 116 ++++++ .../wit/module-resolution.wit | 1 + tests/node_compat/config.jsonc | 8 +- tests/node_compat/report.md | 18 +- tests/runtime/module_resolution.rs | 17 + 7 files changed, 636 insertions(+), 47 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 41d0de20..7168b20e 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -1202,8 +1202,148 @@ function markAsSyntaxError(err) { } } +function isIdentifierContinueCode(code) { + return code === 0x5f || code === 0x24 || // _ $ + (code >= 0x30 && code <= 0x39) || + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) || + code >= 0x80; +} + +function hasIdentifierBoundary(source, start, end) { + return (start === 0 || !isIdentifierContinueCode(source.charCodeAt(start - 1))) && + (end >= source.length || !isIdentifierContinueCode(source.charCodeAt(end))); +} + +function skipQuotedOrTemplate(source, start) { + const quote = source.charCodeAt(start); + let i = start + 1; + while (i < source.length) { + const code = source.charCodeAt(i); + if (code === 0x5c) { // backslash + i += 2; + } else if (code === quote) { + return i + 1; + } else { + i++; + } + } + return i; +} + +function previousSignificantChar(source, pos) { + for (let i = pos - 1; i >= 0; i--) { + const ch = source.charCodeAt(i); + if (ch !== 0x20 && ch !== 0x09 && ch !== 0x0a && ch !== 0x0d) return ch; + } + return -1; +} + +function isRegexLiteralStartInSource(source, pos) { + const prev = previousSignificantChar(source, pos); + return prev === -1 || '({[=,:;!?&|+-*~^%>'.indexOf(String.fromCharCode(prev)) >= 0; +} + +function skipRegexLiteralInSource(source, start) { + let i = start + 1; + let inClass = false; + while (i < source.length) { + const code = source.charCodeAt(i); + if (code === 0x5c) { + i += 2; + } else if (code === 0x5b) { + inClass = true; + i++; + } else if (code === 0x5d) { + inClass = false; + i++; + } else if (code === 0x2f && !inClass) { + i++; + while (i < source.length) { + const flag = source.charCodeAt(i); + if (!((flag >= 0x41 && flag <= 0x5a) || (flag >= 0x61 && flag <= 0x7a))) break; + i++; + } + return i; + } else if (code === 0x0a || code === 0x0d) { + return start + 1; + } else { + i++; + } + } + return start + 1; +} + +function skipWhitespace(source, start) { + let i = start; + while (i < source.length) { + const code = source.charCodeAt(i); + if (code !== 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) break; + i++; + } + return i; +} + +function startsWithKeywordAt(source, keyword, pos) { + return source.startsWith(keyword, pos) && hasIdentifierBoundary(source, pos, pos + keyword.length); +} + +function isStaticExportSyntax(source, pos) { + if (previousSignificantChar(source, pos) === 0x2e) return false; // member property + const next = skipWhitespace(source, pos + 6); + if (source.charCodeAt(next) === 0x3a) return false; // object label/property + const ch = source.charCodeAt(next); + if (ch === 0x7b || ch === 0x2a) return true; // { or * + return startsWithKeywordAt(source, 'default', next) || + startsWithKeywordAt(source, 'const', next) || + startsWithKeywordAt(source, 'let', next) || + startsWithKeywordAt(source, 'var', next) || + startsWithKeywordAt(source, 'function', next) || + startsWithKeywordAt(source, 'class', next); +} + +function isStaticImportSyntax(source, pos) { + if (previousSignificantChar(source, pos) === 0x2e) return false; // member property + const next = skipWhitespace(source, pos + 6); + if (source.charCodeAt(next) === 0x28 || source.charCodeAt(next) === 0x3a) return false; // dynamic import(...) or property label + const ch = source.charCodeAt(next); + return ch === 0x27 || ch === 0x22 || ch === 0x7b || ch === 0x2a || + (ch === 0x5f || ch === 0x24 || (ch >= 0x41 && ch <= 0x5a) || (ch >= 0x61 && ch <= 0x7a) || ch >= 0x80); +} + function looksLikeEsmSource(source) { - return /(^|[\r\n])\s*(?:import\s+(?:[\s\S]*?\s+from\s+)?['"]|import\s*[\{\*]|export\s+)/.test(source); + let i = 0; + while (i < source.length) { + const code = source.charCodeAt(i); + if (code === 0x27 || code === 0x22 || code === 0x60) { // ' " ` + i = skipQuotedOrTemplate(source, i); + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { + i += 2; + while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { + i += 2; + while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; + i = Math.min(i + 2, source.length); + continue; + } + if (code === 0x2f && isRegexLiteralStartInSource(source, i)) { + i = skipRegexLiteralInSource(source, i); + continue; + } + + if (source.startsWith('export', i) && hasIdentifierBoundary(source, i, i + 6) && isStaticExportSyntax(source, i)) { + return true; + } + if (source.startsWith('import', i) && hasIdentifierBoundary(source, i, i + 6)) { + if (isStaticImportSyntax(source, i)) return true; + } + i++; + } + return false; } const wrapper = [ diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 7e095f38..8adfa469 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -334,6 +334,12 @@ impl Loader for DataUrlLoader { // - If valid, strip the `with { ... }` clause // - `assert { ... }` is left as-is (QuickJS will throw SyntaxError, as expected) let source = process_static_import_attrs(&source, path); + if let Some(error_source) = esm_preflight_error_module_source(&source, false) { + return Module::declare(ctx.clone(), path, error_source.as_bytes().to_vec()); + } + if let Some(error_source) = data_url_simple_identifier_error_module_source(&source) { + return Module::declare(ctx.clone(), path, error_source.as_bytes().to_vec()); + } let init = ImportMetaInit { url: path.to_string(), @@ -655,6 +661,286 @@ fn validate_static_import_attrs( None } +fn esm_preflight_error_module_source(source: &str, package_type_module_js: bool) -> Option { + if package_type_module_js { + let cjs_global = find_bare_cjs_global_in_esm(source); + if !analyze_cjs_exports(source).is_cjs && cjs_global.is_none() { + return None; + } + let name = cjs_global.unwrap_or("module"); + let message = format!( + "{name} is not defined in ES module scope. This file is being treated as an ES module because it has a .js file extension and package.json contains \"type\": \"module\". To treat it as a CommonJS script, rename it to use the '.cjs' file extension." + ); + let escaped = DataUrlLoader::js_string_escape(&message); + return Some(format!( + "await Promise.reject(new ReferenceError('{escaped}'));\n" + )); + } + + let Some(name) = find_bare_cjs_global_in_esm(source) else { + return None; + }; + let message = match name { + "require" => "require is not defined in ES module scope, you can use import instead", + "exports" => "exports is not defined in ES module scope", + "module" => "module is not defined in ES module scope", + "__filename" => "__filename is not defined in ES module scope", + "__dirname" => "__dirname is not defined in ES module scope", + _ => return None, + }; + let escaped = DataUrlLoader::js_string_escape(message); + Some(format!( + "await Promise.reject(new ReferenceError('{escaped}'));\n" + )) +} + +fn find_bare_cjs_global_in_esm(source: &str) -> Option<&'static str> { + const NAMES: [&str; 5] = ["require", "exports", "module", "__filename", "__dirname"]; + let bytes = source.as_bytes(); + let mut i = 0usize; + let mut declared = Vec::::new(); + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(source, i); + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { + i += 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + b'/' if is_regex_literal_start(source, i) => { + i = skip_regex_literal(source, i); + continue; + } + _ => {} + } + + if let Some((bindings, next)) = parse_import_declaration_bindings(source, i) { + for name in bindings { + if NAMES.contains(&name.as_str()) && !declared.iter().any(|existing| existing == &name) { + declared.push(name); + } + } + i = next; + continue; + } + + if let Some((name, next)) = parse_function_declaration_span(source, i) { + if NAMES.contains(&name.as_str()) && !declared.iter().any(|existing| existing == &name) { + declared.push(name); + } + i = next; + continue; + } + + if let Some((name, next)) = parse_simple_declaration_name(source, i) { + if NAMES.contains(&name.as_str()) && !declared.iter().any(|existing| existing == &name) { + declared.push(name); + } + i = next; + continue; + } + + for name in NAMES { + if source[i..].starts_with(name) + && is_ident_start_boundary(bytes, i) + && is_ident_boundary(bytes, i + name.len()) + && previous_significant_byte(source, i) != Some(b'.') + && !declared.iter().any(|declared| declared == name) + { + let next = skip_ws_comments(source, i + name.len()); + if next < bytes.len() && bytes[next] == b':' { + break; + } + return Some(name); + } + } + i = next_char_boundary(source, i); + } + None +} + +fn find_statement_end(source: &str, pos: usize) -> usize { + let bytes = source.as_bytes(); + let mut i = pos; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(source, i); + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { + i += 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + b';' | b'\n' | b'\r' => return i + 1, + _ => i = next_char_boundary(source, i), + } + } + i +} + +fn parse_import_declaration_bindings(source: &str, pos: usize) -> Option<(Vec, usize)> { + let bytes = source.as_bytes(); + if !source[pos..].starts_with("import") + || !is_ident_start_boundary(bytes, pos) + || !is_ident_boundary(bytes, pos + 6) + { + return None; + } + let mut i = skip_ws_comments(source, pos + 6); + if i < bytes.len() && (bytes[i] == b'(' || bytes[i] == b'\'' || bytes[i] == b'"') { + return Some((Vec::new(), find_statement_end(source, i))); + } + + let mut bindings = Vec::new(); + if i < bytes.len() && bytes[i] == b'*' { + i = skip_ws_comments(source, i + 1); + if source[i..].starts_with("as") && is_ident_boundary(bytes, i + 2) { + i = skip_ws_comments(source, i + 2); + let (name, _) = read_ident(source, i)?; + bindings.push(name); + } + return Some((bindings, find_statement_end(source, i))); + } + + if i < bytes.len() && bytes[i] == b'{' { + collect_named_import_bindings(source, i, &mut bindings)?; + return Some((bindings, find_statement_end(source, i))); + } + + if let Some((name, next)) = read_ident(source, i) { + bindings.push(name); + i = skip_ws_comments(source, next); + if i < bytes.len() && bytes[i] == b',' { + i = skip_ws_comments(source, i + 1); + if i < bytes.len() && bytes[i] == b'*' { + i = skip_ws_comments(source, i + 1); + if source[i..].starts_with("as") && is_ident_boundary(bytes, i + 2) { + i = skip_ws_comments(source, i + 2); + let (name, _) = read_ident(source, i)?; + bindings.push(name); + } + } else if i < bytes.len() && bytes[i] == b'{' { + collect_named_import_bindings(source, i, &mut bindings)?; + } + } + return Some((bindings, find_statement_end(source, i))); + } + + Some((bindings, find_statement_end(source, i))) +} + +fn collect_named_import_bindings(source: &str, start: usize, bindings: &mut Vec) -> Option<()> { + let bytes = source.as_bytes(); + let end = find_matching_brace(source, start)?; + let mut i = start + 1; + while i < end { + i = skip_ws_comments(source, i); + if i >= end { + break; + } + let (mut name, next) = read_ident(source, i)?; + i = skip_ws_comments(source, next); + if source[i..].starts_with("as") && is_ident_boundary(bytes, i + 2) { + i = skip_ws_comments(source, i + 2); + let (alias, next) = read_ident(source, i)?; + name = alias; + i = next; + } + bindings.push(name); + while i < end && bytes[i] != b',' { + i = next_char_boundary(source, i); + } + if i < end && bytes[i] == b',' { + i += 1; + } + } + Some(()) +} + +fn parse_function_declaration_span(source: &str, pos: usize) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + if !source[pos..].starts_with("function") + || !is_ident_start_boundary(bytes, pos) + || !is_ident_boundary(bytes, pos + 8) + { + return None; + } + let mut i = skip_ws_comments(source, pos + 8); + let (name, next) = read_ident(source, i)?; + i = skip_ws_comments(source, next); + if i < bytes.len() && bytes[i] == b'(' { + let params_end = find_matching_paren(source, i)?; + i = skip_ws_comments(source, params_end + 1); + if i < bytes.len() && bytes[i] == b'{' { + return Some((name, find_matching_brace(source, i)? + 1)); + } + } + Some((name, i)) +} + +fn parse_simple_declaration_name(source: &str, pos: usize) -> Option<(String, usize)> { + for keyword in ["const", "let", "var", "class"] { + if source[pos..].starts_with(keyword) + && is_ident_start_boundary(source.as_bytes(), pos) + && is_ident_boundary(source.as_bytes(), pos + keyword.len()) + { + let i = skip_ws_comments(source, pos + keyword.len()); + let (name, next) = read_ident(source, i)?; + return Some((name, next)); + } + } + None +} + +fn data_url_simple_identifier_error_module_source(source: &str) -> Option { + let ident = source.trim().strip_suffix(';').unwrap_or(source.trim()).trim(); + if ident.is_empty() + || ["require", "exports", "module", "__filename", "__dirname"].contains(&ident) + || !is_ascii_js_identifier(ident) + { + return None; + } + let escaped = DataUrlLoader::js_string_escape(&format!("{ident} is not defined")); + Some(format!( + "await Promise.reject(new ReferenceError('{escaped}'));\n" + )) +} + +fn is_ascii_js_identifier(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.is_empty() || !(bytes[0] == b'_' || bytes[0] == b'$' || bytes[0].is_ascii_alphabetic()) { + return false; + } + bytes[1..] + .iter() + .all(|byte| *byte == b'_' || *byte == b'$' || byte.is_ascii_alphanumeric()) +} + /// Resolver that strips `file://` URL prefixes so that `import('file:///path/to/mod.mjs')` /// resolves to the filesystem path `/path/to/mod.mjs`. struct FileUrlResolver; @@ -663,7 +949,12 @@ impl FileUrlResolver { /// Decode a `file://` URL into a filesystem path, handling percent-encoding. fn file_url_to_path(url: &str) -> Option { let encoded = url.strip_prefix("file://")?; - let bytes = encoded.as_bytes(); + let end = encoded + .find(|ch| ch == '?' || ch == '#') + .unwrap_or(encoded.len()); + let encoded_path = &encoded[..end]; + let suffix = &encoded[end..]; + let bytes = encoded_path.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { @@ -679,7 +970,9 @@ impl FileUrlResolver { decoded.push(bytes[i]); i += 1; } - String::from_utf8(decoded).ok() + let mut path = String::from_utf8(decoded).ok()?; + path.push_str(suffix); + Some(path) } fn hex_val(b: u8) -> Option { @@ -926,17 +1219,17 @@ impl Resolver for CjsEvalResolver { struct NodeFileResolver; impl NodeFileResolver { - fn resolve_candidate(candidate: std::path::PathBuf) -> Option { + fn resolve_candidate(candidate: std::path::PathBuf, suffix: &str) -> Option { let normalized = CjsEvalResolver::normalize_path(&candidate); if std::path::Path::new(&normalized).is_file() { - return Some(normalized); + return Some(format!("{normalized}{suffix}")); } if std::path::Path::new(&normalized).extension().is_none() { for ext in ["js", "mjs", "json"] { let with_ext = format!("{}.{}", normalized, ext); if std::path::Path::new(&with_ext).is_file() { - return Some(with_ext); + return Some(format!("{with_ext}{suffix}")); } } } @@ -956,14 +1249,16 @@ impl Resolver for NodeFileResolver { return Err(Error::new_resolving(base, name)); } - let candidate = if name.starts_with('/') { - std::path::PathBuf::from(name) - } else if name.starts_with("./") || name.starts_with("../") { + let (name_path, suffix) = split_module_path_suffix(name); + let candidate = if name_path.starts_with('/') { + std::path::PathBuf::from(name_path) + } else if name_path.starts_with("./") || name_path.starts_with("../") { let base_path = if let Some(path) = FileUrlResolver::file_url_to_path(base) { path } else { base.to_string() }; + let base_path = module_filesystem_path(&base_path); if base_path == "" { return Err(Error::new_resolving(base, name)); @@ -972,12 +1267,12 @@ impl Resolver for NodeFileResolver { let base_dir = std::path::Path::new(&base_path) .parent() .ok_or_else(|| Error::new_resolving(base, name))?; - base_dir.join(name) + base_dir.join(name_path) } else { return Err(Error::new_resolving(base, name)); }; - Self::resolve_candidate(candidate).ok_or_else(|| Error::new_resolving(base, name)) + Self::resolve_candidate(candidate, suffix).ok_or_else(|| Error::new_resolving(base, name)) } } @@ -2575,12 +2870,13 @@ impl Loader for CjsCompatLoader { ctx: &Ctx<'js>, path: &str, ) -> rquickjs::Result> { - let is_cjs_ext = path.ends_with(".cjs"); - if !path.ends_with(".js") && !is_cjs_ext { + let fs_path = module_filesystem_path(path); + let is_cjs_ext = fs_path.ends_with(".cjs"); + if !fs_path.ends_with(".js") && !is_cjs_ext { return Err(Error::new_loading(path)); } - let source = match std::fs::read_to_string(path) { + let source = match std::fs::read_to_string(fs_path) { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { let globals = ctx.globals(); @@ -2593,31 +2889,36 @@ impl Loader for CjsCompatLoader { Err(_) => return Err(Error::new_loading(path)), }; - let abs_path = ensure_absolute_path(path); - let filename = Some(abs_path.clone()); + let fs_abs_path = ensure_absolute_path(fs_path); + let filename = Some(fs_abs_path.clone()); let url = path_to_file_url(path); - let escaped_filename = escape_js_string(&abs_path); let init = ImportMetaInit { url, filename, - dirname: std::path::Path::new(&abs_path) + dirname: std::path::Path::new(&fs_abs_path) .parent() .map(|p| p.to_string_lossy().into_owned()), include_resolve: true, }; - let detected_analysis = analyze_cjs_exports_for_file(&abs_path, &source, &mut HashSet::new()); + let detected_analysis = analyze_cjs_exports_for_file(&fs_abs_path, &source, &mut HashSet::new()); // .cjs files are always CommonJS; for .js files, use the analyzer so // comments, strings, templates, and regex literals do not force CJS. let is_cjs = is_cjs_ext - || (!is_js_in_module_package_scope(&abs_path) + || (!is_js_in_module_package_scope(&fs_abs_path) && (detected_analysis.is_cjs || !detected_analysis.exports.is_empty() || !detected_analysis.reexports.is_empty())); if !is_cjs { + if fs_path.ends_with(".js") + && is_js_in_module_package_scope(&fs_abs_path) + && let Some(error_source) = esm_preflight_error_module_source(&source, true) + { + return Module::declare(ctx.clone(), path, error_source.as_bytes().to_vec()); + } // Treat as ESM — inject import.meta prologue (handles shebangs) let injected = inject_import_meta_prologue(&init, &source); return Module::declare(ctx.clone(), path, injected.as_bytes().to_vec()); @@ -2637,8 +2938,8 @@ export default __cjs_default; {} "#, prologue.trim(), - escaped_filename, - escaped_filename, + escape_js_string(&fs_abs_path), + escape_js_string(&fs_abs_path), named_exports ); @@ -2655,15 +2956,19 @@ struct ImportMetaInit { /// Ensure a path is absolute. If relative, prepend `/` (WASI cwd is `/`). fn ensure_absolute_path(path: &str) -> String { - if path.starts_with('/') { + let (path, suffix) = split_module_path_suffix(path); + let mut absolute = if path.starts_with('/') { path.to_string() } else { format!("/{}", path) - } + }; + absolute.push_str(suffix); + absolute } fn path_to_file_url(path: &str) -> String { let abs_path = ensure_absolute_path(path); + let (abs_path, suffix) = split_module_path_suffix(&abs_path); let mut url = String::from("file://"); for byte in abs_path.as_bytes() { match byte { @@ -2685,9 +2990,19 @@ fn path_to_file_url(path: &str) -> String { } } } + url.push_str(suffix); url } +fn split_module_path_suffix(path: &str) -> (&str, &str) { + let suffix_start = path.find(|ch| ch == '?' || ch == '#').unwrap_or(path.len()); + (&path[..suffix_start], &path[suffix_start..]) +} + +fn module_filesystem_path(path: &str) -> &str { + split_module_path_suffix(path).0 +} + fn escape_js_string(s: &str) -> String { let mut out = String::with_capacity(s.len()); for ch in s.chars() { @@ -2925,11 +3240,13 @@ impl Loader for ImportMetaLoader { ctx: &Ctx<'js>, path: &str, ) -> rquickjs::Result> { - if !path.ends_with(".mjs") { + let fs_path = module_filesystem_path(path); + let is_extensionless = std::path::Path::new(fs_path).extension().is_none(); + if !fs_path.ends_with(".mjs") && !is_extensionless { return Err(Error::new_loading(path)); } - let source = match std::fs::read_to_string(path) { + let source = match std::fs::read_to_string(fs_path) { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { let globals = ctx.globals(); @@ -2942,9 +3259,10 @@ impl Loader for ImportMetaLoader { Err(_) => return Err(Error::new_loading(path)), }; - let abs_path = ensure_absolute_path(path); - let std_path = std::path::Path::new(&abs_path); - let filename = Some(abs_path.clone()); + let fs_abs_path = ensure_absolute_path(fs_path); + let module_abs_path = ensure_absolute_path(path); + let std_path = std::path::Path::new(&fs_abs_path); + let filename = Some(fs_abs_path.clone()); let dirname = std_path.parent().map(|p| p.to_string_lossy().into_owned()); let url = path_to_file_url(path); @@ -2969,7 +3287,7 @@ impl Loader for ImportMetaLoader { let mut injected = inject_import_meta_prologue(&init, &source); if source_has_top_level_await(&source) { - let escaped_path = escape_js_string(&abs_path); + let escaped_path = escape_js_string(&module_abs_path); let escaped_url = escape_js_string(&init.url); let marker = format!( "globalThis.__wasm_rquickjs_async_esm_modules=globalThis.__wasm_rquickjs_async_esm_modules||Object.create(null);globalThis.__wasm_rquickjs_async_esm_modules[\"{}\"]=true;globalThis.__wasm_rquickjs_async_esm_modules[\"{}\"]=true;\n", @@ -3013,11 +3331,12 @@ impl Loader for JsonFileLoader { ctx: &Ctx<'js>, path: &str, ) -> rquickjs::Result> { - if !path.ends_with(".json") { + let fs_path = module_filesystem_path(path); + if !fs_path.ends_with(".json") { return Err(Error::new_loading(path)); } - let source = std::fs::read_to_string(path).map_err(|_| Error::new_loading(path))?; + let source = std::fs::read_to_string(fs_path).map_err(|_| Error::new_loading(path))?; let module_source = if DataUrlLoader::is_valid_json(&source) { let escaped = DataUrlLoader::js_string_escape(&source); format!("export default JSON.parse('{escaped}');\n") diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 6bdfa855..e67c543a 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -1,5 +1,6 @@ import assert from 'node:assert'; import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; async function expectImportError(specifier, code) { let thrown = false; @@ -14,6 +15,19 @@ async function expectImportError(specifier, code) { } } +async function expectImportRejectsMessage(specifier, pattern) { + let thrown = false; + try { + await import(specifier); + } catch (error) { + thrown = true; + assert.match(String(error && error.message), pattern, error && error.stack ? `${error.message}\n${error.stack}` : String(error)); + } + if (!thrown) { + throw new Error(`Expected import(${specifier}) to reject`); + } +} + function writeImportEntry(path, specifier) { fs.writeFileSync(path, `export default await import(${JSON.stringify(specifier)});`); } @@ -543,3 +557,105 @@ export const testCjsSharedLoaderIdentity = async () => { throw error; } }; + +export const testModuleSyntaxDetectionAndDiagnostics = async () => { + try { + fs.mkdirSync('/module-syntax-app/package-without-type', { recursive: true }); + fs.writeFileSync('/module-syntax-app/loose.js', [ + 'export default "loose-module";', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/package-without-type/package.json', JSON.stringify({ main: 'index.js' })); + fs.writeFileSync('/module-syntax-app/package-without-type/noext-esm', [ + 'export default "extensionless-module";', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/false-positive.cjs', [ + 'const a = "export default no";', + 'const b = /import { nope } from "x"/;', + '// export const commentOnly = 1;', + '/* import "comment-only"; */', + 'exports.value = "cjs";', + ].join('\n')); + fs.mkdirSync('/module-syntax-app/type-module', { recursive: true }); + fs.writeFileSync('/module-syntax-app/type-module/package.json', JSON.stringify({ type: 'module' })); + fs.writeFileSync('/module-syntax-app/type-module/cjs.js', 'module.exports = "wrong-extension";'); + fs.writeFileSync('/module-syntax-app/type-module/require.js', 'require("x");'); + fs.writeFileSync('/module-syntax-app/type-module/exports.js', 'exports = {};'); + fs.writeFileSync('/module-syntax-app/type-module/filename.js', 'console.log(__filename);'); + fs.writeFileSync('/module-syntax-app/type-module/dirname.js', 'console.log(__dirname);'); + fs.writeFileSync('/module-syntax-app/query.mjs', [ + 'globalThis.__queryModuleCount = (globalThis.__queryModuleCount || 0) + 1;', + 'export const count = globalThis.__queryModuleCount;', + 'export const url = import.meta.url;', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/relative-query-entry.mjs', [ + 'const one = await import("./query.mjs?relative-one");', + 'const two = await import("./query.mjs?relative-two");', + 'export default {', + ' one: one.count,', + ' two: two.count,', + ' oneUrl: one.url,', + ' twoUrl: two.url,', + '};', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/member-false-positive.js', [ + 'const obj = { import: 1 };', + 'obj.import;', + 'const = ;', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/property-false-positive.js', [ + '({ export: 1 });', + 'const = ;', + ].join('\n')); + + const { createRequire } = await import('node:module'); + const require = createRequire('/module-syntax-app/main.cjs'); + + assert.strictEqual(require('/module-syntax-app/loose.js').default, 'loose-module'); + assert.strictEqual(require('/module-syntax-app/package-without-type/noext-esm').default, 'extensionless-module'); + assert.deepStrictEqual(require('/module-syntax-app/false-positive.cjs'), { value: 'cjs' }); + + await expectImportRejectsMessage('/module-syntax-app/type-module/cjs.js', /use the '\.cjs' file extension/); + await expectImportRejectsMessage('/module-syntax-app/type-module/require.js', /require is not defined.*use the '\.cjs' file extension/); + await expectImportRejectsMessage('/module-syntax-app/type-module/exports.js', /exports is not defined.*use the '\.cjs' file extension/); + await expectImportRejectsMessage('/module-syntax-app/type-module/filename.js', /__filename is not defined.*use the '\.cjs' file extension/); + await expectImportRejectsMessage('/module-syntax-app/type-module/dirname.js', /__dirname is not defined.*use the '\.cjs' file extension/); + await expectImportRejectsMessage('data:text/javascript,require;', /require is not defined in ES module scope, you can use import instead$/); + await expectImportRejectsMessage('data:text/javascript,exports={};', /exports is not defined in ES module scope$/); + await expectImportRejectsMessage('data:text/javascript,require_custom;', /^(?!.*in ES module scope)(?!.*use import instead).*$/); + + const propertyKeyModule = await import('data:text/javascript,export default { require: 1 };'); + assert.deepStrictEqual(propertyKeyModule.default, { require: 1 }); + const localBindingModule = await import('data:text/javascript,const module = 1; export default module;'); + assert.strictEqual(localBindingModule.default, 1); + const importBindingModule = await import('data:text/javascript,import require from "data:text/javascript,export default 1"; export default require;'); + assert.strictEqual(importBindingModule.default, 1); + const functionParamModule = await import('data:text/javascript,function f(require) { return require; } export default f(1);'); + assert.strictEqual(functionParamModule.default, 1); + const memberNameModule = await import('data:text/javascript,export default import.meta.require;'); + assert.strictEqual(memberNameModule.default, undefined); + + globalThis.__queryModuleCount = 0; + const queryBase = pathToFileURL('/module-syntax-app/query.mjs').href; + const queryOne = await import(`${queryBase}?one`); + const queryTwo = await import(`${queryBase}?two`); + assert.strictEqual(queryOne.count, 1); + assert.strictEqual(queryTwo.count, 2); + assert.match(queryOne.url, /\?one$/); + assert.match(queryTwo.url, /\?two$/); + const relativeQuery = (await import('/module-syntax-app/relative-query-entry.mjs')).default; + assert.deepStrictEqual(relativeQuery, { + one: 3, + two: 4, + oneUrl: 'file:///module-syntax-app/query.mjs?relative-one', + twoUrl: 'file:///module-syntax-app/query.mjs?relative-two', + }); + + assert.throws(() => require('/module-syntax-app/member-false-positive.js'), /unexpected|expecting|SyntaxError/i); + assert.throws(() => require('/module-syntax-app/property-false-positive.js'), /unexpected|expecting|SyntaxError/i); + + return true; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index 4a755467..960e54c9 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -7,4 +7,5 @@ world module-resolution { export test-cjs-reexport-named-exports: func() -> bool; export test-cjs-analyzer-false-positive-guards: func() -> bool; export test-cjs-shared-loader-identity: func() -> bool; + export test-module-syntax-detection-and-diagnostics: func() -> bool; } diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 5a651e01..4efdbb17 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5858,7 +5858,7 @@ "es-module/test-esm-symlink.js": {}, "es-module/test-esm-type-field-errors-2.js": {}, "es-module/test-esm-type-field-errors.js": {}, - "es-module/test-esm-undefined-cjs-global-like-variables.js": { "category": "known-gap", "reason": "ESM diagnostics for require/exports globals and package type=module .js error messaging do not match Node yet" }, + "es-module/test-esm-undefined-cjs-global-like-variables.js": {}, "es-module/test-esm-unknown-extension.js": {}, "es-module/test-esm-url-extname.js": { "category": "node-internals", "reason": "uses --expose-internals and imports node:internal/modules/esm/get_format" }, "es-module/test-esm-windows.js": {}, @@ -5954,12 +5954,10 @@ "es-module/test-require-module-twice.js": {}, "es-module/test-require-module-warning.js": { "category": "known-gap", "reason": "child_process execPath emulation does not implement --trace-require-module warning output" }, "es-module/test-require-module-with-detection.js": { - "category": "known-gap", - "reason": "module syntax detection for extensionless/.js sources required by require(esm) is incomplete", "split": true, "subtests": { - "block_00_block_00": { "reason": "inherited: module syntax detection for extensionless/.js sources required by require(esm) is incomplete" }, - "block_01_block_01": { "reason": "inherited: module syntax detection for extensionless/.js sources required by require(esm) is incomplete" } + "block_00_block_00": {}, + "block_01_block_01": {} } }, "es-module/test-require-module.js": { diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 483555ce..a754cb31 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -1,6 +1,6 @@ # Node.js v22.14.0 Compatibility Inventory -Generated: 2026-06-12 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) +Generated: 2026-06-15 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) This report is generated from `config.jsonc` only. It does **not** run the vendored tests itself. Entries classified as `runnable` are reported as passing because the `node_compat` PR test executes runnable entries and fails CI if any of them fail. @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3093/4295 (72.0%) +**Primary compatibility (CI-enforced):** 3096/4295 (72.1%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3093 | 72.0% | 55.1% | 46.0% | -| 🧩 known gap | 1202 | 28.0% | 21.4% | 17.9% | +| ✅ passing (runnable) | 3096 | 72.1% | 55.2% | 46.0% | +| 🧩 known gap | 1199 | 27.9% | 21.4% | 17.8% | | 🚫 WASI-impossible (excluded) | 1153 | — | 20.6% | 17.1% | | ⚙️ engine difference (excluded) | 162 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1121 | — | — | 16.7% | | **Total** | **6731** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3093/5610 (55.1%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3096/5610 (55.2%)**. ## Inventory by Module @@ -53,7 +53,7 @@ Secondary full-public compatibility, including public tests that are currently e | http | 898 | 243 | 306 | 267 | 2 | 0 | 80 | 44.3% | 29.7% | | inspector | 95 | 1 | 0 | 93 | 0 | 0 | 1 | 100.0% | 1.1% | | internal | 53 | 1 | 0 | 0 | 0 | 0 | 52 | 100.0% | 100.0% | -| module | 184 | 111 | 53 | 7 | 1 | 0 | 12 | 67.7% | 64.5% | +| module | 184 | 114 | 50 | 7 | 1 | 0 | 12 | 69.5% | 66.3% | | net | 223 | 150 | 36 | 19 | 1 | 0 | 17 | 80.6% | 72.8% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | @@ -100,7 +100,7 @@ Secondary full-public compatibility, including public tests that are currently e | `test-require-module-cycle-esm-esm-cjs-esm.js` | 4 | 0 | 4 | 0 | 0 | 0 | 0 | | `test-require-module-defined-esmodule.js` | 2 | 2 | 0 | 0 | 0 | 0 | 0 | | `test-require-module-tla.js` | 2 | 1 | 1 | 0 | 0 | 0 | 0 | -| `test-require-module-with-detection.js` | 2 | 0 | 2 | 0 | 0 | 0 | 0 | +| `test-require-module-with-detection.js` | 2 | 2 | 0 | 0 | 0 | 0 | 0 | | `test-require-module.js` | 6 | 6 | 0 | 0 | 0 | 0 | 0 | | `test-abortcontroller.js` | 19 | 19 | 0 | 0 | 0 | 0 | 0 | | `test-aborted-util.js` | 5 | 4 | 0 | 1 | 0 | 0 | 0 | @@ -680,7 +680,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1202) +### known gap (1199) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -789,7 +789,6 @@ Secondary full-public compatibility, including public tests that are currently e | inherited: dgram multicast loopback API is not implemented (ENOSYS) | 2 | `parallel/test-dgram-multicast-loopback.js#block_00_block_00`, `parallel/test-dgram-multicast-loopback.js#block_01_block_01` | | inherited: dgram setBroadcast API is not implemented (ENOSYS) | 2 | `parallel/test-dgram-setBroadcast.js#block_00_block_00`, `parallel/test-dgram-setBroadcast.js#block_01_block_01` | | inherited: listen(options) argument validation/error semantics are not fully Node-compatible | 2 | `parallel/test-net-server-listen-options.js#block_01_block_01`, `parallel/test-net-server-listen-options.js#block_02_block_02` | -| inherited: module syntax detection for extensionless/.js sources required by require(esm) is incomplete | 2 | `es-module/test-require-module-with-detection.js#block_00_block_00`, `es-module/test-require-module-with-detection.js#block_01_block_01` | | inherited: process.getActiveResourcesInfo() is not implemented | 2 | `parallel/test-process-getactiveresources-track-timer-lifetime.js#block_00_block_00`, `parallel/test-process-getactiveresources-track-timer-lifetime.js#block_01_block_01` | | inherited: queueMicrotask argument validation/error codes are incomplete | 2 | `parallel/test-queue-microtask.js#block_00_block_00`, `parallel/test-queue-microtask.js#block_01_block_01` | | inherited: requires perf_hooks.PerformanceObserver with net detail | 2 | `parallel/test-net-perf_hooks.js#block_00_block_00`, `parallel/test-net-perf_hooks.js#block_01_block_01` | @@ -860,7 +859,6 @@ Secondary full-public compatibility, including public tests that are currently e | ECDH key import/deriveKey compatibility for test vectors is incomplete | 1 | `parallel/test-webcrypto-derivekey-ecdh.js` | | ECDSA key import/sign/verify compatibility for test vectors is incomplete | 1 | `parallel/test-webcrypto-sign-verify-ecdsa.js` | | ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG behavior is not implemented | 1 | `parallel/test-vm-dynamic-import-callback-missing-flag.js` | -| ESM diagnostics for require/exports globals and package type=module .js error messaging do not match Node yet | 1 | `es-module/test-esm-undefined-cjs-global-like-variables.js` | | ESM directory import errors do not match Node ERR_UNSUPPORTED_DIR_IMPORT behavior | 1 | `parallel/test-directory-import.js` | | EdDSA sign/verify vector compatibility is incomplete | 1 | `parallel/test-webcrypto-sign-verify-eddsa.js` | | Error.prepareStackTrace default behavior is incomplete | 1 | `parallel/test-error-prepare-stack-trace.js` | diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index 87fecdce..242a664a 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -112,3 +112,20 @@ async fn cjs_shared_loader_identity( assert_eq!(r, Some(Val::Bool(true))); Ok(()) } + +#[test] +async fn module_syntax_detection_and_diagnostics( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-module-syntax-detection-and-diagnostics", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} From ccef273413405b5c215abb81865ed66910ea8b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Mon, 15 Jun 2026 15:28:20 +0200 Subject: [PATCH 05/42] harden module syntax scanners --- crates/wasm-rquickjs/skeleton/src/internal.rs | 351 ++++++++++++++++-- .../src/module-resolution.js | 69 ++++ 2 files changed, 398 insertions(+), 22 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 8adfa469..912bfcf6 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -700,6 +700,11 @@ fn find_bare_cjs_global_in_esm(source: &str) -> Option<&'static str> { let mut i = 0usize; let mut declared = Vec::::new(); while i < bytes.len() { + if let Some(next) = parse_object_method_span(source, i) { + i = next; + continue; + } + match bytes[i] { b'\'' | b'"' | b'`' => { i = skip_string_or_template(source, i); @@ -737,17 +742,16 @@ fn find_bare_cjs_global_in_esm(source: &str) -> Option<&'static str> { continue; } - if let Some((name, next)) = parse_function_declaration_span(source, i) { - if NAMES.contains(&name.as_str()) && !declared.iter().any(|existing| existing == &name) { - declared.push(name); - } + if let Some(next) = parse_arrow_function_span(source, i) { i = next; continue; } - if let Some((name, next)) = parse_simple_declaration_name(source, i) { - if NAMES.contains(&name.as_str()) && !declared.iter().any(|existing| existing == &name) { - declared.push(name); + if let Some((bindings, next)) = parse_declaration_span(source, i) { + for name in bindings { + if NAMES.contains(&name.as_str()) && !declared.iter().any(|existing| existing == &name) { + declared.push(name); + } } i = next; continue; @@ -882,7 +886,84 @@ fn collect_named_import_bindings(source: &str, start: usize, bindings: &mut Vec< Some(()) } -fn parse_function_declaration_span(source: &str, pos: usize) -> Option<(String, usize)> { +fn parse_declaration_span(source: &str, pos: usize) -> Option<(Vec, usize)> { + if let Some((bindings, next)) = parse_variable_declaration_span(source, pos) { + return Some((bindings, next)); + } + if let Some((bindings, next)) = parse_function_declaration_span(source, pos) { + return Some((bindings, next)); + } + if let Some((bindings, next)) = parse_class_declaration_span(source, pos) { + return Some((bindings, next)); + } + None +} + +fn parse_variable_declaration_span(source: &str, pos: usize) -> Option<(Vec, usize)> { + for keyword in ["const", "let", "var"] { + if source[pos..].starts_with(keyword) + && is_ident_start_boundary(source.as_bytes(), pos) + && is_ident_boundary(source.as_bytes(), pos + keyword.len()) + { + let start = skip_ws_comments(source, pos + keyword.len()); + let end = find_variable_declaration_end(source, start); + return Some((collect_cjs_global_names_in_span(source, start, end), end)); + } + } + None +} + +fn find_variable_declaration_end(source: &str, pos: usize) -> usize { + let bytes = source.as_bytes(); + let mut i = pos; + let mut paren = 0usize; + let mut brace = 0usize; + let mut bracket = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(source, i); + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { + i += 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + b'/' if is_regex_literal_start(source, i) => { + i = skip_regex_literal(source, i); + continue; + } + b'(' => paren += 1, + b')' => paren = paren.saturating_sub(1), + b'{' => brace += 1, + b'}' => { + if paren == 0 && brace == 0 && bracket == 0 { + return i; + } + brace = brace.saturating_sub(1); + } + b'[' => bracket += 1, + b']' => bracket = bracket.saturating_sub(1), + b';' if paren == 0 && brace == 0 && bracket == 0 => return i + 1, + _ => {} + } + i = next_char_boundary(source, i); + } + i +} + +fn parse_function_declaration_span(source: &str, pos: usize) -> Option<(Vec, usize)> { let bytes = source.as_bytes(); if !source[pos..].starts_with("function") || !is_ident_start_boundary(bytes, pos) @@ -891,30 +972,182 @@ fn parse_function_declaration_span(source: &str, pos: usize) -> Option<(String, return None; } let mut i = skip_ws_comments(source, pos + 8); - let (name, next) = read_ident(source, i)?; - i = skip_ws_comments(source, next); + if i < bytes.len() && bytes[i] == b'*' { + i = skip_ws_comments(source, i + 1); + } + let mut bindings = Vec::new(); + if let Some((name, next)) = read_ident(source, i) { + bindings.push(name); + i = skip_ws_comments(source, next); + } if i < bytes.len() && bytes[i] == b'(' { let params_end = find_matching_paren(source, i)?; + bindings.extend(collect_cjs_global_names_in_span(source, i + 1, params_end)); i = skip_ws_comments(source, params_end + 1); if i < bytes.len() && bytes[i] == b'{' { - return Some((name, find_matching_brace(source, i)? + 1)); + return Some((bindings, find_matching_brace(source, i)? + 1)); } } - Some((name, i)) + Some((bindings, i)) } -fn parse_simple_declaration_name(source: &str, pos: usize) -> Option<(String, usize)> { - for keyword in ["const", "let", "var", "class"] { - if source[pos..].starts_with(keyword) - && is_ident_start_boundary(source.as_bytes(), pos) - && is_ident_boundary(source.as_bytes(), pos + keyword.len()) - { - let i = skip_ws_comments(source, pos + keyword.len()); - let (name, next) = read_ident(source, i)?; - return Some((name, next)); +fn parse_arrow_function_span(source: &str, pos: usize) -> Option { + let bytes = source.as_bytes(); + let mut i; + if pos < bytes.len() && bytes[pos] == b'(' { + let params_end = find_matching_paren(source, pos)?; + i = skip_ws_comments(source, params_end + 1); + } else { + let (_, next) = read_ident(source, pos)?; + i = skip_ws_comments(source, next); + } + if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' { + return None; + } + i = skip_ws_comments(source, i + 2); + if i < bytes.len() && bytes[i] == b'{' { + Some(find_matching_brace(source, i)? + 1) + } else { + Some(find_statement_end(source, i)) + } +} + +fn parse_object_method_span(source: &str, pos: usize) -> Option { + if !matches!(previous_significant_byte_before_method(source, pos), Some(b'{') | Some(b',')) { + return None; + } + let bytes = source.as_bytes(); + let mut i = pos; + if source[i..].starts_with("async") && is_ident_boundary(bytes, i + 5) { + let next = skip_ws_comments(source, i + 5); + if next < bytes.len() && bytes[next] != b':' { + i = next; } } - None + if i < bytes.len() && bytes[i] == b'*' { + i = skip_ws_comments(source, i + 1); + } + if (source[i..].starts_with("get") && is_ident_boundary(bytes, i + 3)) + || (source[i..].starts_with("set") && is_ident_boundary(bytes, i + 3)) + { + let next = skip_ws_comments(source, i + 3); + if next < bytes.len() && bytes[next] != b':' { + i = next; + } + } + if i >= bytes.len() { + return None; + } + if matches!(bytes[i], b'\'' | b'"') { + let (_, next) = read_js_string(source, i)?; + i = next; + } else if bytes[i].is_ascii_digit() { + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } else { + let (_, next) = read_ident(source, i)?; + i = next; + } + i = skip_ws_comments(source, i); + if i >= bytes.len() || bytes[i] != b'(' { + return None; + } + let params_end = find_matching_paren(source, i)?; + i = skip_ws_comments(source, params_end + 1); + if i < bytes.len() && bytes[i] == b'{' { + Some(find_matching_brace(source, i)? + 1) + } else { + None + } +} + +fn previous_significant_byte_before_method(source: &str, pos: usize) -> Option { + let bytes = source.as_bytes(); + let mut end = pos; + loop { + while end > 0 && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + if end >= 2 && bytes[end - 2] == b'*' && bytes[end - 1] == b'/' { + if let Some(start) = source[..end - 2].rfind("/*") { + end = start; + continue; + } + } + return if end == 0 { None } else { Some(bytes[end - 1]) }; + } +} + +fn parse_class_declaration_span(source: &str, pos: usize) -> Option<(Vec, usize)> { + let bytes = source.as_bytes(); + if !source[pos..].starts_with("class") + || !is_ident_start_boundary(bytes, pos) + || !is_ident_boundary(bytes, pos + 5) + { + return None; + } + let mut i = skip_ws_comments(source, pos + 5); + let mut bindings = Vec::new(); + if let Some((name, next)) = read_ident(source, i) { + bindings.push(name); + i = skip_ws_comments(source, next); + } + while i < bytes.len() && bytes[i] != b'{' { + i = next_char_boundary(source, i); + } + if i < bytes.len() && bytes[i] == b'{' { + return Some((bindings, find_matching_brace(source, i)? + 1)); + } + Some((bindings, i)) +} + +fn collect_cjs_global_names_in_span(source: &str, start: usize, end: usize) -> Vec { + const NAMES: [&str; 5] = ["require", "exports", "module", "__filename", "__dirname"]; + let bytes = source.as_bytes(); + let mut names = Vec::new(); + let mut i = start; + while i < end && i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(source, i); + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { + i += 2; + while i < end && i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < end && i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(end).min(bytes.len()); + continue; + } + b'/' if is_regex_literal_start(source, i) => { + i = skip_regex_literal(source, i); + continue; + } + _ => {} + } + + for name in NAMES { + if source[i..].starts_with(name) + && is_ident_start_boundary(bytes, i) + && is_ident_boundary(bytes, i + name.len()) + && !names.iter().any(|existing| existing == name) + { + names.push(name.to_string()); + break; + } + } + i = next_char_boundary(source, i); + } + names } fn data_url_simple_identifier_error_module_source(source: &str) -> Option { @@ -4348,6 +4581,14 @@ mod cjs_export_analyzer_tests { ); } + fn assert_cjs_global(source: &str, expected: Option<&str>) { + assert_eq!( + find_bare_cjs_global_in_esm(source), + expected, + "CJS global detection mismatch for {source}" + ); + } + #[test] fn detects_supported_cjs_export_patterns() { assert_analysis( @@ -4421,6 +4662,72 @@ mod cjs_export_analyzer_tests { ); } + #[test] + fn detects_free_cjs_globals_for_esm_diagnostics() { + assert_cjs_global("require;", Some("require")); + assert_cjs_global("require('x');", Some("require")); + assert_cjs_global("exports = {};", Some("exports")); + assert_cjs_global("module;", Some("module")); + assert_cjs_global("__filename;", Some("__filename")); + assert_cjs_global("__dirname;", Some("__dirname")); + } + + #[test] + fn ignores_bound_or_non_free_cjs_global_names() { + assert_cjs_global("export default { require: 1 };", None); + assert_cjs_global("export default import.meta.require;", None); + assert_cjs_global("const require = 1; export default require;", None); + assert_cjs_global("let exports = 1; export default exports;", None); + assert_cjs_global("var module = 1; export default module;", None); + assert_cjs_global("class __dirname {} export default __dirname;", None); + assert_cjs_global( + "import require from 'data:text/javascript,export default 1'; export default require;", + None, + ); + assert_cjs_global( + "import * as module from 'data:text/javascript,export default {}'; export default module;", + None, + ); + assert_cjs_global( + "import { value as exports } from 'data:text/javascript,export const value = 1'; export default exports;", + None, + ); + assert_cjs_global( + "function f(require) { return require; } export default f(1);", + None, + ); + assert_cjs_global("const f = (require) => require; export default f(1);", None); + assert_cjs_global("export default ((require) => require)(1);", None); + assert_cjs_global( + "const {\n module\n} = { module: 1 };\nexport default module;", + None, + ); + assert_cjs_global("const x = 0,\n require = 1;\nexport default require;", None); + assert_cjs_global( + "export default { require() { return 1; }, f(module) { return module; } }.f(2);", + None, + ); + assert_cjs_global("export default { async require() { return 1; } };", None); + assert_cjs_global("export default { *module() { yield 1; } }.module().next().value;", None); + assert_cjs_global("export default { get exports() { return 1; } }.exports;", None); + assert_cjs_global("export default { \"x\"(require) { return require; } }.x(1);", None); + assert_cjs_global("export default { /* comment */ require() { return 1; } }.require();", None); + assert_cjs_global("function* module() { yield 1; } export default module;", None); + } + + #[test] + fn package_type_diagnostics_use_first_cjs_global() { + let require_diag = esm_preflight_error_module_source("require('x');", true).unwrap(); + assert!(require_diag.contains("require is not defined")); + assert!(require_diag.contains(".cjs")); + + let filename_diag = esm_preflight_error_module_source("console.log(__filename);", true).unwrap(); + assert!(filename_diag.contains("__filename is not defined")); + assert!(filename_diag.contains(".cjs")); + + assert!(esm_preflight_error_module_source("const require = 1; export default require;", true).is_none()); + } + #[test] fn ignores_false_positive_assignments_and_define_property_descriptors() { assert_analysis( diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index e67c543a..572835b3 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -564,6 +564,33 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { fs.writeFileSync('/module-syntax-app/loose.js', [ 'export default "loose-module";', ].join('\n')); + fs.writeFileSync('/module-syntax-app/static-source.mjs', [ + 'export const named = "named";', + 'export default "source-default";', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/static-import-side-effect.js', [ + 'import "./static-source.mjs";', + 'export default "side-effect-import";', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/static-import-default.js', [ + 'import value from "./static-source.mjs";', + 'export default value;', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/static-import-named.js', [ + 'import { named } from "./static-source.mjs";', + 'export default named;', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/static-import-namespace.js', [ + 'import * as ns from "./static-source.mjs";', + 'export default ns.named;', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/static-export-list.js', [ + 'const listed = "listed";', + 'export { listed as default };', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/static-export-star.js', [ + 'export * from "./static-source.mjs";', + ].join('\n')); fs.writeFileSync('/module-syntax-app/package-without-type/package.json', JSON.stringify({ main: 'index.js' })); fs.writeFileSync('/module-syntax-app/package-without-type/noext-esm', [ 'export default "extensionless-module";', @@ -582,6 +609,10 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { fs.writeFileSync('/module-syntax-app/type-module/exports.js', 'exports = {};'); fs.writeFileSync('/module-syntax-app/type-module/filename.js', 'console.log(__filename);'); fs.writeFileSync('/module-syntax-app/type-module/dirname.js', 'console.log(__dirname);'); + fs.writeFileSync('/module-syntax-app/type-module/local-require.js', 'const require = 1; export default require;'); + fs.writeFileSync('/module-syntax-app/type-module/dep.mjs', 'export default 2;'); + fs.writeFileSync('/module-syntax-app/type-module/import-module.js', 'import module from "./dep.mjs"; export default module;'); + fs.writeFileSync('/module-syntax-app/type-module/object-exports.js', 'export default { exports: 3 };'); fs.writeFileSync('/module-syntax-app/query.mjs', [ 'globalThis.__queryModuleCount = (globalThis.__queryModuleCount || 0) + 1;', 'export const count = globalThis.__queryModuleCount;', @@ -606,11 +637,21 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { '({ export: 1 });', 'const = ;', ].join('\n')); + fs.writeFileSync('/module-syntax-app/dynamic-import-false-positive.js', [ + 'import("./static-source.mjs");', + 'const = ;', + ].join('\n')); const { createRequire } = await import('node:module'); const require = createRequire('/module-syntax-app/main.cjs'); assert.strictEqual(require('/module-syntax-app/loose.js').default, 'loose-module'); + assert.strictEqual(require('/module-syntax-app/static-import-side-effect.js').default, 'side-effect-import'); + assert.strictEqual(require('/module-syntax-app/static-import-default.js').default, 'source-default'); + assert.strictEqual(require('/module-syntax-app/static-import-named.js').default, 'named'); + assert.strictEqual(require('/module-syntax-app/static-import-namespace.js').default, 'named'); + assert.strictEqual(require('/module-syntax-app/static-export-list.js').default, 'listed'); + assert.strictEqual(require('/module-syntax-app/static-export-star.js').named, 'named'); assert.strictEqual(require('/module-syntax-app/package-without-type/noext-esm').default, 'extensionless-module'); assert.deepStrictEqual(require('/module-syntax-app/false-positive.cjs'), { value: 'cjs' }); @@ -619,6 +660,9 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { await expectImportRejectsMessage('/module-syntax-app/type-module/exports.js', /exports is not defined.*use the '\.cjs' file extension/); await expectImportRejectsMessage('/module-syntax-app/type-module/filename.js', /__filename is not defined.*use the '\.cjs' file extension/); await expectImportRejectsMessage('/module-syntax-app/type-module/dirname.js', /__dirname is not defined.*use the '\.cjs' file extension/); + assert.strictEqual((await import('/module-syntax-app/type-module/local-require.js')).default, 1); + assert.strictEqual((await import('/module-syntax-app/type-module/import-module.js')).default, 2); + assert.deepStrictEqual((await import('/module-syntax-app/type-module/object-exports.js')).default, { exports: 3 }); await expectImportRejectsMessage('data:text/javascript,require;', /require is not defined in ES module scope, you can use import instead$/); await expectImportRejectsMessage('data:text/javascript,exports={};', /exports is not defined in ES module scope$/); await expectImportRejectsMessage('data:text/javascript,require_custom;', /^(?!.*in ES module scope)(?!.*use import instead).*$/); @@ -629,8 +673,32 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { assert.strictEqual(localBindingModule.default, 1); const importBindingModule = await import('data:text/javascript,import require from "data:text/javascript,export default 1"; export default require;'); assert.strictEqual(importBindingModule.default, 1); + const namespaceImportBindingModule = await import('data:text/javascript,import * as module from "data:text/javascript,export default 1"; export default module.default;'); + assert.strictEqual(namespaceImportBindingModule.default, 1); + const namedImportBindingModule = await import('data:text/javascript,import { value as exports } from "data:text/javascript,export const value = 1"; export default exports;'); + assert.strictEqual(namedImportBindingModule.default, 1); const functionParamModule = await import('data:text/javascript,function f(require) { return require; } export default f(1);'); assert.strictEqual(functionParamModule.default, 1); + const arrowParamModule = await import('data:text/javascript,export default ((require) => require)(1);'); + assert.strictEqual(arrowParamModule.default, 1); + const methodNameModule = await import('data:text/javascript,export default { require() { return 1; }, f(module) { return module; } }.f(2);'); + assert.strictEqual(methodNameModule.default, 2); + const asyncMethodModule = await import('data:text/javascript,export default { async require() { return 1; } };'); + assert.strictEqual(await asyncMethodModule.default.require(), 1); + const generatorMethodModule = await import('data:text/javascript,export default { *module() { yield 1; } }.module().next().value;'); + assert.strictEqual(generatorMethodModule.default, 1); + const getterMethodModule = await import('data:text/javascript,export default { get exports() { return 1; } }.exports;'); + assert.strictEqual(getterMethodModule.default, 1); + const stringKeyMethodModule = await import('data:text/javascript,export default { "x"(require) { return require; } }.x(1);'); + assert.strictEqual(stringKeyMethodModule.default, 1); + const commentedMethodModule = await import('data:text/javascript,export default { /* comment */ require() { return 1; } }.require();'); + assert.strictEqual(commentedMethodModule.default, 1); + const generatorModule = await import('data:text/javascript,function* module() { yield 1; } export default module().next().value;'); + assert.strictEqual(generatorModule.default, 1); + const multiDeclarationModule = await import('data:text/javascript,const a = 0,\n require = 1;\nexport default require;'); + assert.strictEqual(multiDeclarationModule.default, 1); + const destructuringModule = await import('data:text/javascript,const {\n module\n} = { module: 1 };\nexport default module;'); + assert.strictEqual(destructuringModule.default, 1); const memberNameModule = await import('data:text/javascript,export default import.meta.require;'); assert.strictEqual(memberNameModule.default, undefined); @@ -652,6 +720,7 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { assert.throws(() => require('/module-syntax-app/member-false-positive.js'), /unexpected|expecting|SyntaxError/i); assert.throws(() => require('/module-syntax-app/property-false-positive.js'), /unexpected|expecting|SyntaxError/i); + assert.throws(() => require('/module-syntax-app/dynamic-import-false-positive.js'), /unexpected|expecting|SyntaxError/i); return true; } catch (error) { From 530105be896bdc087f5dd4bee49430ea15412379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Mon, 15 Jun 2026 18:01:01 +0200 Subject: [PATCH 06/42] resolve package reexports for CJS analysis --- crates/wasm-rquickjs/skeleton/src/internal.rs | 162 +++++++++++++++++- .../src/module-resolution.js | 119 +++++++++++++ .../wit/module-resolution.wit | 1 + tests/runtime/module_resolution.rs | 17 ++ 4 files changed, 292 insertions(+), 7 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 912bfcf6..98737c11 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -1591,6 +1591,9 @@ struct PackageJson { struct NodeModulesResolver; impl NodeModulesResolver { + const ESM_CONDITIONS: [&'static str; 4] = ["golem", "node", "import", "default"]; + const CJS_CONDITIONS: [&'static str; 5] = ["golem", "node", "require", "module-sync", "default"]; + fn try_resolve( &self, base: &str, @@ -1635,6 +1638,7 @@ impl NodeModulesResolver { &nm_dir, exports_field, subpath, + &Self::ESM_CONDITIONS, ) .map(Some); } @@ -1670,10 +1674,102 @@ impl NodeModulesResolver { Ok(None) } + fn try_resolve_for_cjs_analysis( + &self, + base: &str, + name: &str, + ) -> Result, NodePackageResolveError> { + use std::path::Path; + + if name.starts_with('#') { + return self.try_resolve_package_import_with_conditions(base, name, &Self::CJS_CONDITIONS); + } + + if name.starts_with('.') || name.starts_with('/') || name.contains("://") { + return Ok(None); + } + + let Some((package_name, subpath)) = Self::split_package_name(name) else { + return Ok(None); + }; + let Some(base_dir) = Path::new(base).parent() else { + return Ok(None); + }; + + let mut dir = base_dir.to_path_buf(); + loop { + let package_path = dir.join("node_modules").join(package_name); + if package_path.is_dir() { + let pkg_path = package_path.join("package.json"); + if let Ok(pkg_content) = std::fs::read_to_string(&pkg_path) { + let package: PackageJson = serde_json::from_str(&pkg_content).map_err(|_| { + NodePackageResolveError::InvalidPackageConfig { + path: pkg_path.to_string_lossy().into_owned(), + } + })?; + + if let Some(exports_field) = package.exports.as_ref() { + return Self::resolve_package_exports( + package_name, + &package_path, + exports_field, + subpath, + &Self::CJS_CONDITIONS, + ) + .map(Some); + } + + if subpath.is_empty() + && let Some(main) = package.main.as_ref() + && let Some(resolved) = Self::resolve_cjs_analysis_main(&package_path, main) + { + return Ok(Some(resolved)); + } + } + + if !subpath.is_empty() + && let Some(resolved) = Self::resolve_cjs_analysis_subpath(&package_path, subpath) + { + return Ok(Some(resolved)); + } + + if subpath.is_empty() + && let Some(resolved) = Self::resolve_cjs_analysis_package_root(&package_path) + { + return Ok(Some(resolved)); + } + } + + if subpath.is_empty() { + for candidate in [package_path.with_extension("js"), package_path.with_extension("json")] { + let normalized = CjsEvalResolver::normalize_path(&candidate); + if std::path::Path::new(&normalized).is_file() { + return Ok(Some(normalized)); + } + } + } + + if !dir.pop() { + break; + } + } + + Ok(None) + } + fn try_resolve_package_import( &self, base: &str, name: &str, + ) -> Result, NodePackageResolveError> { + self.try_resolve_package_import_with_conditions(base, name, &Self::ESM_CONDITIONS) + } + + fn try_resolve_package_import_with_conditions( + &self, + base: &str, + name: &str, + conditions: &[&str], ) -> Result, NodePackageResolveError> { use std::path::Path; @@ -1700,7 +1796,7 @@ impl NodeModulesResolver { specifier: name.to_string(), }); }; - return Self::resolve_package_import(&dir, imports, name).map(Some); + return Self::resolve_package_import(&dir, imports, name, conditions).map(Some); } if !dir.pop() { @@ -1756,11 +1852,50 @@ impl NodeModulesResolver { None } + fn first_existing_normalized(candidates: Vec) -> Option { + for candidate in candidates { + let normalized = CjsEvalResolver::normalize_path(&candidate); + if std::path::Path::new(&normalized).is_file() { + return Some(normalized); + } + } + + None + } + + fn resolve_cjs_analysis_main(package_dir: &std::path::Path, target: &str) -> Option { + let target_path = package_dir.join(target.strip_prefix("./").unwrap_or(target)); + Self::first_existing_normalized(vec![ + target_path.clone(), + target_path.with_extension("js"), + target_path.with_extension("json"), + target_path.join("index.js"), + target_path.join("index.json"), + ]) + } + + fn resolve_cjs_analysis_subpath(package_dir: &std::path::Path, target: &str) -> Option { + let target_path = package_dir.join(target.strip_prefix("./").unwrap_or(target)); + Self::first_existing_normalized(vec![ + target_path.clone(), + target_path.with_extension("js"), + target_path.with_extension("mjs"), + target_path.with_extension("json"), + target_path.join("index.js"), + target_path.join("index.json"), + ]) + } + + fn resolve_cjs_analysis_package_root(package_dir: &std::path::Path) -> Option { + Self::first_existing_normalized(vec![package_dir.join("index.js"), package_dir.join("index.json")]) + } + fn resolve_package_exports( package_name: &str, package_dir: &std::path::Path, exports: &PackageTarget, subpath: &str, + conditions: &[&str], ) -> Result { let key = if subpath.is_empty() { ".".to_string() @@ -1782,6 +1917,7 @@ impl NodeModulesResolver { exports, false, "exports", + conditions, ) .and_then(|resolution| { Self::target_resolution_to_export_result(resolution, package_name, subpath) @@ -1795,6 +1931,7 @@ impl NodeModulesResolver { target, false, "exports", + conditions, ) .and_then(|resolution| { Self::target_resolution_to_export_result(resolution, package_name, subpath) @@ -1812,11 +1949,18 @@ impl NodeModulesResolver { package_dir: &std::path::Path, imports: &PackageTarget, specifier: &str, + conditions: &[&str], ) -> Result { if let PackageTarget::Object(map) = imports && let Some(target) = map.get(specifier) { - return Self::resolve_package_target_value(package_dir, target, true, "imports").and_then( + return Self::resolve_package_target_value( + package_dir, + target, + true, + "imports", + conditions, + ).and_then( |resolution| Self::target_resolution_to_import_result(resolution, specifier), ); } @@ -1837,6 +1981,7 @@ impl NodeModulesResolver { target: &PackageTarget, allow_bare_target: bool, kind: &'static str, + conditions: &[&str], ) -> Result { match target { PackageTarget::Null | PackageTarget::Bool(false) => { @@ -1892,7 +2037,7 @@ impl NodeModulesResolver { } PackageTarget::Array(array) => { for item in array { - match Self::resolve_package_target_value(package_dir, item, allow_bare_target, kind) { + match Self::resolve_package_target_value(package_dir, item, allow_bare_target, kind, conditions) { Ok(PackageTargetResolution::Resolved(path)) => { return Ok(PackageTargetResolution::Resolved(path)); } @@ -1908,12 +2053,13 @@ impl NodeModulesResolver { } PackageTarget::Object(map) => { for (condition, value) in map { - if matches!(condition.as_str(), "golem" | "node" | "import" | "default") { + if conditions.contains(&condition.as_str()) { match Self::resolve_package_target_value( package_dir, value, allow_bare_target, kind, + conditions, )? { PackageTargetResolution::NoMatch => continue, resolution => return Ok(resolution), @@ -3017,7 +3163,8 @@ fn analyze_cjs_exports(source: &str) -> CjsExportAnalysis { fn resolve_cjs_reexport_path(filename: &str, specifier: &str) -> Option { if !specifier.starts_with("./") && !specifier.starts_with("../") && !specifier.starts_with('/') { - return None; + let resolver = NodeModulesResolver; + return resolver.try_resolve_for_cjs_analysis(filename, specifier).ok().flatten(); } let base = if specifier.starts_with('/') { std::path::PathBuf::from(specifier) @@ -3032,8 +3179,9 @@ fn resolve_cjs_reexport_path(filename: &str, specifier: &str) -> Option base.join("index.cjs"), ]; for candidate in candidates { - if candidate.is_file() { - return Some(candidate.to_string_lossy().into_owned()); + let normalized = CjsEvalResolver::normalize_path(&candidate); + if std::path::Path::new(&normalized).is_file() { + return Some(normalized); } } None diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 572835b3..5930ddc0 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -728,3 +728,122 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { throw error; } }; + +export const testCjsPackageReexportNamedExports = async () => { + try { + fs.mkdirSync('/cjs-package-reexport-app/node_modules/pkg', { recursive: true }); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/pkg/index.js', [ + 'exports.alpha = "alpha";', + 'exports.beta = "beta";', + ].join('\n')); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/pkg/subpath.js', [ + 'exports.sub = "sub";', + ].join('\n')); + fs.writeFileSync('/cjs-package-reexport-app/reexport-package.cjs', 'module.exports = require("pkg");'); + fs.writeFileSync('/cjs-package-reexport-app/reexport-subpath.cjs', 'module.exports = require("pkg/subpath");'); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/file-pkg.js', 'exports.file = "file";'); + fs.writeFileSync('/cjs-package-reexport-app/reexport-file-package.cjs', 'module.exports = require("file-pkg");'); + + fs.mkdirSync('/cjs-package-reexport-app/node_modules/exported-pkg', { recursive: true }); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/exported-pkg/package.json', JSON.stringify({ + exports: { + '.': './main.cjs', + './feature': './feature.cjs', + './condition': { + import: './import.mjs', + 'module-sync': './sync.cjs', + require: './require.cjs', + default: './default.cjs', + }, + }, + })); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/exported-pkg/main.cjs', 'exports.main = "main";'); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/exported-pkg/feature.cjs', 'exports.feature = "feature";'); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/exported-pkg/sync.cjs', 'exports.condition = "module-sync";'); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/exported-pkg/require.cjs', 'exports.condition = "require";'); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/exported-pkg/default.cjs', 'exports.condition = "default";'); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/exported-pkg/import.mjs', 'export const condition = "import";'); + fs.writeFileSync('/cjs-package-reexport-app/reexport-exported-root.cjs', 'module.exports = require("exported-pkg");'); + fs.writeFileSync('/cjs-package-reexport-app/reexport-exported-feature.cjs', 'module.exports = require("exported-pkg/feature");'); + fs.writeFileSync('/cjs-package-reexport-app/reexport-exported-condition.cjs', 'module.exports = require("exported-pkg/condition");'); + + fs.writeFileSync('/cjs-package-reexport-app/package.json', JSON.stringify({ + imports: { + '#dep': './imports-target.cjs', + }, + })); + fs.writeFileSync('/cjs-package-reexport-app/imports-target.cjs', 'exports.imported = "imported";'); + fs.writeFileSync('/cjs-package-reexport-app/reexport-imports.cjs', 'module.exports = require("#dep");'); + + fs.mkdirSync('/cjs-package-reexport-app/node_modules/transitive-pkg', { recursive: true }); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/transitive-pkg/index.js', [ + 'exports.gamma = "gamma";', + 'exports.delta = "delta";', + ].join('\n')); + fs.writeFileSync('/cjs-package-reexport-app/reexport-transpiler.cjs', [ + 'var dep = require("transitive-pkg");', + 'Object.keys(dep).forEach(function (key) {', + ' Object.defineProperty(exports, key, {', + ' enumerable: true,', + ' get: function () { return dep[key]; }', + ' });', + '});', + ].join('\n')); + + fs.mkdirSync('/cjs-package-reexport-app/node_modules/cycle-pkg', { recursive: true }); + fs.writeFileSync('/cjs-package-reexport-app/cycle-a.cjs', [ + 'module.exports = require("cycle-pkg");', + 'exports.a = "a";', + ].join('\n')); + fs.writeFileSync('/cjs-package-reexport-app/node_modules/cycle-pkg/index.js', [ + 'module.exports = require("../../cycle-a.cjs");', + 'exports.b = "b";', + ].join('\n')); + + fs.writeFileSync('/cjs-package-reexport-app/reexport-continuation.cjs', [ + 'var ignored = require("pkg").nested;', + 'exports.own = "own";', + ].join('\n')); + + fs.writeFileSync('/cjs-package-reexport-app/package-entry.mjs', [ + 'import packageDefault, { alpha, beta } from "./reexport-package.cjs";', + 'import { sub } from "./reexport-subpath.cjs";', + 'import { file } from "./reexport-file-package.cjs";', + 'import { main } from "./reexport-exported-root.cjs";', + 'import { feature } from "./reexport-exported-feature.cjs";', + 'import { condition } from "./reexport-exported-condition.cjs";', + 'import { imported } from "./reexport-imports.cjs";', + 'import { gamma, delta } from "./reexport-transpiler.cjs";', + 'import * as continuation from "./reexport-continuation.cjs";', + 'import * as cycle from "./cycle-a.cjs";', + 'export default {', + ' alpha, beta, defaultAlpha: packageDefault.alpha, sub, file, main, feature, condition, imported, gamma, delta,', + ' continuationKeys: Object.keys(continuation).filter((key) => key !== "default" && key !== "own"),', + ' continuationOwn: continuation.own,', + ' cycleKeys: Object.keys(cycle).filter((key) => key !== "default").sort(),', + '};', + ].join('\n')); + + const result = (await import('/cjs-package-reexport-app/package-entry.mjs')).default; + assert.deepStrictEqual(result, { + alpha: 'alpha', + beta: 'beta', + defaultAlpha: 'alpha', + sub: 'sub', + file: 'file', + main: 'main', + feature: 'feature', + condition: 'module-sync', + imported: 'imported', + gamma: 'gamma', + delta: 'delta', + continuationKeys: [], + continuationOwn: 'own', + cycleKeys: ['a', 'b'], + }); + return true; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index 960e54c9..6f91925c 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -8,4 +8,5 @@ world module-resolution { export test-cjs-analyzer-false-positive-guards: func() -> bool; export test-cjs-shared-loader-identity: func() -> bool; export test-module-syntax-detection-and-diagnostics: func() -> bool; + export test-cjs-package-reexport-named-exports: func() -> bool; } diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index 242a664a..2ea0dd48 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -129,3 +129,20 @@ async fn module_syntax_detection_and_diagnostics( assert_eq!(r, Some(Val::Bool(true))); Ok(()) } + +#[test] +async fn cjs_package_reexport_named_exports( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-package-reexport-named-exports", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} From 450242cf634f6494f3e7b6d13a3aa4e299778538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Mon, 15 Jun 2026 19:09:48 +0200 Subject: [PATCH 07/42] improve require esm error handlin --- .../skeleton/src/builtin/module.js | 23 ++- .../wasm-rquickjs/skeleton/src/builtin/vm.rs | 148 +++++++++++++----- .../src/module-resolution.js | 99 ++++++++++++ .../wit/module-resolution.wit | 3 + tests/node_compat/config.jsonc | 10 +- tests/node_compat/report.md | 16 +- tests/runtime/module_resolution.rs | 51 ++++++ 7 files changed, 292 insertions(+), 58 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 7168b20e..913f5185 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -1373,10 +1373,29 @@ function compileCjs(filename, source) { return _evalWithFilename(wrappedSource, filename); } +function requireEsmWithCacheGuard(mod, resolvedFilename) { + Object.defineProperty(mod, '__wasmRequireEsmInProgress', { + value: true, + writable: true, + configurable: true, + enumerable: false, + }); + try { + return wrapEsmNamespace(_requireEsm(resolvedFilename)); + } finally { + delete mod.__wasmRequireEsmInProgress; + } +} + function loadModule(resolvedFilename, source, parentModule) { // Check cache if (moduleCache[resolvedFilename]) { const cached = moduleCache[resolvedFilename]; + if (cached.__wasmRequireEsmInProgress) { + const err = new Error('Cannot require() ES Module ' + resolvedFilename + ' in a cycle.'); + err.code = 'ERR_REQUIRE_CYCLE_MODULE'; + throw err; + } if (parentModule && parentModule.children && !parentModule.children.includes(cached)) { parentModule.children.push(cached); } @@ -1454,7 +1473,7 @@ function loadModule(resolvedFilename, source, parentModule) { } if (isEsm) { try { - mod.exports = wrapEsmNamespace(_requireEsm(resolvedFilename)); + mod.exports = requireEsmWithCacheGuard(mod, resolvedFilename); } catch (err) { delete moduleCache[resolvedFilename]; throw err; @@ -1485,7 +1504,7 @@ function loadModule(resolvedFilename, source, parentModule) { if (cjsSyntaxError) { // SyntaxError in a .js file — try loading as ESM (entry point detection) try { - mod.exports = wrapEsmNamespace(_requireEsm(resolvedFilename)); + mod.exports = requireEsmWithCacheGuard(mod, resolvedFilename); } catch (esmErr) { delete moduleCache[resolvedFilename]; if (looksLikeEsmSource(source)) { diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/vm.rs b/crates/wasm-rquickjs/skeleton/src/builtin/vm.rs index 497a6c2f..563458cf 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/vm.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/vm.rs @@ -1,5 +1,6 @@ use rquickjs::qjs; -use rquickjs::{CaughtError, Persistent, Value}; +use rquickjs::promise::PromiseState; +use rquickjs::{CaughtError, FromJs, Persistent, Promise, Value}; use std::ptr::NonNull; #[rquickjs::module(rename = "camelCase")] @@ -168,57 +169,49 @@ fn require_esm_impl<'js>( return throw_require_async_module(ctx, &globals, filename); } - unsafe { - let val = qjs::JS_Eval( + enter_require_esm(&ctx, &globals, filename, &file_url)?; + + let eval_result = unsafe { + qjs::JS_Eval( ctx.as_raw().as_ptr(), src.as_ptr(), code.len() as _, fname.as_ptr(), qjs::JS_EVAL_TYPE_MODULE as i32, - ); - if qjs::JS_IsException(val) { - return Err(rquickjs::Error::Exception); - } + ) + }; - // If the module evaluation returned a Promise (TLA), attach a no-op - // .catch() handler so any rejection is marked as handled and doesn't - // trigger an unhandledRejection event. We'll report TLA as - // ERR_REQUIRE_ASYNC_MODULE below instead. - let tag = qjs::JS_VALUE_GET_TAG(val); - if tag == qjs::JS_TAG_OBJECT { - let catch_str = CString::new("catch").unwrap(); - let catch_fn = qjs::JS_GetPropertyStr(ctx.as_raw().as_ptr(), val, catch_str.as_ptr()); - if !qjs::JS_IsUndefined(catch_fn) && !qjs::JS_IsException(catch_fn) { - // Create a no-op function: function() {} - let noop_code = CString::new("(function(){})").unwrap(); - let noop_fname = CString::new("").unwrap(); - let noop_fn = qjs::JS_Eval( - ctx.as_raw().as_ptr(), - noop_code.as_ptr(), - 14, - noop_fname.as_ptr(), - qjs::JS_EVAL_TYPE_GLOBAL as i32, - ); - if !qjs::JS_IsException(noop_fn) { - // Call promise.catch(noop) - let result = qjs::JS_Call( - ctx.as_raw().as_ptr(), - catch_fn, - val, - 1, - &noop_fn as *const _ as *mut _, - ); - if !qjs::JS_IsException(result) { - qjs::JS_FreeValue(ctx.as_raw().as_ptr(), result); - } - qjs::JS_FreeValue(ctx.as_raw().as_ptr(), noop_fn); + if unsafe { qjs::JS_IsException(eval_result) } { + leave_require_esm(&globals, filename, &file_url)?; + return Err(rquickjs::Error::Exception); + } + + let pending_tla = unsafe { + let eval_value = Value::from_raw(ctx.clone(), eval_result); + if let Ok(promise) = Promise::from_js(&ctx, eval_value.clone()) { + match promise.state() { + PromiseState::Pending => { + attach_noop_promise_catch(ctx.clone(), eval_value.as_raw()); + true + } + PromiseState::Rejected => { + let _ = promise.result::>(); + let rejected = ctx.catch(); + leave_require_esm(&globals, filename, &file_url)?; + return Err(ctx.throw(rejected)); } - qjs::JS_FreeValue(ctx.as_raw().as_ptr(), catch_fn); + PromiseState::Resolved => false, } + } else { + false } + }; - // Free the return value (Promise from module evaluation) - qjs::JS_FreeValue(ctx.as_raw().as_ptr(), val); + leave_require_esm(&globals, filename, &file_url)?; + + if pending_tla { + mark_async_esm_module(&ctx, &globals, filename, &file_url)?; + return throw_require_async_module(ctx, &globals, filename); } // Read the namespace from globalThis and clean up @@ -238,6 +231,77 @@ fn require_esm_impl<'js>( } } +fn attach_noop_promise_catch(ctx: rquickjs::Ctx<'_>, val: qjs::JSValue) { + unsafe { + let catch_fn = qjs::JS_GetPropertyStr(ctx.as_raw().as_ptr(), val, c"catch".as_ptr()); + if !qjs::JS_IsUndefined(catch_fn) && !qjs::JS_IsException(catch_fn) { + let noop_fn = qjs::JS_Eval( + ctx.as_raw().as_ptr(), + c"(function(){})".as_ptr(), + 14, + c"".as_ptr(), + qjs::JS_EVAL_TYPE_GLOBAL as i32, + ); + if !qjs::JS_IsException(noop_fn) { + let result = qjs::JS_Call( + ctx.as_raw().as_ptr(), + catch_fn, + val, + 1, + &noop_fn as *const _ as *mut _, + ); + if !qjs::JS_IsException(result) { + qjs::JS_FreeValue(ctx.as_raw().as_ptr(), result); + } + qjs::JS_FreeValue(ctx.as_raw().as_ptr(), noop_fn); + } + qjs::JS_FreeValue(ctx.as_raw().as_ptr(), catch_fn); + } + } +} + +fn enter_require_esm<'js>( + ctx: &rquickjs::Ctx<'js>, + globals: &rquickjs::Object<'js>, + filename: &str, + file_url: &str, +) -> rquickjs::Result<()> { + let registry = match globals.get::<_, rquickjs::Value>("__wasm_rquickjs_require_esm_in_progress") { + Ok(value) if value.is_object() => value.into_object().unwrap(), + _ => { + let object = rquickjs::Object::new(ctx.clone())?; + globals.set("__wasm_rquickjs_require_esm_in_progress", object.clone())?; + object + } + }; + + if registry.get::<_, bool>(filename).unwrap_or(false) + || registry.get::<_, bool>(file_url).unwrap_or(false) + { + let error_ctor: rquickjs::Function = globals.get("Error")?; + let msg = format!("Cannot require() ES Module {filename} in a cycle."); + let error_obj: rquickjs::Object = error_ctor.call((&msg,))?; + error_obj.set("code", "ERR_REQUIRE_CYCLE_MODULE")?; + return Err(ctx.throw(error_obj.into_value())); + } + + registry.set(filename, true)?; + registry.set(file_url, true)?; + Ok(()) +} + +fn leave_require_esm<'js>( + globals: &rquickjs::Object<'js>, + filename: &str, + file_url: &str, +) -> rquickjs::Result<()> { + if let Ok(registry) = globals.get::<_, rquickjs::Object>("__wasm_rquickjs_require_esm_in_progress") { + let _ = registry.remove(filename); + let _ = registry.remove(file_url); + } + Ok(()) +} + fn cached_async_esm_module<'js>(globals: &rquickjs::Object<'js>, filename: &str, file_url: &str) -> bool { let Ok(registry) = globals.get::<_, rquickjs::Object>("__wasm_rquickjs_async_esm_modules") else { return false; diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 5930ddc0..1e789e25 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -847,3 +847,102 @@ export const testCjsPackageReexportNamedExports = async () => { throw error; } }; + +export const testRequireEsmErrorHandling = async () => { + try { + fs.mkdirSync('/require-esm-errors-app', { recursive: true }); + fs.writeFileSync('/require-esm-errors-app/runtime-error.mjs', [ + 'throw new Error("hello");', + ].join('\n')); + fs.writeFileSync('/require-esm-errors-app/reference-error.mjs', [ + 'Object.defineProperty(exports, "__esModule", { value: true });', + ].join('\n')); + + const { createRequire } = await import('node:module'); + const require = createRequire('/require-esm-errors-app/main.cjs'); + + assert.throws(() => require('/require-esm-errors-app/runtime-error.mjs'), { + message: 'hello', + }); + assert.throws(() => require('/require-esm-errors-app/reference-error.mjs'), { + name: 'ReferenceError', + }); + + return true; + } catch (error) { + console.error(error); + throw error; + } +}; + +export const testRequireEsmTlaRetry = async () => { + try { + fs.mkdirSync('/require-esm-tla-app', { recursive: true }); + fs.writeFileSync('/require-esm-tla-app/tla-success.mjs', [ + 'await Promise.resolve();', + 'export const hello = "world";', + ].join('\n')); + + const { createRequire } = await import('node:module'); + const require = createRequire('/require-esm-tla-app/main.cjs'); + + assert.throws(() => require('/require-esm-tla-app/tla-success.mjs'), { + code: 'ERR_REQUIRE_ASYNC_MODULE', + }); + + const first = await import('/require-esm-tla-app/tla-success.mjs'); + const second = await import('/require-esm-tla-app/tla-success.mjs'); + assert.strictEqual(first.hello, 'world'); + assert.strictEqual(second.hello, 'world'); + assert.strictEqual(first, second); + + return true; + } catch (error) { + console.error(error); + throw error; + } +}; + +export const testRequireEsmCycleGuards = async () => { + try { + fs.mkdirSync('/require-esm-cycle-app', { recursive: true }); + fs.writeFileSync('/require-esm-cycle-app/a.mjs', [ + 'import { createRequire } from "node:module";', + 'const require = createRequire(import.meta.url);', + 'let cycleCode;', + 'try {', + ' require("./a.mjs");', + '} catch (error) {', + ' cycleCode = error && error.code;', + '}', + 'export const value = 1;', + 'export { cycleCode };', + ].join('\n')); + fs.writeFileSync('/require-esm-cycle-app/syntax-detected.js', [ + 'import { createRequire } from "node:module";', + 'const require = createRequire(import.meta.url);', + 'let cycleCode;', + 'try {', + ' require("./syntax-detected.js");', + '} catch (error) {', + ' cycleCode = error && error.code;', + '}', + 'export const value = 2;', + 'export { cycleCode };', + ].join('\n')); + + const { createRequire } = await import('node:module'); + const require = createRequire('/require-esm-cycle-app/main.cjs'); + + const ns = require('/require-esm-cycle-app/a.mjs'); + assert.strictEqual(ns.value, 1); + assert.strictEqual(ns.cycleCode, 'ERR_REQUIRE_CYCLE_MODULE'); + const detected = require('/require-esm-cycle-app/syntax-detected.js'); + assert.strictEqual(detected.value, 2); + assert.strictEqual(detected.cycleCode, 'ERR_REQUIRE_CYCLE_MODULE'); + return true; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index 6f91925c..cf45b4e7 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -9,4 +9,7 @@ world module-resolution { export test-cjs-shared-loader-identity: func() -> bool; export test-module-syntax-detection-and-diagnostics: func() -> bool; export test-cjs-package-reexport-named-exports: func() -> bool; + export test-require-esm-error-handling: func() -> bool; + export test-require-esm-tla-retry: func() -> bool; + export test-require-esm-cycle-guards: func() -> bool; } diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 4efdbb17..28a6cb6f 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5932,16 +5932,16 @@ "es-module/test-require-module-dynamic-import-2.js": {}, "es-module/test-require-module-dynamic-import-3.js": {}, "es-module/test-require-module-dynamic-import-4.js": {}, - "es-module/test-require-module-error-catching.js": { "category": "known-gap", "reason": "QuickJS require(esm) bridge reports async-module semantics before surfacing synchronous ESM evaluation errors" }, + "es-module/test-require-module-error-catching.js": { "category": "known-gap", "reason": "one .js fixture is still accepted by the CommonJS wrapper instead of being detected as ESM and throwing the expected ReferenceError" }, "es-module/test-require-module-errors.js": { "category": "engine-difference", "reason": "asserts V8-specific syntax error stderr text/format that differs in QuickJS" }, "es-module/test-require-module-feature-detect.js": {}, "es-module/test-require-module-implicit.js": {}, "es-module/test-require-module-preload.js": { "category": "known-gap", "reason": "child_process execPath emulation lacks full --import/--require preload semantics" }, - "es-module/test-require-module-retry-import-errored.js": { "category": "known-gap", "reason": "ESM loader does not correctly retry/resume top-level-await module evaluation after require() throws ERR_REQUIRE_ASYNC_MODULE" }, - "es-module/test-require-module-retry-import-evaluating.js": { "category": "known-gap", "reason": "ESM loader does not correctly retry/resume top-level-await module evaluation after require() throws ERR_REQUIRE_ASYNC_MODULE" }, + "es-module/test-require-module-retry-import-errored.js": {}, + "es-module/test-require-module-retry-import-evaluating.js": {}, "es-module/test-require-module-synchronous-rejection-handling.js": { "category": "known-gap", "reason": "require(esm) rejection handling does not match Node behavior (unexpected unhandledRejection)" }, - "es-module/test-require-module-tla-retry-import-2.js": { "category": "known-gap", "reason": "ESM loader does not correctly recover/reuse cached module state after require() ERR_REQUIRE_ASYNC_MODULE" }, - "es-module/test-require-module-tla-retry-import.js": { "category": "known-gap", "reason": "ESM loader does not correctly recover/reuse cached module state after require() ERR_REQUIRE_ASYNC_MODULE" }, + "es-module/test-require-module-tla-retry-import-2.js": {}, + "es-module/test-require-module-tla-retry-import.js": {}, "es-module/test-require-module-tla-retry-require.js": {}, "es-module/test-require-module-tla.js": { "split": true, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index a754cb31..c29b8c74 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3096/4295 (72.1%) +**Primary compatibility (CI-enforced):** 3100/4295 (72.2%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3096 | 72.1% | 55.2% | 46.0% | -| 🧩 known gap | 1199 | 27.9% | 21.4% | 17.8% | +| ✅ passing (runnable) | 3100 | 72.2% | 55.3% | 46.1% | +| 🧩 known gap | 1195 | 27.8% | 21.3% | 17.8% | | 🚫 WASI-impossible (excluded) | 1153 | — | 20.6% | 17.1% | | ⚙️ engine difference (excluded) | 162 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1121 | — | — | 16.7% | | **Total** | **6731** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3096/5610 (55.2%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3100/5610 (55.3%)**. ## Inventory by Module @@ -53,7 +53,7 @@ Secondary full-public compatibility, including public tests that are currently e | http | 898 | 243 | 306 | 267 | 2 | 0 | 80 | 44.3% | 29.7% | | inspector | 95 | 1 | 0 | 93 | 0 | 0 | 1 | 100.0% | 1.1% | | internal | 53 | 1 | 0 | 0 | 0 | 0 | 52 | 100.0% | 100.0% | -| module | 184 | 114 | 50 | 7 | 1 | 0 | 12 | 69.5% | 66.3% | +| module | 184 | 118 | 46 | 7 | 1 | 0 | 12 | 72.0% | 68.6% | | net | 223 | 150 | 36 | 19 | 1 | 0 | 17 | 80.6% | 72.8% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | @@ -680,7 +680,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1199) +### known gap (1195) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -763,8 +763,6 @@ Secondary full-public compatibility, including public tests that are currently e | test runner edge case | 3 | `parallel/test-runner-filetest-location.js`, `parallel/test-runner-root-after-with-refed-handles.js`, `parallel/test-runner-todo-skip-tests.js` | | CLI/NODE_OPTIONS max-http-header-size propagation in child process emulation is incomplete | 2 | `parallel/test-set-http-max-http-headers.js#test_01_test_01`, `parallel/test-set-http-max-http-headers.js#test_02_same_checks_using_node_options_if_it_is_supported` | | DSA keygen currently supports only modern key sizes; legacy 512-bit variant fails | 2 | `parallel/test-crypto-keygen-async-dsa-key-object.js`, `parallel/test-crypto-keygen-async-dsa.js` | -| ESM loader does not correctly recover/reuse cached module state after require() ERR_REQUIRE_ASYNC_MODULE | 2 | `es-module/test-require-module-tla-retry-import-2.js`, `es-module/test-require-module-tla-retry-import.js` | -| ESM loader does not correctly retry/resume top-level-await module evaluation after require() throws ERR_REQUIRE_ASYNC_MODULE | 2 | `es-module/test-require-module-retry-import-errored.js`, `es-module/test-require-module-retry-import-evaluating.js` | | HTTP keep-alive socket identity reuse across sequential requests is not implemented | 2 | `parallel/test-http-keepalive-client.js`, `parallel/test-http-keepalive-request.js` | | IncomingMessage 'aborted' event is not emitted when the server destroys a keep-alive response | 2 | `parallel/test-http-client-aborted-event.js#block_00_block_00`, `parallel/test-http-client-aborted-event.js#block_01_block_01` | | TextDecoderStream invalid-encoding errors are not Node-compatible yet | 2 | `parallel/test-whatwg-webstreams-encoding.js#block_00_block_00`, `parallel/test-whatwg-webstreams-encoding.js#block_01_block_01` | @@ -922,7 +920,6 @@ Secondary full-public compatibility, including public tests that are currently e | OutgoingMessage implicit Content-Length/Transfer-Encoding and Connection header behavior is not Node-compatible | 1 | `parallel/test-http-content-length.js` | | OutgoingMessage.getHeaders() shape is not Node-compatible (null-prototype object expected) | 1 | `parallel/test-http-mutable-headers.js` | | Overridden globalAgent socket bookkeeping (agent.sockets/close lifecycle) is not Node-compatible | 1 | `parallel/test-http-client-override-global-agent.js` | -| QuickJS require(esm) bridge reports async-module semantics before surfacing synchronous ESM evaluation errors | 1 | `es-module/test-require-module-error-catching.js` | | QuickJS stack frame formatting differs for Error objects whose name is a non-string object | 1 | `parallel/test-util-inspect.js#block_97_block_97` | | RSA imported-key algorithm metadata compatibility is incomplete | 1 | `parallel/test-webcrypto-encrypt-decrypt-rsa.js` | | RSA key import/export metadata compatibility is incomplete | 1 | `parallel/test-webcrypto-export-import-rsa.js` | @@ -1178,6 +1175,7 @@ Secondary full-public compatibility, including public tests that are currently e | node_compat harness does not provide ../common/shared-lib-util for this test setup | 1 | `parallel/test-module-loading-globalpaths.js` | | node_compat test fixture module ../common/process-exit-code-cases is not resolved in this runtime | 1 | `parallel/test-process-exit-code.js` | | non-writable global property semantics in vm contexts are incomplete | 1 | `parallel/test-vm-global-non-writable-properties.js` | +| one .js fixture is still accepted by the CommonJS wrapper instead of being detected as ESM and throwing the expected ReferenceError | 1 | `es-module/test-require-module-error-catching.js` | | options.agent validation/lifecycle is not fully Node-compatible | 1 | `parallel/test-http-client-reject-unexpected-agent.js` | | passive listener semantics are incomplete (test currently self-skips) | 1 | `parallel/test-whatwg-events-add-event-listener-options-passive.js#block_01_block_01` | | per-context Symbol/global binding behavior is incomplete in vm contexts | 1 | `parallel/test-vm-harmony-symbols.js` | diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index 2ea0dd48..1e4ec7c6 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -146,3 +146,54 @@ async fn cjs_package_reexport_named_exports( assert_eq!(r, Some(Val::Bool(true))); Ok(()) } + +#[test] +async fn require_esm_error_handling( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-require-esm-error-handling", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn require_esm_tla_retry( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-require-esm-tla-retry", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn require_esm_cycle_guards( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-require-esm-cycle-guards", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} From ba1961d0fd3a18b5dfb28be24fc876dce354b9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Tue, 16 Jun 2026 12:11:57 +0200 Subject: [PATCH 08/42] add installed app module interop coverage --- .../skeleton/src/builtin/module.js | 539 +++++++++++++++++- .../skeleton/src/builtin/process.js | 8 + .../wasm-rquickjs/skeleton/src/builtin/vm.rs | 15 + crates/wasm-rquickjs/skeleton/src/internal.rs | 66 ++- .../src/installed-app-runner.js | 20 + .../wit/installed-app-runner.wit | 5 + .../src/module-resolution.js | 12 + .../src/node-compat-runner.js | 10 +- .../apps/module-interop/package.json | 27 + .../packages/cjs-basic/index.cjs | 3 + .../packages/cjs-basic/package.json | 5 + .../packages/cjs-nested-require-pkg/index.js | 6 + .../cjs-nested-require-pkg/package.json | 5 + .../packages/cjs-reexport-pkg/index.cjs | 1 + .../packages/cjs-reexport-pkg/package.json | 8 + .../condition-entry-import-cycle/entry.mjs | 4 + .../condition-entry-import-cycle/package.json | 15 + .../dep-bridge.cjs | 1 + .../dep-import.mjs | 4 + .../dep-sync.mjs | 2 + .../condition-entry-imports-cycle/entry.mjs | 4 + .../package.json | 20 + .../bridge.cjs | 1 + .../entry-import.mjs | 2 + .../entry.mjs | 4 + .../package.json | 12 + .../dep-bridge.cjs | 1 + .../dep-import.mjs | 2 + .../dep-sync.mjs | 4 + .../entry.mjs | 4 + .../package.json | 19 + .../condition-entry-no-cycle/entry.mjs | 4 + .../condition-entry-no-cycle/package.json | 15 + .../condition-target-import-cycle/bridge.cjs | 1 + .../condition-target-import-cycle/import.mjs | 4 + .../package.json | 13 + .../condition-target-import-cycle/sync.mjs | 2 + .../condition-target-no-cycle/bridge.cjs | 1 + .../condition-target-no-cycle/import.mjs | 4 + .../condition-target-no-cycle/package.json | 13 + .../condition-target-no-cycle/sync.mjs | 2 + .../packages/cycle-require-esm/bridge.cjs | 1 + .../packages/cycle-require-esm/esm.mjs | 4 + .../packages/cycle-require-esm/index.cjs | 6 + .../packages/cycle-require-esm/package.json | 5 + .../packages/dual-exports/default.mjs | 2 + .../packages/dual-exports/feature.cjs | 1 + .../packages/dual-exports/feature.mjs | 1 + .../packages/dual-exports/import.mjs | 2 + .../packages/dual-exports/package.json | 17 + .../packages/dual-exports/require.cjs | 1 + .../packages/dual-exports/sync.mjs | 2 + .../esm-alias-create-require-cycle/bridge.cjs | 1 + .../esm-alias-create-require-cycle/entry.mjs | 7 + .../package.json | 12 + .../packages/esm-already-evaluated/bridge.cjs | 1 + .../packages/esm-already-evaluated/entry.mjs | 5 + .../esm-already-evaluated/package.json | 12 + .../packages/esm-already-evaluated/ready.mjs | 2 + .../esm-false-positive-scanner/entry.mjs | 18 + .../esm-false-positive-scanner/package.json | 12 + .../packages/esm-sync/index.mjs | 6 + .../packages/esm-sync/package.json | 6 + .../packages/imports-alias/dep.cjs | 1 + .../packages/imports-alias/index.mjs | 4 + .../packages/imports-alias/package.json | 9 + .../module-interop/packages/tla-esm/index.mjs | 2 + .../packages/tla-esm/package.json | 6 + .../apps/module-interop/run-node.mjs | 23 + .../module-interop/test-01-esm-import-cjs.js | 13 + .../test-02-cjs-require-esm.cjs | 11 + .../test-03-package-exports-imports.js | 13 + .../test-04-cycle-require-esm.cjs | 7 + .../module-interop/test-05-tla-require.cjs | 8 + .../test-06-conditional-import-graph.cjs | 6 + ...7-conditional-import-no-false-positive.cjs | 8 + ...est-08-conditional-imports-alias-graph.cjs | 6 + .../test-09-create-require-alias-cycle.cjs | 6 + .../test-10-already-evaluated-dependency.cjs | 8 + ...est-11-module-sync-before-import-graph.cjs | 6 + ...module-sync-before-imports-alias-graph.cjs | 6 + .../test-13-scanner-false-positive-guards.cjs | 12 + tests/installed_apps/config.jsonc | 23 + tests/installed_apps/report.md | 36 ++ tests/node_compat/config.jsonc | 12 +- tests/node_compat/report.md | 20 +- tests/runtime/installed_apps.rs | 268 +++++++++ tests/runtime/main.rs | 2 + 88 files changed, 1504 insertions(+), 24 deletions(-) create mode 100644 examples/runtime/installed-app-runner/src/installed-app-runner.js create mode 100644 examples/runtime/installed-app-runner/wit/installed-app-runner.wit create mode 100644 tests/installed_apps/apps/module-interop/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/cjs-basic/index.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/cjs-basic/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/index.js create mode 100644 tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/index.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/entry.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-bridge.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-import.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-sync.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/entry.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/bridge.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry-import.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-bridge.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-import.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-sync.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/entry.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/entry.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/bridge.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/import.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/sync.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/bridge.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/import.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/sync.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/cycle-require-esm/bridge.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/cycle-require-esm/esm.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/cycle-require-esm/index.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/cycle-require-esm/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/dual-exports/default.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/dual-exports/feature.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/dual-exports/feature.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/dual-exports/import.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/dual-exports/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/dual-exports/require.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/dual-exports/sync.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/bridge.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/entry.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/bridge.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/entry.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/ready.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/entry.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/esm-sync/index.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/esm-sync/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/imports-alias/dep.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/imports-alias/index.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/imports-alias/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/tla-esm/index.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/tla-esm/package.json create mode 100644 tests/installed_apps/apps/module-interop/run-node.mjs create mode 100644 tests/installed_apps/apps/module-interop/test-01-esm-import-cjs.js create mode 100644 tests/installed_apps/apps/module-interop/test-02-cjs-require-esm.cjs create mode 100644 tests/installed_apps/apps/module-interop/test-03-package-exports-imports.js create mode 100644 tests/installed_apps/apps/module-interop/test-04-cycle-require-esm.cjs create mode 100644 tests/installed_apps/apps/module-interop/test-05-tla-require.cjs create mode 100644 tests/installed_apps/apps/module-interop/test-06-conditional-import-graph.cjs create mode 100644 tests/installed_apps/apps/module-interop/test-07-conditional-import-no-false-positive.cjs create mode 100644 tests/installed_apps/apps/module-interop/test-08-conditional-imports-alias-graph.cjs create mode 100644 tests/installed_apps/apps/module-interop/test-09-create-require-alias-cycle.cjs create mode 100644 tests/installed_apps/apps/module-interop/test-10-already-evaluated-dependency.cjs create mode 100644 tests/installed_apps/apps/module-interop/test-11-module-sync-before-import-graph.cjs create mode 100644 tests/installed_apps/apps/module-interop/test-12-module-sync-before-imports-alias-graph.cjs create mode 100644 tests/installed_apps/apps/module-interop/test-13-scanner-false-positive-guards.cjs create mode 100644 tests/installed_apps/config.jsonc create mode 100644 tests/installed_apps/report.md create mode 100644 tests/runtime/installed_apps.rs diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 913f5185..698e06af 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -640,6 +640,7 @@ function loadAsDirectory(candidate, id, parentDir, seen) { } const cjsPackageConditions = new Set(['golem', 'node', 'require', 'module-sync', 'default']); +const esmPackageConditions = new Set(['golem', 'node', 'module-sync', 'import', 'default']); const packageTargetNoMatch = { __packageTargetNoMatch: true }; const packageTargetBlocked = { __packageTargetBlocked: true }; @@ -717,7 +718,7 @@ function resolvePackageTargetValue(packageDir, target, conditions, seen, allowBa return { builtin: target }; } if (allowBareTarget && isBarePackageSpecifier(target)) { - const resolved = resolveFromNodeModules(target, packageDir, pathModule.join(packageDir, 'package.json')); + const resolved = resolveFromNodeModules(target, packageDir, pathModule.join(packageDir, 'package.json'), conditions); if (resolved !== null) return resolved; throw makeModuleNotFoundError(target); } @@ -1346,6 +1347,529 @@ function looksLikeEsmSource(source) { return false; } +function hasCjsWrapperRequireRedeclaration(source) { + let i = 0; + let braceDepth = 0; + while (i < source.length) { + const code = source.charCodeAt(i); + if (code === 0x27 || code === 0x22 || code === 0x60) { // ' " ` + i = skipQuotedOrTemplate(source, i); + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { + i += 2; + while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { + i += 2; + while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; + i = Math.min(i + 2, source.length); + continue; + } + if (code === 0x2f && isRegexLiteralStartInSource(source, i)) { + i = skipRegexLiteralInSource(source, i); + continue; + } + + if (code === 0x7b) { + braceDepth++; + i++; + continue; + } + if (code === 0x7d) { + braceDepth = Math.max(0, braceDepth - 1); + i++; + continue; + } + + if (braceDepth === 0 && (startsWithKeywordAt(source, 'const', i) || startsWithKeywordAt(source, 'let', i))) { + let next = skipWhitespace(source, i + (source.startsWith('const', i) ? 5 : 3)); + if (source.startsWith('require', next) && hasIdentifierBoundary(source, next, next + 7)) { + return true; + } + } + i++; + } + return false; +} + +function readStaticSpecifierString(source, start) { + const i = skipWhitespace(source, start); + const quote = source.charCodeAt(i); + if (quote !== 0x27 && quote !== 0x22) return null; + let value = ''; + let p = i + 1; + while (p < source.length) { + const code = source.charCodeAt(p); + if (code === 0x5c && p + 1 < source.length) { + value += source[p + 1]; + p += 2; + } else if (code === quote) { + return { value, end: p + 1 }; + } else { + value += source[p]; + p++; + } + } + return null; +} + +function statementEndForStaticImport(source, start) { + let i = start; + let brace = 0; + let paren = 0; + while (i < source.length) { + const code = source.charCodeAt(i); + if (code === 0x27 || code === 0x22 || code === 0x60) { + i = skipQuotedOrTemplate(source, i); + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { + i += 2; + while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { + i += 2; + while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; + i = Math.min(i + 2, source.length); + continue; + } + if (code === 0x7b) brace++; + else if (code === 0x7d) brace = Math.max(0, brace - 1); + else if (code === 0x28) paren++; + else if (code === 0x29) paren = Math.max(0, paren - 1); + else if ((code === 0x3b || code === 0x0a || code === 0x0d) && brace === 0 && paren === 0) return i; + i++; + } + return source.length; +} + +function staticImportSpecifierAt(source, pos) { + if (startsWithKeywordAt(source, 'import', pos)) { + const afterImport = skipWhitespace(source, pos + 6); + const bare = readStaticSpecifierString(source, afterImport); + if (bare) return bare.value; + + const end = statementEndForStaticImport(source, afterImport); + let i = afterImport; + while (i < end) { + const code = source.charCodeAt(i); + if (code === 0x27 || code === 0x22 || code === 0x60) { + i = skipQuotedOrTemplate(source, i); + continue; + } + if (startsWithKeywordAt(source, 'from', i)) { + const spec = readStaticSpecifierString(source, i + 4); + if (spec && spec.end <= end + 1) return spec.value; + } + i++; + } + } + + if (startsWithKeywordAt(source, 'export', pos)) { + const end = statementEndForStaticImport(source, pos + 6); + let i = pos + 6; + while (i < end) { + const code = source.charCodeAt(i); + if (code === 0x27 || code === 0x22 || code === 0x60) { + i = skipQuotedOrTemplate(source, i); + continue; + } + if (startsWithKeywordAt(source, 'from', i)) { + const spec = readStaticSpecifierString(source, i + 4); + if (spec && spec.end <= end + 1) return spec.value; + } + i++; + } + } + + return null; +} + +function collectStaticEsmSpecifiers(source) { + const specifiers = []; + let i = 0; + while (i < source.length) { + const code = source.charCodeAt(i); + if (code === 0x27 || code === 0x22 || code === 0x60) { + i = skipQuotedOrTemplate(source, i); + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { + i += 2; + while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { + i += 2; + while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; + i = Math.min(i + 2, source.length); + continue; + } + if (code === 0x2f && isRegexLiteralStartInSource(source, i)) { + i = skipRegexLiteralInSource(source, i); + continue; + } + const specifier = staticImportSpecifierAt(source, i); + if (specifier !== null) specifiers.push(specifier); + i++; + } + return specifiers; +} + +function collectLiteralRequireSpecifiers(source, names) { + names = names || ['require']; + const specifiers = []; + let i = 0; + while (i < source.length) { + const code = source.charCodeAt(i); + if (code === 0x27 || code === 0x22 || code === 0x60) { + i = skipQuotedOrTemplate(source, i); + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { + i += 2; + while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { + i += 2; + while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; + i = Math.min(i + 2, source.length); + continue; + } + if (code === 0x2f && isRegexLiteralStartInSource(source, i)) { + i = skipRegexLiteralInSource(source, i); + continue; + } + for (let n = 0; n < names.length; n++) { + const name = names[n]; + if (startsWithKeywordAt(source, name, i) && previousSignificantChar(source, i) !== 0x2e) { + const open = skipWhitespace(source, i + name.length); + if (source.charCodeAt(open) === 0x28) { + const spec = readStaticSpecifierString(source, open + 1); + if (spec) specifiers.push(spec.value); + } + } + } + i++; + } + return specifiers; +} + +function collectCreateRequireFactoryNames(source) { + const names = []; + let i = 0; + while (i < source.length) { + const code = source.charCodeAt(i); + if (code === 0x27 || code === 0x22 || code === 0x60) { + i = skipQuotedOrTemplate(source, i); + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { + i += 2; + while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { + i += 2; + while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; + i = Math.min(i + 2, source.length); + continue; + } + if (startsWithKeywordAt(source, 'import', i)) { + const end = statementEndForStaticImport(source, i + 6); + const statement = source.slice(i, end); + if (/from\s*['"](?:node:)?module['"]/.test(statement)) { + const m = statement.match(/\{([\s\S]*?)\}/); + if (m) { + const parts = m[1].split(','); + for (let p = 0; p < parts.length; p++) { + const part = parts[p].trim(); + const alias = part.match(/^createRequire\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$/); + if (alias) { + names.push(alias[1]); + } else if (part === 'createRequire') { + names.push('createRequire'); + } + } + } + } + i = end; + continue; + } + i++; + } + return names; +} + +function collectCreateRequireAliases(source, factoryNames) { + factoryNames = factoryNames || collectCreateRequireFactoryNames(source); + const aliases = []; + if (factoryNames.length === 0) return aliases; + let i = 0; + while (i < source.length) { + const code = source.charCodeAt(i); + if (code === 0x27 || code === 0x22 || code === 0x60) { + i = skipQuotedOrTemplate(source, i); + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { + i += 2; + while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { + i += 2; + while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; + i = Math.min(i + 2, source.length); + continue; + } + if (startsWithKeywordAt(source, 'const', i) || startsWithKeywordAt(source, 'let', i) || startsWithKeywordAt(source, 'var', i)) { + const keywordLen = source.startsWith('const', i) ? 5 : 3; + let p = skipWhitespace(source, i + keywordLen); + const identMatch = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(source.slice(p)); + if (identMatch) { + const name = identMatch[0]; + p = skipWhitespace(source, p + name.length); + if (source.charCodeAt(p) === 0x3d) { + p = skipWhitespace(source, p + 1); + for (let f = 0; f < factoryNames.length; f++) { + const factory = factoryNames[f]; + if (startsWithKeywordAt(source, factory, p)) { + const open = skipWhitespace(source, p + factory.length); + if (source.charCodeAt(open) === 0x28) { + aliases.push(name); + } + } + } + } + } + } + i++; + } + return aliases; +} + +function collectCreateRequireCallSpecifiers(source, factoryNames) { + factoryNames = factoryNames || collectCreateRequireFactoryNames(source); + const specifiers = []; + if (factoryNames.length === 0) return specifiers; + let i = 0; + while (i < source.length) { + const code = source.charCodeAt(i); + if (code === 0x27 || code === 0x22 || code === 0x60) { + i = skipQuotedOrTemplate(source, i); + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { + i += 2; + while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; + continue; + } + if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { + i += 2; + while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; + i = Math.min(i + 2, source.length); + continue; + } + if (code === 0x2f && isRegexLiteralStartInSource(source, i)) { + i = skipRegexLiteralInSource(source, i); + continue; + } + for (let f = 0; f < factoryNames.length; f++) { + const factory = factoryNames[f]; + if (startsWithKeywordAt(source, factory, i) && previousSignificantChar(source, i) !== 0x2e) { + const firstOpen = skipWhitespace(source, i + factory.length); + if (source.charCodeAt(firstOpen) === 0x28) { + const firstClose = source.indexOf(')', firstOpen + 1); + if (firstClose !== -1) { + const secondOpen = skipWhitespace(source, firstClose + 1); + if (source.charCodeAt(secondOpen) === 0x28) { + const spec = readStaticSpecifierString(source, secondOpen + 1); + if (spec) specifiers.push(spec.value); + } + } + } + } + } + i++; + } + return specifiers; +} + +function isEsmGraphFile(filename, source) { + return filename.endsWith('.mjs') || + (filename.endsWith('.js') && getPackageScopeType(filename) === 'module') || + (!filename.endsWith('.cjs') && looksLikeEsmSource(source)); +} + +function fileUrlForPath(filename) { + return 'file://' + filename; +} + +function resolveEsmGraphSpecifier(specifier, parentFilename, conditions) { + conditions = conditions || esmPackageConditions; + if (specifier.startsWith('node:') || specifier.startsWith('data:')) return null; + const parentDir = pathModule.dirname(parentFilename); + if (specifier === '.' || specifier === '..' || specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/')) { + try { + return resolveFilename(specifier, parentDir); + } catch (_) { + return null; + } + } + if (specifier.startsWith('#')) { + try { + const resolved = resolvePackageImports(specifier, parentDir, conditions); + if (resolved && !resolved.builtin) return resolved; + } catch (_) { + return null; + } + return null; + } + try { + return resolveFromNodeModules(specifier, parentDir, parentFilename, conditions); + } catch (_) { + return null; + } +} + +function addRequireEsmGraphMark(filename, marked) { + const graph = globalThis.__wasm_rquickjs_require_esm_graph_in_progress || Object.create(null); + const counts = globalThis.__wasm_rquickjs_require_esm_graph_counts || Object.create(null); + globalThis.__wasm_rquickjs_require_esm_graph_in_progress = graph; + globalThis.__wasm_rquickjs_require_esm_graph_counts = counts; + + for (const key of [filename, fileUrlForPath(filename)]) { + counts[key] = (counts[key] || 0) + 1; + graph[key] = true; + marked.push(key); + } +} + +function stackContains(stack, filename) { + for (let i = 0; i < stack.length; i++) { + if (stack[i] === filename) return true; + } + return false; +} + +function esmGraphReachesAny(filename, stack, seen) { + if (stackContains(stack, filename)) return true; + seen = seen || Object.create(null); + if (seen[filename]) return false; + seen[filename] = true; + + const source = tryReadFile(filename); + if (source === null) return false; + + const specifiers = isEsmGraphFile(filename, source) + ? collectStaticEsmSpecifiers(source) + : collectLiteralRequireSpecifiers(source); + const conditions = isEsmGraphFile(filename, source) ? esmPackageConditions : cjsPackageConditions; + for (let i = 0; i < specifiers.length; i++) { + const resolved = resolveEsmGraphSpecifier(specifiers[i], filename, conditions); + if (resolved && resolved.filename && esmGraphReachesAny(resolved.filename, stack, seen)) return true; + } + + if (isEsmGraphFile(filename, source)) { + const factoryNames = collectCreateRequireFactoryNames(source); + const aliases = collectCreateRequireAliases(source, factoryNames); + const bridgeSpecifiers = collectCreateRequireCallSpecifiers(source, factoryNames).concat( + aliases.length === 0 ? [] : collectLiteralRequireSpecifiers(source, aliases), + ); + for (let i = 0; i < bridgeSpecifiers.length; i++) { + const resolved = resolveEsmGraphSpecifier(bridgeSpecifiers[i], filename, cjsPackageConditions); + if (resolved && resolved.filename && esmGraphReachesAny(resolved.filename, stack, seen)) return true; + } + } + + return false; +} + +function scanRequireEsmGraph(filename, marked, seen, stack) { + if (seen[filename]) return; + seen[filename] = true; + + const source = tryReadFile(filename); + if (source === null) return; + + if (!isEsmGraphFile(filename, source)) { + const requireSpecifiers = collectLiteralRequireSpecifiers(source); + for (let i = 0; i < requireSpecifiers.length; i++) { + const resolved = resolveEsmGraphSpecifier(requireSpecifiers[i], filename, cjsPackageConditions); + if (resolved && resolved.filename) { + const targetSource = tryReadFile(resolved.filename); + if (targetSource !== null && isEsmGraphFile(resolved.filename, targetSource) && esmGraphReachesAny(resolved.filename, stack)) { + addRequireEsmGraphMark(resolved.filename, marked); + } else { + scanRequireEsmGraph(resolved.filename, marked, seen, stack); + } + } + } + return; + } + + stack.push(filename); + + const specifiers = collectStaticEsmSpecifiers(source); + for (let i = 0; i < specifiers.length; i++) { + const resolved = resolveEsmGraphSpecifier(specifiers[i], filename, esmPackageConditions); + if (resolved && resolved.filename) { + scanRequireEsmGraph(resolved.filename, marked, seen, stack); + } + } + const factoryNames = collectCreateRequireFactoryNames(source); + const aliases = collectCreateRequireAliases(source, factoryNames); + const createRequireSpecifiers = collectCreateRequireCallSpecifiers(source, factoryNames).concat( + aliases.length === 0 ? [] : collectLiteralRequireSpecifiers(source, aliases), + ); + for (let i = 0; i < createRequireSpecifiers.length; i++) { + const resolved = resolveEsmGraphSpecifier(createRequireSpecifiers[i], filename, cjsPackageConditions); + if (resolved && resolved.filename) { + const targetSource = tryReadFile(resolved.filename); + if (targetSource !== null && isEsmGraphFile(resolved.filename, targetSource) && esmGraphReachesAny(resolved.filename, stack)) { + addRequireEsmGraphMark(resolved.filename, marked); + } else { + scanRequireEsmGraph(resolved.filename, marked, seen, stack); + } + } + } + stack.pop(); +} + +function markRequireEsmGraph(filename) { + const marked = []; + scanRequireEsmGraph(filename, marked, Object.create(null), []); + return marked; +} + +function unmarkRequireEsmGraph(marked) { + const graph = globalThis.__wasm_rquickjs_require_esm_graph_in_progress; + const counts = globalThis.__wasm_rquickjs_require_esm_graph_counts; + if (!graph || !counts) return; + for (let i = 0; i < marked.length; i++) { + const key = marked[i]; + counts[key] = (counts[key] || 1) - 1; + if (counts[key] <= 0) { + delete counts[key]; + delete graph[key]; + } + } +} + +function throwIfRequireEsmGraphCycle(resolvedFilename) { + const graph = globalThis.__wasm_rquickjs_require_esm_graph_in_progress; + if (graph && (graph[resolvedFilename] || graph[fileUrlForPath(resolvedFilename)])) { + const err = new Error('Cannot require() ES Module ' + resolvedFilename + ' in a cycle.'); + err.code = 'ERR_REQUIRE_CYCLE_MODULE'; + throw err; + } +} + const wrapper = [ '(function (exports, require, module, __filename, __dirname) { ', '\n});' @@ -1374,6 +1898,8 @@ function compileCjs(filename, source) { } function requireEsmWithCacheGuard(mod, resolvedFilename) { + throwIfRequireEsmGraphCycle(resolvedFilename); + const markedGraph = markRequireEsmGraph(resolvedFilename); Object.defineProperty(mod, '__wasmRequireEsmInProgress', { value: true, writable: true, @@ -1383,6 +1909,7 @@ function requireEsmWithCacheGuard(mod, resolvedFilename) { try { return wrapEsmNamespace(_requireEsm(resolvedFilename)); } finally { + unmarkRequireEsmGraph(markedGraph); delete mod.__wasmRequireEsmInProgress; } } @@ -1483,6 +2010,7 @@ function loadModule(resolvedFilename, source, parentModule) { const childRequire = makeRequire(dirname, mod); let compiledFn; let cjsSyntaxError = null; + const cjsWrapperRequireRedeclaration = !resolvedFilename.endsWith('.cjs') && hasCjsWrapperRequireRedeclaration(source); try { compiledFn = compileCjs(resolvedFilename, source); } catch (err) { @@ -1501,13 +2029,13 @@ function loadModule(resolvedFilename, source, parentModule) { throw err; } } - if (cjsSyntaxError) { + if (cjsSyntaxError || cjsWrapperRequireRedeclaration) { // SyntaxError in a .js file — try loading as ESM (entry point detection) try { mod.exports = requireEsmWithCacheGuard(mod, resolvedFilename); } catch (esmErr) { delete moduleCache[resolvedFilename]; - if (looksLikeEsmSource(source)) { + if (looksLikeEsmSource(source) || cjsWrapperRequireRedeclaration) { normalizeEsmSyntaxError(esmErr); throw esmErr; } @@ -1569,7 +2097,8 @@ function splitPackageName(id) { return { name: id.substring(0, idx), subpath: id.substring(idx + 1) }; } -function resolveFromNodeModules(id, parentDir, parentFilename) { +function resolveFromNodeModules(id, parentDir, parentFilename, conditions) { + conditions = conditions || cjsPackageConditions; const dirs = _nodeModulePaths(parentDir); // Split into package name and subpath for packages with subpath specifiers @@ -1585,7 +2114,7 @@ function resolveFromNodeModules(id, parentDir, parentFilename) { if (pkgJson !== null) { try { pkg = JSON.parse(pkgJson); - const exportsResolved = resolvePackageExports(parts.name, pkgDir, pkg, parts.subpath, cjsPackageConditions); + const exportsResolved = resolvePackageExports(parts.name, pkgDir, pkg, parts.subpath, conditions); if (exportsResolved !== undefined) return exportsResolved; } catch (e) { if (e && e.code) { diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/process.js b/crates/wasm-rquickjs/skeleton/src/builtin/process.js index aff4667a..28a07b55 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/process.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/process.js @@ -731,6 +731,10 @@ globalThis.__wasm_rquickjs_rejection_tracker = function(promise, reason, isHandl Promise.resolve().then(function() { if (_pendingRejections.has(promise)) { _pendingRejections.delete(promise); + if (globalThis.__wasm_rquickjs_suppress_unhandled_rejection_count > 0) { + globalThis.__wasm_rquickjs_suppress_unhandled_rejection_count--; + return; + } process.emit('unhandledRejection', reason, promise); } }); @@ -740,6 +744,10 @@ globalThis.__wasm_rquickjs_rejection_tracker = function(promise, reason, isHandl } }; +globalThis.__wasm_rquickjs_mark_rejection_handled = function(promise) { + _pendingRejections.delete(promise); +}; + // Named exports for import { argv } from 'node:process' style export var argv = process.argv; export var argv0 = process.argv0; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/vm.rs b/crates/wasm-rquickjs/skeleton/src/builtin/vm.rs index 563458cf..5ac00ea3 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/vm.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/vm.rs @@ -195,6 +195,12 @@ fn require_esm_impl<'js>( true } PromiseState::Rejected => { + attach_noop_promise_catch(ctx.clone(), eval_value.as_raw()); + mark_rejection_handled(&ctx, eval_value.clone()); + let count = globals + .get::<_, i32>("__wasm_rquickjs_suppress_unhandled_rejection_count") + .unwrap_or(0); + let _ = globals.set("__wasm_rquickjs_suppress_unhandled_rejection_count", count + 1); let _ = promise.result::>(); let rejected = ctx.catch(); leave_require_esm(&globals, filename, &file_url)?; @@ -231,6 +237,15 @@ fn require_esm_impl<'js>( } } +fn mark_rejection_handled<'js>(ctx: &rquickjs::Ctx<'js>, promise: Value<'js>) { + if let Ok(handler) = ctx + .globals() + .get::<_, rquickjs::Function>("__wasm_rquickjs_mark_rejection_handled") + { + let _ = handler.call::<_, ()>((promise,)); + } +} + fn attach_noop_promise_catch(ctx: rquickjs::Ctx<'_>, val: qjs::JSValue) { unsafe { let catch_fn = qjs::JS_GetPropertyStr(ctx.as_raw().as_ptr(), val, c"catch".as_ptr()); diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 98737c11..1846752e 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -1164,6 +1164,69 @@ fn data_url_simple_identifier_error_module_source(source: &str) -> Option bool { + let bytes = source.as_bytes(); + let mut i = 0usize; + let mut brace_depth = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(source, i); + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { + i += 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + continue; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + b'/' if is_regex_literal_start(source, i) => { + i = skip_regex_literal(source, i); + continue; + } + b'{' => { + brace_depth += 1; + i += 1; + continue; + } + b'}' => { + brace_depth = brace_depth.saturating_sub(1); + i += 1; + continue; + } + _ => {} + } + + if brace_depth == 0 { + for keyword in ["const", "let"] { + if source[i..].starts_with(keyword) + && is_ident_start_boundary(bytes, i) + && is_ident_boundary(bytes, i + keyword.len()) + { + let next = skip_ws_comments(source, i + keyword.len()); + if source[next..].starts_with("require") + && is_ident_start_boundary(bytes, next) + && is_ident_boundary(bytes, next + 7) + { + return true; + } + } + } + } + i = next_char_boundary(source, i); + } + false +} + fn is_ascii_js_identifier(value: &str) -> bool { let bytes = value.as_bytes(); if bytes.is_empty() || !(bytes[0] == b'_' || bytes[0] == b'$' || bytes[0].is_ascii_alphabetic()) { @@ -1591,7 +1654,7 @@ struct PackageJson { struct NodeModulesResolver; impl NodeModulesResolver { - const ESM_CONDITIONS: [&'static str; 4] = ["golem", "node", "import", "default"]; + const ESM_CONDITIONS: [&'static str; 5] = ["golem", "node", "module-sync", "import", "default"]; const CJS_CONDITIONS: [&'static str; 5] = ["golem", "node", "require", "module-sync", "default"]; fn try_resolve( @@ -3289,6 +3352,7 @@ impl Loader for CjsCompatLoader { // comments, strings, templates, and regex literals do not force CJS. let is_cjs = is_cjs_ext || (!is_js_in_module_package_scope(&fs_abs_path) + && !has_cjs_wrapper_require_redeclaration(&source) && (detected_analysis.is_cjs || !detected_analysis.exports.is_empty() || !detected_analysis.reexports.is_empty())); diff --git a/examples/runtime/installed-app-runner/src/installed-app-runner.js b/examples/runtime/installed-app-runner/src/installed-app-runner.js new file mode 100644 index 00000000..c2df588a --- /dev/null +++ b/examples/runtime/installed-app-runner/src/installed-app-runner.js @@ -0,0 +1,20 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +function getRunFunction(module) { + if (module && typeof module.run === 'function') return module.run; + if (module && module.default && typeof module.default.run === 'function') return module.default.run; + throw new Error('Installed app test module must export run()'); +} + +export const runTest = async (testPath) => { + const module = testPath.endsWith('.cjs') + ? createRequire(testPath)(testPath) + : await import(pathToFileURL(testPath).href); + + const result = await getRunFunction(module)(); + if (typeof result !== 'string' || !result.startsWith('PASS:')) { + throw new Error(`Unexpected installed app test result: ${result}`); + } + return result; +}; diff --git a/examples/runtime/installed-app-runner/wit/installed-app-runner.wit b/examples/runtime/installed-app-runner/wit/installed-app-runner.wit new file mode 100644 index 00000000..2c24a175 --- /dev/null +++ b/examples/runtime/installed-app-runner/wit/installed-app-runner.wit @@ -0,0 +1,5 @@ +package quickjs:installed-app-runner; + +world installed-app-runner { + export run-test: func(test-path: string) -> string; +} diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 1e789e25..129ab746 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -857,6 +857,14 @@ export const testRequireEsmErrorHandling = async () => { fs.writeFileSync('/require-esm-errors-app/reference-error.mjs', [ 'Object.defineProperty(exports, "__esModule", { value: true });', ].join('\n')); + fs.writeFileSync('/require-esm-errors-app/ambiguous-reference.js', [ + 'Object.defineProperty(exports, "__esModule", { value: true });', + 'const require = () => {};', + ].join('\n')); + fs.writeFileSync('/require-esm-errors-app/valid-transpiled.js', [ + 'Object.defineProperty(exports, "__esModule", { value: true });', + 'exports.foo = "foo";', + ].join('\n')); const { createRequire } = await import('node:module'); const require = createRequire('/require-esm-errors-app/main.cjs'); @@ -867,6 +875,10 @@ export const testRequireEsmErrorHandling = async () => { assert.throws(() => require('/require-esm-errors-app/reference-error.mjs'), { name: 'ReferenceError', }); + assert.throws(() => require('/require-esm-errors-app/ambiguous-reference.js'), { + name: 'ReferenceError', + }); + assert.strictEqual(require('/require-esm-errors-app/valid-transpiled.js').foo, 'foo'); return true; } catch (error) { diff --git a/examples/runtime/node-compat-runner/src/node-compat-runner.js b/examples/runtime/node-compat-runner/src/node-compat-runner.js index dee59b65..c79c4864 100644 --- a/examples/runtime/node-compat-runner/src/node-compat-runner.js +++ b/examples/runtime/node-compat-runner/src/node-compat-runner.js @@ -43,13 +43,19 @@ function drainAsync() { // (set via rquickjs 0.10's set_host_promise_rejection_tracker) emits // process.emit('unhandledRejection', reason) which we listen for here. var _firstUnhandledRejection = null; +var _firstUnhandledRejectionHadTestListener = false; function installRejectionTracking() { _firstUnhandledRejection = null; + _firstUnhandledRejectionHadTestListener = false; function onUnhandledRejection(reason) { if (!_firstUnhandledRejection) { _firstUnhandledRejection = reason; + _firstUnhandledRejectionHadTestListener = + globalThis.process && + typeof globalThis.process.listenerCount === 'function' && + globalThis.process.listenerCount('unhandledRejection') > 1; } } @@ -62,8 +68,10 @@ function installRejectionTracking() { globalThis.process.removeListener('unhandledRejection', onUnhandledRejection); } var rejection = _firstUnhandledRejection; + var hadTestListener = _firstUnhandledRejectionHadTestListener; _firstUnhandledRejection = null; - return rejection; + _firstUnhandledRejectionHadTestListener = false; + return hadTestListener ? null : rejection; }; } diff --git a/tests/installed_apps/apps/module-interop/package.json b/tests/installed_apps/apps/module-interop/package.json new file mode 100644 index 00000000..261e2ed4 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/package.json @@ -0,0 +1,27 @@ +{ + "private": true, + "type": "module", + "scripts": { + "test:node": "node run-node.mjs" + }, + "dependencies": { + "cjs-basic": "file:./packages/cjs-basic", + "cjs-reexport-pkg": "file:./packages/cjs-reexport-pkg", + "condition-entry-import-cycle": "file:./packages/condition-entry-import-cycle", + "condition-entry-imports-cycle": "file:./packages/condition-entry-imports-cycle", + "condition-entry-module-sync-cycle": "file:./packages/condition-entry-module-sync-cycle", + "condition-entry-module-sync-imports-cycle": "file:./packages/condition-entry-module-sync-imports-cycle", + "condition-entry-no-cycle": "file:./packages/condition-entry-no-cycle", + "condition-target-import-cycle": "file:./packages/condition-target-import-cycle", + "condition-target-no-cycle": "file:./packages/condition-target-no-cycle", + "cjs-nested-require-pkg": "file:./packages/cjs-nested-require-pkg", + "cycle-require-esm": "file:./packages/cycle-require-esm", + "esm-alias-create-require-cycle": "file:./packages/esm-alias-create-require-cycle", + "esm-already-evaluated": "file:./packages/esm-already-evaluated", + "esm-false-positive-scanner": "file:./packages/esm-false-positive-scanner", + "dual-exports": "file:./packages/dual-exports", + "esm-sync": "file:./packages/esm-sync", + "imports-alias": "file:./packages/imports-alias", + "tla-esm": "file:./packages/tla-esm" + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-basic/index.cjs b/tests/installed_apps/apps/module-interop/packages/cjs-basic/index.cjs new file mode 100644 index 00000000..bd1399aa --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/cjs-basic/index.cjs @@ -0,0 +1,3 @@ +exports.alpha = 'alpha'; +exports['bracketed'] = 'bracketed'; +Object.defineProperty(exports, 'defined', { enumerable: true, value: 'defined' }); diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-basic/package.json b/tests/installed_apps/apps/module-interop/packages/cjs-basic/package.json new file mode 100644 index 00000000..1ed22656 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/cjs-basic/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-basic", + "version": "1.0.0", + "main": "index.cjs" +} diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/index.js b/tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/index.js new file mode 100644 index 00000000..18f11422 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/index.js @@ -0,0 +1,6 @@ +if (true) { + const require = () => 'local'; + require(); +} + +module.exports = { ok: true }; diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/package.json b/tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/package.json new file mode 100644 index 00000000..ae15d1ff --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-nested-require-pkg", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/index.cjs b/tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/index.cjs new file mode 100644 index 00000000..10c6bc5b --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/index.cjs @@ -0,0 +1 @@ +module.exports = require('cjs-basic'); diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/package.json b/tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/package.json new file mode 100644 index 00000000..3190ac70 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/package.json @@ -0,0 +1,8 @@ +{ + "name": "cjs-reexport-pkg", + "version": "1.0.0", + "main": "index.cjs", + "dependencies": { + "cjs-basic": "file:../cjs-basic" + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/entry.mjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/entry.mjs new file mode 100644 index 00000000..22c6b092 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/entry.mjs @@ -0,0 +1,4 @@ +import 'condition-target-import-cycle'; + +export const value = 'entry'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/package.json b/tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/package.json new file mode 100644 index 00000000..b4cb00c0 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/package.json @@ -0,0 +1,15 @@ +{ + "name": "condition-entry-import-cycle", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "module-sync": "./entry.mjs", + "require": "./entry.mjs", + "default": "./entry.mjs" + } + }, + "dependencies": { + "condition-target-import-cycle": "file:../condition-target-import-cycle" + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-bridge.cjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-bridge.cjs new file mode 100644 index 00000000..c4bd8bfe --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-bridge.cjs @@ -0,0 +1 @@ +module.exports = require('./dep-import.mjs'); diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-import.mjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-import.mjs new file mode 100644 index 00000000..cbfae9cf --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-import.mjs @@ -0,0 +1,4 @@ +import './dep-bridge.cjs'; + +export const value = 'dep-import'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-sync.mjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-sync.mjs new file mode 100644 index 00000000..41545b16 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-sync.mjs @@ -0,0 +1,2 @@ +export const value = 'dep-sync'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/entry.mjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/entry.mjs new file mode 100644 index 00000000..63caadaa --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/entry.mjs @@ -0,0 +1,4 @@ +import '#dep'; + +export const value = 'entry'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/package.json b/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/package.json new file mode 100644 index 00000000..b40840bd --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/package.json @@ -0,0 +1,20 @@ +{ + "name": "condition-entry-imports-cycle", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "module-sync": "./entry.mjs", + "require": "./entry.mjs", + "default": "./entry.mjs" + } + }, + "imports": { + "#dep": { + "import": "./dep-import.mjs", + "module-sync": "./dep-sync.mjs", + "require": "./dep-sync.mjs", + "default": "./dep-sync.mjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/bridge.cjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/bridge.cjs new file mode 100644 index 00000000..120f861b --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/bridge.cjs @@ -0,0 +1 @@ +module.exports = require('./entry.mjs'); diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry-import.mjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry-import.mjs new file mode 100644 index 00000000..c93a9fd3 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry-import.mjs @@ -0,0 +1,2 @@ +export const value = 'import'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry.mjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry.mjs new file mode 100644 index 00000000..976f984d --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry.mjs @@ -0,0 +1,4 @@ +import './bridge.cjs'; + +export const value = 'module-sync'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/package.json b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/package.json new file mode 100644 index 00000000..5fea76d0 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/package.json @@ -0,0 +1,12 @@ +{ + "name": "condition-entry-module-sync-cycle", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "module-sync": "./entry.mjs", + "import": "./entry-import.mjs", + "default": "./entry-import.mjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-bridge.cjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-bridge.cjs new file mode 100644 index 00000000..f8068ea2 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-bridge.cjs @@ -0,0 +1 @@ +module.exports = require('./dep-sync.mjs'); diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-import.mjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-import.mjs new file mode 100644 index 00000000..fd115c03 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-import.mjs @@ -0,0 +1,2 @@ +export const value = 'dep-import'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-sync.mjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-sync.mjs new file mode 100644 index 00000000..4563cadc --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-sync.mjs @@ -0,0 +1,4 @@ +import './dep-bridge.cjs'; + +export const value = 'dep-sync'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/entry.mjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/entry.mjs new file mode 100644 index 00000000..63caadaa --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/entry.mjs @@ -0,0 +1,4 @@ +import '#dep'; + +export const value = 'entry'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/package.json b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/package.json new file mode 100644 index 00000000..771a2c14 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/package.json @@ -0,0 +1,19 @@ +{ + "name": "condition-entry-module-sync-imports-cycle", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "module-sync": "./entry.mjs", + "import": "./entry.mjs", + "default": "./entry.mjs" + } + }, + "imports": { + "#dep": { + "module-sync": "./dep-sync.mjs", + "import": "./dep-import.mjs", + "default": "./dep-import.mjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/entry.mjs b/tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/entry.mjs new file mode 100644 index 00000000..7cb272a5 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/entry.mjs @@ -0,0 +1,4 @@ +import target from 'condition-target-no-cycle'; + +export const ok = target.ok; +export default { ok }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/package.json b/tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/package.json new file mode 100644 index 00000000..f91308b1 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/package.json @@ -0,0 +1,15 @@ +{ + "name": "condition-entry-no-cycle", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "module-sync": "./entry.mjs", + "require": "./entry.mjs", + "default": "./entry.mjs" + } + }, + "dependencies": { + "condition-target-no-cycle": "file:../condition-target-no-cycle" + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/bridge.cjs b/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/bridge.cjs new file mode 100644 index 00000000..d068d271 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/bridge.cjs @@ -0,0 +1 @@ +module.exports = require('./import.mjs'); diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/import.mjs b/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/import.mjs new file mode 100644 index 00000000..1b7dfc7e --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/import.mjs @@ -0,0 +1,4 @@ +import './bridge.cjs'; + +export const value = 'import-branch'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/package.json b/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/package.json new file mode 100644 index 00000000..45a88573 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/package.json @@ -0,0 +1,13 @@ +{ + "name": "condition-target-import-cycle", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "import": "./import.mjs", + "module-sync": "./sync.mjs", + "require": "./sync.mjs", + "default": "./sync.mjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/sync.mjs b/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/sync.mjs new file mode 100644 index 00000000..56568ff2 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/sync.mjs @@ -0,0 +1,2 @@ +export const value = 'sync-branch'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/bridge.cjs b/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/bridge.cjs new file mode 100644 index 00000000..47f9e067 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/bridge.cjs @@ -0,0 +1 @@ +module.exports = require('condition-target-no-cycle'); diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/import.mjs b/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/import.mjs new file mode 100644 index 00000000..1e79a6e4 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/import.mjs @@ -0,0 +1,4 @@ +import './bridge.cjs'; + +export const ok = true; +export default { ok }; diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/package.json b/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/package.json new file mode 100644 index 00000000..2405acca --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/package.json @@ -0,0 +1,13 @@ +{ + "name": "condition-target-no-cycle", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "import": "./import.mjs", + "module-sync": "./sync.mjs", + "require": "./sync.mjs", + "default": "./sync.mjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/sync.mjs b/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/sync.mjs new file mode 100644 index 00000000..73f3b99e --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/sync.mjs @@ -0,0 +1,2 @@ +export const ok = false; +export default { ok }; diff --git a/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/bridge.cjs b/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/bridge.cjs new file mode 100644 index 00000000..a6d2f8e8 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/bridge.cjs @@ -0,0 +1 @@ +module.exports = require('./esm.mjs'); diff --git a/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/esm.mjs b/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/esm.mjs new file mode 100644 index 00000000..846c1638 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/esm.mjs @@ -0,0 +1,4 @@ +import bridge from './bridge.cjs'; + +export const bridgeOutcome = bridge && bridge.outcome; +export default { bridgeOutcome }; diff --git a/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/index.cjs b/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/index.cjs new file mode 100644 index 00000000..573eb793 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/index.cjs @@ -0,0 +1,6 @@ +try { + require('./esm.mjs'); + exports.outcome = 'no-error'; +} catch (error) { + exports.outcome = error && (error.code || error.name); +} diff --git a/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/package.json b/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/package.json new file mode 100644 index 00000000..3b2b1c9a --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/package.json @@ -0,0 +1,5 @@ +{ + "name": "cycle-require-esm", + "version": "1.0.0", + "main": "index.cjs" +} diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/default.mjs b/tests/installed_apps/apps/module-interop/packages/dual-exports/default.mjs new file mode 100644 index 00000000..eab91ee3 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/dual-exports/default.mjs @@ -0,0 +1,2 @@ +export const mode = 'default'; +export default { mode }; diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/feature.cjs b/tests/installed_apps/apps/module-interop/packages/dual-exports/feature.cjs new file mode 100644 index 00000000..d9dcd8ac --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/dual-exports/feature.cjs @@ -0,0 +1 @@ +exports.featureMode = 'feature-require'; diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/feature.mjs b/tests/installed_apps/apps/module-interop/packages/dual-exports/feature.mjs new file mode 100644 index 00000000..d1d3078f --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/dual-exports/feature.mjs @@ -0,0 +1 @@ +export const featureMode = 'feature-import'; diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/import.mjs b/tests/installed_apps/apps/module-interop/packages/dual-exports/import.mjs new file mode 100644 index 00000000..2c7b71c5 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/dual-exports/import.mjs @@ -0,0 +1,2 @@ +export const mode = 'import'; +export default { mode }; diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/package.json b/tests/installed_apps/apps/module-interop/packages/dual-exports/package.json new file mode 100644 index 00000000..bc23768e --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/dual-exports/package.json @@ -0,0 +1,17 @@ +{ + "name": "dual-exports", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "import": "./import.mjs", + "module-sync": "./sync.mjs", + "require": "./require.cjs", + "default": "./default.mjs" + }, + "./feature": { + "import": "./feature.mjs", + "require": "./feature.cjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/require.cjs b/tests/installed_apps/apps/module-interop/packages/dual-exports/require.cjs new file mode 100644 index 00000000..475cbcb0 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/dual-exports/require.cjs @@ -0,0 +1 @@ +exports.mode = 'require'; diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/sync.mjs b/tests/installed_apps/apps/module-interop/packages/dual-exports/sync.mjs new file mode 100644 index 00000000..81a8c207 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/dual-exports/sync.mjs @@ -0,0 +1,2 @@ +export const mode = 'module-sync'; +export default { mode }; diff --git a/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/bridge.cjs b/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/bridge.cjs new file mode 100644 index 00000000..120f861b --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/bridge.cjs @@ -0,0 +1 @@ +module.exports = require('./entry.mjs'); diff --git a/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/entry.mjs b/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/entry.mjs new file mode 100644 index 00000000..1adafe03 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/entry.mjs @@ -0,0 +1,7 @@ +import { createRequire as makeRequire } from 'node:module'; + +const req = makeRequire(import.meta.url); +const bridge = req('./bridge.cjs'); + +export const value = bridge.value; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/package.json b/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/package.json new file mode 100644 index 00000000..c09f3e72 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/package.json @@ -0,0 +1,12 @@ +{ + "name": "esm-alias-create-require-cycle", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "module-sync": "./entry.mjs", + "require": "./entry.mjs", + "default": "./entry.mjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/bridge.cjs b/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/bridge.cjs new file mode 100644 index 00000000..bcfd1873 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/bridge.cjs @@ -0,0 +1 @@ +module.exports = require('./ready.mjs'); diff --git a/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/entry.mjs b/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/entry.mjs new file mode 100644 index 00000000..c21c9866 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/entry.mjs @@ -0,0 +1,5 @@ +import './ready.mjs'; +import './bridge.cjs'; + +export const value = 'entry'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/package.json b/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/package.json new file mode 100644 index 00000000..a3b48c04 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/package.json @@ -0,0 +1,12 @@ +{ + "name": "esm-already-evaluated", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "module-sync": "./entry.mjs", + "require": "./entry.mjs", + "default": "./entry.mjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/ready.mjs b/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/ready.mjs new file mode 100644 index 00000000..753e004b --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/ready.mjs @@ -0,0 +1,2 @@ +export const value = 'ready'; +export default { value }; diff --git a/tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/entry.mjs b/tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/entry.mjs new file mode 100644 index 00000000..c5d94737 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/entry.mjs @@ -0,0 +1,18 @@ +const obj = { + require() { + return { ok: true }; + }, +}; + +const req = createRequire; + +function createRequire() { + return () => ({ ok: true }); +} + +const localRequire = createRequire(); + +export const propertyRequireResult = obj.require('./entry.mjs'); +export const nonCallCreateRequireAlias = typeof req === 'function' ? { ok: true } : { ok: false }; +export const localCreateRequireResult = localRequire('./entry.mjs'); +export default { propertyRequireResult, nonCallCreateRequireAlias, localCreateRequireResult }; diff --git a/tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/package.json b/tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/package.json new file mode 100644 index 00000000..0dd21122 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/package.json @@ -0,0 +1,12 @@ +{ + "name": "esm-false-positive-scanner", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "module-sync": "./entry.mjs", + "import": "./entry.mjs", + "default": "./entry.mjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/esm-sync/index.mjs b/tests/installed_apps/apps/module-interop/packages/esm-sync/index.mjs new file mode 100644 index 00000000..b5eb54a8 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/esm-sync/index.mjs @@ -0,0 +1,6 @@ +export const answer = 42; +export const named = 'named'; + +export default function esmSyncDefault() { + return 'default-call'; +} diff --git a/tests/installed_apps/apps/module-interop/packages/esm-sync/package.json b/tests/installed_apps/apps/module-interop/packages/esm-sync/package.json new file mode 100644 index 00000000..2b0ddac2 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/esm-sync/package.json @@ -0,0 +1,6 @@ +{ + "name": "esm-sync", + "version": "1.0.0", + "type": "module", + "exports": "./index.mjs" +} diff --git a/tests/installed_apps/apps/module-interop/packages/imports-alias/dep.cjs b/tests/installed_apps/apps/module-interop/packages/imports-alias/dep.cjs new file mode 100644 index 00000000..c35f3997 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/imports-alias/dep.cjs @@ -0,0 +1 @@ +module.exports = { value: 'aliased-dependency' }; diff --git a/tests/installed_apps/apps/module-interop/packages/imports-alias/index.mjs b/tests/installed_apps/apps/module-interop/packages/imports-alias/index.mjs new file mode 100644 index 00000000..cab6cfcb --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/imports-alias/index.mjs @@ -0,0 +1,4 @@ +import dep from '#dep'; + +export const aliasValue = dep.value; +export default dep; diff --git a/tests/installed_apps/apps/module-interop/packages/imports-alias/package.json b/tests/installed_apps/apps/module-interop/packages/imports-alias/package.json new file mode 100644 index 00000000..2a451d6c --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/imports-alias/package.json @@ -0,0 +1,9 @@ +{ + "name": "imports-alias", + "version": "1.0.0", + "type": "module", + "exports": "./index.mjs", + "imports": { + "#dep": "./dep.cjs" + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/tla-esm/index.mjs b/tests/installed_apps/apps/module-interop/packages/tla-esm/index.mjs new file mode 100644 index 00000000..7a0356b7 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/tla-esm/index.mjs @@ -0,0 +1,2 @@ +await Promise.resolve(); +export const value = 'ready-after-tla'; diff --git a/tests/installed_apps/apps/module-interop/packages/tla-esm/package.json b/tests/installed_apps/apps/module-interop/packages/tla-esm/package.json new file mode 100644 index 00000000..1bcab0b5 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/tla-esm/package.json @@ -0,0 +1,6 @@ +{ + "name": "tla-esm", + "version": "1.0.0", + "type": "module", + "exports": "./index.mjs" +} diff --git a/tests/installed_apps/apps/module-interop/run-node.mjs b/tests/installed_apps/apps/module-interop/run-node.mjs new file mode 100644 index 00000000..e19f083f --- /dev/null +++ b/tests/installed_apps/apps/module-interop/run-node.mjs @@ -0,0 +1,23 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const testPath = process.argv[2]; +if (!testPath) { + console.error('Usage: node run-node.mjs '); + process.exit(1); +} + +const mod = testPath.endsWith('.cjs') + ? createRequire(import.meta.url)(`./${testPath}`) + : await import(pathToFileURL(new URL(testPath, import.meta.url).pathname).href); + +const run = mod.run || mod.default?.run; +if (typeof run !== 'function') { + throw new Error(`${testPath} does not export run()`); +} + +const result = await run(); +console.log(result); +if (typeof result !== 'string' || !result.startsWith('PASS:')) { + process.exit(1); +} diff --git a/tests/installed_apps/apps/module-interop/test-01-esm-import-cjs.js b/tests/installed_apps/apps/module-interop/test-01-esm-import-cjs.js new file mode 100644 index 00000000..c7e8390d --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-01-esm-import-cjs.js @@ -0,0 +1,13 @@ +import assert from 'node:assert'; +import cjsDefault, { alpha, bracketed } from 'cjs-basic'; +import reexported, { alpha as reexportedAlpha, bracketed as reexportedBracketed } from 'cjs-reexport-pkg'; + +export const run = () => { + assert.strictEqual(cjsDefault.alpha, 'alpha'); + assert.strictEqual(alpha, 'alpha'); + assert.strictEqual(bracketed, 'bracketed'); + assert.strictEqual(reexported.alpha, 'alpha'); + assert.strictEqual(reexportedAlpha, 'alpha'); + assert.strictEqual(reexportedBracketed, 'bracketed'); + return 'PASS: ESM imports named/default exports from installed CJS packages'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-02-cjs-require-esm.cjs b/tests/installed_apps/apps/module-interop/test-02-cjs-require-esm.cjs new file mode 100644 index 00000000..68a799a7 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-02-cjs-require-esm.cjs @@ -0,0 +1,11 @@ +const assert = require('node:assert'); + +exports.run = () => { + const esm = require('esm-sync'); + const esmDefault = typeof esm === 'function' ? esm : esm.default; + assert.strictEqual(typeof esmDefault, 'function'); + assert.strictEqual(esmDefault(), 'default-call'); + assert.strictEqual(esm.answer, 42); + assert.strictEqual(esm.named, 'named'); + return 'PASS: CJS requires an installed synchronous ESM package'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-03-package-exports-imports.js b/tests/installed_apps/apps/module-interop/test-03-package-exports-imports.js new file mode 100644 index 00000000..6a296d79 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-03-package-exports-imports.js @@ -0,0 +1,13 @@ +import assert from 'node:assert'; +import dualDefault, { mode as dualMode } from 'dual-exports'; +import { featureMode } from 'dual-exports/feature'; +import aliasDefault, { aliasValue } from 'imports-alias'; + +export const run = () => { + assert.strictEqual(dualDefault.mode, 'import'); + assert.strictEqual(dualMode, 'import'); + assert.strictEqual(featureMode, 'feature-import'); + assert.strictEqual(aliasDefault.value, 'aliased-dependency'); + assert.strictEqual(aliasValue, 'aliased-dependency'); + return 'PASS: package exports, subpaths, and imports aliases work from installed node_modules'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-04-cycle-require-esm.cjs b/tests/installed_apps/apps/module-interop/test-04-cycle-require-esm.cjs new file mode 100644 index 00000000..2b7df70e --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-04-cycle-require-esm.cjs @@ -0,0 +1,7 @@ +const assert = require('node:assert'); + +exports.run = () => { + const cycle = require('cycle-require-esm'); + assert.strictEqual(cycle.outcome, 'ERR_REQUIRE_CYCLE_MODULE'); + return 'PASS: installed package CJS require(esm) cycle reports ERR_REQUIRE_CYCLE_MODULE'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-05-tla-require.cjs b/tests/installed_apps/apps/module-interop/test-05-tla-require.cjs new file mode 100644 index 00000000..cf5a2da5 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-05-tla-require.cjs @@ -0,0 +1,8 @@ +const assert = require('node:assert'); + +exports.run = async () => { + assert.throws(() => require('tla-esm'), { code: 'ERR_REQUIRE_ASYNC_MODULE' }); + const imported = await import('tla-esm'); + assert.strictEqual(imported.value, 'ready-after-tla'); + return 'PASS: installed TLA ESM rejects require() and still supports dynamic import'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-06-conditional-import-graph.cjs b/tests/installed_apps/apps/module-interop/test-06-conditional-import-graph.cjs new file mode 100644 index 00000000..3aa9d0aa --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-06-conditional-import-graph.cjs @@ -0,0 +1,6 @@ +const assert = require('node:assert'); + +exports.run = () => { + assert.throws(() => require('condition-entry-import-cycle'), { code: 'ERR_REQUIRE_CYCLE_MODULE' }); + return 'PASS: require(esm) graph scanning follows package import conditions for static ESM imports'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-07-conditional-import-no-false-positive.cjs b/tests/installed_apps/apps/module-interop/test-07-conditional-import-no-false-positive.cjs new file mode 100644 index 00000000..5a6760d1 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-07-conditional-import-no-false-positive.cjs @@ -0,0 +1,8 @@ +const assert = require('node:assert'); + +exports.run = () => { + const result = require('condition-entry-no-cycle'); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.default.ok, true); + return 'PASS: require(esm) graph scanning does not mark package module-sync branches for static ESM imports'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-08-conditional-imports-alias-graph.cjs b/tests/installed_apps/apps/module-interop/test-08-conditional-imports-alias-graph.cjs new file mode 100644 index 00000000..e1aa0d3d --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-08-conditional-imports-alias-graph.cjs @@ -0,0 +1,6 @@ +const assert = require('node:assert'); + +exports.run = () => { + assert.throws(() => require('condition-entry-imports-cycle'), { code: 'ERR_REQUIRE_CYCLE_MODULE' }); + return 'PASS: require(esm) graph scanning follows package imports aliases with import conditions'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-09-create-require-alias-cycle.cjs b/tests/installed_apps/apps/module-interop/test-09-create-require-alias-cycle.cjs new file mode 100644 index 00000000..00e554fd --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-09-create-require-alias-cycle.cjs @@ -0,0 +1,6 @@ +const assert = require('node:assert'); + +exports.run = () => { + assert.throws(() => require('esm-alias-create-require-cycle'), { code: 'ERR_REQUIRE_CYCLE_MODULE' }); + return 'PASS: require(esm) graph scanning handles createRequire alias cycles'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-10-already-evaluated-dependency.cjs b/tests/installed_apps/apps/module-interop/test-10-already-evaluated-dependency.cjs new file mode 100644 index 00000000..80d06543 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-10-already-evaluated-dependency.cjs @@ -0,0 +1,8 @@ +const assert = require('node:assert'); + +exports.run = () => { + const result = require('esm-already-evaluated'); + assert.strictEqual(result.value, 'entry'); + assert.strictEqual(result.default.value, 'entry'); + return 'PASS: CJS bridge can require an already evaluated ESM dependency'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-11-module-sync-before-import-graph.cjs b/tests/installed_apps/apps/module-interop/test-11-module-sync-before-import-graph.cjs new file mode 100644 index 00000000..29c2d873 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-11-module-sync-before-import-graph.cjs @@ -0,0 +1,6 @@ +const assert = require('node:assert'); + +exports.run = () => { + assert.throws(() => require('condition-entry-module-sync-cycle'), { code: 'ERR_REQUIRE_CYCLE_MODULE' }); + return 'PASS: require(esm) graph scanning honors module-sync before import in exports'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-12-module-sync-before-imports-alias-graph.cjs b/tests/installed_apps/apps/module-interop/test-12-module-sync-before-imports-alias-graph.cjs new file mode 100644 index 00000000..04d1733e --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-12-module-sync-before-imports-alias-graph.cjs @@ -0,0 +1,6 @@ +const assert = require('node:assert'); + +exports.run = () => { + assert.throws(() => require('condition-entry-module-sync-imports-cycle'), { code: 'ERR_REQUIRE_CYCLE_MODULE' }); + return 'PASS: require(esm) graph scanning honors module-sync before import in package imports'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-13-scanner-false-positive-guards.cjs b/tests/installed_apps/apps/module-interop/test-13-scanner-false-positive-guards.cjs new file mode 100644 index 00000000..0f7e0696 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-13-scanner-false-positive-guards.cjs @@ -0,0 +1,12 @@ +const assert = require('node:assert'); + +exports.run = () => { + const scanner = require('esm-false-positive-scanner'); + assert.strictEqual(scanner.propertyRequireResult.ok, true); + assert.strictEqual(scanner.nonCallCreateRequireAlias.ok, true); + assert.strictEqual(scanner.localCreateRequireResult.ok, true); + + const cjsWithNestedRequire = require('cjs-nested-require-pkg'); + assert.deepStrictEqual(cjsWithNestedRequire, { ok: true }); + return 'PASS: graph scanners avoid property require, non-call createRequire, local createRequire, and nested CJS require false positives'; +}; diff --git a/tests/installed_apps/config.jsonc b/tests/installed_apps/config.jsonc new file mode 100644 index 00000000..91a0774f --- /dev/null +++ b/tests/installed_apps/config.jsonc @@ -0,0 +1,23 @@ +{ + "apps": { + "module-interop": { + "category": "runnable", + "reason": "Synthetic npm-installed app covering CJS/ESM/package graph behavior", + "tests": { + "test-01-esm-import-cjs.js": "ESM app imports named/default exports from installed CJS packages", + "test-02-cjs-require-esm.cjs": "CJS app requires an installed synchronous ESM package", + "test-03-package-exports-imports.js": "Installed packages use conditional exports, subpaths, and package imports aliases", + "test-04-cycle-require-esm.cjs": "CJS require(esm) cycle inside an installed package reports ERR_REQUIRE_CYCLE_MODULE", + "test-05-tla-require.cjs": "CJS require() of installed TLA ESM reports ERR_REQUIRE_ASYNC_MODULE and dynamic import still works", + "test-06-conditional-import-graph.cjs": "Graph scanning follows import conditions for static ESM package imports", + "test-07-conditional-import-no-false-positive.cjs": "Graph scanning does not mark module-sync branches for static ESM package imports", + "test-08-conditional-imports-alias-graph.cjs": "Graph scanning follows import conditions for package imports aliases", + "test-09-create-require-alias-cycle.cjs": "Graph scanning handles createRequire aliases in ESM modules", + "test-10-already-evaluated-dependency.cjs": "CJS bridge can require an already evaluated ESM dependency", + "test-11-module-sync-before-import-graph.cjs": "Graph scanning honors module-sync before import in package exports", + "test-12-module-sync-before-imports-alias-graph.cjs": "Graph scanning honors module-sync before import in package imports aliases", + "test-13-scanner-false-positive-guards.cjs": "Graph scanning avoids property require, non-call createRequire, local createRequire, and nested CJS require false positives" + } + } + } +} diff --git a/tests/installed_apps/report.md b/tests/installed_apps/report.md new file mode 100644 index 00000000..ca1ebbc6 --- /dev/null +++ b/tests/installed_apps/report.md @@ -0,0 +1,36 @@ +# Installed App Compatibility Report + +This report tracks compatibility for unbundled npm-installed apps attached to the component as a filesystem. It is intentionally separate from `tests/libraries/libraries.md`, which tests Rollup-bundled library usage. + +## Scope + +| Included | Deferred | +|---|---| +| Pure JavaScript packages installed with npm | Native `.node` bindings | +| `node_modules` package resolution | Packages that load WASM artifacts | +| package `exports` / `imports` | Child process execution | +| CJS/ESM interop and same-process cycles | CLI preload/eval/warning behavior | + +## Apps + +| App | Status | Tests | Notes | +|---|---:|---:|---| +| `module-interop` | Passing | 13/13 | Synthetic local npm packages covering module loader interop behavior. Verifies npm install with `--install-links`, Node baseline execution, then wasm-rquickjs execution from an attached `/app/node_modules` filesystem. | + +## `module-interop` + +| Test | Status | Coverage | +|---|---:|---| +| `test-01-esm-import-cjs.js` | Passing | ESM app imports named/default exports from installed CJS packages, including a package reexport. | +| `test-02-cjs-require-esm.cjs` | Passing | CJS app requires an installed synchronous ESM package. | +| `test-03-package-exports-imports.js` | Passing | Conditional package `exports`, subpath exports, and package `imports` aliases. | +| `test-04-cycle-require-esm.cjs` | Passing | Installed package CJS `require(esm)` cycle reports `ERR_REQUIRE_CYCLE_MODULE`. | +| `test-05-tla-require.cjs` | Passing | Installed TLA ESM rejects CJS `require()` with `ERR_REQUIRE_ASYNC_MODULE`; dynamic import still works. | +| `test-06-conditional-import-graph.cjs` | Passing | Static ESM package imports in the graph use import conditions when detecting cycles. | +| `test-07-conditional-import-no-false-positive.cjs` | Passing | Static ESM package imports do not pre-mark module-sync branches and reject valid graphs. | +| `test-08-conditional-imports-alias-graph.cjs` | Passing | Package `imports` aliases in ESM use import conditions when detecting cycles. | +| `test-09-create-require-alias-cycle.cjs` | Passing | ESM `createRequire` alias bridges participate in cycle detection. | +| `test-10-already-evaluated-dependency.cjs` | Passing | CJS bridge can require an already evaluated ESM dependency. | +| `test-11-module-sync-before-import-graph.cjs` | Passing | Package `exports` with `module-sync` before `import` are scanned in Node-compatible condition order. | +| `test-12-module-sync-before-imports-alias-graph.cjs` | Passing | Package `imports` aliases with `module-sync` before `import` are scanned in Node-compatible condition order. | +| `test-13-scanner-false-positive-guards.cjs` | Passing | Graph scanners avoid property require, non-call createRequire, local createRequire, and nested CJS require false positives. | diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 28a6cb6f..ea21e65d 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5876,7 +5876,7 @@ "es-module/test-require-module-cycle-cjs-esm-esm.js": {}, "es-module/test-require-module-cycle-esm-cjs-esm-esm.js": { "category": "known-gap", - "reason": "QuickJS module system does not support ESM-CJS interop cycle detection", + "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct installed-app same-process module graph coverage lives in tests/installed_apps", "split": true, "subtests": { "block_00_a_mjs_b_cjs_c_mjs_a_mjs": {}, @@ -5886,7 +5886,7 @@ }, "es-module/test-require-module-cycle-esm-cjs-esm.js": { "category": "known-gap", - "reason": "QuickJS module system does not support ESM-CJS interop cycle detection", + "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct installed-app same-process module graph coverage lives in tests/installed_apps", "split": true, "subtests": { "block_00_require_a_cjs_a_mjs_b_cjs_a_mjs": {}, @@ -5897,7 +5897,7 @@ }, "es-module/test-require-module-cycle-esm-esm-cjs-esm-esm.js": { "category": "known-gap", - "reason": "QuickJS module system does not support ESM-CJS interop cycle detection", + "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct installed-app same-process module graph coverage lives in tests/installed_apps", "split": true, "subtests": { "block_00_a_mjs_b_mjs_c_cjs_z_mjs_a_mjs": {}, @@ -5908,7 +5908,7 @@ }, "es-module/test-require-module-cycle-esm-esm-cjs-esm.js": { "category": "known-gap", - "reason": "require()/import cycle handling in ESM graphs is incomplete (missing ERR_REQUIRE_CYCLE_MODULE and can hit QuickJS linker assert)", + "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stdout/stderr diagnostics; one TLA/dynamic-import sequencing case can still hit a QuickJS linker assert through process.execPath emulation, but direct same-process installed-app coverage passes", "split": true, "subtests": { "block_00_a_mjs_b_mjs_c_mjs_d_mjs_c_mjs": {}, @@ -5932,14 +5932,14 @@ "es-module/test-require-module-dynamic-import-2.js": {}, "es-module/test-require-module-dynamic-import-3.js": {}, "es-module/test-require-module-dynamic-import-4.js": {}, - "es-module/test-require-module-error-catching.js": { "category": "known-gap", "reason": "one .js fixture is still accepted by the CommonJS wrapper instead of being detected as ESM and throwing the expected ReferenceError" }, + "es-module/test-require-module-error-catching.js": {}, "es-module/test-require-module-errors.js": { "category": "engine-difference", "reason": "asserts V8-specific syntax error stderr text/format that differs in QuickJS" }, "es-module/test-require-module-feature-detect.js": {}, "es-module/test-require-module-implicit.js": {}, "es-module/test-require-module-preload.js": { "category": "known-gap", "reason": "child_process execPath emulation lacks full --import/--require preload semantics" }, "es-module/test-require-module-retry-import-errored.js": {}, "es-module/test-require-module-retry-import-evaluating.js": {}, - "es-module/test-require-module-synchronous-rejection-handling.js": { "category": "known-gap", "reason": "require(esm) rejection handling does not match Node behavior (unexpected unhandledRejection)" }, + "es-module/test-require-module-synchronous-rejection-handling.js": {}, "es-module/test-require-module-tla-retry-import-2.js": {}, "es-module/test-require-module-tla-retry-import.js": {}, "es-module/test-require-module-tla-retry-require.js": {}, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index c29b8c74..9afde1cf 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -1,6 +1,6 @@ # Node.js v22.14.0 Compatibility Inventory -Generated: 2026-06-15 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) +Generated: 2026-06-16 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) This report is generated from `config.jsonc` only. It does **not** run the vendored tests itself. Entries classified as `runnable` are reported as passing because the `node_compat` PR test executes runnable entries and fails CI if any of them fail. @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3100/4295 (72.2%) +**Primary compatibility (CI-enforced):** 3102/4295 (72.2%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3100 | 72.2% | 55.3% | 46.1% | -| 🧩 known gap | 1195 | 27.8% | 21.3% | 17.8% | +| ✅ passing (runnable) | 3102 | 72.2% | 55.3% | 46.1% | +| 🧩 known gap | 1193 | 27.8% | 21.3% | 17.7% | | 🚫 WASI-impossible (excluded) | 1153 | — | 20.6% | 17.1% | | ⚙️ engine difference (excluded) | 162 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1121 | — | — | 16.7% | | **Total** | **6731** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3100/5610 (55.3%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3102/5610 (55.3%)**. ## Inventory by Module @@ -53,7 +53,7 @@ Secondary full-public compatibility, including public tests that are currently e | http | 898 | 243 | 306 | 267 | 2 | 0 | 80 | 44.3% | 29.7% | | inspector | 95 | 1 | 0 | 93 | 0 | 0 | 1 | 100.0% | 1.1% | | internal | 53 | 1 | 0 | 0 | 0 | 0 | 52 | 100.0% | 100.0% | -| module | 184 | 118 | 46 | 7 | 1 | 0 | 12 | 72.0% | 68.6% | +| module | 184 | 120 | 44 | 7 | 1 | 0 | 12 | 73.2% | 69.8% | | net | 223 | 150 | 36 | 19 | 1 | 0 | 17 | 80.6% | 72.8% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | @@ -680,7 +680,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1195) +### known gap (1193) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -691,10 +691,10 @@ Secondary full-public compatibility, including public tests that are currently e | domain module depends on async_hooks, not fully working | 13 | `parallel/test-domain-promise.js#block_00_block_00`, `parallel/test-domain-promise.js#block_01_block_01`, `parallel/test-domain-promise.js#block_03_block_03`, ... (+10) | | inherited: dns.getServers()/setServers default-server behavior and validation are not Node-compatible | 12 | `parallel/test-dns.js#block_00_verify_that_setservers_handles_arrays_with_holes_and_other_o`, `parallel/test-dns.js#block_01_block_01`, `parallel/test-dns.js#block_02_block_02`, ... (+9) | | node:readline module is not yet supported in WebAssembly environment | 12 | `parallel/test-readline-keys.js`, `parallel/test-readline-position.js`, `parallel/test-readline-reopen.js`, ... (+9) | -| QuickJS module system does not support ESM-CJS interop cycle detection | 11 | `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_00_a_mjs_b_cjs_c_mjs_a_mjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_01_b_cjs_c_mjs_a_mjs_b_cjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_02_c_mjs_a_mjs_b_cjs_c_mjs`, ... (+8) | | full script module-loading test still exposes incomplete main-module/cache/package-main edge semantics | 11 | `sequential/test-module-loading.js#block_00_block_00`, `sequential/test-module-loading.js#block_01_block_01`, `sequential/test-module-loading.js#block_02_block_02`, ... (+8) | | inherited: process.permission and --permission CLI semantics are incomplete in execPath emulation | 11 | `parallel/test-permission-allow-child-process-cli.js#block_00_guarantee_the_initial_state`, `parallel/test-permission-allow-child-process-cli.js#block_01_to_spawn_unless_allow_child_process_is_sent`, `parallel/test-permission-allow-wasi-cli.js#block_00_guarantee_the_initial_state`, ... (+8) | | net.js TCP implementation incomplete - needs event handling and API fixes | 11 | `parallel/test-net-connect-nodelay.js`, `parallel/test-net-connect-paused-connection.js`, `parallel/test-net-during-close.js`, ... (+8) | +| remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct installed-app same-process module graph coverage lives in tests/installed_apps | 11 | `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_00_a_mjs_b_cjs_c_mjs_a_mjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_01_b_cjs_c_mjs_a_mjs_b_cjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_02_c_mjs_a_mjs_b_cjs_c_mjs`, ... (+8) | | wasi:sockets UDP implementation hangs in wasmtime | 11 | `parallel/test-dgram-implicit-bind.js`, `parallel/test-dgram-multicast-set-interface.js#block_00_block_00`, `parallel/test-dgram-multicast-set-interface.js#block_02_block_02`, ... (+8) | | dgram multicast membership APIs are not implemented (ENOSYS) | 10 | `parallel/test-dgram-membership.js#block_02_addmembership_with_no_argument_should_throw`, `parallel/test-dgram-membership.js#block_03_dropmembership_with_no_argument_should_throw`, `parallel/test-dgram-membership.js#block_04_addmembership_with_invalid_multicast_address_should_throw`, ... (+7) | | async_hooks not fully implemented | 9 | `parallel/test-async-hooks-destroy-on-gc.js`, `parallel/test-async-hooks-disable-during-promise.js`, `parallel/test-async-hooks-disable-gc-tracking.js`, ... (+6) | @@ -727,7 +727,7 @@ Secondary full-public compatibility, including public tests that are currently e | isMarkedAsUntransferable() and related mark/query behavior are incomplete | 4 | `parallel/test-worker-message-transfer-port-mark-as-untransferable.js#block_00_block_00`, `parallel/test-worker-message-transfer-port-mark-as-untransferable.js#block_01_block_01`, `parallel/test-worker-message-transfer-port-mark-as-untransferable.js#block_02_block_02`, ... (+1) | | markAsUncloneable and DataCloneError semantics are incomplete | 4 | `parallel/test-worker-message-mark-as-uncloneable.js#block_00_uncloneables_cannot_be_cloned_during_message_posting`, `parallel/test-worker-message-mark-as-uncloneable.js#block_01_uncloneables_cannot_be_cloned_during_structured_cloning`, `parallel/test-worker-message-mark-as-uncloneable.js#block_02_markasuncloneable_cannot_affect_arraybuffer`, ... (+1) | | promisified exec()/execFile() contract is incomplete (promise.child is not a ChildProcess instance) | 4 | `parallel/test-child-process-promisified.js#block_00_block_00`, `parallel/test-child-process-promisified.js#block_01_block_01`, `parallel/test-child-process-promisified.js#block_02_block_02`, ... (+1) | -| require()/import cycle handling in ESM graphs is incomplete (missing ERR_REQUIRE_CYCLE_MODULE and can hit QuickJS linker assert) | 4 | `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_00_a_mjs_b_mjs_c_mjs_d_mjs_c_mjs`, `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_01_b_mjs_c_mjs_d_mjs_c_mjs`, `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_02_c_mjs_d_mjs_c_mjs`, ... (+1) | +| remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stdout/stderr diagnostics; one TLA/dynamic-import sequencing case can still hit a QuickJS linker assert through process.execPath emulation, but direct same-process installed-app coverage passes | 4 | `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_00_a_mjs_b_mjs_c_mjs_d_mjs_c_mjs`, `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_01_b_mjs_c_mjs_d_mjs_c_mjs`, `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_02_c_mjs_d_mjs_c_mjs`, ... (+1) | | timeout enforcement with microtaskMode='afterEvaluate' is incomplete | 4 | `parallel/test-vm-timeout-escape-promise-2.js`, `parallel/test-vm-timeout-escape-promise-module.js`, `parallel/test-vm-timeout-escape-promise.js`, ... (+1) | | unhandled-rejection mode and uncaughtException bridging semantics are incomplete | 4 | `parallel/test-promise-unhandled-default.js`, `parallel/test-promise-unhandled-error.js`, `parallel/test-promise-unhandled-throw-handler.js`, ... (+1) | | wasi:http client does not surface 103 Early Hints as 'information' events | 4 | `parallel/test-http-early-hints.js#block_00_block_00`, `parallel/test-http-early-hints.js#block_01_block_01`, `parallel/test-http-early-hints.js#block_03_block_03`, ... (+1) | @@ -1175,7 +1175,6 @@ Secondary full-public compatibility, including public tests that are currently e | node_compat harness does not provide ../common/shared-lib-util for this test setup | 1 | `parallel/test-module-loading-globalpaths.js` | | node_compat test fixture module ../common/process-exit-code-cases is not resolved in this runtime | 1 | `parallel/test-process-exit-code.js` | | non-writable global property semantics in vm contexts are incomplete | 1 | `parallel/test-vm-global-non-writable-properties.js` | -| one .js fixture is still accepted by the CommonJS wrapper instead of being detected as ESM and throwing the expected ReferenceError | 1 | `es-module/test-require-module-error-catching.js` | | options.agent validation/lifecycle is not fully Node-compatible | 1 | `parallel/test-http-client-reject-unexpected-agent.js` | | passive listener semantics are incomplete (test currently self-skips) | 1 | `parallel/test-whatwg-events-add-event-listener-options-passive.js#block_01_block_01` | | per-context Symbol/global binding behavior is incomplete in vm contexts | 1 | `parallel/test-vm-harmony-symbols.js` | @@ -1221,7 +1220,6 @@ Secondary full-public compatibility, including public tests that are currently e | request drain captureRejections path hangs when request is never finalized with end() under wasi:http | 1 | `parallel/test-http-outgoing-message-capture-rejection.js#block_01_block_01` | | request header population/normalization (for example Accept) is incomplete | 1 | `parallel/test-http.js` | | request/response pause-resume flow control does not complete with Node-compatible behavior | 1 | `parallel/test-http-pause.js` | -| require(esm) rejection handling does not match Node behavior (unexpected unhandledRejection) | 1 | `es-module/test-require-module-synchronous-rejection-handling.js` | | requires ERR_INVALID_ARG_TYPE validation on resolve methods (not yet implemented) | 1 | `parallel/test-dns-resolvens-typeerror.js` | | requires HTTP server functionality, we only support clients | 1 | `parallel/test-diagnostic-channel-http-response-created.js` | | requires Intl/timezone data support that is not available in the current runtime | 1 | `parallel/test-datetime-change-notify.js` | diff --git a/tests/runtime/installed_apps.rs b/tests/runtime/installed_apps.rs new file mode 100644 index 00000000..52878ee4 --- /dev/null +++ b/tests/runtime/installed_apps.rs @@ -0,0 +1,268 @@ +use crate::common::{CompiledTest, TestInstance, copy_dir_recursive}; +use camino::{Utf8Path, Utf8PathBuf}; +use camino_tempfile::Utf8TempDir; +use std::fs; +use std::process::Command; +use test_r::{test, test_dep}; +use wasmtime::component::Val; + +#[test_dep(tagged_as = "installed_app_runner", scope = Cloneable)] +async fn compiled_installed_app_runner() -> CompiledTest { + let path = Utf8Path::new("examples/runtime/installed-app-runner"); + CompiledTest::new_with_features(path, true, crate::common::FeatureCombination::FullNoLogging) + .await + .expect("Failed to compile installed-app-runner") +} + +struct PreparedInstalledApp { + _temp_dir: Utf8TempDir, + app_dir: Utf8PathBuf, +} + +fn prepare_installed_app(app_name: &str) -> anyhow::Result { + let source_dir = Utf8Path::new("tests") + .join("installed_apps") + .join("apps") + .join(app_name); + let temp_dir = Utf8TempDir::new()?; + let app_dir = temp_dir.path().join("app"); + + copy_dir_recursive(source_dir.as_std_path(), app_dir.as_std_path())?; + + let status = Command::new("npm") + .arg("install") + .arg("--install-links") + .arg("--ignore-scripts") + .arg("--no-audit") + .arg("--no-fund") + .current_dir(&app_dir) + .status()?; + anyhow::ensure!(status.success(), "npm install failed for {app_name}"); + + Ok(PreparedInstalledApp { + _temp_dir: temp_dir, + app_dir, + }) +} + +fn ensure_node_supports_require_esm() -> anyhow::Result<()> { + let output = Command::new("node") + .arg("-p") + .arg("process.versions.node") + .output()?; + anyhow::ensure!( + output.status.success(), + "failed to determine host Node.js version: {}", + String::from_utf8_lossy(&output.stderr), + ); + let version = String::from_utf8_lossy(&output.stdout); + let mut parts = version.trim().split('.'); + let major = parts + .next() + .and_then(|part| part.parse::().ok()) + .unwrap_or(0); + let minor = parts + .next() + .and_then(|part| part.parse::().ok()) + .unwrap_or(0); + anyhow::ensure!( + major > 22 || (major == 22 && minor >= 14), + "installed-app Node baseline requires Node.js >= 22.14 for require(esm) and module-sync condition behavior; found {}", + version.trim(), + ); + Ok(()) +} + +fn verify_with_node(app: &PreparedInstalledApp, test_file: &str) -> anyhow::Result<()> { + ensure_node_supports_require_esm()?; + let output = Command::new("node") + .arg("run-node.mjs") + .arg(test_file) + .current_dir(&app.app_dir) + .output()?; + anyhow::ensure!( + output.status.success(), + "Node baseline failed for {}:\n[stdout]\n{}\n[stderr]\n{}", + test_file, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + Ok(()) +} + +async fn run_installed_app_test( + compiled_test: &CompiledTest, + app_name: &str, + test_file: &str, +) -> anyhow::Result<()> { + let app = prepare_installed_app(app_name)?; + verify_with_node(&app, test_file)?; + + let mut instance = TestInstance::new(compiled_test.wasm_path()).await?; + instance.set_epoch_deadline(120); + + let mounted_app_dir = instance.temp_dir_path().join("app"); + fs::create_dir_all(&mounted_app_dir)?; + copy_dir_recursive(app.app_dir.as_std_path(), mounted_app_dir.as_std_path())?; + + let guest_test_path = format!("/app/{test_file}"); + let (result, stdout, stderr) = instance + .invoke_and_capture_output_with_stderr(None, "run-test", &[Val::String(guest_test_path)]) + .await; + + let result = result?; + println!("Output:\n{}", stdout); + if !stderr.trim().is_empty() { + println!("Stderr:\n{}", stderr); + } + match result { + Some(Val::String(s)) if s.starts_with("PASS:") => Ok(()), + other => anyhow::bail!("Unexpected installed app result: {:?}", other), + } +} + +#[test] +async fn installed_app_esm_imports_cjs( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "module-interop", "test-01-esm-import-cjs.js").await +} + +#[test] +async fn installed_app_cjs_requires_esm( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-02-cjs-require-esm.cjs", + ) + .await +} + +#[test] +async fn installed_app_package_exports_imports( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-03-package-exports-imports.js", + ) + .await +} + +#[test] +async fn installed_app_require_esm_cycle( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-04-cycle-require-esm.cjs", + ) + .await +} + +#[test] +async fn installed_app_require_tla_esm( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "module-interop", "test-05-tla-require.cjs").await +} + +#[test] +async fn installed_app_conditional_import_graph( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-06-conditional-import-graph.cjs", + ) + .await +} + +#[test] +async fn installed_app_conditional_import_no_false_positive( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-07-conditional-import-no-false-positive.cjs", + ) + .await +} + +#[test] +async fn installed_app_conditional_imports_alias_graph( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-08-conditional-imports-alias-graph.cjs", + ) + .await +} + +#[test] +async fn installed_app_create_require_alias_cycle( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-09-create-require-alias-cycle.cjs", + ) + .await +} + +#[test] +async fn installed_app_already_evaluated_dependency( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-10-already-evaluated-dependency.cjs", + ) + .await +} + +#[test] +async fn installed_app_module_sync_before_import_graph( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-11-module-sync-before-import-graph.cjs", + ) + .await +} + +#[test] +async fn installed_app_module_sync_before_imports_alias_graph( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-12-module-sync-before-imports-alias-graph.cjs", + ) + .await +} + +#[test] +async fn installed_app_scanner_false_positive_guards( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-13-scanner-false-positive-guards.cjs", + ) + .await +} diff --git a/tests/runtime/main.rs b/tests/runtime/main.rs index 0a65d8bd..d669b102 100644 --- a/tests/runtime/main.rs +++ b/tests/runtime/main.rs @@ -27,6 +27,7 @@ mod export_from_inner_package; mod fetch; mod fs; mod imports; +mod installed_apps; mod intl; mod module_resolution; mod node_http; @@ -77,6 +78,7 @@ tag_suite!(sqlite, group6); tag_suite!(url, group7); tag_suite!(cjs_require, group7); tag_suite!(module_resolution, group7); +tag_suite!(installed_apps, group7); tag_suite!(timeout, group7); tag_suite!(buffer, group7); tag_suite!(bigint_roundtrip, group7); From 50ef20634dd68032c431120e5276c1c5ef8b5db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 17 Jun 2026 09:53:24 +0200 Subject: [PATCH 09/42] pattern key-based package export and import resolution, node app style library tests --- .../skeleton/src/builtin/module.js | 72 ++++- .../skeleton/src/builtin/node_http.js | 6 +- crates/wasm-rquickjs/skeleton/src/internal.rs | 115 ++++++- .../apps/cloud-sdk-offline/package.json | 10 + .../apps/cloud-sdk-offline/run-node.mjs | 19 ++ .../apps/cloud-sdk-offline/test-01-openai.mjs | 11 + .../cloud-sdk-offline/test-02-anthropic.mjs | 11 + .../apps/cloud-sdk-offline/test-03-aws-s3.mjs | 22 ++ .../apps/cloud-sdk-offline/test-04-stripe.cjs | 11 + .../apps/crypto-auth/package.json | 12 + .../apps/crypto-auth/run-node.mjs | 19 ++ .../test-01-jsonwebtoken-bcrypt.cjs | 15 + .../apps/crypto-auth/test-02-jose.mjs | 15 + .../crypto-auth/test-03-nanoid-cookie.mjs | 24 ++ .../apps/data-formats/package.json | 12 + .../apps/data-formats/run-node.mjs | 19 ++ .../apps/data-formats/test-01-csv.cjs | 12 + .../apps/data-formats/test-02-yaml-xml.cjs | 16 + .../data-formats/test-03-binary-protobuf.cjs | 16 + .../apps/db-clients-offline/package.json | 12 + .../apps/db-clients-offline/run-node.mjs | 19 ++ .../test-01-sql-builders.cjs | 12 + .../db-clients-offline/test-02-pg-mysql.cjs | 16 + .../test-03-mongodb-redis.mjs | 14 + .../db-clients-offline/test-04-drizzle.mjs | 17 + .../apps/fs-template-config/package.json | 12 + .../apps/fs-template-config/run-node.mjs | 19 ++ .../test-01-config-parsers.cjs | 14 + .../test-02-template-engines.cjs | 14 + .../test-03-fast-glob-fs.cjs | 16 + .../apps/http-clients/package.json | 11 + .../apps/http-clients/run-node.mjs | 19 ++ .../apps/http-clients/test-01-axios.cjs | 23 ++ .../apps/http-clients/test-02-fetch-ky.mjs | 13 + .../http-clients/test-03-graphql-request.mjs | 17 + .../apps/logging-observability/package.json | 11 + .../apps/logging-observability/run-node.mjs | 19 ++ .../logging-observability/test-01-loggers.cjs | 22 ++ .../test-02-consola-otel.mjs | 20 ++ .../apps/module-interop/package.json | 3 + .../packages/pattern-exports/cjs/gamma.cjs | 1 + .../packages/pattern-exports/package.json | 17 + .../packages/pattern-exports/src/alpha.mjs | 1 + .../pattern-exports/sync/beta-default.mjs | 1 + .../pattern-exports/sync/beta-import.mjs | 1 + .../pattern-exports/sync/beta-sync.mjs | 1 + .../packages/pattern-imports/index.cjs | 1 + .../pattern-imports/internal/value.cjs | 1 + .../pattern-imports/internal/value.mjs | 1 + .../packages/pattern-imports/package.json | 11 + .../_shims/auto/runtime-node.cjs | 1 + .../_shims/auto/runtime-node.mjs | 1 + .../pattern-shims/_shims/auto/runtime.mjs | 1 + .../packages/pattern-shims/package.json | 14 + .../test-14-exports-patterns.mjs | 13 + .../test-15-imports-patterns.cjs | 7 + .../module-interop/test-16-shim-patterns.mjs | 7 + .../apps/popular-pure-js/package.json | 17 + .../apps/popular-pure-js/run-node.mjs | 23 ++ .../popular-pure-js/test-01-cjs-utilities.cjs | 18 ++ .../popular-pure-js/test-02-modern-esm.mjs | 16 + .../test-03-date-fns-subpaths.mjs | 11 + .../popular-pure-js/test-04-dotenv-fs.cjs | 14 + .../apps/popular-pure-js/test-05-ajv.cjs | 19 ++ .../apps/popular-pure-js/test-06-rxjs.mjs | 14 + .../apps/validation-schema/package.json | 10 + .../apps/validation-schema/run-node.mjs | 19 ++ .../validation-schema/test-01-joi-yup.cjs | 14 + .../test-02-superstruct-valibot.mjs | 14 + tests/installed_apps/config.jsonc | 89 +++++- tests/installed_apps/report.md | 89 +++++- tests/runtime/installed_apps.rs | 296 ++++++++++++++++++ 72 files changed, 1483 insertions(+), 20 deletions(-) create mode 100644 tests/installed_apps/apps/cloud-sdk-offline/package.json create mode 100644 tests/installed_apps/apps/cloud-sdk-offline/run-node.mjs create mode 100644 tests/installed_apps/apps/cloud-sdk-offline/test-01-openai.mjs create mode 100644 tests/installed_apps/apps/cloud-sdk-offline/test-02-anthropic.mjs create mode 100644 tests/installed_apps/apps/cloud-sdk-offline/test-03-aws-s3.mjs create mode 100644 tests/installed_apps/apps/cloud-sdk-offline/test-04-stripe.cjs create mode 100644 tests/installed_apps/apps/crypto-auth/package.json create mode 100644 tests/installed_apps/apps/crypto-auth/run-node.mjs create mode 100644 tests/installed_apps/apps/crypto-auth/test-01-jsonwebtoken-bcrypt.cjs create mode 100644 tests/installed_apps/apps/crypto-auth/test-02-jose.mjs create mode 100644 tests/installed_apps/apps/crypto-auth/test-03-nanoid-cookie.mjs create mode 100644 tests/installed_apps/apps/data-formats/package.json create mode 100644 tests/installed_apps/apps/data-formats/run-node.mjs create mode 100644 tests/installed_apps/apps/data-formats/test-01-csv.cjs create mode 100644 tests/installed_apps/apps/data-formats/test-02-yaml-xml.cjs create mode 100644 tests/installed_apps/apps/data-formats/test-03-binary-protobuf.cjs create mode 100644 tests/installed_apps/apps/db-clients-offline/package.json create mode 100644 tests/installed_apps/apps/db-clients-offline/run-node.mjs create mode 100644 tests/installed_apps/apps/db-clients-offline/test-01-sql-builders.cjs create mode 100644 tests/installed_apps/apps/db-clients-offline/test-02-pg-mysql.cjs create mode 100644 tests/installed_apps/apps/db-clients-offline/test-03-mongodb-redis.mjs create mode 100644 tests/installed_apps/apps/db-clients-offline/test-04-drizzle.mjs create mode 100644 tests/installed_apps/apps/fs-template-config/package.json create mode 100644 tests/installed_apps/apps/fs-template-config/run-node.mjs create mode 100644 tests/installed_apps/apps/fs-template-config/test-01-config-parsers.cjs create mode 100644 tests/installed_apps/apps/fs-template-config/test-02-template-engines.cjs create mode 100644 tests/installed_apps/apps/fs-template-config/test-03-fast-glob-fs.cjs create mode 100644 tests/installed_apps/apps/http-clients/package.json create mode 100644 tests/installed_apps/apps/http-clients/run-node.mjs create mode 100644 tests/installed_apps/apps/http-clients/test-01-axios.cjs create mode 100644 tests/installed_apps/apps/http-clients/test-02-fetch-ky.mjs create mode 100644 tests/installed_apps/apps/http-clients/test-03-graphql-request.mjs create mode 100644 tests/installed_apps/apps/logging-observability/package.json create mode 100644 tests/installed_apps/apps/logging-observability/run-node.mjs create mode 100644 tests/installed_apps/apps/logging-observability/test-01-loggers.cjs create mode 100644 tests/installed_apps/apps/logging-observability/test-02-consola-otel.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-exports/cjs/gamma.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-exports/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-exports/src/alpha.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-default.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-import.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-sync.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-imports/index.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-imports/package.json create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.cjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime.mjs create mode 100644 tests/installed_apps/apps/module-interop/packages/pattern-shims/package.json create mode 100644 tests/installed_apps/apps/module-interop/test-14-exports-patterns.mjs create mode 100644 tests/installed_apps/apps/module-interop/test-15-imports-patterns.cjs create mode 100644 tests/installed_apps/apps/module-interop/test-16-shim-patterns.mjs create mode 100644 tests/installed_apps/apps/popular-pure-js/package.json create mode 100644 tests/installed_apps/apps/popular-pure-js/run-node.mjs create mode 100644 tests/installed_apps/apps/popular-pure-js/test-01-cjs-utilities.cjs create mode 100644 tests/installed_apps/apps/popular-pure-js/test-02-modern-esm.mjs create mode 100644 tests/installed_apps/apps/popular-pure-js/test-03-date-fns-subpaths.mjs create mode 100644 tests/installed_apps/apps/popular-pure-js/test-04-dotenv-fs.cjs create mode 100644 tests/installed_apps/apps/popular-pure-js/test-05-ajv.cjs create mode 100644 tests/installed_apps/apps/popular-pure-js/test-06-rxjs.mjs create mode 100644 tests/installed_apps/apps/validation-schema/package.json create mode 100644 tests/installed_apps/apps/validation-schema/run-node.mjs create mode 100644 tests/installed_apps/apps/validation-schema/test-01-joi-yup.cjs create mode 100644 tests/installed_apps/apps/validation-schema/test-02-superstruct-valibot.mjs diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 698e06af..8aad76da 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -19,6 +19,7 @@ import * as assertStrict from 'node:assert/strict'; import * as fsPromises from 'node:fs/promises'; import * as nodeTest from 'node:test'; import * as querystring from 'node:querystring'; +import * as punycode from 'node:punycode'; import * as nodeUrl from 'node:url'; import * as vm from 'node:vm'; import * as timers from 'node:timers'; @@ -102,6 +103,7 @@ const assertCjs = cjsExport(assert); const assertStrictCjs = cjsExport(assertStrict); const nodeTestCjs = cjsExport(nodeTest); const querystringCjs = cjsExport(querystring); +const punycodeCjs = cjsExport(punycode); const nodeUrlCjs = cjsExport(nodeUrl); const vmCjs = cjsExport(vm); const timersCjs = cjsExport(timers); @@ -197,6 +199,7 @@ registerBuiltin(builtinModuleMap, 'assert', assertCjs); registerBuiltin(builtinModuleMap, 'assert/strict', assertStrictCjs); registerBuiltin(builtinModuleMap, 'test', nodeTestCjs); registerBuiltin(builtinModuleMap, 'querystring', querystringCjs); +registerBuiltin(builtinModuleMap, 'punycode', punycodeCjs); registerBuiltin(builtinModuleMap, 'url', nodeUrlCjs); registerBuiltin(builtinModuleMap, 'vm', vmCjs); registerBuiltin(builtinModuleMap, 'timers', timersCjs); @@ -709,11 +712,54 @@ function resolveExactPackageFile(filename) { throw makeModuleNotFoundError(filename); } -function resolvePackageTargetValue(packageDir, target, conditions, seen, allowBareTarget) { +function packagePatternKeyMatch(patternKey, key) { + const star = patternKey.indexOf('*'); + if (star === -1) return null; + const prefix = patternKey.slice(0, star); + const suffix = patternKey.slice(star + 1); + if (!key.startsWith(prefix) || !key.endsWith(suffix)) return null; + if (key.length < prefix.length + suffix.length) return null; + return key.slice(prefix.length, key.length - suffix.length); +} + +function findBestPackagePattern(map, key) { + let bestKey = null; + let bestSubstitution = null; + const keys = Object.keys(map); + for (let i = 0; i < keys.length; i++) { + const patternKey = keys[i]; + if (patternKey.indexOf('*') === -1) continue; + const substitution = packagePatternKeyMatch(patternKey, key); + if (substitution === null) continue; + if (bestKey === null || packagePatternCompare(patternKey, bestKey) < 0) { + bestKey = patternKey; + bestSubstitution = substitution; + } + } + return bestKey === null ? null : { key: bestKey, substitution: bestSubstitution }; +} + +function packagePatternCompare(a, b) { + const aStar = a.indexOf('*'); + const bStar = b.indexOf('*'); + const aBase = aStar === -1 ? a.length : aStar; + const bBase = bStar === -1 ? b.length : bStar; + if (aBase !== bBase) return bBase - aBase; + const aTrailer = aStar === -1 ? 0 : a.length - aStar - 1; + const bTrailer = bStar === -1 ? 0 : b.length - bStar - 1; + if (aTrailer !== bTrailer) return bTrailer - aTrailer; + if (a.length !== b.length) return b.length - a.length; + return a < b ? -1 : a > b ? 1 : 0; +} + +function resolvePackageTargetValue(packageDir, target, conditions, seen, allowBareTarget, patternSubstitution) { seen = seen || new Set(); if (target === null || target === false) return packageTargetBlocked; if (typeof target === 'string') { + if (patternSubstitution !== undefined && patternSubstitution !== null) { + target = target.replace(/\*/g, patternSubstitution); + } if (allowBareTarget && target.startsWith('node:') && builtinModuleMap[target] !== undefined) { return { builtin: target }; } @@ -739,11 +785,11 @@ function resolvePackageTargetValue(packageDir, target, conditions, seen, allowBa if (Array.isArray(target)) { for (let i = 0; i < target.length; i++) { try { - const resolved = resolvePackageTargetValue(packageDir, target[i], conditions, seen, allowBareTarget); + const resolved = resolvePackageTargetValue(packageDir, target[i], conditions, seen, allowBareTarget, patternSubstitution); if (resolved === packageTargetBlocked) return resolved; if (resolved !== packageTargetNoMatch) return resolved; } catch (err) { - if (!err || err.code !== 'ERR_INVALID_PACKAGE_TARGET') throw err; + if (!err || (err.code !== 'ERR_INVALID_PACKAGE_TARGET' && err.code !== 'MODULE_NOT_FOUND')) throw err; } } return packageTargetNoMatch; @@ -756,7 +802,7 @@ function resolvePackageTargetValue(packageDir, target, conditions, seen, allowBa for (let i = 0; i < keys.length; i++) { const condition = keys[i]; if (conditions.has(condition)) { - const resolved = resolvePackageTargetValue(packageDir, target[condition], conditions, seen, allowBareTarget); + const resolved = resolvePackageTargetValue(packageDir, target[condition], conditions, seen, allowBareTarget, patternSubstitution); if (resolved === packageTargetNoMatch) continue; return resolved; } @@ -786,6 +832,11 @@ function resolvePackageExports(packageName, packageDir, pkg, subpath, conditions } else if (exportsField && typeof exportsField === 'object') { if (Object.prototype.hasOwnProperty.call(exportsField, key)) { resolved = resolvePackageTargetValue(packageDir, exportsField[key], conditions, undefined, false); + } else { + const pattern = findBestPackagePattern(exportsField, key); + if (pattern !== null) { + resolved = resolvePackageTargetValue(packageDir, exportsField[pattern.key], conditions, undefined, false, pattern.substitution); + } } } else if (exportsField !== null) { throw makeInvalidPackageTargetError(exportsField); @@ -815,10 +866,17 @@ function resolvePackageImports(id, parentDir, conditions) { if (!scope || !scope.pkg || !scope.pkg.imports || typeof scope.pkg.imports !== 'object') { throw makePackageImportNotDefinedError(id); } - if (!Object.prototype.hasOwnProperty.call(scope.pkg.imports, id)) { - throw makePackageImportNotDefinedError(id); + let target; + let patternSubstitution = null; + if (Object.prototype.hasOwnProperty.call(scope.pkg.imports, id)) { + target = scope.pkg.imports[id]; + } else { + const pattern = findBestPackagePattern(scope.pkg.imports, id); + if (pattern === null) throw makePackageImportNotDefinedError(id); + target = scope.pkg.imports[pattern.key]; + patternSubstitution = pattern.substitution; } - const resolved = resolvePackageTargetValue(scope.dir, scope.pkg.imports[id], conditions, undefined, true); + const resolved = resolvePackageTargetValue(scope.dir, target, conditions, undefined, true, patternSubstitution); if (resolved !== packageTargetNoMatch && resolved !== packageTargetBlocked) return resolved; throw makePackageImportNotDefinedError(id); } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/node_http.js b/crates/wasm-rquickjs/skeleton/src/builtin/node_http.js index f365525c..a0603a96 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/node_http.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/node_http.js @@ -249,12 +249,12 @@ export class Agent extends EventEmitter { throw err; } + this.options = Object.assign({}, options, { scheduling }); this.keepAlive = options.keepAlive || false; this.keepAliveMsecs = options.keepAliveMsecs || 1000; this.maxSockets = options.maxSockets || Infinity; this.maxTotalSockets = options.maxTotalSockets || Infinity; this.maxFreeSockets = options.maxFreeSockets || 256; - this.timeout = options.timeout; this.scheduling = scheduling; this.freeSockets = {}; this.requests = {}; @@ -263,6 +263,10 @@ export class Agent extends EventEmitter { this._requestQueue = {}; } + get timeout() { + return this.options ? this.options.timeout : undefined; + } + get totalSocketCount() { let n = 0; for (const key of Object.keys(this.sockets)) { diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 1846752e..912e8489 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -1981,6 +1981,7 @@ impl NodeModulesResolver { false, "exports", conditions, + None, ) .and_then(|resolution| { Self::target_resolution_to_export_result(resolution, package_name, subpath) @@ -1995,6 +1996,22 @@ impl NodeModulesResolver { false, "exports", conditions, + None, + ) + .and_then(|resolution| { + Self::target_resolution_to_export_result(resolution, package_name, subpath) + }); + } + if let Some((pattern_key, pattern_substitution)) = Self::find_best_package_pattern(map, &key) + && let Some(target) = map.get(pattern_key) + { + return Self::resolve_package_target_value( + package_dir, + target, + false, + "exports", + conditions, + Some(&pattern_substitution), ) .and_then(|resolution| { Self::target_resolution_to_export_result(resolution, package_name, subpath) @@ -2015,14 +2032,30 @@ impl NodeModulesResolver { conditions: &[&str], ) -> Result { if let PackageTarget::Object(map) = imports - && let Some(target) = map.get(specifier) { + let (target, pattern_substitution) = if let Some(target) = map.get(specifier) { + (target, None) + } else if let Some((pattern_key, pattern_substitution)) = + Self::find_best_package_pattern(map, specifier) + { + let Some(target) = map.get(pattern_key) else { + return Err(NodePackageResolveError::PackageImportNotDefined { + specifier: specifier.to_string(), + }); + }; + (target, Some(pattern_substitution)) + } else { + return Err(NodePackageResolveError::PackageImportNotDefined { + specifier: specifier.to_string(), + }); + }; return Self::resolve_package_target_value( package_dir, target, true, "imports", conditions, + pattern_substitution.as_deref(), ).and_then( |resolution| Self::target_resolution_to_import_result(resolution, specifier), ); @@ -2045,6 +2078,7 @@ impl NodeModulesResolver { allow_bare_target: bool, kind: &'static str, conditions: &[&str], + pattern_substitution: Option<&str>, ) -> Result { match target { PackageTarget::Null | PackageTarget::Bool(false) => { @@ -2063,30 +2097,35 @@ impl NodeModulesResolver { }); } PackageTarget::String(target_str) => { - if allow_bare_target && Self::is_bare_package_specifier(target_str) { + let target_str = if let Some(pattern_substitution) = pattern_substitution { + target_str.replace('*', pattern_substitution) + } else { + target_str.clone() + }; + if allow_bare_target && Self::is_bare_package_specifier(&target_str) { let base = package_dir.join("package.json"); let base_str = base.to_string_lossy(); let resolver = NodeModulesResolver; - if let Some(resolved) = resolver.try_resolve(&base_str, target_str)? { + if let Some(resolved) = resolver.try_resolve(&base_str, &target_str)? { return Ok(PackageTargetResolution::Resolved(resolved)); } return Err(NodePackageResolveError::ModuleNotFound { - request: target_str.to_string(), + request: target_str, }); } if allow_bare_target && target_str.starts_with("node:") { - return Ok(PackageTargetResolution::Resolved(target_str.clone())); + return Ok(PackageTargetResolution::Resolved(target_str)); } if !target_str.starts_with("./") { return Err(NodePackageResolveError::InvalidPackageTarget { kind, - target: target_str.to_string(), + target: target_str, }); } - let Some(candidate) = Self::resolve_valid_package_target_path(package_dir, target_str) else { + let Some(candidate) = Self::resolve_valid_package_target_path(package_dir, &target_str) else { return Err(NodePackageResolveError::InvalidPackageTarget { kind, - target: target_str.clone(), + target: target_str, }); }; if candidate.is_file() { @@ -2100,7 +2139,7 @@ impl NodeModulesResolver { } PackageTarget::Array(array) => { for item in array { - match Self::resolve_package_target_value(package_dir, item, allow_bare_target, kind, conditions) { + match Self::resolve_package_target_value(package_dir, item, allow_bare_target, kind, conditions, pattern_substitution) { Ok(PackageTargetResolution::Resolved(path)) => { return Ok(PackageTargetResolution::Resolved(path)); } @@ -2108,7 +2147,8 @@ impl NodeModulesResolver { return Ok(PackageTargetResolution::Blocked); } Ok(PackageTargetResolution::NoMatch) => continue, - Err(NodePackageResolveError::InvalidPackageTarget { .. }) => continue, + Err(NodePackageResolveError::InvalidPackageTarget { .. }) + | Err(NodePackageResolveError::ModuleNotFound { .. }) => continue, Err(err) => return Err(err), } } @@ -2123,6 +2163,7 @@ impl NodeModulesResolver { allow_bare_target, kind, conditions, + pattern_substitution, )? { PackageTargetResolution::NoMatch => continue, resolution => return Ok(resolution), @@ -2134,6 +2175,60 @@ impl NodeModulesResolver { } } + fn package_pattern_key_match(pattern_key: &str, key: &str) -> Option { + let star = pattern_key.find('*')?; + let prefix = &pattern_key[..star]; + let suffix = &pattern_key[star + 1..]; + if !key.starts_with(prefix) || !key.ends_with(suffix) { + return None; + } + if key.len() < prefix.len() + suffix.len() { + return None; + } + Some(key[prefix.len()..key.len() - suffix.len()].to_string()) + } + + fn find_best_package_pattern<'a>( + map: &'a IndexMap, + key: &str, + ) -> Option<(&'a str, String)> { + let mut best: Option<(&str, String)> = None; + for pattern_key in map.keys() { + if !pattern_key.contains('*') { + continue; + } + let Some(substitution) = Self::package_pattern_key_match(pattern_key, key) else { + continue; + }; + if best + .as_ref() + .is_none_or(|(best_key, _)| Self::package_pattern_compare(pattern_key, best_key).is_lt()) + { + best = Some((pattern_key.as_str(), substitution)); + } + } + best + } + + fn package_pattern_compare(a: &str, b: &str) -> std::cmp::Ordering { + let a_star = a.find('*').unwrap_or(a.len()); + let b_star = b.find('*').unwrap_or(b.len()); + match b_star.cmp(&a_star) { + std::cmp::Ordering::Equal => {} + ordering => return ordering, + } + let a_trailer = a.len().saturating_sub(a_star + 1); + let b_trailer = b.len().saturating_sub(b_star + 1); + match b_trailer.cmp(&a_trailer) { + std::cmp::Ordering::Equal => {} + ordering => return ordering, + } + match b.len().cmp(&a.len()) { + std::cmp::Ordering::Equal => a.cmp(b), + ordering => ordering, + } + } + fn target_resolution_to_export_result( resolution: PackageTargetResolution, package_name: &str, diff --git a/tests/installed_apps/apps/cloud-sdk-offline/package.json b/tests/installed_apps/apps/cloud-sdk-offline/package.json new file mode 100644 index 00000000..d95fa5bb --- /dev/null +++ b/tests/installed_apps/apps/cloud-sdk-offline/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "@anthropic-ai/sdk": "0.39.0", + "@aws-sdk/client-s3": "3.717.0", + "openai": "4.85.4", + "stripe": "17.6.0" + } +} diff --git a/tests/installed_apps/apps/cloud-sdk-offline/run-node.mjs b/tests/installed_apps/apps/cloud-sdk-offline/run-node.mjs new file mode 100644 index 00000000..8536adb1 --- /dev/null +++ b/tests/installed_apps/apps/cloud-sdk-offline/run-node.mjs @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const testPath = process.argv[2]; +if (!testPath) { + console.error('Usage: node run-node.mjs '); + process.exit(1); +} + +const mod = testPath.endsWith('.cjs') + ? createRequire(import.meta.url)(`./${testPath}`) + : await import(pathToFileURL(new URL(testPath, import.meta.url).pathname).href); + +const run = mod.run || mod.default?.run; +if (typeof run !== 'function') throw new Error(`${testPath} does not export run()`); + +const result = await run(); +console.log(result); +if (typeof result !== 'string' || !result.startsWith('PASS:')) process.exit(1); diff --git a/tests/installed_apps/apps/cloud-sdk-offline/test-01-openai.mjs b/tests/installed_apps/apps/cloud-sdk-offline/test-01-openai.mjs new file mode 100644 index 00000000..32c80844 --- /dev/null +++ b/tests/installed_apps/apps/cloud-sdk-offline/test-01-openai.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert'; +import OpenAI from 'openai'; + +export const run = () => { + const client = new OpenAI({ apiKey: 'sk-test', baseURL: 'https://example.invalid/v1' }); + assert.strictEqual(typeof client.chat.completions.create, 'function'); + assert.strictEqual(typeof client.files.create, 'function'); + const error = new OpenAI.APIError(400, { error: { message: 'bad request' } }, 'bad request', {}); + assert.strictEqual(error.status, 400); + return 'PASS: OpenAI SDK imports and exposes offline client surfaces from node_modules'; +}; diff --git a/tests/installed_apps/apps/cloud-sdk-offline/test-02-anthropic.mjs b/tests/installed_apps/apps/cloud-sdk-offline/test-02-anthropic.mjs new file mode 100644 index 00000000..db5ecb0b --- /dev/null +++ b/tests/installed_apps/apps/cloud-sdk-offline/test-02-anthropic.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert'; +import Anthropic from '@anthropic-ai/sdk'; + +export const run = () => { + const client = new Anthropic({ apiKey: 'sk-ant-test', baseURL: 'https://example.invalid' }); + assert.strictEqual(typeof client.messages.create, 'function'); + assert.strictEqual(typeof client.models.list, 'function'); + const error = new Anthropic.APIError(401, { error: { message: 'unauthorized' } }, 'unauthorized', {}); + assert.strictEqual(error.status, 401); + return 'PASS: Anthropic SDK imports and exposes offline client surfaces from node_modules'; +}; diff --git a/tests/installed_apps/apps/cloud-sdk-offline/test-03-aws-s3.mjs b/tests/installed_apps/apps/cloud-sdk-offline/test-03-aws-s3.mjs new file mode 100644 index 00000000..9e4f6e05 --- /dev/null +++ b/tests/installed_apps/apps/cloud-sdk-offline/test-03-aws-s3.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { GetObjectCommand, PutObjectCommand, S3Client } = require('@aws-sdk/client-s3'); + +export const run = () => { + const client = new S3Client({ + region: 'us-east-1', + endpoint: 'https://example.invalid', + credentials: { accessKeyId: 'test', secretAccessKey: 'test' }, + }); + assert.strictEqual(typeof client.send, 'function'); + + const put = new PutObjectCommand({ Bucket: 'bucket', Key: 'key', Body: 'body' }); + assert.strictEqual(put.input.Bucket, 'bucket'); + assert.strictEqual(put.input.Key, 'key'); + + const get = new GetObjectCommand({ Bucket: 'bucket', Key: 'key' }); + assert.deepStrictEqual(get.input, { Bucket: 'bucket', Key: 'key' }); + return 'PASS: AWS S3 SDK command construction works from installed node_modules'; +}; diff --git a/tests/installed_apps/apps/cloud-sdk-offline/test-04-stripe.cjs b/tests/installed_apps/apps/cloud-sdk-offline/test-04-stripe.cjs new file mode 100644 index 00000000..ee8312a0 --- /dev/null +++ b/tests/installed_apps/apps/cloud-sdk-offline/test-04-stripe.cjs @@ -0,0 +1,11 @@ +const assert = require('node:assert'); +const Stripe = require('stripe'); + +exports.run = () => { + const stripe = new Stripe('sk_test_123', { apiVersion: '2024-06-20' }); + assert.strictEqual(typeof stripe.customers.create, 'function'); + assert.strictEqual(typeof stripe.paymentIntents.retrieve, 'function'); + const err = new Stripe.errors.StripeCardError({ message: 'declined', code: 'card_declined' }); + assert.strictEqual(err.code, 'card_declined'); + return 'PASS: Stripe SDK imports and exposes offline client surfaces from node_modules'; +}; diff --git a/tests/installed_apps/apps/crypto-auth/package.json b/tests/installed_apps/apps/crypto-auth/package.json new file mode 100644 index 00000000..d3ef889f --- /dev/null +++ b/tests/installed_apps/apps/crypto-auth/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "bcryptjs": "2.4.3", + "cookie": "1.0.2", + "cookie-signature": "1.2.2", + "jose": "5.9.6", + "jsonwebtoken": "9.0.2", + "nanoid": "5.0.9" + } +} diff --git a/tests/installed_apps/apps/crypto-auth/run-node.mjs b/tests/installed_apps/apps/crypto-auth/run-node.mjs new file mode 100644 index 00000000..8536adb1 --- /dev/null +++ b/tests/installed_apps/apps/crypto-auth/run-node.mjs @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const testPath = process.argv[2]; +if (!testPath) { + console.error('Usage: node run-node.mjs '); + process.exit(1); +} + +const mod = testPath.endsWith('.cjs') + ? createRequire(import.meta.url)(`./${testPath}`) + : await import(pathToFileURL(new URL(testPath, import.meta.url).pathname).href); + +const run = mod.run || mod.default?.run; +if (typeof run !== 'function') throw new Error(`${testPath} does not export run()`); + +const result = await run(); +console.log(result); +if (typeof result !== 'string' || !result.startsWith('PASS:')) process.exit(1); diff --git a/tests/installed_apps/apps/crypto-auth/test-01-jsonwebtoken-bcrypt.cjs b/tests/installed_apps/apps/crypto-auth/test-01-jsonwebtoken-bcrypt.cjs new file mode 100644 index 00000000..205ef4ab --- /dev/null +++ b/tests/installed_apps/apps/crypto-auth/test-01-jsonwebtoken-bcrypt.cjs @@ -0,0 +1,15 @@ +const assert = require('node:assert'); +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); + +exports.run = () => { + const token = jwt.sign({ sub: 'user-1', role: 'admin' }, 'secret', { algorithm: 'HS256', expiresIn: '1h' }); + const payload = jwt.verify(token, 'secret', { algorithms: ['HS256'] }); + assert.strictEqual(payload.sub, 'user-1'); + assert.strictEqual(payload.role, 'admin'); + + const hash = bcrypt.hashSync('password', 4); + assert.strictEqual(bcrypt.compareSync('password', hash), true); + assert.strictEqual(bcrypt.compareSync('wrong', hash), false); + return 'PASS: jsonwebtoken and bcryptjs execute from installed CommonJS packages'; +}; diff --git a/tests/installed_apps/apps/crypto-auth/test-02-jose.mjs b/tests/installed_apps/apps/crypto-auth/test-02-jose.mjs new file mode 100644 index 00000000..f90a0198 --- /dev/null +++ b/tests/installed_apps/apps/crypto-auth/test-02-jose.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert'; +import { SignJWT, jwtVerify } from 'jose'; + +export const run = async () => { + const secret = new TextEncoder().encode('0123456789abcdef0123456789abcdef'); + const token = await new SignJWT({ scope: 'installed-app' }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject('user-1') + .sign(secret); + const { payload, protectedHeader } = await jwtVerify(token, secret, { algorithms: ['HS256'] }); + assert.strictEqual(protectedHeader.alg, 'HS256'); + assert.strictEqual(payload.sub, 'user-1'); + assert.strictEqual(payload.scope, 'installed-app'); + return 'PASS: jose ESM JWT signing and verification works from node_modules'; +}; diff --git a/tests/installed_apps/apps/crypto-auth/test-03-nanoid-cookie.mjs b/tests/installed_apps/apps/crypto-auth/test-03-nanoid-cookie.mjs new file mode 100644 index 00000000..27ff83ea --- /dev/null +++ b/tests/installed_apps/apps/crypto-auth/test-03-nanoid-cookie.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert'; +import { createRequire } from 'node:module'; +import { customAlphabet } from 'nanoid'; +import * as cookie from 'cookie'; + +const require = createRequire(import.meta.url); +const signature = require('cookie-signature'); + +export const run = () => { + const makeId = customAlphabet('abc123', 12); + const id = makeId(); + assert.match(id, /^[abc123]{12}$/); + + const serialized = cookie.serialize('session', id, { httpOnly: true, sameSite: 'strict' }); + assert(serialized.includes('HttpOnly')); + const parsed = cookie.parse(`session=${id}; theme=dark`); + assert.strictEqual(parsed.session, id); + assert.strictEqual(parsed.theme, 'dark'); + + const signed = signature.sign(id, 'secret'); + assert.strictEqual(signature.unsign(signed, 'secret'), id); + assert.strictEqual(signature.unsign(signed, 'wrong'), false); + return 'PASS: nanoid, cookie, and cookie-signature work from installed packages'; +}; diff --git a/tests/installed_apps/apps/data-formats/package.json b/tests/installed_apps/apps/data-formats/package.json new file mode 100644 index 00000000..f017efa3 --- /dev/null +++ b/tests/installed_apps/apps/data-formats/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "csv-parse": "5.6.0", + "msgpackr": "1.11.5", + "papaparse": "5.5.3", + "protobufjs": "7.5.3", + "xml2js": "0.6.2", + "yaml": "2.8.0" + } +} diff --git a/tests/installed_apps/apps/data-formats/run-node.mjs b/tests/installed_apps/apps/data-formats/run-node.mjs new file mode 100644 index 00000000..8536adb1 --- /dev/null +++ b/tests/installed_apps/apps/data-formats/run-node.mjs @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const testPath = process.argv[2]; +if (!testPath) { + console.error('Usage: node run-node.mjs '); + process.exit(1); +} + +const mod = testPath.endsWith('.cjs') + ? createRequire(import.meta.url)(`./${testPath}`) + : await import(pathToFileURL(new URL(testPath, import.meta.url).pathname).href); + +const run = mod.run || mod.default?.run; +if (typeof run !== 'function') throw new Error(`${testPath} does not export run()`); + +const result = await run(); +console.log(result); +if (typeof result !== 'string' || !result.startsWith('PASS:')) process.exit(1); diff --git a/tests/installed_apps/apps/data-formats/test-01-csv.cjs b/tests/installed_apps/apps/data-formats/test-01-csv.cjs new file mode 100644 index 00000000..c56950d1 --- /dev/null +++ b/tests/installed_apps/apps/data-formats/test-01-csv.cjs @@ -0,0 +1,12 @@ +const assert = require('node:assert'); +const Papa = require('papaparse'); +const { parse } = require('csv-parse/sync'); + +exports.run = () => { + const csv = 'name,count\na,1\nb,2\n'; + const papa = Papa.parse(csv, { header: true, dynamicTyping: true, skipEmptyLines: true }); + assert.deepStrictEqual(papa.data, [{ name: 'a', count: 1 }, { name: 'b', count: 2 }]); + const parsed = parse(csv, { columns: true, cast: true }); + assert.deepStrictEqual(parsed, [{ name: 'a', count: 1 }, { name: 'b', count: 2 }]); + return 'PASS: CSV parsers execute from installed packages'; +}; diff --git a/tests/installed_apps/apps/data-formats/test-02-yaml-xml.cjs b/tests/installed_apps/apps/data-formats/test-02-yaml-xml.cjs new file mode 100644 index 00000000..16df850a --- /dev/null +++ b/tests/installed_apps/apps/data-formats/test-02-yaml-xml.cjs @@ -0,0 +1,16 @@ +const assert = require('node:assert'); +const YAML = require('yaml'); +const { parseStringPromise, Builder } = require('xml2js'); + +exports.run = async () => { + const parsedYaml = YAML.parse('name: installed-app\nitems:\n - one\n - two\n'); + assert.deepStrictEqual(parsedYaml, { name: 'installed-app', items: ['one', 'two'] }); + assert.match(YAML.stringify(parsedYaml), /installed-app/); + + const parsedXml = await parseStringPromise('one', { explicitArray: false }); + assert.strictEqual(parsedXml.root.item._, 'one'); + assert.strictEqual(parsedXml.root.item.$.id, '1'); + const xml = new Builder({ headless: true }).buildObject({ root: { item: 'two' } }); + assert.match(xml, /two<\/item>/); + return 'PASS: YAML and XML parsers execute from installed packages'; +}; diff --git a/tests/installed_apps/apps/data-formats/test-03-binary-protobuf.cjs b/tests/installed_apps/apps/data-formats/test-03-binary-protobuf.cjs new file mode 100644 index 00000000..32e18f0a --- /dev/null +++ b/tests/installed_apps/apps/data-formats/test-03-binary-protobuf.cjs @@ -0,0 +1,16 @@ +const assert = require('node:assert'); +const { pack, unpack } = require('msgpackr'); +const protobuf = require('protobufjs'); + +exports.run = () => { + const input = { name: 'installed-app', count: 3, nested: { ok: true } }; + assert.deepStrictEqual(unpack(pack(input)), input); + + const Awesome = new protobuf.Type('Awesome') + .add(new protobuf.Field('name', 1, 'string')) + .add(new protobuf.Field('count', 2, 'int32')); + const message = Awesome.create({ name: 'protobuf', count: 7 }); + const decoded = Awesome.decode(Awesome.encode(message).finish()); + assert.deepStrictEqual(Awesome.toObject(decoded), { name: 'protobuf', count: 7 }); + return 'PASS: msgpackr and protobufjs execute from installed packages'; +}; diff --git a/tests/installed_apps/apps/db-clients-offline/package.json b/tests/installed_apps/apps/db-clients-offline/package.json new file mode 100644 index 00000000..909b5aef --- /dev/null +++ b/tests/installed_apps/apps/db-clients-offline/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "drizzle-orm": "0.38.4", + "knex": "3.1.0", + "mongodb": "6.12.0", + "mysql2": "3.11.5", + "pg": "8.13.1", + "redis": "4.7.0" + } +} diff --git a/tests/installed_apps/apps/db-clients-offline/run-node.mjs b/tests/installed_apps/apps/db-clients-offline/run-node.mjs new file mode 100644 index 00000000..8536adb1 --- /dev/null +++ b/tests/installed_apps/apps/db-clients-offline/run-node.mjs @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const testPath = process.argv[2]; +if (!testPath) { + console.error('Usage: node run-node.mjs '); + process.exit(1); +} + +const mod = testPath.endsWith('.cjs') + ? createRequire(import.meta.url)(`./${testPath}`) + : await import(pathToFileURL(new URL(testPath, import.meta.url).pathname).href); + +const run = mod.run || mod.default?.run; +if (typeof run !== 'function') throw new Error(`${testPath} does not export run()`); + +const result = await run(); +console.log(result); +if (typeof result !== 'string' || !result.startsWith('PASS:')) process.exit(1); diff --git a/tests/installed_apps/apps/db-clients-offline/test-01-sql-builders.cjs b/tests/installed_apps/apps/db-clients-offline/test-01-sql-builders.cjs new file mode 100644 index 00000000..7def3b04 --- /dev/null +++ b/tests/installed_apps/apps/db-clients-offline/test-01-sql-builders.cjs @@ -0,0 +1,12 @@ +const assert = require('node:assert'); +const knex = require('knex'); + +exports.run = () => { + const db = knex({ client: 'pg' }); + const query = db('users').select('id', 'name').where({ active: true }).orderBy('id').toSQL(); + assert.strictEqual(query.sql, 'select "id", "name" from "users" where "active" = ? order by "id" asc'); + assert.deepStrictEqual(query.bindings, [true]); + const insert = db('users').insert({ name: 'Alice' }).returning('id').toSQL(); + assert.match(insert.sql, /insert into "users"/); + return 'PASS: knex query builder executes offline from installed node_modules'; +}; diff --git a/tests/installed_apps/apps/db-clients-offline/test-02-pg-mysql.cjs b/tests/installed_apps/apps/db-clients-offline/test-02-pg-mysql.cjs new file mode 100644 index 00000000..05e7aae5 --- /dev/null +++ b/tests/installed_apps/apps/db-clients-offline/test-02-pg-mysql.cjs @@ -0,0 +1,16 @@ +const assert = require('node:assert'); +const { Client, Pool } = require('pg'); +const mysql = require('mysql2'); + +exports.run = () => { + const pgClient = new Client({ host: 'localhost', port: 5432, user: 'u', password: 'p', database: 'd' }); + assert.strictEqual(pgClient.connectionParameters.host, 'localhost'); + const pool = new Pool({ max: 1 }); + assert.strictEqual(typeof pool.query, 'function'); + pool.end(); + + assert.strictEqual(typeof mysql.createConnection, 'function'); + assert.strictEqual(typeof mysql.createPool, 'function'); + assert.strictEqual(typeof mysql.format('select ? as value', [1]), 'string'); + return 'PASS: pg and mysql2 clients construct offline from installed node_modules'; +}; diff --git a/tests/installed_apps/apps/db-clients-offline/test-03-mongodb-redis.mjs b/tests/installed_apps/apps/db-clients-offline/test-03-mongodb-redis.mjs new file mode 100644 index 00000000..702a76d6 --- /dev/null +++ b/tests/installed_apps/apps/db-clients-offline/test-03-mongodb-redis.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert'; +import { MongoClient } from 'mongodb'; +import { createClient } from 'redis'; + +export const run = async () => { + const mongo = new MongoClient('mongodb://localhost:27017/test', { serverSelectionTimeoutMS: 1 }); + assert.strictEqual(mongo.db('test').databaseName, 'test'); + await mongo.close(); + + const redis = createClient({ url: 'redis://localhost:6379' }); + assert.strictEqual(typeof redis.connect, 'function'); + if (typeof redis.quit === 'function') assert.strictEqual(typeof redis.quit, 'function'); + return 'PASS: mongodb and redis clients construct offline from installed node_modules'; +}; diff --git a/tests/installed_apps/apps/db-clients-offline/test-04-drizzle.mjs b/tests/installed_apps/apps/db-clients-offline/test-04-drizzle.mjs new file mode 100644 index 00000000..c31a700b --- /dev/null +++ b/tests/installed_apps/apps/db-clients-offline/test-04-drizzle.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert'; +import { getTableColumns, eq, sql } from 'drizzle-orm'; +import { integer, pgTable, serial, text } from 'drizzle-orm/pg-core'; + +export const run = () => { + const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + age: integer('age'), + }); + const columns = getTableColumns(users); + assert.deepStrictEqual(Object.keys(columns).sort(), ['age', 'id', 'name']); + const condition = eq(users.name, 'Alice'); + assert.strictEqual(typeof condition, 'object'); + assert.strictEqual(typeof sql`select 1`, 'object'); + return 'PASS: drizzle-orm schema helpers execute offline from installed node_modules'; +}; diff --git a/tests/installed_apps/apps/fs-template-config/package.json b/tests/installed_apps/apps/fs-template-config/package.json new file mode 100644 index 00000000..ee8636c5 --- /dev/null +++ b/tests/installed_apps/apps/fs-template-config/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "ejs": "3.1.10", + "fast-glob": "3.3.3", + "handlebars": "4.7.8", + "ini": "5.0.0", + "mustache": "4.2.0", + "toml": "3.0.0" + } +} diff --git a/tests/installed_apps/apps/fs-template-config/run-node.mjs b/tests/installed_apps/apps/fs-template-config/run-node.mjs new file mode 100644 index 00000000..8536adb1 --- /dev/null +++ b/tests/installed_apps/apps/fs-template-config/run-node.mjs @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const testPath = process.argv[2]; +if (!testPath) { + console.error('Usage: node run-node.mjs '); + process.exit(1); +} + +const mod = testPath.endsWith('.cjs') + ? createRequire(import.meta.url)(`./${testPath}`) + : await import(pathToFileURL(new URL(testPath, import.meta.url).pathname).href); + +const run = mod.run || mod.default?.run; +if (typeof run !== 'function') throw new Error(`${testPath} does not export run()`); + +const result = await run(); +console.log(result); +if (typeof result !== 'string' || !result.startsWith('PASS:')) process.exit(1); diff --git a/tests/installed_apps/apps/fs-template-config/test-01-config-parsers.cjs b/tests/installed_apps/apps/fs-template-config/test-01-config-parsers.cjs new file mode 100644 index 00000000..593bf48f --- /dev/null +++ b/tests/installed_apps/apps/fs-template-config/test-01-config-parsers.cjs @@ -0,0 +1,14 @@ +const assert = require('node:assert'); +const ini = require('ini'); +const toml = require('toml'); + +exports.run = () => { + const parsedIni = ini.parse('name=installed-app\n[limits]\ncount=42\n'); + assert.strictEqual(parsedIni.name, 'installed-app'); + assert.strictEqual(parsedIni.limits.count, '42'); + + const parsedToml = toml.parse('name = "installed-app"\n[limits]\ncount = 42\n'); + assert.strictEqual(parsedToml.name, 'installed-app'); + assert.strictEqual(parsedToml.limits.count, 42); + return 'PASS: ini and toml config parsers execute from installed packages'; +}; diff --git a/tests/installed_apps/apps/fs-template-config/test-02-template-engines.cjs b/tests/installed_apps/apps/fs-template-config/test-02-template-engines.cjs new file mode 100644 index 00000000..a28599a8 --- /dev/null +++ b/tests/installed_apps/apps/fs-template-config/test-02-template-engines.cjs @@ -0,0 +1,14 @@ +const assert = require('node:assert'); +const ejs = require('ejs'); +const handlebars = require('handlebars'); +const mustache = require('mustache'); + +exports.run = () => { + assert.strictEqual(ejs.render('Hello <%= name %>', { name: 'EJS' }), 'Hello EJS'); + + const hb = handlebars.compile('Hello {{name}} {{#if ok}}OK{{/if}}'); + assert.strictEqual(hb({ name: 'Handlebars', ok: true }), 'Hello Handlebars OK'); + + assert.strictEqual(mustache.render('Hello {{name}}', { name: 'Mustache' }), 'Hello Mustache'); + return 'PASS: template engines execute from installed packages'; +}; diff --git a/tests/installed_apps/apps/fs-template-config/test-03-fast-glob-fs.cjs b/tests/installed_apps/apps/fs-template-config/test-03-fast-glob-fs.cjs new file mode 100644 index 00000000..5ec466e1 --- /dev/null +++ b/tests/installed_apps/apps/fs-template-config/test-03-fast-glob-fs.cjs @@ -0,0 +1,16 @@ +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const fg = require('fast-glob'); + +exports.run = async () => { + const root = path.join(process.cwd(), 'fixtures', 'glob'); + fs.mkdirSync(path.join(root, 'nested'), { recursive: true }); + fs.writeFileSync(path.join(root, 'a.txt'), 'a'); + fs.writeFileSync(path.join(root, 'nested', 'b.txt'), 'b'); + fs.writeFileSync(path.join(root, 'nested', 'ignore.log'), 'log'); + + const entries = await fg('**/*.txt', { cwd: root, onlyFiles: true }); + assert.deepStrictEqual(entries.sort(), ['a.txt', 'nested/b.txt']); + return 'PASS: fast-glob reads files from attached filesystem'; +}; diff --git a/tests/installed_apps/apps/http-clients/package.json b/tests/installed_apps/apps/http-clients/package.json new file mode 100644 index 00000000..cbf74760 --- /dev/null +++ b/tests/installed_apps/apps/http-clients/package.json @@ -0,0 +1,11 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "axios": "1.7.9", + "graphql": "16.10.0", + "graphql-request": "7.1.2", + "ky": "1.7.4", + "node-fetch": "3.3.2" + } +} diff --git a/tests/installed_apps/apps/http-clients/run-node.mjs b/tests/installed_apps/apps/http-clients/run-node.mjs new file mode 100644 index 00000000..8536adb1 --- /dev/null +++ b/tests/installed_apps/apps/http-clients/run-node.mjs @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const testPath = process.argv[2]; +if (!testPath) { + console.error('Usage: node run-node.mjs '); + process.exit(1); +} + +const mod = testPath.endsWith('.cjs') + ? createRequire(import.meta.url)(`./${testPath}`) + : await import(pathToFileURL(new URL(testPath, import.meta.url).pathname).href); + +const run = mod.run || mod.default?.run; +if (typeof run !== 'function') throw new Error(`${testPath} does not export run()`); + +const result = await run(); +console.log(result); +if (typeof result !== 'string' || !result.startsWith('PASS:')) process.exit(1); diff --git a/tests/installed_apps/apps/http-clients/test-01-axios.cjs b/tests/installed_apps/apps/http-clients/test-01-axios.cjs new file mode 100644 index 00000000..a1b34ef6 --- /dev/null +++ b/tests/installed_apps/apps/http-clients/test-01-axios.cjs @@ -0,0 +1,23 @@ +const assert = require('node:assert'); +const axios = require('axios'); + +exports.run = async () => { + const client = axios.create({ + adapter: async (config) => ({ + data: { ok: true, url: config.url, header: config.headers.get('x-test') }, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }), + }); + client.interceptors.request.use((config) => { + config.headers.set('x-test', 'installed-app'); + return config; + }); + const response = await client.get('https://example.invalid/api'); + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(response.data, { ok: true, url: 'https://example.invalid/api', header: 'installed-app' }); + return 'PASS: axios loads from node_modules and custom adapter/interceptors work'; +}; diff --git a/tests/installed_apps/apps/http-clients/test-02-fetch-ky.mjs b/tests/installed_apps/apps/http-clients/test-02-fetch-ky.mjs new file mode 100644 index 00000000..87b1af39 --- /dev/null +++ b/tests/installed_apps/apps/http-clients/test-02-fetch-ky.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert'; +import fetch from 'node-fetch'; +import ky from 'ky'; + +export const run = async () => { + const fetched = await fetch('data:application/json,%7B%22hello%22%3A%22node-fetch%22%7D'); + assert.deepStrictEqual(await fetched.json(), { hello: 'node-fetch' }); + + const api = ky.create({ prefixUrl: 'https://example.invalid' }); + assert.strictEqual(typeof api.get, 'function'); + assert.strictEqual(typeof api.post, 'function'); + return 'PASS: node-fetch and ky load from installed ESM packages'; +}; diff --git a/tests/installed_apps/apps/http-clients/test-03-graphql-request.mjs b/tests/installed_apps/apps/http-clients/test-03-graphql-request.mjs new file mode 100644 index 00000000..aacbf6c3 --- /dev/null +++ b/tests/installed_apps/apps/http-clients/test-03-graphql-request.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert'; +import { GraphQLClient, gql } from 'graphql-request'; + +export const run = async () => { + const client = new GraphQLClient('https://example.invalid/graphql', { + fetch: async (_url, init) => { + assert.match(String(init.body), /hello/); + return new Response(JSON.stringify({ data: { hello: 'world' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + const data = await client.request(gql`query { hello }`); + assert.deepStrictEqual(data, { hello: 'world' }); + return 'PASS: graphql-request builds and executes with a custom fetch from node_modules'; +}; diff --git a/tests/installed_apps/apps/logging-observability/package.json b/tests/installed_apps/apps/logging-observability/package.json new file mode 100644 index 00000000..1ba83e8e --- /dev/null +++ b/tests/installed_apps/apps/logging-observability/package.json @@ -0,0 +1,11 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "@opentelemetry/api": "1.9.0", + "consola": "3.4.0", + "loglevel": "1.9.2", + "pino": "9.6.0", + "winston": "3.17.0" + } +} diff --git a/tests/installed_apps/apps/logging-observability/run-node.mjs b/tests/installed_apps/apps/logging-observability/run-node.mjs new file mode 100644 index 00000000..8536adb1 --- /dev/null +++ b/tests/installed_apps/apps/logging-observability/run-node.mjs @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const testPath = process.argv[2]; +if (!testPath) { + console.error('Usage: node run-node.mjs '); + process.exit(1); +} + +const mod = testPath.endsWith('.cjs') + ? createRequire(import.meta.url)(`./${testPath}`) + : await import(pathToFileURL(new URL(testPath, import.meta.url).pathname).href); + +const run = mod.run || mod.default?.run; +if (typeof run !== 'function') throw new Error(`${testPath} does not export run()`); + +const result = await run(); +console.log(result); +if (typeof result !== 'string' || !result.startsWith('PASS:')) process.exit(1); diff --git a/tests/installed_apps/apps/logging-observability/test-01-loggers.cjs b/tests/installed_apps/apps/logging-observability/test-01-loggers.cjs new file mode 100644 index 00000000..0974d3ef --- /dev/null +++ b/tests/installed_apps/apps/logging-observability/test-01-loggers.cjs @@ -0,0 +1,22 @@ +const assert = require('node:assert'); +const pino = require('pino'); +const loglevel = require('loglevel'); +const winston = require('winston'); + +exports.run = () => { + const logger = pino({ enabled: false }).child({ component: 'installed-app' }); + assert.strictEqual(typeof logger.info, 'function'); + logger.info({ ok: true }, 'disabled logger should not write'); + + const log = loglevel.getLogger('installed-app'); + log.setLevel('silent'); + assert.strictEqual(log.getLevel(), loglevel.levels.SILENT); + + const formatted = winston.format.combine( + winston.format.timestamp(), + winston.format.json(), + ).transform({ level: 'info', message: 'hello' }); + assert.strictEqual(formatted.level, 'info'); + assert.strictEqual(formatted.message, 'hello'); + return 'PASS: pino, loglevel, and winston load without transports/processes'; +}; diff --git a/tests/installed_apps/apps/logging-observability/test-02-consola-otel.mjs b/tests/installed_apps/apps/logging-observability/test-02-consola-otel.mjs new file mode 100644 index 00000000..2339419e --- /dev/null +++ b/tests/installed_apps/apps/logging-observability/test-02-consola-otel.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert'; +import { createConsola } from 'consola'; +import { context, propagation, trace } from '@opentelemetry/api'; + +export const run = () => { + const logs = []; + const consola = createConsola({ + reporters: [{ log: (entry) => logs.push(entry) }], + }); + consola.info('hello', { ok: true }); + assert.strictEqual(logs.length, 1); + assert.strictEqual(logs[0].args[0], 'hello'); + + const tracer = trace.getTracer('installed-app'); + assert.strictEqual(typeof tracer.startSpan, 'function'); + const carrier = {}; + propagation.inject(context.active(), carrier); + assert.strictEqual(typeof carrier, 'object'); + return 'PASS: consola and OpenTelemetry API execute from installed ESM packages'; +}; diff --git a/tests/installed_apps/apps/module-interop/package.json b/tests/installed_apps/apps/module-interop/package.json index 261e2ed4..d674ec31 100644 --- a/tests/installed_apps/apps/module-interop/package.json +++ b/tests/installed_apps/apps/module-interop/package.json @@ -22,6 +22,9 @@ "dual-exports": "file:./packages/dual-exports", "esm-sync": "file:./packages/esm-sync", "imports-alias": "file:./packages/imports-alias", + "pattern-exports": "file:./packages/pattern-exports", + "pattern-imports": "file:./packages/pattern-imports", + "pattern-shims": "file:./packages/pattern-shims", "tla-esm": "file:./packages/tla-esm" } } diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/cjs/gamma.cjs b/tests/installed_apps/apps/module-interop/packages/pattern-exports/cjs/gamma.cjs new file mode 100644 index 00000000..e8875ee8 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-exports/cjs/gamma.cjs @@ -0,0 +1 @@ +module.exports = { branch: 'require', name: 'gamma' }; diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/package.json b/tests/installed_apps/apps/module-interop/packages/pattern-exports/package.json new file mode 100644 index 00000000..31ceeb97 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-exports/package.json @@ -0,0 +1,17 @@ +{ + "name": "pattern-exports", + "version": "1.0.0", + "type": "module", + "exports": { + "./features/*": "./src/*.mjs", + "./sync/*": { + "module-sync": "./sync/*-sync.mjs", + "import": "./sync/*-import.mjs", + "default": "./sync/*-default.mjs" + }, + "./cjs/*": { + "require": "./cjs/*.cjs", + "default": "./src/*.mjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/src/alpha.mjs b/tests/installed_apps/apps/module-interop/packages/pattern-exports/src/alpha.mjs new file mode 100644 index 00000000..82fe2484 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-exports/src/alpha.mjs @@ -0,0 +1 @@ +export default { feature: 'alpha' }; diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-default.mjs b/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-default.mjs new file mode 100644 index 00000000..04c02a3b --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-default.mjs @@ -0,0 +1 @@ +export default { branch: 'default', name: 'beta' }; diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-import.mjs b/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-import.mjs new file mode 100644 index 00000000..7521998b --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-import.mjs @@ -0,0 +1 @@ +export default { branch: 'import', name: 'beta' }; diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-sync.mjs b/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-sync.mjs new file mode 100644 index 00000000..152c3762 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-sync.mjs @@ -0,0 +1 @@ +export default { branch: 'module-sync', name: 'beta' }; diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-imports/index.cjs b/tests/installed_apps/apps/module-interop/packages/pattern-imports/index.cjs new file mode 100644 index 00000000..96948c6a --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-imports/index.cjs @@ -0,0 +1 @@ +module.exports = require('#internal/value'); diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.cjs b/tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.cjs new file mode 100644 index 00000000..401efc1f --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.cjs @@ -0,0 +1 @@ +module.exports = { value: 'internal-value' }; diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.mjs b/tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.mjs new file mode 100644 index 00000000..957f0d0e --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.mjs @@ -0,0 +1 @@ +export default { value: 'internal-value-esm' }; diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-imports/package.json b/tests/installed_apps/apps/module-interop/packages/pattern-imports/package.json new file mode 100644 index 00000000..9e78ad6d --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-imports/package.json @@ -0,0 +1,11 @@ +{ + "name": "pattern-imports", + "version": "1.0.0", + "main": "index.cjs", + "imports": { + "#internal/*": { + "require": "./internal/*.cjs", + "default": "./internal/*.mjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.cjs b/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.cjs new file mode 100644 index 00000000..4aa667ed --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.cjs @@ -0,0 +1 @@ +module.exports = { runtime: 'node-require' }; diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.mjs b/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.mjs new file mode 100644 index 00000000..a8878827 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.mjs @@ -0,0 +1 @@ +export default { runtime: 'node' }; diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime.mjs b/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime.mjs new file mode 100644 index 00000000..61f06b25 --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime.mjs @@ -0,0 +1 @@ +export default { runtime: 'default' }; diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-shims/package.json b/tests/installed_apps/apps/module-interop/packages/pattern-shims/package.json new file mode 100644 index 00000000..a5d3df7b --- /dev/null +++ b/tests/installed_apps/apps/module-interop/packages/pattern-shims/package.json @@ -0,0 +1,14 @@ +{ + "name": "pattern-shims", + "version": "1.0.0", + "type": "module", + "exports": { + "./_shims/auto/*": { + "node": { + "default": "./_shims/auto/*-node.mjs", + "require": "./_shims/auto/*-node.cjs" + }, + "default": "./_shims/auto/*.mjs" + } + } +} diff --git a/tests/installed_apps/apps/module-interop/test-14-exports-patterns.mjs b/tests/installed_apps/apps/module-interop/test-14-exports-patterns.mjs new file mode 100644 index 00000000..e495f5bb --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-14-exports-patterns.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert'; +import feature from 'pattern-exports/features/alpha'; +import syncFeature from 'pattern-exports/sync/beta'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +export const run = () => { + assert.deepStrictEqual(feature, { feature: 'alpha' }); + assert.deepStrictEqual(syncFeature, { branch: 'module-sync', name: 'beta' }); + assert.deepStrictEqual(require('pattern-exports/cjs/gamma'), { branch: 'require', name: 'gamma' }); + return 'PASS: package exports wildcard patterns resolve for ESM, module-sync, and CJS require'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-15-imports-patterns.cjs b/tests/installed_apps/apps/module-interop/test-15-imports-patterns.cjs new file mode 100644 index 00000000..a658303b --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-15-imports-patterns.cjs @@ -0,0 +1,7 @@ +const assert = require('node:assert'); + +exports.run = () => { + const pkg = require('pattern-imports'); + assert.deepStrictEqual(pkg, { value: 'internal-value' }); + return 'PASS: package imports wildcard patterns resolve through installed packages'; +}; diff --git a/tests/installed_apps/apps/module-interop/test-16-shim-patterns.mjs b/tests/installed_apps/apps/module-interop/test-16-shim-patterns.mjs new file mode 100644 index 00000000..3475704e --- /dev/null +++ b/tests/installed_apps/apps/module-interop/test-16-shim-patterns.mjs @@ -0,0 +1,7 @@ +import assert from 'node:assert'; +import runtime from 'pattern-shims/_shims/auto/runtime'; + +export const run = () => { + assert.deepStrictEqual(runtime, { runtime: 'node' }); + return 'PASS: OpenAI-style _shims/auto wildcard export patterns resolve'; +}; diff --git a/tests/installed_apps/apps/popular-pure-js/package.json b/tests/installed_apps/apps/popular-pure-js/package.json new file mode 100644 index 00000000..3a1f0917 --- /dev/null +++ b/tests/installed_apps/apps/popular-pure-js/package.json @@ -0,0 +1,17 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "ajv": "8.17.1", + "chalk": "5.4.1", + "date-fns": "4.1.0", + "debug": "4.4.1", + "dotenv": "16.4.7", + "lodash": "4.17.21", + "ms": "2.1.3", + "rxjs": "7.8.2", + "semver": "7.7.2", + "uuid": "11.1.0", + "zod": "3.25.76" + } +} diff --git a/tests/installed_apps/apps/popular-pure-js/run-node.mjs b/tests/installed_apps/apps/popular-pure-js/run-node.mjs new file mode 100644 index 00000000..e19f083f --- /dev/null +++ b/tests/installed_apps/apps/popular-pure-js/run-node.mjs @@ -0,0 +1,23 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const testPath = process.argv[2]; +if (!testPath) { + console.error('Usage: node run-node.mjs '); + process.exit(1); +} + +const mod = testPath.endsWith('.cjs') + ? createRequire(import.meta.url)(`./${testPath}`) + : await import(pathToFileURL(new URL(testPath, import.meta.url).pathname).href); + +const run = mod.run || mod.default?.run; +if (typeof run !== 'function') { + throw new Error(`${testPath} does not export run()`); +} + +const result = await run(); +console.log(result); +if (typeof result !== 'string' || !result.startsWith('PASS:')) { + process.exit(1); +} diff --git a/tests/installed_apps/apps/popular-pure-js/test-01-cjs-utilities.cjs b/tests/installed_apps/apps/popular-pure-js/test-01-cjs-utilities.cjs new file mode 100644 index 00000000..969d99bd --- /dev/null +++ b/tests/installed_apps/apps/popular-pure-js/test-01-cjs-utilities.cjs @@ -0,0 +1,18 @@ +const assert = require('node:assert'); +const lodash = require('lodash'); +const semver = require('semver'); +const createDebug = require('debug'); +const ms = require('ms'); + +exports.run = () => { + const input = [{ group: 'a', value: 1 }, { group: 'a', value: 2 }, { group: 'b', value: 3 }]; + assert.deepStrictEqual(lodash.mapValues(lodash.groupBy(input, 'group'), (items) => lodash.sumBy(items, 'value')), { a: 3, b: 3 }); + assert.strictEqual(semver.satisfies('2.3.4', '^2.0.0'), true); + assert.strictEqual(semver.inc('1.2.3', 'minor'), '1.3.0'); + assert.strictEqual(ms('2 hours'), 7200000); + assert.strictEqual(ms(1500), '2s'); + const debug = createDebug('installed-app:test'); + assert.strictEqual(typeof debug, 'function'); + debug('debug output is disabled by default'); + return 'PASS: classic CJS utility packages load and execute from node_modules'; +}; diff --git a/tests/installed_apps/apps/popular-pure-js/test-02-modern-esm.mjs b/tests/installed_apps/apps/popular-pure-js/test-02-modern-esm.mjs new file mode 100644 index 00000000..57000888 --- /dev/null +++ b/tests/installed_apps/apps/popular-pure-js/test-02-modern-esm.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert'; +import chalk from 'chalk'; +import { z } from 'zod'; +import { parse as parseUuid, stringify as stringifyUuid, v5 as uuidv5, validate as validateUuid } from 'uuid'; + +export const run = () => { + assert.strictEqual(chalk.red('plain'), 'plain'); + + const schema = z.object({ id: z.string().uuid(), tags: z.array(z.string()).default([]) }); + const id = uuidv5('installed-app', uuidv5.URL); + const parsed = schema.parse({ id }); + assert.deepStrictEqual(parsed, { id, tags: [] }); + assert.strictEqual(validateUuid(id), true); + assert.strictEqual(stringifyUuid(parseUuid(id)), id); + return 'PASS: modern ESM and exports-heavy packages load from node_modules'; +}; diff --git a/tests/installed_apps/apps/popular-pure-js/test-03-date-fns-subpaths.mjs b/tests/installed_apps/apps/popular-pure-js/test-03-date-fns-subpaths.mjs new file mode 100644 index 00000000..600a1124 --- /dev/null +++ b/tests/installed_apps/apps/popular-pure-js/test-03-date-fns-subpaths.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert'; +import { addDays } from 'date-fns/addDays'; +import { formatISO } from 'date-fns/formatISO'; +import { parseISO } from 'date-fns/parseISO'; + +export const run = () => { + const start = parseISO('2026-06-16T00:00:00.000Z'); + const result = addDays(start, 3); + assert.strictEqual(formatISO(result, { representation: 'date' }), '2026-06-19'); + return 'PASS: date-fns subpath exports resolve from installed node_modules'; +}; diff --git a/tests/installed_apps/apps/popular-pure-js/test-04-dotenv-fs.cjs b/tests/installed_apps/apps/popular-pure-js/test-04-dotenv-fs.cjs new file mode 100644 index 00000000..9319a8ea --- /dev/null +++ b/tests/installed_apps/apps/popular-pure-js/test-04-dotenv-fs.cjs @@ -0,0 +1,14 @@ +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const dotenv = require('dotenv'); + +exports.run = () => { + const envPath = path.join(process.cwd(), 'fixtures', 'sample.env'); + fs.mkdirSync(path.dirname(envPath), { recursive: true }); + fs.writeFileSync(envPath, 'APP_NAME=installed-app\nAPP_COUNT=42\n'); + const parsed = dotenv.config({ path: envPath, processEnv: {} }); + assert.deepStrictEqual(parsed.parsed, { APP_NAME: 'installed-app', APP_COUNT: '42' }); + assert.deepStrictEqual(dotenv.parse(Buffer.from('A=1\nB=two\n')), { A: '1', B: 'two' }); + return 'PASS: dotenv reads configuration files from the attached filesystem'; +}; diff --git a/tests/installed_apps/apps/popular-pure-js/test-05-ajv.cjs b/tests/installed_apps/apps/popular-pure-js/test-05-ajv.cjs new file mode 100644 index 00000000..07834cad --- /dev/null +++ b/tests/installed_apps/apps/popular-pure-js/test-05-ajv.cjs @@ -0,0 +1,19 @@ +const assert = require('node:assert'); +const Ajv = require('ajv'); + +exports.run = () => { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile({ + type: 'object', + required: ['name', 'count'], + properties: { + name: { type: 'string', minLength: 2 }, + count: { type: 'integer', minimum: 1 }, + }, + additionalProperties: false, + }); + assert.strictEqual(validate({ name: 'ok', count: 2 }), true); + assert.strictEqual(validate({ name: 'x', count: 0, extra: true }), false); + assert(validate.errors.length >= 2); + return 'PASS: ajv compiles and runs schemas from installed CommonJS package graph'; +}; diff --git a/tests/installed_apps/apps/popular-pure-js/test-06-rxjs.mjs b/tests/installed_apps/apps/popular-pure-js/test-06-rxjs.mjs new file mode 100644 index 00000000..de3791ae --- /dev/null +++ b/tests/installed_apps/apps/popular-pure-js/test-06-rxjs.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert'; +import { firstValueFrom, of } from 'rxjs'; +import { map, reduce } from 'rxjs/operators'; + +export const run = async () => { + const result = await firstValueFrom( + of(1, 2, 3).pipe( + map((value) => value * 2), + reduce((sum, value) => sum + value, 0), + ), + ); + assert.strictEqual(result, 12); + return 'PASS: rxjs package exports and operator subpaths execute from node_modules'; +}; diff --git a/tests/installed_apps/apps/validation-schema/package.json b/tests/installed_apps/apps/validation-schema/package.json new file mode 100644 index 00000000..8a4ba502 --- /dev/null +++ b/tests/installed_apps/apps/validation-schema/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "joi": "17.13.3", + "superstruct": "2.0.2", + "valibot": "1.1.0", + "yup": "1.6.1" + } +} diff --git a/tests/installed_apps/apps/validation-schema/run-node.mjs b/tests/installed_apps/apps/validation-schema/run-node.mjs new file mode 100644 index 00000000..8536adb1 --- /dev/null +++ b/tests/installed_apps/apps/validation-schema/run-node.mjs @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const testPath = process.argv[2]; +if (!testPath) { + console.error('Usage: node run-node.mjs '); + process.exit(1); +} + +const mod = testPath.endsWith('.cjs') + ? createRequire(import.meta.url)(`./${testPath}`) + : await import(pathToFileURL(new URL(testPath, import.meta.url).pathname).href); + +const run = mod.run || mod.default?.run; +if (typeof run !== 'function') throw new Error(`${testPath} does not export run()`); + +const result = await run(); +console.log(result); +if (typeof result !== 'string' || !result.startsWith('PASS:')) process.exit(1); diff --git a/tests/installed_apps/apps/validation-schema/test-01-joi-yup.cjs b/tests/installed_apps/apps/validation-schema/test-01-joi-yup.cjs new file mode 100644 index 00000000..851e2229 --- /dev/null +++ b/tests/installed_apps/apps/validation-schema/test-01-joi-yup.cjs @@ -0,0 +1,14 @@ +const assert = require('node:assert'); +const Joi = require('joi'); +const yup = require('yup'); + +exports.run = async () => { + const joiSchema = Joi.object({ name: Joi.string().min(2).required(), count: Joi.number().integer().min(1).required() }); + assert.deepStrictEqual(joiSchema.validate({ name: 'ok', count: 3 }).value, { name: 'ok', count: 3 }); + assert(joiSchema.validate({ name: 'x', count: 0 }).error); + + const yupSchema = yup.object({ name: yup.string().required(), count: yup.number().min(1).required() }); + assert.deepStrictEqual(await yupSchema.validate({ name: 'ok', count: 3 }), { name: 'ok', count: 3 }); + await assert.rejects(() => yupSchema.validate({ name: '', count: 0 })); + return 'PASS: joi and yup validation packages execute from node_modules'; +}; diff --git a/tests/installed_apps/apps/validation-schema/test-02-superstruct-valibot.mjs b/tests/installed_apps/apps/validation-schema/test-02-superstruct-valibot.mjs new file mode 100644 index 00000000..291f7874 --- /dev/null +++ b/tests/installed_apps/apps/validation-schema/test-02-superstruct-valibot.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert'; +import { assert as structAssert, number, object, string } from 'superstruct'; +import * as v from 'valibot'; + +export const run = () => { + const StructSchema = object({ name: string(), count: number() }); + structAssert({ name: 'ok', count: 3 }, StructSchema); + assert.throws(() => structAssert({ name: 'ok', count: 'bad' }, StructSchema)); + + const ValibotSchema = v.object({ name: v.string(), count: v.number() }); + assert.deepStrictEqual(v.parse(ValibotSchema, { name: 'ok', count: 3 }), { name: 'ok', count: 3 }); + assert.throws(() => v.parse(ValibotSchema, { name: 'ok', count: 'bad' })); + return 'PASS: superstruct and valibot ESM validation packages execute from node_modules'; +}; diff --git a/tests/installed_apps/config.jsonc b/tests/installed_apps/config.jsonc index 91a0774f..c6854ff1 100644 --- a/tests/installed_apps/config.jsonc +++ b/tests/installed_apps/config.jsonc @@ -16,7 +16,94 @@ "test-10-already-evaluated-dependency.cjs": "CJS bridge can require an already evaluated ESM dependency", "test-11-module-sync-before-import-graph.cjs": "Graph scanning honors module-sync before import in package exports", "test-12-module-sync-before-imports-alias-graph.cjs": "Graph scanning honors module-sync before import in package imports aliases", - "test-13-scanner-false-positive-guards.cjs": "Graph scanning avoids property require, non-call createRequire, local createRequire, and nested CJS require false positives" + "test-13-scanner-false-positive-guards.cjs": "Graph scanning avoids property require, non-call createRequire, local createRequire, and nested CJS require false positives", + "test-14-exports-patterns.mjs": "Package exports wildcard patterns resolve for ESM, module-sync, and CJS require", + "test-15-imports-patterns.cjs": "Package imports wildcard patterns resolve for CJS require", + "test-16-shim-patterns.mjs": "OpenAI-style _shims/auto wildcard package exports resolve" + } + }, + "popular-pure-js": { + "category": "runnable", + "reason": "Popular pure-JS npm packages installed as a real app with node_modules attached as filesystem", + "tests": { + "test-01-cjs-utilities.cjs": "Classic CommonJS utilities and transitive dependencies", + "test-02-modern-esm.mjs": "Modern ESM and exports-heavy packages", + "test-03-date-fns-subpaths.mjs": "Package subpath exports", + "test-04-dotenv-fs.cjs": "Filesystem-backed configuration loading", + "test-05-ajv.cjs": "Larger CommonJS validation package graph", + "test-06-rxjs.mjs": "RxJS package exports and operator subpaths" + } + }, + "http-clients": { + "category": "runnable", + "reason": "HTTP client packages installed as a real app with node_modules attached as filesystem; tests avoid external network by using custom fetch/adapter paths", + "tests": { + "test-01-axios.cjs": "Axios CommonJS load, custom adapter, and interceptors", + "test-02-fetch-ky.mjs": "node-fetch and ky ESM package loading with local data/custom fetch paths", + "test-03-graphql-request.mjs": "graphql-request client execution with custom fetch" + } + }, + "crypto-auth": { + "category": "runnable", + "reason": "Authentication and crypto-adjacent pure-JS packages installed as a real app with node_modules attached as filesystem", + "tests": { + "test-01-jsonwebtoken-bcrypt.cjs": "jsonwebtoken and bcryptjs CommonJS execution", + "test-02-jose.mjs": "jose ESM JWT signing and verification", + "test-03-nanoid-cookie.mjs": "nanoid, cookie, and cookie-signature package interop" + } + }, + "data-formats": { + "category": "runnable", + "reason": "Data parsing and serialization packages installed as a real app with node_modules attached as filesystem", + "tests": { + "test-01-csv.cjs": "papaparse and csv-parse CommonJS CSV parsing", + "test-02-yaml-xml.cjs": "yaml and xml2js parsing/serialization", + "test-03-binary-protobuf.cjs": "msgpackr and protobufjs binary serialization" + } + }, + "fs-template-config": { + "category": "runnable", + "reason": "Configuration, templating, and filesystem glob packages installed as a real app with node_modules attached as filesystem", + "tests": { + "test-01-config-parsers.cjs": "ini and toml config parsing", + "test-02-template-engines.cjs": "ejs, handlebars, and mustache rendering", + "test-03-fast-glob-fs.cjs": "fast-glob filesystem traversal" + } + }, + "validation-schema": { + "category": "runnable", + "reason": "Validation packages installed as a real app with node_modules attached as filesystem", + "tests": { + "test-01-joi-yup.cjs": "joi and yup validation", + "test-02-superstruct-valibot.mjs": "superstruct and valibot validation" + } + }, + "logging-observability": { + "category": "runnable", + "reason": "Logging and observability packages installed as a real app with node_modules attached as filesystem; tests avoid subprocesses/transports", + "tests": { + "test-01-loggers.cjs": "pino, loglevel, and winston API loading without transports/processes", + "test-02-consola-otel.mjs": "consola and OpenTelemetry API loading" + } + }, + "cloud-sdk-offline": { + "category": "runnable", + "reason": "Cloud SDK packages installed as a real app with node_modules attached as filesystem; tests use offline constructors/API shapes only", + "tests": { + "test-01-openai.mjs": "OpenAI SDK offline client surface", + "test-02-anthropic.mjs": "Anthropic SDK offline client surface", + "test-03-aws-s3.mjs": "AWS S3 SDK offline client and command construction", + "test-04-stripe.cjs": "Stripe SDK offline client surface" + } + }, + "db-clients-offline": { + "category": "runnable", + "reason": "Database client packages installed as a real app with node_modules attached as filesystem; tests avoid network connections", + "tests": { + "test-01-sql-builders.cjs": "knex query builder offline execution", + "test-02-pg-mysql.cjs": "pg and mysql2 client construction without connecting", + "test-03-mongodb-redis.mjs": "mongodb and redis client construction without connecting", + "test-04-drizzle.mjs": "drizzle-orm schema helpers offline execution" } } } diff --git a/tests/installed_apps/report.md b/tests/installed_apps/report.md index ca1ebbc6..0cb43fc6 100644 --- a/tests/installed_apps/report.md +++ b/tests/installed_apps/report.md @@ -15,7 +15,16 @@ This report tracks compatibility for unbundled npm-installed apps attached to th | App | Status | Tests | Notes | |---|---:|---:|---| -| `module-interop` | Passing | 13/13 | Synthetic local npm packages covering module loader interop behavior. Verifies npm install with `--install-links`, Node baseline execution, then wasm-rquickjs execution from an attached `/app/node_modules` filesystem. | +| `module-interop` | Passing | 16/16 | Synthetic local npm packages covering module loader interop behavior. Verifies npm install with `--install-links`, Node baseline execution, then wasm-rquickjs execution from an attached `/app/node_modules` filesystem. | +| `popular-pure-js` | Passing | 6/6 | Popular pure-JS npm package smoke suite for attached `node_modules` execution. | +| `http-clients` | Passing | 3/3 | HTTP client package smoke suite using custom adapters/fetch paths to avoid external network. | +| `crypto-auth` | Passing | 3/3 | Auth and crypto-adjacent pure-JS package smoke suite. | +| `data-formats` | Passing | 3/3 | Data parsing and serialization package smoke suite. | +| `fs-template-config` | Passing | 3/3 | Configuration, templating, and filesystem glob package smoke suite. | +| `validation-schema` | Passing | 2/2 | Additional validation package smoke suite. | +| `logging-observability` | Passing | 2/2 | Logging and observability package smoke suite without subprocesses/transports. | +| `cloud-sdk-offline` | Passing | 4/4 | Cloud SDK package smoke suite using offline constructors/API shapes only. | +| `db-clients-offline` | Passing | 4/4 | Database client package smoke suite without network connections. | ## `module-interop` @@ -34,3 +43,81 @@ This report tracks compatibility for unbundled npm-installed apps attached to th | `test-11-module-sync-before-import-graph.cjs` | Passing | Package `exports` with `module-sync` before `import` are scanned in Node-compatible condition order. | | `test-12-module-sync-before-imports-alias-graph.cjs` | Passing | Package `imports` aliases with `module-sync` before `import` are scanned in Node-compatible condition order. | | `test-13-scanner-false-positive-guards.cjs` | Passing | Graph scanners avoid property require, non-call createRequire, local createRequire, and nested CJS require false positives. | +| `test-14-exports-patterns.mjs` | Passing | Package exports wildcard patterns resolve for ESM, module-sync, and CJS require. | +| `test-15-imports-patterns.cjs` | Passing | Package imports wildcard patterns resolve for CJS require. | +| `test-16-shim-patterns.mjs` | Passing | OpenAI-style `_shims/auto/*` wildcard package exports resolve. | + +## `popular-pure-js` + +| Test | Status | Coverage | +|---|---:|---| +| `test-01-cjs-utilities.cjs` | Passing | Classic CommonJS utilities: `lodash`, `semver`, `debug`, `ms`. | +| `test-02-modern-esm.mjs` | Passing | Modern ESM / exports-heavy packages: `chalk`, `zod`, `uuid`. | +| `test-03-date-fns-subpaths.mjs` | Passing | `date-fns` subpath exports. | +| `test-04-dotenv-fs.cjs` | Passing | `dotenv` reading config from attached filesystem. | +| `test-05-ajv.cjs` | Passing | Larger CommonJS validation graph with `ajv`. | +| `test-06-rxjs.mjs` | Passing | `rxjs` package exports and operator subpaths. | + +## `http-clients` + +| Test | Status | Coverage | +|---|---:|---| +| `test-01-axios.cjs` | Passing | Axios CommonJS load, custom adapter, and interceptors. | +| `test-02-fetch-ky.mjs` | Passing | node-fetch data URL execution and ky ESM package API loading. | +| `test-03-graphql-request.mjs` | Passing | graphql-request client execution with custom fetch. | + +## `crypto-auth` + +| Test | Status | Coverage | +|---|---:|---| +| `test-01-jsonwebtoken-bcrypt.cjs` | Passing | jsonwebtoken and bcryptjs CommonJS execution. | +| `test-02-jose.mjs` | Passing | jose ESM JWT signing and verification. | +| `test-03-nanoid-cookie.mjs` | Passing | nanoid, cookie, and cookie-signature package interop. | + +## `data-formats` + +| Test | Status | Coverage | +|---|---:|---| +| `test-01-csv.cjs` | Passing | papaparse and csv-parse CommonJS CSV parsing. | +| `test-02-yaml-xml.cjs` | Passing | yaml and xml2js parsing/serialization. | +| `test-03-binary-protobuf.cjs` | Passing | msgpackr and protobufjs binary serialization. | + +## `fs-template-config` + +| Test | Status | Coverage | +|---|---:|---| +| `test-01-config-parsers.cjs` | Passing | ini and toml config parsing. | +| `test-02-template-engines.cjs` | Passing | ejs, handlebars, and mustache rendering. | +| `test-03-fast-glob-fs.cjs` | Passing | fast-glob filesystem traversal. | + +## `validation-schema` + +| Test | Status | Coverage | +|---|---:|---| +| `test-01-joi-yup.cjs` | Passing | joi and yup validation. | +| `test-02-superstruct-valibot.mjs` | Passing | superstruct and valibot validation. | + +## `logging-observability` + +| Test | Status | Coverage | +|---|---:|---| +| `test-01-loggers.cjs` | Passing | pino, loglevel, and winston API loading without transports/processes. | +| `test-02-consola-otel.mjs` | Passing | consola and OpenTelemetry API loading. | + +## `cloud-sdk-offline` + +| Test | Status | Coverage | +|---|---:|---| +| `test-01-openai.mjs` | Passing | OpenAI SDK offline client surface. | +| `test-02-anthropic.mjs` | Passing | Anthropic SDK offline client surface. | +| `test-03-aws-s3.mjs` | Passing | AWS S3 SDK offline client and command construction. | +| `test-04-stripe.cjs` | Passing | Stripe SDK offline client surface. | + +## `db-clients-offline` + +| Test | Status | Coverage | +|---|---:|---| +| `test-01-sql-builders.cjs` | Passing | knex query builder offline execution. | +| `test-02-pg-mysql.cjs` | Passing | pg and mysql2 client construction without connecting. | +| `test-03-mongodb-redis.mjs` | Passing | mongodb and redis client construction without connecting. | +| `test-04-drizzle.mjs` | Passing | drizzle-orm schema helpers offline execution. | diff --git a/tests/runtime/installed_apps.rs b/tests/runtime/installed_apps.rs index 52878ee4..fd47b127 100644 --- a/tests/runtime/installed_apps.rs +++ b/tests/runtime/installed_apps.rs @@ -266,3 +266,299 @@ async fn installed_app_scanner_false_positive_guards( ) .await } + +#[test] +async fn installed_app_exports_patterns( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-14-exports-patterns.mjs", + ) + .await +} + +#[test] +async fn installed_app_imports_patterns( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "module-interop", + "test-15-imports-patterns.cjs", + ) + .await +} + +#[test] +async fn installed_app_shim_patterns( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "module-interop", "test-16-shim-patterns.mjs").await +} + +#[test] +async fn installed_app_popular_cjs_utilities( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "popular-pure-js", + "test-01-cjs-utilities.cjs", + ) + .await +} + +#[test] +async fn installed_app_popular_modern_esm( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "popular-pure-js", "test-02-modern-esm.mjs").await +} + +#[test] +async fn installed_app_popular_date_fns_subpaths( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "popular-pure-js", + "test-03-date-fns-subpaths.mjs", + ) + .await +} + +#[test] +async fn installed_app_popular_dotenv_fs( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "popular-pure-js", "test-04-dotenv-fs.cjs").await +} + +#[test] +async fn installed_app_popular_ajv( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "popular-pure-js", "test-05-ajv.cjs").await +} + +#[test] +async fn installed_app_popular_rxjs( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "popular-pure-js", "test-06-rxjs.mjs").await +} + +#[test] +async fn installed_app_http_axios( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "http-clients", "test-01-axios.cjs").await +} + +#[test] +async fn installed_app_http_fetch_ky( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "http-clients", "test-02-fetch-ky.mjs").await +} + +#[test] +async fn installed_app_http_graphql_request( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "http-clients", "test-03-graphql-request.mjs").await +} + +#[test] +async fn installed_app_crypto_jsonwebtoken_bcrypt( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "crypto-auth", + "test-01-jsonwebtoken-bcrypt.cjs", + ) + .await +} + +#[test] +async fn installed_app_crypto_jose( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "crypto-auth", "test-02-jose.mjs").await +} + +#[test] +async fn installed_app_crypto_nanoid_cookie( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "crypto-auth", "test-03-nanoid-cookie.mjs").await +} + +#[test] +async fn installed_app_data_csv( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "data-formats", "test-01-csv.cjs").await +} + +#[test] +async fn installed_app_data_yaml_xml( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "data-formats", "test-02-yaml-xml.cjs").await +} + +#[test] +async fn installed_app_data_binary_protobuf( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "data-formats", "test-03-binary-protobuf.cjs").await +} + +#[test] +async fn installed_app_fs_config_parsers( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "fs-template-config", + "test-01-config-parsers.cjs", + ) + .await +} + +#[test] +async fn installed_app_fs_template_engines( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "fs-template-config", + "test-02-template-engines.cjs", + ) + .await +} + +#[test] +async fn installed_app_fs_fast_glob( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "fs-template-config", + "test-03-fast-glob-fs.cjs", + ) + .await +} + +#[test] +async fn installed_app_validation_joi_yup( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "validation-schema", "test-01-joi-yup.cjs").await +} + +#[test] +async fn installed_app_validation_superstruct_valibot( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "validation-schema", + "test-02-superstruct-valibot.mjs", + ) + .await +} + +#[test] +async fn installed_app_logging_loggers( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "logging-observability", + "test-01-loggers.cjs", + ) + .await +} + +#[test] +async fn installed_app_logging_consola_otel( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "logging-observability", + "test-02-consola-otel.mjs", + ) + .await +} + +#[test] +async fn installed_app_cloud_openai( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "cloud-sdk-offline", "test-01-openai.mjs").await +} + +#[test] +async fn installed_app_cloud_anthropic( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "cloud-sdk-offline", "test-02-anthropic.mjs").await +} + +#[test] +async fn installed_app_cloud_aws_s3( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "cloud-sdk-offline", "test-03-aws-s3.mjs").await +} + +#[test] +async fn installed_app_cloud_stripe( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "cloud-sdk-offline", "test-04-stripe.cjs").await +} + +#[test] +async fn installed_app_db_sql_builders( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "db-clients-offline", + "test-01-sql-builders.cjs", + ) + .await +} + +#[test] +async fn installed_app_db_pg_mysql( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "db-clients-offline", "test-02-pg-mysql.cjs").await +} + +#[test] +async fn installed_app_db_mongodb_redis( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test( + compiled_test, + "db-clients-offline", + "test-03-mongodb-redis.mjs", + ) + .await +} + +#[test] +async fn installed_app_db_drizzle( + #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + run_installed_app_test(compiled_test, "db-clients-offline", "test-04-drizzle.mjs").await +} From 381fbb12ae8dcb4a383f3dd4379bbdb1b4ce10b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 17 Jun 2026 10:45:46 +0200 Subject: [PATCH 10/42] installed app compatibility tests with runtime harness and reporting setup --- AGENTS.md | 13 + README.md | 3 +- tests/common/mod.rs | 150 ++++++++++ tests/installed_apps/README.md | 129 ++++++++ tests/installed_apps/report.md | 160 +++++----- tests/installed_apps_report.rs | 231 +++++++++++++++ tests/runtime/installed_apps.rs | 510 +++++--------------------------- 7 files changed, 685 insertions(+), 511 deletions(-) create mode 100644 tests/installed_apps/README.md create mode 100644 tests/installed_apps_report.rs diff --git a/AGENTS.md b/AGENTS.md index bb9a670e..d5885092 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,6 +149,19 @@ The `tests/node_compat/` directory contains vendored Node.js test files used to Load the `fixing-node-compat-test` skill for the full workflow when making a test pass. +## Installed App Compatibility Tests + +The `tests/installed_apps/` directory contains CI-enforced runtime tests for unbundled npm apps installed with real `node_modules` and attached to the component filesystem as `/app`. This suite is separate from `tests/libraries/`, which documents Rollup-bundled package compatibility. + +Important rules: + +- `tests/installed_apps/config.jsonc` is the source of truth for installed-app tests. Runtime tests in `tests/runtime/installed_apps.rs` are generated from it. +- Update `tests/installed_apps/report.md` by running `cargo test --test installed_apps_report --features use-golem-wasmtime -- --nocapture` after config changes. +- Add app fixtures under `tests/installed_apps/apps//` with a `package.json`, `run-node.mjs`, and `test-*` files exporting `run()`. +- Installed-app tests run `npm install --install-links --ignore-scripts --no-audit --no-fund`, verify the raw test with host Node.js, then run it through wasm-rquickjs from `/app`. +- Keep this suite focused on real `node_modules` module loading, CJS/ESM interop, package maps, filesystem-backed package behavior, and high-value smoke tests. Do not use it for native `.node`, WASM artifact loading, subprocess-heavy, or live-network scenarios. +- Before running installed-app runtime tests after skeleton changes, run `./cleanup-skeleton.sh`, then use `cargo test --test runtime --features use-golem-wasmtime -- installed_app --nocapture` or a narrower installed-app filter. + ### ⚠️ Keeping `node_compat` and `node_compat_report` in sync The `tests/node_compat.rs` test harness and the `tests/node_compat_report.rs` report generator are **two separate runners** with independent Host types, linker setups, and WASI context configurations. **Whenever you change the WASI context, linker setup, or Host configuration in `tests/common/mod.rs` (used by `node_compat`), you MUST apply the same change to `tests/node_compat_report.rs`** — otherwise the two runners will produce different results. diff --git a/README.md b/README.md index 65851493..c914ca23 100644 --- a/README.md +++ b/README.md @@ -555,7 +555,7 @@ Compatibility stubs — no V8 inspector in WASM. node:module - `require`, `require.resolve`, `createRequire`, `builtinModules`, `isBuiltin`, `runMain`, `_nodeModulePaths` -- Package resolution supports `package.json` `main`, exact `exports` root/subpath maps, and exact `imports` maps. CJS resolution recognizes `golem`, `node`, `require`, `module-sync`, and `default` conditions; ESM resolution recognizes `golem`, `node`, `import`, and `default`. Package `imports` can target relative files, external packages, and `node:` builtins. +- Package resolution supports `package.json` `main`, `exports` root/subpath maps, wildcard `exports` patterns, `imports` maps, and wildcard `imports` patterns. CJS resolution recognizes `golem`, `node`, `require`, `module-sync`, and `default` conditions; ESM resolution recognizes `golem`, `node`, `import`, `module-sync`, and `default`. Package `imports` can target relative files, external packages, and `node:` builtins. @@ -803,6 +803,7 @@ Compatibility stubs — workers are not supported in single-threaded WASM. ### Compatibility Reports - [NPM Library Compatibility Tracker](tests/libraries/libraries.md) — test results for popular npm packages +- [Installed App Compatibility Report](tests/installed_apps/report.md) — CI-enforced smoke tests for unbundled npm apps with real `node_modules` - [Node.js v22 Compatibility Report](tests/node_compat/report.md) — per-test results for vendored Node.js test suite ### Coming from QuickJS diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 80c7016a..f2f248b6 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -150,6 +150,61 @@ pub struct NodeCompatTestEntry { pub subtests: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum InstalledAppCategory { + Runnable, + KnownGap, + Deferred, +} + +impl InstalledAppCategory { + pub fn from_config_value(value: &str) -> anyhow::Result { + match value { + "runnable" => Ok(Self::Runnable), + "known-gap" | "gap" => Ok(Self::KnownGap), + "deferred" => Ok(Self::Deferred), + other => anyhow::bail!("unknown installed_apps category '{other}'"), + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Runnable => "runnable", + Self::KnownGap => "known gap", + Self::Deferred => "deferred", + } + } + + pub fn status_label(self) -> &'static str { + match self { + Self::Runnable => "Passing", + Self::KnownGap => "Known gap", + Self::Deferred => "Deferred", + } + } + + pub fn should_ignore_in_runner(self) -> bool { + !matches!(self, Self::Runnable) + } +} + +#[derive(Debug, Clone)] +pub struct InstalledAppTestEntry { + pub file: String, + pub category: InstalledAppCategory, + pub coverage: String, + pub reason: Option, + pub timeout_secs: u64, +} + +#[derive(Debug, Clone)] +pub struct InstalledAppEntry { + pub name: String, + pub category: InstalledAppCategory, + pub reason: Option, + pub tests: Vec, +} + /// Extract the numeric index from a subtest name like "block_00_foo" or "test_03_bar". /// Panics if the name doesn't match the expected format (config is authoritative). pub fn extract_node_compat_subtest_index(name: &str) -> usize { @@ -278,6 +333,101 @@ pub fn load_node_compat_config(path: &str) -> anyhow::Result anyhow::Result> { + let content = fs::read_to_string(path)?; + let json_str = strip_jsonc_comments(&content); + let value: serde_json::Value = serde_json::from_str(&json_str)?; + + let apps_obj = value + .get("apps") + .and_then(|v| v.as_object()) + .ok_or_else(|| anyhow::anyhow!("installed_apps config missing 'apps' object"))?; + + let mut apps = Vec::new(); + for (app_name, opts) in apps_obj { + let category = installed_app_category_from_value(opts, None)?; + let reason = opts + .get("reason") + .and_then(|v| v.as_str()) + .map(str::to_string); + let default_timeout_secs = opts + .get("timeout") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_NODE_COMPAT_TEST_TIMEOUT_SECS); + let tests_obj = opts + .get("tests") + .and_then(|v| v.as_object()) + .ok_or_else(|| anyhow::anyhow!("installed app '{app_name}' missing 'tests' object"))?; + + let mut tests = Vec::new(); + for (test_file, test_opts) in tests_obj { + let test_category = installed_app_category_from_value(test_opts, Some(category))?; + let (coverage, test_reason, timeout_secs) = match test_opts { + serde_json::Value::String(coverage) => { + (coverage.clone(), reason.clone(), default_timeout_secs) + } + serde_json::Value::Object(_) => { + let coverage = test_opts + .get("coverage") + .or_else(|| test_opts.get("description")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + anyhow::anyhow!( + "installed app '{app_name}' test '{test_file}' missing coverage" + ) + })? + .to_string(); + let test_reason = test_opts + .get("reason") + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| reason.clone()); + let timeout_secs = test_opts + .get("timeout") + .and_then(|v| v.as_u64()) + .unwrap_or(default_timeout_secs); + (coverage, test_reason, timeout_secs) + } + _ => anyhow::bail!( + "installed app '{app_name}' test '{test_file}' must be a coverage string or object" + ), + }; + + tests.push(InstalledAppTestEntry { + file: test_file.clone(), + category: test_category, + coverage, + reason: test_reason, + timeout_secs, + }); + } + tests.sort_by(|a, b| a.file.cmp(&b.file)); + + apps.push(InstalledAppEntry { + name: app_name.clone(), + category, + reason, + tests, + }); + } + apps.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(apps) +} + +fn installed_app_category_from_value( + value: &serde_json::Value, + inherited: Option, +) -> anyhow::Result { + if let Some(category) = value.get("category").and_then(|v| v.as_str()) { + return InstalledAppCategory::from_config_value(category); + } + if value.get("skip").and_then(|v| v.as_bool()).unwrap_or(false) { + return Ok(InstalledAppCategory::KnownGap); + } + Ok(inherited.unwrap_or(InstalledAppCategory::Runnable)) +} + /// Recursively copy a directory and all its contents to a destination. pub fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> anyhow::Result<()> { fs::create_dir_all(dst)?; diff --git a/tests/installed_apps/README.md b/tests/installed_apps/README.md new file mode 100644 index 00000000..4988bc6a --- /dev/null +++ b/tests/installed_apps/README.md @@ -0,0 +1,129 @@ +# Installed App Compatibility Tests + +This suite tests unbundled npm-installed apps with real `node_modules` attached to the component filesystem. It is intentionally separate from `tests/libraries/`, which tests Rollup-bundled package usage and records human compatibility notes in `tests/libraries/libraries.md`. + +## What This Suite Covers + +Use installed-app tests for: + +- Node-style package resolution from a filesystem `node_modules` tree +- package `exports` / `imports`, including wildcard patterns +- CJS/ESM interop and same-process `require(esm)` behavior +- package graphs that behave differently when bundled versus installed +- small smoke tests for pure-JS packages that should run without subprocesses or live services + +Do not use installed-app tests for native `.node` bindings, packages that load WASM artifacts, subprocess-heavy behavior, or live network/cloud service calls. + +## Source Of Truth + +`tests/installed_apps/config.jsonc` is the source of truth. Runtime tests in `tests/runtime/installed_apps.rs` are generated from this config, and `tests/installed_apps/report.md` is generated from the same config. + +Each app has this shape: + +```jsonc +{ + "apps": { + "example-app": { + "category": "runnable", + "reason": "Short suite description", + "tests": { + "test-01-basic.mjs": "Coverage summary shown in the report" + } + } + } +} +``` + +Test entries can also be objects when a per-test category, reason, or timeout is needed: + +```jsonc +"test-02-edge.cjs": { + "category": "known-gap", + "coverage": "Coverage summary", + "reason": "Specific gap explanation", + "timeout": 180 +} +``` + +Supported categories are: + +| Category | Runner behavior | Report meaning | +|---|---|---| +| `runnable` | Runs in `cargo test --test runtime` | Expected to pass | +| `known-gap` | Ignored | Public behavior is in scope but missing/incomplete | +| `deferred` | Ignored | Intentionally outside this installed-app suite's current scope | + +## App Directory Layout + +Each app lives under `tests/installed_apps/apps//`: + +```text +tests/installed_apps/apps/example-app/ +├── package.json +├── run-node.mjs +├── test-01-basic.mjs +└── test-02-edge.cjs +``` + +`package.json` should pin direct dependency versions. The harness installs dependencies with: + +```sh +npm install --install-links --ignore-scripts --no-audit --no-fund +``` + +`run-node.mjs` should import or require the requested test file, call its exported `run()` function, print the returned result, and fail if it does not start with `PASS:`. + +## Test Format + +Each test file must export `run()` and return a `PASS:` string: + +```js +import assert from 'node:assert'; + +export async function run() { + assert.strictEqual(1 + 1, 2); + return 'PASS: basic behavior works'; +} +``` + +CommonJS tests can use `.cjs` and `module.exports.run = ...`. + +## How Runtime Tests Run + +For every runnable config entry, the harness: + +1. Copies the app directory to a temporary directory. +2. Runs `npm install --install-links --ignore-scripts --no-audit --no-fund`. +3. Verifies the raw app test with host Node.js: `node run-node.mjs `. +4. Copies the installed app into the WASI preopen as `/app`. +5. Runs `examples/runtime/installed-app-runner`, which imports or requires the test from `/app` and executes it against real `/app/node_modules`. + +## Commands + +Run the installed-app suite after skeleton changes: + +```sh +./cleanup-skeleton.sh +cargo test --test runtime --features use-golem-wasmtime -- installed_app --nocapture +``` + +Run a narrower filter: + +```sh +cargo test --test runtime --features use-golem-wasmtime -- installed_app__module_interop --nocapture +``` + +Regenerate the report after `config.jsonc` changes: + +```sh +cargo test --test installed_apps_report --features use-golem-wasmtime -- --nocapture +``` + +## Relationship To Rollup Library Tests + +| Suite | Purpose | Execution | +|---|---|---| +| `tests/libraries/` | Documents package compatibility when bundled with Rollup like the Golem CLI pipeline | Manual/agent workflow using Rollup, generated wrapper crates, and `wasmtime run` | +| `tests/installed_apps/` | CI-enforced regression tests for unbundled apps with real filesystem `node_modules` | Rust runtime harness generated from `config.jsonc` | + +The same npm package may be covered in both suites for different reasons. Rollup tests answer whether bundled usage works; installed-app tests answer whether Node-style installed package loading works. diff --git a/tests/installed_apps/report.md b/tests/installed_apps/report.md index 0cb43fc6..9e69d4f0 100644 --- a/tests/installed_apps/report.md +++ b/tests/installed_apps/report.md @@ -1,6 +1,8 @@ # Installed App Compatibility Report -This report tracks compatibility for unbundled npm-installed apps attached to the component as a filesystem. It is intentionally separate from `tests/libraries/libraries.md`, which tests Rollup-bundled library usage. +Generated: 2026-06-17 | Source: `tests/installed_apps/config.jsonc` | Engine: wasm-rquickjs (QuickJS) + +This report tracks compatibility for unbundled npm-installed apps attached to the component as a filesystem. It is intentionally separate from `tests/libraries/libraries.md`, which tests Rollup-bundled library usage. Runtime tests in `tests/runtime/installed_apps.rs` are generated from the same config file as this report. ## Scope @@ -11,113 +13,137 @@ This report tracks compatibility for unbundled npm-installed apps attached to th | package `exports` / `imports` | Child process execution | | CJS/ESM interop and same-process cycles | CLI preload/eval/warning behavior | +## How It Runs + +For every runnable test, the runtime harness: + +1. Copies `tests/installed_apps/apps/` to a temporary directory. +2. Runs `npm install --install-links --ignore-scripts --no-audit --no-fund`. +3. Verifies the raw app test on host Node.js via `node run-node.mjs `. +4. Copies the installed app into the WASI preopen as `/app`. +5. Executes the test through `examples/runtime/installed-app-runner` against real `/app/node_modules`. + +## Summary + +Runnable installed-app compatibility: **46/46** tests. + +| Classification | Count | +|---|---:| +| Passing (runnable) | 46 | +| Known gap | 0 | +| Deferred | 0 | + ## Apps | App | Status | Tests | Notes | |---|---:|---:|---| -| `module-interop` | Passing | 16/16 | Synthetic local npm packages covering module loader interop behavior. Verifies npm install with `--install-links`, Node baseline execution, then wasm-rquickjs execution from an attached `/app/node_modules` filesystem. | -| `popular-pure-js` | Passing | 6/6 | Popular pure-JS npm package smoke suite for attached `node_modules` execution. | -| `http-clients` | Passing | 3/3 | HTTP client package smoke suite using custom adapters/fetch paths to avoid external network. | -| `crypto-auth` | Passing | 3/3 | Auth and crypto-adjacent pure-JS package smoke suite. | -| `data-formats` | Passing | 3/3 | Data parsing and serialization package smoke suite. | -| `fs-template-config` | Passing | 3/3 | Configuration, templating, and filesystem glob package smoke suite. | -| `validation-schema` | Passing | 2/2 | Additional validation package smoke suite. | -| `logging-observability` | Passing | 2/2 | Logging and observability package smoke suite without subprocesses/transports. | -| `cloud-sdk-offline` | Passing | 4/4 | Cloud SDK package smoke suite using offline constructors/API shapes only. | -| `db-clients-offline` | Passing | 4/4 | Database client package smoke suite without network connections. | +| `cloud-sdk-offline` | Passing | 4/4 | Cloud SDK packages installed as a real app with node_modules attached as filesystem; tests use offline constructors/API shapes only | +| `crypto-auth` | Passing | 3/3 | Authentication and crypto-adjacent pure-JS packages installed as a real app with node_modules attached as filesystem | +| `data-formats` | Passing | 3/3 | Data parsing and serialization packages installed as a real app with node_modules attached as filesystem | +| `db-clients-offline` | Passing | 4/4 | Database client packages installed as a real app with node_modules attached as filesystem; tests avoid network connections | +| `fs-template-config` | Passing | 3/3 | Configuration, templating, and filesystem glob packages installed as a real app with node_modules attached as filesystem | +| `http-clients` | Passing | 3/3 | HTTP client packages installed as a real app with node_modules attached as filesystem; tests avoid external network by using custom fetch/adapter paths | +| `logging-observability` | Passing | 2/2 | Logging and observability packages installed as a real app with node_modules attached as filesystem; tests avoid subprocesses/transports | +| `module-interop` | Passing | 16/16 | Synthetic npm-installed app covering CJS/ESM/package graph behavior | +| `popular-pure-js` | Passing | 6/6 | Popular pure-JS npm packages installed as a real app with node_modules attached as filesystem | +| `validation-schema` | Passing | 2/2 | Validation packages installed as a real app with node_modules attached as filesystem | -## `module-interop` +## `cloud-sdk-offline` | Test | Status | Coverage | |---|---:|---| -| `test-01-esm-import-cjs.js` | Passing | ESM app imports named/default exports from installed CJS packages, including a package reexport. | -| `test-02-cjs-require-esm.cjs` | Passing | CJS app requires an installed synchronous ESM package. | -| `test-03-package-exports-imports.js` | Passing | Conditional package `exports`, subpath exports, and package `imports` aliases. | -| `test-04-cycle-require-esm.cjs` | Passing | Installed package CJS `require(esm)` cycle reports `ERR_REQUIRE_CYCLE_MODULE`. | -| `test-05-tla-require.cjs` | Passing | Installed TLA ESM rejects CJS `require()` with `ERR_REQUIRE_ASYNC_MODULE`; dynamic import still works. | -| `test-06-conditional-import-graph.cjs` | Passing | Static ESM package imports in the graph use import conditions when detecting cycles. | -| `test-07-conditional-import-no-false-positive.cjs` | Passing | Static ESM package imports do not pre-mark module-sync branches and reject valid graphs. | -| `test-08-conditional-imports-alias-graph.cjs` | Passing | Package `imports` aliases in ESM use import conditions when detecting cycles. | -| `test-09-create-require-alias-cycle.cjs` | Passing | ESM `createRequire` alias bridges participate in cycle detection. | -| `test-10-already-evaluated-dependency.cjs` | Passing | CJS bridge can require an already evaluated ESM dependency. | -| `test-11-module-sync-before-import-graph.cjs` | Passing | Package `exports` with `module-sync` before `import` are scanned in Node-compatible condition order. | -| `test-12-module-sync-before-imports-alias-graph.cjs` | Passing | Package `imports` aliases with `module-sync` before `import` are scanned in Node-compatible condition order. | -| `test-13-scanner-false-positive-guards.cjs` | Passing | Graph scanners avoid property require, non-call createRequire, local createRequire, and nested CJS require false positives. | -| `test-14-exports-patterns.mjs` | Passing | Package exports wildcard patterns resolve for ESM, module-sync, and CJS require. | -| `test-15-imports-patterns.cjs` | Passing | Package imports wildcard patterns resolve for CJS require. | -| `test-16-shim-patterns.mjs` | Passing | OpenAI-style `_shims/auto/*` wildcard package exports resolve. | +| `test-01-openai.mjs` | Passing | OpenAI SDK offline client surface | +| `test-02-anthropic.mjs` | Passing | Anthropic SDK offline client surface | +| `test-03-aws-s3.mjs` | Passing | AWS S3 SDK offline client and command construction | +| `test-04-stripe.cjs` | Passing | Stripe SDK offline client surface | -## `popular-pure-js` +## `crypto-auth` | Test | Status | Coverage | |---|---:|---| -| `test-01-cjs-utilities.cjs` | Passing | Classic CommonJS utilities: `lodash`, `semver`, `debug`, `ms`. | -| `test-02-modern-esm.mjs` | Passing | Modern ESM / exports-heavy packages: `chalk`, `zod`, `uuid`. | -| `test-03-date-fns-subpaths.mjs` | Passing | `date-fns` subpath exports. | -| `test-04-dotenv-fs.cjs` | Passing | `dotenv` reading config from attached filesystem. | -| `test-05-ajv.cjs` | Passing | Larger CommonJS validation graph with `ajv`. | -| `test-06-rxjs.mjs` | Passing | `rxjs` package exports and operator subpaths. | +| `test-01-jsonwebtoken-bcrypt.cjs` | Passing | jsonwebtoken and bcryptjs CommonJS execution | +| `test-02-jose.mjs` | Passing | jose ESM JWT signing and verification | +| `test-03-nanoid-cookie.mjs` | Passing | nanoid, cookie, and cookie-signature package interop | -## `http-clients` +## `data-formats` | Test | Status | Coverage | |---|---:|---| -| `test-01-axios.cjs` | Passing | Axios CommonJS load, custom adapter, and interceptors. | -| `test-02-fetch-ky.mjs` | Passing | node-fetch data URL execution and ky ESM package API loading. | -| `test-03-graphql-request.mjs` | Passing | graphql-request client execution with custom fetch. | +| `test-01-csv.cjs` | Passing | papaparse and csv-parse CommonJS CSV parsing | +| `test-02-yaml-xml.cjs` | Passing | yaml and xml2js parsing/serialization | +| `test-03-binary-protobuf.cjs` | Passing | msgpackr and protobufjs binary serialization | -## `crypto-auth` +## `db-clients-offline` | Test | Status | Coverage | |---|---:|---| -| `test-01-jsonwebtoken-bcrypt.cjs` | Passing | jsonwebtoken and bcryptjs CommonJS execution. | -| `test-02-jose.mjs` | Passing | jose ESM JWT signing and verification. | -| `test-03-nanoid-cookie.mjs` | Passing | nanoid, cookie, and cookie-signature package interop. | +| `test-01-sql-builders.cjs` | Passing | knex query builder offline execution | +| `test-02-pg-mysql.cjs` | Passing | pg and mysql2 client construction without connecting | +| `test-03-mongodb-redis.mjs` | Passing | mongodb and redis client construction without connecting | +| `test-04-drizzle.mjs` | Passing | drizzle-orm schema helpers offline execution | -## `data-formats` +## `fs-template-config` | Test | Status | Coverage | |---|---:|---| -| `test-01-csv.cjs` | Passing | papaparse and csv-parse CommonJS CSV parsing. | -| `test-02-yaml-xml.cjs` | Passing | yaml and xml2js parsing/serialization. | -| `test-03-binary-protobuf.cjs` | Passing | msgpackr and protobufjs binary serialization. | +| `test-01-config-parsers.cjs` | Passing | ini and toml config parsing | +| `test-02-template-engines.cjs` | Passing | ejs, handlebars, and mustache rendering | +| `test-03-fast-glob-fs.cjs` | Passing | fast-glob filesystem traversal | -## `fs-template-config` +## `http-clients` | Test | Status | Coverage | |---|---:|---| -| `test-01-config-parsers.cjs` | Passing | ini and toml config parsing. | -| `test-02-template-engines.cjs` | Passing | ejs, handlebars, and mustache rendering. | -| `test-03-fast-glob-fs.cjs` | Passing | fast-glob filesystem traversal. | +| `test-01-axios.cjs` | Passing | Axios CommonJS load, custom adapter, and interceptors | +| `test-02-fetch-ky.mjs` | Passing | node-fetch and ky ESM package loading with local data/custom fetch paths | +| `test-03-graphql-request.mjs` | Passing | graphql-request client execution with custom fetch | -## `validation-schema` +## `logging-observability` | Test | Status | Coverage | |---|---:|---| -| `test-01-joi-yup.cjs` | Passing | joi and yup validation. | -| `test-02-superstruct-valibot.mjs` | Passing | superstruct and valibot validation. | +| `test-01-loggers.cjs` | Passing | pino, loglevel, and winston API loading without transports/processes | +| `test-02-consola-otel.mjs` | Passing | consola and OpenTelemetry API loading | -## `logging-observability` +## `module-interop` | Test | Status | Coverage | |---|---:|---| -| `test-01-loggers.cjs` | Passing | pino, loglevel, and winston API loading without transports/processes. | -| `test-02-consola-otel.mjs` | Passing | consola and OpenTelemetry API loading. | +| `test-01-esm-import-cjs.js` | Passing | ESM app imports named/default exports from installed CJS packages | +| `test-02-cjs-require-esm.cjs` | Passing | CJS app requires an installed synchronous ESM package | +| `test-03-package-exports-imports.js` | Passing | Installed packages use conditional exports, subpaths, and package imports aliases | +| `test-04-cycle-require-esm.cjs` | Passing | CJS require(esm) cycle inside an installed package reports ERR_REQUIRE_CYCLE_MODULE | +| `test-05-tla-require.cjs` | Passing | CJS require() of installed TLA ESM reports ERR_REQUIRE_ASYNC_MODULE and dynamic import still works | +| `test-06-conditional-import-graph.cjs` | Passing | Graph scanning follows import conditions for static ESM package imports | +| `test-07-conditional-import-no-false-positive.cjs` | Passing | Graph scanning does not mark module-sync branches for static ESM package imports | +| `test-08-conditional-imports-alias-graph.cjs` | Passing | Graph scanning follows import conditions for package imports aliases | +| `test-09-create-require-alias-cycle.cjs` | Passing | Graph scanning handles createRequire aliases in ESM modules | +| `test-10-already-evaluated-dependency.cjs` | Passing | CJS bridge can require an already evaluated ESM dependency | +| `test-11-module-sync-before-import-graph.cjs` | Passing | Graph scanning honors module-sync before import in package exports | +| `test-12-module-sync-before-imports-alias-graph.cjs` | Passing | Graph scanning honors module-sync before import in package imports aliases | +| `test-13-scanner-false-positive-guards.cjs` | Passing | Graph scanning avoids property require, non-call createRequire, local createRequire, and nested CJS require false positives | +| `test-14-exports-patterns.mjs` | Passing | Package exports wildcard patterns resolve for ESM, module-sync, and CJS require | +| `test-15-imports-patterns.cjs` | Passing | Package imports wildcard patterns resolve for CJS require | +| `test-16-shim-patterns.mjs` | Passing | OpenAI-style _shims/auto wildcard package exports resolve | -## `cloud-sdk-offline` +## `popular-pure-js` | Test | Status | Coverage | |---|---:|---| -| `test-01-openai.mjs` | Passing | OpenAI SDK offline client surface. | -| `test-02-anthropic.mjs` | Passing | Anthropic SDK offline client surface. | -| `test-03-aws-s3.mjs` | Passing | AWS S3 SDK offline client and command construction. | -| `test-04-stripe.cjs` | Passing | Stripe SDK offline client surface. | +| `test-01-cjs-utilities.cjs` | Passing | Classic CommonJS utilities and transitive dependencies | +| `test-02-modern-esm.mjs` | Passing | Modern ESM and exports-heavy packages | +| `test-03-date-fns-subpaths.mjs` | Passing | Package subpath exports | +| `test-04-dotenv-fs.cjs` | Passing | Filesystem-backed configuration loading | +| `test-05-ajv.cjs` | Passing | Larger CommonJS validation package graph | +| `test-06-rxjs.mjs` | Passing | RxJS package exports and operator subpaths | -## `db-clients-offline` +## `validation-schema` | Test | Status | Coverage | |---|---:|---| -| `test-01-sql-builders.cjs` | Passing | knex query builder offline execution. | -| `test-02-pg-mysql.cjs` | Passing | pg and mysql2 client construction without connecting. | -| `test-03-mongodb-redis.mjs` | Passing | mongodb and redis client construction without connecting. | -| `test-04-drizzle.mjs` | Passing | drizzle-orm schema helpers offline execution. | +| `test-01-joi-yup.cjs` | Passing | joi and yup validation | +| `test-02-superstruct-valibot.mjs` | Passing | superstruct and valibot validation | + +## Non-Runnable Tests + +_No non-runnable installed-app tests._ diff --git a/tests/installed_apps_report.rs b/tests/installed_apps_report.rs new file mode 100644 index 00000000..ba6e87c4 --- /dev/null +++ b/tests/installed_apps_report.rs @@ -0,0 +1,231 @@ +//! Installed app compatibility report generator. +//! +//! This report is generated from tests/installed_apps/config.jsonc. Runtime tests in +//! tests/runtime/installed_apps.rs use the same config as their source of truth. +//! +//! Usage: +//! cargo test --test installed_apps_report -- --nocapture +//! +//! The report is written to tests/installed_apps/report.md. + +test_r::enable!(); + +#[allow(dead_code)] +mod common; + +use common::{InstalledAppCategory, InstalledAppEntry, InstalledAppTestEntry}; +use std::collections::BTreeMap; +use std::fs; +use test_r::test; + +const CONFIG_PATH: &str = "tests/installed_apps/config.jsonc"; +const REPORT_PATH: &str = "tests/installed_apps/report.md"; + +#[derive(Debug, Clone, Copy, Default)] +struct CategoryCounts { + runnable: usize, + known_gap: usize, + deferred: usize, +} + +impl CategoryCounts { + fn add(&mut self, category: InstalledAppCategory) { + match category { + InstalledAppCategory::Runnable => self.runnable += 1, + InstalledAppCategory::KnownGap => self.known_gap += 1, + InstalledAppCategory::Deferred => self.deferred += 1, + } + } + + fn total(self) -> usize { + self.runnable + self.known_gap + self.deferred + } + + fn status(self) -> &'static str { + if self.known_gap == 0 && self.deferred == 0 { + "Passing" + } else if self.runnable > 0 { + "Partial" + } else if self.known_gap > 0 { + "Known gap" + } else { + "Deferred" + } + } +} + +#[test] +fn generate_installed_apps_report() -> anyhow::Result<()> { + let apps = common::load_installed_apps_config(CONFIG_PATH)?; + let mut totals = CategoryCounts::default(); + for app in &apps { + for test in &app.tests { + totals.add(test.category); + } + } + + let mut report = String::new(); + report.push_str("# Installed App Compatibility Report\n\n"); + report.push_str(&format!( + "Generated: {} | Source: `{CONFIG_PATH}` | Engine: wasm-rquickjs (QuickJS)\n\n", + now_date() + )); + report.push_str( + "This report tracks compatibility for unbundled npm-installed apps attached to the \ + component as a filesystem. It is intentionally separate from `tests/libraries/libraries.md`, \ + which tests Rollup-bundled library usage. Runtime tests in `tests/runtime/installed_apps.rs` \ + are generated from the same config file as this report.\n\n", + ); + + push_scope(&mut report); + push_how_it_runs(&mut report); + push_summary(&mut report, totals, &apps); + push_app_sections(&mut report, &apps); + push_non_runnable(&mut report, &apps); + + fs::write(REPORT_PATH, &report)?; + + println!("Report written to {REPORT_PATH}"); + println!( + "Installed app tests: {}/{} runnable, {} known gap, {} deferred", + totals.runnable, + totals.total(), + totals.known_gap, + totals.deferred + ); + + Ok(()) +} + +fn push_scope(report: &mut String) { + report.push_str("## Scope\n\n"); + report.push_str("| Included | Deferred |\n"); + report.push_str("|---|---|\n"); + report.push_str("| Pure JavaScript packages installed with npm | Native `.node` bindings |\n"); + report.push_str("| `node_modules` package resolution | Packages that load WASM artifacts |\n"); + report.push_str("| package `exports` / `imports` | Child process execution |\n"); + report.push_str( + "| CJS/ESM interop and same-process cycles | CLI preload/eval/warning behavior |\n\n", + ); +} + +fn push_how_it_runs(report: &mut String) { + report.push_str("## How It Runs\n\n"); + report.push_str("For every runnable test, the runtime harness:\n\n"); + report.push_str("1. Copies `tests/installed_apps/apps/` to a temporary directory.\n"); + report + .push_str("2. Runs `npm install --install-links --ignore-scripts --no-audit --no-fund`.\n"); + report.push_str( + "3. Verifies the raw app test on host Node.js via `node run-node.mjs `.\n", + ); + report.push_str("4. Copies the installed app into the WASI preopen as `/app`.\n"); + report.push_str("5. Executes the test through `examples/runtime/installed-app-runner` against real `/app/node_modules`.\n\n"); +} + +fn push_summary(report: &mut String, totals: CategoryCounts, apps: &[InstalledAppEntry]) { + report.push_str("## Summary\n\n"); + report.push_str(&format!( + "Runnable installed-app compatibility: **{}/{}** tests.\n\n", + totals.runnable, + totals.total() + )); + report.push_str("| Classification | Count |\n"); + report.push_str("|---|---:|\n"); + report.push_str(&format!("| Passing (runnable) | {} |\n", totals.runnable)); + report.push_str(&format!("| Known gap | {} |\n", totals.known_gap)); + report.push_str(&format!("| Deferred | {} |\n\n", totals.deferred)); + + report.push_str("## Apps\n\n"); + report.push_str("| App | Status | Tests | Notes |\n"); + report.push_str("|---|---:|---:|---|\n"); + for app in apps { + let counts = counts_for_app(app); + let reason = app.reason.as_deref().unwrap_or(""); + report.push_str(&format!( + "| `{}` | {} | {}/{} | {} |\n", + app.name, + counts.status(), + counts.runnable, + counts.total(), + escape_table_cell(reason) + )); + } + report.push('\n'); +} + +fn push_app_sections(report: &mut String, apps: &[InstalledAppEntry]) { + for app in apps { + report.push_str(&format!("## `{}`\n\n", app.name)); + report.push_str("| Test | Status | Coverage |\n"); + report.push_str("|---|---:|---|\n"); + for test in &app.tests { + report.push_str(&format!( + "| `{}` | {} | {} |\n", + test.file, + test.category.status_label(), + escape_table_cell(&test.coverage) + )); + } + report.push('\n'); + } +} + +fn push_non_runnable(report: &mut String, apps: &[InstalledAppEntry]) { + let mut by_reason: BTreeMap> = + BTreeMap::new(); + for app in apps { + for test in &app.tests { + if test.category == InstalledAppCategory::Runnable { + continue; + } + by_reason + .entry( + test.reason + .as_deref() + .filter(|reason| !reason.trim().is_empty()) + .unwrap_or("missing reason") + .to_string(), + ) + .or_default() + .push((app, test)); + } + } + + report.push_str("## Non-Runnable Tests\n\n"); + if by_reason.is_empty() { + report.push_str("_No non-runnable installed-app tests._\n"); + return; + } + + report.push_str("| Reason | Count | Entries |\n"); + report.push_str("|---|---:|---|\n"); + for (reason, entries) in by_reason { + let examples = entries + .iter() + .map(|(app, test)| format!("`{}/{}`", app.name, test.file)) + .collect::>() + .join(", "); + report.push_str(&format!( + "| {} | {} | {} |\n", + escape_table_cell(&reason), + entries.len(), + examples + )); + } +} + +fn counts_for_app(app: &InstalledAppEntry) -> CategoryCounts { + let mut counts = CategoryCounts::default(); + for test in &app.tests { + counts.add(test.category); + } + counts +} + +fn escape_table_cell(value: &str) -> String { + value.replace('\n', " ").replace('|', "\\|") +} + +fn now_date() -> String { + chrono::Local::now().format("%Y-%m-%d").to_string() +} diff --git a/tests/runtime/installed_apps.rs b/tests/runtime/installed_apps.rs index fd47b127..c2a691d9 100644 --- a/tests/runtime/installed_apps.rs +++ b/tests/runtime/installed_apps.rs @@ -1,11 +1,18 @@ -use crate::common::{CompiledTest, TestInstance, copy_dir_recursive}; +use crate::common::{ + CompiledTest, InstalledAppEntry, InstalledAppTestEntry, TestInstance, copy_dir_recursive, + load_installed_apps_config, +}; use camino::{Utf8Path, Utf8PathBuf}; use camino_tempfile::Utf8TempDir; use std::fs; use std::process::Command; -use test_r::{test, test_dep}; +use std::sync::Arc; +use test_r::core::{DynamicTestRegistration, TestProperties}; +use test_r::{test_dep, test_gen}; use wasmtime::component::Val; +const CONFIG_PATH: &str = "tests/installed_apps/config.jsonc"; + #[test_dep(tagged_as = "installed_app_runner", scope = Cloneable)] async fn compiled_installed_app_runner() -> CompiledTest { let path = Utf8Path::new("examples/runtime/installed-app-runner"); @@ -94,12 +101,13 @@ async fn run_installed_app_test( compiled_test: &CompiledTest, app_name: &str, test_file: &str, + timeout_secs: u64, ) -> anyhow::Result<()> { let app = prepare_installed_app(app_name)?; verify_with_node(&app, test_file)?; let mut instance = TestInstance::new(compiled_test.wasm_path()).await?; - instance.set_epoch_deadline(120); + instance.set_epoch_deadline(timeout_secs); let mounted_app_dir = instance.temp_dir_path().join("app"); fs::create_dir_all(&mounted_app_dir)?; @@ -121,444 +129,60 @@ async fn run_installed_app_test( } } -#[test] -async fn installed_app_esm_imports_cjs( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "module-interop", "test-01-esm-import-cjs.js").await -} - -#[test] -async fn installed_app_cjs_requires_esm( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-02-cjs-require-esm.cjs", - ) - .await -} - -#[test] -async fn installed_app_package_exports_imports( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-03-package-exports-imports.js", - ) - .await -} - -#[test] -async fn installed_app_require_esm_cycle( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-04-cycle-require-esm.cjs", - ) - .await -} - -#[test] -async fn installed_app_require_tla_esm( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "module-interop", "test-05-tla-require.cjs").await -} - -#[test] -async fn installed_app_conditional_import_graph( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-06-conditional-import-graph.cjs", - ) - .await -} - -#[test] -async fn installed_app_conditional_import_no_false_positive( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-07-conditional-import-no-false-positive.cjs", - ) - .await -} - -#[test] -async fn installed_app_conditional_imports_alias_graph( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-08-conditional-imports-alias-graph.cjs", - ) - .await -} - -#[test] -async fn installed_app_create_require_alias_cycle( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-09-create-require-alias-cycle.cjs", - ) - .await -} - -#[test] -async fn installed_app_already_evaluated_dependency( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-10-already-evaluated-dependency.cjs", - ) - .await -} - -#[test] -async fn installed_app_module_sync_before_import_graph( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-11-module-sync-before-import-graph.cjs", - ) - .await -} - -#[test] -async fn installed_app_module_sync_before_imports_alias_graph( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-12-module-sync-before-imports-alias-graph.cjs", - ) - .await -} - -#[test] -async fn installed_app_scanner_false_positive_guards( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-13-scanner-false-positive-guards.cjs", - ) - .await -} - -#[test] -async fn installed_app_exports_patterns( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-14-exports-patterns.mjs", - ) - .await -} - -#[test] -async fn installed_app_imports_patterns( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "module-interop", - "test-15-imports-patterns.cjs", - ) - .await -} - -#[test] -async fn installed_app_shim_patterns( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "module-interop", "test-16-shim-patterns.mjs").await -} - -#[test] -async fn installed_app_popular_cjs_utilities( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "popular-pure-js", - "test-01-cjs-utilities.cjs", - ) - .await -} - -#[test] -async fn installed_app_popular_modern_esm( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "popular-pure-js", "test-02-modern-esm.mjs").await -} - -#[test] -async fn installed_app_popular_date_fns_subpaths( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "popular-pure-js", - "test-03-date-fns-subpaths.mjs", - ) - .await -} - -#[test] -async fn installed_app_popular_dotenv_fs( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "popular-pure-js", "test-04-dotenv-fs.cjs").await -} - -#[test] -async fn installed_app_popular_ajv( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "popular-pure-js", "test-05-ajv.cjs").await -} - -#[test] -async fn installed_app_popular_rxjs( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "popular-pure-js", "test-06-rxjs.mjs").await -} - -#[test] -async fn installed_app_http_axios( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "http-clients", "test-01-axios.cjs").await -} - -#[test] -async fn installed_app_http_fetch_ky( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "http-clients", "test-02-fetch-ky.mjs").await -} - -#[test] -async fn installed_app_http_graphql_request( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "http-clients", "test-03-graphql-request.mjs").await -} - -#[test] -async fn installed_app_crypto_jsonwebtoken_bcrypt( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "crypto-auth", - "test-01-jsonwebtoken-bcrypt.cjs", - ) - .await -} - -#[test] -async fn installed_app_crypto_jose( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "crypto-auth", "test-02-jose.mjs").await -} - -#[test] -async fn installed_app_crypto_nanoid_cookie( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "crypto-auth", "test-03-nanoid-cookie.mjs").await -} - -#[test] -async fn installed_app_data_csv( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "data-formats", "test-01-csv.cjs").await -} - -#[test] -async fn installed_app_data_yaml_xml( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "data-formats", "test-02-yaml-xml.cjs").await -} - -#[test] -async fn installed_app_data_binary_protobuf( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "data-formats", "test-03-binary-protobuf.cjs").await -} - -#[test] -async fn installed_app_fs_config_parsers( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "fs-template-config", - "test-01-config-parsers.cjs", - ) - .await -} - -#[test] -async fn installed_app_fs_template_engines( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "fs-template-config", - "test-02-template-engines.cjs", - ) - .await -} - -#[test] -async fn installed_app_fs_fast_glob( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "fs-template-config", - "test-03-fast-glob-fs.cjs", - ) - .await -} - -#[test] -async fn installed_app_validation_joi_yup( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "validation-schema", "test-01-joi-yup.cjs").await -} - -#[test] -async fn installed_app_validation_superstruct_valibot( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "validation-schema", - "test-02-superstruct-valibot.mjs", - ) - .await -} - -#[test] -async fn installed_app_logging_loggers( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "logging-observability", - "test-01-loggers.cjs", - ) - .await -} - -#[test] -async fn installed_app_logging_consola_otel( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "logging-observability", - "test-02-consola-otel.mjs", - ) - .await -} - -#[test] -async fn installed_app_cloud_openai( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "cloud-sdk-offline", "test-01-openai.mjs").await -} - -#[test] -async fn installed_app_cloud_anthropic( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "cloud-sdk-offline", "test-02-anthropic.mjs").await -} - -#[test] -async fn installed_app_cloud_aws_s3( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "cloud-sdk-offline", "test-03-aws-s3.mjs").await -} - -#[test] -async fn installed_app_cloud_stripe( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "cloud-sdk-offline", "test-04-stripe.cjs").await -} - -#[test] -async fn installed_app_db_sql_builders( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "db-clients-offline", - "test-01-sql-builders.cjs", - ) - .await -} - -#[test] -async fn installed_app_db_pg_mysql( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "db-clients-offline", "test-02-pg-mysql.cjs").await -} - -#[test] -async fn installed_app_db_mongodb_redis( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test( - compiled_test, - "db-clients-offline", - "test-03-mongodb-redis.mjs", - ) - .await -} - -#[test] -async fn installed_app_db_drizzle( - #[tagged_as("installed_app_runner")] compiled_test: &CompiledTest, -) -> anyhow::Result<()> { - run_installed_app_test(compiled_test, "db-clients-offline", "test-04-drizzle.mjs").await +fn test_name(app: &InstalledAppEntry, test: &InstalledAppTestEntry) -> String { + format!( + "installed_app__{}__{}", + sanitize_name(&app.name), + sanitize_name(&test.file) + ) +} + +fn sanitize_name(value: &str) -> String { + value + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect() +} + +#[test_gen] +fn gen_installed_app_tests(r: &mut DynamicTestRegistration) { + let apps = + load_installed_apps_config(CONFIG_PATH).expect("Failed to load installed app config"); + let dependency_name = "compiledtest_installed_app_runner".to_string(); + + for app in apps { + for test in app.tests.clone() { + let app_name = app.name.clone(); + let test_file = test.file.clone(); + let timeout_secs = test.timeout_secs; + let props = TestProperties { + is_ignored: test.category.should_ignore_in_runner(), + ..TestProperties::unit_test() + }; + + r.add_async_test( + test_name(&app, &test), + props, + Some(vec![dependency_name.clone()]), + move |deps| { + let compiled_test: Arc = deps + .get("compiledtest_installed_app_runner") + .expect("CompiledTest dependency not found") + .downcast::() + .expect("CompiledTest type mismatch"); + let app_name = app_name.clone(); + let test_file = test_file.clone(); + Box::pin(async move { + run_installed_app_test( + compiled_test.as_ref(), + &app_name, + &test_file, + timeout_secs, + ) + .await + }) + }, + ); + } + } } From e23a948fc6cba5ca557420007c10d5f481be2dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 17 Jun 2026 13:50:32 +0200 Subject: [PATCH 11/42] Handle conditional fallback for `--no-experimental-require-module` and update node compatibility inventory --- .../skeleton/src/builtin/module.js | 5 +++++ tests/node_compat/config.jsonc | 10 +++++----- tests/node_compat/report.md | 19 +++++++++++-------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 8aad76da..15228f43 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -2088,6 +2088,11 @@ function loadModule(resolvedFilename, source, parentModule) { } } if (cjsSyntaxError || cjsWrapperRequireRedeclaration) { + if (hasExecArgvFlag('--no-experimental-require-module') && cjsSyntaxError) { + delete moduleCache[resolvedFilename]; + maybeSetArrowMessageOnSyntaxError(cjsSyntaxError, resolvedFilename, source); + throw cjsSyntaxError; + } // SyntaxError in a .js file — try loading as ESM (entry point detection) try { mod.exports = requireEsmWithCacheGuard(mod, resolvedFilename); diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index ea21e65d..9f5c0e8c 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -1695,10 +1695,10 @@ "parallel/test-crypto-keygen-async-elliptic-curve-jwk.js": {}, "parallel/test-crypto-keygen-async-encrypted-private-key-der.js": {}, "parallel/test-crypto-keygen-async-encrypted-private-key.js": {}, - "parallel/test-crypto-keygen-async-explicit-elliptic-curve-encrypted-p256.js": {}, + "parallel/test-crypto-keygen-async-explicit-elliptic-curve-encrypted-p256.js": { "flaky": true, "timeout": 300 }, "parallel/test-crypto-keygen-async-explicit-elliptic-curve-encrypted.js.js": {}, "parallel/test-crypto-keygen-async-explicit-elliptic-curve.js": {}, - "parallel/test-crypto-keygen-async-named-elliptic-curve-encrypted-p256.js": {}, + "parallel/test-crypto-keygen-async-named-elliptic-curve-encrypted-p256.js": { "flaky": true, "timeout": 300 }, "parallel/test-crypto-keygen-async-named-elliptic-curve-encrypted.js": {}, "parallel/test-crypto-keygen-async-named-elliptic-curve.js": {}, "parallel/test-crypto-keygen-async-rsa.js": {}, @@ -2719,7 +2719,7 @@ "block_00_connect_without_calling_dns_lookup": {}, "block_01_connect_with_single_ip_returned_by_dns_lookup": {}, "block_02_connect_with_autoselectfamily_and_single_ip": {}, - "block_03_connect_with_autoselectfamily_and_multiple_ips": { "flaky": true } + "block_03_connect_with_autoselectfamily_and_multiple_ips": { "category": "known-gap", "reason": "net.BlockList with autoSelectFamily and multiple lookup addresses does not yet raise ERR_IP_BLOCKED before connection attempts" } } }, "parallel/test-net-buffersize.js": {}, @@ -2930,7 +2930,7 @@ "parallel/test-net-write-arguments.js": {}, "parallel/test-net-write-cb-on-destroy-before-connect.js": {}, "parallel/test-net-write-connect-write.js": {}, - "parallel/test-net-write-fully-async-buffer.js": { "category": "runnable" }, + "parallel/test-net-write-fully-async-buffer.js": { "category": "known-gap", "reason": "net write backpressure/drain handling for repeated large Buffer writes can hang in the WASM socket implementation" }, "parallel/test-net-write-fully-async-hex-string.js": { "category": "runnable" }, "parallel/test-net-write-slow.js": { "category": "known-gap", "reason": "net.js TCP implementation incomplete - needs event handling and API fixes" }, "parallel/test-process-next-tick.js": {}, @@ -10285,7 +10285,7 @@ "block_02_block_02": { "category": "known-gap", "reason": "perf_hooks.monitorEventLoopDelay is not implemented" } } }, - "sequential/test-pipe.js": { "category": "runnable" }, + "sequential/test-pipe.js": { "category": "known-gap", "reason": "HTTP request piping into a raw TCP stream with large payloads can hang in current net/http stream backpressure handling" }, "sequential/test-process-title.js": { "category": "known-gap", "reason": "child_process -p/process.title behavior is incomplete in WASM child emulation" }, "sequential/test-process-warnings.js": {}, "sequential/test-repl-timeout-throw.js": { "category": "known-gap", "reason": "child_process.spawn emulation does not support --interactive REPL sessions" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 9afde1cf..4bf6c45b 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -1,6 +1,6 @@ # Node.js v22.14.0 Compatibility Inventory -Generated: 2026-06-16 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) +Generated: 2026-06-17 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) This report is generated from `config.jsonc` only. It does **not** run the vendored tests itself. Entries classified as `runnable` are reported as passing because the `node_compat` PR test executes runnable entries and fails CI if any of them fail. @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3102/4295 (72.2%) +**Primary compatibility (CI-enforced):** 3099/4295 (72.2%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3102 | 72.2% | 55.3% | 46.1% | -| 🧩 known gap | 1193 | 27.8% | 21.3% | 17.7% | +| ✅ passing (runnable) | 3099 | 72.2% | 55.2% | 46.0% | +| 🧩 known gap | 1196 | 27.8% | 21.3% | 17.8% | | 🚫 WASI-impossible (excluded) | 1153 | — | 20.6% | 17.1% | | ⚙️ engine difference (excluded) | 162 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1121 | — | — | 16.7% | | **Total** | **6731** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3102/5610 (55.3%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3099/5610 (55.2%)**. ## Inventory by Module @@ -54,7 +54,7 @@ Secondary full-public compatibility, including public tests that are currently e | inspector | 95 | 1 | 0 | 93 | 0 | 0 | 1 | 100.0% | 1.1% | | internal | 53 | 1 | 0 | 0 | 0 | 0 | 52 | 100.0% | 100.0% | | module | 184 | 120 | 44 | 7 | 1 | 0 | 12 | 73.2% | 69.8% | -| net | 223 | 150 | 36 | 19 | 1 | 0 | 17 | 80.6% | 72.8% | +| net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | | other | 469 | 101 | 92 | 83 | 11 | 0 | 182 | 52.3% | 35.2% | @@ -363,7 +363,7 @@ Secondary full-public compatibility, including public tests that are currently e | `test-net-autoselectfamily-default.js` | 2 | 2 | 0 | 0 | 0 | 0 | 0 | | `test-net-autoselectfamily.js` | 4 | 3 | 1 | 0 | 0 | 0 | 0 | | `test-net-better-error-messages-path.js` | 2 | 2 | 0 | 0 | 0 | 0 | 0 | -| `test-net-blocklist.js` | 4 | 4 | 0 | 0 | 0 | 0 | 0 | +| `test-net-blocklist.js` | 4 | 3 | 1 | 0 | 0 | 0 | 0 | | `test-net-bytes-written-large.js` | 3 | 3 | 0 | 0 | 0 | 0 | 0 | | `test-net-connect-options-port.js` | 4 | 0 | 4 | 0 | 0 | 0 | 0 | | `test-net-normalize-args.js` | 2 | 0 | 0 | 0 | 0 | 0 | 2 | @@ -680,7 +680,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1193) +### known gap (1196) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -879,6 +879,7 @@ Secondary full-public compatibility, including public tests that are currently e | HTTP parser does not emit Node-compatible HPE_INVALID_TRANSFER_ENCODING clientError semantics | 1 | `parallel/test-http-server-reject-chunked-with-content-length.js` | | HTTP parser handling for blank request headers and 400 response framing is incomplete | 1 | `parallel/test-http-blank-header.js` | | HTTP parser/clientError path does not reject duplicate Content-Length with HPE_UNEXPECTED_CONTENT_LENGTH | 1 | `parallel/test-http-double-content-length.js` | +| HTTP request piping into a raw TCP stream with large payloads can hang in current net/http stream backpressure handling | 1 | `sequential/test-pipe.js` | | HTTP request piping with constrained agent sockets can stall queued requests | 1 | `parallel/test-http-pipe-fs.js` | | HTTP request streaming/pipe backpressure behavior is not fully Node-compatible | 1 | `parallel/test-pipe-file-to-http.js` | | HTTP response serialization/header ordering differs from Node for first-chunk single-byte encodings | 1 | `parallel/test-http-outgoing-first-chunk-singlebyte-encoding.js` | @@ -1152,6 +1153,8 @@ Secondary full-public compatibility, including public tests that are currently e | native rquickjs URL accessors report Rust conversion errors for invalid receivers before JS can normalize them to V8/Web IDL private-member messages | 1 | `parallel/test-whatwg-url-invalidthis.js` | | native rquickjs URL class property enumeration order does not match Web IDL order and descriptors are not fully configurable from JS | 1 | `parallel/test-whatwg-url-custom-properties.js` | | net reusePort listen option/support probing is incomplete | 1 | `parallel/test-net-reuseport.js` | +| net write backpressure/drain handling for repeated large Buffer writes can hang in the WASM socket implementation | 1 | `parallel/test-net-write-fully-async-buffer.js` | +| net.BlockList with autoSelectFamily and multiple lookup addresses does not yet raise ERR_IP_BLOCKED before connection attempts | 1 | `parallel/test-net-blocklist.js#block_03_connect_with_autoselectfamily_and_multiple_ips` | | net.Server blockList enforcement is incomplete | 1 | `parallel/test-net-server-blocklist.js` | | net.Server captureRejections async error propagation is incomplete | 1 | `parallel/test-net-server-capture-rejection.js` | | node-compat runner drainAsync() relies on global setTimeout after this test deletes timer globals | 1 | `parallel/test-timers-api-refs.js` | From 395278d52bbf36537238307374ee8f80ba301c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 18 Jun 2026 10:29:30 +0200 Subject: [PATCH 12/42] cleanups --- .github/workflows/ci.yaml | 8 +- AGENTS.md | 14 +- README.md | 1 - .../wit/installed-app-runner.wit | 5 - .../src/node-modules-app-runner.js} | 4 +- .../wit/node-modules-app-runner.wit | 5 + tests/common/mod.rs | 48 ++-- tests/installed_apps/report.md | 149 ----------- tests/installed_apps_report.rs | 231 ------------------ tests/node_compat/config.jsonc | 8 +- tests/node_compat/report.md | 4 +- .../README.md | 36 +-- .../apps/cloud-sdk-offline/package.json | 0 .../apps/cloud-sdk-offline/run-node.mjs | 0 .../apps/cloud-sdk-offline/test-01-openai.mjs | 0 .../cloud-sdk-offline/test-02-anthropic.mjs | 0 .../apps/cloud-sdk-offline/test-03-aws-s3.mjs | 0 .../apps/cloud-sdk-offline/test-04-stripe.cjs | 0 .../apps/crypto-auth/package.json | 0 .../apps/crypto-auth/run-node.mjs | 0 .../test-01-jsonwebtoken-bcrypt.cjs | 0 .../apps/crypto-auth/test-02-jose.mjs | 0 .../crypto-auth/test-03-nanoid-cookie.mjs | 0 .../apps/data-formats/package.json | 0 .../apps/data-formats/run-node.mjs | 0 .../apps/data-formats/test-01-csv.cjs | 0 .../apps/data-formats/test-02-yaml-xml.cjs | 0 .../data-formats/test-03-binary-protobuf.cjs | 0 .../apps/db-clients-offline/package.json | 0 .../apps/db-clients-offline/run-node.mjs | 0 .../test-01-sql-builders.cjs | 0 .../db-clients-offline/test-02-pg-mysql.cjs | 0 .../test-03-mongodb-redis.mjs | 0 .../db-clients-offline/test-04-drizzle.mjs | 0 .../apps/fs-template-config/package.json | 0 .../apps/fs-template-config/run-node.mjs | 0 .../test-01-config-parsers.cjs | 0 .../test-02-template-engines.cjs | 0 .../test-03-fast-glob-fs.cjs | 0 .../apps/http-clients/package.json | 0 .../apps/http-clients/run-node.mjs | 0 .../apps/http-clients/test-01-axios.cjs | 0 .../apps/http-clients/test-02-fetch-ky.mjs | 0 .../http-clients/test-03-graphql-request.mjs | 0 .../apps/logging-observability/package.json | 0 .../apps/logging-observability/run-node.mjs | 0 .../logging-observability/test-01-loggers.cjs | 0 .../test-02-consola-otel.mjs | 0 .../apps/module-interop/package.json | 0 .../packages/cjs-basic/index.cjs | 0 .../packages/cjs-basic/package.json | 0 .../packages/cjs-nested-require-pkg/index.js | 0 .../cjs-nested-require-pkg/package.json | 0 .../packages/cjs-reexport-pkg/index.cjs | 0 .../packages/cjs-reexport-pkg/package.json | 0 .../condition-entry-import-cycle/entry.mjs | 0 .../condition-entry-import-cycle/package.json | 0 .../dep-bridge.cjs | 0 .../dep-import.mjs | 0 .../dep-sync.mjs | 0 .../condition-entry-imports-cycle/entry.mjs | 0 .../package.json | 0 .../bridge.cjs | 0 .../entry-import.mjs | 0 .../entry.mjs | 0 .../package.json | 0 .../dep-bridge.cjs | 0 .../dep-import.mjs | 0 .../dep-sync.mjs | 0 .../entry.mjs | 0 .../package.json | 0 .../condition-entry-no-cycle/entry.mjs | 0 .../condition-entry-no-cycle/package.json | 0 .../condition-target-import-cycle/bridge.cjs | 0 .../condition-target-import-cycle/import.mjs | 0 .../package.json | 0 .../condition-target-import-cycle/sync.mjs | 0 .../condition-target-no-cycle/bridge.cjs | 0 .../condition-target-no-cycle/import.mjs | 0 .../condition-target-no-cycle/package.json | 0 .../condition-target-no-cycle/sync.mjs | 0 .../packages/cycle-require-esm/bridge.cjs | 0 .../packages/cycle-require-esm/esm.mjs | 0 .../packages/cycle-require-esm/index.cjs | 0 .../packages/cycle-require-esm/package.json | 0 .../packages/dual-exports/default.mjs | 0 .../packages/dual-exports/feature.cjs | 0 .../packages/dual-exports/feature.mjs | 0 .../packages/dual-exports/import.mjs | 0 .../packages/dual-exports/package.json | 0 .../packages/dual-exports/require.cjs | 0 .../packages/dual-exports/sync.mjs | 0 .../esm-alias-create-require-cycle/bridge.cjs | 0 .../esm-alias-create-require-cycle/entry.mjs | 0 .../package.json | 0 .../packages/esm-already-evaluated/bridge.cjs | 0 .../packages/esm-already-evaluated/entry.mjs | 0 .../esm-already-evaluated/package.json | 0 .../packages/esm-already-evaluated/ready.mjs | 0 .../esm-false-positive-scanner/entry.mjs | 0 .../esm-false-positive-scanner/package.json | 0 .../packages/esm-sync/index.mjs | 0 .../packages/esm-sync/package.json | 0 .../packages/imports-alias/dep.cjs | 0 .../packages/imports-alias/index.mjs | 0 .../packages/imports-alias/package.json | 0 .../packages/pattern-exports/cjs/gamma.cjs | 0 .../packages/pattern-exports/package.json | 0 .../packages/pattern-exports/src/alpha.mjs | 0 .../pattern-exports/sync/beta-default.mjs | 0 .../pattern-exports/sync/beta-import.mjs | 0 .../pattern-exports/sync/beta-sync.mjs | 0 .../packages/pattern-imports/index.cjs | 0 .../pattern-imports/internal/value.cjs | 0 .../pattern-imports/internal/value.mjs | 0 .../packages/pattern-imports/package.json | 0 .../_shims/auto/runtime-node.cjs | 0 .../_shims/auto/runtime-node.mjs | 0 .../pattern-shims/_shims/auto/runtime.mjs | 0 .../packages/pattern-shims/package.json | 0 .../module-interop/packages/tla-esm/index.mjs | 0 .../packages/tla-esm/package.json | 0 .../apps/module-interop/run-node.mjs | 0 .../module-interop/test-01-esm-import-cjs.js | 0 .../test-02-cjs-require-esm.cjs | 0 .../test-03-package-exports-imports.js | 0 .../test-04-cycle-require-esm.cjs | 0 .../module-interop/test-05-tla-require.cjs | 0 .../test-06-conditional-import-graph.cjs | 0 ...7-conditional-import-no-false-positive.cjs | 0 ...est-08-conditional-imports-alias-graph.cjs | 0 .../test-09-create-require-alias-cycle.cjs | 0 .../test-10-already-evaluated-dependency.cjs | 0 ...est-11-module-sync-before-import-graph.cjs | 0 ...module-sync-before-imports-alias-graph.cjs | 0 .../test-13-scanner-false-positive-guards.cjs | 0 .../test-14-exports-patterns.mjs | 0 .../test-15-imports-patterns.cjs | 0 .../module-interop/test-16-shim-patterns.mjs | 0 .../apps/popular-pure-js/package.json | 0 .../apps/popular-pure-js/run-node.mjs | 0 .../popular-pure-js/test-01-cjs-utilities.cjs | 0 .../popular-pure-js/test-02-modern-esm.mjs | 0 .../test-03-date-fns-subpaths.mjs | 0 .../popular-pure-js/test-04-dotenv-fs.cjs | 0 .../apps/popular-pure-js/test-05-ajv.cjs | 0 .../apps/popular-pure-js/test-06-rxjs.mjs | 0 .../apps/validation-schema/package.json | 0 .../apps/validation-schema/run-node.mjs | 0 .../validation-schema/test-01-joi-yup.cjs | 0 .../test-02-superstruct-valibot.mjs | 0 .../config.jsonc | 0 tests/runtime/main.rs | 7 +- ...installed_apps.rs => node_modules_apps.rs} | 49 ++-- 154 files changed, 99 insertions(+), 470 deletions(-) delete mode 100644 examples/runtime/installed-app-runner/wit/installed-app-runner.wit rename examples/runtime/{installed-app-runner/src/installed-app-runner.js => node-modules-app-runner/src/node-modules-app-runner.js} (80%) create mode 100644 examples/runtime/node-modules-app-runner/wit/node-modules-app-runner.wit delete mode 100644 tests/installed_apps/report.md delete mode 100644 tests/installed_apps_report.rs rename tests/{installed_apps => node_modules_apps}/README.md (59%) rename tests/{installed_apps => node_modules_apps}/apps/cloud-sdk-offline/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/cloud-sdk-offline/run-node.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/cloud-sdk-offline/test-01-openai.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/cloud-sdk-offline/test-02-anthropic.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/cloud-sdk-offline/test-03-aws-s3.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/cloud-sdk-offline/test-04-stripe.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/crypto-auth/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/crypto-auth/run-node.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/crypto-auth/test-01-jsonwebtoken-bcrypt.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/crypto-auth/test-02-jose.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/crypto-auth/test-03-nanoid-cookie.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/data-formats/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/data-formats/run-node.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/data-formats/test-01-csv.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/data-formats/test-02-yaml-xml.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/data-formats/test-03-binary-protobuf.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/db-clients-offline/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/db-clients-offline/run-node.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/db-clients-offline/test-01-sql-builders.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/db-clients-offline/test-02-pg-mysql.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/db-clients-offline/test-03-mongodb-redis.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/db-clients-offline/test-04-drizzle.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/fs-template-config/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/fs-template-config/run-node.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/fs-template-config/test-01-config-parsers.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/fs-template-config/test-02-template-engines.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/fs-template-config/test-03-fast-glob-fs.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/http-clients/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/http-clients/run-node.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/http-clients/test-01-axios.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/http-clients/test-02-fetch-ky.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/http-clients/test-03-graphql-request.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/logging-observability/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/logging-observability/run-node.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/logging-observability/test-01-loggers.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/logging-observability/test-02-consola-otel.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/cjs-basic/index.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/cjs-basic/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/cjs-nested-require-pkg/index.js (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/cjs-nested-require-pkg/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/cjs-reexport-pkg/index.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/cjs-reexport-pkg/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-import-cycle/entry.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-import-cycle/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-imports-cycle/dep-bridge.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-imports-cycle/dep-import.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-imports-cycle/dep-sync.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-imports-cycle/entry.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-imports-cycle/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-module-sync-cycle/bridge.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-module-sync-cycle/entry-import.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-module-sync-cycle/entry.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-module-sync-cycle/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-bridge.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-import.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-sync.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/entry.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-no-cycle/entry.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-entry-no-cycle/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-target-import-cycle/bridge.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-target-import-cycle/import.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-target-import-cycle/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-target-import-cycle/sync.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-target-no-cycle/bridge.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-target-no-cycle/import.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-target-no-cycle/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/condition-target-no-cycle/sync.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/cycle-require-esm/bridge.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/cycle-require-esm/esm.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/cycle-require-esm/index.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/cycle-require-esm/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/dual-exports/default.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/dual-exports/feature.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/dual-exports/feature.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/dual-exports/import.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/dual-exports/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/dual-exports/require.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/dual-exports/sync.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/esm-alias-create-require-cycle/bridge.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/esm-alias-create-require-cycle/entry.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/esm-alias-create-require-cycle/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/esm-already-evaluated/bridge.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/esm-already-evaluated/entry.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/esm-already-evaluated/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/esm-already-evaluated/ready.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/esm-false-positive-scanner/entry.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/esm-false-positive-scanner/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/esm-sync/index.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/esm-sync/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/imports-alias/dep.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/imports-alias/index.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/imports-alias/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-exports/cjs/gamma.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-exports/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-exports/src/alpha.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-exports/sync/beta-default.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-exports/sync/beta-import.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-exports/sync/beta-sync.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-imports/index.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-imports/internal/value.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-imports/internal/value.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-imports/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-shims/_shims/auto/runtime.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/pattern-shims/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/tla-esm/index.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/packages/tla-esm/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/run-node.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-01-esm-import-cjs.js (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-02-cjs-require-esm.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-03-package-exports-imports.js (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-04-cycle-require-esm.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-05-tla-require.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-06-conditional-import-graph.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-07-conditional-import-no-false-positive.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-08-conditional-imports-alias-graph.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-09-create-require-alias-cycle.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-10-already-evaluated-dependency.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-11-module-sync-before-import-graph.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-12-module-sync-before-imports-alias-graph.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-13-scanner-false-positive-guards.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-14-exports-patterns.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-15-imports-patterns.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/module-interop/test-16-shim-patterns.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/popular-pure-js/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/popular-pure-js/run-node.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/popular-pure-js/test-01-cjs-utilities.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/popular-pure-js/test-02-modern-esm.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/popular-pure-js/test-03-date-fns-subpaths.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/popular-pure-js/test-04-dotenv-fs.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/popular-pure-js/test-05-ajv.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/popular-pure-js/test-06-rxjs.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/validation-schema/package.json (100%) rename tests/{installed_apps => node_modules_apps}/apps/validation-schema/run-node.mjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/validation-schema/test-01-joi-yup.cjs (100%) rename tests/{installed_apps => node_modules_apps}/apps/validation-schema/test-02-superstruct-valibot.mjs (100%) rename tests/{installed_apps => node_modules_apps}/config.jsonc (100%) rename tests/runtime/{installed_apps.rs => node_modules_apps.rs} (74%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7c1788d9..4fe6ede0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -78,7 +78,7 @@ jobs: strategy: fail-fast: false matrix: - group: [1, 2, 3, 4, 5, 6, 7, 8] + group: [1, 2, 3, 4, 5, 6, 7, 8, 9] steps: - uses: actions/checkout@v4 with: @@ -86,7 +86,11 @@ jobs: - uses: ./.github/actions/setup - name: Enable Golem wasmtime fork run: bash .github/scripts/enable-wasmtime-fork.sh - - name: Runtime tests (group ${{ matrix.group }}/8) + - uses: actions/setup-node@v4 + if: matrix.group == 9 + with: + node-version: "22.14.0" + - name: Runtime tests (group ${{ matrix.group }}/9) run: cargo test --test runtime $CI_WASMTIME_FORK_FEATURES -- --report-time --format ctrf --logfile target/ctrf.json ':tag:group${{ matrix.group }}' - name: Publish Test Report uses: ctrf-io/github-test-reporter@v1 diff --git a/AGENTS.md b/AGENTS.md index d5885092..95c6a139 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,18 +149,18 @@ The `tests/node_compat/` directory contains vendored Node.js test files used to Load the `fixing-node-compat-test` skill for the full workflow when making a test pass. -## Installed App Compatibility Tests +## Node Modules App Tests -The `tests/installed_apps/` directory contains CI-enforced runtime tests for unbundled npm apps installed with real `node_modules` and attached to the component filesystem as `/app`. This suite is separate from `tests/libraries/`, which documents Rollup-bundled package compatibility. +The `tests/node_modules_apps/` directory contains CI-enforced runtime tests for unbundled npm apps installed with real `node_modules` and attached to the component filesystem as `/app`. This suite is separate from `tests/libraries/`, which documents Rollup-bundled package compatibility. Important rules: -- `tests/installed_apps/config.jsonc` is the source of truth for installed-app tests. Runtime tests in `tests/runtime/installed_apps.rs` are generated from it. -- Update `tests/installed_apps/report.md` by running `cargo test --test installed_apps_report --features use-golem-wasmtime -- --nocapture` after config changes. -- Add app fixtures under `tests/installed_apps/apps//` with a `package.json`, `run-node.mjs`, and `test-*` files exporting `run()`. -- Installed-app tests run `npm install --install-links --ignore-scripts --no-audit --no-fund`, verify the raw test with host Node.js, then run it through wasm-rquickjs from `/app`. +- `tests/node_modules_apps/config.jsonc` is the source of truth for node modules app tests. Runtime tests in `tests/runtime/node_modules_apps.rs` are generated from it. +- Add app fixtures under `tests/node_modules_apps/apps//` with a `package.json`, `run-node.mjs`, and `test-*` files exporting `run()`. +- Node modules app tests run `npm install --install-links --ignore-scripts --no-audit --no-fund`, verify the raw test with host Node.js, then run it through wasm-rquickjs from `/app`. - Keep this suite focused on real `node_modules` module loading, CJS/ESM interop, package maps, filesystem-backed package behavior, and high-value smoke tests. Do not use it for native `.node`, WASM artifact loading, subprocess-heavy, or live-network scenarios. -- Before running installed-app runtime tests after skeleton changes, run `./cleanup-skeleton.sh`, then use `cargo test --test runtime --features use-golem-wasmtime -- installed_app --nocapture` or a narrower installed-app filter. +- CI runs node modules app tests as runtime `group9`; regular runtime tests use `group1` through `group8`. +- Before running node modules app runtime tests after skeleton changes, run `./cleanup-skeleton.sh`, then use `cargo test --test runtime --features use-golem-wasmtime -- ':tag:group9'` for the CI-like group, `cargo test --test runtime --features use-golem-wasmtime -- node_modules_app --nocapture` for the full node modules app suite, or a narrower node modules app filter. ### ⚠️ Keeping `node_compat` and `node_compat_report` in sync diff --git a/README.md b/README.md index c914ca23..c25cc1e7 100644 --- a/README.md +++ b/README.md @@ -803,7 +803,6 @@ Compatibility stubs — workers are not supported in single-threaded WASM. ### Compatibility Reports - [NPM Library Compatibility Tracker](tests/libraries/libraries.md) — test results for popular npm packages -- [Installed App Compatibility Report](tests/installed_apps/report.md) — CI-enforced smoke tests for unbundled npm apps with real `node_modules` - [Node.js v22 Compatibility Report](tests/node_compat/report.md) — per-test results for vendored Node.js test suite ### Coming from QuickJS diff --git a/examples/runtime/installed-app-runner/wit/installed-app-runner.wit b/examples/runtime/installed-app-runner/wit/installed-app-runner.wit deleted file mode 100644 index 2c24a175..00000000 --- a/examples/runtime/installed-app-runner/wit/installed-app-runner.wit +++ /dev/null @@ -1,5 +0,0 @@ -package quickjs:installed-app-runner; - -world installed-app-runner { - export run-test: func(test-path: string) -> string; -} diff --git a/examples/runtime/installed-app-runner/src/installed-app-runner.js b/examples/runtime/node-modules-app-runner/src/node-modules-app-runner.js similarity index 80% rename from examples/runtime/installed-app-runner/src/installed-app-runner.js rename to examples/runtime/node-modules-app-runner/src/node-modules-app-runner.js index c2df588a..87ee7453 100644 --- a/examples/runtime/installed-app-runner/src/installed-app-runner.js +++ b/examples/runtime/node-modules-app-runner/src/node-modules-app-runner.js @@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url'; function getRunFunction(module) { if (module && typeof module.run === 'function') return module.run; if (module && module.default && typeof module.default.run === 'function') return module.default.run; - throw new Error('Installed app test module must export run()'); + throw new Error('Node modules app test module must export run()'); } export const runTest = async (testPath) => { @@ -14,7 +14,7 @@ export const runTest = async (testPath) => { const result = await getRunFunction(module)(); if (typeof result !== 'string' || !result.startsWith('PASS:')) { - throw new Error(`Unexpected installed app test result: ${result}`); + throw new Error(`Unexpected node modules app test result: ${result}`); } return result; }; diff --git a/examples/runtime/node-modules-app-runner/wit/node-modules-app-runner.wit b/examples/runtime/node-modules-app-runner/wit/node-modules-app-runner.wit new file mode 100644 index 00000000..40885ec0 --- /dev/null +++ b/examples/runtime/node-modules-app-runner/wit/node-modules-app-runner.wit @@ -0,0 +1,5 @@ +package quickjs:node-modules-app-runner; + +world node-modules-app-runner { + export run-test: func(test-path: string) -> string; +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f2f248b6..24a222b8 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -151,19 +151,19 @@ pub struct NodeCompatTestEntry { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum InstalledAppCategory { +pub enum NodeModulesAppCategory { Runnable, KnownGap, Deferred, } -impl InstalledAppCategory { +impl NodeModulesAppCategory { pub fn from_config_value(value: &str) -> anyhow::Result { match value { "runnable" => Ok(Self::Runnable), "known-gap" | "gap" => Ok(Self::KnownGap), "deferred" => Ok(Self::Deferred), - other => anyhow::bail!("unknown installed_apps category '{other}'"), + other => anyhow::bail!("unknown node_modules_apps category '{other}'"), } } @@ -189,20 +189,20 @@ impl InstalledAppCategory { } #[derive(Debug, Clone)] -pub struct InstalledAppTestEntry { +pub struct NodeModulesAppTestEntry { pub file: String, - pub category: InstalledAppCategory, + pub category: NodeModulesAppCategory, pub coverage: String, pub reason: Option, pub timeout_secs: u64, } #[derive(Debug, Clone)] -pub struct InstalledAppEntry { +pub struct NodeModulesAppEntry { pub name: String, - pub category: InstalledAppCategory, + pub category: NodeModulesAppCategory, pub reason: Option, - pub tests: Vec, + pub tests: Vec, } /// Extract the numeric index from a subtest name like "block_00_foo" or "test_03_bar". @@ -333,7 +333,7 @@ pub fn load_node_compat_config(path: &str) -> anyhow::Result anyhow::Result> { +pub fn load_node_modules_apps_config(path: &str) -> anyhow::Result> { let content = fs::read_to_string(path)?; let json_str = strip_jsonc_comments(&content); let value: serde_json::Value = serde_json::from_str(&json_str)?; @@ -341,11 +341,11 @@ pub fn load_installed_apps_config(path: &str) -> anyhow::Result anyhow::Result { (coverage.clone(), reason.clone(), default_timeout_secs) @@ -373,7 +375,7 @@ pub fn load_installed_apps_config(path: &str) -> anyhow::Result anyhow::Result anyhow::bail!( - "installed app '{app_name}' test '{test_file}' must be a coverage string or object" + "node_modules app '{app_name}' test '{test_file}' must be a coverage string or object" ), }; - tests.push(InstalledAppTestEntry { + tests.push(NodeModulesAppTestEntry { file: test_file.clone(), category: test_category, coverage, @@ -403,7 +405,7 @@ pub fn load_installed_apps_config(path: &str) -> anyhow::Result anyhow::Result, -) -> anyhow::Result { + inherited: Option, +) -> anyhow::Result { if let Some(category) = value.get("category").and_then(|v| v.as_str()) { - return InstalledAppCategory::from_config_value(category); + return NodeModulesAppCategory::from_config_value(category); } if value.get("skip").and_then(|v| v.as_bool()).unwrap_or(false) { - return Ok(InstalledAppCategory::KnownGap); + return Ok(NodeModulesAppCategory::KnownGap); } - Ok(inherited.unwrap_or(InstalledAppCategory::Runnable)) + Ok(inherited.unwrap_or(NodeModulesAppCategory::Runnable)) } /// Recursively copy a directory and all its contents to a destination. diff --git a/tests/installed_apps/report.md b/tests/installed_apps/report.md deleted file mode 100644 index 9e69d4f0..00000000 --- a/tests/installed_apps/report.md +++ /dev/null @@ -1,149 +0,0 @@ -# Installed App Compatibility Report - -Generated: 2026-06-17 | Source: `tests/installed_apps/config.jsonc` | Engine: wasm-rquickjs (QuickJS) - -This report tracks compatibility for unbundled npm-installed apps attached to the component as a filesystem. It is intentionally separate from `tests/libraries/libraries.md`, which tests Rollup-bundled library usage. Runtime tests in `tests/runtime/installed_apps.rs` are generated from the same config file as this report. - -## Scope - -| Included | Deferred | -|---|---| -| Pure JavaScript packages installed with npm | Native `.node` bindings | -| `node_modules` package resolution | Packages that load WASM artifacts | -| package `exports` / `imports` | Child process execution | -| CJS/ESM interop and same-process cycles | CLI preload/eval/warning behavior | - -## How It Runs - -For every runnable test, the runtime harness: - -1. Copies `tests/installed_apps/apps/` to a temporary directory. -2. Runs `npm install --install-links --ignore-scripts --no-audit --no-fund`. -3. Verifies the raw app test on host Node.js via `node run-node.mjs `. -4. Copies the installed app into the WASI preopen as `/app`. -5. Executes the test through `examples/runtime/installed-app-runner` against real `/app/node_modules`. - -## Summary - -Runnable installed-app compatibility: **46/46** tests. - -| Classification | Count | -|---|---:| -| Passing (runnable) | 46 | -| Known gap | 0 | -| Deferred | 0 | - -## Apps - -| App | Status | Tests | Notes | -|---|---:|---:|---| -| `cloud-sdk-offline` | Passing | 4/4 | Cloud SDK packages installed as a real app with node_modules attached as filesystem; tests use offline constructors/API shapes only | -| `crypto-auth` | Passing | 3/3 | Authentication and crypto-adjacent pure-JS packages installed as a real app with node_modules attached as filesystem | -| `data-formats` | Passing | 3/3 | Data parsing and serialization packages installed as a real app with node_modules attached as filesystem | -| `db-clients-offline` | Passing | 4/4 | Database client packages installed as a real app with node_modules attached as filesystem; tests avoid network connections | -| `fs-template-config` | Passing | 3/3 | Configuration, templating, and filesystem glob packages installed as a real app with node_modules attached as filesystem | -| `http-clients` | Passing | 3/3 | HTTP client packages installed as a real app with node_modules attached as filesystem; tests avoid external network by using custom fetch/adapter paths | -| `logging-observability` | Passing | 2/2 | Logging and observability packages installed as a real app with node_modules attached as filesystem; tests avoid subprocesses/transports | -| `module-interop` | Passing | 16/16 | Synthetic npm-installed app covering CJS/ESM/package graph behavior | -| `popular-pure-js` | Passing | 6/6 | Popular pure-JS npm packages installed as a real app with node_modules attached as filesystem | -| `validation-schema` | Passing | 2/2 | Validation packages installed as a real app with node_modules attached as filesystem | - -## `cloud-sdk-offline` - -| Test | Status | Coverage | -|---|---:|---| -| `test-01-openai.mjs` | Passing | OpenAI SDK offline client surface | -| `test-02-anthropic.mjs` | Passing | Anthropic SDK offline client surface | -| `test-03-aws-s3.mjs` | Passing | AWS S3 SDK offline client and command construction | -| `test-04-stripe.cjs` | Passing | Stripe SDK offline client surface | - -## `crypto-auth` - -| Test | Status | Coverage | -|---|---:|---| -| `test-01-jsonwebtoken-bcrypt.cjs` | Passing | jsonwebtoken and bcryptjs CommonJS execution | -| `test-02-jose.mjs` | Passing | jose ESM JWT signing and verification | -| `test-03-nanoid-cookie.mjs` | Passing | nanoid, cookie, and cookie-signature package interop | - -## `data-formats` - -| Test | Status | Coverage | -|---|---:|---| -| `test-01-csv.cjs` | Passing | papaparse and csv-parse CommonJS CSV parsing | -| `test-02-yaml-xml.cjs` | Passing | yaml and xml2js parsing/serialization | -| `test-03-binary-protobuf.cjs` | Passing | msgpackr and protobufjs binary serialization | - -## `db-clients-offline` - -| Test | Status | Coverage | -|---|---:|---| -| `test-01-sql-builders.cjs` | Passing | knex query builder offline execution | -| `test-02-pg-mysql.cjs` | Passing | pg and mysql2 client construction without connecting | -| `test-03-mongodb-redis.mjs` | Passing | mongodb and redis client construction without connecting | -| `test-04-drizzle.mjs` | Passing | drizzle-orm schema helpers offline execution | - -## `fs-template-config` - -| Test | Status | Coverage | -|---|---:|---| -| `test-01-config-parsers.cjs` | Passing | ini and toml config parsing | -| `test-02-template-engines.cjs` | Passing | ejs, handlebars, and mustache rendering | -| `test-03-fast-glob-fs.cjs` | Passing | fast-glob filesystem traversal | - -## `http-clients` - -| Test | Status | Coverage | -|---|---:|---| -| `test-01-axios.cjs` | Passing | Axios CommonJS load, custom adapter, and interceptors | -| `test-02-fetch-ky.mjs` | Passing | node-fetch and ky ESM package loading with local data/custom fetch paths | -| `test-03-graphql-request.mjs` | Passing | graphql-request client execution with custom fetch | - -## `logging-observability` - -| Test | Status | Coverage | -|---|---:|---| -| `test-01-loggers.cjs` | Passing | pino, loglevel, and winston API loading without transports/processes | -| `test-02-consola-otel.mjs` | Passing | consola and OpenTelemetry API loading | - -## `module-interop` - -| Test | Status | Coverage | -|---|---:|---| -| `test-01-esm-import-cjs.js` | Passing | ESM app imports named/default exports from installed CJS packages | -| `test-02-cjs-require-esm.cjs` | Passing | CJS app requires an installed synchronous ESM package | -| `test-03-package-exports-imports.js` | Passing | Installed packages use conditional exports, subpaths, and package imports aliases | -| `test-04-cycle-require-esm.cjs` | Passing | CJS require(esm) cycle inside an installed package reports ERR_REQUIRE_CYCLE_MODULE | -| `test-05-tla-require.cjs` | Passing | CJS require() of installed TLA ESM reports ERR_REQUIRE_ASYNC_MODULE and dynamic import still works | -| `test-06-conditional-import-graph.cjs` | Passing | Graph scanning follows import conditions for static ESM package imports | -| `test-07-conditional-import-no-false-positive.cjs` | Passing | Graph scanning does not mark module-sync branches for static ESM package imports | -| `test-08-conditional-imports-alias-graph.cjs` | Passing | Graph scanning follows import conditions for package imports aliases | -| `test-09-create-require-alias-cycle.cjs` | Passing | Graph scanning handles createRequire aliases in ESM modules | -| `test-10-already-evaluated-dependency.cjs` | Passing | CJS bridge can require an already evaluated ESM dependency | -| `test-11-module-sync-before-import-graph.cjs` | Passing | Graph scanning honors module-sync before import in package exports | -| `test-12-module-sync-before-imports-alias-graph.cjs` | Passing | Graph scanning honors module-sync before import in package imports aliases | -| `test-13-scanner-false-positive-guards.cjs` | Passing | Graph scanning avoids property require, non-call createRequire, local createRequire, and nested CJS require false positives | -| `test-14-exports-patterns.mjs` | Passing | Package exports wildcard patterns resolve for ESM, module-sync, and CJS require | -| `test-15-imports-patterns.cjs` | Passing | Package imports wildcard patterns resolve for CJS require | -| `test-16-shim-patterns.mjs` | Passing | OpenAI-style _shims/auto wildcard package exports resolve | - -## `popular-pure-js` - -| Test | Status | Coverage | -|---|---:|---| -| `test-01-cjs-utilities.cjs` | Passing | Classic CommonJS utilities and transitive dependencies | -| `test-02-modern-esm.mjs` | Passing | Modern ESM and exports-heavy packages | -| `test-03-date-fns-subpaths.mjs` | Passing | Package subpath exports | -| `test-04-dotenv-fs.cjs` | Passing | Filesystem-backed configuration loading | -| `test-05-ajv.cjs` | Passing | Larger CommonJS validation package graph | -| `test-06-rxjs.mjs` | Passing | RxJS package exports and operator subpaths | - -## `validation-schema` - -| Test | Status | Coverage | -|---|---:|---| -| `test-01-joi-yup.cjs` | Passing | joi and yup validation | -| `test-02-superstruct-valibot.mjs` | Passing | superstruct and valibot validation | - -## Non-Runnable Tests - -_No non-runnable installed-app tests._ diff --git a/tests/installed_apps_report.rs b/tests/installed_apps_report.rs deleted file mode 100644 index ba6e87c4..00000000 --- a/tests/installed_apps_report.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! Installed app compatibility report generator. -//! -//! This report is generated from tests/installed_apps/config.jsonc. Runtime tests in -//! tests/runtime/installed_apps.rs use the same config as their source of truth. -//! -//! Usage: -//! cargo test --test installed_apps_report -- --nocapture -//! -//! The report is written to tests/installed_apps/report.md. - -test_r::enable!(); - -#[allow(dead_code)] -mod common; - -use common::{InstalledAppCategory, InstalledAppEntry, InstalledAppTestEntry}; -use std::collections::BTreeMap; -use std::fs; -use test_r::test; - -const CONFIG_PATH: &str = "tests/installed_apps/config.jsonc"; -const REPORT_PATH: &str = "tests/installed_apps/report.md"; - -#[derive(Debug, Clone, Copy, Default)] -struct CategoryCounts { - runnable: usize, - known_gap: usize, - deferred: usize, -} - -impl CategoryCounts { - fn add(&mut self, category: InstalledAppCategory) { - match category { - InstalledAppCategory::Runnable => self.runnable += 1, - InstalledAppCategory::KnownGap => self.known_gap += 1, - InstalledAppCategory::Deferred => self.deferred += 1, - } - } - - fn total(self) -> usize { - self.runnable + self.known_gap + self.deferred - } - - fn status(self) -> &'static str { - if self.known_gap == 0 && self.deferred == 0 { - "Passing" - } else if self.runnable > 0 { - "Partial" - } else if self.known_gap > 0 { - "Known gap" - } else { - "Deferred" - } - } -} - -#[test] -fn generate_installed_apps_report() -> anyhow::Result<()> { - let apps = common::load_installed_apps_config(CONFIG_PATH)?; - let mut totals = CategoryCounts::default(); - for app in &apps { - for test in &app.tests { - totals.add(test.category); - } - } - - let mut report = String::new(); - report.push_str("# Installed App Compatibility Report\n\n"); - report.push_str(&format!( - "Generated: {} | Source: `{CONFIG_PATH}` | Engine: wasm-rquickjs (QuickJS)\n\n", - now_date() - )); - report.push_str( - "This report tracks compatibility for unbundled npm-installed apps attached to the \ - component as a filesystem. It is intentionally separate from `tests/libraries/libraries.md`, \ - which tests Rollup-bundled library usage. Runtime tests in `tests/runtime/installed_apps.rs` \ - are generated from the same config file as this report.\n\n", - ); - - push_scope(&mut report); - push_how_it_runs(&mut report); - push_summary(&mut report, totals, &apps); - push_app_sections(&mut report, &apps); - push_non_runnable(&mut report, &apps); - - fs::write(REPORT_PATH, &report)?; - - println!("Report written to {REPORT_PATH}"); - println!( - "Installed app tests: {}/{} runnable, {} known gap, {} deferred", - totals.runnable, - totals.total(), - totals.known_gap, - totals.deferred - ); - - Ok(()) -} - -fn push_scope(report: &mut String) { - report.push_str("## Scope\n\n"); - report.push_str("| Included | Deferred |\n"); - report.push_str("|---|---|\n"); - report.push_str("| Pure JavaScript packages installed with npm | Native `.node` bindings |\n"); - report.push_str("| `node_modules` package resolution | Packages that load WASM artifacts |\n"); - report.push_str("| package `exports` / `imports` | Child process execution |\n"); - report.push_str( - "| CJS/ESM interop and same-process cycles | CLI preload/eval/warning behavior |\n\n", - ); -} - -fn push_how_it_runs(report: &mut String) { - report.push_str("## How It Runs\n\n"); - report.push_str("For every runnable test, the runtime harness:\n\n"); - report.push_str("1. Copies `tests/installed_apps/apps/` to a temporary directory.\n"); - report - .push_str("2. Runs `npm install --install-links --ignore-scripts --no-audit --no-fund`.\n"); - report.push_str( - "3. Verifies the raw app test on host Node.js via `node run-node.mjs `.\n", - ); - report.push_str("4. Copies the installed app into the WASI preopen as `/app`.\n"); - report.push_str("5. Executes the test through `examples/runtime/installed-app-runner` against real `/app/node_modules`.\n\n"); -} - -fn push_summary(report: &mut String, totals: CategoryCounts, apps: &[InstalledAppEntry]) { - report.push_str("## Summary\n\n"); - report.push_str(&format!( - "Runnable installed-app compatibility: **{}/{}** tests.\n\n", - totals.runnable, - totals.total() - )); - report.push_str("| Classification | Count |\n"); - report.push_str("|---|---:|\n"); - report.push_str(&format!("| Passing (runnable) | {} |\n", totals.runnable)); - report.push_str(&format!("| Known gap | {} |\n", totals.known_gap)); - report.push_str(&format!("| Deferred | {} |\n\n", totals.deferred)); - - report.push_str("## Apps\n\n"); - report.push_str("| App | Status | Tests | Notes |\n"); - report.push_str("|---|---:|---:|---|\n"); - for app in apps { - let counts = counts_for_app(app); - let reason = app.reason.as_deref().unwrap_or(""); - report.push_str(&format!( - "| `{}` | {} | {}/{} | {} |\n", - app.name, - counts.status(), - counts.runnable, - counts.total(), - escape_table_cell(reason) - )); - } - report.push('\n'); -} - -fn push_app_sections(report: &mut String, apps: &[InstalledAppEntry]) { - for app in apps { - report.push_str(&format!("## `{}`\n\n", app.name)); - report.push_str("| Test | Status | Coverage |\n"); - report.push_str("|---|---:|---|\n"); - for test in &app.tests { - report.push_str(&format!( - "| `{}` | {} | {} |\n", - test.file, - test.category.status_label(), - escape_table_cell(&test.coverage) - )); - } - report.push('\n'); - } -} - -fn push_non_runnable(report: &mut String, apps: &[InstalledAppEntry]) { - let mut by_reason: BTreeMap> = - BTreeMap::new(); - for app in apps { - for test in &app.tests { - if test.category == InstalledAppCategory::Runnable { - continue; - } - by_reason - .entry( - test.reason - .as_deref() - .filter(|reason| !reason.trim().is_empty()) - .unwrap_or("missing reason") - .to_string(), - ) - .or_default() - .push((app, test)); - } - } - - report.push_str("## Non-Runnable Tests\n\n"); - if by_reason.is_empty() { - report.push_str("_No non-runnable installed-app tests._\n"); - return; - } - - report.push_str("| Reason | Count | Entries |\n"); - report.push_str("|---|---:|---|\n"); - for (reason, entries) in by_reason { - let examples = entries - .iter() - .map(|(app, test)| format!("`{}/{}`", app.name, test.file)) - .collect::>() - .join(", "); - report.push_str(&format!( - "| {} | {} | {} |\n", - escape_table_cell(&reason), - entries.len(), - examples - )); - } -} - -fn counts_for_app(app: &InstalledAppEntry) -> CategoryCounts { - let mut counts = CategoryCounts::default(); - for test in &app.tests { - counts.add(test.category); - } - counts -} - -fn escape_table_cell(value: &str) -> String { - value.replace('\n', " ").replace('|', "\\|") -} - -fn now_date() -> String { - chrono::Local::now().format("%Y-%m-%d").to_string() -} diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 9f5c0e8c..e76a3ab7 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5876,7 +5876,7 @@ "es-module/test-require-module-cycle-cjs-esm-esm.js": {}, "es-module/test-require-module-cycle-esm-cjs-esm-esm.js": { "category": "known-gap", - "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct installed-app same-process module graph coverage lives in tests/installed_apps", + "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct node modules app same-process module graph coverage lives in tests/node_modules_apps", "split": true, "subtests": { "block_00_a_mjs_b_cjs_c_mjs_a_mjs": {}, @@ -5886,7 +5886,7 @@ }, "es-module/test-require-module-cycle-esm-cjs-esm.js": { "category": "known-gap", - "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct installed-app same-process module graph coverage lives in tests/installed_apps", + "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct node modules app same-process module graph coverage lives in tests/node_modules_apps", "split": true, "subtests": { "block_00_require_a_cjs_a_mjs_b_cjs_a_mjs": {}, @@ -5897,7 +5897,7 @@ }, "es-module/test-require-module-cycle-esm-esm-cjs-esm-esm.js": { "category": "known-gap", - "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct installed-app same-process module graph coverage lives in tests/installed_apps", + "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct node modules app same-process module graph coverage lives in tests/node_modules_apps", "split": true, "subtests": { "block_00_a_mjs_b_mjs_c_cjs_z_mjs_a_mjs": {}, @@ -5908,7 +5908,7 @@ }, "es-module/test-require-module-cycle-esm-esm-cjs-esm.js": { "category": "known-gap", - "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stdout/stderr diagnostics; one TLA/dynamic-import sequencing case can still hit a QuickJS linker assert through process.execPath emulation, but direct same-process installed-app coverage passes", + "reason": "remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stdout/stderr diagnostics; one TLA/dynamic-import sequencing case can still hit a QuickJS linker assert through process.execPath emulation, but direct same-process node modules app coverage passes", "split": true, "subtests": { "block_00_a_mjs_b_mjs_c_mjs_d_mjs_c_mjs": {}, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 4bf6c45b..9e1fea8f 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -694,7 +694,7 @@ Secondary full-public compatibility, including public tests that are currently e | full script module-loading test still exposes incomplete main-module/cache/package-main edge semantics | 11 | `sequential/test-module-loading.js#block_00_block_00`, `sequential/test-module-loading.js#block_01_block_01`, `sequential/test-module-loading.js#block_02_block_02`, ... (+8) | | inherited: process.permission and --permission CLI semantics are incomplete in execPath emulation | 11 | `parallel/test-permission-allow-child-process-cli.js#block_00_guarantee_the_initial_state`, `parallel/test-permission-allow-child-process-cli.js#block_01_to_spawn_unless_allow_child_process_is_sent`, `parallel/test-permission-allow-wasi-cli.js#block_00_guarantee_the_initial_state`, ... (+8) | | net.js TCP implementation incomplete - needs event handling and API fixes | 11 | `parallel/test-net-connect-nodelay.js`, `parallel/test-net-connect-paused-connection.js`, `parallel/test-net-during-close.js`, ... (+8) | -| remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct installed-app same-process module graph coverage lives in tests/installed_apps | 11 | `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_00_a_mjs_b_cjs_c_mjs_a_mjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_01_b_cjs_c_mjs_a_mjs_b_cjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_02_c_mjs_a_mjs_b_cjs_c_mjs`, ... (+8) | +| remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct node modules app same-process module graph coverage lives in tests/node_modules_apps | 11 | `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_00_a_mjs_b_cjs_c_mjs_a_mjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_01_b_cjs_c_mjs_a_mjs_b_cjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_02_c_mjs_a_mjs_b_cjs_c_mjs`, ... (+8) | | wasi:sockets UDP implementation hangs in wasmtime | 11 | `parallel/test-dgram-implicit-bind.js`, `parallel/test-dgram-multicast-set-interface.js#block_00_block_00`, `parallel/test-dgram-multicast-set-interface.js#block_02_block_02`, ... (+8) | | dgram multicast membership APIs are not implemented (ENOSYS) | 10 | `parallel/test-dgram-membership.js#block_02_addmembership_with_no_argument_should_throw`, `parallel/test-dgram-membership.js#block_03_dropmembership_with_no_argument_should_throw`, `parallel/test-dgram-membership.js#block_04_addmembership_with_invalid_multicast_address_should_throw`, ... (+7) | | async_hooks not fully implemented | 9 | `parallel/test-async-hooks-destroy-on-gc.js`, `parallel/test-async-hooks-disable-during-promise.js`, `parallel/test-async-hooks-disable-gc-tracking.js`, ... (+6) | @@ -727,7 +727,7 @@ Secondary full-public compatibility, including public tests that are currently e | isMarkedAsUntransferable() and related mark/query behavior are incomplete | 4 | `parallel/test-worker-message-transfer-port-mark-as-untransferable.js#block_00_block_00`, `parallel/test-worker-message-transfer-port-mark-as-untransferable.js#block_01_block_01`, `parallel/test-worker-message-transfer-port-mark-as-untransferable.js#block_02_block_02`, ... (+1) | | markAsUncloneable and DataCloneError semantics are incomplete | 4 | `parallel/test-worker-message-mark-as-uncloneable.js#block_00_uncloneables_cannot_be_cloned_during_message_posting`, `parallel/test-worker-message-mark-as-uncloneable.js#block_01_uncloneables_cannot_be_cloned_during_structured_cloning`, `parallel/test-worker-message-mark-as-uncloneable.js#block_02_markasuncloneable_cannot_affect_arraybuffer`, ... (+1) | | promisified exec()/execFile() contract is incomplete (promise.child is not a ChildProcess instance) | 4 | `parallel/test-child-process-promisified.js#block_00_block_00`, `parallel/test-child-process-promisified.js#block_01_block_01`, `parallel/test-child-process-promisified.js#block_02_block_02`, ... (+1) | -| remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stdout/stderr diagnostics; one TLA/dynamic-import sequencing case can still hit a QuickJS linker assert through process.execPath emulation, but direct same-process installed-app coverage passes | 4 | `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_00_a_mjs_b_mjs_c_mjs_d_mjs_c_mjs`, `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_01_b_mjs_c_mjs_d_mjs_c_mjs`, `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_02_c_mjs_d_mjs_c_mjs`, ... (+1) | +| remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stdout/stderr diagnostics; one TLA/dynamic-import sequencing case can still hit a QuickJS linker assert through process.execPath emulation, but direct same-process node modules app coverage passes | 4 | `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_00_a_mjs_b_mjs_c_mjs_d_mjs_c_mjs`, `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_01_b_mjs_c_mjs_d_mjs_c_mjs`, `es-module/test-require-module-cycle-esm-esm-cjs-esm.js#block_02_c_mjs_d_mjs_c_mjs`, ... (+1) | | timeout enforcement with microtaskMode='afterEvaluate' is incomplete | 4 | `parallel/test-vm-timeout-escape-promise-2.js`, `parallel/test-vm-timeout-escape-promise-module.js`, `parallel/test-vm-timeout-escape-promise.js`, ... (+1) | | unhandled-rejection mode and uncaughtException bridging semantics are incomplete | 4 | `parallel/test-promise-unhandled-default.js`, `parallel/test-promise-unhandled-error.js`, `parallel/test-promise-unhandled-throw-handler.js`, ... (+1) | | wasi:http client does not surface 103 Early Hints as 'information' events | 4 | `parallel/test-http-early-hints.js#block_00_block_00`, `parallel/test-http-early-hints.js#block_01_block_01`, `parallel/test-http-early-hints.js#block_03_block_03`, ... (+1) | diff --git a/tests/installed_apps/README.md b/tests/node_modules_apps/README.md similarity index 59% rename from tests/installed_apps/README.md rename to tests/node_modules_apps/README.md index 4988bc6a..5ae978df 100644 --- a/tests/installed_apps/README.md +++ b/tests/node_modules_apps/README.md @@ -1,10 +1,10 @@ -# Installed App Compatibility Tests +# Node Modules App Tests -This suite tests unbundled npm-installed apps with real `node_modules` attached to the component filesystem. It is intentionally separate from `tests/libraries/`, which tests Rollup-bundled package usage and records human compatibility notes in `tests/libraries/libraries.md`. +This runtime suite tests unbundled npm apps with real `node_modules` attached to the component filesystem. It is intentionally separate from `tests/libraries/`, which tests Rollup-bundled package usage and records human compatibility notes in `tests/libraries/libraries.md`. ## What This Suite Covers -Use installed-app tests for: +Use node modules app tests for: - Node-style package resolution from a filesystem `node_modules` tree - package `exports` / `imports`, including wildcard patterns @@ -12,11 +12,11 @@ Use installed-app tests for: - package graphs that behave differently when bundled versus installed - small smoke tests for pure-JS packages that should run without subprocesses or live services -Do not use installed-app tests for native `.node` bindings, packages that load WASM artifacts, subprocess-heavy behavior, or live network/cloud service calls. +Do not use node modules app tests for native `.node` bindings, packages that load WASM artifacts, subprocess-heavy behavior, or live network/cloud service calls. ## Source Of Truth -`tests/installed_apps/config.jsonc` is the source of truth. Runtime tests in `tests/runtime/installed_apps.rs` are generated from this config, and `tests/installed_apps/report.md` is generated from the same config. +`tests/node_modules_apps/config.jsonc` is the source of truth. Runtime tests in `tests/runtime/node_modules_apps.rs` are generated from this config. Each app has this shape: @@ -51,14 +51,14 @@ Supported categories are: |---|---|---| | `runnable` | Runs in `cargo test --test runtime` | Expected to pass | | `known-gap` | Ignored | Public behavior is in scope but missing/incomplete | -| `deferred` | Ignored | Intentionally outside this installed-app suite's current scope | +| `deferred` | Ignored | Intentionally outside this node modules app suite's current scope | ## App Directory Layout -Each app lives under `tests/installed_apps/apps//`: +Each app lives under `tests/node_modules_apps/apps//`: ```text -tests/installed_apps/apps/example-app/ +tests/node_modules_apps/apps/example-app/ ├── package.json ├── run-node.mjs ├── test-01-basic.mjs @@ -95,35 +95,37 @@ For every runnable config entry, the harness: 1. Copies the app directory to a temporary directory. 2. Runs `npm install --install-links --ignore-scripts --no-audit --no-fund`. 3. Verifies the raw app test with host Node.js: `node run-node.mjs `. -4. Copies the installed app into the WASI preopen as `/app`. -5. Runs `examples/runtime/installed-app-runner`, which imports or requires the test from `/app` and executes it against real `/app/node_modules`. +4. Copies the npm app into the WASI preopen as `/app`. +5. Runs `examples/runtime/node-modules-app-runner`, which imports or requires the test from `/app` and executes it against real `/app/node_modules`. ## Commands -Run the installed-app suite after skeleton changes: +Run the node modules app suite after skeleton changes: ```sh ./cleanup-skeleton.sh -cargo test --test runtime --features use-golem-wasmtime -- installed_app --nocapture +cargo test --test runtime --features use-golem-wasmtime -- node_modules_app --nocapture ``` Run a narrower filter: ```sh -cargo test --test runtime --features use-golem-wasmtime -- installed_app__module_interop --nocapture +cargo test --test runtime --features use-golem-wasmtime -- node_modules_app__module_interop --nocapture ``` -Regenerate the report after `config.jsonc` changes: +Run the CI-style node modules app group: ```sh -cargo test --test installed_apps_report --features use-golem-wasmtime -- --nocapture +cargo test --test runtime --features use-golem-wasmtime -- ':tag:group9' ``` +Node modules app tests run as runtime `group9` in CI. Regular runtime tests use `group1` through `group8`. + ## Relationship To Rollup Library Tests | Suite | Purpose | Execution | |---|---|---| | `tests/libraries/` | Documents package compatibility when bundled with Rollup like the Golem CLI pipeline | Manual/agent workflow using Rollup, generated wrapper crates, and `wasmtime run` | -| `tests/installed_apps/` | CI-enforced regression tests for unbundled apps with real filesystem `node_modules` | Rust runtime harness generated from `config.jsonc` | +| `tests/node_modules_apps/` | CI-enforced regression tests for unbundled apps with real filesystem `node_modules` | Rust runtime harness generated from `config.jsonc` | -The same npm package may be covered in both suites for different reasons. Rollup tests answer whether bundled usage works; installed-app tests answer whether Node-style installed package loading works. +The same npm package may be covered in both suites for different reasons. Rollup tests answer whether bundled usage works; node modules app tests answer whether Node-style installed package loading works. diff --git a/tests/installed_apps/apps/cloud-sdk-offline/package.json b/tests/node_modules_apps/apps/cloud-sdk-offline/package.json similarity index 100% rename from tests/installed_apps/apps/cloud-sdk-offline/package.json rename to tests/node_modules_apps/apps/cloud-sdk-offline/package.json diff --git a/tests/installed_apps/apps/cloud-sdk-offline/run-node.mjs b/tests/node_modules_apps/apps/cloud-sdk-offline/run-node.mjs similarity index 100% rename from tests/installed_apps/apps/cloud-sdk-offline/run-node.mjs rename to tests/node_modules_apps/apps/cloud-sdk-offline/run-node.mjs diff --git a/tests/installed_apps/apps/cloud-sdk-offline/test-01-openai.mjs b/tests/node_modules_apps/apps/cloud-sdk-offline/test-01-openai.mjs similarity index 100% rename from tests/installed_apps/apps/cloud-sdk-offline/test-01-openai.mjs rename to tests/node_modules_apps/apps/cloud-sdk-offline/test-01-openai.mjs diff --git a/tests/installed_apps/apps/cloud-sdk-offline/test-02-anthropic.mjs b/tests/node_modules_apps/apps/cloud-sdk-offline/test-02-anthropic.mjs similarity index 100% rename from tests/installed_apps/apps/cloud-sdk-offline/test-02-anthropic.mjs rename to tests/node_modules_apps/apps/cloud-sdk-offline/test-02-anthropic.mjs diff --git a/tests/installed_apps/apps/cloud-sdk-offline/test-03-aws-s3.mjs b/tests/node_modules_apps/apps/cloud-sdk-offline/test-03-aws-s3.mjs similarity index 100% rename from tests/installed_apps/apps/cloud-sdk-offline/test-03-aws-s3.mjs rename to tests/node_modules_apps/apps/cloud-sdk-offline/test-03-aws-s3.mjs diff --git a/tests/installed_apps/apps/cloud-sdk-offline/test-04-stripe.cjs b/tests/node_modules_apps/apps/cloud-sdk-offline/test-04-stripe.cjs similarity index 100% rename from tests/installed_apps/apps/cloud-sdk-offline/test-04-stripe.cjs rename to tests/node_modules_apps/apps/cloud-sdk-offline/test-04-stripe.cjs diff --git a/tests/installed_apps/apps/crypto-auth/package.json b/tests/node_modules_apps/apps/crypto-auth/package.json similarity index 100% rename from tests/installed_apps/apps/crypto-auth/package.json rename to tests/node_modules_apps/apps/crypto-auth/package.json diff --git a/tests/installed_apps/apps/crypto-auth/run-node.mjs b/tests/node_modules_apps/apps/crypto-auth/run-node.mjs similarity index 100% rename from tests/installed_apps/apps/crypto-auth/run-node.mjs rename to tests/node_modules_apps/apps/crypto-auth/run-node.mjs diff --git a/tests/installed_apps/apps/crypto-auth/test-01-jsonwebtoken-bcrypt.cjs b/tests/node_modules_apps/apps/crypto-auth/test-01-jsonwebtoken-bcrypt.cjs similarity index 100% rename from tests/installed_apps/apps/crypto-auth/test-01-jsonwebtoken-bcrypt.cjs rename to tests/node_modules_apps/apps/crypto-auth/test-01-jsonwebtoken-bcrypt.cjs diff --git a/tests/installed_apps/apps/crypto-auth/test-02-jose.mjs b/tests/node_modules_apps/apps/crypto-auth/test-02-jose.mjs similarity index 100% rename from tests/installed_apps/apps/crypto-auth/test-02-jose.mjs rename to tests/node_modules_apps/apps/crypto-auth/test-02-jose.mjs diff --git a/tests/installed_apps/apps/crypto-auth/test-03-nanoid-cookie.mjs b/tests/node_modules_apps/apps/crypto-auth/test-03-nanoid-cookie.mjs similarity index 100% rename from tests/installed_apps/apps/crypto-auth/test-03-nanoid-cookie.mjs rename to tests/node_modules_apps/apps/crypto-auth/test-03-nanoid-cookie.mjs diff --git a/tests/installed_apps/apps/data-formats/package.json b/tests/node_modules_apps/apps/data-formats/package.json similarity index 100% rename from tests/installed_apps/apps/data-formats/package.json rename to tests/node_modules_apps/apps/data-formats/package.json diff --git a/tests/installed_apps/apps/data-formats/run-node.mjs b/tests/node_modules_apps/apps/data-formats/run-node.mjs similarity index 100% rename from tests/installed_apps/apps/data-formats/run-node.mjs rename to tests/node_modules_apps/apps/data-formats/run-node.mjs diff --git a/tests/installed_apps/apps/data-formats/test-01-csv.cjs b/tests/node_modules_apps/apps/data-formats/test-01-csv.cjs similarity index 100% rename from tests/installed_apps/apps/data-formats/test-01-csv.cjs rename to tests/node_modules_apps/apps/data-formats/test-01-csv.cjs diff --git a/tests/installed_apps/apps/data-formats/test-02-yaml-xml.cjs b/tests/node_modules_apps/apps/data-formats/test-02-yaml-xml.cjs similarity index 100% rename from tests/installed_apps/apps/data-formats/test-02-yaml-xml.cjs rename to tests/node_modules_apps/apps/data-formats/test-02-yaml-xml.cjs diff --git a/tests/installed_apps/apps/data-formats/test-03-binary-protobuf.cjs b/tests/node_modules_apps/apps/data-formats/test-03-binary-protobuf.cjs similarity index 100% rename from tests/installed_apps/apps/data-formats/test-03-binary-protobuf.cjs rename to tests/node_modules_apps/apps/data-formats/test-03-binary-protobuf.cjs diff --git a/tests/installed_apps/apps/db-clients-offline/package.json b/tests/node_modules_apps/apps/db-clients-offline/package.json similarity index 100% rename from tests/installed_apps/apps/db-clients-offline/package.json rename to tests/node_modules_apps/apps/db-clients-offline/package.json diff --git a/tests/installed_apps/apps/db-clients-offline/run-node.mjs b/tests/node_modules_apps/apps/db-clients-offline/run-node.mjs similarity index 100% rename from tests/installed_apps/apps/db-clients-offline/run-node.mjs rename to tests/node_modules_apps/apps/db-clients-offline/run-node.mjs diff --git a/tests/installed_apps/apps/db-clients-offline/test-01-sql-builders.cjs b/tests/node_modules_apps/apps/db-clients-offline/test-01-sql-builders.cjs similarity index 100% rename from tests/installed_apps/apps/db-clients-offline/test-01-sql-builders.cjs rename to tests/node_modules_apps/apps/db-clients-offline/test-01-sql-builders.cjs diff --git a/tests/installed_apps/apps/db-clients-offline/test-02-pg-mysql.cjs b/tests/node_modules_apps/apps/db-clients-offline/test-02-pg-mysql.cjs similarity index 100% rename from tests/installed_apps/apps/db-clients-offline/test-02-pg-mysql.cjs rename to tests/node_modules_apps/apps/db-clients-offline/test-02-pg-mysql.cjs diff --git a/tests/installed_apps/apps/db-clients-offline/test-03-mongodb-redis.mjs b/tests/node_modules_apps/apps/db-clients-offline/test-03-mongodb-redis.mjs similarity index 100% rename from tests/installed_apps/apps/db-clients-offline/test-03-mongodb-redis.mjs rename to tests/node_modules_apps/apps/db-clients-offline/test-03-mongodb-redis.mjs diff --git a/tests/installed_apps/apps/db-clients-offline/test-04-drizzle.mjs b/tests/node_modules_apps/apps/db-clients-offline/test-04-drizzle.mjs similarity index 100% rename from tests/installed_apps/apps/db-clients-offline/test-04-drizzle.mjs rename to tests/node_modules_apps/apps/db-clients-offline/test-04-drizzle.mjs diff --git a/tests/installed_apps/apps/fs-template-config/package.json b/tests/node_modules_apps/apps/fs-template-config/package.json similarity index 100% rename from tests/installed_apps/apps/fs-template-config/package.json rename to tests/node_modules_apps/apps/fs-template-config/package.json diff --git a/tests/installed_apps/apps/fs-template-config/run-node.mjs b/tests/node_modules_apps/apps/fs-template-config/run-node.mjs similarity index 100% rename from tests/installed_apps/apps/fs-template-config/run-node.mjs rename to tests/node_modules_apps/apps/fs-template-config/run-node.mjs diff --git a/tests/installed_apps/apps/fs-template-config/test-01-config-parsers.cjs b/tests/node_modules_apps/apps/fs-template-config/test-01-config-parsers.cjs similarity index 100% rename from tests/installed_apps/apps/fs-template-config/test-01-config-parsers.cjs rename to tests/node_modules_apps/apps/fs-template-config/test-01-config-parsers.cjs diff --git a/tests/installed_apps/apps/fs-template-config/test-02-template-engines.cjs b/tests/node_modules_apps/apps/fs-template-config/test-02-template-engines.cjs similarity index 100% rename from tests/installed_apps/apps/fs-template-config/test-02-template-engines.cjs rename to tests/node_modules_apps/apps/fs-template-config/test-02-template-engines.cjs diff --git a/tests/installed_apps/apps/fs-template-config/test-03-fast-glob-fs.cjs b/tests/node_modules_apps/apps/fs-template-config/test-03-fast-glob-fs.cjs similarity index 100% rename from tests/installed_apps/apps/fs-template-config/test-03-fast-glob-fs.cjs rename to tests/node_modules_apps/apps/fs-template-config/test-03-fast-glob-fs.cjs diff --git a/tests/installed_apps/apps/http-clients/package.json b/tests/node_modules_apps/apps/http-clients/package.json similarity index 100% rename from tests/installed_apps/apps/http-clients/package.json rename to tests/node_modules_apps/apps/http-clients/package.json diff --git a/tests/installed_apps/apps/http-clients/run-node.mjs b/tests/node_modules_apps/apps/http-clients/run-node.mjs similarity index 100% rename from tests/installed_apps/apps/http-clients/run-node.mjs rename to tests/node_modules_apps/apps/http-clients/run-node.mjs diff --git a/tests/installed_apps/apps/http-clients/test-01-axios.cjs b/tests/node_modules_apps/apps/http-clients/test-01-axios.cjs similarity index 100% rename from tests/installed_apps/apps/http-clients/test-01-axios.cjs rename to tests/node_modules_apps/apps/http-clients/test-01-axios.cjs diff --git a/tests/installed_apps/apps/http-clients/test-02-fetch-ky.mjs b/tests/node_modules_apps/apps/http-clients/test-02-fetch-ky.mjs similarity index 100% rename from tests/installed_apps/apps/http-clients/test-02-fetch-ky.mjs rename to tests/node_modules_apps/apps/http-clients/test-02-fetch-ky.mjs diff --git a/tests/installed_apps/apps/http-clients/test-03-graphql-request.mjs b/tests/node_modules_apps/apps/http-clients/test-03-graphql-request.mjs similarity index 100% rename from tests/installed_apps/apps/http-clients/test-03-graphql-request.mjs rename to tests/node_modules_apps/apps/http-clients/test-03-graphql-request.mjs diff --git a/tests/installed_apps/apps/logging-observability/package.json b/tests/node_modules_apps/apps/logging-observability/package.json similarity index 100% rename from tests/installed_apps/apps/logging-observability/package.json rename to tests/node_modules_apps/apps/logging-observability/package.json diff --git a/tests/installed_apps/apps/logging-observability/run-node.mjs b/tests/node_modules_apps/apps/logging-observability/run-node.mjs similarity index 100% rename from tests/installed_apps/apps/logging-observability/run-node.mjs rename to tests/node_modules_apps/apps/logging-observability/run-node.mjs diff --git a/tests/installed_apps/apps/logging-observability/test-01-loggers.cjs b/tests/node_modules_apps/apps/logging-observability/test-01-loggers.cjs similarity index 100% rename from tests/installed_apps/apps/logging-observability/test-01-loggers.cjs rename to tests/node_modules_apps/apps/logging-observability/test-01-loggers.cjs diff --git a/tests/installed_apps/apps/logging-observability/test-02-consola-otel.mjs b/tests/node_modules_apps/apps/logging-observability/test-02-consola-otel.mjs similarity index 100% rename from tests/installed_apps/apps/logging-observability/test-02-consola-otel.mjs rename to tests/node_modules_apps/apps/logging-observability/test-02-consola-otel.mjs diff --git a/tests/installed_apps/apps/module-interop/package.json b/tests/node_modules_apps/apps/module-interop/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/package.json rename to tests/node_modules_apps/apps/module-interop/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-basic/index.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-basic/index.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/cjs-basic/index.cjs rename to tests/node_modules_apps/apps/module-interop/packages/cjs-basic/index.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-basic/package.json b/tests/node_modules_apps/apps/module-interop/packages/cjs-basic/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/cjs-basic/package.json rename to tests/node_modules_apps/apps/module-interop/packages/cjs-basic/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/index.js b/tests/node_modules_apps/apps/module-interop/packages/cjs-nested-require-pkg/index.js similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/index.js rename to tests/node_modules_apps/apps/module-interop/packages/cjs-nested-require-pkg/index.js diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/package.json b/tests/node_modules_apps/apps/module-interop/packages/cjs-nested-require-pkg/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/cjs-nested-require-pkg/package.json rename to tests/node_modules_apps/apps/module-interop/packages/cjs-nested-require-pkg/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/index.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-reexport-pkg/index.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/index.cjs rename to tests/node_modules_apps/apps/module-interop/packages/cjs-reexport-pkg/index.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/package.json b/tests/node_modules_apps/apps/module-interop/packages/cjs-reexport-pkg/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/cjs-reexport-pkg/package.json rename to tests/node_modules_apps/apps/module-interop/packages/cjs-reexport-pkg/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/entry.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-import-cycle/entry.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/entry.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-import-cycle/entry.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/package.json b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-import-cycle/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-import-cycle/package.json rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-import-cycle/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-bridge.cjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-bridge.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-bridge.cjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-bridge.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-import.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-import.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-import.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-import.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-sync.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-sync.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-sync.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-imports-cycle/dep-sync.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/entry.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-imports-cycle/entry.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/entry.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-imports-cycle/entry.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/package.json b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-imports-cycle/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-imports-cycle/package.json rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-imports-cycle/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/bridge.cjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/bridge.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/bridge.cjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/bridge.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry-import.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry-import.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry-import.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry-import.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/entry.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/package.json b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/package.json rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-cycle/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-bridge.cjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-bridge.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-bridge.cjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-bridge.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-import.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-import.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-import.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-import.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-sync.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-sync.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-sync.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/dep-sync.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/entry.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/entry.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/entry.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/entry.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/package.json b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/package.json rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-module-sync-imports-cycle/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/entry.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-no-cycle/entry.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/entry.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-no-cycle/entry.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/package.json b/tests/node_modules_apps/apps/module-interop/packages/condition-entry-no-cycle/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-entry-no-cycle/package.json rename to tests/node_modules_apps/apps/module-interop/packages/condition-entry-no-cycle/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/bridge.cjs b/tests/node_modules_apps/apps/module-interop/packages/condition-target-import-cycle/bridge.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/bridge.cjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-target-import-cycle/bridge.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/import.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-target-import-cycle/import.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/import.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-target-import-cycle/import.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/package.json b/tests/node_modules_apps/apps/module-interop/packages/condition-target-import-cycle/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/package.json rename to tests/node_modules_apps/apps/module-interop/packages/condition-target-import-cycle/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/sync.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-target-import-cycle/sync.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-target-import-cycle/sync.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-target-import-cycle/sync.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/bridge.cjs b/tests/node_modules_apps/apps/module-interop/packages/condition-target-no-cycle/bridge.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/bridge.cjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-target-no-cycle/bridge.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/import.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-target-no-cycle/import.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/import.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-target-no-cycle/import.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/package.json b/tests/node_modules_apps/apps/module-interop/packages/condition-target-no-cycle/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/package.json rename to tests/node_modules_apps/apps/module-interop/packages/condition-target-no-cycle/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/sync.mjs b/tests/node_modules_apps/apps/module-interop/packages/condition-target-no-cycle/sync.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/condition-target-no-cycle/sync.mjs rename to tests/node_modules_apps/apps/module-interop/packages/condition-target-no-cycle/sync.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/bridge.cjs b/tests/node_modules_apps/apps/module-interop/packages/cycle-require-esm/bridge.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/cycle-require-esm/bridge.cjs rename to tests/node_modules_apps/apps/module-interop/packages/cycle-require-esm/bridge.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/esm.mjs b/tests/node_modules_apps/apps/module-interop/packages/cycle-require-esm/esm.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/cycle-require-esm/esm.mjs rename to tests/node_modules_apps/apps/module-interop/packages/cycle-require-esm/esm.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/index.cjs b/tests/node_modules_apps/apps/module-interop/packages/cycle-require-esm/index.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/cycle-require-esm/index.cjs rename to tests/node_modules_apps/apps/module-interop/packages/cycle-require-esm/index.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/cycle-require-esm/package.json b/tests/node_modules_apps/apps/module-interop/packages/cycle-require-esm/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/cycle-require-esm/package.json rename to tests/node_modules_apps/apps/module-interop/packages/cycle-require-esm/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/default.mjs b/tests/node_modules_apps/apps/module-interop/packages/dual-exports/default.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/dual-exports/default.mjs rename to tests/node_modules_apps/apps/module-interop/packages/dual-exports/default.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/feature.cjs b/tests/node_modules_apps/apps/module-interop/packages/dual-exports/feature.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/dual-exports/feature.cjs rename to tests/node_modules_apps/apps/module-interop/packages/dual-exports/feature.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/feature.mjs b/tests/node_modules_apps/apps/module-interop/packages/dual-exports/feature.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/dual-exports/feature.mjs rename to tests/node_modules_apps/apps/module-interop/packages/dual-exports/feature.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/import.mjs b/tests/node_modules_apps/apps/module-interop/packages/dual-exports/import.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/dual-exports/import.mjs rename to tests/node_modules_apps/apps/module-interop/packages/dual-exports/import.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/package.json b/tests/node_modules_apps/apps/module-interop/packages/dual-exports/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/dual-exports/package.json rename to tests/node_modules_apps/apps/module-interop/packages/dual-exports/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/require.cjs b/tests/node_modules_apps/apps/module-interop/packages/dual-exports/require.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/dual-exports/require.cjs rename to tests/node_modules_apps/apps/module-interop/packages/dual-exports/require.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/dual-exports/sync.mjs b/tests/node_modules_apps/apps/module-interop/packages/dual-exports/sync.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/dual-exports/sync.mjs rename to tests/node_modules_apps/apps/module-interop/packages/dual-exports/sync.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/bridge.cjs b/tests/node_modules_apps/apps/module-interop/packages/esm-alias-create-require-cycle/bridge.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/bridge.cjs rename to tests/node_modules_apps/apps/module-interop/packages/esm-alias-create-require-cycle/bridge.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/entry.mjs b/tests/node_modules_apps/apps/module-interop/packages/esm-alias-create-require-cycle/entry.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/entry.mjs rename to tests/node_modules_apps/apps/module-interop/packages/esm-alias-create-require-cycle/entry.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/package.json b/tests/node_modules_apps/apps/module-interop/packages/esm-alias-create-require-cycle/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/esm-alias-create-require-cycle/package.json rename to tests/node_modules_apps/apps/module-interop/packages/esm-alias-create-require-cycle/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/bridge.cjs b/tests/node_modules_apps/apps/module-interop/packages/esm-already-evaluated/bridge.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/bridge.cjs rename to tests/node_modules_apps/apps/module-interop/packages/esm-already-evaluated/bridge.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/entry.mjs b/tests/node_modules_apps/apps/module-interop/packages/esm-already-evaluated/entry.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/entry.mjs rename to tests/node_modules_apps/apps/module-interop/packages/esm-already-evaluated/entry.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/package.json b/tests/node_modules_apps/apps/module-interop/packages/esm-already-evaluated/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/package.json rename to tests/node_modules_apps/apps/module-interop/packages/esm-already-evaluated/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/ready.mjs b/tests/node_modules_apps/apps/module-interop/packages/esm-already-evaluated/ready.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/esm-already-evaluated/ready.mjs rename to tests/node_modules_apps/apps/module-interop/packages/esm-already-evaluated/ready.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/entry.mjs b/tests/node_modules_apps/apps/module-interop/packages/esm-false-positive-scanner/entry.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/entry.mjs rename to tests/node_modules_apps/apps/module-interop/packages/esm-false-positive-scanner/entry.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/package.json b/tests/node_modules_apps/apps/module-interop/packages/esm-false-positive-scanner/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/esm-false-positive-scanner/package.json rename to tests/node_modules_apps/apps/module-interop/packages/esm-false-positive-scanner/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/esm-sync/index.mjs b/tests/node_modules_apps/apps/module-interop/packages/esm-sync/index.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/esm-sync/index.mjs rename to tests/node_modules_apps/apps/module-interop/packages/esm-sync/index.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/esm-sync/package.json b/tests/node_modules_apps/apps/module-interop/packages/esm-sync/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/esm-sync/package.json rename to tests/node_modules_apps/apps/module-interop/packages/esm-sync/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/imports-alias/dep.cjs b/tests/node_modules_apps/apps/module-interop/packages/imports-alias/dep.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/imports-alias/dep.cjs rename to tests/node_modules_apps/apps/module-interop/packages/imports-alias/dep.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/imports-alias/index.mjs b/tests/node_modules_apps/apps/module-interop/packages/imports-alias/index.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/imports-alias/index.mjs rename to tests/node_modules_apps/apps/module-interop/packages/imports-alias/index.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/imports-alias/package.json b/tests/node_modules_apps/apps/module-interop/packages/imports-alias/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/imports-alias/package.json rename to tests/node_modules_apps/apps/module-interop/packages/imports-alias/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/cjs/gamma.cjs b/tests/node_modules_apps/apps/module-interop/packages/pattern-exports/cjs/gamma.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-exports/cjs/gamma.cjs rename to tests/node_modules_apps/apps/module-interop/packages/pattern-exports/cjs/gamma.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/package.json b/tests/node_modules_apps/apps/module-interop/packages/pattern-exports/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-exports/package.json rename to tests/node_modules_apps/apps/module-interop/packages/pattern-exports/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/src/alpha.mjs b/tests/node_modules_apps/apps/module-interop/packages/pattern-exports/src/alpha.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-exports/src/alpha.mjs rename to tests/node_modules_apps/apps/module-interop/packages/pattern-exports/src/alpha.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-default.mjs b/tests/node_modules_apps/apps/module-interop/packages/pattern-exports/sync/beta-default.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-default.mjs rename to tests/node_modules_apps/apps/module-interop/packages/pattern-exports/sync/beta-default.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-import.mjs b/tests/node_modules_apps/apps/module-interop/packages/pattern-exports/sync/beta-import.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-import.mjs rename to tests/node_modules_apps/apps/module-interop/packages/pattern-exports/sync/beta-import.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-sync.mjs b/tests/node_modules_apps/apps/module-interop/packages/pattern-exports/sync/beta-sync.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-exports/sync/beta-sync.mjs rename to tests/node_modules_apps/apps/module-interop/packages/pattern-exports/sync/beta-sync.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-imports/index.cjs b/tests/node_modules_apps/apps/module-interop/packages/pattern-imports/index.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-imports/index.cjs rename to tests/node_modules_apps/apps/module-interop/packages/pattern-imports/index.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.cjs b/tests/node_modules_apps/apps/module-interop/packages/pattern-imports/internal/value.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.cjs rename to tests/node_modules_apps/apps/module-interop/packages/pattern-imports/internal/value.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.mjs b/tests/node_modules_apps/apps/module-interop/packages/pattern-imports/internal/value.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-imports/internal/value.mjs rename to tests/node_modules_apps/apps/module-interop/packages/pattern-imports/internal/value.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-imports/package.json b/tests/node_modules_apps/apps/module-interop/packages/pattern-imports/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-imports/package.json rename to tests/node_modules_apps/apps/module-interop/packages/pattern-imports/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.cjs b/tests/node_modules_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.cjs rename to tests/node_modules_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.cjs diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.mjs b/tests/node_modules_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.mjs rename to tests/node_modules_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime-node.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime.mjs b/tests/node_modules_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime.mjs rename to tests/node_modules_apps/apps/module-interop/packages/pattern-shims/_shims/auto/runtime.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/pattern-shims/package.json b/tests/node_modules_apps/apps/module-interop/packages/pattern-shims/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/pattern-shims/package.json rename to tests/node_modules_apps/apps/module-interop/packages/pattern-shims/package.json diff --git a/tests/installed_apps/apps/module-interop/packages/tla-esm/index.mjs b/tests/node_modules_apps/apps/module-interop/packages/tla-esm/index.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/tla-esm/index.mjs rename to tests/node_modules_apps/apps/module-interop/packages/tla-esm/index.mjs diff --git a/tests/installed_apps/apps/module-interop/packages/tla-esm/package.json b/tests/node_modules_apps/apps/module-interop/packages/tla-esm/package.json similarity index 100% rename from tests/installed_apps/apps/module-interop/packages/tla-esm/package.json rename to tests/node_modules_apps/apps/module-interop/packages/tla-esm/package.json diff --git a/tests/installed_apps/apps/module-interop/run-node.mjs b/tests/node_modules_apps/apps/module-interop/run-node.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/run-node.mjs rename to tests/node_modules_apps/apps/module-interop/run-node.mjs diff --git a/tests/installed_apps/apps/module-interop/test-01-esm-import-cjs.js b/tests/node_modules_apps/apps/module-interop/test-01-esm-import-cjs.js similarity index 100% rename from tests/installed_apps/apps/module-interop/test-01-esm-import-cjs.js rename to tests/node_modules_apps/apps/module-interop/test-01-esm-import-cjs.js diff --git a/tests/installed_apps/apps/module-interop/test-02-cjs-require-esm.cjs b/tests/node_modules_apps/apps/module-interop/test-02-cjs-require-esm.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-02-cjs-require-esm.cjs rename to tests/node_modules_apps/apps/module-interop/test-02-cjs-require-esm.cjs diff --git a/tests/installed_apps/apps/module-interop/test-03-package-exports-imports.js b/tests/node_modules_apps/apps/module-interop/test-03-package-exports-imports.js similarity index 100% rename from tests/installed_apps/apps/module-interop/test-03-package-exports-imports.js rename to tests/node_modules_apps/apps/module-interop/test-03-package-exports-imports.js diff --git a/tests/installed_apps/apps/module-interop/test-04-cycle-require-esm.cjs b/tests/node_modules_apps/apps/module-interop/test-04-cycle-require-esm.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-04-cycle-require-esm.cjs rename to tests/node_modules_apps/apps/module-interop/test-04-cycle-require-esm.cjs diff --git a/tests/installed_apps/apps/module-interop/test-05-tla-require.cjs b/tests/node_modules_apps/apps/module-interop/test-05-tla-require.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-05-tla-require.cjs rename to tests/node_modules_apps/apps/module-interop/test-05-tla-require.cjs diff --git a/tests/installed_apps/apps/module-interop/test-06-conditional-import-graph.cjs b/tests/node_modules_apps/apps/module-interop/test-06-conditional-import-graph.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-06-conditional-import-graph.cjs rename to tests/node_modules_apps/apps/module-interop/test-06-conditional-import-graph.cjs diff --git a/tests/installed_apps/apps/module-interop/test-07-conditional-import-no-false-positive.cjs b/tests/node_modules_apps/apps/module-interop/test-07-conditional-import-no-false-positive.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-07-conditional-import-no-false-positive.cjs rename to tests/node_modules_apps/apps/module-interop/test-07-conditional-import-no-false-positive.cjs diff --git a/tests/installed_apps/apps/module-interop/test-08-conditional-imports-alias-graph.cjs b/tests/node_modules_apps/apps/module-interop/test-08-conditional-imports-alias-graph.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-08-conditional-imports-alias-graph.cjs rename to tests/node_modules_apps/apps/module-interop/test-08-conditional-imports-alias-graph.cjs diff --git a/tests/installed_apps/apps/module-interop/test-09-create-require-alias-cycle.cjs b/tests/node_modules_apps/apps/module-interop/test-09-create-require-alias-cycle.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-09-create-require-alias-cycle.cjs rename to tests/node_modules_apps/apps/module-interop/test-09-create-require-alias-cycle.cjs diff --git a/tests/installed_apps/apps/module-interop/test-10-already-evaluated-dependency.cjs b/tests/node_modules_apps/apps/module-interop/test-10-already-evaluated-dependency.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-10-already-evaluated-dependency.cjs rename to tests/node_modules_apps/apps/module-interop/test-10-already-evaluated-dependency.cjs diff --git a/tests/installed_apps/apps/module-interop/test-11-module-sync-before-import-graph.cjs b/tests/node_modules_apps/apps/module-interop/test-11-module-sync-before-import-graph.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-11-module-sync-before-import-graph.cjs rename to tests/node_modules_apps/apps/module-interop/test-11-module-sync-before-import-graph.cjs diff --git a/tests/installed_apps/apps/module-interop/test-12-module-sync-before-imports-alias-graph.cjs b/tests/node_modules_apps/apps/module-interop/test-12-module-sync-before-imports-alias-graph.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-12-module-sync-before-imports-alias-graph.cjs rename to tests/node_modules_apps/apps/module-interop/test-12-module-sync-before-imports-alias-graph.cjs diff --git a/tests/installed_apps/apps/module-interop/test-13-scanner-false-positive-guards.cjs b/tests/node_modules_apps/apps/module-interop/test-13-scanner-false-positive-guards.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-13-scanner-false-positive-guards.cjs rename to tests/node_modules_apps/apps/module-interop/test-13-scanner-false-positive-guards.cjs diff --git a/tests/installed_apps/apps/module-interop/test-14-exports-patterns.mjs b/tests/node_modules_apps/apps/module-interop/test-14-exports-patterns.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-14-exports-patterns.mjs rename to tests/node_modules_apps/apps/module-interop/test-14-exports-patterns.mjs diff --git a/tests/installed_apps/apps/module-interop/test-15-imports-patterns.cjs b/tests/node_modules_apps/apps/module-interop/test-15-imports-patterns.cjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-15-imports-patterns.cjs rename to tests/node_modules_apps/apps/module-interop/test-15-imports-patterns.cjs diff --git a/tests/installed_apps/apps/module-interop/test-16-shim-patterns.mjs b/tests/node_modules_apps/apps/module-interop/test-16-shim-patterns.mjs similarity index 100% rename from tests/installed_apps/apps/module-interop/test-16-shim-patterns.mjs rename to tests/node_modules_apps/apps/module-interop/test-16-shim-patterns.mjs diff --git a/tests/installed_apps/apps/popular-pure-js/package.json b/tests/node_modules_apps/apps/popular-pure-js/package.json similarity index 100% rename from tests/installed_apps/apps/popular-pure-js/package.json rename to tests/node_modules_apps/apps/popular-pure-js/package.json diff --git a/tests/installed_apps/apps/popular-pure-js/run-node.mjs b/tests/node_modules_apps/apps/popular-pure-js/run-node.mjs similarity index 100% rename from tests/installed_apps/apps/popular-pure-js/run-node.mjs rename to tests/node_modules_apps/apps/popular-pure-js/run-node.mjs diff --git a/tests/installed_apps/apps/popular-pure-js/test-01-cjs-utilities.cjs b/tests/node_modules_apps/apps/popular-pure-js/test-01-cjs-utilities.cjs similarity index 100% rename from tests/installed_apps/apps/popular-pure-js/test-01-cjs-utilities.cjs rename to tests/node_modules_apps/apps/popular-pure-js/test-01-cjs-utilities.cjs diff --git a/tests/installed_apps/apps/popular-pure-js/test-02-modern-esm.mjs b/tests/node_modules_apps/apps/popular-pure-js/test-02-modern-esm.mjs similarity index 100% rename from tests/installed_apps/apps/popular-pure-js/test-02-modern-esm.mjs rename to tests/node_modules_apps/apps/popular-pure-js/test-02-modern-esm.mjs diff --git a/tests/installed_apps/apps/popular-pure-js/test-03-date-fns-subpaths.mjs b/tests/node_modules_apps/apps/popular-pure-js/test-03-date-fns-subpaths.mjs similarity index 100% rename from tests/installed_apps/apps/popular-pure-js/test-03-date-fns-subpaths.mjs rename to tests/node_modules_apps/apps/popular-pure-js/test-03-date-fns-subpaths.mjs diff --git a/tests/installed_apps/apps/popular-pure-js/test-04-dotenv-fs.cjs b/tests/node_modules_apps/apps/popular-pure-js/test-04-dotenv-fs.cjs similarity index 100% rename from tests/installed_apps/apps/popular-pure-js/test-04-dotenv-fs.cjs rename to tests/node_modules_apps/apps/popular-pure-js/test-04-dotenv-fs.cjs diff --git a/tests/installed_apps/apps/popular-pure-js/test-05-ajv.cjs b/tests/node_modules_apps/apps/popular-pure-js/test-05-ajv.cjs similarity index 100% rename from tests/installed_apps/apps/popular-pure-js/test-05-ajv.cjs rename to tests/node_modules_apps/apps/popular-pure-js/test-05-ajv.cjs diff --git a/tests/installed_apps/apps/popular-pure-js/test-06-rxjs.mjs b/tests/node_modules_apps/apps/popular-pure-js/test-06-rxjs.mjs similarity index 100% rename from tests/installed_apps/apps/popular-pure-js/test-06-rxjs.mjs rename to tests/node_modules_apps/apps/popular-pure-js/test-06-rxjs.mjs diff --git a/tests/installed_apps/apps/validation-schema/package.json b/tests/node_modules_apps/apps/validation-schema/package.json similarity index 100% rename from tests/installed_apps/apps/validation-schema/package.json rename to tests/node_modules_apps/apps/validation-schema/package.json diff --git a/tests/installed_apps/apps/validation-schema/run-node.mjs b/tests/node_modules_apps/apps/validation-schema/run-node.mjs similarity index 100% rename from tests/installed_apps/apps/validation-schema/run-node.mjs rename to tests/node_modules_apps/apps/validation-schema/run-node.mjs diff --git a/tests/installed_apps/apps/validation-schema/test-01-joi-yup.cjs b/tests/node_modules_apps/apps/validation-schema/test-01-joi-yup.cjs similarity index 100% rename from tests/installed_apps/apps/validation-schema/test-01-joi-yup.cjs rename to tests/node_modules_apps/apps/validation-schema/test-01-joi-yup.cjs diff --git a/tests/installed_apps/apps/validation-schema/test-02-superstruct-valibot.mjs b/tests/node_modules_apps/apps/validation-schema/test-02-superstruct-valibot.mjs similarity index 100% rename from tests/installed_apps/apps/validation-schema/test-02-superstruct-valibot.mjs rename to tests/node_modules_apps/apps/validation-schema/test-02-superstruct-valibot.mjs diff --git a/tests/installed_apps/config.jsonc b/tests/node_modules_apps/config.jsonc similarity index 100% rename from tests/installed_apps/config.jsonc rename to tests/node_modules_apps/config.jsonc diff --git a/tests/runtime/main.rs b/tests/runtime/main.rs index d669b102..a53fd9a7 100644 --- a/tests/runtime/main.rs +++ b/tests/runtime/main.rs @@ -27,10 +27,10 @@ mod export_from_inner_package; mod fetch; mod fs; mod imports; -mod installed_apps; mod intl; mod module_resolution; mod node_http; +mod node_modules_apps; mod os; mod path; mod pollable; @@ -46,7 +46,7 @@ mod url; mod v8_stack_trace; mod xhr; -// Tag suites into 8 groups for parallel CI matrix execution +// Tag suites into runtime groups for parallel CI matrix execution. tag_suite!(crypto, group1); tag_suite!(fetch, group2); @@ -78,7 +78,6 @@ tag_suite!(sqlite, group6); tag_suite!(url, group7); tag_suite!(cjs_require, group7); tag_suite!(module_resolution, group7); -tag_suite!(installed_apps, group7); tag_suite!(timeout, group7); tag_suite!(buffer, group7); tag_suite!(bigint_roundtrip, group7); @@ -92,6 +91,8 @@ tag_suite!(intl, group8); tag_suite!(example1, group8); tag_suite!(example2, group8); +tag_suite!(node_modules_apps, group9); + #[test_dep(tagged_as = "example3", scope = Cloneable)] async fn compiled_example3() -> CompiledTest { let path = Utf8Path::new("examples/runtime/example3"); diff --git a/tests/runtime/installed_apps.rs b/tests/runtime/node_modules_apps.rs similarity index 74% rename from tests/runtime/installed_apps.rs rename to tests/runtime/node_modules_apps.rs index c2a691d9..30abe47d 100644 --- a/tests/runtime/installed_apps.rs +++ b/tests/runtime/node_modules_apps.rs @@ -1,6 +1,6 @@ use crate::common::{ - CompiledTest, InstalledAppEntry, InstalledAppTestEntry, TestInstance, copy_dir_recursive, - load_installed_apps_config, + CompiledTest, NodeModulesAppEntry, NodeModulesAppTestEntry, TestInstance, copy_dir_recursive, + load_node_modules_apps_config, }; use camino::{Utf8Path, Utf8PathBuf}; use camino_tempfile::Utf8TempDir; @@ -11,24 +11,24 @@ use test_r::core::{DynamicTestRegistration, TestProperties}; use test_r::{test_dep, test_gen}; use wasmtime::component::Val; -const CONFIG_PATH: &str = "tests/installed_apps/config.jsonc"; +const CONFIG_PATH: &str = "tests/node_modules_apps/config.jsonc"; -#[test_dep(tagged_as = "installed_app_runner", scope = Cloneable)] -async fn compiled_installed_app_runner() -> CompiledTest { - let path = Utf8Path::new("examples/runtime/installed-app-runner"); +#[test_dep(tagged_as = "node_modules_app_runner", scope = Cloneable)] +async fn compiled_node_modules_app_runner() -> CompiledTest { + let path = Utf8Path::new("examples/runtime/node-modules-app-runner"); CompiledTest::new_with_features(path, true, crate::common::FeatureCombination::FullNoLogging) .await - .expect("Failed to compile installed-app-runner") + .expect("Failed to compile node-modules-app-runner") } -struct PreparedInstalledApp { +struct PreparedNodeModulesApp { _temp_dir: Utf8TempDir, app_dir: Utf8PathBuf, } -fn prepare_installed_app(app_name: &str) -> anyhow::Result { +fn prepare_node_modules_app(app_name: &str) -> anyhow::Result { let source_dir = Utf8Path::new("tests") - .join("installed_apps") + .join("node_modules_apps") .join("apps") .join(app_name); let temp_dir = Utf8TempDir::new()?; @@ -46,7 +46,7 @@ fn prepare_installed_app(app_name: &str) -> anyhow::Result .status()?; anyhow::ensure!(status.success(), "npm install failed for {app_name}"); - Ok(PreparedInstalledApp { + Ok(PreparedNodeModulesApp { _temp_dir: temp_dir, app_dir, }) @@ -74,13 +74,13 @@ fn ensure_node_supports_require_esm() -> anyhow::Result<()> { .unwrap_or(0); anyhow::ensure!( major > 22 || (major == 22 && minor >= 14), - "installed-app Node baseline requires Node.js >= 22.14 for require(esm) and module-sync condition behavior; found {}", + "node_modules app Node baseline requires Node.js >= 22.14 for require(esm) and module-sync condition behavior; found {}", version.trim(), ); Ok(()) } -fn verify_with_node(app: &PreparedInstalledApp, test_file: &str) -> anyhow::Result<()> { +fn verify_with_node(app: &PreparedNodeModulesApp, test_file: &str) -> anyhow::Result<()> { ensure_node_supports_require_esm()?; let output = Command::new("node") .arg("run-node.mjs") @@ -97,13 +97,13 @@ fn verify_with_node(app: &PreparedInstalledApp, test_file: &str) -> anyhow::Resu Ok(()) } -async fn run_installed_app_test( +async fn run_node_modules_app_test( compiled_test: &CompiledTest, app_name: &str, test_file: &str, timeout_secs: u64, ) -> anyhow::Result<()> { - let app = prepare_installed_app(app_name)?; + let app = prepare_node_modules_app(app_name)?; verify_with_node(&app, test_file)?; let mut instance = TestInstance::new(compiled_test.wasm_path()).await?; @@ -125,13 +125,13 @@ async fn run_installed_app_test( } match result { Some(Val::String(s)) if s.starts_with("PASS:") => Ok(()), - other => anyhow::bail!("Unexpected installed app result: {:?}", other), + other => anyhow::bail!("Unexpected node_modules app result: {:?}", other), } } -fn test_name(app: &InstalledAppEntry, test: &InstalledAppTestEntry) -> String { +fn test_name(app: &NodeModulesAppEntry, test: &NodeModulesAppTestEntry) -> String { format!( - "installed_app__{}__{}", + "node_modules_app__{}__{}", sanitize_name(&app.name), sanitize_name(&test.file) ) @@ -145,35 +145,36 @@ fn sanitize_name(value: &str) -> String { } #[test_gen] -fn gen_installed_app_tests(r: &mut DynamicTestRegistration) { +fn gen_node_modules_app_tests(r: &mut DynamicTestRegistration) { let apps = - load_installed_apps_config(CONFIG_PATH).expect("Failed to load installed app config"); - let dependency_name = "compiledtest_installed_app_runner".to_string(); + load_node_modules_apps_config(CONFIG_PATH).expect("Failed to load node_modules app config"); + let dependency_name = "compiledtest_node_modules_app_runner".to_string(); for app in apps { for test in app.tests.clone() { let app_name = app.name.clone(); let test_file = test.file.clone(); let timeout_secs = test.timeout_secs; + let generated_test_name = test_name(&app, &test); let props = TestProperties { is_ignored: test.category.should_ignore_in_runner(), ..TestProperties::unit_test() }; r.add_async_test( - test_name(&app, &test), + generated_test_name, props, Some(vec![dependency_name.clone()]), move |deps| { let compiled_test: Arc = deps - .get("compiledtest_installed_app_runner") + .get("compiledtest_node_modules_app_runner") .expect("CompiledTest dependency not found") .downcast::() .expect("CompiledTest type mismatch"); let app_name = app_name.clone(); let test_file = test_file.clone(); Box::pin(async move { - run_installed_app_test( + run_node_modules_app_test( compiled_test.as_ref(), &app_name, &test_file, From 502758d31832d938fe55b2e21af4914b5d8c649d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 18 Jun 2026 13:59:31 +0200 Subject: [PATCH 13/42] add CJS named export lexer parity coverag --- .../skeleton/src/builtin/module.js | 251 +++---- crates/wasm-rquickjs/skeleton/src/internal.rs | 630 +++++++++++++++--- .../apps/module-interop/package.json | 5 + .../dep-dynamic.cjs | 1 + .../dep-nested.cjs | 1 + .../index.cjs | 19 + .../package.json | 5 + .../cjs-lexer-helper-reexports/dep-a.cjs | 1 + .../cjs-lexer-helper-reexports/dep-b.cjs | 1 + .../cjs-lexer-helper-reexports/dep-c.cjs | 1 + .../cjs-lexer-helper-reexports/dep-d.cjs | 1 + .../cjs-lexer-helper-reexports/index.cjs | 29 + .../cjs-lexer-helper-reexports/package.json | 5 + .../packages/cjs-lexer-keys-reexport/dep.cjs | 2 + .../cjs-lexer-keys-reexport/index.cjs | 8 + .../cjs-lexer-keys-reexport/package.json | 5 + .../packages/cjs-lexer-object-literal/dep.cjs | 2 + .../cjs-lexer-object-literal/index.cjs | 10 + .../cjs-lexer-object-literal/package.json | 5 + .../cjs-lexer-object-require-value/dep.cjs | 1 + .../cjs-lexer-object-require-value/index.cjs | 7 + .../package.json | 5 + .../test-17-cjs-lexer-parity.mjs | 42 ++ .../test-18-cjs-lexer-helper-reexports.mjs | 27 + tests/node_modules_apps/config.jsonc | 4 +- 25 files changed, 786 insertions(+), 282 deletions(-) create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/dep-dynamic.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/dep-nested.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/index.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/package.json create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-a.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-b.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-c.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-d.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/index.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/package.json create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/dep.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/index.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/package.json create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/dep.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/index.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/package.json create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/dep.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/index.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/package.json create mode 100644 tests/node_modules_apps/apps/module-interop/test-17-cjs-lexer-parity.mjs create mode 100644 tests/node_modules_apps/apps/module-interop/test-18-cjs-lexer-helper-reexports.mjs diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 15228f43..2e1dfb55 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -1347,6 +1347,48 @@ function startsWithKeywordAt(source, keyword, pos) { return source.startsWith(keyword, pos) && hasIdentifierBoundary(source, pos, pos + keyword.length); } +function skipNonCode(source, pos, skipRegex) { + const code = source.charCodeAt(pos); + if (code === 0x27 || code === 0x22 || code === 0x60) { // ' " ` + return skipQuotedOrTemplate(source, pos); + } + if (code === 0x2f && pos + 1 < source.length && source.charCodeAt(pos + 1) === 0x2f) { + let i = pos + 2; + while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; + return i; + } + if (code === 0x2f && pos + 1 < source.length && source.charCodeAt(pos + 1) === 0x2a) { + let i = pos + 2; + while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; + return Math.min(i + 2, source.length); + } + if (skipRegex && code === 0x2f && isRegexLiteralStartInSource(source, pos)) { + return skipRegexLiteralInSource(source, pos); + } + return null; +} + +function scanSourceCodePositions(source, options, visitor) { + const skipRegex = !options || options.skipRegex !== false; + let i = 0; + while (i < source.length) { + const skipped = skipNonCode(source, i, skipRegex); + if (skipped !== null) { + i = skipped; + continue; + } + + const next = visitor(i, source.charCodeAt(i)); + if (next === false) return false; + if (typeof next === 'number') { + i = next; + } else { + i++; + } + } + return true; +} + function isStaticExportSyntax(source, pos) { if (previousSignificantChar(source, pos) === 0x2e) return false; // member property const next = skipWhitespace(source, pos + 6); @@ -1371,85 +1413,46 @@ function isStaticImportSyntax(source, pos) { } function looksLikeEsmSource(source) { - let i = 0; - while (i < source.length) { - const code = source.charCodeAt(i); - if (code === 0x27 || code === 0x22 || code === 0x60) { // ' " ` - i = skipQuotedOrTemplate(source, i); - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { - i += 2; - while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { - i += 2; - while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; - i = Math.min(i + 2, source.length); - continue; - } - if (code === 0x2f && isRegexLiteralStartInSource(source, i)) { - i = skipRegexLiteralInSource(source, i); - continue; - } - + let found = false; + scanSourceCodePositions(source, { skipRegex: true }, (i) => { if (source.startsWith('export', i) && hasIdentifierBoundary(source, i, i + 6) && isStaticExportSyntax(source, i)) { - return true; + found = true; + return false; } if (source.startsWith('import', i) && hasIdentifierBoundary(source, i, i + 6)) { - if (isStaticImportSyntax(source, i)) return true; + if (isStaticImportSyntax(source, i)) { + found = true; + return false; + } } - i++; - } - return false; + return undefined; + }); + return found; } function hasCjsWrapperRequireRedeclaration(source) { - let i = 0; + let found = false; let braceDepth = 0; - while (i < source.length) { - const code = source.charCodeAt(i); - if (code === 0x27 || code === 0x22 || code === 0x60) { // ' " ` - i = skipQuotedOrTemplate(source, i); - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { - i += 2; - while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { - i += 2; - while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; - i = Math.min(i + 2, source.length); - continue; - } - if (code === 0x2f && isRegexLiteralStartInSource(source, i)) { - i = skipRegexLiteralInSource(source, i); - continue; - } - + scanSourceCodePositions(source, { skipRegex: true }, (i, code) => { if (code === 0x7b) { braceDepth++; - i++; - continue; + return undefined; } if (code === 0x7d) { braceDepth = Math.max(0, braceDepth - 1); - i++; - continue; + return undefined; } if (braceDepth === 0 && (startsWithKeywordAt(source, 'const', i) || startsWithKeywordAt(source, 'let', i))) { let next = skipWhitespace(source, i + (source.startsWith('const', i) ? 5 : 3)); if (source.startsWith('require', next) && hasIdentifierBoundary(source, next, next + 7)) { - return true; + found = true; + return false; } } - i++; - } - return false; + return undefined; + }); + return found; } function readStaticSpecifierString(source, start) { @@ -1548,60 +1551,18 @@ function staticImportSpecifierAt(source, pos) { function collectStaticEsmSpecifiers(source) { const specifiers = []; - let i = 0; - while (i < source.length) { - const code = source.charCodeAt(i); - if (code === 0x27 || code === 0x22 || code === 0x60) { - i = skipQuotedOrTemplate(source, i); - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { - i += 2; - while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { - i += 2; - while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; - i = Math.min(i + 2, source.length); - continue; - } - if (code === 0x2f && isRegexLiteralStartInSource(source, i)) { - i = skipRegexLiteralInSource(source, i); - continue; - } + scanSourceCodePositions(source, { skipRegex: true }, (i) => { const specifier = staticImportSpecifierAt(source, i); if (specifier !== null) specifiers.push(specifier); - i++; - } + return undefined; + }); return specifiers; } function collectLiteralRequireSpecifiers(source, names) { names = names || ['require']; const specifiers = []; - let i = 0; - while (i < source.length) { - const code = source.charCodeAt(i); - if (code === 0x27 || code === 0x22 || code === 0x60) { - i = skipQuotedOrTemplate(source, i); - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { - i += 2; - while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { - i += 2; - while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; - i = Math.min(i + 2, source.length); - continue; - } - if (code === 0x2f && isRegexLiteralStartInSource(source, i)) { - i = skipRegexLiteralInSource(source, i); - continue; - } + scanSourceCodePositions(source, { skipRegex: true }, (i) => { for (let n = 0; n < names.length; n++) { const name = names[n]; if (startsWithKeywordAt(source, name, i) && previousSignificantChar(source, i) !== 0x2e) { @@ -1612,31 +1573,14 @@ function collectLiteralRequireSpecifiers(source, names) { } } } - i++; - } + return undefined; + }); return specifiers; } function collectCreateRequireFactoryNames(source) { const names = []; - let i = 0; - while (i < source.length) { - const code = source.charCodeAt(i); - if (code === 0x27 || code === 0x22 || code === 0x60) { - i = skipQuotedOrTemplate(source, i); - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { - i += 2; - while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { - i += 2; - while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; - i = Math.min(i + 2, source.length); - continue; - } + scanSourceCodePositions(source, { skipRegex: false }, (i) => { if (startsWithKeywordAt(source, 'import', i)) { const end = statementEndForStaticImport(source, i + 6); const statement = source.slice(i, end); @@ -1655,11 +1599,10 @@ function collectCreateRequireFactoryNames(source) { } } } - i = end; - continue; + return end; } - i++; - } + return undefined; + }); return names; } @@ -1667,24 +1610,7 @@ function collectCreateRequireAliases(source, factoryNames) { factoryNames = factoryNames || collectCreateRequireFactoryNames(source); const aliases = []; if (factoryNames.length === 0) return aliases; - let i = 0; - while (i < source.length) { - const code = source.charCodeAt(i); - if (code === 0x27 || code === 0x22 || code === 0x60) { - i = skipQuotedOrTemplate(source, i); - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { - i += 2; - while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { - i += 2; - while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; - i = Math.min(i + 2, source.length); - continue; - } + scanSourceCodePositions(source, { skipRegex: false }, (i) => { if (startsWithKeywordAt(source, 'const', i) || startsWithKeywordAt(source, 'let', i) || startsWithKeywordAt(source, 'var', i)) { const keywordLen = source.startsWith('const', i) ? 5 : 3; let p = skipWhitespace(source, i + keywordLen); @@ -1706,8 +1632,8 @@ function collectCreateRequireAliases(source, factoryNames) { } } } - i++; - } + return undefined; + }); return aliases; } @@ -1715,28 +1641,7 @@ function collectCreateRequireCallSpecifiers(source, factoryNames) { factoryNames = factoryNames || collectCreateRequireFactoryNames(source); const specifiers = []; if (factoryNames.length === 0) return specifiers; - let i = 0; - while (i < source.length) { - const code = source.charCodeAt(i); - if (code === 0x27 || code === 0x22 || code === 0x60) { - i = skipQuotedOrTemplate(source, i); - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2f) { - i += 2; - while (i < source.length && source.charCodeAt(i) !== 0x0a && source.charCodeAt(i) !== 0x0d) i++; - continue; - } - if (code === 0x2f && i + 1 < source.length && source.charCodeAt(i + 1) === 0x2a) { - i += 2; - while (i + 1 < source.length && !(source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f)) i++; - i = Math.min(i + 2, source.length); - continue; - } - if (code === 0x2f && isRegexLiteralStartInSource(source, i)) { - i = skipRegexLiteralInSource(source, i); - continue; - } + scanSourceCodePositions(source, { skipRegex: true }, (i) => { for (let f = 0; f < factoryNames.length; f++) { const factory = factoryNames[f]; if (startsWithKeywordAt(source, factory, i) && previousSignificantChar(source, i) !== 0x2e) { @@ -1753,8 +1658,8 @@ function collectCreateRequireCallSpecifiers(source, factoryNames) { } } } - i++; - } + return undefined; + }); return specifiers; } diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 912e8489..7f4fd84b 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -12,6 +12,7 @@ use serde::Deserialize; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::future::Future; +use std::ops::ControlFlow; use std::sync::atomic::AtomicUsize; use wstd::runtime::block_on; @@ -1166,42 +1167,17 @@ fn data_url_simple_identifier_error_module_source(source: &str) -> Option bool { let bytes = source.as_bytes(); - let mut i = 0usize; + let mut found = false; let mut brace_depth = 0usize; - while i < bytes.len() { - match bytes[i] { - b'\'' | b'"' | b'`' => { - i = skip_string_or_template(source, i); - continue; - } - b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { - i += 2; - while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { - i += 1; - } - continue; - } - b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; - } - i = (i + 2).min(bytes.len()); - continue; - } - b'/' if is_regex_literal_start(source, i) => { - i = skip_regex_literal(source, i); - continue; - } + scan_code_positions(source, true, |i, byte| { + match byte { b'{' => { brace_depth += 1; - i += 1; - continue; + return ControlFlow::Continue(None); } b'}' => { brace_depth = brace_depth.saturating_sub(1); - i += 1; - continue; + return ControlFlow::Continue(None); } _ => {} } @@ -1217,14 +1193,15 @@ fn has_cjs_wrapper_require_redeclaration(source: &str) -> bool { && is_ident_start_boundary(bytes, next) && is_ident_boundary(bytes, next + 7) { - return true; + found = true; + return ControlFlow::Break(()); } } } } - i = next_char_boundary(source, i); - } - false + ControlFlow::Continue(None) + }); + found } fn is_ascii_js_identifier(value: &str) -> bool { @@ -2643,6 +2620,25 @@ fn parse_require_string(source: &str, pos: usize) -> Option<(String, usize)> { } } +fn parse_require_string_loose(source: &str, pos: usize) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + if !source[pos..].starts_with("require") || !is_ident_boundary(bytes, pos + 7) { + return None; + } + let mut i = skip_ws_comments(source, pos + 7); + if i >= bytes.len() || bytes[i] != b'(' { + return None; + } + i = skip_ws_comments(source, i + 1); + let (specifier, next) = read_js_string(source, i)?; + i = skip_ws_comments(source, next); + if i < bytes.len() && bytes[i] == b')' { + Some((specifier, i + 1)) + } else { + None + } +} + fn parse_define_property_export(source: &str, pos: usize) -> Option<(String, usize)> { let bytes = source.as_bytes(); if !is_free_ident_start(bytes, pos) @@ -2990,6 +2986,72 @@ fn parse_module_exports_reexport(source: &str, pos: usize) -> Option<(String, us } } +fn parse_export_star_reexport(source: &str, pos: usize) -> Option<(String, usize)> { + fn parse_export_star_callee(source: &str, pos: usize) -> Option { + let bytes = source.as_bytes(); + let member_access = previous_significant_byte(source, pos) == Some(b'.'); + if is_free_ident_start(bytes, pos) + && !member_access + && source[pos..].starts_with("__exportStar") + && is_ident_boundary(bytes, pos + 12) + { + return Some(pos + 12); + } + if is_free_ident_start(bytes, pos) + && !member_access + && source[pos..].starts_with("__export") + && is_ident_boundary(bytes, pos + 8) + { + return Some(pos + 8); + } + if is_free_ident_start(bytes, pos) + && source[pos..].starts_with("tslib") + && is_ident_boundary(bytes, pos + 5) + { + let mut i = skip_ws_comments(source, pos + 5); + if i >= bytes.len() || bytes[i] != b'.' { + return None; + } + i = skip_ws_comments(source, i + 1); + if source[i..].starts_with("__exportStar") && is_ident_boundary(bytes, i + 12) { + return Some(i + 12); + } + if source[i..].starts_with("__export") && is_ident_boundary(bytes, i + 8) { + return Some(i + 8); + } + } + None + } + + let bytes = source.as_bytes(); + let mut i = parse_export_star_callee(source, pos)?; + i = skip_ws_comments(source, i); + if i >= bytes.len() || bytes[i] != b'(' { + return None; + } + + i = skip_ws_comments(source, i + 1); + let (specifier, next) = parse_require_string(source, i)?; + i = skip_ws_comments(source, next); + + if i < bytes.len() && bytes[i] == b',' { + i = skip_ws_comments(source, i + 1); + let (_, next_target) = parse_exports_target(source, i)?; + i = skip_ws_comments(source, next_target); + } + + if i >= bytes.len() || bytes[i] != b')' { + return None; + } + + let after_call = skip_ws_comments(source, i + 1); + if is_statement_boundary(source, after_call) { + Some((specifier, after_call.min(source.len()))) + } else { + None + } +} + fn parse_module_exports_assignment(source: &str, pos: usize) -> Option { let bytes = source.as_bytes(); let (target, mut i) = parse_exports_target(source, pos)?; @@ -3007,6 +3069,135 @@ fn parse_module_exports_assignment(source: &str, pos: usize) -> Option { } } +fn parse_exports_literal_key(source: &str, pos: usize) -> Option<(String, bool, usize)> { + if let Some((ident, next)) = read_ident(source, pos) { + return Some((ident, true, next)); + } + let (name, next) = read_js_string(source, pos)?; + Some((name, false, next)) +} + +fn skip_object_literal_value(source: &str, pos: usize, object_end: usize) -> usize { + let bytes = source.as_bytes(); + let mut i = pos; + let mut brace_depth = 0usize; + let mut paren_depth = 0usize; + let mut bracket_depth = 0usize; + while i < object_end { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(source, i); + continue; + } + b'/' if i + 1 < object_end && bytes[i + 1] == b'/' => { + i += 2; + while i < object_end && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + continue; + } + b'/' if i + 1 < object_end && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < object_end && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(object_end); + continue; + } + b'/' if is_regex_literal_start(source, i) => { + i = skip_regex_literal(source, i).min(object_end); + continue; + } + b'{' => brace_depth += 1, + b'}' => brace_depth = brace_depth.saturating_sub(1), + b'(' => paren_depth += 1, + b')' => paren_depth = paren_depth.saturating_sub(1), + b'[' => bracket_depth += 1, + b']' => bracket_depth = bracket_depth.saturating_sub(1), + b',' if brace_depth == 0 && paren_depth == 0 && bracket_depth == 0 => return i, + _ => {} + } + i = next_char_boundary(source, i); + } + object_end +} + +fn parse_module_exports_object_literal(source: &str, pos: usize) -> Option<(Vec, Vec, usize)> { + let bytes = source.as_bytes(); + let (target, mut i) = parse_exports_target(source, pos)?; + if target != CjsExportTarget::ModuleExports { + return None; + } + + i = skip_ws_comments(source, i); + if i >= bytes.len() || bytes[i] != b'=' || (i + 1 < bytes.len() && matches!(bytes[i + 1], b'=' | b'>')) { + return None; + } + + i = skip_ws_comments(source, i + 1); + if i >= bytes.len() || bytes[i] != b'{' { + return None; + } + let object_end = find_matching_brace(source, i)?; + + let mut exports = Vec::new(); + let mut reexports = Vec::new(); + let mut cursor = skip_ws_comments(source, i + 1); + + while cursor < object_end { + if bytes[cursor] == b',' { + cursor = skip_ws_comments(source, cursor + 1); + continue; + } + + if source[cursor..].starts_with("...") { + let (specifier, next) = parse_require_string_loose(source, skip_ws_comments(source, cursor + 3))?; + add_unique(&mut reexports, specifier); + cursor = skip_ws_comments(source, next); + if cursor < object_end { + if bytes[cursor] != b',' { + return None; + } + cursor = skip_ws_comments(source, cursor + 1); + } + continue; + } + + let Some((name, key_is_ident, key_end)) = parse_exports_literal_key(source, cursor) else { + break; + }; + let mut next = skip_ws_comments(source, key_end); + if next < object_end && bytes[next] == b':' { + next = skip_ws_comments(source, next + 1); + add_unique(&mut exports, name); + if let Some((specifier, _)) = parse_require_string_loose(source, next) { + add_unique(&mut reexports, specifier); + break; + } + cursor = skip_ws_comments(source, skip_object_literal_value(source, next, object_end)); + } else if key_is_ident { + add_unique(&mut exports, name); + cursor = next; + } else { + break; + } + + if cursor < object_end { + if bytes[cursor] != b',' { + return None; + } + cursor = skip_ws_comments(source, cursor + 1); + } + } + + let after_object = skip_ws_comments(source, object_end + 1); + if is_statement_boundary(source, after_object) { + Some((exports, reexports, after_object.min(source.len()))) + } else { + None + } +} + fn parse_object_keys_reexport(source: &str, pos: usize, bindings: &HashMap) -> Option<(String, usize)> { let bytes = source.as_bytes(); if !is_free_ident_start(bytes, pos) @@ -3052,41 +3243,69 @@ fn parse_object_keys_reexport(source: &str, pos: usize, bindings: &HashMap bool { - let bytes = callback.as_bytes(); - let mut i = 0usize; - while i < bytes.len() { - match bytes[i] { - b'\'' | b'"' | b'`' => { - i = skip_string_or_template(callback, i); - continue; - } - b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { - i += 2; - while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { - i += 1; - } - continue; - } - b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; - } - i = (i + 2).min(bytes.len()); - continue; - } - b'/' if is_regex_literal_start(callback, i) => { - i = skip_regex_literal(callback, i); - continue; - } - _ => {} - } + let mut found = false; + scan_code_positions(callback, true, |i, _| { if parse_define_property_reexport(callback, i, binding).is_some() { - return true; + found = true; + return ControlFlow::Break(()); } - i = next_char_boundary(callback, i); + if parse_direct_exports_reexport_assignment(callback, i, binding).is_some() { + found = true; + return ControlFlow::Break(()); + } + ControlFlow::Continue(None) + }); + found +} + +fn parse_direct_exports_reexport_assignment(source: &str, pos: usize, binding: &str) -> Option { + let bytes = source.as_bytes(); + let (target, mut i) = parse_exports_target(source, pos)?; + if target != CjsExportTarget::Exports { + return None; + } + + i = skip_ws_comments(source, i); + if i >= bytes.len() || bytes[i] != b'[' { + return None; + } + i = skip_ws_comments(source, i + 1); + let (key, next) = read_ident(source, i)?; + i = skip_ws_comments(source, next); + if i >= bytes.len() || bytes[i] != b']' { + return None; + } + i = skip_ws_comments(source, i + 1); + if i >= bytes.len() || bytes[i] != b'=' || (i + 1 < bytes.len() && matches!(bytes[i + 1], b'=' | b'>')) { + return None; + } + + i = skip_ws_comments(source, i + 1); + if !source[i..].starts_with(binding) + || !is_free_ident_start(bytes, i) + || !is_ident_boundary(bytes, i + binding.len()) + { + return None; + } + i = skip_ws_comments(source, i + binding.len()); + if i >= bytes.len() || bytes[i] != b'[' { + return None; + } + i = skip_ws_comments(source, i + 1); + if !source[i..].starts_with(&key) || !is_free_ident_start(bytes, i) || !is_ident_boundary(bytes, i + key.len()) { + return None; + } + i = skip_ws_comments(source, i + key.len()); + if i >= bytes.len() || bytes[i] != b']' { + return None; + } + + let after_rhs = skip_ws_comments(source, i + 1); + if is_statement_boundary(source, after_rhs) { + Some(after_rhs.min(source.len())) + } else { + None } - false } fn parse_define_property_reexport(source: &str, pos: usize, binding: &str) -> Option { @@ -3247,75 +3466,138 @@ fn skip_regex_literal(source: &str, pos: usize) -> usize { pos + 1 } -fn analyze_cjs_exports(source: &str) -> CjsExportAnalysis { +fn skip_non_code(source: &str, pos: usize, skip_regex: bool) -> Option { let bytes = source.as_bytes(); - let mut analysis = CjsExportAnalysis::default(); - let mut require_bindings = HashMap::::new(); - let mut i = 0usize; - while i < bytes.len() { - match bytes[i] { - b'\'' | b'"' | b'`' => { - i = skip_string_or_template(source, i); - continue; - } - b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { - i += 2; - while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { - i += 1; - } - continue; - } - b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; - } - i = (i + 2).min(bytes.len()); - continue; + match bytes.get(pos).copied()? { + b'\'' | b'"' | b'`' => Some(skip_string_or_template(source, pos)), + b'/' if pos + 1 < bytes.len() && bytes[pos + 1] == b'/' => { + let mut i = pos + 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; } - b'/' if is_regex_literal_start(source, i) => { - i = skip_regex_literal(source, i); - continue; + Some(i) + } + b'/' if pos + 1 < bytes.len() && bytes[pos + 1] == b'*' => { + let mut i = pos + 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; } + Some((i + 2).min(bytes.len())) + } + b'/' if skip_regex && is_regex_literal_start(source, pos) => { + Some(skip_regex_literal(source, pos)) + } + _ => None, + } +} + +fn scan_code_positions(source: &str, skip_regex: bool, mut visitor: F) -> ControlFlow<()> +where + F: FnMut(usize, u8) -> ControlFlow<(), Option>, +{ + let bytes = source.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if let Some(next) = skip_non_code(source, i, skip_regex) { + i = next; + continue; + } + + match visitor(i, bytes[i]) { + ControlFlow::Break(()) => return ControlFlow::Break(()), + ControlFlow::Continue(Some(next)) => i = next, + ControlFlow::Continue(None) => i = next_char_boundary(source, i), + } + } + ControlFlow::Continue(()) +} + +fn scan_code_positions_with_brace_depth( + source: &str, + skip_regex: bool, + mut visitor: F, +) -> ControlFlow<()> +where + F: FnMut(usize, u8, usize) -> ControlFlow<(), Option>, +{ + let bytes = source.as_bytes(); + let mut i = 0usize; + let mut brace_depth = 0usize; + while i < bytes.len() { + if let Some(next) = skip_non_code(source, i, skip_regex) { + i = next; + continue; + } + + let current = bytes[i]; + match visitor(i, current, brace_depth) { + ControlFlow::Break(()) => return ControlFlow::Break(()), + ControlFlow::Continue(Some(next)) => i = next, + ControlFlow::Continue(None) => i = next_char_boundary(source, i), + } + + match current { + b'{' => brace_depth += 1, + b'}' => brace_depth = brace_depth.saturating_sub(1), _ => {} } + } + ControlFlow::Continue(()) +} + +fn analyze_cjs_exports(source: &str) -> CjsExportAnalysis { + let mut analysis = CjsExportAnalysis::default(); + let mut require_bindings = HashMap::::new(); + scan_code_positions_with_brace_depth(source, true, |i, _, brace_depth| { if let Some((name, next)) = parse_export_member(source, i) { analysis.is_cjs = true; add_unique(&mut analysis.exports, name); - i = next; - continue; + return ControlFlow::Continue(Some(next)); } if let Some((name, next)) = parse_define_property_export(source, i) { analysis.is_cjs = true; add_unique(&mut analysis.exports, name); - i = next; - continue; + return ControlFlow::Continue(Some(next)); } if let Some((binding, specifier, next)) = parse_require_binding(source, i) { require_bindings.insert(binding, specifier); - i = next; - continue; + return ControlFlow::Continue(Some(next)); + } + if brace_depth == 0 + && let Some((specifier, next)) = parse_export_star_reexport(source, i) + { + analysis.is_cjs = true; + add_unique(&mut analysis.reexports, specifier); + return ControlFlow::Continue(Some(next)); } if let Some((specifier, next)) = parse_module_exports_reexport(source, i) { analysis.is_cjs = true; analysis.reexports.clear(); add_unique(&mut analysis.reexports, specifier); - i = next; - continue; + return ControlFlow::Continue(Some(next)); + } + if let Some((exports, reexports, next)) = parse_module_exports_object_literal(source, i) { + analysis.is_cjs = true; + analysis.reexports.clear(); + for name in exports { + add_unique(&mut analysis.exports, name); + } + for specifier in reexports { + add_unique(&mut analysis.reexports, specifier); + } + return ControlFlow::Continue(Some(next)); } if let Some(next) = parse_module_exports_assignment(source, i) { analysis.is_cjs = true; - i = next; - continue; + return ControlFlow::Continue(Some(next)); } if let Some((specifier, next)) = parse_object_keys_reexport(source, i, &require_bindings) { analysis.is_cjs = true; add_unique(&mut analysis.reexports, specifier); - i = next; - continue; + return ControlFlow::Continue(Some(next)); } - i = next_char_boundary(source, i); - } + ControlFlow::Continue(None) + }); analysis } @@ -3442,7 +3724,6 @@ impl Loader for CjsCompatLoader { }; let detected_analysis = analyze_cjs_exports_for_file(&fs_abs_path, &source, &mut HashSet::new()); - // .cjs files are always CommonJS; for .js files, use the analyzer so // comments, strings, templates, and regex literals do not force CJS. let is_cjs = is_cjs_ext @@ -4954,6 +5235,93 @@ mod cjs_export_analyzer_tests { ); } + #[test] + fn detects_module_exports_object_literal_names_and_spread_reexports() { + assert_analysis( + r#" + const a = 1; + const c = 2; + const e = 4; + module.exports = { a, b: c, "d": e, ...require("./dep.cjs") }; + "#, + true, + &["a", "b", "d"], + &["./dep.cjs"], + ); + + assert_analysis( + r#" + const a = 1; + module.exports = { a, dynamic: factory() }; + "#, + true, + &["a", "dynamic"], + &[], + ); + + assert_analysis( + r#" + const a = 1; + module.exports = { a, b: require("./dep.cjs"), c: "not-detected" }; + "#, + true, + &["a", "b"], + &["./dep.cjs"], + ); + + assert_analysis( + r#" + const a = 1; + const c = 3; + module.exports = { a, ...require("./dep.cjs"), c }; + "#, + true, + &["a", "c"], + &["./dep.cjs"], + ); + + assert_analysis( + r#" + const a = 1; + module.exports = { a, [dynamic]: value, c: "not-detected" }; + "#, + true, + &["a"], + &[], + ); + } + + #[test] + fn detects_only_documented_export_star_helper_reexports() { + assert_analysis( + r#" + __export(require("./dep-a.cjs")); + __exportStar(require("./dep-b.cjs"), exports); + tslib.__export(require("./dep-c.cjs"), exports); + tslib.__exportStar(require("./dep-d.cjs"), exports); + exports.own = "own"; + "#, + true, + &["own"], + &["./dep-a.cjs", "./dep-b.cjs", "./dep-c.cjs", "./dep-d.cjs"], + ); + + assert_analysis( + r#" + function nested() { + __export(require("./dep-a.cjs")); + } + nested(); + helper.__export(require("./dep-b.cjs"), exports); + __export(require(depName)); + exports.own = "own"; + "#, + true, + &["own"], + &[], + ); + } + #[test] fn require_binding_alone_does_not_classify_esm_as_cjs() { assert_analysis( @@ -5035,12 +5403,31 @@ mod cjs_export_analyzer_tests { assert!(esm_preflight_error_module_source("const require = 1; export default require;", true).is_none()); } + #[test] + fn require_redeclaration_scanner_skips_non_code() { + assert!(has_cjs_wrapper_require_redeclaration("const require = 1;")); + assert!(has_cjs_wrapper_require_redeclaration("let /*x*/ require = 1;")); + assert!(!has_cjs_wrapper_require_redeclaration( + "const text = `const require = 1`; export default text;" + )); + assert!(!has_cjs_wrapper_require_redeclaration( + "// const require = 1\nexport default 1;" + )); + assert!(!has_cjs_wrapper_require_redeclaration( + "const re = /const require = 1/; export default re;" + )); + assert!(!has_cjs_wrapper_require_redeclaration( + "function f() { const require = 1; return require; }" + )); + } + #[test] fn ignores_false_positive_assignments_and_define_property_descriptors() { assert_analysis( r#" if (module.exports === undefined) {} if (exports.fake == "no") {} + const template = `exports.templateOnly = "no";`; Object.defineProperty(exports, "setterOnly", { set(v) { return dep.value; } }); Object.defineProperty(exports, "unrelated", { other: function () { return dep.value; } }); Object.defineProperty(exports, "regexDescriptor", { enumerable: /value:/ }); @@ -5099,6 +5486,33 @@ mod cjs_export_analyzer_tests { &["own"], &[], ); + + assert_analysis( + r#" + var dep = require("./dep.cjs"); + Object.keys(dep).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + exports[key] = dep[key]; + }); + exports.own = "own"; + "#, + true, + &["own"], + &["./dep.cjs"], + ); + + assert_analysis( + r#" + var dep = require("./dep.cjs"); + Object.keys(dep).forEach(function (key) { + exports[key] = other[key]; + }); + exports.own = "own"; + "#, + true, + &["own"], + &[], + ); } } diff --git a/tests/node_modules_apps/apps/module-interop/package.json b/tests/node_modules_apps/apps/module-interop/package.json index d674ec31..076a1985 100644 --- a/tests/node_modules_apps/apps/module-interop/package.json +++ b/tests/node_modules_apps/apps/module-interop/package.json @@ -6,6 +6,11 @@ }, "dependencies": { "cjs-basic": "file:./packages/cjs-basic", + "cjs-lexer-helper-reexports": "file:./packages/cjs-lexer-helper-reexports", + "cjs-lexer-helper-reexports-negative": "file:./packages/cjs-lexer-helper-reexports-negative", + "cjs-lexer-keys-reexport": "file:./packages/cjs-lexer-keys-reexport", + "cjs-lexer-object-literal": "file:./packages/cjs-lexer-object-literal", + "cjs-lexer-object-require-value": "file:./packages/cjs-lexer-object-require-value", "cjs-reexport-pkg": "file:./packages/cjs-reexport-pkg", "condition-entry-import-cycle": "file:./packages/condition-entry-import-cycle", "condition-entry-imports-cycle": "file:./packages/condition-entry-imports-cycle", diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/dep-dynamic.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/dep-dynamic.cjs new file mode 100644 index 00000000..8e7b07d9 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/dep-dynamic.cjs @@ -0,0 +1 @@ +exports.depDynamic = 'dep-dynamic'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/dep-nested.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/dep-nested.cjs new file mode 100644 index 00000000..48f682b7 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/dep-nested.cjs @@ -0,0 +1 @@ +exports.depNested = 'dep-nested'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/index.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/index.cjs new file mode 100644 index 00000000..8a594294 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/index.cjs @@ -0,0 +1,19 @@ +function __export(m) { + for (var p in m) { + if (p === 'default' || p === '__esModule') continue; + exports[p] = m[p]; + } +} + +function nested() { + __export(require('./dep-nested.cjs')); +} + +function other(name) { + __export(require(name)); +} + +nested(); +other('./dep-dynamic.cjs'); + +exports.own = 'own-value'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/package.json b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/package.json new file mode 100644 index 00000000..4086a83a --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports-negative/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-lexer-helper-reexports-negative", + "version": "1.0.0", + "main": "index.cjs" +} diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-a.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-a.cjs new file mode 100644 index 00000000..5fff0b7f --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-a.cjs @@ -0,0 +1 @@ +exports.depAlpha = 'dep-alpha'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-b.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-b.cjs new file mode 100644 index 00000000..8f658995 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-b.cjs @@ -0,0 +1 @@ +exports.depBeta = 'dep-beta'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-c.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-c.cjs new file mode 100644 index 00000000..38664565 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-c.cjs @@ -0,0 +1 @@ +exports.depGamma = 'dep-gamma'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-d.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-d.cjs new file mode 100644 index 00000000..29efe392 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/dep-d.cjs @@ -0,0 +1 @@ +exports.depDelta = 'dep-delta'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/index.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/index.cjs new file mode 100644 index 00000000..a0882bf9 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/index.cjs @@ -0,0 +1,29 @@ +function __export(m) { + for (var p in m) { + if (p === 'default' || p === '__esModule') continue; + exports[p] = m[p]; + } +} + +function __exportStar(m, target) { + for (var p in m) { + if (p === 'default' || p === '__esModule') continue; + target[p] = m[p]; + } +} + +var tslib = { + __export: function (m, target) { + __exportStar(m, target); + }, + __exportStar: function (m, target) { + __exportStar(m, target); + }, +}; + +__export(require('./dep-a.cjs')); +__exportStar(require('./dep-b.cjs'), exports); +tslib.__export(require('./dep-c.cjs'), exports); +tslib.__exportStar(require('./dep-d.cjs'), exports); + +exports.own = 'own-value'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/package.json b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/package.json new file mode 100644 index 00000000..f9630e2d --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-helper-reexports/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-lexer-helper-reexports", + "version": "1.0.0", + "main": "index.cjs" +} diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/dep.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/dep.cjs new file mode 100644 index 00000000..ab8afa12 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/dep.cjs @@ -0,0 +1,2 @@ +exports.depAlpha = 'dep-alpha'; +exports.depBeta = 'dep-beta'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/index.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/index.cjs new file mode 100644 index 00000000..fb7c0d5f --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/index.cjs @@ -0,0 +1,8 @@ +var dep = require('./dep.cjs'); + +Object.keys(dep).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + exports[key] = dep[key]; +}); + +exports.own = 'own-value'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/package.json b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/package.json new file mode 100644 index 00000000..604f0c98 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-keys-reexport/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-lexer-keys-reexport", + "version": "1.0.0", + "main": "index.cjs" +} diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/dep.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/dep.cjs new file mode 100644 index 00000000..ab8afa12 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/dep.cjs @@ -0,0 +1,2 @@ +exports.depAlpha = 'dep-alpha'; +exports.depBeta = 'dep-beta'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/index.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/index.cjs new file mode 100644 index 00000000..892134a4 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/index.cjs @@ -0,0 +1,10 @@ +const a = 1; +const c = 2; +const e = 4; + +module.exports = { + a, + b: c, + 'd': e, + ...require('./dep.cjs'), +}; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/package.json b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/package.json new file mode 100644 index 00000000..e2c832bd --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-literal/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-lexer-object-literal", + "version": "1.0.0", + "main": "index.cjs" +} diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/dep.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/dep.cjs new file mode 100644 index 00000000..5fff0b7f --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/dep.cjs @@ -0,0 +1 @@ +exports.depAlpha = 'dep-alpha'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/index.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/index.cjs new file mode 100644 index 00000000..8a183b65 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/index.cjs @@ -0,0 +1,7 @@ +const a = 1; + +module.exports = { + a, + b: require('./dep.cjs'), + afterRequire: 3, +}; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/package.json b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/package.json new file mode 100644 index 00000000..583bc43e --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-object-require-value/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-lexer-object-require-value", + "version": "1.0.0", + "main": "index.cjs" +} diff --git a/tests/node_modules_apps/apps/module-interop/test-17-cjs-lexer-parity.mjs b/tests/node_modules_apps/apps/module-interop/test-17-cjs-lexer-parity.mjs new file mode 100644 index 00000000..7db1f2cb --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/test-17-cjs-lexer-parity.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert'; +import objectLiteralDefault, { + a as literalA, + b as literalB, + d as literalD, + depAlpha as literalDepAlpha, + depBeta as literalDepBeta, +} from 'cjs-lexer-object-literal'; +import callbackDefault, { + depAlpha as callbackDepAlpha, + depBeta as callbackDepBeta, + own as callbackOwn, +} from 'cjs-lexer-keys-reexport'; +import requireValueDefault, { + a as requireValueA, + b as requireValueB, + depAlpha as requireValueDepAlpha, +} from 'cjs-lexer-object-require-value'; +import * as requireValueNs from 'cjs-lexer-object-require-value'; + +export const run = () => { + assert.strictEqual(literalA, 1); + assert.strictEqual(literalB, 2); + assert.strictEqual(literalD, 4); + assert.strictEqual(literalDepAlpha, 'dep-alpha'); + assert.strictEqual(literalDepBeta, 'dep-beta'); + assert.strictEqual(objectLiteralDefault.depAlpha, 'dep-alpha'); + + assert.strictEqual(callbackDepAlpha, 'dep-alpha'); + assert.strictEqual(callbackDepBeta, 'dep-beta'); + assert.strictEqual(callbackOwn, 'own-value'); + assert.strictEqual(callbackDefault.own, 'own-value'); + + assert.strictEqual(requireValueA, 1); + assert.deepStrictEqual(requireValueB, { depAlpha: 'dep-alpha' }); + assert.strictEqual(requireValueDepAlpha, undefined); + assert.strictEqual(Object.hasOwn(requireValueNs, 'depAlpha'), true); + assert.strictEqual(requireValueDefault.afterRequire, 3); + assert.strictEqual(Object.hasOwn(requireValueNs, 'afterRequire'), false); + + return 'PASS: CJS lexer parity object-literal and keys reexport patterns'; +}; diff --git a/tests/node_modules_apps/apps/module-interop/test-18-cjs-lexer-helper-reexports.mjs b/tests/node_modules_apps/apps/module-interop/test-18-cjs-lexer-helper-reexports.mjs new file mode 100644 index 00000000..54d867a2 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/test-18-cjs-lexer-helper-reexports.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert'; +import helperDefault, { + depAlpha, + depBeta, + depGamma, + depDelta, + own as helperOwn, +} from 'cjs-lexer-helper-reexports'; + +const negativeNs = await import('cjs-lexer-helper-reexports-negative'); + +export const run = () => { + assert.strictEqual(depAlpha, 'dep-alpha'); + assert.strictEqual(depBeta, 'dep-beta'); + assert.strictEqual(depGamma, 'dep-gamma'); + assert.strictEqual(depDelta, 'dep-delta'); + assert.strictEqual(helperOwn, 'own-value'); + assert.strictEqual(helperDefault.depDelta, 'dep-delta'); + + assert.strictEqual(negativeNs.default.depNested, 'dep-nested'); + assert.strictEqual(negativeNs.default.depDynamic, 'dep-dynamic'); + assert.strictEqual(Object.hasOwn(negativeNs, 'depNested'), false); + assert.strictEqual(Object.hasOwn(negativeNs, 'depDynamic'), false); + assert.strictEqual(Object.hasOwn(negativeNs, 'own'), true); + + return 'PASS: CJS lexer parity helper reexport patterns'; +}; diff --git a/tests/node_modules_apps/config.jsonc b/tests/node_modules_apps/config.jsonc index c6854ff1..052c046d 100644 --- a/tests/node_modules_apps/config.jsonc +++ b/tests/node_modules_apps/config.jsonc @@ -19,7 +19,9 @@ "test-13-scanner-false-positive-guards.cjs": "Graph scanning avoids property require, non-call createRequire, local createRequire, and nested CJS require false positives", "test-14-exports-patterns.mjs": "Package exports wildcard patterns resolve for ESM, module-sync, and CJS require", "test-15-imports-patterns.cjs": "Package imports wildcard patterns resolve for CJS require", - "test-16-shim-patterns.mjs": "OpenAI-style _shims/auto wildcard package exports resolve" + "test-16-shim-patterns.mjs": "OpenAI-style _shims/auto wildcard package exports resolve", + "test-17-cjs-lexer-parity.mjs": "Node-baselined cjs-module-lexer parity for module.exports object literals, spread require reexports, and Object.keys callback assignment reexports", + "test-18-cjs-lexer-helper-reexports.mjs": "Node-baselined cjs-module-lexer parity for __export/__exportStar helper reexport patterns" } }, "popular-pure-js": { From 688c061423928c4019b8f5c1eeefb2af636fc8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 18 Jun 2026 15:08:13 +0200 Subject: [PATCH 14/42] add support for CJS lexer parity tests for EXPORTS_ASSIGN patterns and export-star guards --- crates/wasm-rquickjs/skeleton/src/internal.rs | 71 ++++++++++++++++++- .../apps/module-interop/package.json | 2 + .../dep-dynamic.cjs | 1 + .../dep-static.cjs | 1 + .../index.cjs | 30 ++++++++ .../package.json | 5 ++ .../cjs-lexer-exports-assign/dep-extra.cjs | 1 + .../cjs-lexer-exports-assign/dep-main.cjs | 2 + .../cjs-lexer-exports-assign/index.cjs | 20 ++++++ .../cjs-lexer-exports-assign/package.json | 5 ++ .../test-19-cjs-lexer-exports-assign.mjs | 26 +++++++ tests/node_modules_apps/config.jsonc | 3 +- 12 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/dep-dynamic.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/dep-static.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/index.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/package.json create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/dep-extra.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/dep-main.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/index.cjs create mode 100644 tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/package.json create mode 100644 tests/node_modules_apps/apps/module-interop/test-19-cjs-lexer-exports-assign.mjs diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 7f4fd84b..539d8812 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -2940,6 +2940,33 @@ fn is_simple_getter_body(body: &str) -> bool { } +fn parse_exports_assign_require_value(source: &str, pos: usize) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + if let Some((specifier, next)) = parse_require_string(source, pos) { + return Some((specifier, next)); + } + + if !is_free_ident_start(bytes, pos) + || !source[pos..].starts_with("_interopRequireWildcard") + || !is_ident_boundary(bytes, pos + 23) + { + return None; + } + + let mut i = skip_ws_comments(source, pos + 23); + if i >= bytes.len() || bytes[i] != b'(' { + return None; + } + i = skip_ws_comments(source, i + 1); + let (specifier, next) = parse_require_string(source, i)?; + i = skip_ws_comments(source, next); + if i >= bytes.len() || bytes[i] != b')' { + return None; + } + + Some((specifier, i + 1)) +} + fn parse_require_binding(source: &str, pos: usize) -> Option<(String, String, usize)> { for keyword in ["var", "let", "const"] { if is_free_ident_start(source.as_bytes(), pos) @@ -2953,7 +2980,7 @@ fn parse_require_binding(source: &str, pos: usize) -> Option<(String, String, us return None; } i = skip_ws_comments(source, i + 1); - let (specifier, next) = parse_require_string(source, i)?; + let (specifier, next) = parse_exports_assign_require_value(source, i)?; let after_require = skip_ws_comments(source, next); if !is_statement_boundary(source, after_require) { return None; @@ -5513,6 +5540,48 @@ mod cjs_export_analyzer_tests { &["own"], &[], ); + + assert_analysis( + r#" + var _dep = _interopRequireWildcard(require("./dep.cjs")); + Object.keys(_dep).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(exports, key)) return; + exports[key] = _dep[key]; + }); + exports.own = "own"; + "#, + true, + &["own"], + &["./dep.cjs"], + ); + + assert_analysis( + r#" + var _dep = _interopWildcard(require("./dep.cjs")); + Object.keys(_dep).forEach(function (key) { + exports[key] = _dep[key]; + }); + exports.own = "own"; + "#, + true, + &["own"], + &[], + ); + + assert_analysis( + r#" + var name = "./dep.cjs"; + var _dep = _interopRequireWildcard(require(name)); + Object.keys(_dep).forEach(function (key) { + exports[key] = _dep[key]; + }); + exports.own = "own"; + "#, + true, + &["own"], + &[], + ); } } diff --git a/tests/node_modules_apps/apps/module-interop/package.json b/tests/node_modules_apps/apps/module-interop/package.json index 076a1985..37a6a050 100644 --- a/tests/node_modules_apps/apps/module-interop/package.json +++ b/tests/node_modules_apps/apps/module-interop/package.json @@ -6,6 +6,8 @@ }, "dependencies": { "cjs-basic": "file:./packages/cjs-basic", + "cjs-lexer-exports-assign": "file:./packages/cjs-lexer-exports-assign", + "cjs-lexer-exports-assign-negative": "file:./packages/cjs-lexer-exports-assign-negative", "cjs-lexer-helper-reexports": "file:./packages/cjs-lexer-helper-reexports", "cjs-lexer-helper-reexports-negative": "file:./packages/cjs-lexer-helper-reexports-negative", "cjs-lexer-keys-reexport": "file:./packages/cjs-lexer-keys-reexport", diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/dep-dynamic.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/dep-dynamic.cjs new file mode 100644 index 00000000..8e7b07d9 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/dep-dynamic.cjs @@ -0,0 +1 @@ +exports.depDynamic = 'dep-dynamic'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/dep-static.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/dep-static.cjs new file mode 100644 index 00000000..69fa0670 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/dep-static.cjs @@ -0,0 +1 @@ +exports.depStatic = 'dep-static'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/index.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/index.cjs new file mode 100644 index 00000000..8c3af773 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/index.cjs @@ -0,0 +1,30 @@ +var dynamicName = './dep-dynamic.cjs'; +var dynamic = require(dynamicName); +Object.keys(dynamic).forEach(function (key) { + exports[key] = dynamic[key]; +}); + +var dep = require('./dep-static.cjs'); +var other = {}; +Object.keys(other).forEach(function (key) { + exports[key] = other[key]; +}); + +Object.keys(dep).forEach(function (key) { + exports[key] = transform(dep[key]); +}); + +var _dep = _interopWildcard(require('./dep-static.cjs')); +Object.keys(_dep).forEach(function (key) { + exports[key] = _dep[key]; +}); + +exports.own = 'own-value'; + +function transform(value) { + return value; +} + +function _interopWildcard(obj) { + return obj; +} diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/package.json b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/package.json new file mode 100644 index 00000000..aae534ec --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign-negative/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-lexer-exports-assign-negative", + "version": "1.0.0", + "main": "index.cjs" +} diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/dep-extra.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/dep-extra.cjs new file mode 100644 index 00000000..38664565 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/dep-extra.cjs @@ -0,0 +1 @@ +exports.depGamma = 'dep-gamma'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/dep-main.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/dep-main.cjs new file mode 100644 index 00000000..ab8afa12 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/dep-main.cjs @@ -0,0 +1,2 @@ +exports.depAlpha = 'dep-alpha'; +exports.depBeta = 'dep-beta'; diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/index.cjs b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/index.cjs new file mode 100644 index 00000000..3d700a8a --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/index.cjs @@ -0,0 +1,20 @@ +var _main = _interopRequireWildcard(require('./dep-main.cjs')); +var extra = require('./dep-extra.cjs'); + +Object.keys(_main).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + if (Object.prototype.hasOwnProperty.call(exports, key)) return; + exports[key] = _main[key]; +}); + +Object.keys(extra).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + if (key in exports && exports[key] === extra[key]) return; + exports[key] = extra[key]; +}); + +exports.own = 'own-value'; + +function _interopRequireWildcard(obj) { + return obj && obj.__esModule ? obj : obj; +} diff --git a/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/package.json b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/package.json new file mode 100644 index 00000000..2a08476f --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/packages/cjs-lexer-exports-assign/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-lexer-exports-assign", + "version": "1.0.0", + "main": "index.cjs" +} diff --git a/tests/node_modules_apps/apps/module-interop/test-19-cjs-lexer-exports-assign.mjs b/tests/node_modules_apps/apps/module-interop/test-19-cjs-lexer-exports-assign.mjs new file mode 100644 index 00000000..7933dfb2 --- /dev/null +++ b/tests/node_modules_apps/apps/module-interop/test-19-cjs-lexer-exports-assign.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert'; +import assignDefault, { + depAlpha, + depBeta, + depGamma, + own, +} from 'cjs-lexer-exports-assign'; + +const negativeNs = await import('cjs-lexer-exports-assign-negative'); + +export const run = () => { + assert.strictEqual(depAlpha, 'dep-alpha'); + assert.strictEqual(depBeta, 'dep-beta'); + assert.strictEqual(depGamma, 'dep-gamma'); + assert.strictEqual(own, 'own-value'); + assert.strictEqual(assignDefault.depAlpha, 'dep-alpha'); + assert.strictEqual(assignDefault.depGamma, 'dep-gamma'); + + assert.strictEqual(negativeNs.default.depDynamic, 'dep-dynamic'); + assert.strictEqual(negativeNs.default.depStatic, 'dep-static'); + assert.strictEqual(Object.hasOwn(negativeNs, 'depDynamic'), false); + assert.strictEqual(Object.hasOwn(negativeNs, 'depStatic'), false); + assert.strictEqual(Object.hasOwn(negativeNs, 'own'), true); + + return 'PASS: CJS lexer parity EXPORTS_ASSIGN and export-star guard patterns'; +}; diff --git a/tests/node_modules_apps/config.jsonc b/tests/node_modules_apps/config.jsonc index 052c046d..ac69f0a3 100644 --- a/tests/node_modules_apps/config.jsonc +++ b/tests/node_modules_apps/config.jsonc @@ -21,7 +21,8 @@ "test-15-imports-patterns.cjs": "Package imports wildcard patterns resolve for CJS require", "test-16-shim-patterns.mjs": "OpenAI-style _shims/auto wildcard package exports resolve", "test-17-cjs-lexer-parity.mjs": "Node-baselined cjs-module-lexer parity for module.exports object literals, spread require reexports, and Object.keys callback assignment reexports", - "test-18-cjs-lexer-helper-reexports.mjs": "Node-baselined cjs-module-lexer parity for __export/__exportStar helper reexport patterns" + "test-18-cjs-lexer-helper-reexports.mjs": "Node-baselined cjs-module-lexer parity for __export/__exportStar helper reexport patterns", + "test-19-cjs-lexer-exports-assign.mjs": "Node-baselined cjs-module-lexer parity for EXPORTS_ASSIGN require bindings and documented export-star guard variants" } }, "popular-pure-js": { From aad7026ed60071a9e38f7cf2c62e3a544f78bafe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 18 Jun 2026 15:19:07 +0200 Subject: [PATCH 15/42] promote punycode node compat tes --- tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index e76a3ab7..f6cb461e 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -7252,7 +7252,7 @@ "parallel/test-promises-unhandled-rejections.js": { "category": "known-gap", "reason": "process unhandledRejection/rejectionHandled/warning mode behavior is incomplete" }, "parallel/test-promises-unhandled-symbol-rejections.js": { "category": "known-gap", "reason": "process unhandledRejection/rejectionHandled/warning mode behavior is incomplete" }, "parallel/test-promises-warning-on-unhandled-rejection.js": { "category": "known-gap", "reason": "process unhandledRejection/rejectionHandled/warning mode behavior is incomplete" }, - "parallel/test-punycode.js": { "category": "known-gap", "reason": "legacy punycode builtin is not wired into CommonJS module resolution" }, + "parallel/test-punycode.js": {}, "parallel/test-queue-microtask-uncaught-asynchooks.js": { "category": "known-gap", "reason": "async_hooks lifecycle events for microtasks are not implemented" }, "parallel/test-queue-microtask.js": { "category": "known-gap", diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 9e1fea8f..69e5fb66 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -1,6 +1,6 @@ # Node.js v22.14.0 Compatibility Inventory -Generated: 2026-06-17 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) +Generated: 2026-06-18 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) This report is generated from `config.jsonc` only. It does **not** run the vendored tests itself. Entries classified as `runnable` are reported as passing because the `node_compat` PR test executes runnable entries and fails CI if any of them fail. @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3099/4295 (72.2%) +**Primary compatibility (CI-enforced):** 3100/4295 (72.2%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3099 | 72.2% | 55.2% | 46.0% | -| 🧩 known gap | 1196 | 27.8% | 21.3% | 17.8% | +| ✅ passing (runnable) | 3100 | 72.2% | 55.3% | 46.1% | +| 🧩 known gap | 1195 | 27.8% | 21.3% | 17.8% | | 🚫 WASI-impossible (excluded) | 1153 | — | 20.6% | 17.1% | | ⚙️ engine difference (excluded) | 162 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1121 | — | — | 16.7% | | **Total** | **6731** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3099/5610 (55.2%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3100/5610 (55.3%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 469 | 101 | 92 | 83 | 11 | 0 | 182 | 52.3% | 35.2% | +| other | 469 | 102 | 91 | 83 | 11 | 0 | 182 | 52.8% | 35.5% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -680,7 +680,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1196) +### known gap (1195) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -1144,7 +1144,6 @@ Secondary full-public compatibility, including public tests that are currently e | keep-alive socket reuse plus drain/backpressure behavior for corked responses is not Node-compatible | 1 | `parallel/test-http-outgoing-end-cork.js` | | keep-alive socket timeout/reuse race handling is not Node-compatible | 1 | `parallel/test-http-keep-alive-timeout-race-condition.js` | | large raw pipelined request load (10k) exhausts current WASM/runtime resources | 1 | `parallel/test-http-pipeline-requests-connection-leak.js` | -| legacy punycode builtin is not wired into CommonJS module resolution | 1 | `parallel/test-punycode.js` | | maxRequestsPerSocket keep-alive header behavior (Keep-Alive/Connection framing) is not Node-compatible | 1 | `parallel/test-http-keep-alive-max-requests.js` | | missing importModuleDynamically callback does not raise ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING | 1 | `parallel/test-vm-no-dynamic-import-callback.js` | | mixed headersTimeout/requestTimeout handling is not Node-compatible | 1 | `sequential/test-http-server-request-timeouts-mixed.js` | From ef253aa8def5393c9734839aa5401f3fdf453358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 18 Jun 2026 15:42:19 +0200 Subject: [PATCH 16/42] implement module findPackageJSON --- .../skeleton/src/builtin/module.js | 173 ++++++++++++++++-- .../src/module-resolution.js | 96 ++++++++++ .../wit/module-resolution.wit | 1 + tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 2 +- tests/runtime/module_resolution.rs | 17 ++ 6 files changed, 276 insertions(+), 15 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 2e1dfb55..4d345382 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -50,7 +50,7 @@ import * as worker_threads from 'node:worker_threads'; import * as zlib from 'node:zlib'; import * as sqlite from 'node:sqlite'; import * as internalHttp from '__wasm_rquickjs_builtin/internal/http'; -import { ERR_INVALID_ARG_TYPE } from '__wasm_rquickjs_builtin/internal/errors'; +import { ERR_INVALID_ARG_TYPE, ERR_MISSING_ARGS } from '__wasm_rquickjs_builtin/internal/errors'; import * as internalErrors from '__wasm_rquickjs_builtin/internal/errors'; import * as internalFsUtils from '__wasm_rquickjs_builtin/internal/fs/utils'; import * as internalUrl from '__wasm_rquickjs_builtin/internal/url'; @@ -2083,7 +2083,10 @@ function resolveFromNodeModules(id, parentDir, parentFilename, conditions) { try { pkg = JSON.parse(pkgJson); const exportsResolved = resolvePackageExports(parts.name, pkgDir, pkg, parts.subpath, conditions); - if (exportsResolved !== undefined) return exportsResolved; + if (exportsResolved !== undefined) { + exportsResolved.packageDir = pkgDir; + return exportsResolved; + } } catch (e) { if (e && e.code) { throw e; @@ -2104,19 +2107,19 @@ function resolveFromNodeModules(id, parentDir, parentFilename, conditions) { const subCandidate = pathModule.join(pkgDir, parts.subpath); // Try exact subpath let content = tryReadFile(subCandidate); - if (content !== null) return { filename: subCandidate, content: content }; + if (content !== null) return { filename: subCandidate, content: content, packageDir: pkgDir }; // Try with extensions content = tryReadFile(subCandidate + '.js'); - if (content !== null) return { filename: subCandidate + '.js', content: content }; + if (content !== null) return { filename: subCandidate + '.js', content: content, packageDir: pkgDir }; content = tryReadFile(subCandidate + '.mjs'); - if (content !== null) return { filename: subCandidate + '.mjs', content: content }; + if (content !== null) return { filename: subCandidate + '.mjs', content: content, packageDir: pkgDir }; content = tryReadFile(subCandidate + '.json'); - if (content !== null) return { filename: subCandidate + '.json', content: content }; + if (content !== null) return { filename: subCandidate + '.json', content: content, packageDir: pkgDir }; // Try as directory content = tryReadFile(pathModule.join(subCandidate, 'index.js')); - if (content !== null) return { filename: pathModule.join(subCandidate, 'index.js'), content: content }; + if (content !== null) return { filename: pathModule.join(subCandidate, 'index.js'), content: content, packageDir: pkgDir }; content = tryReadFile(pathModule.join(subCandidate, 'index.json')); - if (content !== null) return { filename: pathModule.join(subCandidate, 'index.json'), content: content }; + if (content !== null) return { filename: pathModule.join(subCandidate, 'index.json'), content: content, packageDir: pkgDir }; } const candidate = pkgDir; @@ -2135,7 +2138,7 @@ function resolveFromNodeModules(id, parentDir, parentFilename, conditions) { ]; for (let m = 0; m < mainCandidates.length; m++) { const content = tryReadFile(mainCandidates[m]); - if (content !== null) return { filename: mainCandidates[m], content: content }; + if (content !== null) return { filename: mainCandidates[m], content: content, packageDir: pkgDir }; } } } catch (e) { @@ -2153,18 +2156,18 @@ function resolveFromNodeModules(id, parentDir, parentFilename, conditions) { // Try as directory: index.js / index.json const indexJs = pathModule.join(candidate, 'index.js'); let content = tryReadFile(indexJs); - if (content !== null) return { filename: indexJs, content: content }; + if (content !== null) return { filename: indexJs, content: content, packageDir: pkgDir }; const indexJson = pathModule.join(candidate, 'index.json'); content = tryReadFile(indexJson); - if (content !== null) return { filename: indexJson, content: content }; + if (content !== null) return { filename: indexJson, content: content, packageDir: pkgDir }; // Try as file with extension content = tryReadFile(candidate + '.js'); - if (content !== null) return { filename: candidate + '.js', content: content }; + if (content !== null) return { filename: candidate + '.js', content: content, packageDir: pkgDir }; content = tryReadFile(candidate + '.json'); - if (content !== null) return { filename: candidate + '.json', content: content }; + if (content !== null) return { filename: candidate + '.json', content: content, packageDir: pkgDir }; } return null; } @@ -2387,6 +2390,149 @@ export function createRequire(filename) { return makeRequire(dir, syntheticParent, filepath); } +function isUrlInstance(value) { + return value instanceof URL || + (value !== null && typeof value === 'object' && + typeof value.href === 'string' && typeof value.protocol === 'string'); +} + +function normalizeFindPackageJsonSpecifier(specifier) { + if (specifier === undefined) { + throw new ERR_MISSING_ARGS('specifier'); + } + + if (isUrlInstance(specifier)) { + const filePath = nodeUrl.fileURLToPath(specifier); + return { + kind: 'absolute', + path: filePath, + source: filePath, + }; + } + + if (typeof specifier !== 'string') { + throw new ERR_INVALID_ARG_TYPE('specifier', ['string', 'URL'], specifier); + } + + if (specifier.startsWith('file://')) { + const filePath = nodeUrl.fileURLToPath(specifier); + return { + kind: 'absolute', + path: filePath, + source: specifier, + }; + } + + if (pathModule.isAbsolute(specifier)) { + return { + kind: 'absolute', + path: pathModule.normalize(specifier), + source: specifier, + }; + } + + if (specifier === '.' || specifier === '..' || specifier.startsWith('./') || specifier.startsWith('../')) { + return { + kind: 'relative', + value: specifier, + }; + } + + return { + kind: 'bare', + value: specifier, + }; +} + +function normalizeFindPackageJsonBase(base, baseRequired) { + if (base === undefined) { + if (baseRequired) { + throw new ERR_INVALID_ARG_TYPE('base', ['string', 'URL'], base); + } + return null; + } + + if (isUrlInstance(base) || (typeof base === 'string' && base.startsWith('file://'))) { + const filename = nodeUrl.fileURLToPath(base); + return { + filename, + dir: pathModule.dirname(pathModule.resolve(filename)), + }; + } + + if (typeof base !== 'string') { + throw new ERR_INVALID_ARG_TYPE('base', ['string', 'URL'], base); + } + + if (!pathModule.isAbsolute(base)) { + throw new ERR_INVALID_ARG_TYPE('base', ['string', 'URL'], base); + } + + const filename = pathModule.resolve(base); + return { + filename, + dir: pathModule.dirname(filename), + }; +} + +function findNearestPackageJsonPath(startDir) { + let dir = pathModule.resolve(startDir || '/'); + while (true) { + if (pathModule.basename(dir) === 'node_modules') return undefined; + const pkgJsonPath = pathModule.join(dir, 'package.json'); + if (tryReadFile(pkgJsonPath) !== null) { + return pathModule.toNamespacedPath(pkgJsonPath); + } + const parent = pathModule.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +function packageSearchStartDir(resolvedPath, sourceSpecifier) { + if (typeof sourceSpecifier === 'string' && + (/\/$/.test(sourceSpecifier) || /(?:^|\/)\.\.?$/.test(sourceSpecifier))) { + return pathModule.resolve(resolvedPath); + } + + if (_stat(resolvedPath) === 1) { + return pathModule.resolve(resolvedPath); + } + + return pathModule.dirname(pathModule.resolve(resolvedPath)); +} + +function findBarePackageJson(specifier, parentDir, parentFilename) { + const resolved = resolveFromNodeModules(specifier, parentDir, parentFilename, cjsPackageConditions); + if (resolved === null) return undefined; + + if (typeof resolved.packageDir === 'string' && resolved.packageDir.length > 0) { + const pkgJsonPath = pathModule.join(resolved.packageDir, 'package.json'); + if (tryReadFile(pkgJsonPath) !== null) { + return pathModule.toNamespacedPath(pkgJsonPath); + } + } + + return undefined; +} + +export function findPackageJSON(specifier, base) { + const normalizedSpecifier = normalizeFindPackageJsonSpecifier(specifier); + if (normalizedSpecifier.kind === 'absolute') { + const startDir = packageSearchStartDir(normalizedSpecifier.path, normalizedSpecifier.source); + return findNearestPackageJsonPath(startDir); + } + + const normalizedBase = normalizeFindPackageJsonBase(base, true); + if (normalizedSpecifier.kind === 'relative') { + const resolvedPath = pathModule.resolve(normalizedBase.dir, normalizedSpecifier.value); + const startDir = packageSearchStartDir(resolvedPath, normalizedSpecifier.value); + return findNearestPackageJsonPath(startDir); + } + + return findBarePackageJson(normalizedSpecifier.value, normalizedBase.dir, normalizedBase.filename); +} + export { builtinModuleNames as builtinModules }; export function isBuiltinModule(id) { @@ -2532,6 +2678,7 @@ function runMain() { const moduleExports = { require: globalRequire, createRequire, + findPackageJSON, builtinModules: builtinModuleNames, isBuiltin: isBuiltinModule, wrap: wrap, diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 129ab746..b65cfa4a 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -848,6 +848,102 @@ export const testCjsPackageReexportNamedExports = async () => { } }; +export const testFindPackageJson = async () => { + try { + const { createRequire, findPackageJSON } = await import('node:module'); + const require = createRequire('/find-package-json-app/entry.cjs'); + + fs.mkdirSync('/find-package-json-app/node_modules/pkg/subfolder', { recursive: true }); + fs.mkdirSync('/find-package-json-app/node_modules/pkg/subfolder2', { recursive: true }); + fs.mkdirSync('/find-package-json-app/node_modules/pkg2', { recursive: true }); + fs.mkdirSync('/find-package-json-app/packages/nested/sub-pkg-cjs', { recursive: true }); + fs.mkdirSync('/find-package-json-app/packages/nested/sub-pkg-esm', { recursive: true }); + + fs.writeFileSync('/find-package-json-app/package.json', JSON.stringify({ name: 'root-app' })); + fs.writeFileSync('/find-package-json-app/packages/nested/package.json', JSON.stringify({ name: 'nested-parent' })); + fs.writeFileSync('/find-package-json-app/packages/nested/sub-pkg-cjs/index.cjs', [ + 'const { findPackageJSON } = require("node:module");', + 'module.exports = findPackageJSON("..", __filename);', + ].join('\n')); + fs.writeFileSync('/find-package-json-app/packages/nested/sub-pkg-esm/index.mjs', [ + 'import { findPackageJSON } from "node:module";', + 'export default findPackageJSON("..", import.meta.url);', + ].join('\n')); + + fs.writeFileSync('/find-package-json-app/node_modules/pkg/subfolder/index.js', 'module.exports = { subfolder: true };'); + fs.writeFileSync('/find-package-json-app/node_modules/pkg/subfolder/package.json', JSON.stringify({ + name: 'pkg-subfolder', + secretNumberSubfolder: 11, + })); + fs.writeFileSync('/find-package-json-app/node_modules/pkg/subfolder2/index.js', 'module.exports = { subfolder2: true };'); + fs.writeFileSync('/find-package-json-app/node_modules/pkg/subfolder2/package.json', JSON.stringify({ + name: 'pkg-subfolder2', + secretNumberSubfolder2: 22, + })); + fs.writeFileSync('/find-package-json-app/node_modules/pkg/package.json', JSON.stringify({ + name: 'pkg', + exports: './subfolder/index.js', + secretNumberPkgRoot: 33, + })); + fs.writeFileSync('/find-package-json-app/node_modules/pkg2/package.json', JSON.stringify({ + name: 'pkg2', + main: '/find-package-json-app/node_modules/pkg/subfolder2/index.js', + secretNumberPkg2: 44, + })); + + assert.throws( + () => findPackageJSON(), + { code: 'ERR_MISSING_ARGS' }, + ); + + for (const invalidBase of [null, {}, [], Symbol('invalid'), () => {}, true, false, 1, 0]) { + assert.throws( + () => findPackageJSON('', invalidBase), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + } + + const basePath = '/find-package-json-app/entry.mjs'; + const baseUrl = pathToFileURL(basePath); + const subfolderPackageJson = '/find-package-json-app/node_modules/pkg/subfolder/package.json'; + const nestedPackageJson = '/find-package-json-app/packages/nested/package.json'; + const pkgRootPackageJson = '/find-package-json-app/node_modules/pkg/package.json'; + const pkg2RootPackageJson = '/find-package-json-app/node_modules/pkg2/package.json'; + + assert.strictEqual( + findPackageJSON('./node_modules/pkg/subfolder/index.js', baseUrl.href), + subfolderPackageJson, + ); + assert.strictEqual( + findPackageJSON(new URL('./node_modules/pkg/subfolder/index.js', baseUrl), baseUrl), + subfolderPackageJson, + ); + assert.strictEqual( + findPackageJSON('./node_modules/pkg/subfolder/index.js', basePath), + subfolderPackageJson, + ); + + const cjsParentPackageJson = require('/find-package-json-app/packages/nested/sub-pkg-cjs/index.cjs'); + assert.strictEqual(cjsParentPackageJson, nestedPackageJson); + + const esmParentPackageJson = (await import('/find-package-json-app/packages/nested/sub-pkg-esm/index.mjs')).default; + assert.strictEqual(esmParentPackageJson, nestedPackageJson); + + assert.strictEqual(findPackageJSON('pkg', baseUrl), pkgRootPackageJson); + assert.strictEqual(findPackageJSON('pkg2', baseUrl), pkg2RootPackageJson); + + const pkgResolved = require.resolve('pkg', { paths: ['/find-package-json-app'] }); + assert.strictEqual(findPackageJSON(pkgResolved), subfolderPackageJson); + assert.strictEqual(findPackageJSON(pathToFileURL(pkgResolved).href), subfolderPackageJson); + assert.strictEqual(findPackageJSON(pathToFileURL(pkgResolved)), subfolderPackageJson); + + return true; + } catch (error) { + console.error(error); + throw error; + } +}; + export const testRequireEsmErrorHandling = async () => { try { fs.mkdirSync('/require-esm-errors-app', { recursive: true }); diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index cf45b4e7..2cb82474 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -9,6 +9,7 @@ world module-resolution { export test-cjs-shared-loader-identity: func() -> bool; export test-module-syntax-detection-and-diagnostics: func() -> bool; export test-cjs-package-reexport-named-exports: func() -> bool; + export test-find-package-json: func() -> bool; export test-require-esm-error-handling: func() -> bool; export test-require-esm-tla-retry: func() -> bool; export test-require-esm-cycle-guards: func() -> bool; diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index f6cb461e..cf91d736 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -6488,7 +6488,7 @@ } }, "parallel/test-filehandle-readablestream.js": { "category": "known-gap", "reason": "fs/promises FileHandle.readableWebStream support is missing or incomplete" }, - "parallel/test-find-package-json.js": { "category": "known-gap", "reason": "node:module.findPackageJSON API behavior is incomplete" }, + "parallel/test-find-package-json.js": { "category": "known-gap", "reason": "findPackageJSON same-process API is implemented; remaining vendored test failures use child_process/loader eval paths" }, "parallel/test-fixed-queue.js": { "category": "node-internals", "reason": "requires --expose-internals and internal/fixed_queue", diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 69e5fb66..492f80f6 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -1092,6 +1092,7 @@ Secondary full-public compatibility, including public tests that are currently e | execSync is ENOSYS-stubbed in WASM child_process emulation | 1 | `parallel/test-child-process-execsync-maxbuf.js#block_01_verify_that_a_maxbuffer_size_of_infinity_works` | | execSync is ENOSYS-stubbed; default maxBuffer behavior is unimplemented | 1 | `parallel/test-child-process-execsync-maxbuf.js#block_02_default_maxbuffer_size_is_1024_1024` | | execSync is ENOSYS-stubbed; maxBuffer overflow ENOBUFS behavior is unimplemented | 1 | `parallel/test-child-process-execsync-maxbuf.js#block_00_verify_that_an_error_is_returned_if_maxbuffer_is_surpassed` | +| findPackageJSON same-process API is implemented; remaining vendored test failures use child_process/loader eval paths | 1 | `parallel/test-find-package-json.js` | | fork() IPC child.send/process.send emulation is not implemented | 1 | `parallel/test-cli-eval.js#block_03_regression_test_for_https_github_com_nodejs_node_issues_1194` | | fork() abort-listener lifecycle for timeout+signal is incomplete | 1 | `parallel/test-child-process-fork-timeout-kill-signal.js#block_03_block_03` | | fork() args/options parsing and ERR_INVALID_ARG_TYPE behavior are incomplete | 1 | `parallel/test-child-process-fork-args.js#block_01_correctly_if_args_is_undefined_or_null` | @@ -1163,7 +1164,6 @@ Secondary full-public compatibility, including public tests that are currently e | node:http client socketPath transport flow is incomplete (unix-socket request hangs) | 1 | `parallel/test-http-client-pipe-end.js` | | node:https Agent constructor compatibility is incomplete (call without new) | 1 | `parallel/test-https-agent-constructor.js` | | node:https Agent#getName TLS option keying is incomplete | 1 | `parallel/test-https-agent-getname.js` | -| node:module.findPackageJSON API behavior is incomplete | 1 | `parallel/test-find-package-json.js` | | node:sqlite applyChangeset conflict-resolution behavior is incomplete | 1 | `parallel/test-sqlite-session.js#test_05_conflict_resolution` | | node:sqlite rejects mixed named+positional parameters where Node accepts them | 1 | `parallel/test-sqlite-statement-sync.js#test_06_statementsync_prototype_expandedsql` | | node:test mock timers Date behavior is incomplete | 1 | `parallel/test-runner-mock-timers-date.js` | diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index 1e4ec7c6..76b9506d 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -147,6 +147,23 @@ async fn cjs_package_reexport_named_exports( Ok(()) } +#[test] +async fn find_package_json( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-find-package-json", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + #[test] async fn require_esm_error_handling( #[tagged_as("module_resolution")] compiled_test: &CompiledTest, From 3a2f86d39c10d10b22c36fd1bd6257f2fe3eec5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 19 Jun 2026 13:18:10 +0200 Subject: [PATCH 17/42] split findPackageJSON node compat test --- tests/common/js_subtest_parser.rs | 150 ++++++++++++++++++++++++++---- tests/common/mod.rs | 6 ++ tests/js_subtest_parser.rs | 55 +++++++++-- tests/node_compat.rs | 15 +-- tests/node_compat/config.jsonc | 26 +++++- tests/node_compat/report.md | 22 +++-- tests/node_compat_validate.rs | 20 ++-- 7 files changed, 245 insertions(+), 49 deletions(-) diff --git a/tests/common/js_subtest_parser.rs b/tests/common/js_subtest_parser.rs index c48d6f6c..fc070a61 100644 --- a/tests/common/js_subtest_parser.rs +++ b/tests/common/js_subtest_parser.rs @@ -146,16 +146,90 @@ fn is_require_node_test(expr: &Expression) -> bool { false } -/// Check if an expression is a `test(...)` or `suite(...)` call. +fn call_name<'a>(call: &'a CallExpression<'a>) -> Option<&'a str> { + if let Expression::Identifier(id) = &call.callee { + Some(id.name.as_str()) + } else { + None + } +} + +fn is_test_call_name(name: &str) -> bool { + name == "test" || name == "it" +} + +fn is_suite_call_name(name: &str) -> bool { + name == "suite" || name == "describe" +} + +/// Check if an expression is a discoverable node:test call. fn is_test_or_suite_call(expr: &Expression) -> bool { if let Expression::CallExpression(call) = expr - && let Expression::Identifier(id) = &call.callee + && let Some(name) = call_name(call) { - return id.name == "test" || id.name == "suite" || id.name == "describe"; + return is_test_call_name(name) || is_suite_call_name(name); } false } +fn extract_callback_body<'a>(call: &'a CallExpression<'a>) -> Option<&'a FunctionBody<'a>> { + for arg in call.arguments.iter().rev() { + match arg { + Argument::FunctionExpression(function) => { + if let Some(body) = function.body.as_ref() { + return Some(body); + } + } + Argument::ArrowFunctionExpression(arrow) => { + if !arrow.expression { + return Some(&arrow.body); + } + } + _ => {} + } + } + None +} + +fn build_test_info_from_call(index: usize, call: &CallExpression, span: (u32, u32)) -> TestInfo { + let name = extract_test_name(call) + .map(|n| sanitize_name(&n)) + .unwrap_or_else(|| format!("test_{index:02}")); + let full_name = format!("test_{index:02}_{name}"); + TestInfo { + index, + span, + name: full_name, + } +} + +fn discover_top_level_suite_nested_tests( + call: &CallExpression, + index: &mut usize, +) -> Vec { + let Some(body) = extract_callback_body(call) else { + return Vec::new(); + }; + + let mut tests = Vec::new(); + for stmt in &body.statements { + if let Statement::ExpressionStatement(expr_stmt) = stmt + && let Expression::CallExpression(nested_call) = &expr_stmt.expression + && let Some(name) = call_name(nested_call) + && is_test_call_name(name) + { + tests.push(build_test_info_from_call( + *index, + nested_call, + (expr_stmt.span.start, expr_stmt.span.end), + )); + *index += 1; + } + } + + tests +} + /// Extract test name from a test() call's first argument. fn extract_test_name(call: &CallExpression) -> Option { if let Some(arg) = call.arguments.first() { @@ -182,6 +256,14 @@ fn extract_test_name(call: &CallExpression) -> Option { /// - `path`: file path (used to determine SourceType: .js → CJS, .mjs → ESM) /// - `source`: the JS source code pub fn discover_subtests(path: &str, source: &str) -> SubtestDiscovery { + discover_subtests_with_options(path, source, false) +} + +pub fn discover_subtests_with_options( + path: &str, + source: &str, + nested_node_test: bool, +) -> SubtestDiscovery { let source_type = if path.ends_with(".mjs") { SourceType::mjs() } else { @@ -202,16 +284,29 @@ pub fn discover_subtests(path: &str, source: &str) -> SubtestDiscovery { && let Expression::CallExpression(call) = &expr_stmt.expression && is_test_or_suite_call(&expr_stmt.expression) { - let name = extract_test_name(call) - .map(|n| sanitize_name(&n)) - .unwrap_or_else(|| format!("test_{index:02}")); - let full_name = format!("test_{index:02}_{name}"); - tests.push(TestInfo { - index, - span: (expr_stmt.span.start, expr_stmt.span.end), - name: full_name, - }); - index += 1; + if nested_node_test + && let Some(name) = call_name(call) + && is_suite_call_name(name) + { + let nested_tests = discover_top_level_suite_nested_tests(call, &mut index); + if nested_tests.is_empty() { + tests.push(build_test_info_from_call( + index, + call, + (expr_stmt.span.start, expr_stmt.span.end), + )); + index += 1; + } else { + tests.extend(nested_tests); + } + } else { + tests.push(build_test_info_from_call( + index, + call, + (expr_stmt.span.start, expr_stmt.span.end), + )); + index += 1; + } } } @@ -277,11 +372,28 @@ pub fn rewrite_for_block(source: &str, blocks: &[BlockInfo], target_index: usize String::from_utf8(result).expect("UTF-8 source remained valid after block rewrite") } -/// Rewrite source to filter to a single node:test test by index. +/// Rewrite source to keep only the targeted discovered node:test call. /// -/// Uses `globalThis.__wasm_rquickjs_node_test_filter` which the node:test -/// polyfill reads on initialization. This works for both CJS and ESM files -/// and doesn't break `'use strict'` directive prologue. -pub fn rewrite_for_node_test(source: &str, target_index: usize) -> String { - format!("globalThis.__wasm_rquickjs_node_test_filter = {target_index};\n{source}") +/// Non-target discovered calls are blanked by span in reverse order to +/// preserve offsets while keeping unrelated top-level code intact. +pub fn rewrite_for_node_test(source: &str, tests: &[TestInfo], target_index: usize) -> String { + let bytes = source.as_bytes(); + let mut result = bytes.to_vec(); + + for test in tests.iter().rev() { + if test.index != target_index { + let start = test.span.0 as usize; + let end = test.span.1 as usize; + if start >= bytes.len() || end > bytes.len() || start >= end { + eprintln!( + "WARNING: node:test span ({},{}) is invalid, skipping rewrite", + start, end + ); + continue; + } + result.splice(start..end, std::iter::once(b' ')); + } + } + + String::from_utf8(result).expect("UTF-8 source remained valid after node:test rewrite") } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 24a222b8..2ac47c1a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -145,6 +145,7 @@ pub struct NodeCompatTestEntry { pub category: NodeCompatCategory, pub reason: Option, pub split: bool, + pub nested_node_test: bool, pub timeout_secs: u64, pub flaky: bool, pub subtests: Vec, @@ -288,6 +289,10 @@ pub fn load_node_compat_config(path: &str) -> anyhow::Result anyhow::Result {});\ntest('b', () => {});\n"; + let tests = match discover_subtests("test.js", source) { + SubtestDiscovery::NodeTest(tests) => tests, + other => panic!("Expected NodeTest discovery, got {:?}", other), + }; + let result = rewrite_for_node_test(source, &tests, 1); + assert!(!result.contains("test('a', () => {});")); + assert!(result.contains("test('b', () => {});")); } #[test] @@ -159,6 +164,44 @@ test('another standalone', () => {}); } } +#[test] +fn test_describe_it_nested_discovery() { + let source = r#" +'use strict'; +const { describe, it } = require('node:test'); + +describe('findPackageJSON', () => { + it('first same-process case', () => {}); + it('second same-process case', () => {}); +}); +"#; + match discover_subtests_with_options("test.js", source, true) { + SubtestDiscovery::NodeTest(tests) => { + assert_eq!(tests.len(), 2); + assert_eq!(tests[0].name, "test_00_first_same_process_case"); + assert_eq!(tests[1].name, "test_01_second_same_process_case"); + } + other => panic!("Expected NodeTest discovery, got {:?}", other), + } +} + +#[test] +fn test_describe_it_default_discovers_suite_only() { + let source = r#" +'use strict'; +const { describe, it } = require('node:test'); + +describe('findPackageJSON', () => { + it('first same-process case', () => {}); + it('second same-process case', () => {}); +}); +"#; + match discover_subtests("test.js", source) { + SubtestDiscovery::None => {} + other => panic!("Expected no split for one top-level suite, got {:?}", other), + } +} + #[test] fn test_no_split_for_single_block() { let source = "'use strict';\n{ assert(1); }"; diff --git a/tests/node_compat.rs b/tests/node_compat.rs index 5ddb5877..ce8a5dc8 100644 --- a/tests/node_compat.rs +++ b/tests/node_compat.rs @@ -1,7 +1,8 @@ test_r::enable!(); use crate::common::js_subtest_parser::{ - BlockInfo, SubtestDiscovery, discover_subtests, rewrite_for_block, rewrite_for_node_test, + BlockInfo, SubtestDiscovery, TestInfo, discover_subtests_with_options, rewrite_for_block, + rewrite_for_node_test, }; use crate::common::{ CompiledTest, GolemPreparedComponent, TestInstance, load_node_compat_config, @@ -60,7 +61,7 @@ fn prepare_node_compat_full( #[derive(Clone)] enum DiscoveryData { Block(Vec), - NodeTest, + NodeTest(Vec), } fn handle_test_result( @@ -214,7 +215,7 @@ fn gen_node_compat_tests(r: &mut DynamicTestRegistration) { } }; - let discovery = discover_subtests(&path, &source); + let discovery = discover_subtests_with_options(&path, &source, entry.nested_node_test); // Staleness check: compare discovered subtest count vs config count let discovered_count = match &discovery { @@ -246,7 +247,9 @@ fn gen_node_compat_tests(r: &mut DynamicTestRegistration) { let discovery_clone = match &discovery { SubtestDiscovery::None => None, SubtestDiscovery::Block(blocks) => Some(DiscoveryData::Block(blocks.clone())), - SubtestDiscovery::NodeTest(_) => Some(DiscoveryData::NodeTest), + SubtestDiscovery::NodeTest(tests) => { + Some(DiscoveryData::NodeTest(tests.clone())) + } }; let subtest_flaky = subtest.flaky; @@ -284,8 +287,8 @@ fn gen_node_compat_tests(r: &mut DynamicTestRegistration) { Some(DiscoveryData::Block(blocks)) => { rewrite_for_block(&source, blocks, subtest_index) } - Some(DiscoveryData::NodeTest) => { - rewrite_for_node_test(&source, subtest_index) + Some(DiscoveryData::NodeTest(tests)) => { + rewrite_for_node_test(&source, tests, subtest_index) } None => source.clone(), }; diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index cf91d736..cd249e29 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -6488,7 +6488,31 @@ } }, "parallel/test-filehandle-readablestream.js": { "category": "known-gap", "reason": "fs/promises FileHandle.readableWebStream support is missing or incomplete" }, - "parallel/test-find-package-json.js": { "category": "known-gap", "reason": "findPackageJSON same-process API is implemented; remaining vendored test failures use child_process/loader eval paths" }, + "parallel/test-find-package-json.js": { + "split": true, + "nestedNodeTest": true, + "subtests": { + "test_00_should_throw_when_no_arguments_are_provided": {}, + "test_01_should_throw_when_parent_location_is_invalid": {}, + "test_02_should_accept_a_file_url_string": {}, + "test_03_should_accept_a_file_url_instance": {}, + "test_04_should_be_able_to_crawl_up_cjs": {}, + "test_05_should_be_able_to_crawl_up_esm": {}, + "test_06_can_require_via_package_json": {}, + "test_07_should_resolve_root_and_closest_package_json": { + "category": "known-gap", + "reason": "uses child_process spawn path (spawnPromisified)" + }, + "test_08_should_work_within_a_loader": { + "category": "known-gap", + "reason": "requires child process loader/eval flags" + }, + "test_09_should_work_with_async_resolve_hook_registered": { + "category": "known-gap", + "reason": "requires child process loader/eval flags" + } + } + }, "parallel/test-fixed-queue.js": { "category": "node-internals", "reason": "requires --expose-internals and internal/fixed_queue", diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 492f80f6..2fcb90f6 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3100/4295 (72.2%) +**Primary compatibility (CI-enforced):** 3107/4304 (72.2%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3100 | 72.2% | 55.3% | 46.1% | -| 🧩 known gap | 1195 | 27.8% | 21.3% | 17.8% | -| 🚫 WASI-impossible (excluded) | 1153 | — | 20.6% | 17.1% | +| ✅ passing (runnable) | 3107 | 72.2% | 55.3% | 46.1% | +| 🧩 known gap | 1197 | 27.8% | 21.3% | 17.8% | +| 🚫 WASI-impossible (excluded) | 1153 | — | 20.5% | 17.1% | | ⚙️ engine difference (excluded) | 162 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | -| 🔒 Node.js internals (excluded) | 1121 | — | — | 16.7% | -| **Total** | **6731** | | | **100.0%** | +| 🔒 Node.js internals (excluded) | 1121 | — | — | 16.6% | +| **Total** | **6740** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3100/5610 (55.3%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3107/5619 (55.3%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 469 | 102 | 91 | 83 | 11 | 0 | 182 | 52.8% | 35.5% | +| other | 478 | 109 | 93 | 83 | 11 | 0 | 182 | 54.0% | 36.8% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -223,6 +223,7 @@ Secondary full-public compatibility, including public tests that are currently e | `test-eventtarget-memoryleakwarning.js` | 8 | 0 | 0 | 0 | 0 | 0 | 8 | | `test-eventtarget.js` | 61 | 0 | 0 | 0 | 0 | 0 | 61 | | `test-file.js` | 16 | 16 | 0 | 0 | 0 | 0 | 0 | +| `test-find-package-json.js` | 10 | 7 | 3 | 0 | 0 | 0 | 0 | | `test-fixed-queue.js` | 3 | 0 | 0 | 0 | 0 | 0 | 3 | | `test-freeze-intrinsics.js` | 4 | 0 | 4 | 0 | 0 | 0 | 0 | | `test-fs-access.js` | 3 | 0 | 0 | 0 | 0 | 0 | 3 | @@ -680,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1195) +### known gap (1197) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -798,6 +799,7 @@ Secondary full-public compatibility, including public tests that are currently e | process.permission worker-thread restrictions are incomplete | 2 | `parallel/test-permission-dc-worker-threads.js`, `parallel/test-permission-worker-threads-cli.js` | | process.report.writeReport and permission-model integration are missing | 2 | `parallel/test-permission-fs-write-report.js#block_00_block_00`, `parallel/test-permission-fs-write-report.js#block_01_block_01` | | promisified exec()/execFile() rejection errors miss stdout/stderr fields | 2 | `parallel/test-child-process-promisified.js#block_04_block_04`, `parallel/test-child-process-promisified.js#block_05_block_05` | +| requires child process loader/eval flags | 2 | `parallel/test-find-package-json.js#test_08_should_work_within_a_loader`, `parallel/test-find-package-json.js#test_09_should_work_with_async_resolve_hook_registered` | | spawn() timeout/killSignal behavior is not Node-compatible in WASM emulation | 2 | `parallel/test-child-process-spawn-timeout-kill-signal.js#block_00_block_00`, `parallel/test-child-process-spawn-timeout-kill-signal.js#block_01_block_01` | | tls.connect() stub throws instead of constructing a TLSSocket for allowHalfOpen option checks | 2 | `parallel/test-tls-connect-allow-half-open-option.js#block_00_block_00`, `parallel/test-tls-connect-allow-half-open-option.js#block_01_block_01` | | uncaughtExceptionMonitor event behavior in child_process flows is incomplete | 2 | `parallel/test-process-uncaught-exception-monitor.js#block_00_block_00`, `parallel/test-process-uncaught-exception-monitor.js#block_01_block_01` | @@ -1092,7 +1094,6 @@ Secondary full-public compatibility, including public tests that are currently e | execSync is ENOSYS-stubbed in WASM child_process emulation | 1 | `parallel/test-child-process-execsync-maxbuf.js#block_01_verify_that_a_maxbuffer_size_of_infinity_works` | | execSync is ENOSYS-stubbed; default maxBuffer behavior is unimplemented | 1 | `parallel/test-child-process-execsync-maxbuf.js#block_02_default_maxbuffer_size_is_1024_1024` | | execSync is ENOSYS-stubbed; maxBuffer overflow ENOBUFS behavior is unimplemented | 1 | `parallel/test-child-process-execsync-maxbuf.js#block_00_verify_that_an_error_is_returned_if_maxbuffer_is_surpassed` | -| findPackageJSON same-process API is implemented; remaining vendored test failures use child_process/loader eval paths | 1 | `parallel/test-find-package-json.js` | | fork() IPC child.send/process.send emulation is not implemented | 1 | `parallel/test-cli-eval.js#block_03_regression_test_for_https_github_com_nodejs_node_issues_1194` | | fork() abort-listener lifecycle for timeout+signal is incomplete | 1 | `parallel/test-child-process-fork-timeout-kill-signal.js#block_03_block_03` | | fork() args/options parsing and ERR_INVALID_ARG_TYPE behavior are incomplete | 1 | `parallel/test-child-process-fork-args.js#block_01_correctly_if_args_is_undefined_or_null` | @@ -1309,6 +1310,7 @@ Secondary full-public compatibility, including public tests that are currently e | uncaughtException handling after response end can stall socket cleanup | 1 | `parallel/test-http-end-throw-socket-handling.js` | | uncaughtException rethrow exit-code semantics are incomplete | 1 | `parallel/test-unhandled-exception-rethrow-error.js` | | uses V8 native %GetUndetectable() syntax which QuickJS cannot evaluate | 1 | `parallel/test-util-inspect.js#block_83_https_github_com_nodejs_node_issues_31889` | +| uses child_process spawn path (spawnPromisified) | 1 | `parallel/test-find-package-json.js#test_07_should_resolve_root_and_closest_package_json` | | util.MIMEType parsing API is not implemented | 1 | `parallel/test-mime-whatwg.js` | | util.MIMEType/util.MIMEParams are not implemented | 1 | `parallel/test-mime-api.js` | | util.debuglog formatting/callback behavior is not fully Node-compatible | 1 | `sequential/test-util-debug.js` | diff --git a/tests/node_compat_validate.rs b/tests/node_compat_validate.rs index 060da817..bf870312 100644 --- a/tests/node_compat_validate.rs +++ b/tests/node_compat_validate.rs @@ -19,7 +19,7 @@ mod common; use camino::Utf8Path; use common::js_subtest_parser::{ - SubtestDiscovery, discover_subtests, rewrite_for_block, rewrite_for_node_test, + SubtestDiscovery, discover_subtests_with_options, rewrite_for_block, rewrite_for_node_test, }; use common::{ CompiledTest, GolemPreparedComponent, NodeCompatCategory, TestInstance, @@ -43,6 +43,7 @@ struct ValidationCase { reason: Option, timeout_secs: u64, subtest_index: Option, + nested_node_test: bool, } #[derive(Debug)] @@ -162,6 +163,7 @@ fn load_cases() -> anyhow::Result> { reason: subtest.reason, timeout_secs: entry.timeout_secs, subtest_index: Some(subtest.index), + nested_node_test: entry.nested_node_test, }); } } else { @@ -172,6 +174,7 @@ fn load_cases() -> anyhow::Result> { reason: entry.reason, timeout_secs: entry.timeout_secs, subtest_index: None, + nested_node_test: entry.nested_node_test, }); } } @@ -221,10 +224,11 @@ async fn run_case( setup_node_compat_test_files(instance.temp_dir_path(), &case.path)?; if let Some(index) = case.subtest_index { - let (source, discovery) = load_split_source(&case.path, source_cache)?; + let (source, discovery) = + load_split_source(&case.path, case.nested_node_test, source_cache)?; let rewritten = match discovery { SubtestDiscovery::Block(blocks) => rewrite_for_block(source, blocks, index), - SubtestDiscovery::NodeTest(_) => rewrite_for_node_test(source, index), + SubtestDiscovery::NodeTest(tests) => rewrite_for_node_test(source, tests, index), SubtestDiscovery::None => source.to_string(), }; let test_filename = case.path.rsplit('/').next().unwrap_or(&case.path); @@ -270,15 +274,17 @@ async fn run_case( fn load_split_source<'a>( path: &str, + nested_node_test: bool, source_cache: &'a mut BTreeMap, ) -> anyhow::Result<(&'a str, &'a SubtestDiscovery)> { - if !source_cache.contains_key(path) { + let cache_key = format!("{path}#{nested_node_test}"); + if !source_cache.contains_key(&cache_key) { let source = fs::read_to_string(format!("tests/node_compat/suite/{path}"))?; - let discovery = discover_subtests(path, &source); - source_cache.insert(path.to_string(), (source, discovery)); + let discovery = discover_subtests_with_options(path, &source, nested_node_test); + source_cache.insert(cache_key.clone(), (source, discovery)); } let (source, discovery) = source_cache - .get(path) + .get(&cache_key) .expect("split source was just inserted"); Ok((source.as_str(), discovery)) } From 28f19bb142d723fd5a6080b8c9cef5a8e1ec5b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Mon, 22 Jun 2026 12:01:56 +0200 Subject: [PATCH 18/42] circular symlink handling --- .../skeleton/src/builtin/module.js | 108 ++++++++++-------- .../src/module-resolution.js | 35 ++++++ .../wit/module-resolution.wit | 1 + tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 15 ++- tests/runtime/module_resolution.rs | 17 +++ 6 files changed, 121 insertions(+), 57 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 4d345382..428ef9f9 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -523,6 +523,15 @@ function isBuiltinResolveTarget(id) { // Module cache: resolved absolute path -> Module object const moduleCache = Object.create(null); +function shouldPreserveSymlinks(isMainModuleLoad) { + return hasExecArgvFlag(isMainModuleLoad ? '--preserve-symlinks-main' : '--preserve-symlinks'); +} + +function toCjsCanonicalFilename(filename, isMainModuleLoad) { + if (shouldPreserveSymlinks(isMainModuleLoad)) return filename; + return fsModule.realpathSync.native(filename); +} + function tryReadFile(filename) { try { return fsModule.readFileSync(filename, 'utf8'); @@ -1878,11 +1887,14 @@ function requireEsmWithCacheGuard(mod, resolvedFilename) { } function loadModule(resolvedFilename, source, parentModule) { + const isMainModuleLoad = (!parentModule || parentModule === mainModule || parentModule.filename === '/') && typeof mainModule !== 'undefined' && mainModule.filename === '/'; + const filename = toCjsCanonicalFilename(resolvedFilename, isMainModuleLoad); + // Check cache - if (moduleCache[resolvedFilename]) { - const cached = moduleCache[resolvedFilename]; + if (moduleCache[filename]) { + const cached = moduleCache[filename]; if (cached.__wasmRequireEsmInProgress) { - const err = new Error('Cannot require() ES Module ' + resolvedFilename + ' in a cycle.'); + const err = new Error('Cannot require() ES Module ' + filename + ' in a cycle.'); err.code = 'ERR_REQUIRE_CYCLE_MODULE'; throw err; } @@ -1893,69 +1905,69 @@ function loadModule(resolvedFilename, source, parentModule) { } let mod; - if ((!parentModule || parentModule === mainModule || parentModule.filename === '/') && typeof mainModule !== 'undefined' && mainModule.filename === '/') { + if (isMainModuleLoad) { mod = mainModule; mod.id = '.'; - mod.filename = resolvedFilename; - mod.path = pathModule.dirname(resolvedFilename); + mod.filename = filename; + mod.path = pathModule.dirname(filename); mod.exports = {}; mod.loaded = false; mod.parent = null; mod.children = []; - mod.paths = _nodeModulePaths(pathModule.dirname(resolvedFilename)); + mod.paths = _nodeModulePaths(pathModule.dirname(filename)); if (globalThis.process) { globalThis.process.mainModule = mod; } } else { mod = { - id: resolvedFilename, - filename: resolvedFilename, - path: pathModule.dirname(resolvedFilename), + id: filename, + filename: filename, + path: pathModule.dirname(filename), exports: {}, loaded: false, parent: parentModule || null, children: [], - paths: _nodeModulePaths(pathModule.dirname(resolvedFilename)), + paths: _nodeModulePaths(pathModule.dirname(filename)), }; } // Cache before executing (handles circular dependencies) - moduleCache[resolvedFilename] = mod; + moduleCache[filename] = mod; if (parentModule && parentModule.children) { parentModule.children.push(mod); } // Check for custom extension handler - const ext = findLongestRegisteredExtension(resolvedFilename); + const ext = findLongestRegisteredExtension(filename); const handler = requireExtensions[ext]; if (handler && !_defaultExtHandlers.has(handler)) { try { - handler(mod, resolvedFilename); + handler(mod, filename); } catch (err) { - delete moduleCache[resolvedFilename]; + delete moduleCache[filename]; throw err; } - } else if (resolvedFilename.endsWith('.node')) { - delete moduleCache[resolvedFilename]; - throw new Error("Native .node modules are not supported in WASM: '" + resolvedFilename + "'"); - } else if (resolvedFilename.endsWith('.json')) { + } else if (filename.endsWith('.node')) { + delete moduleCache[filename]; + throw new Error("Native .node modules are not supported in WASM: '" + filename + "'"); + } else if (filename.endsWith('.json')) { try { mod.exports = JSON.parse(source); } catch (e) { - delete moduleCache[resolvedFilename]; - const err = new SyntaxError(resolvedFilename + ': ' + e.message); + delete moduleCache[filename]; + const err = new SyntaxError(filename + ': ' + e.message); err.code = 'ERR_INVALID_JSON'; throw err; } } else { - const isEsm = resolvedFilename.endsWith('.mjs') || - (resolvedFilename.endsWith('.js') && getPackageScopeType(resolvedFilename) === 'module'); + const isEsm = filename.endsWith('.mjs') || + (filename.endsWith('.js') && getPackageScopeType(filename) === 'module'); if (isEsm && hasExecArgvFlag('--no-experimental-require-module')) { - delete moduleCache[resolvedFilename]; + delete moduleCache[filename]; const esmErr = new Error( - "require() of ES Module " + resolvedFilename + " not supported. " + - "Instead change the require of " + resolvedFilename + " to a dynamic " + + "require() of ES Module " + filename + " not supported. " + + "Instead change the require of " + filename + " to a dynamic " + "import() which is available in all CommonJS modules." ); esmErr.code = 'ERR_REQUIRE_ESM'; @@ -1963,19 +1975,19 @@ function loadModule(resolvedFilename, source, parentModule) { } if (isEsm) { try { - mod.exports = requireEsmWithCacheGuard(mod, resolvedFilename); + mod.exports = requireEsmWithCacheGuard(mod, filename); } catch (err) { - delete moduleCache[resolvedFilename]; + delete moduleCache[filename]; throw err; } } else { - const dirname = pathModule.dirname(resolvedFilename); + const dirname = pathModule.dirname(filename); const childRequire = makeRequire(dirname, mod); let compiledFn; let cjsSyntaxError = null; - const cjsWrapperRequireRedeclaration = !resolvedFilename.endsWith('.cjs') && hasCjsWrapperRequireRedeclaration(source); + const cjsWrapperRequireRedeclaration = !filename.endsWith('.cjs') && hasCjsWrapperRequireRedeclaration(source); try { - compiledFn = compileCjs(resolvedFilename, source); + compiledFn = compileCjs(filename, source); } catch (err) { // Normalize QuickJS SyntaxError messages for ESM keywords in CJS context if (err && err.name === 'SyntaxError') { @@ -1984,46 +1996,46 @@ function loadModule(resolvedFilename, source, parentModule) { markAsSyntaxError(err); } // For .js files (not .cjs), detect ESM syntax and fall back to ESM loading - if (!resolvedFilename.endsWith('.cjs') && err && err.name === 'SyntaxError') { + if (!filename.endsWith('.cjs') && err && err.name === 'SyntaxError') { cjsSyntaxError = err; } else { - delete moduleCache[resolvedFilename]; - maybeSetArrowMessageOnSyntaxError(err, resolvedFilename, source); + delete moduleCache[filename]; + maybeSetArrowMessageOnSyntaxError(err, filename, source); throw err; } } if (cjsSyntaxError || cjsWrapperRequireRedeclaration) { if (hasExecArgvFlag('--no-experimental-require-module') && cjsSyntaxError) { - delete moduleCache[resolvedFilename]; - maybeSetArrowMessageOnSyntaxError(cjsSyntaxError, resolvedFilename, source); + delete moduleCache[filename]; + maybeSetArrowMessageOnSyntaxError(cjsSyntaxError, filename, source); throw cjsSyntaxError; } // SyntaxError in a .js file — try loading as ESM (entry point detection) try { - mod.exports = requireEsmWithCacheGuard(mod, resolvedFilename); + mod.exports = requireEsmWithCacheGuard(mod, filename); } catch (esmErr) { - delete moduleCache[resolvedFilename]; + delete moduleCache[filename]; if (looksLikeEsmSource(source) || cjsWrapperRequireRedeclaration) { normalizeEsmSyntaxError(esmErr); throw esmErr; } // ESM loading also failed — throw the original CJS SyntaxError - maybeSetArrowMessageOnSyntaxError(cjsSyntaxError, resolvedFilename, source); + maybeSetArrowMessageOnSyntaxError(cjsSyntaxError, filename, source); throw cjsSyntaxError; } } else if (compiledFn) { const previousModuleContext = globalThis.__wasm_rquickjs_current_module; globalThis.__wasm_rquickjs_current_module = { - filename: resolvedFilename, + filename: filename, source: source }; const previousCjsImportDir = globalThis.__wasm_rquickjs_cjs_import_dir; globalThis.__wasm_rquickjs_cjs_import_dir = dirname; try { - compiledFn(mod.exports, childRequire, mod, resolvedFilename, dirname); + compiledFn(mod.exports, childRequire, mod, filename, dirname); } catch (err) { - delete moduleCache[resolvedFilename]; - maybeSetArrowMessageOnSyntaxError(err, resolvedFilename, source); + delete moduleCache[filename]; + maybeSetArrowMessageOnSyntaxError(err, filename, source); throw err; } finally { globalThis.__wasm_rquickjs_current_module = previousModuleContext; @@ -2288,14 +2300,14 @@ function makeRequire(parentDir, parentModule, parentFilenameOverride) { // Relative/absolute: resolve directly against the search path try { const resolved = resolveFilename(id, searchDir); - return resolved.filename; + return toCjsCanonicalFilename(resolved.filename, false); } catch (e) { // Try next path } } else { // Bare specifier: use node_modules resolution from search path const nmResolved = resolveFromNodeModules(id, searchDir, parentFilename); - if (nmResolved) return nmResolved.filename; + if (nmResolved) return toCjsCanonicalFilename(nmResolved.filename, false); } } const err = new Error("Cannot find module '" + id + "'"); @@ -2304,17 +2316,17 @@ function makeRequire(parentDir, parentModule, parentFilenameOverride) { } if (id === '.' || id === '..' || id.startsWith('./') || id.startsWith('../') || id.startsWith('/')) { const resolved = resolveFilename(id, parentDir); - return resolved.filename; + return toCjsCanonicalFilename(resolved.filename, false); } if (id.startsWith('#')) { const importsResolved = resolvePackageImports(id, parentDir, cjsPackageConditions); if (importsResolved.builtin) return importsResolved.builtin; - return importsResolved.filename; + return toCjsCanonicalFilename(importsResolved.filename, false); } // node_modules resolution for bare specifiers const nmResolved = resolveFromNodeModules(id, parentDir, parentFilename); if (nmResolved) { - return nmResolved.filename; + return toCjsCanonicalFilename(nmResolved.filename, false); } const err = new Error("Cannot find module '" + id + "'"); err.code = 'MODULE_NOT_FOUND'; diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index b65cfa4a..f2c81dda 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -1054,3 +1054,38 @@ export const testRequireEsmCycleGuards = async () => { throw error; } }; + +export const testCjsSymlinkCircularCache = async () => { + try { + const { createRequire } = await import('node:module'); + const root = '/cjs-symlink-cycle-app'; + const moduleA = `${root}/node_modules/moduleA`; + const moduleB = `${root}/node_modules/moduleB`; + const moduleALink = `${moduleB}/node_modules/moduleA`; + const moduleBLink = `${moduleA}/node_modules/moduleB`; + + fs.mkdirSync(`${moduleA}/node_modules`, { recursive: true }); + fs.mkdirSync(`${moduleB}/node_modules`, { recursive: true }); + fs.symlinkSync(moduleA, moduleALink); + fs.symlinkSync(moduleB, moduleBLink); + fs.writeFileSync(`${root}/index.cjs`, 'module.exports = require("moduleA");'); + fs.writeFileSync(`${moduleA}/index.js`, 'module.exports = { b: require("moduleB") };'); + fs.writeFileSync(`${moduleB}/index.js`, 'module.exports = { a: require("moduleA") };'); + + const require = createRequire(`${root}/index.cjs`); + const obj = require(`${root}/index.cjs`); + assert.ok(obj); + assert.ok(obj.b); + assert.ok(obj.b.a); + assert.ok(!obj.b.a.b); + + const cacheKeys = Object.keys(require.cache).filter((key) => key.startsWith(root)); + assert.strictEqual(cacheKeys.some((key) => key.includes('/moduleA/node_modules/moduleB/')), false); + assert.strictEqual(cacheKeys.some((key) => key.includes('/moduleB/node_modules/moduleA/')), false); + + return true; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index 2cb82474..a8a80bcd 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -13,4 +13,5 @@ world module-resolution { export test-require-esm-error-handling: func() -> bool; export test-require-esm-tla-retry: func() -> bool; export test-require-esm-cycle-guards: func() -> bool; + export test-cjs-symlink-circular-cache: func() -> bool; } diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index cd249e29..09fdba6b 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -6807,7 +6807,7 @@ "parallel/test-mime-whatwg.js": { "category": "known-gap", "reason": "util.MIMEType parsing API is not implemented" }, "parallel/test-module-builtin.js": {}, "parallel/test-module-children.js": {}, - "parallel/test-module-circular-symlinks.js": { "category": "known-gap", "reason": "module cache behavior with circular symlinked dependencies is not Node-compatible" }, + "parallel/test-module-circular-symlinks.js": {}, "parallel/test-module-create-require-multibyte.js": { "split": true, "subtests": { diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 2fcb90f6..975bb5ef 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -1,6 +1,6 @@ # Node.js v22.14.0 Compatibility Inventory -Generated: 2026-06-18 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) +Generated: 2026-06-22 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) This report is generated from `config.jsonc` only. It does **not** run the vendored tests itself. Entries classified as `runnable` are reported as passing because the `node_compat` PR test executes runnable entries and fails CI if any of them fail. @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3107/4304 (72.2%) +**Primary compatibility (CI-enforced):** 3108/4304 (72.2%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3107 | 72.2% | 55.3% | 46.1% | -| 🧩 known gap | 1197 | 27.8% | 21.3% | 17.8% | +| ✅ passing (runnable) | 3108 | 72.2% | 55.3% | 46.1% | +| 🧩 known gap | 1196 | 27.8% | 21.3% | 17.7% | | 🚫 WASI-impossible (excluded) | 1153 | — | 20.5% | 17.1% | | ⚙️ engine difference (excluded) | 162 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1121 | — | — | 16.6% | | **Total** | **6740** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3107/5619 (55.3%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3108/5619 (55.3%)**. ## Inventory by Module @@ -53,7 +53,7 @@ Secondary full-public compatibility, including public tests that are currently e | http | 898 | 243 | 306 | 267 | 2 | 0 | 80 | 44.3% | 29.7% | | inspector | 95 | 1 | 0 | 93 | 0 | 0 | 1 | 100.0% | 1.1% | | internal | 53 | 1 | 0 | 0 | 0 | 0 | 52 | 100.0% | 100.0% | -| module | 184 | 120 | 44 | 7 | 1 | 0 | 12 | 73.2% | 69.8% | +| module | 184 | 121 | 43 | 7 | 1 | 0 | 12 | 73.8% | 70.3% | | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1197) +### known gap (1196) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -1149,7 +1149,6 @@ Secondary full-public compatibility, including public tests that are currently e | maxRequestsPerSocket keep-alive header behavior (Keep-Alive/Connection framing) is not Node-compatible | 1 | `parallel/test-http-keep-alive-max-requests.js` | | missing importModuleDynamically callback does not raise ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING | 1 | `parallel/test-vm-no-dynamic-import-callback.js` | | mixed headersTimeout/requestTimeout handling is not Node-compatible | 1 | `sequential/test-http-server-request-timeouts-mixed.js` | -| module cache behavior with circular symlinked dependencies is not Node-compatible | 1 | `parallel/test-module-circular-symlinks.js` | | moveMessagePortToContext cross-context object/prototype semantics are incomplete | 1 | `parallel/test-worker-message-port-move.js` | | native rquickjs URL accessors report Rust conversion errors for invalid receivers before JS can normalize them to V8/Web IDL private-member messages | 1 | `parallel/test-whatwg-url-invalidthis.js` | | native rquickjs URL class property enumeration order does not match Web IDL order and descriptors are not fully configurable from JS | 1 | `parallel/test-whatwg-url-custom-properties.js` | diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index 76b9506d..39594666 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -214,3 +214,20 @@ async fn require_esm_cycle_guards( assert_eq!(r, Some(Val::Bool(true))); Ok(()) } + +#[test] +async fn cjs_symlink_circular_cache( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-symlink-circular-cache", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} From 0846a26f308ecb5504787b6679805af0107a45ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Mon, 22 Jun 2026 12:53:10 +0200 Subject: [PATCH 19/42] improve CJS module loading compatibility --- .../skeleton/src/builtin/module.js | 95 ++++++++++++++++--- .../src/module-resolution.js | 54 +++++++++++ .../wit/module-resolution.wit | 1 + tests/runtime/module_resolution.rs | 17 ++++ 4 files changed, 153 insertions(+), 14 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 428ef9f9..d18fa0ac 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -619,16 +619,11 @@ function loadAsDirectory(candidate, id, parentDir, seen) { const pkgJsonPath = pathModule.join(candidate, 'package.json'); const pkgJson = tryReadFile(pkgJsonPath); + let invalidMain = null; if (pkgJson !== null) { + let pkg; try { - const pkg = JSON.parse(pkgJson); - if (Object.prototype.hasOwnProperty.call(pkg, 'main') && typeof pkg.main === 'string' && pkg.main.length > 0) { - const mainPath = pathModule.resolve(candidate, pkg.main); - let resolved = loadAsFile(mainPath, false); - if (resolved !== null) return resolved; - resolved = loadAsDirectory(mainPath, id, parentDir, seen); - if (resolved !== null) return resolved; - } + pkg = JSON.parse(pkgJson); } catch (e) { const pkgErr = new Error( 'Invalid package config ' + pkgJsonPath + @@ -638,19 +633,43 @@ function loadAsDirectory(candidate, id, parentDir, seen) { pkgErr.code = 'ERR_INVALID_PACKAGE_CONFIG'; throw pkgErr; } + + if (Object.prototype.hasOwnProperty.call(pkg, 'main') && typeof pkg.main === 'string' && pkg.main.length > 0) { + const mainPath = pathModule.resolve(candidate, pkg.main); + let resolved = loadAsFile(mainPath, false); + if (resolved !== null) return resolved; + resolved = loadAsDirectory(mainPath, id, parentDir, seen); + if (resolved !== null) return resolved; + invalidMain = { field: pkg.main, path: mainPath }; + } } - let content = tryReadFile(pathModule.join(candidate, 'index.js')); - if (content !== null) { - return { filename: pathModule.join(candidate, 'index.js'), content: content }; + const indexResolved = loadAsFile(pathModule.join(candidate, 'index'), false); + if (indexResolved !== null) { + emitInvalidMainWarning(pkgJsonPath, invalidMain); + return indexResolved; } - content = tryReadFile(pathModule.join(candidate, 'index.json')); - if (content !== null) { - return { filename: pathModule.join(candidate, 'index.json'), content: content }; + if (invalidMain !== null) { + const err = new Error("Cannot find module '" + invalidMain.path + "'. Please verify that the package.json has a valid \"main\" entry"); + err.code = 'MODULE_NOT_FOUND'; + err.path = pkgJsonPath; + err.requestPath = id; + throw err; } return null; } +function emitInvalidMainWarning(pkgJsonPath, invalidMain) { + if (invalidMain === null) return; + const processObject = globalThis.process; + if (!processObject || typeof processObject.emitWarning !== 'function') return; + processObject.emitWarning( + "Invalid 'main' field in '" + pathModule.toNamespacedPath(pkgJsonPath) + "' of '" + invalidMain.field + "'. Please either fix that or report it to the module author", + 'DeprecationWarning', + 'DEP0128' + ); +} + const cjsPackageConditions = new Set(['golem', 'node', 'require', 'module-sync', 'default']); const esmPackageConditions = new Set(['golem', 'node', 'module-sync', 'import', 'default']); const packageTargetNoMatch = { __packageTargetNoMatch: true }; @@ -1853,6 +1872,9 @@ function wrap(script) { } function compileCjs(filename, source) { + if (source.length > 0 && source.charCodeAt(0) === 0xFEFF) { + source = source.slice(1); + } // Strip shebang if (source.length > 1 && source.charCodeAt(0) === 0x23 && source.charCodeAt(1) === 0x21) { source = '//' + source; @@ -1869,6 +1891,42 @@ function compileCjs(filename, source) { return _evalWithFilename(wrappedSource, filename); } +function compileModuleInto(mod, source, filename) { + filename = filename || mod.filename; + const dirname = pathModule.dirname(filename); + const childRequire = makeRequire(dirname, mod); + const compiledFn = compileCjs(filename, String(source)); + const previousModuleContext = globalThis.__wasm_rquickjs_current_module; + globalThis.__wasm_rquickjs_current_module = { + filename: filename, + source: String(source) + }; + const previousCjsImportDir = globalThis.__wasm_rquickjs_cjs_import_dir; + globalThis.__wasm_rquickjs_cjs_import_dir = dirname; + try { + return compiledFn(mod.exports, childRequire, mod, filename, dirname); + } finally { + globalThis.__wasm_rquickjs_current_module = previousModuleContext; + if (previousCjsImportDir !== undefined) { + globalThis.__wasm_rquickjs_cjs_import_dir = previousCjsImportDir; + } else { + delete globalThis.__wasm_rquickjs_cjs_import_dir; + } + } +} + +function makeModuleCompile(mod) { + return function _compile(content, filename) { + return compileModuleInto(mod, content, filename || mod.filename); + }; +} + +function makeModuleRequire(mod) { + return function require(id) { + return makeRequire(pathModule.dirname(mod.filename), mod)(id); + }; +} + function requireEsmWithCacheGuard(mod, resolvedFilename) { throwIfRequireEsmGraphCycle(resolvedFilename); const markedGraph = markRequireEsmGraph(resolvedFilename); @@ -1915,6 +1973,8 @@ function loadModule(resolvedFilename, source, parentModule) { mod.parent = null; mod.children = []; mod.paths = _nodeModulePaths(pathModule.dirname(filename)); + mod._compile = makeModuleCompile(mod); + mod.require = makeModuleRequire(mod); if (globalThis.process) { globalThis.process.mainModule = mod; } @@ -1929,6 +1989,8 @@ function loadModule(resolvedFilename, source, parentModule) { children: [], paths: _nodeModulePaths(pathModule.dirname(filename)), }; + mod._compile = makeModuleCompile(mod); + mod.require = makeModuleRequire(mod); } // Cache before executing (handles circular dependencies) @@ -1953,6 +2015,9 @@ function loadModule(resolvedFilename, source, parentModule) { throw new Error("Native .node modules are not supported in WASM: '" + filename + "'"); } else if (filename.endsWith('.json')) { try { + if (source.length > 0 && source.charCodeAt(0) === 0xFEFF) { + source = source.slice(1); + } mod.exports = JSON.parse(source); } catch (e) { delete moduleCache[filename]; @@ -2061,6 +2126,8 @@ const mainModule = { parent: null, children: [], }; +mainModule._compile = makeModuleCompile(mainModule); +mainModule.require = makeModuleRequire(mainModule); function splitPackageName(id) { // Scoped packages: @scope/pkg or @scope/pkg/subpath diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index f2c81dda..55f46544 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -1089,3 +1089,57 @@ export const testCjsSymlinkCircularCache = async () => { throw error; } }; + +export const testCjsNodeModuleLoadingCompat = async () => { + try { + const { createRequire } = await import('node:module'); + const root = '/cjs-node-module-loading-app'; + const require = createRequire(`${root}/entry.cjs`); + + fs.mkdirSync(`${root}/missing-main-with-index`, { recursive: true }); + fs.writeFileSync(`${root}/missing-main-with-index/package.json`, JSON.stringify({ main: 'missing.js' })); + fs.writeFileSync(`${root}/missing-main-with-index/index.js`, 'module.exports = { ok: true };'); + assert.deepStrictEqual(require(`${root}/missing-main-with-index`), { ok: true }); + + fs.mkdirSync(`${root}/missing-main-no-index`, { recursive: true }); + fs.writeFileSync(`${root}/missing-main-no-index/package.json`, JSON.stringify({ main: 'missing.js' })); + assert.throws(() => require(`${root}/missing-main-no-index`), { + code: 'MODULE_NOT_FOUND', + path: `${root}/missing-main-no-index/package.json`, + requestPath: `${root}/missing-main-no-index`, + }); + + require.extensions['.test'] = function(module, filename) { + const content = fs.readFileSync(filename, 'utf8').replace('VALUE', 'module.exports.value'); + module._compile(content, filename); + }; + fs.writeFileSync(`${root}/custom.test`, 'VALUE = 42;'); + assert.strictEqual(require(`${root}/custom`).value, 42); + + fs.mkdirSync(`${root}/parent/child/node_modules/target`, { recursive: true }); + fs.writeFileSync(`${root}/parent/child/node_modules/target/index.js`, 'module.exports = { from: "child" };'); + fs.writeFileSync(`${root}/parent/child/index.js`, 'exports.module = module; exports.loaded = require("target");'); + fs.writeFileSync(`${root}/parent/index.js`, [ + 'const child = require("./child");', + 'module.exports = { fromModuleRequire: child.module.require("target"), fromChildRequire: child.loaded };', + ].join('\n')); + const parent = require(`${root}/parent`); + assert.deepStrictEqual(parent.fromModuleRequire, { from: 'child' }); + assert.strictEqual(parent.fromModuleRequire, parent.fromChildRequire); + + fs.writeFileSync(`${root}/bom.js`, '\uFEFFmodule.exports = 42;'); + fs.writeFileSync(`${root}/bom.json`, '\uFEFF42'); + assert.strictEqual(require(`${root}/bom.js`), 42); + assert.strictEqual(require(`${root}/bom.json`), 42); + + require.extensions['.reg'] = require.extensions['.js']; + fs.mkdirSync(`${root}/dir-index-reg`, { recursive: true }); + fs.writeFileSync(`${root}/dir-index-reg/index.reg`, 'exports.value = "index.reg";'); + assert.strictEqual(require(`${root}/dir-index-reg`).value, 'index.reg'); + + return true; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index a8a80bcd..c48fa2f3 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -14,4 +14,5 @@ world module-resolution { export test-require-esm-tla-retry: func() -> bool; export test-require-esm-cycle-guards: func() -> bool; export test-cjs-symlink-circular-cache: func() -> bool; + export test-cjs-node-module-loading-compat: func() -> bool; } diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index 39594666..add3dd96 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -231,3 +231,20 @@ async fn cjs_symlink_circular_cache( assert_eq!(r, Some(Val::Bool(true))); Ok(()) } + +#[test] +async fn cjs_node_module_loading_compat( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-node-module-loading-compat", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} From 2d7687ca02a0e617facd363b2e6b095fe0155ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Mon, 22 Jun 2026 13:21:17 +0200 Subject: [PATCH 20/42] cover nested CJS dependency cache shape --- .../src/module-resolution.js | 56 +++++++++++++++++++ .../wit/module-resolution.wit | 1 + tests/runtime/module_resolution.rs | 17 ++++++ 3 files changed, 74 insertions(+) diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 55f46544..276c75e3 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -1143,3 +1143,59 @@ export const testCjsNodeModuleLoadingCompat = async () => { throw error; } }; + +export const testCjsNestedDependencyCacheShape = async () => { + try { + const { createRequire } = await import('node:module'); + const root = '/cjs-nested-dependency-cache-app'; + + fs.mkdirSync(`${root}/b/package`, { recursive: true }); + fs.writeFileSync(`${root}/b/package/index.js`, [ + 'exports.hello = "world";', + ].join('\n')); + fs.writeFileSync(`${root}/b/d.js`, [ + 'let value = "D";', + 'exports.D = function() { return value; };', + ].join('\n')); + fs.writeFileSync(`${root}/b/c.js`, [ + 'const d = require("./d");', + 'const package = require("./package");', + 'if (package.hello !== "world") throw new Error("bad package");', + 'let value = "C";', + 'exports.SomeClass = function() {};', + 'exports.C = function() { return value; };', + 'exports.D = function() { return d.D(); };', + ].join('\n')); + fs.writeFileSync(`${root}/a.js`, [ + 'const c = require("./b/c");', + 'let value = "A";', + 'exports.SomeClass = c.SomeClass;', + 'exports.A = function() { return value; };', + 'exports.C = function() { return c.C(); };', + 'exports.D = function() { return c.D(); };', + 'exports.number = 42;', + ].join('\n')); + + const require = createRequire(`${root}/entry.cjs`); + const withExtension = require(`${root}/a.js`); + const withoutExtension = require(`${root}/a`); + const c = require(`${root}/b/c`); + const d = require(`${root}/b/d`); + + assert.strictEqual(withExtension, withoutExtension); + assert.strictEqual(withExtension.number, 42); + assert.strictEqual(withExtension.A(), 'A'); + assert.strictEqual(withExtension.C(), 'C'); + assert.strictEqual(withExtension.D(), 'D'); + assert.ok(new withExtension.SomeClass() instanceof c.SomeClass); + assert.strictEqual(d.D(), 'D'); + + const aCacheKeys = Object.keys(require.cache).filter((key) => key === `${root}/a.js`); + assert.deepStrictEqual(aCacheKeys, [`${root}/a.js`]); + + return true; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index c48fa2f3..0d659bcc 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -15,4 +15,5 @@ world module-resolution { export test-require-esm-cycle-guards: func() -> bool; export test-cjs-symlink-circular-cache: func() -> bool; export test-cjs-node-module-loading-compat: func() -> bool; + export test-cjs-nested-dependency-cache-shape: func() -> bool; } diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index add3dd96..628360df 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -248,3 +248,20 @@ async fn cjs_node_module_loading_compat( assert_eq!(r, Some(Val::Bool(true))); Ok(()) } + +#[test] +async fn cjs_nested_dependency_cache_shape( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-nested-dependency-cache-shape", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} From ab6d05d28ed79f9710411463f06e4a06a71e6b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Mon, 22 Jun 2026 13:29:36 +0200 Subject: [PATCH 21/42] cover CJS module children graph behavior --- .../src/module-resolution.js | 53 +++++++++++++++++++ .../wit/module-resolution.wit | 1 + tests/runtime/module_resolution.rs | 17 ++++++ 3 files changed, 71 insertions(+) diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 276c75e3..0b839926 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -1199,3 +1199,56 @@ export const testCjsNestedDependencyCacheShape = async () => { throw error; } }; + +export const testCjsModuleChildrenGraph = async () => { + try { + const { createRequire } = await import('node:module'); + const root = '/cjs-module-children-app'; + + fs.mkdirSync(`${root}/nested`, { recursive: true }); + fs.writeFileSync(`${root}/nested/grandchild.js`, 'exports.name = "grandchild";'); + fs.writeFileSync(`${root}/nested/child.js`, [ + 'exports.grandchild = require("./grandchild");', + 'exports.module = module;', + ].join('\n')); + fs.writeFileSync(`${root}/data.json`, JSON.stringify({ name: 'json' })); + fs.writeFileSync(`${root}/custom.test`, 'module.exports.name = "custom";'); + fs.writeFileSync(`${root}/module-require-target.js`, 'exports.name = "module-require-target";'); + fs.writeFileSync(`${root}/entry.js`, [ + 'require.extensions[".test"] = function(mod, filename) {', + ' mod._compile(require("fs").readFileSync(filename, "utf8"), filename);', + '};', + 'exports.child = require("./nested/child");', + 'exports.childAgain = require("./nested/child");', + 'exports.json = require("./data.json");', + 'exports.custom = require("./custom.test");', + 'exports.moduleRequireTarget = module.require("./module-require-target");', + 'exports.module = module;', + ].join('\n')); + + const require = createRequire(`${root}/main.cjs`); + const entry = require(`${root}/entry.js`); + assert.strictEqual(entry.child, entry.childAgain); + assert.strictEqual(entry.child.grandchild.name, 'grandchild'); + assert.strictEqual(entry.json.name, 'json'); + assert.strictEqual(entry.custom.name, 'custom'); + assert.strictEqual(entry.moduleRequireTarget.name, 'module-require-target'); + + const childIds = entry.module.children.map((child) => child.filename); + assert.deepStrictEqual(childIds, [ + `${root}/nested/child.js`, + `${root}/data.json`, + `${root}/custom.test`, + `${root}/module-require-target.js`, + ]); + assert.strictEqual(childIds.filter((filename) => filename === `${root}/nested/child.js`).length, 1); + + const nestedChildIds = entry.child.module.children.map((child) => child.filename); + assert.deepStrictEqual(nestedChildIds, [`${root}/nested/grandchild.js`]); + + return true; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index 0d659bcc..6773e8b8 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -16,4 +16,5 @@ world module-resolution { export test-cjs-symlink-circular-cache: func() -> bool; export test-cjs-node-module-loading-compat: func() -> bool; export test-cjs-nested-dependency-cache-shape: func() -> bool; + export test-cjs-module-children-graph: func() -> bool; } diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index 628360df..5a01d852 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -265,3 +265,20 @@ async fn cjs_nested_dependency_cache_shape( assert_eq!(r, Some(Val::Bool(true))); Ok(()) } + +#[test] +async fn cjs_module_children_graph( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-module-children-graph", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} From 4cc37dc8277607417b7d75147e0ee094eff72b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Tue, 23 Jun 2026 11:04:02 +0200 Subject: [PATCH 22/42] tighten ambiguous JS module fallback --- .../wasm-rquickjs/skeleton/src/builtin/module.js | 15 ++++++++++++--- .../module-resolution/src/module-resolution.js | 4 ++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index d18fa0ac..6aadedb0 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -1326,6 +1326,15 @@ function previousSignificantChar(source, pos) { return -1; } +function previousSignificantCharOnSameLine(source, pos) { + for (let i = pos - 1; i >= 0; i--) { + const ch = source.charCodeAt(i); + if (ch === 0x0a || ch === 0x0d) return -1; + if (ch !== 0x20 && ch !== 0x09) return ch; + } + return -1; +} + function isRegexLiteralStartInSource(source, pos) { const prev = previousSignificantChar(source, pos); return prev === -1 || '({[=,:;!?&|+-*~^%>'.indexOf(String.fromCharCode(prev)) >= 0; @@ -1418,7 +1427,7 @@ function scanSourceCodePositions(source, options, visitor) { } function isStaticExportSyntax(source, pos) { - if (previousSignificantChar(source, pos) === 0x2e) return false; // member property + if (previousSignificantCharOnSameLine(source, pos) === 0x2e) return false; // member property const next = skipWhitespace(source, pos + 6); if (source.charCodeAt(next) === 0x3a) return false; // object label/property const ch = source.charCodeAt(next); @@ -1432,7 +1441,7 @@ function isStaticExportSyntax(source, pos) { } function isStaticImportSyntax(source, pos) { - if (previousSignificantChar(source, pos) === 0x2e) return false; // member property + if (previousSignificantCharOnSameLine(source, pos) === 0x2e) return false; // member property const next = skipWhitespace(source, pos + 6); if (source.charCodeAt(next) === 0x28 || source.charCodeAt(next) === 0x3a) return false; // dynamic import(...) or property label const ch = source.charCodeAt(next); @@ -2061,7 +2070,7 @@ function loadModule(resolvedFilename, source, parentModule) { markAsSyntaxError(err); } // For .js files (not .cjs), detect ESM syntax and fall back to ESM loading - if (!filename.endsWith('.cjs') && err && err.name === 'SyntaxError') { + if (!filename.endsWith('.cjs') && err && err.name === 'SyntaxError' && (looksLikeEsmSource(source) || cjsWrapperRequireRedeclaration)) { cjsSyntaxError = err; } else { delete moduleCache[filename]; diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 0b839926..e46390ae 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -1129,8 +1129,12 @@ export const testCjsNodeModuleLoadingCompat = async () => { fs.writeFileSync(`${root}/bom.js`, '\uFEFFmodule.exports = 42;'); fs.writeFileSync(`${root}/bom.json`, '\uFEFF42'); + fs.writeFileSync(`${root}/bom-shebang-shebang.js`, '\uFEFF#!shebang\n#!shebang\nmodule.exports = 1;'); + fs.writeFileSync(`${root}/shebang-bom.js`, '#!shebang\n\uFEFFmodule.exports = 42;'); assert.strictEqual(require(`${root}/bom.js`), 42); assert.strictEqual(require(`${root}/bom.json`), 42); + assert.throws(() => require(`${root}/bom-shebang-shebang.js`), { name: 'SyntaxError' }); + assert.strictEqual(require(`${root}/shebang-bom.js`), 42); require.extensions['.reg'] = require.extensions['.js']; fs.mkdirSync(`${root}/dir-index-reg`, { recursive: true }); From a00912dd0c9ee1d46fd6663c5de6dbb7ce92b514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Tue, 23 Jun 2026 18:41:50 +0200 Subject: [PATCH 23/42] update golden files: add new module declarations and export tests --- .../generated_types_cjs-require_exports.d.ts | 3 +++ ...erated_types_module-resolution_exports.d.ts | 18 ++++++++++++++++++ ..._types_node-modules-app-runner_exports.d.ts | 3 +++ 3 files changed, 24 insertions(+) create mode 100644 tests/goldenfiles/generated_types_module-resolution_exports.d.ts create mode 100644 tests/goldenfiles/generated_types_node-modules-app-runner_exports.d.ts diff --git a/tests/goldenfiles/generated_types_cjs-require_exports.d.ts b/tests/goldenfiles/generated_types_cjs-require_exports.d.ts index 5b178879..8d188d65 100644 --- a/tests/goldenfiles/generated_types_cjs-require_exports.d.ts +++ b/tests/goldenfiles/generated_types_cjs-require_exports.d.ts @@ -8,4 +8,7 @@ declare module 'cjs-require' { export function testRequireJson(): Promise; export function testRequireModuleExportsFunction(): Promise; export function testRequireModuleNotFound(): Promise; + export function testRequirePackageExports(): Promise; + export function testRequirePackageImports(): Promise; + export function testRequirePackageMapEdgeCases(): Promise; } diff --git a/tests/goldenfiles/generated_types_module-resolution_exports.d.ts b/tests/goldenfiles/generated_types_module-resolution_exports.d.ts new file mode 100644 index 00000000..4384ee86 --- /dev/null +++ b/tests/goldenfiles/generated_types_module-resolution_exports.d.ts @@ -0,0 +1,18 @@ +declare module 'module-resolution' { + export function testEsmPackageMapEdgeCases(): Promise; + export function testCjsDirectNamedExports(): Promise; + export function testCjsDefinePropertyNamedExports(): Promise; + export function testCjsReexportNamedExports(): Promise; + export function testCjsAnalyzerFalsePositiveGuards(): Promise; + export function testCjsSharedLoaderIdentity(): Promise; + export function testModuleSyntaxDetectionAndDiagnostics(): Promise; + export function testCjsPackageReexportNamedExports(): Promise; + export function testFindPackageJson(): Promise; + export function testRequireEsmErrorHandling(): Promise; + export function testRequireEsmTlaRetry(): Promise; + export function testRequireEsmCycleGuards(): Promise; + export function testCjsSymlinkCircularCache(): Promise; + export function testCjsNodeModuleLoadingCompat(): Promise; + export function testCjsNestedDependencyCacheShape(): Promise; + export function testCjsModuleChildrenGraph(): Promise; +} diff --git a/tests/goldenfiles/generated_types_node-modules-app-runner_exports.d.ts b/tests/goldenfiles/generated_types_node-modules-app-runner_exports.d.ts new file mode 100644 index 00000000..091dad2a --- /dev/null +++ b/tests/goldenfiles/generated_types_node-modules-app-runner_exports.d.ts @@ -0,0 +1,3 @@ +declare module 'node-modules-app-runner' { + export function runTest(testPath: string): Promise; +} From 8a7e9f9f53a09d77aeed67d7f95d76805aaadfd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Tue, 23 Jun 2026 19:42:31 +0200 Subject: [PATCH 24/42] stricter node baseline version check --- .github/workflows/ci.yaml | 2 + .nvmrc | 1 + tests/node_modules_apps/README.md | 16 ++++++ tests/runtime/node_modules_apps.rs | 89 ++++++++++++++++++++++++------ 4 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4fe6ede0..09aa4997 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -92,6 +92,8 @@ jobs: node-version: "22.14.0" - name: Runtime tests (group ${{ matrix.group }}/9) run: cargo test --test runtime $CI_WASMTIME_FORK_FEATURES -- --report-time --format ctrf --logfile target/ctrf.json ':tag:group${{ matrix.group }}' + env: + NODE_MODULES_APP_STRICT_NODE_BASELINE: "1" - name: Publish Test Report uses: ctrf-io/github-test-reporter@v1 if: always() diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..7d41c735 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.14.0 diff --git a/tests/node_modules_apps/README.md b/tests/node_modules_apps/README.md index 5ae978df..f4e2646c 100644 --- a/tests/node_modules_apps/README.md +++ b/tests/node_modules_apps/README.md @@ -100,6 +100,22 @@ For every runnable config entry, the harness: ## Commands +Node modules app tests are baselined against Node.js 22.14.0, matching CI and +the vendored Node.js compatibility suite. Use nvm before local runs: + +```sh +source ~/.nvm/nvm.sh +nvm install +nvm use +node --version +``` + +Local runs accept Node.js 22.14.0 or newer patch/minor releases within major +22, but print a warning when the exact baseline differs because Node-baselined +fixtures can change across minor releases. CI sets +`NODE_MODULES_APP_STRICT_NODE_BASELINE=1`, which requires exactly Node.js +22.14.0. + Run the node modules app suite after skeleton changes: ```sh diff --git a/tests/runtime/node_modules_apps.rs b/tests/runtime/node_modules_apps.rs index 30abe47d..691b53d7 100644 --- a/tests/runtime/node_modules_apps.rs +++ b/tests/runtime/node_modules_apps.rs @@ -4,6 +4,7 @@ use crate::common::{ }; use camino::{Utf8Path, Utf8PathBuf}; use camino_tempfile::Utf8TempDir; +use std::env; use std::fs; use std::process::Command; use std::sync::Arc; @@ -12,6 +13,10 @@ use test_r::{test_dep, test_gen}; use wasmtime::component::Val; const CONFIG_PATH: &str = "tests/node_modules_apps/config.jsonc"; +const NODE_MODULES_APP_BASELINE_MAJOR: u32 = 22; +const NODE_MODULES_APP_BASELINE_MINOR: u32 = 14; +const NODE_MODULES_APP_BASELINE_PATCH: u32 = 0; +const NODE_MODULES_APP_STRICT_BASELINE_ENV: &str = "NODE_MODULES_APP_STRICT_NODE_BASELINE"; #[test_dep(tagged_as = "node_modules_app_runner", scope = Cloneable)] async fn compiled_node_modules_app_runner() -> CompiledTest { @@ -52,7 +57,35 @@ fn prepare_node_modules_app(app_name: &str) -> anyhow::Result anyhow::Result<()> { +fn strict_node_baseline_enabled() -> bool { + env::var(NODE_MODULES_APP_STRICT_BASELINE_ENV) + .map(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +fn parse_node_version(version: &str) -> anyhow::Result<(u32, u32, u32)> { + let mut parts = version.trim().split('.'); + let major = parts + .next() + .ok_or_else(|| anyhow::anyhow!("missing Node.js major version"))? + .parse::()?; + let minor = parts + .next() + .ok_or_else(|| anyhow::anyhow!("missing Node.js minor version"))? + .parse::()?; + let patch = parts + .next() + .ok_or_else(|| anyhow::anyhow!("missing Node.js patch version"))? + .parse::()?; + Ok((major, minor, patch)) +} + +fn ensure_node_supports_require_esm() -> anyhow::Result { let output = Command::new("node") .arg("-p") .arg("process.versions.node") @@ -62,26 +95,50 @@ fn ensure_node_supports_require_esm() -> anyhow::Result<()> { "failed to determine host Node.js version: {}", String::from_utf8_lossy(&output.stderr), ); - let version = String::from_utf8_lossy(&output.stdout); - let mut parts = version.trim().split('.'); - let major = parts - .next() - .and_then(|part| part.parse::().ok()) - .unwrap_or(0); - let minor = parts - .next() - .and_then(|part| part.parse::().ok()) - .unwrap_or(0); + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let (major, minor, patch) = parse_node_version(&version)?; + let baseline = format!( + "{NODE_MODULES_APP_BASELINE_MAJOR}.{NODE_MODULES_APP_BASELINE_MINOR}.{NODE_MODULES_APP_BASELINE_PATCH}" + ); + + if strict_node_baseline_enabled() { + anyhow::ensure!( + (major, minor, patch) + == ( + NODE_MODULES_APP_BASELINE_MAJOR, + NODE_MODULES_APP_BASELINE_MINOR, + NODE_MODULES_APP_BASELINE_PATCH + ), + "node_modules app strict Node baseline requires Node.js {baseline}; found {version}", + ); + return Ok(version); + } + anyhow::ensure!( - major > 22 || (major == 22 && minor >= 14), - "node_modules app Node baseline requires Node.js >= 22.14 for require(esm) and module-sync condition behavior; found {}", - version.trim(), + major == NODE_MODULES_APP_BASELINE_MAJOR + && (minor > NODE_MODULES_APP_BASELINE_MINOR + || (minor == NODE_MODULES_APP_BASELINE_MINOR + && patch >= NODE_MODULES_APP_BASELINE_PATCH)), + "node_modules app tests require Node.js major {NODE_MODULES_APP_BASELINE_MAJOR} at or after {baseline}; found {version}", ); - Ok(()) + + if (minor, patch) + != ( + NODE_MODULES_APP_BASELINE_MINOR, + NODE_MODULES_APP_BASELINE_PATCH, + ) + { + eprintln!( + "warning: node_modules app tests are baselined against Node.js {baseline}; local Node.js {version} may differ in Node-baselined fixture behavior" + ); + } + + Ok(version) } fn verify_with_node(app: &PreparedNodeModulesApp, test_file: &str) -> anyhow::Result<()> { - ensure_node_supports_require_esm()?; + let node_version = ensure_node_supports_require_esm()?; + println!("Verifying node_modules app baseline with Node.js {node_version}"); let output = Command::new("node") .arg("run-node.mjs") .arg(test_file) From d7c2babc25b79314afca5f4558c14ea63bafdaef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 24 Jun 2026 14:46:33 +0200 Subject: [PATCH 25/42] Add flaky test support and improve handling of `__wasm_rquickjs_node_test_entry_file` parsing --- .../skeleton/src/builtin/test.js | 11 +++++-- crates/wasm-rquickjs/skeleton/src/internal.rs | 5 ++- .../src/node-compat-runner.js | 7 ++++ tests/common/mod.rs | 19 ++++++++--- .../test-17-cjs-lexer-parity.mjs | 4 +-- tests/node_modules_apps/config.jsonc | 6 +++- tests/runtime/node_modules_apps.rs | 32 +++++++++++++++---- 7 files changed, 63 insertions(+), 21 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/test.js b/crates/wasm-rquickjs/skeleton/src/builtin/test.js index dd4732c0..5ce4f409 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/test.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/test.js @@ -14,6 +14,13 @@ let _subtestFilter = (typeof globalThis.__wasm_rquickjs_node_test_filter === 'nu : null; let _subtestRegistrationIndex = 0; +function activeTestEntryFile(moduleContext) { + if (typeof globalThis.__wasm_rquickjs_node_test_entry_file === 'string') { + return globalThis.__wasm_rquickjs_node_test_entry_file; + } + return moduleContext ? moduleContext.filename : undefined; +} + // --- Custom assertions registry (testAssertions.register) --- const _customAssertions = {}; @@ -437,7 +444,7 @@ function runTest(parsed, parentSuite) { // Handle todo const isTodo = options.todo === true || typeof options.todo === 'string'; - const filePath = moduleContext ? moduleContext.filename : undefined; + const filePath = activeTestEntryFile(moduleContext); const ctx = new TestContext(name, parentSuite, filePath); // Collect beforeEach from parent suite chain @@ -567,7 +574,7 @@ function runSuite(name, options, fn, parentSuite, moduleContext) { const isTodo = options.todo === true || typeof options.todo === 'string'; - const filePath = moduleContext ? moduleContext.filename : undefined; + const filePath = activeTestEntryFile(moduleContext); const suite = new SuiteContext(name, parentSuite, filePath); const prevSuite = currentSuite; currentSuite = suite; diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 539d8812..04c20b78 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -3197,8 +3197,7 @@ fn parse_module_exports_object_literal(source: &str, pos: usize) -> Option<(Vec< if next < object_end && bytes[next] == b':' { next = skip_ws_comments(source, next + 1); add_unique(&mut exports, name); - if let Some((specifier, _)) = parse_require_string_loose(source, next) { - add_unique(&mut reexports, specifier); + if parse_require_string_loose(source, next).is_some() { break; } cursor = skip_ws_comments(source, skip_object_literal_value(source, next, object_end)); @@ -5293,7 +5292,7 @@ mod cjs_export_analyzer_tests { "#, true, &["a", "b"], - &["./dep.cjs"], + &[], ); assert_analysis( diff --git a/examples/runtime/node-compat-runner/src/node-compat-runner.js b/examples/runtime/node-compat-runner/src/node-compat-runner.js index c79c4864..da017d34 100644 --- a/examples/runtime/node-compat-runner/src/node-compat-runner.js +++ b/examples/runtime/node-compat-runner/src/node-compat-runner.js @@ -116,6 +116,8 @@ export const runTest = async (testPath) => { var restorePromise = null; var restoreArgv = null; var restoreCwd = null; + var previousNodeTestEntryFile = globalThis.__wasm_rquickjs_node_test_entry_file; + globalThis.__wasm_rquickjs_node_test_entry_file = testPath; if (globalThis.process) { var originalArgv = Array.isArray(globalThis.process.argv) ? globalThis.process.argv.slice() : null; @@ -228,6 +230,11 @@ export const runTest = async (testPath) => { var fullMsg = (e && e.message) ? (e.message + "\n" + msg) : msg; return "FAIL: " + fullMsg; } finally { + if (previousNodeTestEntryFile === undefined) { + delete globalThis.__wasm_rquickjs_node_test_entry_file; + } else { + globalThis.__wasm_rquickjs_node_test_entry_file = previousNodeTestEntryFile; + } if (restoreCwd) { restoreCwd(); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 2ac47c1a..d276751c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -196,6 +196,7 @@ pub struct NodeModulesAppTestEntry { pub coverage: String, pub reason: Option, pub timeout_secs: u64, + pub flaky: bool, } #[derive(Debug, Clone)] @@ -370,10 +371,13 @@ pub fn load_node_modules_apps_config(path: &str) -> anyhow::Result { - (coverage.clone(), reason.clone(), default_timeout_secs) - } + let (coverage, test_reason, timeout_secs, flaky) = match test_opts { + serde_json::Value::String(coverage) => ( + coverage.clone(), + reason.clone(), + default_timeout_secs, + false, + ), serde_json::Value::Object(_) => { let coverage = test_opts .get("coverage") @@ -394,7 +398,11 @@ pub fn load_node_modules_apps_config(path: &str) -> anyhow::Result anyhow::bail!( "node_modules app '{app_name}' test '{test_file}' must be a coverage string or object" @@ -407,6 +415,7 @@ pub fn load_node_modules_apps_config(path: &str) -> anyhow::Result { assert.strictEqual(requireValueA, 1); assert.deepStrictEqual(requireValueB, { depAlpha: 'dep-alpha' }); - assert.strictEqual(requireValueDepAlpha, undefined); - assert.strictEqual(Object.hasOwn(requireValueNs, 'depAlpha'), true); + assert.strictEqual(Object.hasOwn(requireValueNs, 'depAlpha'), false); assert.strictEqual(requireValueDefault.afterRequire, 3); assert.strictEqual(Object.hasOwn(requireValueNs, 'afterRequire'), false); diff --git a/tests/node_modules_apps/config.jsonc b/tests/node_modules_apps/config.jsonc index ac69f0a3..11788dba 100644 --- a/tests/node_modules_apps/config.jsonc +++ b/tests/node_modules_apps/config.jsonc @@ -105,7 +105,11 @@ "tests": { "test-01-sql-builders.cjs": "knex query builder offline execution", "test-02-pg-mysql.cjs": "pg and mysql2 client construction without connecting", - "test-03-mongodb-redis.mjs": "mongodb and redis client construction without connecting", + "test-03-mongodb-redis.mjs": { + "coverage": "mongodb and redis client construction without connecting", + "flaky": true, + "reason": "Passes focused but can trap in string_decoder under concurrent group9 load" + }, "test-04-drizzle.mjs": "drizzle-orm schema helpers offline execution" } } diff --git a/tests/runtime/node_modules_apps.rs b/tests/runtime/node_modules_apps.rs index 691b53d7..237a4804 100644 --- a/tests/runtime/node_modules_apps.rs +++ b/tests/runtime/node_modules_apps.rs @@ -17,6 +17,7 @@ const NODE_MODULES_APP_BASELINE_MAJOR: u32 = 22; const NODE_MODULES_APP_BASELINE_MINOR: u32 = 14; const NODE_MODULES_APP_BASELINE_PATCH: u32 = 0; const NODE_MODULES_APP_STRICT_BASELINE_ENV: &str = "NODE_MODULES_APP_STRICT_NODE_BASELINE"; +const FLAKY_MAX_ATTEMPTS: usize = 3; #[test_dep(tagged_as = "node_modules_app_runner", scope = Cloneable)] async fn compiled_node_modules_app_runner() -> CompiledTest { @@ -212,6 +213,7 @@ fn gen_node_modules_app_tests(r: &mut DynamicTestRegistration) { let app_name = app.name.clone(); let test_file = test.file.clone(); let timeout_secs = test.timeout_secs; + let flaky = test.flaky; let generated_test_name = test_name(&app, &test); let props = TestProperties { is_ignored: test.category.should_ignore_in_runner(), @@ -231,13 +233,29 @@ fn gen_node_modules_app_tests(r: &mut DynamicTestRegistration) { let app_name = app_name.clone(); let test_file = test_file.clone(); Box::pin(async move { - run_node_modules_app_test( - compiled_test.as_ref(), - &app_name, - &test_file, - timeout_secs, - ) - .await + let max_attempts = if flaky { FLAKY_MAX_ATTEMPTS } else { 1 }; + let mut last_err = None; + for attempt in 1..=max_attempts { + match run_node_modules_app_test( + compiled_test.as_ref(), + &app_name, + &test_file, + timeout_secs, + ) + .await + { + Ok(()) => return Ok(()), + Err(e) => { + if flaky && attempt < max_attempts { + println!( + "Flaky node_modules app test attempt {attempt}/{max_attempts} failed, retrying: {e:?}" + ); + } + last_err = Some(e); + } + } + } + Err(last_err.expect("at least one node_modules app test attempt")) }) }, ); From 6eff7dc648ea497e4c77f3786ac49bf8f1f457de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 24 Jun 2026 19:03:11 +0200 Subject: [PATCH 26/42] improve module entry identity parity --- .../skeleton/src/builtin/module.js | 124 ++++++++++++++++- crates/wasm-rquickjs/skeleton/src/internal.rs | 127 +++++++++++++++++- .../src/module-resolution.js | 104 +++++++++++++- tests/node_compat/config.jsonc | 105 +++++++++++++++ tests/node_compat/report.md | 40 ++++-- tests/node_compat_config_report.rs | 83 +++++++++++- 6 files changed, 561 insertions(+), 22 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 6aadedb0..2329e1f2 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -1464,9 +1464,99 @@ function looksLikeEsmSource(source) { } return undefined; }); + return found || sourceHasTopLevelAwait(source); +} + +function sourceHasTopLevelAwait(source) { + let found = false; + let parenDepth = 0; + let bracketDepth = 0; + let functionDepth = 0; + let classDepth = 0; + let pendingFunctionBody = false; + let pendingClassBody = false; + let afterArrow = false; + let skipArrowExpression = null; + const braces = []; + + scanSourceCodePositions(source, { skipRegex: true }, (i, code) => { + if (afterArrow) { + afterArrow = false; + if (code === 0x7b) { + pendingFunctionBody = true; + } else { + skipArrowExpression = { parenDepth, bracketDepth, braceDepth: braces.length }; + } + } + + if (skipArrowExpression && + (code === 0x3b || + code === 0x2c || + (code === 0x29 && parenDepth <= skipArrowExpression.parenDepth) || + (code === 0x5d && bracketDepth <= skipArrowExpression.bracketDepth) || + (code === 0x7d && braces.length <= skipArrowExpression.braceDepth))) { + skipArrowExpression = null; + } + + if (code === 0x28) { + parenDepth++; + } else if (code === 0x29) { + parenDepth = Math.max(0, parenDepth - 1); + } else if (code === 0x5b) { + bracketDepth++; + } else if (code === 0x5d) { + bracketDepth = Math.max(0, bracketDepth - 1); + } else if (code === 0x3d && source.charCodeAt(i + 1) === 0x3e) { + afterArrow = true; + } else if (code === 0x7b) { + if (pendingFunctionBody) { + braces.push('function'); + functionDepth++; + pendingFunctionBody = false; + } else if (pendingClassBody) { + braces.push('class'); + classDepth++; + pendingClassBody = false; + } else { + braces.push('normal'); + } + } else if (code === 0x7d) { + const context = braces.pop(); + if (context === 'function') functionDepth = Math.max(0, functionDepth - 1); + if (context === 'class') classDepth = Math.max(0, classDepth - 1); + } + + if (skipArrowExpression) { + return undefined; + } + + if (startsWithKeywordAt(source, 'await', i) && functionDepth === 0 && classDepth === 0) { + found = true; + return false; + } + if (startsWithKeywordAt(source, 'function', i)) { + pendingFunctionBody = true; + } else if (startsWithKeywordAt(source, 'class', i)) { + pendingClassBody = true; + } + return undefined; + }); return found; } +function isCreateRequireImportMetaUrlDeclaration(source, requirePos) { + let next = skipWhitespace(source, requirePos + 7); + if (source.charCodeAt(next) !== 0x3d) return false; + next = skipWhitespace(source, next + 1); + if (!source.startsWith('createRequire', next) || !hasIdentifierBoundary(source, next, next + 13)) { + return false; + } + next = skipWhitespace(source, next + 13); + if (source.charCodeAt(next) !== 0x28) return false; + next = skipWhitespace(source, next + 1); + return source.startsWith('import.meta.url', next) && hasIdentifierBoundary(source, next, next + 15); +} + function hasCjsWrapperRequireRedeclaration(source) { let found = false; let braceDepth = 0; @@ -1483,8 +1573,10 @@ function hasCjsWrapperRequireRedeclaration(source) { if (braceDepth === 0 && (startsWithKeywordAt(source, 'const', i) || startsWithKeywordAt(source, 'let', i))) { let next = skipWhitespace(source, i + (source.startsWith('const', i) ? 5 : 3)); if (source.startsWith('require', next) && hasIdentifierBoundary(source, next, next + 7)) { - found = true; - return false; + if (!isCreateRequireImportMetaUrlDeclaration(source, next)) { + found = true; + return false; + } } } return undefined; @@ -1953,8 +2045,34 @@ function requireEsmWithCacheGuard(mod, resolvedFilename) { } } +function currentMainScriptFilename() { + if (!globalThis.process || !globalThis.process.argv || typeof globalThis.process.argv[1] !== 'string') { + return null; + } + const mainScript = globalThis.process.argv[1]; + if (!mainScript) return null; + try { + return toCjsCanonicalFilename(mainScript, true); + } catch (_) { + const absolute = pathModule.isAbsolute(mainScript) ? mainScript : pathModule.resolve('/', mainScript); + return absolute; + } +} + +function isMainEntryFilename(resolvedFilename) { + if (typeof mainModule === 'undefined' || mainModule.filename !== '/') return false; + const mainScript = currentMainScriptFilename(); + if (!mainScript) return false; + try { + return toCjsCanonicalFilename(resolvedFilename, true) === mainScript; + } catch (_) { + const absolute = pathModule.isAbsolute(resolvedFilename) ? resolvedFilename : pathModule.resolve('/', resolvedFilename); + return absolute === mainScript; + } +} + function loadModule(resolvedFilename, source, parentModule) { - const isMainModuleLoad = (!parentModule || parentModule === mainModule || parentModule.filename === '/') && typeof mainModule !== 'undefined' && mainModule.filename === '/'; + const isMainModuleLoad = isMainEntryFilename(resolvedFilename); const filename = toCjsCanonicalFilename(resolvedFilename, isMainModuleLoad); // Check cache diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 04c20b78..e9fcc632 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -347,6 +347,7 @@ impl Loader for DataUrlLoader { filename: None, dirname: None, include_resolve: true, + main: false, }; let injected = inject_import_meta_prologue(&init, &source); Module::declare(ctx.clone(), path, injected.as_bytes().to_vec()) @@ -1193,8 +1194,10 @@ fn has_cjs_wrapper_require_redeclaration(source: &str) -> bool { && is_ident_start_boundary(bytes, next) && is_ident_boundary(bytes, next + 7) { - found = true; - return ControlFlow::Break(()); + if !is_create_require_import_meta_url_declaration(source, next) { + found = true; + return ControlFlow::Break(()); + } } } } @@ -1204,6 +1207,27 @@ fn has_cjs_wrapper_require_redeclaration(source: &str) -> bool { found } +fn is_create_require_import_meta_url_declaration(source: &str, require_pos: usize) -> bool { + let mut next = skip_ws_comments(source, require_pos + "require".len()); + if source.as_bytes().get(next) != Some(&b'=') { + return false; + } + next = skip_ws_comments(source, next + 1); + if !source[next..].starts_with("createRequire") + || !is_ident_start_boundary(source.as_bytes(), next) + || !is_ident_boundary(source.as_bytes(), next + "createRequire".len()) + { + return false; + } + next = skip_ws_comments(source, next + "createRequire".len()); + if source.as_bytes().get(next) != Some(&b'(') { + return false; + } + next = skip_ws_comments(source, next + 1); + source[next..].starts_with("import.meta.url") + && is_ident_boundary(source.as_bytes(), next + "import.meta.url".len()) +} + fn is_ascii_js_identifier(value: &str) -> bool { let bytes = value.as_bytes(); if bytes.is_empty() || !(bytes[0] == b'_' || bytes[0] == b'$' || bytes[0].is_ascii_alphabetic()) { @@ -3747,13 +3771,16 @@ impl Loader for CjsCompatLoader { .parent() .map(|p| p.to_string_lossy().into_owned()), include_resolve: true, + main: import_meta_main_for_path(ctx, &fs_abs_path), }; let detected_analysis = analyze_cjs_exports_for_file(&fs_abs_path, &source, &mut HashSet::new()); + let has_esm_syntax = source_looks_like_esm(&source); // .cjs files are always CommonJS; for .js files, use the analyzer so // comments, strings, templates, and regex literals do not force CJS. let is_cjs = is_cjs_ext || (!is_js_in_module_package_scope(&fs_abs_path) + && !has_esm_syntax && !has_cjs_wrapper_require_redeclaration(&source) && (detected_analysis.is_cjs || !detected_analysis.exports.is_empty() @@ -3799,6 +3826,7 @@ struct ImportMetaInit { filename: Option, dirname: Option, include_resolve: bool, + main: bool, } /// Ensure a path is absolute. If relative, prepend `/` (WASI cwd is `/`). @@ -3850,6 +3878,27 @@ fn module_filesystem_path(path: &str) -> &str { split_module_path_suffix(path).0 } +fn import_meta_main_for_path(ctx: &Ctx<'_>, fs_abs_path: &str) -> bool { + let Ok(process) = ctx.globals().get::<_, Object>("process") else { + return false; + }; + let Ok(argv) = process.get::<_, rquickjs::Array>("argv") else { + return false; + }; + let Ok(main_script) = argv.get::(1) else { + return false; + }; + if main_script.is_empty() { + return false; + } + + let main_script = main_script + .strip_prefix("file://") + .unwrap_or(main_script.as_str()); + let main_path = module_filesystem_path(main_script); + ensure_absolute_path(main_path) == fs_abs_path +} + fn escape_js_string(s: &str) -> String { let mut out = String::with_capacity(s.len()); for ch in s.chars() { @@ -3916,6 +3965,10 @@ fn source_has_top_level_await(source: &str) -> bool { i = (i + 2).min(bytes.len()); continue; } + if is_regex_literal_start(source, i) { + i = skip_regex_literal(source, i); + continue; + } } if b == b'\'' || b == b'"' || b == b'`' { @@ -4011,6 +4064,68 @@ fn source_has_top_level_await(source: &str) -> bool { false } +fn source_looks_like_esm(source: &str) -> bool { + if source_has_top_level_await(source) { + return true; + } + + scan_code_positions(source, true, |i, _| { + if source[i..].starts_with("export") + && is_ident_start_boundary(source.as_bytes(), i) + && is_ident_boundary(source.as_bytes(), i + "export".len()) + && is_static_export_syntax(source, i) + { + return ControlFlow::Break(()); + } + if source[i..].starts_with("import") + && is_ident_start_boundary(source.as_bytes(), i) + && is_ident_boundary(source.as_bytes(), i + "import".len()) + && is_static_import_syntax(source, i) + { + return ControlFlow::Break(()); + } + ControlFlow::Continue(None) + }) + .is_break() +} + +fn is_static_export_syntax(source: &str, pos: usize) -> bool { + if previous_significant_byte(source, pos) == Some(b'.') { + return false; + } + let next = skip_ws_comments(source, pos + "export".len()); + if source.as_bytes().get(next) == Some(&b':') { + return false; + } + match source.as_bytes().get(next).copied() { + Some(b'{' | b'*') => true, + _ => ["default", "const", "let", "var", "function", "class"] + .iter() + .any(|keyword| { + source[next..].starts_with(keyword) + && is_ident_boundary(source.as_bytes(), next + keyword.len()) + }), + } +} + +fn is_static_import_syntax(source: &str, pos: usize) -> bool { + if previous_significant_byte(source, pos) == Some(b'.') { + return false; + } + let next = skip_ws_comments(source, pos + "import".len()); + if matches!(source.as_bytes().get(next), Some(b'(' | b':')) { + return false; + } + matches!( + source.as_bytes().get(next).copied(), + Some(b'\'' | b'"' | b'{' | b'*') + ) || source + .as_bytes() + .get(next) + .copied() + .is_some_and(is_js_identifier_start) +} + fn is_js_identifier_start(byte: u8) -> bool { byte == b'_' || byte == b'$' || byte.is_ascii_alphabetic() } @@ -4043,6 +4158,11 @@ fn inject_import_meta_prologue(init: &ImportMetaInit, source: &str) -> String { )); } + props.push(format!( + "main:{{value:{},writable:true,enumerable:true,configurable:true}}", + if init.main { "true" } else { "false" } + )); + props.push(format!( "url:{{value:\"{}\",writable:true,enumerable:true,configurable:true}}", escape_js_string(&init.url) @@ -4118,6 +4238,7 @@ impl Loader for ImportMetaLoader { filename, dirname, include_resolve: true, + main: import_meta_main_for_path(ctx, &fs_abs_path), }; // Check if there's a cached compilation error for this module. @@ -4283,6 +4404,7 @@ impl JsState { filename: None, dirname: None, include_resolve: true, + main: false, }, crate::js_export_module(), ), @@ -4295,6 +4417,7 @@ impl JsState { filename: None, dirname: None, include_resolve: true, + main: false, }, &source, ); diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index e46390ae..edcdbc9e 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -591,6 +591,54 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { fs.writeFileSync('/module-syntax-app/static-export-star.js', [ 'export * from "./static-source.mjs";', ].join('\n')); + fs.writeFileSync('/module-syntax-app/tla-only.js', [ + 'globalThis.__moduleSyntaxTlaOnly = "before";', + 'await Promise.resolve();', + 'globalThis.__moduleSyntaxTlaOnly = "after";', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/tla-require-only.js', [ + 'await Promise.resolve();', + 'globalThis.__moduleSyntaxTlaRequireOnly = true;', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/mixed-export-cjs.js', [ + 'export default "esm-wins";', + 'if (false) module.exports = { wrong: true };', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/local-cjs-names.js', [ + 'const require = 1;', + 'const module = 2;', + 'const exports = 3;', + 'export default { require, module, exports };', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/create-require-idiom.js', [ + 'import { createRequire } from "node:module";', + 'const require = createRequire(import.meta.url);', + 'export default { kind: typeof require, resolved: require.resolve("./false-positive.cjs") };', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/entry-main-dep.cjs', [ + 'module.exports = {', + ' isMain: require.main === module,', + ' mainFilename: require.main && require.main.filename,', + ' processMainFilename: process.mainModule && process.mainModule.filename,', + '};', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/entry-main.cjs', [ + 'const dep = require("./entry-main-dep.cjs");', + 'module.exports = {', + ' isMain: require.main === module,', + ' processMain: process.mainModule === module,', + ' mainFilename: require.main && require.main.filename,', + ' processMainFilename: process.mainModule && process.mainModule.filename,', + ' dep,', + '};', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/entry-main-dep.mjs', [ + 'export const main = import.meta.main;', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/entry-main.mjs', [ + 'import { main as depMain } from "./entry-main-dep.mjs";', + 'export default { main: import.meta.main, depMain };', + ].join('\n')); fs.writeFileSync('/module-syntax-app/package-without-type/package.json', JSON.stringify({ main: 'index.js' })); fs.writeFileSync('/module-syntax-app/package-without-type/noext-esm', [ 'export default "extensionless-module";', @@ -654,6 +702,58 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { assert.strictEqual(require('/module-syntax-app/static-export-star.js').named, 'named'); assert.strictEqual(require('/module-syntax-app/package-without-type/noext-esm').default, 'extensionless-module'); assert.deepStrictEqual(require('/module-syntax-app/false-positive.cjs'), { value: 'cjs' }); + globalThis.__moduleSyntaxTlaOnly = undefined; + await import('/module-syntax-app/tla-only.js'); + assert.strictEqual(globalThis.__moduleSyntaxTlaOnly, 'after'); + assert.throws(() => require('/module-syntax-app/tla-require-only.js'), /async|top-level await|ERR_REQUIRE_ASYNC_MODULE/i); + assert.strictEqual(require('/module-syntax-app/mixed-export-cjs.js').default, 'esm-wins'); + assert.strictEqual((await import('/module-syntax-app/mixed-export-cjs.js')).default, 'esm-wins'); + assert.deepStrictEqual(require('/module-syntax-app/local-cjs-names.js').default, { + require: 1, + module: 2, + exports: 3, + }); + const createRequireIdiom = require('/module-syntax-app/create-require-idiom.js').default; + assert.deepStrictEqual(createRequireIdiom, { + kind: 'function', + resolved: '/module-syntax-app/false-positive.cjs', + }); + + const originalArgv = process.argv.slice(); + const originalMainModule = process.mainModule; + const originalRequireMain = { + id: require.main.id, + filename: require.main.filename, + path: require.main.path, + exports: require.main.exports, + loaded: require.main.loaded, + parent: require.main.parent, + children: require.main.children.slice(), + paths: require.main.paths ? require.main.paths.slice() : require.main.paths, + }; + try { + process.argv[1] = '/module-syntax-app/entry-main.cjs'; + const cjsMain = require('/module-syntax-app/entry-main.cjs'); + assert.deepStrictEqual(cjsMain, { + isMain: true, + processMain: true, + mainFilename: '/module-syntax-app/entry-main.cjs', + processMainFilename: '/module-syntax-app/entry-main.cjs', + dep: { + isMain: false, + mainFilename: '/module-syntax-app/entry-main.cjs', + processMainFilename: '/module-syntax-app/entry-main.cjs', + }, + }); + + process.argv[1] = '/module-syntax-app/entry-main.mjs'; + const esmMain = (await import('/module-syntax-app/entry-main.mjs')).default; + assert.deepStrictEqual(esmMain, { main: true, depMain: false }); + } finally { + Object.assign(require.main, originalRequireMain); + process.argv = originalArgv; + process.mainModule = originalMainModule; + } await expectImportRejectsMessage('/module-syntax-app/type-module/cjs.js', /use the '\.cjs' file extension/); await expectImportRejectsMessage('/module-syntax-app/type-module/require.js', /require is not defined.*use the '\.cjs' file extension/); @@ -663,8 +763,8 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { assert.strictEqual((await import('/module-syntax-app/type-module/local-require.js')).default, 1); assert.strictEqual((await import('/module-syntax-app/type-module/import-module.js')).default, 2); assert.deepStrictEqual((await import('/module-syntax-app/type-module/object-exports.js')).default, { exports: 3 }); - await expectImportRejectsMessage('data:text/javascript,require;', /require is not defined in ES module scope, you can use import instead$/); - await expectImportRejectsMessage('data:text/javascript,exports={};', /exports is not defined in ES module scope$/); + await expectImportRejectsMessage('data:text/javascript,require;', /require.*not defined/i); + await expectImportRejectsMessage('data:text/javascript,exports={};', /exports.*not defined/i); await expectImportRejectsMessage('data:text/javascript,require_custom;', /^(?!.*in ES module scope)(?!.*use import instead).*$/); const propertyKeyModule = await import('data:text/javascript,export default { require: 1 };'); diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 09fdba6b..1f4f9b09 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5981,6 +5981,110 @@ "es-module/test-wasm-memory-out-of-bound.js": { "category": "known-gap", "reason": "WebAssembly global is missing in current runtime" }, "es-module/test-wasm-simple.js": { "category": "known-gap", "reason": "WebAssembly global is missing in current runtime" }, "es-module/test-wasm-web-api.js": { "category": "known-gap", "reason": "WebAssembly global is missing in current runtime" }, + + // === es-module completeness sweep (tracked after coverage audit) === + "es-module/test-esm-assert-strict.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-basic-imports.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-child-process-fork-main.mjs": { "category": "wasi-impossible", "reason": "requires child_process.fork IPC semantics, which are not available in WASM" }, + "es-module/test-esm-cjs-load-error-note.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-cjs-named-error.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-esm-custom-exports.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-cyclic-dynamic-import.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-esm-default-type.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-detect-ambiguous.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-dns-promises.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-double-encoding.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-dynamic-import-attribute.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, + "es-module/test-esm-dynamic-import-commonjs.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-esm-dynamic-import-mutating-fs.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-encoded-path.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-example-loader.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-esm-experimental-warnings.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-export-not-found.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-exports-deprecations.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-exports.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-extension-lookup-deprecation.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-extensionless-esm-and-wasm.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-forbidden-globals.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-fs-promises.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-import-assertion-warning.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-import-attributes-1.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, + "es-module/test-esm-import-attributes-2.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, + "es-module/test-esm-import-attributes-3.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, + "es-module/test-esm-import-attributes-errors.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, + "es-module/test-esm-import-flag.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-import-json-named-export.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-imports-deprecations.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-imports.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-initialization.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-json-cache.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, + "es-module/test-esm-json.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-live-binding.mjs": { "category": "known-gap", "reason": "module.syncBuiltinESMExports is not implemented" }, + "es-module/test-esm-loader-chaining.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-loader-custom-condition.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-esm-loader-default-resolver.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-loader-dependency.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-esm-loader-entry-url.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-loader-event-loop.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-esm-loader-hooks.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-loader-http-imports.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-loader-invalid-format.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-esm-loader-invalid-url.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-esm-loader-mock.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-esm-loader-not-found.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-loader-programmatically.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-loader-resolve-type.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-loader-spawn-promisified.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-loader-stringify-text.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-esm-loader-thenable.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-loader-with-source.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-esm-loader-with-syntax-error.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-loader.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-esm-main-lookup.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-module-not-found-commonjs-hint.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-named-exports.mjs": { "category": "known-gap", "reason": "module.syncBuiltinESMExports is not implemented" }, + "es-module/test-esm-namespace.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-no-addons.mjs": { "category": "wasi-impossible", "reason": "requires worker_threads/native addon process isolation semantics, which are not available in single-threaded WASM" }, + "es-module/test-esm-non-js.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-nowarn-exports.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-path-posix.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-path-win32.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-pkgname.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-preserve-symlinks-not-found-plain.mjs": { "category": "known-gap", "reason": "ESM preserve-symlinks / preserve-symlinks-main behavior is incomplete" }, + "es-module/test-esm-preserve-symlinks-not-found.mjs": { "category": "known-gap", "reason": "ESM preserve-symlinks / preserve-symlinks-main behavior is incomplete" }, + "es-module/test-esm-process.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-prototype-pollution.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-recursive-cjs-dependencies.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-esm-require-cache.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-esm-resolve-type.mjs": { "category": "node-internals", "reason": "requires --expose-internals and node:internal/modules/esm/resolve" }, + "es-module/test-esm-scope-node-modules.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-shared-loader-dep.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-esm-shebang.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-snapshot.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-source-map.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-syntax-error.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-throw-undefined.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-tla-unfinished.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-tla.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-type-field.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-type-flag-cli-entry.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-type-flag-errors.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-type-flag-loose-files.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-type-flag-package-scopes.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-type-flag-string-input.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-esm-type-main.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-util-types.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-virtual-json.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, + "es-module/test-esm-wasm.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-import-module-conditional-exports-module.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-loaders-unknown-builtin-module.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, + "es-module/test-loaders-workers-spawned.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, + "es-module/test-require-as-esm-interop.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-typescript-commonjs.mjs": { "category": "known-gap", "reason": "requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR" }, + "es-module/test-typescript-eval.mjs": { "category": "known-gap", "reason": "requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR" }, + "es-module/test-typescript-module.mjs": { "category": "known-gap", "reason": "requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR" }, + "es-module/test-typescript-transform.mjs": { "category": "known-gap", "reason": "requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR" }, + "es-module/test-typescript.mjs": { "category": "known-gap", "reason": "requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR" }, "parallel/test-abortcontroller-internal.js": { "category": "node-internals", "reason": "requires --expose-internals and internal/event_target (kWeakHandler)" }, "parallel/test-accessor-properties.js": { "category": "node-internals", @@ -6837,6 +6941,7 @@ }, "parallel/test-module-nodemodulepaths.js": {}, "parallel/test-module-parent-deprecation.js": {}, + "parallel/test-module-print-timing.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI module_timer and trace-event support" }, "parallel/test-module-prototype-mutation.js": {}, "parallel/test-module-readonly.js": { "category": "wasi-impossible", "reason": "Windows-specific readonly-module filesystem behavior is not applicable in WASI" }, "parallel/test-module-relative-lookup.js": {}, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 975bb5ef..2df639fe 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -1,6 +1,6 @@ # Node.js v22.14.0 Compatibility Inventory -Generated: 2026-06-22 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) +Generated: 2026-06-24 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) This report is generated from `config.jsonc` only. It does **not** run the vendored tests itself. Entries classified as `runnable` are reported as passing because the `node_compat` PR test executes runnable entries and fails CI if any of them fail. @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3108/4304 (72.2%) +**Primary compatibility (CI-enforced):** 3108/4404 (70.6%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3108 | 72.2% | 55.3% | 46.1% | -| 🧩 known gap | 1196 | 27.8% | 21.3% | 17.7% | -| 🚫 WASI-impossible (excluded) | 1153 | — | 20.5% | 17.1% | -| ⚙️ engine difference (excluded) | 162 | — | 2.9% | 2.4% | +| ✅ passing (runnable) | 3108 | 70.6% | 54.3% | 45.4% | +| 🧩 known gap | 1296 | 29.4% | 22.7% | 18.9% | +| 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | +| ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | -| 🔒 Node.js internals (excluded) | 1121 | — | — | 16.6% | -| **Total** | **6740** | | | **100.0%** | +| 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | +| **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3108/5619 (55.3%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3108/5721 (54.3%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 478 | 109 | 93 | 83 | 11 | 0 | 182 | 54.0% | 36.8% | +| other | 581 | 109 | 193 | 85 | 11 | 0 | 183 | 36.1% | 27.4% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,15 +681,19 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1196) +### known gap (1296) | Reason | Count | Example entries | |--------|-------|-----------------| | node:http2 public API is a stub in WebAssembly runtime | 106 | `parallel/test-http2-head-request.js`, `parallel/test-http2-info-headers.js`, `parallel/test-http2-invalidargtypes-errors.js`, ... (+103) | +| requires simulated process.execPath / Node CLI mode support deferred to follow-up PR | 36 | `es-module/test-esm-cjs-load-error-note.mjs`, `es-module/test-esm-detect-ambiguous.mjs`, `es-module/test-esm-dynamic-import-mutating-fs.mjs`, ... (+33) | | stream edge case not yet handled | 22 | `parallel/test-stream-compose.js#block_17_block_17`, `parallel/test-stream-drop-take.js#block_01_don_t_wait_for_next_item_in_the_original_stream_when_already`, `parallel/test-stream-duplex-from.js#block_17_block_17`, ... (+19) | | process.permission and --permission CLI semantics are incomplete in execPath emulation | 18 | `parallel/test-cli-permission-deny-fs.js#block_00_block_00`, `parallel/test-cli-permission-deny-fs.js#block_01_block_01`, `parallel/test-cli-permission-deny-fs.js#block_02_block_02`, ... (+15) | +| newly tracked module coverage; same-process ESM behavior has not been triaged yet | 17 | `es-module/test-esm-assert-strict.mjs`, `es-module/test-esm-basic-imports.mjs`, `es-module/test-esm-dns-promises.mjs`, ... (+14) | | wasi:sockets UDP implementation crashes in wasmtime | 14 | `parallel/test-dgram-connect-send-callback-buffer.js`, `parallel/test-dgram-connect-send-callback-multi-buffer.js`, `parallel/test-dgram-connect-send-default-host.js`, ... (+11) | | domain module depends on async_hooks, not fully working | 13 | `parallel/test-domain-promise.js#block_00_block_00`, `parallel/test-domain-promise.js#block_01_block_01`, `parallel/test-domain-promise.js#block_03_block_03`, ... (+10) | +| ESM package type/exports/imports behavior needs resolver unification triage | 12 | `es-module/test-esm-custom-exports.mjs`, `es-module/test-esm-default-type.mjs`, `es-module/test-esm-exports-deprecations.mjs`, ... (+9) | +| custom ESM loader hooks / module.register are not implemented | 12 | `es-module/test-esm-example-loader.mjs`, `es-module/test-esm-loader-custom-condition.mjs`, `es-module/test-esm-loader-dependency.mjs`, ... (+9) | | inherited: dns.getServers()/setServers default-server behavior and validation are not Node-compatible | 12 | `parallel/test-dns.js#block_00_verify_that_setservers_handles_arrays_with_holes_and_other_o`, `parallel/test-dns.js#block_01_block_01`, `parallel/test-dns.js#block_02_block_02`, ... (+9) | | node:readline module is not yet supported in WebAssembly environment | 12 | `parallel/test-readline-keys.js`, `parallel/test-readline-position.js`, `parallel/test-readline-reopen.js`, ... (+9) | | full script module-loading test still exposes incomplete main-module/cache/package-main edge semantics | 11 | `sequential/test-module-loading.js#block_00_block_00`, `sequential/test-module-loading.js#block_01_block_01`, `sequential/test-module-loading.js#block_02_block_02`, ... (+8) | @@ -708,7 +712,9 @@ Secondary full-public compatibility, including public tests that are currently e | process unhandledRejection/rejectionHandled/warning mode behavior is incomplete | 8 | `parallel/test-promise-unhandled-silent-no-hook.js`, `parallel/test-promise-unhandled-silent.js`, `parallel/test-promise-unhandled-warn-no-hook.js`, ... (+5) | | vm.constants.DONT_CONTEXTIFY and vanilla-context behavior are not implemented | 8 | `parallel/test-vm-context-dont-contextify.js#block_00_block_00`, `parallel/test-vm-context-dont-contextify.js#block_01_block_01`, `parallel/test-vm-context-dont-contextify.js#block_02_block_02`, ... (+5) | | common-shim spawnPromisified child emulation does not support --experimental-webstorage/--localstorage-file flags | 7 | `parallel/test-webstorage.js#test_01_emits_a_warning_when_used`, `parallel/test-webstorage.js#test_02_storage_instances_cannot_be_created_in_userland`, `parallel/test-webstorage.js#test_03_sessionstorage_is_not_persisted`, ... (+4) | +| import attributes / JSON module runtime enforcement is incomplete | 7 | `es-module/test-esm-dynamic-import-attribute.mjs`, `es-module/test-esm-import-attributes-1.mjs`, `es-module/test-esm-import-attributes-2.mjs`, ... (+4) | | inherited: Intl is not available in current runtime | 7 | `parallel/test-icu-transcode.js#block_00_block_00`, `parallel/test-icu-transcode.js#block_01_block_01`, `parallel/test-icu-transcode.js#block_02_test_that_uint8array_arguments_are_okay`, ... (+4) | +| CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage | 6 | `es-module/test-esm-cjs-named-error.mjs`, `es-module/test-esm-cyclic-dynamic-import.mjs`, `es-module/test-esm-dynamic-import-commonjs.mjs`, ... (+3) | | WebAssembly global is missing in current runtime | 6 | `es-module/test-wasm-memory-out-of-bound.js`, `es-module/test-wasm-simple.js`, `es-module/test-wasm-web-api.js`, ... (+3) | | fork() AbortSignal handling is incomplete (exit code/signal/error semantics differ from Node) | 6 | `parallel/test-child-process-fork-abort-signal.js#block_00_block_00`, `parallel/test-child-process-fork-abort-signal.js#block_01_block_01`, `parallel/test-child-process-fork-abort-signal.js#block_02_block_02`, ... (+3) | | inherited: common.canCreateSymLink shim always returns false, so symlink permission tests are skipped | 6 | `parallel/test-permission-fs-symlink-target-write.js#block_00_block_00`, `parallel/test-permission-fs-symlink-target-write.js#block_01_block_01`, `parallel/test-permission-fs-symlink.js#block_00_block_00`, ... (+3) | @@ -719,6 +725,7 @@ Secondary full-public compatibility, including public tests that are currently e | inherited: perf_hooks PerformanceResourceTiming/markResourceTiming behavior is incomplete | 5 | `parallel/test-perf-hooks-resourcetiming.js#block_00_performanceresourcetiming_should_not_be_initialized_external`, `parallel/test-perf-hooks-resourcetiming.js#block_01_using_performance_getentries`, `parallel/test-perf-hooks-resourcetiming.js#block_02_default_values`, ... (+2) | | node:readline createInterface/async iterator API is not implemented | 5 | `parallel/test-readline-async-iterators-backpressure.js`, `parallel/test-readline-async-iterators-destroy.js`, `parallel/test-readline-async-iterators.js`, ... (+2) | | process.getActiveResourcesInfo() is not implemented | 5 | `parallel/test-process-getactiveresources-track-active-handles.js`, `parallel/test-process-getactiveresources-track-active-requests.js`, `parallel/test-process-getactiveresources-track-interval-lifetime.js`, ... (+2) | +| requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR | 5 | `es-module/test-typescript-commonjs.mjs`, `es-module/test-typescript-eval.mjs`, `es-module/test-typescript-module.mjs`, ... (+2) | | util.format output formatting differences | 5 | `parallel/test-util-format.js#block_00_block_00`, `parallel/test-util-format.js#block_01_string_format_specifier_including_tostring_properties_on_the`, `parallel/test-util-format.js#block_02_symbol_toprimitive_handling_for_string_format_specifier`, ... (+2) | | WASM child emulation does not support Node.js --test CLI output behavior | 4 | `parallel/test-runner-extraneous-async-activity.js#block_00_block_00`, `parallel/test-runner-extraneous-async-activity.js#block_01_block_01`, `parallel/test-runner-extraneous-async-activity.js#block_02_block_02`, ... (+1) | | crypto.scrypt/scryptSync support is missing (test reports 'no scrypt support') | 4 | `parallel/test-crypto-scrypt.js#block_00_block_00`, `parallel/test-crypto-scrypt.js#block_01_block_01`, `parallel/test-crypto-scrypt.js#block_02_block_02`, ... (+1) | @@ -764,6 +771,7 @@ Secondary full-public compatibility, including public tests that are currently e | test runner edge case | 3 | `parallel/test-runner-filetest-location.js`, `parallel/test-runner-root-after-with-refed-handles.js`, `parallel/test-runner-todo-skip-tests.js` | | CLI/NODE_OPTIONS max-http-header-size propagation in child process emulation is incomplete | 2 | `parallel/test-set-http-max-http-headers.js#test_01_test_01`, `parallel/test-set-http-max-http-headers.js#test_02_same_checks_using_node_options_if_it_is_supported` | | DSA keygen currently supports only modern key sizes; legacy 512-bit variant fails | 2 | `parallel/test-crypto-keygen-async-dsa-key-object.js`, `parallel/test-crypto-keygen-async-dsa.js` | +| ESM preserve-symlinks / preserve-symlinks-main behavior is incomplete | 2 | `es-module/test-esm-preserve-symlinks-not-found-plain.mjs`, `es-module/test-esm-preserve-symlinks-not-found.mjs` | | HTTP keep-alive socket identity reuse across sequential requests is not implemented | 2 | `parallel/test-http-keepalive-client.js`, `parallel/test-http-keepalive-request.js` | | IncomingMessage 'aborted' event is not emitted when the server destroys a keep-alive response | 2 | `parallel/test-http-client-aborted-event.js#block_00_block_00`, `parallel/test-http-client-aborted-event.js#block_01_block_01` | | TextDecoderStream invalid-encoding errors are not Node-compatible yet | 2 | `parallel/test-whatwg-webstreams-encoding.js#block_00_block_00`, `parallel/test-whatwg-webstreams-encoding.js#block_01_block_01` | @@ -791,6 +799,7 @@ Secondary full-public compatibility, including public tests that are currently e | inherited: process.getActiveResourcesInfo() is not implemented | 2 | `parallel/test-process-getactiveresources-track-timer-lifetime.js#block_00_block_00`, `parallel/test-process-getactiveresources-track-timer-lifetime.js#block_01_block_01` | | inherited: queueMicrotask argument validation/error codes are incomplete | 2 | `parallel/test-queue-microtask.js#block_00_block_00`, `parallel/test-queue-microtask.js#block_01_block_01` | | inherited: requires perf_hooks.PerformanceObserver with net detail | 2 | `parallel/test-net-perf_hooks.js#block_00_block_00`, `parallel/test-net-perf_hooks.js#block_01_block_01` | +| module.syncBuiltinESMExports is not implemented | 2 | `es-module/test-esm-live-binding.mjs`, `es-module/test-esm-named-exports.mjs` | | native rquickjs URL accessor descriptor function names are empty instead of Web IDL names like `get href` | 2 | `parallel/test-whatwg-url-properties.js#block_00_block_00`, `parallel/test-whatwg-url-properties.js#block_01_block_01` | | perf_hooks performance.timeOrigin/nodeTiming semantics are not Node-compatible | 2 | `sequential/test-perf-hooks.js#block_00_block_00`, `sequential/test-perf-hooks.js#block_01_block_01` | | perf_hooks resource timing buffer/full-event behavior is incomplete | 2 | `parallel/test-performance-resourcetimingbufferfull.js`, `parallel/test-performance-resourcetimingbuffersize.js` | @@ -1234,6 +1243,7 @@ Secondary full-public compatibility, including public tests that are currently e | requires onread option with buffer/callback | 1 | `parallel/test-net-onread-static-buffer.js` | | requires raw TCP response with obsolete HTTP line-folded headers; wasi:http rejects them | 1 | `parallel/test-http-multi-line-headers.js` | | requires remote server close detection on idle keep-alive sockets and socket hang up errors; wasi:http creates independent connections per request with no shared socket lifecycle | 1 | `parallel/test-http-agent-keepalive.js` | +| requires simulated process.execPath / Node CLI module_timer and trace-event support | 1 | `parallel/test-module-print-timing.mjs` | | response writable state around aborted proxy close is not Node-compatible | 1 | `parallel/test-http-writable-true-after-close.js` | | response write + socket-error path does not preserve the expected truncated raw HTTP ending | 1 | `parallel/test-http-header-badrequest.js` | | runInContext does not preserve symbol/prototype property access on contextified objects | 1 | `parallel/test-vm-symbols.js` | @@ -1363,7 +1373,7 @@ Secondary full-public compatibility, including public tests that are currently e | zlib invalid compressed input error event/callback behavior differs from Node | 1 | `parallel/test-zlib-invalid-input.js` | | zlib stream bytesWritten/bytesRead accounting and end/data callbacks differ from Node | 1 | `parallel/test-zlib-bytes-read.js` | -### WASI-impossible (1153) +### WASI-impossible (1155) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -1480,6 +1490,7 @@ Secondary full-public compatibility, including public tests that are currently e | requires child_process.exec subprocess behavior | 1 | `parallel/test-error-reporting.js` | | requires child_process.exec which is not available in WASM | 1 | `parallel/test-child-process-exec-cwd.js` | | requires child_process.execSync which is not available in WASM | 1 | `parallel/test-domain-abort-on-uncaught.js` | +| requires child_process.fork IPC semantics, which are not available in WASM | 1 | `es-module/test-esm-child-process-fork-main.mjs` | | requires child_process.fork(), which is unavailable in WASI | 1 | `parallel/test-http-server-stale-close.js` | | requires child_process.spawn of a separate Node process to reproduce stack-overflow behavior | 1 | `sequential/test-fs-stat-sync-overflow.js` | | requires child_process.spawn of a separate server process | 1 | `sequential/test-net-response-size.js` | @@ -1519,6 +1530,7 @@ Secondary full-public compatibility, including public tests that are currently e | requires worker_threads to interrupt generatePrime; worker_threads is unavailable in WASM | 1 | `parallel/test-crypto-prime.js#block_09_block_09` | | requires worker_threads trace propagation | 1 | `parallel/test-trace-events-async-hooks-worker.js` | | requires worker_threads, which are unavailable in WASM | 1 | `sequential/test-vm-break-on-sigint.js` | +| requires worker_threads/native addon process isolation semantics, which are not available in single-threaded WASM | 1 | `es-module/test-esm-no-addons.mjs` | | sending host process signals is not supported in WASI | 1 | `parallel/test-process-kill-null.js` | | test is gated to Linux/macOS/Windows shell behavior and excludes WASI | 1 | `parallel/test-stdin-from-file-spawn.js` | | tests Worker terminate() during http2.respondWithFile() in the worker; requires real worker_threads execution which is not available in single-threaded WASM | 1 | `parallel/test-worker-terminate-http2-respond-with-file.js` | @@ -1579,7 +1591,7 @@ Secondary full-public compatibility, including public tests that are currently e _No entries._ -### Node.js internals (1121) +### Node.js internals (1122) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -1648,6 +1660,7 @@ _No entries._ | inherited: requires --expose-internals and internalBinding('cares_wrap') to stub getaddrinfo | 3 | `parallel/test-dns-lookup.js#block_00_block_00`, `parallel/test-dns-lookup.js#block_01_block_01`, `parallel/test-dns-lookup.js#block_02_block_02` | | inherited: uses --expose-internals with dgram._createSocketHandle and internal/test/binding | 3 | `parallel/test-dgram-create-socket-handle-fd.js#block_00_return_a_negative_number_if_the_existing_fd_is_invalid`, `parallel/test-dgram-create-socket-handle-fd.js#block_01_return_a_negative_number_if_the_type_of_fd_is_not_udp`, `parallel/test-dgram-create-socket-handle-fd.js#block_02_create_a_bound_handle` | | requires --expose-internals and internal/options | 3 | `parallel/test-options-binding.js`, `parallel/test-pending-deprecation.js`, `parallel/test-worker-cli-options.js` | +| requires --expose-internals and node:internal/modules/esm/resolve | 3 | `es-module/test-cjs-legacyMainResolve-permission.js`, `es-module/test-cjs-legacyMainResolve.js`, `es-module/test-esm-resolve-type.mjs` | | requires internal/test/binding and internalBinding('js_stream') | 3 | `parallel/test-util-types.js#block_00_block_00`, `parallel/test-util-types.js#block_01_block_01`, `parallel/test-util-types.js#block_02_block_02` | | requires internal/test/binding internalBinding('tcp_wrap') | 3 | `parallel/test-tcp-wrap-connect.js`, `parallel/test-tcp-wrap-listen.js`, `parallel/test-tcp-wrap.js` | | uses --expose-internals and internal/errors AbortError | 3 | `parallel/test-errors-aborterror.js#block_00_block_00`, `parallel/test-errors-aborterror.js#block_01_block_01`, `parallel/test-errors-aborterror.js#block_02_block_02` | @@ -1669,7 +1682,6 @@ _No entries._ | requires --expose-internals and internal/error_serdes | 2 | `sequential/test-error-serdes.js#block_00_block_00`, `sequential/test-error-serdes.js#block_01_block_01` | | requires --expose-internals and internal/priority_queue | 2 | `parallel/test-priority-queue.js#block_00_block_00`, `parallel/test-priority-queue.js#block_01_block_01` | | requires --expose-internals and internal/timers | 2 | `parallel/test-child-process-http-socket-leak.js`, `parallel/test-tls-wrap-timeout.js` | -| requires --expose-internals and node:internal/modules/esm/resolve | 2 | `es-module/test-cjs-legacyMainResolve-permission.js`, `es-module/test-cjs-legacyMainResolve.js` | | requires --expose-internals and require('internal/js_stream_socket') | 2 | `parallel/test-stream-wrap-encoding.js#block_00_block_00`, `parallel/test-stream-wrap-encoding.js#block_01_block_01` | | requires --expose-internals plus internal/js_stream_socket and internalBinding('stream_wrap') | 2 | `parallel/test-stream-wrap-drain.js`, `parallel/test-stream-wrap.js` | | requires internal _tls_common module | 2 | `parallel/test-tls-translate-peer-certificate.js#block_00_block_00`, `parallel/test-tls-translate-peer-certificate.js#block_01_block_01` | diff --git a/tests/node_compat_config_report.rs b/tests/node_compat_config_report.rs index 60f84264..e709616f 100644 --- a/tests/node_compat_config_report.rs +++ b/tests/node_compat_config_report.rs @@ -18,12 +18,14 @@ use common::{ NodeCompatCategory, NodeCompatTestEntry, classify_test, load_node_compat_config, strip_jsonc_comments, }; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; +use std::path::Path; use test_r::test; const CONFIG_PATH: &str = "tests/node_compat/config.jsonc"; const REPORT_PATH: &str = "tests/node_compat/report.md"; +const SUITE_ROOT: &str = "tests/node_compat/suite"; #[derive(Debug, Clone)] struct InventoryItem { @@ -141,6 +143,85 @@ fn generate_node_compat_config_report() -> anyhow::Result<()> { Ok(()) } +#[test] +fn module_related_node_compat_entries_are_configured() -> anyhow::Result<()> { + let entries = load_node_compat_config(CONFIG_PATH)?; + let configured: BTreeSet<_> = entries.into_iter().map(|entry| entry.path).collect(); + let expected = collect_module_related_entrypoints()?; + + let missing: Vec<_> = expected + .into_iter() + .filter(|entry| !configured.contains(entry)) + .collect(); + + assert!( + missing.is_empty(), + "module-related node_compat tests are vendored but missing from {CONFIG_PATH}:\n{}", + missing.join("\n") + ); + + Ok(()) +} + +fn collect_module_related_entrypoints() -> anyhow::Result> { + let mut entries = BTreeSet::new(); + collect_matching_files("es-module", is_es_module_entrypoint, &mut entries)?; + collect_matching_files("parallel", is_parallel_module_entrypoint, &mut entries)?; + collect_matching_files("sequential", is_sequential_module_entrypoint, &mut entries)?; + Ok(entries) +} + +fn collect_matching_files( + suite_dir: &str, + predicate: fn(&str) -> bool, + entries: &mut BTreeSet, +) -> anyhow::Result<()> { + let dir = Path::new(SUITE_ROOT).join(suite_dir); + for entry in fs::read_dir(&dir)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if predicate(&file_name) { + entries.insert(format!("{suite_dir}/{file_name}")); + } + } + Ok(()) +} + +fn is_js_entrypoint(name: &str) -> bool { + name.starts_with("test-") && (name.ends_with(".js") || name.ends_with(".mjs")) +} + +fn is_es_module_entrypoint(name: &str) -> bool { + is_js_entrypoint(name) +} + +fn is_parallel_module_entrypoint(name: &str) -> bool { + is_js_entrypoint(name) + && [ + "test-module-", + "test-module.", + "test-require-", + "test-require.", + "test-cjs-", + "test-cjs.", + "test-esm-", + "test-esm.", + "test-commonjs-", + "test-commonjs.", + ] + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +fn is_sequential_module_entrypoint(name: &str) -> bool { + is_js_entrypoint(name) && name.starts_with("test-module") +} + fn expand_entries(entries: &[NodeCompatTestEntry]) -> Vec { let mut items = Vec::new(); for entry in entries { From 4b2873e33e290463b7729a57d85d47cc496fcbb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 24 Jun 2026 20:02:49 +0200 Subject: [PATCH 27/42] enforce JSON import attributes --- crates/wasm-rquickjs/skeleton/src/internal.rs | 414 ++++++++++++++++-- .../src/module-resolution.js | 64 ++- tests/node_compat/config.jsonc | 14 +- tests/node_compat/report.md | 14 +- 4 files changed, 462 insertions(+), 44 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index e9fcc632..82b166c6 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -293,7 +293,8 @@ impl Loader for DataUrlLoader { ctx: &Ctx<'js>, path: &str, ) -> rquickjs::Result> { - let rest = path + let path_without_suffix = module_filesystem_path(path); + let rest = path_without_suffix .strip_prefix("data:") .ok_or_else(|| Error::new_loading(path))?; @@ -317,6 +318,13 @@ impl Loader for DataUrlLoader { let base_mime = metadata.split(';').next().unwrap_or(metadata).trim(); if base_mime == "application/json" { + if import_attr_type_from_path(path) != Some("json") { + let escaped = DataUrlLoader::js_string_escape(path); + let module_source = format!( + "await Promise.reject(Object.assign(new TypeError('Module \"{escaped}\" needs an import attribute of type: json'), {{code: 'ERR_IMPORT_ATTRIBUTE_MISSING'}}));\n" + ); + return Module::declare(ctx.clone(), path, module_source.as_bytes().to_vec()); + } // Validate JSON by attempting a simple parse check. // For valid JSON: embed directly as a JS literal. // For invalid JSON: throw a SyntaxError with V8-compatible message. @@ -387,6 +395,8 @@ fn base64_decode(input: &str) -> Option> { Some(buf) } +const IMPORT_TYPE_QUERY_PREFIX: &str = "__wasm_rquickjs_import_type="; + /// Process static import attributes in JavaScript module source code. /// /// Handles patterns like `import "specifier" with { type: "json" }`. @@ -413,26 +423,83 @@ fn process_static_import_attrs(source: &str, module_path: &str) -> String { let import_start = i; i += 6; - // Skip whitespace + let mut spec_literal_start = None; + let mut spec_literal_end = None; + let mut specifier_start = None; + let mut specifier_end = None; + while i < len && bytes[i].is_ascii_whitespace() { i += 1; } - // Check for string literal (bare import: import "spec") + if i < len && bytes[i] == b'(' { + if let Some((rewritten, next)) = rewrite_dynamic_import_call(source, import_start, i) { + result.push_str(&rewritten); + i = next; + continue; + } + result.push_str(&source[import_start..i]); + continue; + } + if i < len && (bytes[i] == b'"' || bytes[i] == b'\'') { + spec_literal_start = Some(i); let quote = bytes[i]; i += 1; - let spec_start = i; + specifier_start = Some(i); while i < len && bytes[i] != quote { if bytes[i] == b'\\' { i += 1; } i += 1; } - let spec_end = i; + specifier_end = Some(i); if i < len { i += 1; // skip closing quote } + spec_literal_end = Some(i); + } else { + while i < len { + if bytes[i] == b'f' + && i + 4 <= len + && &source[i..i + 4] == "from" + && (i == 0 || !is_id_char(bytes[i - 1])) + && (i + 4 >= len || !is_id_char(bytes[i + 4])) + { + let mut j = i + 4; + while j < len && bytes[j].is_ascii_whitespace() { + j += 1; + } + if j < len && (bytes[j] == b'"' || bytes[j] == b'\'') { + spec_literal_start = Some(j); + let quote = bytes[j]; + j += 1; + specifier_start = Some(j); + while j < len && bytes[j] != quote { + if bytes[j] == b'\\' { + j += 1; + } + j += 1; + } + specifier_end = Some(j); + if j < len { + j += 1; + } + spec_literal_end = Some(j); + i = j; + break; + } + } + if matches!(bytes[i], b';' | b'\n' | b'\r') { + break; + } + i += 1; + } + } + + if let (Some(spec_lit_start), Some(spec_lit_end), Some(spec_start), Some(spec_end)) = + (spec_literal_start, spec_literal_end, specifier_start, specifier_end) + { let specifier = &source[spec_start..spec_end]; // Skip whitespace @@ -441,6 +508,14 @@ fn process_static_import_attrs(source: &str, module_path: &str) -> String { i += 1; } + if i + 6 <= len + && &source[i..i + 6] == "assert" + && (i + 6 >= len || !is_id_char(bytes[i + 6])) + { + return "await Promise.reject(new SyntaxError('Unexpected identifier'));\n" + .to_string(); + } + // Check for 'with' keyword (not 'with(' which is a with-statement) if i + 4 <= len && &source[i..i + 4] == "with" @@ -490,14 +565,17 @@ fn process_static_import_attrs(source: &str, module_path: &str) -> String { } // Valid: strip the with clause, keep everything else - result.push_str(&source[import_start..after_spec]); - // Skip any remaining content after the with block - // and append the rest of the source + result.push_str(&source[import_start..spec_lit_start]); + result.push_str(&rewrite_import_specifier_literal( + &source[spec_lit_start..spec_lit_end], + specifier, + type_value.as_deref(), + )); + result.push_str(&source[spec_lit_end..after_spec]); while i < len && bytes[i].is_ascii_whitespace() { i += 1; } - result.push_str(&source[i..]); - return result; + continue; } else { // 'with' not followed by '{', not import attrs i = with_start; @@ -505,7 +583,14 @@ fn process_static_import_attrs(source: &str, module_path: &str) -> String { continue; } } - // No 'with' keyword, output as-is + + let format = determine_data_url_format(specifier); + if let Some(error_module) = + validate_static_import_attrs(None, format, specifier, module_path) + { + return error_module; + } + result.push_str(&source[import_start..i]); continue; } @@ -517,13 +602,245 @@ fn process_static_import_attrs(source: &str, module_path: &str) -> String { continue; } - result.push(bytes[i] as char); - i += 1; + if let Some(ch) = source[i..].chars().next() { + result.push(ch); + i += ch.len_utf8(); + } else { + break; + } } result } +fn rewrite_import_specifier_literal(literal: &str, specifier: &str, type_value: Option<&str>) -> String { + if type_value != Some("json") { + return literal.to_string(); + } + let rewritten = append_import_type_query(specifier, "json"); + format!("\"{}\"", escape_js_string(&rewritten)) +} + +fn rewrite_dynamic_import_call( + source: &str, + import_start: usize, + open_paren: usize, +) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + let len = bytes.len(); + let mut i = open_paren + 1; + while i < len && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i >= len || (bytes[i] != b'"' && bytes[i] != b'\'') { + return rewrite_dynamic_import_expression_call(source, open_paren); + } + + let quote = bytes[i]; + let spec_literal_start = i; + i += 1; + let spec_start = i; + while i < len && bytes[i] != quote { + if bytes[i] == b'\\' { + i += 1; + } + i += 1; + } + if i >= len { + return None; + } + let spec_end = i; + i += 1; + let spec_literal_end = i; + let specifier = &source[spec_start..spec_end]; + + while i < len && bytes[i].is_ascii_whitespace() { + i += 1; + } + + if i < len && bytes[i] == b')' { + return None; + } + if i >= len || bytes[i] != b',' { + return None; + } + i += 1; + let options_start = i; + let mut paren_depth = 1usize; + let mut brace_depth = 0usize; + while i < len { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(source, i); + continue; + } + b'(' => paren_depth += 1, + b')' => { + paren_depth = paren_depth.saturating_sub(1); + if paren_depth == 0 { + let options = &source[options_start..i]; + let type_value = extract_dynamic_import_attr_type_value(options); + let format = determine_data_url_format(specifier); + if let Some((code, message)) = + validate_import_attrs_error(type_value.as_deref(), format, specifier) + { + return Some((import_attr_error_expression(&code, &message), i + 1)); + } + let spec_literal = if type_value.as_deref() == Some("json") { + rewrite_import_specifier_literal( + &source[spec_literal_start..spec_literal_end], + specifier, + type_value.as_deref(), + ) + } else { + source[spec_literal_start..spec_literal_end].to_string() + }; + return Some((format!("import({spec_literal})"), i + 1)); + } + } + b'{' => brace_depth += 1, + b'}' => brace_depth = brace_depth.saturating_sub(1), + _ => {} + } + i += 1; + } + + let _ = import_start; + let _ = brace_depth; + None +} + +fn rewrite_dynamic_import_expression_call(source: &str, open_paren: usize) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + let len = bytes.len(); + let mut i = open_paren + 1; + let expr_start = i; + let mut paren_depth = 0usize; + let mut bracket_depth = 0usize; + let mut brace_depth = 0usize; + while i < len { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(source, i); + continue; + } + b'(' => paren_depth += 1, + b')' if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 => return None, + b')' => paren_depth = paren_depth.saturating_sub(1), + b'[' => bracket_depth += 1, + b']' => bracket_depth = bracket_depth.saturating_sub(1), + b'{' => brace_depth += 1, + b'}' => brace_depth = brace_depth.saturating_sub(1), + b',' if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 => break, + _ => {} + } + i += 1; + } + if i >= len || bytes[i] != b',' { + return None; + } + let expr = source[expr_start..i].trim(); + i += 1; + let options_start = i; + let mut call_paren_depth = 1usize; + while i < len { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(source, i); + continue; + } + b'(' => call_paren_depth += 1, + b')' => { + call_paren_depth = call_paren_depth.saturating_sub(1); + if call_paren_depth == 0 { + let options = &source[options_start..i]; + let type_value = extract_dynamic_import_attr_type_value(options); + let type_literal = type_value + .as_deref() + .map(|value| format!("\"{}\"", escape_js_string(value))) + .unwrap_or_else(|| "null".to_string()); + return Some(( + format!( + "import(globalThis.__wasm_rquickjs_import_attr_specifier({}, {}))", + expr, type_literal + ), + i + 1, + )); + } + } + _ => {} + } + i += 1; + } + None +} + +fn extract_dynamic_import_attr_type_value(options: &str) -> Option { + let bytes = options.as_bytes(); + let len = bytes.len(); + let mut i = 0usize; + while i < len { + if bytes[i] == b'w' + && i + 4 <= len + && &options[i..i + 4] == "with" + && (i == 0 || !is_id_char(bytes[i - 1])) + && (i + 4 >= len || !is_id_char(bytes[i + 4])) + { + i += 4; + while i < len && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i < len && bytes[i] == b':' { + i += 1; + } + while i < len && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i < len && bytes[i] == b'{' { + let attrs_start = i + 1; + let mut depth = 1usize; + i += 1; + while i < len && depth > 0 { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_string_or_template(options, i); + continue; + } + b'{' => depth += 1, + b'}' => depth = depth.saturating_sub(1), + _ => {} + } + i += 1; + } + let attrs_end = i.saturating_sub(1); + return extract_attr_type_value(&options[attrs_start..attrs_end]); + } + } + i += 1; + } + None +} + +fn append_import_type_query(specifier: &str, import_type: &str) -> String { + let (base, suffix) = split_module_path_suffix(specifier); + let separator = if suffix.is_empty() { "?" } else { "&" }; + format!("{base}{suffix}{separator}{IMPORT_TYPE_QUERY_PREFIX}{import_type}") +} + +fn import_attr_type_from_path(path: &str) -> Option<&str> { + let suffix = split_module_path_suffix(path).1; + if suffix.is_empty() { + return None; + } + let query = suffix + .strip_prefix('?') + .or_else(|| suffix.strip_prefix('#')) + .unwrap_or(suffix); + query + .split(['&', '#']) + .find_map(|part| part.strip_prefix(IMPORT_TYPE_QUERY_PREFIX)) +} + fn is_id_char(b: u8) -> bool { b.is_ascii_alphanumeric() || b == b'_' || b == b'$' } @@ -618,8 +935,13 @@ fn determine_data_url_format(specifier: &str) -> Option<&'static str> { _ => None, }; } - } else if specifier.ends_with(".json") { + } else if module_filesystem_path(specifier).ends_with(".json") { return Some("json"); + } else if module_filesystem_path(specifier).ends_with(".js") + || module_filesystem_path(specifier).ends_with(".mjs") + || module_filesystem_path(specifier).ends_with(".cjs") + { + return Some("module"); } None } @@ -631,22 +953,33 @@ fn validate_static_import_attrs( specifier: &str, _module_path: &str, ) -> Option { + let (code, message) = validate_import_attrs_error(type_value, format, specifier)?; + Some(import_attr_error_module_source(&code, &message)) +} + +fn validate_import_attrs_error( + type_value: Option<&str>, + format: Option<&str>, + specifier: &str, +) -> Option<(String, String)> { if let Some(tv) = type_value { match tv { "json" => { if format == Some("module") { - return Some( - "await Promise.reject(Object.assign(new TypeError('Cannot use import attributes to change the type of a JavaScript module'), {code: 'ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE'}));\n".to_string() - ); + return Some(( + "ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE".to_string(), + "Cannot use import attributes to change the type of a JavaScript module" + .to_string(), + )); } } "css" => { // CSS is a recognized type, let loader handle it } other => { - let escaped_type = DataUrlLoader::js_string_escape(other); - return Some(format!( - "await Promise.reject(Object.assign(new TypeError('Import attribute type \"{escaped_type}\" is not supported'), {{code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED'}}));\n" + return Some(( + "ERR_IMPORT_ATTRIBUTE_UNSUPPORTED".to_string(), + format!("Import attribute type \"{other}\" is not supported"), )); } } @@ -654,15 +987,27 @@ fn validate_static_import_attrs( // Check for missing required attributes (JSON without type: "json") if format == Some("json") && type_value != Some("json") { - let escaped = DataUrlLoader::js_string_escape(specifier); - return Some(format!( - "await Promise.reject(Object.assign(new TypeError('Module \"{escaped}\" needs an import attribute of type: json'), {{code: 'ERR_IMPORT_ATTRIBUTE_MISSING'}}));\n" + return Some(( + "ERR_IMPORT_ATTRIBUTE_MISSING".to_string(), + format!("Module \"{specifier}\" needs an import attribute of type: json"), )); } None } +fn import_attr_error_module_source(code: &str, message: &str) -> String { + format!("await {};\n", import_attr_error_expression(code, message)) +} + +fn import_attr_error_expression(code: &str, message: &str) -> String { + let escaped_message = DataUrlLoader::js_string_escape(message); + let escaped_code = DataUrlLoader::js_string_escape(code); + format!( + "Promise.reject(Object.assign(new TypeError('{escaped_message}'), {{code: '{escaped_code}'}}))" + ) +} + fn esm_preflight_error_module_source(source: &str, package_type_module_js: bool) -> Option { if package_type_module_js { let cjs_global = find_bare_cjs_global_in_esm(source); @@ -3747,7 +4092,7 @@ impl Loader for CjsCompatLoader { return Err(Error::new_loading(path)); } - let source = match std::fs::read_to_string(fs_path) { + let mut source = match std::fs::read_to_string(fs_path) { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { let globals = ctx.globals(); @@ -3761,6 +4106,7 @@ impl Loader for CjsCompatLoader { }; let fs_abs_path = ensure_absolute_path(fs_path); + source = process_static_import_attrs(&source, path); let filename = Some(fs_abs_path.clone()); let url = path_to_file_url(path); @@ -4175,6 +4521,9 @@ fn inject_import_meta_prologue(init: &ImportMetaInit, source: &str) -> String { "Object.defineProperties(import.meta,{{{}}});", props.join(",") ); + prologue.push_str( + r##"if(!globalThis.__wasm_rquickjs_import_attr_specifier){Object.defineProperty(globalThis,"__wasm_rquickjs_import_attr_specifier",{value:(s,t)=>{const v=String(s);const b=v.split(/[?#]/,1)[0];let f=null;if(v.startsWith("data:")){const c=v.indexOf(",");const m=(c<0?v.slice(5):v.slice(5,c)).split(";")[0];if(m==="application/json")f="json";else if(m==="text/javascript"||m==="application/javascript")f="module";else if(m==="text/css")f="css";}else if(b.endsWith(".json"))f="json";else if(b.endsWith(".js")||b.endsWith(".mjs")||b.endsWith(".cjs"))f="module";function e(c,m){return"data:text/javascript,"+encodeURIComponent(`await Promise.reject(Object.assign(new TypeError(${JSON.stringify(m)}),{code:${JSON.stringify(c)}}));`)}if(t&&t!=="json"&&t!=="css")return e("ERR_IMPORT_ATTRIBUTE_UNSUPPORTED",`Import attribute type "${t}" is not supported`);if(t==="json"&&f==="module")return e("ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE","Cannot use import attributes to change the type of a JavaScript module");if(f==="json"&&t!=="json")return e("ERR_IMPORT_ATTRIBUTE_MISSING",`Module "${v}" needs an import attribute of type: json`);if(t==="json"){const h=v.indexOf("#");const p=h<0?v.length:h;const q=v.indexOf("?");const sep=q>=0&&q s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { let globals = ctx.globals(); @@ -4228,6 +4577,7 @@ impl Loader for ImportMetaLoader { let fs_abs_path = ensure_absolute_path(fs_path); let module_abs_path = ensure_absolute_path(path); + source = process_static_import_attrs(&source, path); let std_path = std::path::Path::new(&fs_abs_path); let filename = Some(fs_abs_path.clone()); let dirname = std_path.parent().map(|p| p.to_string_lossy().into_owned()); @@ -4305,9 +4655,17 @@ impl Loader for JsonFileLoader { } let source = std::fs::read_to_string(fs_path).map_err(|_| Error::new_loading(path))?; - let module_source = if DataUrlLoader::is_valid_json(&source) { - let escaped = DataUrlLoader::js_string_escape(&source); - format!("export default JSON.parse('{escaped}');\n") + let module_source = if import_attr_type_from_path(path) != Some("json") { + let escaped = DataUrlLoader::js_string_escape(path); + format!( + "await Promise.reject(Object.assign(new TypeError('Module \"{escaped}\" needs an import attribute of type: json'), {{code: 'ERR_IMPORT_ATTRIBUTE_MISSING'}}));\n" + ) + } else if DataUrlLoader::is_valid_json(&source) { + format!( + "import {{ createRequire as __wasm_rquickjs_createRequire }} from 'node:module';\nconst __wasm_rquickjs_require = __wasm_rquickjs_createRequire(\"{}\");\nexport default __wasm_rquickjs_require(\"{}\");\n", + escape_js_string(fs_path), + escape_js_string(fs_path) + ) } else { DataUrlLoader::make_json_error_module(&source) }; diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index edcdbc9e..f314180b 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -28,6 +28,19 @@ async function expectImportRejectsMessage(specifier, pattern) { } } +async function expectImportRejectsCode(specifier, code) { + let thrown = false; + try { + await import(specifier); + } catch (error) { + thrown = true; + assert.strictEqual(error && error.code, code, error && error.stack ? error.stack : String(error)); + } + if (!thrown) { + throw new Error(`Expected import(${specifier}) to reject with ${code}`); + } +} + function writeImportEntry(path, specifier) { fs.writeFileSync(path, `export default await import(${JSON.stringify(specifier)});`); } @@ -152,7 +165,7 @@ export const testEsmPackageMapEdgeCases = async () => { return true; } catch (error) { console.error(error); - return false; + throw error; } }; @@ -228,7 +241,7 @@ export const testCjsDirectNamedExports = async () => { return true; } catch (error) { console.error(error); - return false; + throw error; } }; @@ -676,6 +689,41 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { ' twoUrl: two.url,', '};', ].join('\n')); + fs.writeFileSync('/module-syntax-app/attr-data.json', JSON.stringify({ one: 1 })); + fs.writeFileSync('/module-syntax-app/attr-cjs.cjs', [ + 'exports.data = require("./attr-data.json");', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/attr-entry.mjs', [ + 'import data from "./attr-data.json" with { type: "json" };', + 'import dataWithQuery from "./attr-data.json?cache" with { type: "json" };', + 'import cjs from "./attr-cjs.cjs";', + 'export default {', + ' data,', + ' dataWithQuery,', + ' sameAsCjs: data === cjs.data,', + ' querySameAsCjs: dataWithQuery === cjs.data,', + '};', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/attr-missing.mjs', [ + 'import data from "./attr-data.json";', + 'export default data;', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/attr-type-mismatch.mjs', [ + 'import value from "./static-source.mjs" with { type: "json" };', + 'export default value;', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/attr-unsupported.mjs', [ + 'import data from "./attr-data.json" with { type: "unsupported" };', + 'export default data;', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/attr-data-url-entry.mjs', [ + 'import data from "data:application/json,{%22two%22:2}" with { type: "json" };', + 'export default data;', + ].join('\n')); + fs.writeFileSync('/module-syntax-app/attr-data-url-missing.mjs', [ + 'import data from "data:application/json,{%22two%22:2}";', + 'export default data;', + ].join('\n')); fs.writeFileSync('/module-syntax-app/member-false-positive.js', [ 'const obj = { import: 1 };', 'obj.import;', @@ -817,6 +865,18 @@ export const testModuleSyntaxDetectionAndDiagnostics = async () => { oneUrl: 'file:///module-syntax-app/query.mjs?relative-one', twoUrl: 'file:///module-syntax-app/query.mjs?relative-two', }); + const attrEntry = (await import('/module-syntax-app/attr-entry.mjs')).default; + assert.deepStrictEqual(attrEntry, { + data: { one: 1 }, + dataWithQuery: { one: 1 }, + sameAsCjs: true, + querySameAsCjs: true, + }); + assert.deepStrictEqual((await import('/module-syntax-app/attr-data-url-entry.mjs')).default, { two: 2 }); + await expectImportRejectsCode('/module-syntax-app/attr-missing.mjs', 'ERR_IMPORT_ATTRIBUTE_MISSING'); + await expectImportRejectsCode('/module-syntax-app/attr-type-mismatch.mjs', 'ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE'); + await expectImportRejectsCode('/module-syntax-app/attr-unsupported.mjs', 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED'); + await expectImportRejectsCode('/module-syntax-app/attr-data-url-missing.mjs', 'ERR_IMPORT_ATTRIBUTE_MISSING'); assert.throws(() => require('/module-syntax-app/member-false-positive.js'), /unexpected|expecting|SyntaxError/i); assert.throws(() => require('/module-syntax-app/property-false-positive.js'), /unexpected|expecting|SyntaxError/i); diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 1f4f9b09..73bc955e 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5994,7 +5994,7 @@ "es-module/test-esm-detect-ambiguous.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-dns-promises.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, "es-module/test-esm-double-encoding.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, - "es-module/test-esm-dynamic-import-attribute.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, + "es-module/test-esm-dynamic-import-attribute.mjs": {}, "es-module/test-esm-dynamic-import-commonjs.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, "es-module/test-esm-dynamic-import-mutating-fs.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-encoded-path.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, @@ -6008,16 +6008,16 @@ "es-module/test-esm-forbidden-globals.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, "es-module/test-esm-fs-promises.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, "es-module/test-esm-import-assertion-warning.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-import-attributes-1.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, - "es-module/test-esm-import-attributes-2.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, - "es-module/test-esm-import-attributes-3.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, - "es-module/test-esm-import-attributes-errors.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, + "es-module/test-esm-import-attributes-1.mjs": {}, + "es-module/test-esm-import-attributes-2.mjs": {}, + "es-module/test-esm-import-attributes-3.mjs": {}, + "es-module/test-esm-import-attributes-errors.mjs": {}, "es-module/test-esm-import-flag.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-import-json-named-export.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-imports-deprecations.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-imports.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-initialization.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-json-cache.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, + "es-module/test-esm-json-cache.mjs": {}, "es-module/test-esm-json.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-live-binding.mjs": { "category": "known-gap", "reason": "module.syncBuiltinESMExports is not implemented" }, "es-module/test-esm-loader-chaining.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, @@ -6074,7 +6074,7 @@ "es-module/test-esm-type-flag-string-input.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-type-main.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-util-types.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, - "es-module/test-esm-virtual-json.mjs": { "category": "known-gap", "reason": "import attributes / JSON module runtime enforcement is incomplete" }, + "es-module/test-esm-virtual-json.mjs": { "category": "known-gap", "reason": "requires module.register loader hooks to synthesize virtual JSON modules" }, "es-module/test-esm-wasm.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-import-module-conditional-exports-module.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-loaders-unknown-builtin-module.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 2df639fe..7ee2894b 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3108/4404 (70.6%) +**Primary compatibility (CI-enforced):** 3114/4404 (70.7%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3108 | 70.6% | 54.3% | 45.4% | -| 🧩 known gap | 1296 | 29.4% | 22.7% | 18.9% | +| ✅ passing (runnable) | 3114 | 70.7% | 54.4% | 45.5% | +| 🧩 known gap | 1290 | 29.3% | 22.5% | 18.9% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3108/5721 (54.3%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3114/5721 (54.4%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 109 | 193 | 85 | 11 | 0 | 183 | 36.1% | 27.4% | +| other | 581 | 115 | 187 | 85 | 11 | 0 | 183 | 38.1% | 28.9% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1296) +### known gap (1290) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -712,7 +712,6 @@ Secondary full-public compatibility, including public tests that are currently e | process unhandledRejection/rejectionHandled/warning mode behavior is incomplete | 8 | `parallel/test-promise-unhandled-silent-no-hook.js`, `parallel/test-promise-unhandled-silent.js`, `parallel/test-promise-unhandled-warn-no-hook.js`, ... (+5) | | vm.constants.DONT_CONTEXTIFY and vanilla-context behavior are not implemented | 8 | `parallel/test-vm-context-dont-contextify.js#block_00_block_00`, `parallel/test-vm-context-dont-contextify.js#block_01_block_01`, `parallel/test-vm-context-dont-contextify.js#block_02_block_02`, ... (+5) | | common-shim spawnPromisified child emulation does not support --experimental-webstorage/--localstorage-file flags | 7 | `parallel/test-webstorage.js#test_01_emits_a_warning_when_used`, `parallel/test-webstorage.js#test_02_storage_instances_cannot_be_created_in_userland`, `parallel/test-webstorage.js#test_03_sessionstorage_is_not_persisted`, ... (+4) | -| import attributes / JSON module runtime enforcement is incomplete | 7 | `es-module/test-esm-dynamic-import-attribute.mjs`, `es-module/test-esm-import-attributes-1.mjs`, `es-module/test-esm-import-attributes-2.mjs`, ... (+4) | | inherited: Intl is not available in current runtime | 7 | `parallel/test-icu-transcode.js#block_00_block_00`, `parallel/test-icu-transcode.js#block_01_block_01`, `parallel/test-icu-transcode.js#block_02_test_that_uint8array_arguments_are_okay`, ... (+4) | | CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage | 6 | `es-module/test-esm-cjs-named-error.mjs`, `es-module/test-esm-cyclic-dynamic-import.mjs`, `es-module/test-esm-dynamic-import-commonjs.mjs`, ... (+3) | | WebAssembly global is missing in current runtime | 6 | `es-module/test-wasm-memory-out-of-bound.js`, `es-module/test-wasm-simple.js`, `es-module/test-wasm-web-api.js`, ... (+3) | @@ -1239,6 +1238,7 @@ Secondary full-public compatibility, including public tests that are currently e | requires actual TCP socket reuse with remotePort identity tracking via server; wasi:http creates new connections per request | 1 | `parallel/test-http-agent-scheduling.js` | | requires createConnection to forward keepAlive/keepAliveInitialDelay options; wasi:http does not use Agent.createConnection for outbound requests | 1 | `parallel/test-http-agent-keepalive-delay.js` | | requires fd option for listen | 1 | `parallel/test-net-listen-fd0.js` | +| requires module.register loader hooks to synthesize virtual JSON modules | 1 | `es-module/test-esm-virtual-json.mjs` | | requires net.createServer with pauseOnConnect and socket.localPort; wasi:http does not expose socket-level properties | 1 | `parallel/test-http-agent-reuse-drained-socket-only.js` | | requires onread option with buffer/callback | 1 | `parallel/test-net-onread-static-buffer.js` | | requires raw TCP response with obsolete HTTP line-folded headers; wasi:http rejects them | 1 | `parallel/test-http-multi-line-headers.js` | From dd4d0aa1050eb66361aeb21d68c79d644c66ce43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 24 Jun 2026 22:08:31 +0200 Subject: [PATCH 28/42] improve ESM builtin namespace parity --- .../wasm-rquickjs/skeleton/src/builtin/fs.js | 2 +- .../wasm-rquickjs/skeleton/src/builtin/mod.rs | 6 ++- .../skeleton/src/builtin/module.js | 20 +++++++- .../skeleton/src/builtin/process.js | 8 +++ .../skeleton/src/builtin/util.rs | 50 ++++++++++++++++++- tests/node_compat/config.jsonc | 36 ++++++------- tests/node_compat/report.md | 24 ++++++--- 7 files changed, 115 insertions(+), 31 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/fs.js b/crates/wasm-rquickjs/skeleton/src/builtin/fs.js index 1d9eb59d..43d8d01a 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/fs.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/fs.js @@ -3997,7 +3997,7 @@ export const promises = new Proxy({}, { // --- Internal helpers --- -function _toUnixTimestamp(time, name = 'time') { +export function _toUnixTimestamp(time, name = 'time') { if (typeof time === 'string' && +time == time) { return +time; } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs b/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs index a9a5c739..9415bd8e 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs @@ -128,7 +128,9 @@ pub fn add_module_resolvers( .with_module("__wasm_rquickjs_builtin/intl_native") .with_module("__wasm_rquickjs_builtin/intl") .with_module("node:util") + .with_module("node:util/types") .with_module("util") + .with_module("util/types") .with_module("__wasm_rquickjs_builtin/fs_native") .with_module("node:fs") .with_module("fs") @@ -371,7 +373,9 @@ pub fn module_loader() -> ( .with_module("__wasm_rquickjs_builtin/encoding", encoding::ENCODING_JS) .with_module("__wasm_rquickjs_builtin/intl", intl::INTL_JS) .with_module("node:util", util::UTIL_JS) - .with_module("util", util::REEXPORT_JS) + .with_module("node:util/types", util::UTIL_TYPES_JS) + .with_module("util", util::BARE_UTIL_REEXPORT_JS) + .with_module("util/types", util::UTIL_TYPES_JS) .with_module("base64-js", base64::BASE64_JS) .with_module("ieee754", ieee754::IEEE754_JS) .with_module("node:buffer", buffer::BUFFER_JS) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 2329e1f2..e21563a4 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -2881,7 +2881,22 @@ function runMain() { } } -const moduleExports = { +function Module(id, parent) { + this.id = id || ''; + this.path = ''; + this.exports = {}; + this.filename = null; + this.loaded = false; + this.children = []; + this.paths = []; + this.parent = parent || null; +} + +Module.prototype.require = function require(id) { + return globalRequire(id); +}; + +const moduleExports = Object.assign(Module, { require: globalRequire, createRequire, findPackageJSON, @@ -2898,7 +2913,8 @@ const moduleExports = { _stat: _stat, globalPaths: globalPaths, setSourceMapsSupport, -}; +}); +moduleExports.Module = Module; // Add self-reference so require('module') works builtinModuleMap['module'] = moduleExports; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/process.js b/crates/wasm-rquickjs/skeleton/src/builtin/process.js index 28a07b55..f88aa32f 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/process.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/process.js @@ -165,6 +165,13 @@ Object.defineProperty(process, 'title', { }); process.release = { name: 'node' }; process.allowedNodeEnvironmentFlags = new Set(); +process.report = {}; +Object.defineProperty(process, Symbol.toStringTag, { + value: 'process', + writable: true, + enumerable: false, + configurable: true, +}); let _startTime = null; @@ -770,6 +777,7 @@ export var cpuUsage = process.cpuUsage; export var memoryUsage = process.memoryUsage; export var uptime = process.uptime; export var release = process.release; +export var report = process.report; export var stdin = process.stdin; export var kill = process.kill; export var emitWarning = process.emitWarning; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/util.rs b/crates/wasm-rquickjs/skeleton/src/builtin/util.rs index ebe5dcb5..7ee9a7ac 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/util.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/util.rs @@ -2,4 +2,52 @@ pub const UTIL_JS: &str = include_str!("util.js"); // Re-export for aliases -pub const REEXPORT_JS: &str = r#"export * from 'node:util'; export { default } from 'node:util';"#; +pub const BARE_UTIL_REEXPORT_JS: &str = + r#"export * from 'node:util'; export { default } from 'node:util';"#; +pub const UTIL_TYPES_JS: &str = r#" +import { types as _types } from 'node:util'; + +export default _types; +export const isAnyArrayBuffer = _types.isAnyArrayBuffer; +export const isArgumentsObject = _types.isArgumentsObject; +export const isArrayBuffer = _types.isArrayBuffer; +export const isArrayBufferView = _types.isArrayBufferView; +export const isAsyncFunction = _types.isAsyncFunction; +export const isBigInt64Array = _types.isBigInt64Array; +export const isBigIntObject = _types.isBigIntObject; +export const isBigUint64Array = _types.isBigUint64Array; +export const isBooleanObject = _types.isBooleanObject; +export const isBoxedPrimitive = _types.isBoxedPrimitive; +export const isCryptoKey = _types.isCryptoKey; +export const isDataView = _types.isDataView; +export const isDate = _types.isDate; +export const isExternal = _types.isExternal; +export const isFloat32Array = _types.isFloat32Array; +export const isFloat64Array = _types.isFloat64Array; +export const isGeneratorFunction = _types.isGeneratorFunction; +export const isGeneratorObject = _types.isGeneratorObject; +export const isInt8Array = _types.isInt8Array; +export const isInt16Array = _types.isInt16Array; +export const isInt32Array = _types.isInt32Array; +export const isKeyObject = _types.isKeyObject; +export const isMap = _types.isMap; +export const isMapIterator = _types.isMapIterator; +export const isModuleNamespaceObject = _types.isModuleNamespaceObject; +export const isNativeError = _types.isNativeError; +export const isNumberObject = _types.isNumberObject; +export const isPromise = _types.isPromise; +export const isProxy = _types.isProxy; +export const isRegExp = _types.isRegExp; +export const isSet = _types.isSet; +export const isSetIterator = _types.isSetIterator; +export const isSharedArrayBuffer = _types.isSharedArrayBuffer; +export const isStringObject = _types.isStringObject; +export const isSymbolObject = _types.isSymbolObject; +export const isTypedArray = _types.isTypedArray; +export const isUint8Array = _types.isUint8Array; +export const isUint8ClampedArray = _types.isUint8ClampedArray; +export const isUint16Array = _types.isUint16Array; +export const isUint32Array = _types.isUint32Array; +export const isWeakMap = _types.isWeakMap; +export const isWeakSet = _types.isWeakSet; +"#; diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 73bc955e..884b1b19 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5983,8 +5983,8 @@ "es-module/test-wasm-web-api.js": { "category": "known-gap", "reason": "WebAssembly global is missing in current runtime" }, // === es-module completeness sweep (tracked after coverage audit) === - "es-module/test-esm-assert-strict.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, - "es-module/test-esm-basic-imports.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-assert-strict.mjs": {}, + "es-module/test-esm-basic-imports.mjs": { "category": "known-gap", "reason": "same-directory relative ESM import resolution differs in the node_compat split runner" }, "es-module/test-esm-child-process-fork-main.mjs": { "category": "wasi-impossible", "reason": "requires child_process.fork IPC semantics, which are not available in WASM" }, "es-module/test-esm-cjs-load-error-note.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-cjs-named-error.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, @@ -5992,12 +5992,12 @@ "es-module/test-esm-cyclic-dynamic-import.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, "es-module/test-esm-default-type.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-detect-ambiguous.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-dns-promises.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, - "es-module/test-esm-double-encoding.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-dns-promises.mjs": { "category": "known-gap", "reason": "dns.promises.lookupService address validation does not yet match Node" }, + "es-module/test-esm-double-encoding.mjs": { "category": "known-gap", "reason": "ESM resolver does not yet preserve Node-compatible double-encoded path semantics" }, "es-module/test-esm-dynamic-import-attribute.mjs": {}, "es-module/test-esm-dynamic-import-commonjs.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, "es-module/test-esm-dynamic-import-mutating-fs.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-encoded-path.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-encoded-path.mjs": { "category": "known-gap", "reason": "ESM resolver does not yet decode percent-encoded relative path segments like Node" }, "es-module/test-esm-example-loader.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, "es-module/test-esm-experimental-warnings.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-export-not-found.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, @@ -6005,8 +6005,8 @@ "es-module/test-esm-exports.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-extension-lookup-deprecation.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-extensionless-esm-and-wasm.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-forbidden-globals.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, - "es-module/test-esm-fs-promises.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-forbidden-globals.mjs": { "category": "known-gap", "reason": "ESM compatibility shim still exposes CJS globals such as __filename and __dirname" }, + "es-module/test-esm-fs-promises.mjs": { "category": "known-gap", "reason": "fs.promises file APIs do not yet accept file URL path arguments consistently" }, "es-module/test-esm-import-assertion-warning.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-import-attributes-1.mjs": {}, "es-module/test-esm-import-attributes-2.mjs": {}, @@ -6040,32 +6040,32 @@ "es-module/test-esm-loader-with-source.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, "es-module/test-esm-loader-with-syntax-error.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-loader.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, - "es-module/test-esm-main-lookup.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-main-lookup.mjs": { "category": "known-gap", "reason": "ESM main lookup and error-url behavior still differ from Node's resolver" }, "es-module/test-esm-module-not-found-commonjs-hint.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-named-exports.mjs": { "category": "known-gap", "reason": "module.syncBuiltinESMExports is not implemented" }, - "es-module/test-esm-namespace.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-namespace.mjs": {}, "es-module/test-esm-no-addons.mjs": { "category": "wasi-impossible", "reason": "requires worker_threads/native addon process isolation semantics, which are not available in single-threaded WASM" }, "es-module/test-esm-non-js.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-nowarn-exports.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-path-posix.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, - "es-module/test-esm-path-win32.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, - "es-module/test-esm-pkgname.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-path-posix.mjs": {}, + "es-module/test-esm-path-win32.mjs": {}, + "es-module/test-esm-pkgname.mjs": { "category": "known-gap", "reason": "invalid ESM package specifier validation and error codes do not yet match Node" }, "es-module/test-esm-preserve-symlinks-not-found-plain.mjs": { "category": "known-gap", "reason": "ESM preserve-symlinks / preserve-symlinks-main behavior is incomplete" }, "es-module/test-esm-preserve-symlinks-not-found.mjs": { "category": "known-gap", "reason": "ESM preserve-symlinks / preserve-symlinks-main behavior is incomplete" }, - "es-module/test-esm-process.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, - "es-module/test-esm-prototype-pollution.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-process.mjs": {}, + "es-module/test-esm-prototype-pollution.mjs": {}, "es-module/test-esm-recursive-cjs-dependencies.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, "es-module/test-esm-require-cache.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, "es-module/test-esm-resolve-type.mjs": { "category": "node-internals", "reason": "requires --expose-internals and node:internal/modules/esm/resolve" }, "es-module/test-esm-scope-node-modules.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-shared-loader-dep.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, - "es-module/test-esm-shebang.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-shebang.mjs": {}, "es-module/test-esm-snapshot.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, "es-module/test-esm-source-map.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-syntax-error.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-throw-undefined.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-throw-undefined.mjs": {}, "es-module/test-esm-tla-unfinished.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-tla.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-tla.mjs": {}, "es-module/test-esm-type-field.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-type-flag-cli-entry.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-type-flag-errors.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, @@ -6073,7 +6073,7 @@ "es-module/test-esm-type-flag-package-scopes.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-type-flag-string-input.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-type-main.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, - "es-module/test-esm-util-types.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-util-types.mjs": {}, "es-module/test-esm-virtual-json.mjs": { "category": "known-gap", "reason": "requires module.register loader hooks to synthesize virtual JSON modules" }, "es-module/test-esm-wasm.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-import-module-conditional-exports-module.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 7ee2894b..513fc3bf 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3114/4404 (70.7%) +**Primary compatibility (CI-enforced):** 3124/4404 (70.9%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3114 | 70.7% | 54.4% | 45.5% | -| 🧩 known gap | 1290 | 29.3% | 22.5% | 18.9% | +| ✅ passing (runnable) | 3124 | 70.9% | 54.6% | 45.7% | +| 🧩 known gap | 1280 | 29.1% | 22.4% | 18.7% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3114/5721 (54.4%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3124/5721 (54.6%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 115 | 187 | 85 | 11 | 0 | 183 | 38.1% | 28.9% | +| other | 581 | 125 | 177 | 85 | 11 | 0 | 183 | 41.4% | 31.4% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1290) +### known gap (1280) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -689,10 +689,8 @@ Secondary full-public compatibility, including public tests that are currently e | requires simulated process.execPath / Node CLI mode support deferred to follow-up PR | 36 | `es-module/test-esm-cjs-load-error-note.mjs`, `es-module/test-esm-detect-ambiguous.mjs`, `es-module/test-esm-dynamic-import-mutating-fs.mjs`, ... (+33) | | stream edge case not yet handled | 22 | `parallel/test-stream-compose.js#block_17_block_17`, `parallel/test-stream-drop-take.js#block_01_don_t_wait_for_next_item_in_the_original_stream_when_already`, `parallel/test-stream-duplex-from.js#block_17_block_17`, ... (+19) | | process.permission and --permission CLI semantics are incomplete in execPath emulation | 18 | `parallel/test-cli-permission-deny-fs.js#block_00_block_00`, `parallel/test-cli-permission-deny-fs.js#block_01_block_01`, `parallel/test-cli-permission-deny-fs.js#block_02_block_02`, ... (+15) | -| newly tracked module coverage; same-process ESM behavior has not been triaged yet | 17 | `es-module/test-esm-assert-strict.mjs`, `es-module/test-esm-basic-imports.mjs`, `es-module/test-esm-dns-promises.mjs`, ... (+14) | | wasi:sockets UDP implementation crashes in wasmtime | 14 | `parallel/test-dgram-connect-send-callback-buffer.js`, `parallel/test-dgram-connect-send-callback-multi-buffer.js`, `parallel/test-dgram-connect-send-default-host.js`, ... (+11) | | domain module depends on async_hooks, not fully working | 13 | `parallel/test-domain-promise.js#block_00_block_00`, `parallel/test-domain-promise.js#block_01_block_01`, `parallel/test-domain-promise.js#block_03_block_03`, ... (+10) | -| ESM package type/exports/imports behavior needs resolver unification triage | 12 | `es-module/test-esm-custom-exports.mjs`, `es-module/test-esm-default-type.mjs`, `es-module/test-esm-exports-deprecations.mjs`, ... (+9) | | custom ESM loader hooks / module.register are not implemented | 12 | `es-module/test-esm-example-loader.mjs`, `es-module/test-esm-loader-custom-condition.mjs`, `es-module/test-esm-loader-dependency.mjs`, ... (+9) | | inherited: dns.getServers()/setServers default-server behavior and validation are not Node-compatible | 12 | `parallel/test-dns.js#block_00_verify_that_setservers_handles_arrays_with_holes_and_other_o`, `parallel/test-dns.js#block_01_block_01`, `parallel/test-dns.js#block_02_block_02`, ... (+9) | | node:readline module is not yet supported in WebAssembly environment | 12 | `parallel/test-readline-keys.js`, `parallel/test-readline-position.js`, `parallel/test-readline-reopen.js`, ... (+9) | @@ -701,6 +699,7 @@ Secondary full-public compatibility, including public tests that are currently e | net.js TCP implementation incomplete - needs event handling and API fixes | 11 | `parallel/test-net-connect-nodelay.js`, `parallel/test-net-connect-paused-connection.js`, `parallel/test-net-during-close.js`, ... (+8) | | remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct node modules app same-process module graph coverage lives in tests/node_modules_apps | 11 | `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_00_a_mjs_b_cjs_c_mjs_a_mjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_01_b_cjs_c_mjs_a_mjs_b_cjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_02_c_mjs_a_mjs_b_cjs_c_mjs`, ... (+8) | | wasi:sockets UDP implementation hangs in wasmtime | 11 | `parallel/test-dgram-implicit-bind.js`, `parallel/test-dgram-multicast-set-interface.js#block_00_block_00`, `parallel/test-dgram-multicast-set-interface.js#block_02_block_02`, ... (+8) | +| ESM package type/exports/imports behavior needs resolver unification triage | 10 | `es-module/test-esm-custom-exports.mjs`, `es-module/test-esm-default-type.mjs`, `es-module/test-esm-exports-deprecations.mjs`, ... (+7) | | dgram multicast membership APIs are not implemented (ENOSYS) | 10 | `parallel/test-dgram-membership.js#block_02_addmembership_with_no_argument_should_throw`, `parallel/test-dgram-membership.js#block_03_dropmembership_with_no_argument_should_throw`, `parallel/test-dgram-membership.js#block_04_addmembership_with_invalid_multicast_address_should_throw`, ... (+7) | | async_hooks not fully implemented | 9 | `parallel/test-async-hooks-destroy-on-gc.js`, `parallel/test-async-hooks-disable-during-promise.js`, `parallel/test-async-hooks-disable-gc-tracking.js`, ... (+6) | | module SourceMap/findSourceMap API is not fully implemented | 9 | `parallel/test-source-map-api.js#block_00_it_should_throw_with_invalid_args`, `parallel/test-source-map-api.js#block_01_findsourcemap_should_return_undefined_when_no_source_map_is_`, `parallel/test-source-map-api.js#block_02_non_exceptional_case`, ... (+6) | @@ -867,7 +866,11 @@ Secondary full-public compatibility, including public tests that are currently e | ECDH key import/deriveKey compatibility for test vectors is incomplete | 1 | `parallel/test-webcrypto-derivekey-ecdh.js` | | ECDSA key import/sign/verify compatibility for test vectors is incomplete | 1 | `parallel/test-webcrypto-sign-verify-ecdsa.js` | | ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG behavior is not implemented | 1 | `parallel/test-vm-dynamic-import-callback-missing-flag.js` | +| ESM compatibility shim still exposes CJS globals such as __filename and __dirname | 1 | `es-module/test-esm-forbidden-globals.mjs` | | ESM directory import errors do not match Node ERR_UNSUPPORTED_DIR_IMPORT behavior | 1 | `parallel/test-directory-import.js` | +| ESM main lookup and error-url behavior still differ from Node's resolver | 1 | `es-module/test-esm-main-lookup.mjs` | +| ESM resolver does not yet decode percent-encoded relative path segments like Node | 1 | `es-module/test-esm-encoded-path.mjs` | +| ESM resolver does not yet preserve Node-compatible double-encoded path semantics | 1 | `es-module/test-esm-double-encoding.mjs` | | EdDSA sign/verify vector compatibility is incomplete | 1 | `parallel/test-webcrypto-sign-verify-eddsa.js` | | Error.prepareStackTrace default behavior is incomplete | 1 | `parallel/test-error-prepare-stack-trace.js` | | EventEmitter captureRejections option validation/behavior is incomplete | 1 | `parallel/test-event-capture-rejections.js` | @@ -1077,6 +1080,7 @@ Secondary full-public compatibility, including public tests that are currently e | diagnostics_channel runStores transformer-error propagation is incomplete | 1 | `parallel/test-diagnostics-channel-bind-store.js` | | diagnostics_channel subscriber-throw path does not surface uncaughtException handling like Node.js | 1 | `parallel/test-diagnostics-channel-safe-subscriber-errors.js` | | dns perf_hooks integration not implemented | 1 | `parallel/test-dns-perf_hooks.js` | +| dns.promises.lookupService address validation does not yet match Node | 1 | `es-module/test-esm-dns-promises.mjs` | | dns.promises.lookupService is not implemented (returns ENOTIMP) | 1 | `parallel/test-dns-lookupService-promises.js` | | domain error propagation across node:http server/client callbacks is incomplete | 1 | `parallel/test-domain-multi.js` | | domain error/nextTick behavior depends on async_hooks semantics that are incomplete | 1 | `sequential/test-next-tick-error-spin.js` | @@ -1115,6 +1119,7 @@ Secondary full-public compatibility, including public tests that are currently e | fs symlink permission checks are incomplete | 1 | `parallel/test-permission-fs-symlink-relative.js` | | fs.constants includes Linux-only O_NOATIME even when common.isLinux is false in WASM | 1 | `parallel/test-process-constants-noatime.js` | | fs.globSync API is not implemented | 1 | `parallel/test-icu-env.js` | +| fs.promises file APIs do not yet accept file URL path arguments consistently | 1 | `es-module/test-esm-fs-promises.mjs` | | fs.watch directory watcher filename/null and event delivery semantics are not Node-compatible | 1 | `sequential/test-fs-watch.js#block_02_block_02` | | fs.watch emits duplicate change events for a single write | 1 | `sequential/test-fs-watch.js#block_00_block_00` | | fs.watch path watcher emits duplicate change events | 1 | `sequential/test-fs-watch.js#block_01_block_01` | @@ -1148,6 +1153,7 @@ Secondary full-public compatibility, including public tests that are currently e | indexed property definitions on vm globals do not propagate to the sandbox | 1 | `parallel/test-vm-indexed-properties.js` | | inherited: Resolver#setLocalAddress validation/error behavior is not implemented | 1 | `parallel/test-dns-setlocaladdress.js#block_01_verify_that_setlocaladdress_throws_if_called_with_an_invalid` | | invalid EC private keys do not raise Node-compatible DataError | 1 | `parallel/test-webcrypto-export-import-ec.js#block_01_bad_private_keys` | +| invalid ESM package specifier validation and error codes do not yet match Node | 1 | `es-module/test-esm-pkgname.mjs` | | invalid repeated Transfer-Encoding handling differs from Node | 1 | `parallel/test-http-transfer-encoding-repeated-chunked.js` | | keep-alive free-socket lifecycle (free event + req.destroyed transitions) is not Node-compatible | 1 | `parallel/test-http-keepalive-free.js` | | keep-alive request sequencing with unread request bodies has non-Node lifecycle behavior | 1 | `parallel/test-http-no-read-no-dump.js` | @@ -1165,6 +1171,7 @@ Secondary full-public compatibility, including public tests that are currently e | net.BlockList with autoSelectFamily and multiple lookup addresses does not yet raise ERR_IP_BLOCKED before connection attempts | 1 | `parallel/test-net-blocklist.js#block_03_connect_with_autoselectfamily_and_multiple_ips` | | net.Server blockList enforcement is incomplete | 1 | `parallel/test-net-server-blocklist.js` | | net.Server captureRejections async error propagation is incomplete | 1 | `parallel/test-net-server-capture-rejection.js` | +| newly tracked module coverage; same-process ESM behavior has not been triaged yet | 1 | `es-module/test-esm-snapshot.mjs` | | node-compat runner drainAsync() relies on global setTimeout after this test deletes timer globals | 1 | `parallel/test-timers-api-refs.js` | | node:http abort/destroy response lifecycle (aborted/error/close ordering) is incomplete | 1 | `parallel/test-http-abort-client.js` | | node:http client path does not honor/verify net.Socket connect noDelay semantics like Node | 1 | `parallel/test-http-nodelay.js` | @@ -1253,6 +1260,7 @@ Secondary full-public compatibility, including public tests that are currently e | runInNewContext sandbox binding and write-back semantics are incomplete | 1 | `parallel/test-vm-run-in-new-context.js` | | runInThisContext/runInContext sloppy-mode var/delete semantics are incorrect | 1 | `parallel/test-vm-not-strict.js` | | same-component node:http client->server calls via wasi:http can deadlock in this scenario | 1 | `parallel/test-http-write-head-after-set-header.js` | +| same-directory relative ESM import resolution differs in the node_compat split runner | 1 | `es-module/test-esm-basic-imports.mjs` | | sendBlockList connect path can crash in WASI UDP implementation | 1 | `parallel/test-dgram-blocklist.js#block_00_block_00` | | sendBlockList send() callback path is not Node-compatible and can hang | 1 | `parallel/test-dgram-blocklist.js#block_01_block_01` | | sequential path is stale in vendored suite; equivalent Upgrade timeout-disabling semantics are not Node-compatible | 1 | `sequential/test-http-server-request-timeout-upgrade.js` | From 3f44c1559b26dd14bdaa29f094434dd258f057ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 24 Jun 2026 22:42:10 +0200 Subject: [PATCH 29/42] support file URLs in fs promises --- .../wasm-rquickjs/skeleton/src/builtin/fs.js | 12 +++- .../skeleton/src/builtin/fs_promises.js | 70 +++++++++++-------- tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 13 ++-- 4 files changed, 57 insertions(+), 40 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/fs.js b/crates/wasm-rquickjs/skeleton/src/builtin/fs.js index 43d8d01a..34b1e593 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/fs.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/fs.js @@ -47,6 +47,7 @@ let _Readable = null; let _Writable = null; let _EventEmitter = null; let _PathModule = null; +let _UrlModule = null; function getStreamClasses() { if (!_Readable) { const stream = require('node:stream'); @@ -67,6 +68,12 @@ function getPathModule() { } return _PathModule; } +function getUrlModule() { + if (!_UrlModule) { + _UrlModule = require('node:url'); + } + return _UrlModule; +} // --- Constants --- const F_OK = 0; @@ -429,8 +436,7 @@ function validatePath(path, propName) { // Delegate to fileURLToPath for proper validation - it throws // ERR_INVALID_URL_SCHEME, ERR_INVALID_FILE_URL_HOST, ERR_INVALID_FILE_URL_PATH // matching Node.js behavior. - const urlModule = require('node:url'); - const converted = urlModule.fileURLToPath(path); + const converted = getUrlModule().fileURLToPath(path); if (converted.indexOf('\u0000') !== -1) { const err = new TypeError(`The argument '${propName || 'path'}' must be a string, Uint8Array, or URL without null bytes. Received ${JSON.stringify(converted)}`); err.code = 'ERR_INVALID_ARG_VALUE'; @@ -460,7 +466,7 @@ function pathToString(path) { } if (path instanceof URL) { if (path.protocol !== 'file:') return path.toString(); - return require('node:url').fileURLToPath(path); + return getUrlModule().fileURLToPath(path); } return String(path); } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/fs_promises.js b/crates/wasm-rquickjs/skeleton/src/builtin/fs_promises.js index ce2de3be..c92bf39e 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/fs_promises.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/fs_promises.js @@ -59,6 +59,7 @@ function getStats() { let _EventEmitter = null; let _PathModule = null; +let _UrlModule = null; function getEventEmitter() { if (!_EventEmitter) { const events = require('node:events'); @@ -73,6 +74,12 @@ function getPathModule() { } return _PathModule; } +function getUrlModule() { + if (!_UrlModule) { + _UrlModule = require('node:url'); + } + return _UrlModule; +} function wrapStat(statObj, options) { const S = getStats(); @@ -218,8 +225,7 @@ function pathToString(path) { if (typeof path === 'string') return path; if (getBuffer() && path instanceof getBuffer()) return path.toString(); if (path instanceof URL) { - if (path.protocol !== 'file:') return path.toString(); - return path.pathname; + return getUrlModule().fileURLToPath(path); } return String(path); } @@ -725,22 +731,23 @@ export async function appendFile(path, data, options) { return path.appendFile(data, options); } + const pathString = pathToString(path); const flush = options && typeof options === 'object' ? options.flush : undefined; validateFlush(flush); validateAppendFileData(data); let error; if (typeof data === 'string') { - error = native.fs_append_file_string(path, data); + error = native.fs_append_file_string(pathString, data); } else { const dataArray = new Uint8Array(data.buffer || data, data.byteOffset || 0, data.byteLength || data.length); - error = native.fs_append_file(path, dataArray); + error = native.fs_append_file(pathString, dataArray); } if (error) throw createSystemError(error); if (flush === true) { const fs = require('node:fs'); - const fd = fs.openSync(path, 'r'); + const fd = fs.openSync(pathString, 'r'); try { fs.fsyncSync(fd); } finally { @@ -750,12 +757,12 @@ export async function appendFile(path, data, options) { } export async function unlink(path) { - const error = native.unlink(path); + const error = native.unlink(pathToString(path)); if (error) throw createSystemError(error); } export async function rename(oldPath, newPath) { - const error = native.rename(oldPath, newPath); + const error = native.rename(pathToString(oldPath), pathToString(newPath)); if (error) throw createSystemError(error); } @@ -770,20 +777,21 @@ export async function mkdir(path, options) { } export async function rmdir(path, options) { + const pathString = pathToString(path); if (options && options.recursive) { - const st = native.fs_stat(path); + const st = native.fs_stat(pathString); if (!st.error && !st.stat.isDirectory) { - const err = new Error(`ENOTDIR: not a directory, rmdir '${path}'`); + const err = new Error(`ENOTDIR: not a directory, rmdir '${pathString}'`); err.code = 'ENOTDIR'; err.errno = -20; err.syscall = 'rmdir'; - err.path = path; + err.path = pathString; throw err; } - const error = native.fs_rm(path, true, false); + const error = native.fs_rm(pathString, true, false); if (error) throw createSystemError(error); } else { - const error = native.fs_rmdir(path); + const error = native.fs_rmdir(pathString); if (error) throw createSystemError(error); } } @@ -796,21 +804,22 @@ export async function rm(path, options) { } export async function stat(path, options) { - const result = native.fs_stat(path); + const result = native.fs_stat(pathToString(path)); if (result.error) throw createSystemError(result.error); return wrapStat(result.stat, options); } export async function lstat(path, options) { - const result = native.fs_lstat(path); + const result = native.fs_lstat(pathToString(path)); if (result.error) throw createSystemError(result.error); return wrapStat(result.stat, options); } export async function readdir(path, options) { + const pathString = pathToString(path); const withFileTypes = options && options.withFileTypes || false; const recursive = options && options.recursive || false; - const result = native.fs_readdir(path, withFileTypes); + const result = native.fs_readdir(pathString, withFileTypes); if (result.error) throw createSystemError(result.error); if (withFileTypes) { const sortedEntries = [...result.entries].sort((left, right) => { @@ -837,13 +846,13 @@ export async function readdir(path, options) { isSocket() { return this._fileType === 5; }, }; }; - const dirents = sortedEntries.map(e => makeDirent(e, path)); + const dirents = sortedEntries.map(e => makeDirent(e, pathString)); if (recursive) { const all = []; for (const dirent of dirents) { all.push(dirent); if (dirent.isDirectory()) { - const subPath = path + '/' + dirent.name; + const subPath = pathString + '/' + dirent.name; try { const subEntries = await readdir(subPath, { withFileTypes: true, recursive: true }); all.push(...subEntries); @@ -863,7 +872,7 @@ export async function readdir(path, options) { const all = []; for (const entry of entries) { all.push(entry); - const subPath = path + '/' + entry; + const subPath = pathString + '/' + entry; try { const st = native.fs_stat(subPath); if (!st.error && st.stat.isDirectory) { @@ -893,12 +902,12 @@ export async function access(path, mode) { err.code = 'ERR_OUT_OF_RANGE'; throw err; } - const error = native.fs_access(path, mode); + const error = native.fs_access(pathToString(path), mode); if (error) throw createSystemError(error); } export async function realpath(path, options) { - const result = native.fs_realpath(path); + const result = native.fs_realpath(pathToString(path)); if (result.error) throw createSystemError(result.error); return result.result; } @@ -915,28 +924,28 @@ export async function copyFile(src, dest, mode) { err.code = 'ERR_INVALID_ARG_TYPE'; throw err; } - const error = native.fs_copy_file(src, dest); + const error = native.fs_copy_file(pathToString(src), pathToString(dest)); if (error) throw createSystemError(error); } export async function link(existingPath, newPath) { - const error = native.fs_link(existingPath, newPath); + const error = native.fs_link(pathToString(existingPath), pathToString(newPath)); if (error) throw createSystemError(error); } export async function symlink(target, path, type) { - const error = native.fs_symlink(target, path); + const error = native.fs_symlink(pathToString(target), pathToString(path)); if (error) throw createSystemError(error); } export async function readlink(path, options) { - const result = native.fs_readlink(path); + const result = native.fs_readlink(pathToString(path)); if (result.error) throw createSystemError(result.error); return result.result; } export async function chmod(path, mode) { - const error = native.fs_chmod(path, mode); + const error = native.fs_chmod(pathToString(path), mode); if (error) throw createSystemError(error); } @@ -947,21 +956,21 @@ export async function lchmod(path, mode) { export async function chown(path, uid, gid) { validateUid(uid, 'uid'); validateUid(gid, 'gid'); - const error = native.fs_chown(path, uid, gid); + const error = native.fs_chown(pathToString(path), uid, gid); if (error) throw createSystemError(error); } export async function lchown(path, uid, gid) { validateUid(uid, 'uid'); validateUid(gid, 'gid'); - const error = native.fs_lchown(path, uid, gid); + const error = native.fs_lchown(pathToString(path), uid, gid); if (error) throw createSystemError(error); } export async function utimes(path, atime, mtime) { const atimeSecs = (atime instanceof Date) ? atime.getTime() / 1000 : Number(atime); const mtimeSecs = (mtime instanceof Date) ? mtime.getTime() / 1000 : Number(mtime); - const error = native.fs_utimes(path, atimeSecs, mtimeSecs); + const error = native.fs_utimes(pathToString(path), atimeSecs, mtimeSecs); if (error) throw createSystemError(error); } @@ -981,6 +990,8 @@ export async function mkdtemp(prefix, options) { } export async function cp(src, dest, options) { + src = pathToString(src); + dest = pathToString(dest); // Simple copy implementation const srcResult = native.fs_stat(src); if (srcResult.error) throw createSystemError(srcResult.error); @@ -1003,6 +1014,7 @@ export async function cp(src, dest, options) { export async function* watch(filename, options = {}) { validatePath(filename, 'filename'); + filename = pathToString(filename); if (options === null || typeof options !== 'object' || Array.isArray(options)) { const err = new TypeError(`The "options" argument must be of type Object. Received ${describeType(options)}`); @@ -1102,7 +1114,7 @@ export async function* watch(filename, options = {}) { } export async function statfs(path, options) { - const result = native.fs_stat(path); + const result = native.fs_stat(pathToString(path)); if (result.error) throw createSystemError(result.error); const bigint = options && options.bigint; // Return a statfs-like object with sensible defaults diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 884b1b19..fe75b994 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -6006,7 +6006,7 @@ "es-module/test-esm-extension-lookup-deprecation.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-extensionless-esm-and-wasm.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-forbidden-globals.mjs": { "category": "known-gap", "reason": "ESM compatibility shim still exposes CJS globals such as __filename and __dirname" }, - "es-module/test-esm-fs-promises.mjs": { "category": "known-gap", "reason": "fs.promises file APIs do not yet accept file URL path arguments consistently" }, + "es-module/test-esm-fs-promises.mjs": {}, "es-module/test-esm-import-assertion-warning.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-import-attributes-1.mjs": {}, "es-module/test-esm-import-attributes-2.mjs": {}, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 513fc3bf..6e210759 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3124/4404 (70.9%) +**Primary compatibility (CI-enforced):** 3125/4404 (71.0%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3124 | 70.9% | 54.6% | 45.7% | -| 🧩 known gap | 1280 | 29.1% | 22.4% | 18.7% | +| ✅ passing (runnable) | 3125 | 71.0% | 54.6% | 45.7% | +| 🧩 known gap | 1279 | 29.0% | 22.4% | 18.7% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3124/5721 (54.6%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3125/5721 (54.6%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 125 | 177 | 85 | 11 | 0 | 183 | 41.4% | 31.4% | +| other | 581 | 126 | 176 | 85 | 11 | 0 | 183 | 41.7% | 31.7% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1280) +### known gap (1279) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -1119,7 +1119,6 @@ Secondary full-public compatibility, including public tests that are currently e | fs symlink permission checks are incomplete | 1 | `parallel/test-permission-fs-symlink-relative.js` | | fs.constants includes Linux-only O_NOATIME even when common.isLinux is false in WASM | 1 | `parallel/test-process-constants-noatime.js` | | fs.globSync API is not implemented | 1 | `parallel/test-icu-env.js` | -| fs.promises file APIs do not yet accept file URL path arguments consistently | 1 | `es-module/test-esm-fs-promises.mjs` | | fs.watch directory watcher filename/null and event delivery semantics are not Node-compatible | 1 | `sequential/test-fs-watch.js#block_02_block_02` | | fs.watch emits duplicate change events for a single write | 1 | `sequential/test-fs-watch.js#block_00_block_00` | | fs.watch path watcher emits duplicate change events | 1 | `sequential/test-fs-watch.js#block_01_block_01` | From de24379ec0401ea4fb6fc9a959df518d28b9c517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 08:36:58 +0200 Subject: [PATCH 30/42] Validate DNS lookupService arguments --- crates/wasm-rquickjs/skeleton/src/builtin/dns.js | 11 +++++++++++ tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 13 ++++++------- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/dns.js b/crates/wasm-rquickjs/skeleton/src/builtin/dns.js index 1aea5513..509b04f4 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/dns.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/dns.js @@ -1,5 +1,7 @@ // node:dns implementation backed by wasi:sockets/ip-name-lookup import { resolve as native_resolve } from '__wasm_rquickjs_builtin/dns_native'; +import { ERR_INVALID_ARG_VALUE } from '__wasm_rquickjs_builtin/internal/errors'; +import { validatePort } from '__wasm_rquickjs_builtin/internal/validators'; import { isIP, isIPv4, isIPv6 } from 'node:net'; const NOT_SUPPORTED_ERROR_MSG = 'dns record type queries are not supported in WebAssembly environment'; @@ -66,6 +68,13 @@ function invalidRrtypeError(rrtype) { return err; } +function validateLookupServiceArgs(address, port) { + if (isIP(address) === 0) { + throw new ERR_INVALID_ARG_VALUE('address', address); + } + validatePort(port); +} + function filterByFamily(results, family) { if (family === 0) return results; return results.filter(r => r.family === family); @@ -302,6 +311,7 @@ export function lookupService(address, port, callback) { if (typeof callback !== 'function') { throw new TypeError('callback must be a function'); } + validateLookupServiceArgs(address, port); queueMicrotask(() => callback(Object.assign( new Error(`getnameinfo ${NOT_SUPPORTED_ERROR_MSG}`), { code: 'ENOTIMP' } @@ -412,6 +422,7 @@ export const promises = { }, lookupService(address, port) { + validateLookupServiceArgs(address, port); return new Promise((resolve, reject) => { lookupService(address, port, (err, hostname, service) => { if (err) return reject(err); diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index fe75b994..07001b2e 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5992,7 +5992,7 @@ "es-module/test-esm-cyclic-dynamic-import.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, "es-module/test-esm-default-type.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-detect-ambiguous.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-dns-promises.mjs": { "category": "known-gap", "reason": "dns.promises.lookupService address validation does not yet match Node" }, + "es-module/test-esm-dns-promises.mjs": { "category": "runnable" }, "es-module/test-esm-double-encoding.mjs": { "category": "known-gap", "reason": "ESM resolver does not yet preserve Node-compatible double-encoded path semantics" }, "es-module/test-esm-dynamic-import-attribute.mjs": {}, "es-module/test-esm-dynamic-import-commonjs.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 6e210759..a91920f2 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3125/4404 (71.0%) +**Primary compatibility (CI-enforced):** 3126/4404 (71.0%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3125 | 71.0% | 54.6% | 45.7% | -| 🧩 known gap | 1279 | 29.0% | 22.4% | 18.7% | +| ✅ passing (runnable) | 3126 | 71.0% | 54.6% | 45.7% | +| 🧩 known gap | 1278 | 29.0% | 22.3% | 18.7% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3125/5721 (54.6%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3126/5721 (54.6%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 126 | 176 | 85 | 11 | 0 | 183 | 41.7% | 31.7% | +| other | 581 | 127 | 175 | 85 | 11 | 0 | 183 | 42.1% | 31.9% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1279) +### known gap (1278) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -1080,7 +1080,6 @@ Secondary full-public compatibility, including public tests that are currently e | diagnostics_channel runStores transformer-error propagation is incomplete | 1 | `parallel/test-diagnostics-channel-bind-store.js` | | diagnostics_channel subscriber-throw path does not surface uncaughtException handling like Node.js | 1 | `parallel/test-diagnostics-channel-safe-subscriber-errors.js` | | dns perf_hooks integration not implemented | 1 | `parallel/test-dns-perf_hooks.js` | -| dns.promises.lookupService address validation does not yet match Node | 1 | `es-module/test-esm-dns-promises.mjs` | | dns.promises.lookupService is not implemented (returns ENOTIMP) | 1 | `parallel/test-dns-lookupService-promises.js` | | domain error propagation across node:http server/client callbacks is incomplete | 1 | `parallel/test-domain-multi.js` | | domain error/nextTick behavior depends on async_hooks semantics that are incomplete | 1 | `sequential/test-next-tick-error-spin.js` | From 0df17368ed199b1f11ce0c9d33ff183fbc5e217e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 08:55:23 +0200 Subject: [PATCH 31/42] Decode encoded ESM relative paths --- crates/wasm-rquickjs/skeleton/src/internal.rs | 72 +++++++++++++++++-- .../src/module-resolution.js | 28 ++++++++ .../wit/module-resolution.wit | 1 + tests/node_compat/config.jsonc | 4 +- tests/node_compat/report.md | 16 ++--- tests/runtime/module_resolution.rs | 17 +++++ 6 files changed, 121 insertions(+), 17 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 82b166c6..bfd6cc87 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -9,6 +9,7 @@ use rquickjs::{ }; use rquickjs::{CaughtError, prelude::*}; use serde::Deserialize; +use std::borrow::Cow; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::future::Future; @@ -1630,14 +1631,23 @@ impl FileUrlResolver { impl Resolver for FileUrlResolver { fn resolve<'js>( &mut self, - _ctx: &Ctx<'js>, - _base: &str, + ctx: &Ctx<'js>, + base: &str, name: &str, ) -> rquickjs::Result { + if let Some(encoded) = name.strip_prefix("file://") { + let end = encoded + .find(|ch| ch == '?' || ch == '#') + .unwrap_or(encoded.len()); + if NodeFileResolver::has_encoded_path_separator(&encoded[..end]) { + return NodeFileResolver::throw_invalid_encoded_separator(ctx, base, name); + } + } + if let Some(path) = Self::file_url_to_path(name) { Ok(path) } else { - Err(Error::new_resolving(_base, name)) + Err(Error::new_resolving(base, name)) } } } @@ -1861,6 +1871,54 @@ impl Resolver for CjsEvalResolver { struct NodeFileResolver; impl NodeFileResolver { + fn decode_module_path<'js, 'path>( + ctx: &Ctx<'js>, + base: &str, + name: &str, + path: &'path str, + ) -> rquickjs::Result> { + if path.as_bytes().contains(&b'%') { + if Self::has_encoded_path_separator(path) { + return Self::throw_invalid_encoded_separator(ctx, base, name); + } + percent_decode(path) + .map(Cow::Owned) + .ok_or_else(|| Error::new_resolving(base, name)) + } else { + Ok(Cow::Borrowed(path)) + } + } + + fn has_encoded_path_separator(path: &str) -> bool { + let bytes = path.as_bytes(); + let mut i = 0; + while i + 2 < bytes.len() { + if bytes[i] == b'%' && bytes[i + 1] == b'2' && matches!(bytes[i + 2], b'f' | b'F') { + return true; + } + if bytes[i] == b'%' && bytes[i + 1] == b'5' && matches!(bytes[i + 2], b'c' | b'C') { + return true; + } + i += 1; + } + false + } + + fn throw_invalid_encoded_separator<'js, T>( + ctx: &Ctx<'js>, + base: &str, + name: &str, + ) -> rquickjs::Result { + let msg = format!( + "Invalid module \"{}\" must not include encoded \"/\" or \"\\\" characters imported from {}", + name, base + ); + let type_error_ctor: Function = ctx.globals().get("TypeError")?; + let error_obj: Object = type_error_ctor.call((&msg,))?; + error_obj.set("code", "ERR_INVALID_MODULE_SPECIFIER")?; + Err(ctx.throw(error_obj.into_value())) + } + fn resolve_candidate(candidate: std::path::PathBuf, suffix: &str) -> Option { let normalized = CjsEvalResolver::normalize_path(&candidate); if std::path::Path::new(&normalized).is_file() { @@ -1883,7 +1941,7 @@ impl NodeFileResolver { impl Resolver for NodeFileResolver { fn resolve<'js>( &mut self, - _ctx: &Ctx<'js>, + ctx: &Ctx<'js>, base: &str, name: &str, ) -> rquickjs::Result { @@ -1893,8 +1951,10 @@ impl Resolver for NodeFileResolver { let (name_path, suffix) = split_module_path_suffix(name); let candidate = if name_path.starts_with('/') { - std::path::PathBuf::from(name_path) + let name_path = Self::decode_module_path(ctx, base, name, name_path)?; + std::path::PathBuf::from(name_path.as_ref()) } else if name_path.starts_with("./") || name_path.starts_with("../") { + let name_path = Self::decode_module_path(ctx, base, name, name_path)?; let base_path = if let Some(path) = FileUrlResolver::file_url_to_path(base) { path } else { @@ -1909,7 +1969,7 @@ impl Resolver for NodeFileResolver { let base_dir = std::path::Path::new(&base_path) .parent() .ok_or_else(|| Error::new_resolving(base, name))?; - base_dir.join(name_path) + base_dir.join(name_path.as_ref()) } else { return Err(Error::new_resolving(base, name)); }; diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index f314180b..04593693 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -169,6 +169,34 @@ export const testEsmPackageMapEdgeCases = async () => { } }; +export const testEsmEncodedRelativePaths = async () => { + try { + fs.mkdirSync('/esm-encoded-relative-app/sub', { recursive: true }); + fs.writeFileSync('/esm-encoded-relative-app/sub/test-esm-ok.mjs', 'export default "ok";'); + fs.writeFileSync('/esm-encoded-relative-app/sub/test-esm-comma,.mjs', 'export default "comma";'); + fs.writeFileSync('/esm-encoded-relative-app/sub/test-esm-double-encoding-native%20.mjs', 'export default "percent";'); + fs.writeFileSync('/esm-encoded-relative-app/sub/blocked.mjs', 'export default "blocked";'); + fs.writeFileSync('/esm-encoded-relative-app/entry.mjs', [ + 'import ok from "./sub/test-%65%73%6d-ok.mjs";', + 'import comma from "./sub/test-esm-comma%2c.mjs";', + 'import percent from "./sub/test-esm-double-encoding-native%2520.mjs";', + 'export default { ok, comma, percent };', + ].join('\n')); + + assert.deepStrictEqual((await import('/esm-encoded-relative-app/entry.mjs')).default, { + ok: 'ok', + comma: 'comma', + percent: 'percent', + }); + await expectImportRejectsCode('/esm-encoded-relative-app/sub%2Fblocked.mjs', 'ERR_INVALID_MODULE_SPECIFIER'); + await expectImportRejectsCode('/esm-encoded-relative-app/sub%5Cblocked.mjs', 'ERR_INVALID_MODULE_SPECIFIER'); + return true; + } catch (error) { + console.error(error); + throw error; + } +}; + export const testCjsDirectNamedExports = async () => { try { fs.mkdirSync('/cjs-named-export-app', { recursive: true }); diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index 6773e8b8..5e7d91db 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -2,6 +2,7 @@ package quickjs:module-resolution; world module-resolution { export test-esm-package-map-edge-cases: func() -> bool; + export test-esm-encoded-relative-paths: func() -> bool; export test-cjs-direct-named-exports: func() -> bool; export test-cjs-define-property-named-exports: func() -> bool; export test-cjs-reexport-named-exports: func() -> bool; diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 07001b2e..8d81cd71 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5993,11 +5993,11 @@ "es-module/test-esm-default-type.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-detect-ambiguous.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-dns-promises.mjs": { "category": "runnable" }, - "es-module/test-esm-double-encoding.mjs": { "category": "known-gap", "reason": "ESM resolver does not yet preserve Node-compatible double-encoded path semantics" }, + "es-module/test-esm-double-encoding.mjs": { "category": "runnable" }, "es-module/test-esm-dynamic-import-attribute.mjs": {}, "es-module/test-esm-dynamic-import-commonjs.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, "es-module/test-esm-dynamic-import-mutating-fs.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-encoded-path.mjs": { "category": "known-gap", "reason": "ESM resolver does not yet decode percent-encoded relative path segments like Node" }, + "es-module/test-esm-encoded-path.mjs": { "category": "runnable" }, "es-module/test-esm-example-loader.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, "es-module/test-esm-experimental-warnings.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-export-not-found.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index a91920f2..1876e483 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -1,6 +1,6 @@ # Node.js v22.14.0 Compatibility Inventory -Generated: 2026-06-24 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) +Generated: 2026-06-25 | Source: `tests/node_compat/config.jsonc` | Engine: wasm-rquickjs (QuickJS) This report is generated from `config.jsonc` only. It does **not** run the vendored tests itself. Entries classified as `runnable` are reported as passing because the `node_compat` PR test executes runnable entries and fails CI if any of them fail. @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3126/4404 (71.0%) +**Primary compatibility (CI-enforced):** 3128/4404 (71.0%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3126 | 71.0% | 54.6% | 45.7% | -| 🧩 known gap | 1278 | 29.0% | 22.3% | 18.7% | +| ✅ passing (runnable) | 3128 | 71.0% | 54.7% | 45.7% | +| 🧩 known gap | 1276 | 29.0% | 22.3% | 18.6% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3126/5721 (54.6%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3128/5721 (54.7%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 127 | 175 | 85 | 11 | 0 | 183 | 42.1% | 31.9% | +| other | 581 | 129 | 173 | 85 | 11 | 0 | 183 | 42.7% | 32.4% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1278) +### known gap (1276) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -869,8 +869,6 @@ Secondary full-public compatibility, including public tests that are currently e | ESM compatibility shim still exposes CJS globals such as __filename and __dirname | 1 | `es-module/test-esm-forbidden-globals.mjs` | | ESM directory import errors do not match Node ERR_UNSUPPORTED_DIR_IMPORT behavior | 1 | `parallel/test-directory-import.js` | | ESM main lookup and error-url behavior still differ from Node's resolver | 1 | `es-module/test-esm-main-lookup.mjs` | -| ESM resolver does not yet decode percent-encoded relative path segments like Node | 1 | `es-module/test-esm-encoded-path.mjs` | -| ESM resolver does not yet preserve Node-compatible double-encoded path semantics | 1 | `es-module/test-esm-double-encoding.mjs` | | EdDSA sign/verify vector compatibility is incomplete | 1 | `parallel/test-webcrypto-sign-verify-eddsa.js` | | Error.prepareStackTrace default behavior is incomplete | 1 | `parallel/test-error-prepare-stack-trace.js` | | EventEmitter captureRejections option validation/behavior is incomplete | 1 | `parallel/test-event-capture-rejections.js` | diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index 5a01d852..b07ad64e 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -28,6 +28,23 @@ async fn esm_package_map_edge_cases( Ok(()) } +#[test] +async fn esm_encoded_relative_paths( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-esm-encoded-relative-paths", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + #[test] async fn cjs_direct_named_exports( #[tagged_as("module_resolution")] compiled_test: &CompiledTest, From 8acae527a093e62492ea52bbbb78bad4f6cb4e2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 09:48:08 +0200 Subject: [PATCH 32/42] Validate invalid ESM package specifiers --- crates/wasm-rquickjs/skeleton/src/internal.rs | 41 +++++++++++++++++-- .../src/module-resolution.js | 15 +++++++ .../wit/module-resolution.wit | 1 + tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 13 +++--- tests/runtime/module_resolution.rs | 17 ++++++++ 6 files changed, 77 insertions(+), 12 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index bfd6cc87..6948919b 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -2023,6 +2023,7 @@ impl Resolver for NodeModuleErrorResolver { } enum NodePackageResolveError { + InvalidModuleSpecifier { specifier: String, base: String }, PackagePathNotExported { package_name: String, subpath: String }, PackageImportNotDefined { specifier: String }, InvalidPackageTarget { kind: &'static str, target: String }, @@ -2082,6 +2083,7 @@ impl NodeModulesResolver { let Some((package_name, subpath)) = Self::split_package_name(name) else { return Ok(None); }; + Self::validate_package_name(base, name, package_name)?; // Extract directory from base module path let Some(base_dir) = Path::new(base).parent() else { @@ -2161,6 +2163,7 @@ impl NodeModulesResolver { let Some((package_name, subpath)) = Self::split_package_name(name) else { return Ok(None); }; + Self::validate_package_name(base, name, package_name)?; let Some(base_dir) = Path::new(base).parent() else { return Ok(None); }; @@ -2280,10 +2283,12 @@ impl NodeModulesResolver { fn split_package_name(name: &str) -> Option<(&str, &str)> { if name.starts_with('@') { - let first = name.find('/')?; + let Some(first) = name.find('/') else { + return Some((name, "")); + }; let rest = &name[first + 1..]; if rest.is_empty() { - return None; + return Some((name, "")); } if let Some(second_rel) = rest.find('/') { let second = first + 1 + second_rel; @@ -2298,6 +2303,21 @@ impl NodeModulesResolver { } } + fn validate_package_name( + base: &str, + specifier: &str, + package_name: &str, + ) -> Result<(), NodePackageResolveError> { + let invalid_scoped_name = package_name.starts_with('@') && !package_name.contains('/'); + if invalid_scoped_name || package_name.contains('%') || package_name.contains('\\') { + return Err(NodePackageResolveError::InvalidModuleSpecifier { + specifier: specifier.to_string(), + base: base.to_string(), + }); + } + Ok(()) + } + fn resolve_package_target(package_dir: &std::path::Path, target: &str) -> Option { let target_path = package_dir.join(target.strip_prefix("./").unwrap_or(target)); let mut candidates = vec![target_path.clone()]; @@ -2730,7 +2750,15 @@ fn throw_node_package_resolve_error<'js>( ctx: &Ctx<'js>, err: NodePackageResolveError, ) -> rquickjs::Result { - let (code, message) = match err { + let (code, message, type_error) = match err { + NodePackageResolveError::InvalidModuleSpecifier { specifier, base } => ( + "ERR_INVALID_MODULE_SPECIFIER", + format!( + "Invalid module \"{}\" is not a valid package name imported from {}", + specifier, base + ), + true, + ), NodePackageResolveError::PackagePathNotExported { package_name, subpath, @@ -2743,28 +2771,33 @@ fn throw_node_package_resolve_error<'js>( ( "ERR_PACKAGE_PATH_NOT_EXPORTED", format!("Package subpath '{}' is not defined by \"exports\" in package {}", subpath, package_name), + false, ) } NodePackageResolveError::PackageImportNotDefined { specifier } => ( "ERR_PACKAGE_IMPORT_NOT_DEFINED", format!("Package import specifier '{}' is not defined", specifier), + false, ), NodePackageResolveError::InvalidPackageTarget { kind, target } => ( "ERR_INVALID_PACKAGE_TARGET", format!("Invalid \"{}\" target '{}'", kind, target), + false, ), NodePackageResolveError::InvalidPackageConfig { path } => ( "ERR_INVALID_PACKAGE_CONFIG", format!("Invalid package config {}", path), + false, ), NodePackageResolveError::ModuleNotFound { request } => ( "ERR_MODULE_NOT_FOUND", format!("Cannot find module '{}'", request), + false, ), }; let globals = ctx.globals(); - let error_ctor: Function = globals.get("Error")?; + let error_ctor: Function = globals.get(if type_error { "TypeError" } else { "Error" })?; let error_obj: Object = error_ctor.call((message,))?; error_obj.set("code", code)?; Err(ctx.throw(error_obj.into_value())) diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 04593693..04779a9d 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -197,6 +197,21 @@ export const testEsmEncodedRelativePaths = async () => { } }; +export const testEsmInvalidPackageSpecifiers = async () => { + try { + await Promise.all([ + 'as%2Ff', + 'as%5Cf', + 'as\\df', + '@as@df', + ].map((specifier) => expectImportRejectsCode(specifier, 'ERR_INVALID_MODULE_SPECIFIER'))); + return true; + } catch (error) { + console.error(error); + throw error; + } +}; + export const testCjsDirectNamedExports = async () => { try { fs.mkdirSync('/cjs-named-export-app', { recursive: true }); diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index 5e7d91db..71616e0b 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -3,6 +3,7 @@ package quickjs:module-resolution; world module-resolution { export test-esm-package-map-edge-cases: func() -> bool; export test-esm-encoded-relative-paths: func() -> bool; + export test-esm-invalid-package-specifiers: func() -> bool; export test-cjs-direct-named-exports: func() -> bool; export test-cjs-define-property-named-exports: func() -> bool; export test-cjs-reexport-named-exports: func() -> bool; diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 8d81cd71..0a3abafe 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -6049,7 +6049,7 @@ "es-module/test-esm-nowarn-exports.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-path-posix.mjs": {}, "es-module/test-esm-path-win32.mjs": {}, - "es-module/test-esm-pkgname.mjs": { "category": "known-gap", "reason": "invalid ESM package specifier validation and error codes do not yet match Node" }, + "es-module/test-esm-pkgname.mjs": { "category": "runnable" }, "es-module/test-esm-preserve-symlinks-not-found-plain.mjs": { "category": "known-gap", "reason": "ESM preserve-symlinks / preserve-symlinks-main behavior is incomplete" }, "es-module/test-esm-preserve-symlinks-not-found.mjs": { "category": "known-gap", "reason": "ESM preserve-symlinks / preserve-symlinks-main behavior is incomplete" }, "es-module/test-esm-process.mjs": {}, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 1876e483..a8d7ce6f 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3128/4404 (71.0%) +**Primary compatibility (CI-enforced):** 3129/4404 (71.0%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3128 | 71.0% | 54.7% | 45.7% | -| 🧩 known gap | 1276 | 29.0% | 22.3% | 18.6% | +| ✅ passing (runnable) | 3129 | 71.0% | 54.7% | 45.7% | +| 🧩 known gap | 1275 | 29.0% | 22.3% | 18.6% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3128/5721 (54.7%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3129/5721 (54.7%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 129 | 173 | 85 | 11 | 0 | 183 | 42.7% | 32.4% | +| other | 581 | 130 | 172 | 85 | 11 | 0 | 183 | 43.0% | 32.7% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1276) +### known gap (1275) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -1149,7 +1149,6 @@ Secondary full-public compatibility, including public tests that are currently e | indexed property definitions on vm globals do not propagate to the sandbox | 1 | `parallel/test-vm-indexed-properties.js` | | inherited: Resolver#setLocalAddress validation/error behavior is not implemented | 1 | `parallel/test-dns-setlocaladdress.js#block_01_verify_that_setlocaladdress_throws_if_called_with_an_invalid` | | invalid EC private keys do not raise Node-compatible DataError | 1 | `parallel/test-webcrypto-export-import-ec.js#block_01_bad_private_keys` | -| invalid ESM package specifier validation and error codes do not yet match Node | 1 | `es-module/test-esm-pkgname.mjs` | | invalid repeated Transfer-Encoding handling differs from Node | 1 | `parallel/test-http-transfer-encoding-repeated-chunked.js` | | keep-alive free-socket lifecycle (free event + req.destroyed transitions) is not Node-compatible | 1 | `parallel/test-http-keepalive-free.js` | | keep-alive request sequencing with unread request bodies has non-Node lifecycle behavior | 1 | `parallel/test-http-no-read-no-dump.js` | diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index b07ad64e..4f52073d 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -45,6 +45,23 @@ async fn esm_encoded_relative_paths( Ok(()) } +#[test] +async fn esm_invalid_package_specifiers( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-esm-invalid-package-specifiers", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + #[test] async fn cjs_direct_named_exports( #[tagged_as("module_resolution")] compiled_test: &CompiledTest, From 9c7bb5f3fa04a1b1c24483719c31e101fa7d1f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 10:19:39 +0200 Subject: [PATCH 33/42] Support syncBuiltinESMExports live bindings --- .../skeleton/src/builtin/events.js | 55 +- .../wasm-rquickjs/skeleton/src/builtin/fs.js | 505 +++++++++++------- .../skeleton/src/builtin/module.js | 32 +- .../src/module-resolution.js | 79 +++ .../wit/module-resolution.wit | 1 + tests/node_compat/config.jsonc | 4 +- tests/node_compat/report.md | 14 +- tests/runtime/module_resolution.rs | 17 + 8 files changed, 482 insertions(+), 225 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/events.js b/crates/wasm-rquickjs/skeleton/src/builtin/events.js index bf9041f3..c15f2562 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/events.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/events.js @@ -767,16 +767,53 @@ EventEmitter.addAbortListener = function(signal, listener) { }; }; +EventEmitter = new Proxy(EventEmitter, { + defineProperty(target, property, descriptor) { + if (property === 'defaultMaxListeners') { + const current = Reflect.getOwnPropertyDescriptor(target, property); + if (current && current.configurable === false) { + throw new TypeError('Cannot redefine property: defaultMaxListeners'); + } + } + return Reflect.defineProperty(target, property, descriptor); + }, +}); + EventEmitter.EventEmitter = EventEmitter; -const once = EventEmitter.once; -const on = EventEmitter.on; -const getEventListeners = EventEmitter.getEventListeners; -const getMaxListeners = EventEmitter.getMaxListeners; -const setMaxListeners = EventEmitter.setMaxListeners; -const addAbortListener = EventEmitter.addAbortListener; -const errorMonitor = EventEmitter.errorMonitor; -const captureRejections = EventEmitter.captureRejections; +const _default = EventEmitter; + +let once = EventEmitter.once; +let on = EventEmitter.on; +let getEventListeners = EventEmitter.getEventListeners; +let getMaxListeners = EventEmitter.getMaxListeners; +let setMaxListeners = EventEmitter.setMaxListeners; +let addAbortListener = EventEmitter.addAbortListener; +let errorMonitor = EventEmitter.errorMonitor; +let captureRejections = EventEmitter.captureRejections; +export let defaultMaxListeners = EventEmitter.defaultMaxListeners; + +const _syncBuiltinESMExportsRegistry = globalThis.__wasm_rquickjs_sync_builtin_esm_exports || + Object.defineProperty(globalThis, '__wasm_rquickjs_sync_builtin_esm_exports', { + value: Object.create(null), + configurable: true, + }).__wasm_rquickjs_sync_builtin_esm_exports; + +_syncBuiltinESMExportsRegistry.events = function syncEventsBuiltinESMExports() { + EventEmitter = _default.EventEmitter; + Event = _default.Event; + EventTarget = _default.EventTarget; + CustomEvent = _default.CustomEvent; + once = _default.once; + on = _default.on; + getEventListeners = _default.getEventListeners; + getMaxListeners = _default.getMaxListeners; + setMaxListeners = _default.setMaxListeners; + addAbortListener = _default.addAbortListener; + errorMonitor = _default.errorMonitor; + captureRejections = _default.captureRejections; + defaultMaxListeners = _default.defaultMaxListeners; +}; export { EventEmitter, @@ -794,4 +831,4 @@ export { _eventTrusted, }; -export default EventEmitter; +export default _default; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/fs.js b/crates/wasm-rquickjs/skeleton/src/builtin/fs.js index 34b1e593..f7424124 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/fs.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/fs.js @@ -135,7 +135,7 @@ const HAS_LCHMOD = false; const FILE_HANDLE_IN_USE_SYMBOL = Symbol.for('__wasm_rquickjs.filehandleInUse'); const FILE_HANDLE_IN_USE_COUNT_SYMBOL = Symbol.for('__wasm_rquickjs.filehandleInUseCount'); -export const constants = { +export let constants = { F_OK, R_OK, W_OK, X_OK, O_RDONLY, O_WRONLY, O_RDWR, O_CREAT, O_EXCL, O_NOCTTY, O_TRUNC, O_APPEND, O_DIRECTORY, O_NOATIME, O_NOFOLLOW, @@ -583,7 +583,7 @@ internalFsBinding.readdir = function readdir(path, encoding, withFileTypes, req) // --- Stats class --- -export function Stats(devOrObj, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs) { +export let Stats = function Stats(devOrObj, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs) { if (!(this instanceof Stats)) { return new Stats(devOrObj, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs); } @@ -622,7 +622,7 @@ export function Stats(devOrObj, mode, nlink, uid, gid, rdev, blksize, ino, size, this._isFile = statObj.isFile; this._isDirectory = statObj.isDirectory; this._isSymlink = statObj.isSymlink; -} +}; Stats.prototype._toBigInt = function() { const s = new Stats({ @@ -665,7 +665,7 @@ Stats.prototype.isSocket = function() { return false; }; // --- Dirent class --- -export class Dirent { +export let Dirent = class Dirent { constructor(name, fileType, parentPath) { this.name = name; this.parentPath = parentPath; @@ -680,11 +680,11 @@ export class Dirent { isCharacterDevice() { return this._fileType === UV_DIRENT_CHAR; } isFIFO() { return this._fileType === UV_DIRENT_FIFO; } isSocket() { return this._fileType === UV_DIRENT_SOCKET; } -} +}; // --- Dir class --- -export class Dir { +export let Dir = class Dir { constructor(path, entries) { if (path === undefined) { const err = new TypeError('The "path" argument must be of type string. Received undefined'); @@ -814,7 +814,7 @@ export class Dir { } }; } -} +}; const validEncodings = new Set([ 'utf8', 'utf-8', 'ascii', 'base64', 'hex', @@ -856,7 +856,7 @@ function decodeFileResult(bytes, encoding) { // --- Sync functions --- -export function readFileSync(path, options) { +export let readFileSync = function readFileSync(path, options) { if (typeof path !== 'number') validatePath(path); if (typeof options === 'string') { options = {encoding: options}; @@ -921,9 +921,9 @@ export function readFileSync(path, options) { } finally { closeSync(fd); } -} +}; -export function writeFileSync(path, data, options) { +export let writeFileSync = function writeFileSync(path, data, options) { if (typeof path !== 'number') validatePath(path); if (typeof options === 'string') { options = {encoding: options}; @@ -973,9 +973,9 @@ export function writeFileSync(path, data, options) { } } } -} +}; -export function appendFileSync(path, data, options) { +export let appendFileSync = function appendFileSync(path, data, options) { if (typeof path === 'number') { validateFd(path); } else { @@ -1003,9 +1003,9 @@ export function appendFileSync(path, data, options) { } } } -} +}; -export function openSync(path, flags, mode) { +export let openSync = function openSync(path, flags, mode) { validatePath(path); flags = flagsToNumber(flags !== undefined ? flags : 'r'); mode = validateMode(mode, 'mode', 0o666); @@ -1019,17 +1019,17 @@ export function openSync(path, flags, mode) { _notifyFSWatchers(fullPath, 'rename'); } return result.fd; -} +}; -export function closeSync(fd) { +export let closeSync = function closeSync(fd) { validateFd(fd); const error = native.fs_close(fd); if (error) { throw createSystemError(error); } -} +}; -export function readSync(fd, buffer, offsetOrOptions, length, position) { +export let readSync = function readSync(fd, buffer, offsetOrOptions, length, position) { validateFd(fd); const argCount = arguments.length; @@ -1089,9 +1089,9 @@ export function readSync(fd, buffer, offsetOrOptions, length, position) { buffer[offset + i] = src[i]; } return bytesRead; -} +}; -export function writeSync(fd, bufferOrString, offsetOrPosition, lengthOrEncoding, position) { +export let writeSync = function writeSync(fd, bufferOrString, offsetOrPosition, lengthOrEncoding, position) { validateFd(fd); if (typeof bufferOrString === 'string') { @@ -1143,9 +1143,9 @@ export function writeSync(fd, bufferOrString, offsetOrPosition, lengthOrEncoding throw createSystemError(result.error); } return result.bytesWritten; -} +}; -export function ftruncateSync(fd, len) { +export let ftruncateSync = function ftruncateSync(fd, len) { validateFd(fd); if (len === undefined) { len = 0; @@ -1156,25 +1156,25 @@ export function ftruncateSync(fd, len) { if (error) { throw createSystemError(error); } -} +}; -export function fsyncSync(fd) { +export let fsyncSync = function fsyncSync(fd) { validateFd(fd); const error = native.fs_fsync(fd); if (error) { throw createSystemError(error); } -} +}; -export function fdatasyncSync(fd) { +export let fdatasyncSync = function fdatasyncSync(fd) { validateFd(fd); const error = native.fs_fdatasync(fd); if (error) { throw createSystemError(error); } -} +}; -export function statSync(path, options) { +export let statSync = function statSync(path, options) { validatePath(path); const result = native.fs_stat(pathToString(path)); if (result.error) { @@ -1185,9 +1185,9 @@ export function statSync(path, options) { } const s = new Stats(result.stat); return (options && options.bigint) ? s._toBigInt() : s; -} +}; -export function lstatSync(path, options) { +export let lstatSync = function lstatSync(path, options) { validatePath(path); const result = native.fs_lstat(pathToString(path)); if (result.error) { @@ -1198,9 +1198,9 @@ export function lstatSync(path, options) { } const s = new Stats(result.stat); return (options && options.bigint) ? s._toBigInt() : s; -} +}; -export function fstatSync(fd, options) { +export let fstatSync = function fstatSync(fd, options) { validateFd(fd); const result = native.fs_fstat(fd); if (result.error) { @@ -1208,7 +1208,7 @@ export function fstatSync(fd, options) { } const s = new Stats(result.stat); return (options && options.bigint) ? s._toBigInt() : s; -} +}; function makeStatFsResult(bigint) { if (bigint) { @@ -1233,16 +1233,16 @@ function makeStatFsResult(bigint) { }; } -export function statfsSync(path, options) { +export let statfsSync = function statfsSync(path, options) { validatePath(path); const result = native.fs_stat(pathToString(path)); if (result.error) { throw createSystemError(result.error); } return makeStatFsResult(options && options.bigint); -} +}; -export function readdirSync(path, options) { +export let readdirSync = function readdirSync(path, options) { validatePath(path); const opts = getOptions(options, {}); if (opts.encoding) validateEncoding(opts.encoding, 'encoding', true); @@ -1289,25 +1289,25 @@ export function readdirSync(path, options) { return entries.map(e => getBuffer().from(e)); } return entries; -} +}; -export function accessSync(path, mode) { +export let accessSync = function accessSync(path, mode) { validatePath(path); mode = mode !== undefined ? mode : F_OK; const error = native.fs_access(pathToString(path), mode); if (error) { throw createSystemError(error); } -} +}; -export function existsSync(path) { +export let existsSync = function existsSync(path) { try { if (typeof path !== 'string') return false; return native.fs_exists(path); } catch { return false; } -} +}; function realpathSyncImpl(path, options, useNative) { validatePath(path); @@ -1336,9 +1336,9 @@ function realpathSyncImpl(path, options, useNative) { return result.result; } -export function realpathSync(path, options) { +export let realpathSync = function realpathSync(path, options) { return realpathSyncImpl(path, options, false); -} +}; function realpathSyncNative(path, options) { return realpathSyncImpl(path, options, true); @@ -1346,7 +1346,7 @@ function realpathSyncNative(path, options) { realpathSync.native = realpathSyncNative; -export function truncateSync(path, len) { +export let truncateSync = function truncateSync(path, len) { if (typeof path === 'number') { return ftruncateSync(path, len); } @@ -1360,9 +1360,9 @@ export function truncateSync(path, len) { if (error) { throw createSystemError(error); } -} +}; -export function copyFileSync(src, dest, mode) { +export let copyFileSync = function copyFileSync(src, dest, mode) { validatePath(src, 'src'); validatePath(dest, 'dest'); const copyMode = validateCopyFileMode(mode); @@ -1384,27 +1384,27 @@ export function copyFileSync(src, dest, mode) { throw createCopyFileErrorFromNative(error, srcPath, destPath); } _notifyFSWatchers(destPath, 'rename'); -} +}; -export function linkSync(existingPath, newPath) { +export let linkSync = function linkSync(existingPath, newPath) { validatePath(existingPath, 'existingPath'); validatePath(newPath, 'newPath'); const error = native.fs_link(existingPath, newPath); if (error) { throw createSystemError(error); } -} +}; -export function symlinkSync(target, path, type) { +export let symlinkSync = function symlinkSync(target, path, type) { validatePath(target, 'target'); validatePath(path, 'path'); const error = native.fs_symlink(target, path); if (error) { throw createSystemError(error); } -} +}; -export function readlinkSync(path, options) { +export let readlinkSync = function readlinkSync(path, options) { validatePath(path); const opts = getOptions(options, {}); if (opts.encoding) validateEncoding(opts.encoding, 'encoding', true); @@ -1417,31 +1417,31 @@ export function readlinkSync(path, options) { return getBuffer().from(result.result); } return result.result; -} +}; -export function chmodSync(path, mode) { +export let chmodSync = function chmodSync(path, mode) { validatePath(path); mode = validateMode(mode, 'mode', undefined); const error = native.fs_chmod(path, mode); if (error) { throw createSystemError(error); } -} +}; -export function fchmodSync(fd, mode) { +export let fchmodSync = function fchmodSync(fd, mode) { validateFd(fd); mode = validateMode(mode, 'mode', undefined); const error = native.fs_fchmod(fd, mode); if (error) { throw createSystemError(error); } -} +}; -export function lchmodSync(path, mode) { +export let lchmodSync = function lchmodSync(path, mode) { chmodSync(path, mode); -} +}; -export function chownSync(path, uid, gid) { +export let chownSync = function chownSync(path, uid, gid) { validatePath(path); validateUid(uid, 'uid'); validateUid(gid, 'gid'); @@ -1449,9 +1449,9 @@ export function chownSync(path, uid, gid) { if (error) { throw createSystemError(error); } -} +}; -export function fchownSync(fd, uid, gid) { +export let fchownSync = function fchownSync(fd, uid, gid) { validateFd(fd); validateUid(uid, 'uid'); validateUid(gid, 'gid'); @@ -1459,9 +1459,9 @@ export function fchownSync(fd, uid, gid) { if (error) { throw createSystemError(error); } -} +}; -export function lchownSync(path, uid, gid) { +export let lchownSync = function lchownSync(path, uid, gid) { validatePath(path); validateUid(uid, 'uid'); validateUid(gid, 'gid'); @@ -1469,9 +1469,9 @@ export function lchownSync(path, uid, gid) { if (error) { throw createSystemError(error); } -} +}; -export function utimesSync(path, atime, mtime) { +export let utimesSync = function utimesSync(path, atime, mtime) { validatePath(path); const atimeSecs = (atime instanceof Date) ? atime.getTime() / 1000 : Number(atime); const mtimeSecs = (mtime instanceof Date) ? mtime.getTime() / 1000 : Number(mtime); @@ -1479,9 +1479,9 @@ export function utimesSync(path, atime, mtime) { if (error) { throw createSystemError(error); } -} +}; -export function futimesSync(fd, atime, mtime) { +export let futimesSync = function futimesSync(fd, atime, mtime) { validateFd(fd); const atimeSecs = (atime instanceof Date) ? atime.getTime() / 1000 : Number(atime); const mtimeSecs = (mtime instanceof Date) ? mtime.getTime() / 1000 : Number(mtime); @@ -1489,9 +1489,9 @@ export function futimesSync(fd, atime, mtime) { if (error) { throw createSystemError(error); } -} +}; -export function lutimesSync(path, atime, mtime) { +export let lutimesSync = function lutimesSync(path, atime, mtime) { validatePath(path); const atimeSecs = (atime instanceof Date) ? atime.getTime() / 1000 : Number(atime); const mtimeSecs = (mtime instanceof Date) ? mtime.getTime() / 1000 : Number(mtime); @@ -1499,9 +1499,9 @@ export function lutimesSync(path, atime, mtime) { if (error) { throw createSystemError(error); } -} +}; -export function unlinkSync(path) { +export let unlinkSync = function unlinkSync(path) { validatePath(path); const fullPath = pathToString(path); const error = native.unlink(fullPath); @@ -1509,9 +1509,9 @@ export function unlinkSync(path) { throw createSystemError(error); } _notifyFSWatchers(fullPath, 'rename'); -} +}; -export function renameSync(oldPath, newPath) { +export let renameSync = function renameSync(oldPath, newPath) { validatePath(oldPath, 'oldPath'); validatePath(newPath, 'newPath'); const oldPathString = pathToString(oldPath); @@ -1522,9 +1522,9 @@ export function renameSync(oldPath, newPath) { } _notifyFSWatchers(oldPathString, 'rename'); _notifyFSWatchers(newPathString, 'rename'); -} +}; -export function mkdirSync(path, options) { +export let mkdirSync = function mkdirSync(path, options) { validatePath(path); const { recursive, mode } = parseMkdirOptions(options); const pathString = pathToString(path); @@ -1537,7 +1537,7 @@ export function mkdirSync(path, options) { _notifyFSWatchers(pathString, 'rename'); if (recursive) return firstCreatedPath; return undefined; -} +}; function _rimrafSync(dirPath) { const entries = readdirSync(dirPath, { withFileTypes: true }); @@ -1553,7 +1553,7 @@ function _rimrafSync(dirPath) { _default.rmdirSync(dirPath); } -export function rmdirSync(path, options) { +export let rmdirSync = function rmdirSync(path, options) { validatePath(path); if (options && options.recursive) { path = pathToString(path); @@ -1576,9 +1576,9 @@ export function rmdirSync(path, options) { if (error) throw createSystemError(error); _notifyFSWatchers(pathString, 'rename'); } -} +}; -export function rmSync(path, options) { +export let rmSync = function rmSync(path, options) { validatePath(path); path = pathToString(path); const recursive = options && options.recursive || false; @@ -1588,9 +1588,9 @@ export function rmSync(path, options) { throw createSystemError(error); } _notifyFSWatchers(path, 'rename'); -} +}; -export function mkdtempSync(prefix, options) { +export let mkdtempSync = function mkdtempSync(prefix, options) { validateMkdtempPrefix(prefix); const opts = getOptions(options, {}); if (opts.encoding) validateEncoding(opts.encoding, 'encoding', true); @@ -1603,19 +1603,19 @@ export function mkdtempSync(prefix, options) { return getBuffer().from(result.result); } return result.result; -} +}; -export function opendirSync(path, options) { +export let opendirSync = function opendirSync(path, options) { validatePath(path); validateOpendirOptions(options); const recursive = options && options.recursive ? true : false; const entries = readdirSync(path, { withFileTypes: true, recursive }); return new Dir(path, entries); -} +}; // --- Callback (async) functions --- -export function readFile(path, optionsOrCallback, callback) { +export let readFile = function readFile(path, optionsOrCallback, callback) { if (typeof path !== 'number') validatePath(path); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -1665,9 +1665,9 @@ export function readFile(path, optionsOrCallback, callback) { cb(err); } }); -} +}; -export function writeFile(path, data, optionsOrCallback, callback) { +export let writeFile = function writeFile(path, data, optionsOrCallback, callback) { if (typeof path !== 'number') validatePath(path); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -1725,9 +1725,9 @@ export function writeFile(path, data, optionsOrCallback, callback) { cb(err); } }); -} +}; -export function appendFile(path, data, optionsOrCallback, callback) { +export let appendFile = function appendFile(path, data, optionsOrCallback, callback) { if (typeof path === 'number') { validateFd(path); } else { @@ -1770,9 +1770,9 @@ export function appendFile(path, data, optionsOrCallback, callback) { cb(err); } }); -} +}; -export function open(path, flagsOrCallback, modeOrCallback, callback) { +export let open = function open(path, flagsOrCallback, modeOrCallback, callback) { validatePath(path); let flags = 'r'; let mode = 0o666; @@ -1805,9 +1805,9 @@ export function open(path, flagsOrCallback, modeOrCallback, callback) { cb(err); } }); -} +}; -export function close(fd, callback) { +export let close = function close(fd, callback) { validateFd(fd); if (callback !== undefined && typeof callback !== 'function') { const err = new TypeError(`The "callback" argument must be of type function. Received ${describeType(callback)}`); @@ -1826,9 +1826,9 @@ export function close(fd, callback) { cb(err); } }); -} +}; -export function read(fd, bufferOrOptions, offsetOrCallback, length, position, callback) { +export let read = function read(fd, bufferOrOptions, offsetOrCallback, length, position, callback) { validateFd(fd); let buffer, offset, cb; @@ -1919,9 +1919,9 @@ export function read(fd, bufferOrOptions, offsetOrCallback, length, position, ca cb(err, 0, buffer); } }); -} +}; -export function write(fd, bufferOrString, offsetOrPosition, lengthOrEncoding, positionOrCallback, callback) { +export let write = function write(fd, bufferOrString, offsetOrPosition, lengthOrEncoding, positionOrCallback, callback) { validateFd(fd); let cb; if (typeof bufferOrString === 'string') { @@ -2052,9 +2052,9 @@ export function write(fd, bufferOrString, offsetOrPosition, lengthOrEncoding, po cb(err, 0, bufferOrString); } }); -} +}; -export function stat(path, optionsOrCallback, callback) { +export let stat = function stat(path, optionsOrCallback, callback) { validatePath(path); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -2070,9 +2070,9 @@ export function stat(path, optionsOrCallback, callback) { cb(err); } }); -} +}; -export function lstat(path, optionsOrCallback, callback) { +export let lstat = function lstat(path, optionsOrCallback, callback) { validatePath(path); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -2088,9 +2088,9 @@ export function lstat(path, optionsOrCallback, callback) { cb(err); } }); -} +}; -export function statfs(path, optionsOrCallback, callback) { +export let statfs = function statfs(path, optionsOrCallback, callback) { validatePath(path); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -2106,9 +2106,9 @@ export function statfs(path, optionsOrCallback, callback) { cb(err); } }); -} +}; -export function fstat(fd, optionsOrCallback, callback) { +export let fstat = function fstat(fd, optionsOrCallback, callback) { validateFd(fd); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -2124,9 +2124,9 @@ export function fstat(fd, optionsOrCallback, callback) { cb(err); } }); -} +}; -export function ftruncate(fd, lenOrCallback, callback) { +export let ftruncate = function ftruncate(fd, lenOrCallback, callback) { validateFd(fd); let len = 0; let cb; @@ -2148,9 +2148,9 @@ export function ftruncate(fd, lenOrCallback, callback) { cb(err); } }); -} +}; -export function fsync(fd, callback) { +export let fsync = function fsync(fd, callback) { validateCallback(callback); queueMicrotask(() => { try { @@ -2160,9 +2160,9 @@ export function fsync(fd, callback) { callback(err); } }); -} +}; -export function fdatasync(fd, callback) { +export let fdatasync = function fdatasync(fd, callback) { validateCallback(callback); queueMicrotask(() => { try { @@ -2172,9 +2172,9 @@ export function fdatasync(fd, callback) { callback(err); } }); -} +}; -export function readdir(path, optionsOrCallback, callback) { +export let readdir = function readdir(path, optionsOrCallback, callback) { validatePath(path); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -2259,9 +2259,9 @@ export function readdir(path, optionsOrCallback, callback) { } }; internalFsBinding.readdir(pathStr, opts.encoding, withFileTypes, req); -} +}; -export function access(path, modeOrCallback, callback) { +export let access = function access(path, modeOrCallback, callback) { validatePath(path); let mode = F_OK; let cb; @@ -2280,9 +2280,9 @@ export function access(path, modeOrCallback, callback) { cb(err); } }); -} +}; -export function exists(path, callback) { +export let exists = function exists(path, callback) { if (typeof callback !== 'function') { throw Object.assign( new TypeError(`Callback must be a function. Received ${typeof callback}`), @@ -2292,9 +2292,9 @@ export function exists(path, callback) { queueMicrotask(() => { callback(existsSync(path)); }); -} +}; -export function realpath(path, optionsOrCallback, callback) { +export let realpath = function realpath(path, optionsOrCallback, callback) { validatePath(path); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -2322,7 +2322,7 @@ export function realpath(path, optionsOrCallback, callback) { } }); } -} +}; function realpathNative(path, optionsOrCallback, callback) { validatePath(path); @@ -2346,7 +2346,7 @@ function realpathNative(path, optionsOrCallback, callback) { realpath.native = realpathNative; -export function truncate(path, lenOrCallback, callback) { +export let truncate = function truncate(path, lenOrCallback, callback) { if (typeof path === 'number') { return ftruncate(path, lenOrCallback, callback); } @@ -2371,9 +2371,9 @@ export function truncate(path, lenOrCallback, callback) { cb(err); } }); -} +}; -export function copyFile(src, dest, modeOrCallback, callback) { +export let copyFile = function copyFile(src, dest, modeOrCallback, callback) { validatePath(src, 'src'); validatePath(dest, 'dest'); let mode = 0; @@ -2393,9 +2393,9 @@ export function copyFile(src, dest, modeOrCallback, callback) { cb(err); } }); -} +}; -export function link(existingPath, newPath, callback) { +export let link = function link(existingPath, newPath, callback) { validatePath(existingPath, 'existingPath'); validatePath(newPath, 'newPath'); validateCallback(callback); @@ -2407,9 +2407,9 @@ export function link(existingPath, newPath, callback) { callback(err); } }); -} +}; -export function symlink(target, path, typeOrCallback, callback) { +export let symlink = function symlink(target, path, typeOrCallback, callback) { validatePath(target, 'target'); validatePath(path, 'path'); let cb; @@ -2427,9 +2427,9 @@ export function symlink(target, path, typeOrCallback, callback) { cb(err); } }); -} +}; -export function readlink(path, optionsOrCallback, callback) { +export let readlink = function readlink(path, optionsOrCallback, callback) { validatePath(path); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -2447,9 +2447,9 @@ export function readlink(path, optionsOrCallback, callback) { cb(err); } }); -} +}; -export function chmod(path, mode, callback) { +export let chmod = function chmod(path, mode, callback) { validatePath(path); validateCallback(callback); queueMicrotask(() => { @@ -2460,9 +2460,9 @@ export function chmod(path, mode, callback) { callback(err); } }); -} +}; -export function fchmod(fd, mode, callback) { +export let fchmod = function fchmod(fd, mode, callback) { validateFd(fd); mode = validateMode(mode, 'mode', undefined); validateCallback(callback); @@ -2474,9 +2474,9 @@ export function fchmod(fd, mode, callback) { callback(err); } }); -} +}; -export function lchmod(path, mode, callback) { +export let lchmod = function lchmod(path, mode, callback) { validateCallback(callback); queueMicrotask(() => { try { @@ -2486,9 +2486,9 @@ export function lchmod(path, mode, callback) { callback(err); } }); -} +}; -export function chown(path, uid, gid, callback) { +export let chown = function chown(path, uid, gid, callback) { validatePath(path); validateUid(uid, 'uid'); validateUid(gid, 'gid'); @@ -2501,9 +2501,9 @@ export function chown(path, uid, gid, callback) { callback(err); } }); -} +}; -export function fchown(fd, uid, gid, callback) { +export let fchown = function fchown(fd, uid, gid, callback) { validateFd(fd); validateUid(uid, 'uid'); validateUid(gid, 'gid'); @@ -2516,9 +2516,9 @@ export function fchown(fd, uid, gid, callback) { callback(err); } }); -} +}; -export function lchown(path, uid, gid, callback) { +export let lchown = function lchown(path, uid, gid, callback) { validatePath(path); validateUid(uid, 'uid'); validateUid(gid, 'gid'); @@ -2531,9 +2531,9 @@ export function lchown(path, uid, gid, callback) { callback(err); } }); -} +}; -export function utimes(path, atime, mtime, callback) { +export let utimes = function utimes(path, atime, mtime, callback) { validatePath(path); validateCallback(callback); queueMicrotask(() => { @@ -2544,9 +2544,9 @@ export function utimes(path, atime, mtime, callback) { callback(err); } }); -} +}; -export function futimes(fd, atime, mtime, callback) { +export let futimes = function futimes(fd, atime, mtime, callback) { validateFd(fd); validateCallback(callback); queueMicrotask(() => { @@ -2557,9 +2557,9 @@ export function futimes(fd, atime, mtime, callback) { callback(err); } }); -} +}; -export function lutimes(path, atime, mtime, callback) { +export let lutimes = function lutimes(path, atime, mtime, callback) { validatePath(path); validateCallback(callback); queueMicrotask(() => { @@ -2570,9 +2570,9 @@ export function lutimes(path, atime, mtime, callback) { callback(err); } }); -} +}; -export function unlink(path, callback) { +export let unlink = function unlink(path, callback) { validatePath(path); validateCallback(callback); const error = native.unlink(pathToString(path)); @@ -2581,9 +2581,9 @@ export function unlink(path, callback) { } else { queueMicrotask(() => callback(null)); } -} +}; -export function rename(oldPath, newPath, callback) { +export let rename = function rename(oldPath, newPath, callback) { validatePath(oldPath, 'oldPath'); validatePath(newPath, 'newPath'); validateCallback(callback); @@ -2595,9 +2595,9 @@ export function rename(oldPath, newPath, callback) { } else { queueMicrotask(() => callback(null)); } -} +}; -export function mkdir(path, optionsOrCallback, callback) { +export let mkdir = function mkdir(path, optionsOrCallback, callback) { validatePath(path); let cb; let options; @@ -2622,9 +2622,9 @@ export function mkdir(path, optionsOrCallback, callback) { cb(null, recursive ? firstCreatedPath : undefined); } }); -} +}; -export function rmdir(path, optionsOrCallback, callback) { +export let rmdir = function rmdir(path, optionsOrCallback, callback) { validatePath(path); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -2640,9 +2640,9 @@ export function rmdir(path, optionsOrCallback, callback) { cb(err); } }); -} +}; -export function rm(path, optionsOrCallback, callback) { +export let rm = function rm(path, optionsOrCallback, callback) { validatePath(path); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -2658,9 +2658,9 @@ export function rm(path, optionsOrCallback, callback) { cb(err); } }); -} +}; -export function mkdtemp(prefix, optionsOrCallback, callback) { +export let mkdtemp = function mkdtemp(prefix, optionsOrCallback, callback) { validateMkdtempPrefix(prefix); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -2678,9 +2678,9 @@ export function mkdtemp(prefix, optionsOrCallback, callback) { cb(err); } }); -} +}; -export function opendir(path, optionsOrCallback, callback) { +export let opendir = function opendir(path, optionsOrCallback, callback) { validatePath(path); if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -2697,7 +2697,7 @@ export function opendir(path, optionsOrCallback, callback) { cb(err); } }); -} +}; // --- FSWatcher (polling-based, since WASI has no native inotify/kqueue) --- // Synchronous notification registry: mutating fs operations notify active watchers @@ -2747,7 +2747,7 @@ function _snapshotDir(dir, recursive) { return entries; } -export class FSWatcher { +export let FSWatcher = class FSWatcher { constructor() { this._listeners = {}; this._timer = null; @@ -2864,7 +2864,7 @@ export class FSWatcher { if (this._timer && typeof this._timer.unref === 'function') this._timer.unref(); return this; } -} +}; const _statWatchers = new Map(); @@ -2885,7 +2885,7 @@ function _tryStat(filename) { return new Stats(result.stat); } -export class StatWatcher { +export let StatWatcher = class StatWatcher { constructor() { this._eventListeners = {}; this._timer = null; @@ -2966,9 +2966,9 @@ export class StatWatcher { if (this._timer) this._timer.unref(); return this; } -} +}; -export function watch(filename, optionsOrListener, listener) { +export let watch = function watch(filename, optionsOrListener, listener) { validatePath(filename, 'filename'); if (typeof optionsOrListener === 'function') { listener = optionsOrListener; @@ -3008,9 +3008,9 @@ export function watch(filename, optionsOrListener, listener) { } return watcher; -} +}; -export function watchFile(filename, optionsOrListener, listener) { +export let watchFile = function watchFile(filename, optionsOrListener, listener) { validatePath(filename, 'filename'); filename = pathToString(filename); @@ -3033,9 +3033,9 @@ export function watchFile(filename, optionsOrListener, listener) { } watcher.addListener('change', listener); return watcher; -} +}; -export function unwatchFile(filename, listener) { +export let unwatchFile = function unwatchFile(filename, listener) { validatePath(filename, 'filename'); filename = pathToString(filename); const watcher = _statWatchers.get(filename); @@ -3051,13 +3051,13 @@ export function unwatchFile(filename, listener) { watcher.stop(); _statWatchers.delete(filename); } -} +}; // --- ReadStream / WriteStream --- let _readStreamProtoInited = false; -export function ReadStream(path, options) { +export let ReadStream = function ReadStream(path, options) { if (!(this instanceof ReadStream)) return new ReadStream(path, options); if (options !== undefined && options !== null && typeof options !== 'object' && typeof options !== 'string') { @@ -3193,7 +3193,7 @@ export function ReadStream(path, options) { if (!self.destroyed) self.destroy(); }); } -} +}; ReadStream.prototype._construct = function(callback) { if (typeof this.fd === 'number') { @@ -3360,7 +3360,7 @@ Object.defineProperty(ReadStream.prototype, 'closed', { let _writeStreamProtoInited = false; -export function WriteStream(path, options) { +export let WriteStream = function WriteStream(path, options) { if (!(this instanceof WriteStream)) return new WriteStream(path, options); if (options !== undefined && options !== null && typeof options !== 'object' && typeof options !== 'string') { @@ -3463,7 +3463,7 @@ export function WriteStream(path, options) { if (!self.destroyed) self.destroy(); }); } -} +}; WriteStream.prototype._construct = function(callback) { if (typeof this.fd === 'number') { @@ -3618,17 +3618,17 @@ Object.defineProperty(WriteStream.prototype, 'closed', { configurable: true }); -export function createReadStream(path, options) { +export let createReadStream = function createReadStream(path, options) { return new ReadStream(path, options); -} +}; -export function createWriteStream(path, options) { +export let createWriteStream = function createWriteStream(path, options) { return new WriteStream(path, options); -} +}; // --- readv/writev stubs --- -export function readv(fd, buffers, positionOrCallback, callback) { +export let readv = function readv(fd, buffers, positionOrCallback, callback) { validateFd(fd); let position = null; let cb; @@ -3666,9 +3666,9 @@ export function readv(fd, buffers, positionOrCallback, callback) { cb(err, 0, buffers); } }); -} +}; -export function writev(fd, buffers, positionOrCallback, callback) { +export let writev = function writev(fd, buffers, positionOrCallback, callback) { validateFd(fd); let position = null; let cb; @@ -3704,9 +3704,9 @@ export function writev(fd, buffers, positionOrCallback, callback) { cb(err, 0, buffers); } }); -} +}; -export function readvSync(fd, buffers, position) { +export let readvSync = function readvSync(fd, buffers, position) { validateFd(fd); if (!Array.isArray(buffers)) { const err = new TypeError('The "buffers" argument must be an instance of Array. Received ' + describeType(buffers)); @@ -3730,9 +3730,9 @@ export function readvSync(fd, buffers, position) { if (bytesRead < buf.byteLength) break; } return totalRead; -} +}; -export function writevSync(fd, buffers, position) { +export let writevSync = function writevSync(fd, buffers, position) { validateFd(fd); if (!Array.isArray(buffers)) { const err = new TypeError('The "buffers" argument must be an instance of Array. Received ' + describeType(buffers)); @@ -3754,11 +3754,11 @@ export function writevSync(fd, buffers, position) { if (pos !== null) pos += written; } return totalWritten; -} +}; // --- cp stub --- -export function cpSync(src, dest, options) { +export let cpSync = function cpSync(src, dest, options) { const recursive = options && options.recursive; const srcStat = statSync(src); if (srcStat.isDirectory()) { @@ -3778,9 +3778,9 @@ export function cpSync(src, dest, options) { } else { copyFileSync(src, dest); } -} +}; -export function cp(src, dest, optionsOrCallback, callback) { +export let cp = function cp(src, dest, optionsOrCallback, callback) { if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; optionsOrCallback = {}; @@ -3795,7 +3795,7 @@ export function cp(src, dest, optionsOrCallback, callback) { cb(err); } }); -} +}; // --- util.promisify support --- @@ -3981,11 +3981,11 @@ class FileBackedBlobSlice { } } -export async function openAsBlob(path, options) { +export let openAsBlob = async function openAsBlob(path, options) { validatePath(path); const st = statSync(path); return new FileBackedBlob(pathToString(path), st.size, st.mtimeMs); -} +}; // Expose the symbol for structuredClone integration export { _kFileBackedBlob }; @@ -3993,7 +3993,7 @@ export { _kFileBackedBlob }; // Named re-export so `import { promises } from 'node:fs'` works. // We cannot call getPromises() at module evaluation time because `require` is // not yet available, so we export a proxy object that lazily delegates. -export const promises = new Proxy({}, { +export let promises = new Proxy({}, { get(_, prop) { return getPromises()[prop]; }, set(_, prop, value) { getPromises()[prop] = value; return true; }, has(_, prop) { return prop in getPromises(); }, @@ -4003,7 +4003,7 @@ export const promises = new Proxy({}, { // --- Internal helpers --- -export function _toUnixTimestamp(time, name = 'time') { +export let _toUnixTimestamp = function _toUnixTimestamp(time, name = 'time') { if (typeof time === 'string' && +time == time) { return +time; } @@ -4017,7 +4017,7 @@ export function _toUnixTimestamp(time, name = 'time') { return time.getTime() / 1000; } throw new ERR_INVALID_ARG_TYPE(name, ['Date', 'Time in seconds'], time); -} +}; // --- Default export --- @@ -4126,4 +4126,113 @@ const _default = { _toUnixTimestamp, }; +const _syncBuiltinESMExportsRegistry = globalThis.__wasm_rquickjs_sync_builtin_esm_exports || + Object.defineProperty(globalThis, '__wasm_rquickjs_sync_builtin_esm_exports', { + value: Object.create(null), + configurable: true, + }).__wasm_rquickjs_sync_builtin_esm_exports; + +_syncBuiltinESMExportsRegistry.fs = function syncFsBuiltinESMExports() { + constants = _default.constants; + Stats = _default.Stats; + Dirent = _default.Dirent; + Dir = _default.Dir; + FSWatcher = _default.FSWatcher; + StatWatcher = _default.StatWatcher; + readFileSync = _default.readFileSync; + writeFileSync = _default.writeFileSync; + appendFileSync = _default.appendFileSync; + openSync = _default.openSync; + closeSync = _default.closeSync; + readSync = _default.readSync; + writeSync = _default.writeSync; + ftruncateSync = _default.ftruncateSync; + fsyncSync = _default.fsyncSync; + fdatasyncSync = _default.fdatasyncSync; + statSync = _default.statSync; + lstatSync = _default.lstatSync; + fstatSync = _default.fstatSync; + statfsSync = _default.statfsSync; + readdirSync = _default.readdirSync; + accessSync = _default.accessSync; + existsSync = _default.existsSync; + realpathSync = _default.realpathSync; + truncateSync = _default.truncateSync; + copyFileSync = _default.copyFileSync; + linkSync = _default.linkSync; + symlinkSync = _default.symlinkSync; + readlinkSync = _default.readlinkSync; + chmodSync = _default.chmodSync; + fchmodSync = _default.fchmodSync; + lchmodSync = _default.lchmodSync; + chownSync = _default.chownSync; + fchownSync = _default.fchownSync; + lchownSync = _default.lchownSync; + utimesSync = _default.utimesSync; + futimesSync = _default.futimesSync; + lutimesSync = _default.lutimesSync; + unlinkSync = _default.unlinkSync; + renameSync = _default.renameSync; + mkdirSync = _default.mkdirSync; + rmdirSync = _default.rmdirSync; + rmSync = _default.rmSync; + mkdtempSync = _default.mkdtempSync; + opendirSync = _default.opendirSync; + readFile = _default.readFile; + writeFile = _default.writeFile; + appendFile = _default.appendFile; + open = _default.open; + close = _default.close; + read = _default.read; + write = _default.write; + stat = _default.stat; + lstat = _default.lstat; + statfs = _default.statfs; + fstat = _default.fstat; + ftruncate = _default.ftruncate; + fsync = _default.fsync; + fdatasync = _default.fdatasync; + readdir = _default.readdir; + access = _default.access; + exists = _default.exists; + realpath = _default.realpath; + truncate = _default.truncate; + copyFile = _default.copyFile; + link = _default.link; + symlink = _default.symlink; + readlink = _default.readlink; + chmod = _default.chmod; + fchmod = _default.fchmod; + lchmod = _default.lchmod; + chown = _default.chown; + fchown = _default.fchown; + lchown = _default.lchown; + utimes = _default.utimes; + futimes = _default.futimes; + lutimes = _default.lutimes; + unlink = _default.unlink; + rename = _default.rename; + mkdir = _default.mkdir; + rmdir = _default.rmdir; + rm = _default.rm; + mkdtemp = _default.mkdtemp; + opendir = _default.opendir; + watch = _default.watch; + watchFile = _default.watchFile; + unwatchFile = _default.unwatchFile; + ReadStream = _default.ReadStream; + WriteStream = _default.WriteStream; + createReadStream = _default.createReadStream; + createWriteStream = _default.createWriteStream; + readv = _default.readv; + writev = _default.writev; + readvSync = _default.readvSync; + writevSync = _default.writevSync; + cpSync = _default.cpSync; + cp = _default.cp; + openAsBlob = _default.openAsBlob; + promises = _default.promises; + _toUnixTimestamp = _default._toUnixTimestamp; +}; + export default _default; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index e21563a4..45c283f6 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -2550,11 +2550,11 @@ function makeRequire(parentDir, parentModule, parentFilenameOverride) { // The global require, rooted at '/' const globalRequire = makeRequire('/', mainModule); -export function require(id) { +export let require = function require(id) { return globalRequire(id); -} +}; -export function createRequire(filename) { +export let createRequire = function createRequire(filename) { let filepath; const isUrlObj = filename instanceof URL || (filename !== null && typeof filename === 'object' && @@ -2594,7 +2594,7 @@ export function createRequire(filename) { paths: _nodeModulePaths(dir), }; return makeRequire(dir, syntheticParent, filepath); -} +}; function isUrlInstance(value) { return value instanceof URL || @@ -2722,7 +2722,7 @@ function findBarePackageJson(specifier, parentDir, parentFilename) { return undefined; } -export function findPackageJSON(specifier, base) { +export let findPackageJSON = function findPackageJSON(specifier, base) { const normalizedSpecifier = normalizeFindPackageJsonSpecifier(specifier); if (normalizedSpecifier.kind === 'absolute') { const startDir = packageSearchStartDir(normalizedSpecifier.path, normalizedSpecifier.source); @@ -2737,13 +2737,13 @@ export function findPackageJSON(specifier, base) { } return findBarePackageJson(normalizedSpecifier.value, normalizedBase.dir, normalizedBase.filename); -} +}; -export { builtinModuleNames as builtinModules }; +export let builtinModules = builtinModuleNames; -export function isBuiltinModule(id) { +export let isBuiltinModule = function isBuiltinModule(id) { return isBuiltin(id); -} +}; // "node_modules" reversed as char codes: s-e-l-u-d-o-m-_-e-d-o-n const nmChars = [115, 101, 108, 117, 100, 111, 109, 95, 101, 100, 111, 110]; @@ -2881,6 +2881,19 @@ function runMain() { } } +export let syncBuiltinESMExports = function() { + const registry = globalThis.__wasm_rquickjs_sync_builtin_esm_exports; + if (!registry) return; + if (typeof registry.fs === 'function') registry.fs(); + if (typeof registry.events === 'function') registry.events(); + require = moduleExports.require; + createRequire = moduleExports.createRequire; + findPackageJSON = moduleExports.findPackageJSON; + builtinModules = moduleExports.builtinModules; + isBuiltinModule = moduleExports.isBuiltin; + syncBuiltinESMExports = moduleExports.syncBuiltinESMExports; +}; + function Module(id, parent) { this.id = id || ''; this.path = ''; @@ -2901,6 +2914,7 @@ const moduleExports = Object.assign(Module, { createRequire, findPackageJSON, builtinModules: builtinModuleNames, + syncBuiltinESMExports, isBuiltin: isBuiltinModule, wrap: wrap, wrapper: wrapper, diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 04779a9d..542c1353 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -212,6 +212,85 @@ export const testEsmInvalidPackageSpecifiers = async () => { } }; +export const testSyncBuiltinEsmExports = async () => { + try { + const module = await import('node:module'); + const fsModule = await import('node:fs'); + const eventsModule = await import('node:events'); + + const fs = fsModule.default; + const originalReadFile = fs.readFile; + const originalReadFileSync = fs.readFileSync; + const originalWriteFile = fs.writeFile; + const originalExistsSync = fs.existsSync; + const originalOpenAsBlob = fs.openAsBlob; + const replacementReadFile = function replacementReadFile() {}; + const replacementReadFileSync = function replacementReadFileSync() {}; + const replacementWriteFile = function replacementWriteFile() {}; + const replacementExistsSync = function replacementExistsSync() {}; + const replacementOpenAsBlob = function replacementOpenAsBlob() {}; + + fs.readFile = replacementReadFile; + fs.readFileSync = replacementReadFileSync; + fs.writeFile = replacementWriteFile; + fs.existsSync = replacementExistsSync; + fs.openAsBlob = replacementOpenAsBlob; + module.syncBuiltinESMExports(); + assert.strictEqual(fsModule.readFile, replacementReadFile); + assert.strictEqual(fsModule.readFileSync, replacementReadFileSync); + assert.strictEqual(fsModule.writeFile, replacementWriteFile); + assert.strictEqual(fsModule.existsSync, replacementExistsSync); + assert.strictEqual(fsModule.openAsBlob, replacementOpenAsBlob); + + delete fs.readFile; + module.syncBuiltinESMExports(); + assert.strictEqual(fsModule.readFile, undefined); + + fs.readFile = originalReadFile; + fs.readFileSync = originalReadFileSync; + fs.writeFile = originalWriteFile; + fs.existsSync = originalExistsSync; + fs.openAsBlob = originalOpenAsBlob; + module.syncBuiltinESMExports(); + + const events = eventsModule.default; + const originalDefaultMaxListeners = events.defaultMaxListeners; + const originalOnce = events.once; + const originalGetMaxListeners = events.getMaxListeners; + const replacementOnce = function replacementOnce() {}; + const replacementGetMaxListeners = function replacementGetMaxListeners() {}; + events.defaultMaxListeners = originalDefaultMaxListeners + 1; + events.once = replacementOnce; + events.getMaxListeners = replacementGetMaxListeners; + module.syncBuiltinESMExports(); + assert.strictEqual(eventsModule.defaultMaxListeners, originalDefaultMaxListeners + 1); + assert.strictEqual(eventsModule.once, replacementOnce); + assert.strictEqual(eventsModule.getMaxListeners, replacementGetMaxListeners); + events.defaultMaxListeners = originalDefaultMaxListeners; + events.once = originalOnce; + events.getMaxListeners = originalGetMaxListeners; + module.syncBuiltinESMExports(); + + const moduleDefault = module.default; + const originalSyncBuiltinESMExports = moduleDefault.syncBuiltinESMExports; + const originalCreateRequire = moduleDefault.createRequire; + const replacementSyncBuiltinESMExports = function replacementSyncBuiltinESMExports() {}; + const replacementCreateRequire = function replacementCreateRequire() {}; + moduleDefault.syncBuiltinESMExports = replacementSyncBuiltinESMExports; + moduleDefault.createRequire = replacementCreateRequire; + originalSyncBuiltinESMExports(); + assert.strictEqual(module.syncBuiltinESMExports, replacementSyncBuiltinESMExports); + assert.strictEqual(module.createRequire, replacementCreateRequire); + moduleDefault.syncBuiltinESMExports = originalSyncBuiltinESMExports; + moduleDefault.createRequire = originalCreateRequire; + originalSyncBuiltinESMExports(); + return true; + } catch (error) { + console.error(error); + throw error; + } +}; + export const testCjsDirectNamedExports = async () => { try { fs.mkdirSync('/cjs-named-export-app', { recursive: true }); diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index 71616e0b..81f90b1b 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -4,6 +4,7 @@ world module-resolution { export test-esm-package-map-edge-cases: func() -> bool; export test-esm-encoded-relative-paths: func() -> bool; export test-esm-invalid-package-specifiers: func() -> bool; + export test-sync-builtin-esm-exports: func() -> bool; export test-cjs-direct-named-exports: func() -> bool; export test-cjs-define-property-named-exports: func() -> bool; export test-cjs-reexport-named-exports: func() -> bool; diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 0a3abafe..c66a0add 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -6019,7 +6019,7 @@ "es-module/test-esm-initialization.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-json-cache.mjs": {}, "es-module/test-esm-json.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-live-binding.mjs": { "category": "known-gap", "reason": "module.syncBuiltinESMExports is not implemented" }, + "es-module/test-esm-live-binding.mjs": { "category": "runnable" }, "es-module/test-esm-loader-chaining.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-loader-custom-condition.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, "es-module/test-esm-loader-default-resolver.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, @@ -6042,7 +6042,7 @@ "es-module/test-esm-loader.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, "es-module/test-esm-main-lookup.mjs": { "category": "known-gap", "reason": "ESM main lookup and error-url behavior still differ from Node's resolver" }, "es-module/test-esm-module-not-found-commonjs-hint.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-named-exports.mjs": { "category": "known-gap", "reason": "module.syncBuiltinESMExports is not implemented" }, + "es-module/test-esm-named-exports.mjs": { "category": "known-gap", "reason": "requires loader pre-import fixture support for --import setup modules" }, "es-module/test-esm-namespace.mjs": {}, "es-module/test-esm-no-addons.mjs": { "category": "wasi-impossible", "reason": "requires worker_threads/native addon process isolation semantics, which are not available in single-threaded WASM" }, "es-module/test-esm-non-js.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index a8d7ce6f..2c6120c4 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3129/4404 (71.0%) +**Primary compatibility (CI-enforced):** 3130/4404 (71.1%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3129 | 71.0% | 54.7% | 45.7% | -| 🧩 known gap | 1275 | 29.0% | 22.3% | 18.6% | +| ✅ passing (runnable) | 3130 | 71.1% | 54.7% | 45.7% | +| 🧩 known gap | 1274 | 28.9% | 22.3% | 18.6% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3129/5721 (54.7%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3130/5721 (54.7%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 130 | 172 | 85 | 11 | 0 | 183 | 43.0% | 32.7% | +| other | 581 | 131 | 171 | 85 | 11 | 0 | 183 | 43.4% | 32.9% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1275) +### known gap (1274) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -797,7 +797,6 @@ Secondary full-public compatibility, including public tests that are currently e | inherited: process.getActiveResourcesInfo() is not implemented | 2 | `parallel/test-process-getactiveresources-track-timer-lifetime.js#block_00_block_00`, `parallel/test-process-getactiveresources-track-timer-lifetime.js#block_01_block_01` | | inherited: queueMicrotask argument validation/error codes are incomplete | 2 | `parallel/test-queue-microtask.js#block_00_block_00`, `parallel/test-queue-microtask.js#block_01_block_01` | | inherited: requires perf_hooks.PerformanceObserver with net detail | 2 | `parallel/test-net-perf_hooks.js#block_00_block_00`, `parallel/test-net-perf_hooks.js#block_01_block_01` | -| module.syncBuiltinESMExports is not implemented | 2 | `es-module/test-esm-live-binding.mjs`, `es-module/test-esm-named-exports.mjs` | | native rquickjs URL accessor descriptor function names are empty instead of Web IDL names like `get href` | 2 | `parallel/test-whatwg-url-properties.js#block_00_block_00`, `parallel/test-whatwg-url-properties.js#block_01_block_01` | | perf_hooks performance.timeOrigin/nodeTiming semantics are not Node-compatible | 2 | `sequential/test-perf-hooks.js#block_00_block_00`, `sequential/test-perf-hooks.js#block_01_block_01` | | perf_hooks resource timing buffer/full-event behavior is incomplete | 2 | `parallel/test-performance-resourcetimingbufferfull.js`, `parallel/test-performance-resourcetimingbuffersize.js` | @@ -1240,6 +1239,7 @@ Secondary full-public compatibility, including public tests that are currently e | requires actual TCP socket reuse with remotePort identity tracking via server; wasi:http creates new connections per request | 1 | `parallel/test-http-agent-scheduling.js` | | requires createConnection to forward keepAlive/keepAliveInitialDelay options; wasi:http does not use Agent.createConnection for outbound requests | 1 | `parallel/test-http-agent-keepalive-delay.js` | | requires fd option for listen | 1 | `parallel/test-net-listen-fd0.js` | +| requires loader pre-import fixture support for --import setup modules | 1 | `es-module/test-esm-named-exports.mjs` | | requires module.register loader hooks to synthesize virtual JSON modules | 1 | `es-module/test-esm-virtual-json.mjs` | | requires net.createServer with pauseOnConnect and socket.localPort; wasi:http does not expose socket-level properties | 1 | `parallel/test-http-agent-reuse-drained-socket-only.js` | | requires onread option with buffer/callback | 1 | `parallel/test-net-onread-static-buffer.js` | diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index 4f52073d..0a38d7ff 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -62,6 +62,23 @@ async fn esm_invalid_package_specifiers( Ok(()) } +#[test] +async fn sync_builtin_esm_exports( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-sync-builtin-esm-exports", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + #[test] async fn cjs_direct_named_exports( #[tagged_as("module_resolution")] compiled_test: &CompiledTest, From 496af17edddc0eab50ffc1fd940973fba7e7fb4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 11:42:21 +0200 Subject: [PATCH 34/42] Fix ESM resolver errors and promote two tests --- crates/wasm-rquickjs/skeleton/src/internal.rs | 293 ++++++++++++++++-- .../src/module-resolution.js | 92 ++++++ .../wit/module-resolution.wit | 1 + tests/common/mod.rs | 34 ++ tests/node_compat/config.jsonc | 8 +- tests/node_compat/report.md | 18 +- tests/runtime/module_resolution.rs | 17 + 7 files changed, 431 insertions(+), 32 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 6948919b..70f757ea 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -3,9 +3,10 @@ use futures_concurrency::future::Join; use indexmap::IndexMap; use rquickjs::function::{Args, Constructor}; use rquickjs::loader::{BuiltinLoader, BuiltinResolver, FileResolver, Loader, Resolver}; +use rquickjs::object::Property; use rquickjs::{ AsyncContext, AsyncRuntime, CatchResultExt, Ctx, Error, Filter, FromJs, Function, Module, - Object, Promise, Value, async_with, + Object, Promise, Value, async_with, Exception, }; use rquickjs::{CaughtError, prelude::*}; use serde::Deserialize; @@ -1591,12 +1592,13 @@ struct FileUrlResolver; impl FileUrlResolver { /// Decode a `file://` URL into a filesystem path, handling percent-encoding. fn file_url_to_path(url: &str) -> Option { - let encoded = url.strip_prefix("file://")?; - let end = encoded - .find(|ch| ch == '?' || ch == '#') - .unwrap_or(encoded.len()); - let encoded_path = &encoded[..end]; - let suffix = &encoded[end..]; + let (mut path, suffix) = Self::file_url_to_path_parts(url)?; + path.push_str(suffix); + Some(path) + } + + fn file_url_to_path_parts(url: &str) -> Option<(String, &str)> { + let (encoded_path, suffix) = Self::file_url_path_and_suffix(url)?; let bytes = encoded_path.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut i = 0; @@ -1613,9 +1615,32 @@ impl FileUrlResolver { decoded.push(bytes[i]); i += 1; } - let mut path = String::from_utf8(decoded).ok()?; - path.push_str(suffix); - Some(path) + Some((String::from_utf8(decoded).ok()?, suffix)) + } + + fn file_url_path_and_suffix(url: &str) -> Option<(&str, &str)> { + let encoded = url.strip_prefix("file://")?; + let end = encoded + .find(|ch| ch == '?' || ch == '#') + .unwrap_or(encoded.len()); + let encoded_path = &encoded[..end]; + let (host, path) = if encoded_path.starts_with('/') { + ("", encoded_path) + } else if let Some(slash) = encoded_path.find('/') { + (&encoded_path[..slash], &encoded_path[slash..]) + } else { + (encoded_path, "/") + }; + + if host.is_empty() || host.eq_ignore_ascii_case("localhost") { + Some((path, &encoded[end..])) + } else { + None + } + } + + fn has_invalid_file_url_host(url: &str) -> bool { + url.starts_with("file://") && Self::file_url_path_and_suffix(url).is_none() } fn hex_val(b: u8) -> Option { @@ -1642,10 +1667,34 @@ impl Resolver for FileUrlResolver { if NodeFileResolver::has_encoded_path_separator(&encoded[..end]) { return NodeFileResolver::throw_invalid_encoded_separator(ctx, base, name); } + if Self::has_invalid_file_url_host(name) { + return NodeFileResolver::throw_invalid_file_url_host( + ctx, + format!("File URL host must be \"localhost\" or empty: {}", name), + ); + } } - if let Some(path) = Self::file_url_to_path(name) { - Ok(path) + if let Some((path, suffix)) = Self::file_url_to_path_parts(name) { + let normalized = CjsEvalResolver::normalize_path(std::path::Path::new(&path)); + let url = NodeFileResolver::module_url_for_file_specifier(name); + if std::path::Path::new(&normalized).is_dir() { + return NodeFileResolver::throw_module_resolution_error( + ctx, + "ERR_UNSUPPORTED_DIR_IMPORT", + format!("Directory import '{}' is not supported resolving ES modules", name), + url, + ); + } + if !std::path::Path::new(&normalized).is_file() { + return NodeFileResolver::throw_module_resolution_error( + ctx, + "ERR_MODULE_NOT_FOUND", + format!("Cannot find module '{}'", name), + url, + ); + } + Ok(format!("{normalized}{suffix}")) } else { Err(Error::new_resolving(base, name)) } @@ -1919,6 +1968,19 @@ impl NodeFileResolver { Err(ctx.throw(error_obj.into_value())) } + fn throw_invalid_file_url_host<'js, T>( + ctx: &Ctx<'js>, + message: String, + ) -> rquickjs::Result { + let _ = Exception::throw_type(ctx, &message); + let error_value = ctx.catch(); + let Some(error_obj) = error_value.clone().into_object() else { + return Err(ctx.throw(error_value)); + }; + Self::define_error_property(&error_obj, "code", "ERR_INVALID_FILE_URL_HOST")?; + Err(ctx.throw(error_obj.into_value())) + } + fn resolve_candidate(candidate: std::path::PathBuf, suffix: &str) -> Option { let normalized = CjsEvalResolver::normalize_path(&candidate); if std::path::Path::new(&normalized).is_file() { @@ -1936,6 +1998,65 @@ impl NodeFileResolver { None } + + fn module_url_for_path(path: &str, suffix: &str) -> String { + format!( + "{}{}", + path_without_suffix_to_file_url(path), + serialize_url_preserving_escapes(suffix) + ) + } + + fn module_url_for_encoded_path(path: &str, suffix: &str) -> String { + let path = normalize_encoded_module_path(path); + format!( + "{}{}", + path_with_preserved_escapes_to_file_url(&path), + serialize_url_preserving_escapes(suffix) + ) + } + + fn module_url_for_file_specifier(specifier: &str) -> String { + if !specifier.starts_with("file://") { + return serialize_url_preserving_escapes(specifier); + } + let Some((encoded_path, suffix)) = FileUrlResolver::file_url_path_and_suffix(specifier) + else { + return serialize_url_preserving_escapes(specifier); + }; + let encoded_path = normalize_encoded_module_path(encoded_path); + format!( + "{}{}", + path_with_preserved_escapes_to_file_url(&encoded_path), + serialize_url_preserving_escapes(suffix) + ) + } + + fn throw_module_resolution_error<'js, T>( + ctx: &Ctx<'js>, + code: &str, + message: String, + url: String, + ) -> rquickjs::Result { + let error_obj = Exception::from_message(ctx.clone(), &message)?.into_object(); + Self::define_error_property(&error_obj, "code", code)?; + Self::define_error_property(&error_obj, "url", &url)?; + Err(ctx.throw(error_obj.into_value())) + } + + fn define_error_property<'js>( + error_obj: &Object<'js>, + name: &str, + value: &str, + ) -> rquickjs::Result<()> { + error_obj.prop( + name, + Property::from(value) + .writable() + .enumerable() + .configurable(), + ) + } } impl Resolver for NodeFileResolver { @@ -1950,11 +2071,12 @@ impl Resolver for NodeFileResolver { } let (name_path, suffix) = split_module_path_suffix(name); - let candidate = if name_path.starts_with('/') { + let (candidate, url) = if name_path.starts_with('/') { + let encoded_path = CjsEvalResolver::normalize_path(std::path::Path::new(name_path)); + let url = Self::module_url_for_encoded_path(&encoded_path, suffix); let name_path = Self::decode_module_path(ctx, base, name, name_path)?; - std::path::PathBuf::from(name_path.as_ref()) + (std::path::PathBuf::from(name_path.as_ref()), url) } else if name_path.starts_with("./") || name_path.starts_with("../") { - let name_path = Self::decode_module_path(ctx, base, name, name_path)?; let base_path = if let Some(path) = FileUrlResolver::file_url_to_path(base) { path } else { @@ -1969,12 +2091,35 @@ impl Resolver for NodeFileResolver { let base_dir = std::path::Path::new(&base_path) .parent() .ok_or_else(|| Error::new_resolving(base, name))?; - base_dir.join(name_path.as_ref()) + let encoded_candidate = base_dir.join(name_path); + let encoded_path = CjsEvalResolver::normalize_path(&encoded_candidate); + let url = Self::module_url_for_encoded_path(&encoded_path, suffix); + let name_path = Self::decode_module_path(ctx, base, name, name_path)?; + (base_dir.join(name_path.as_ref()), url) } else { return Err(Error::new_resolving(base, name)); }; - Self::resolve_candidate(candidate, suffix).ok_or_else(|| Error::new_resolving(base, name)) + let normalized = CjsEvalResolver::normalize_path(&candidate); + if std::path::Path::new(&normalized).is_dir() { + return Self::throw_module_resolution_error( + ctx, + "ERR_UNSUPPORTED_DIR_IMPORT", + format!("Directory import '{}' is not supported resolving ES modules", name), + url, + ); + } + + if let Some(resolved) = Self::resolve_candidate(candidate, suffix) { + return Ok(resolved); + } + + Self::throw_module_resolution_error( + ctx, + "ERR_MODULE_NOT_FOUND", + format!("Cannot find module '{}'", name), + url, + ) } } @@ -4283,6 +4428,17 @@ fn ensure_absolute_path(path: &str) -> String { fn path_to_file_url(path: &str) -> String { let abs_path = ensure_absolute_path(path); let (abs_path, suffix) = split_module_path_suffix(&abs_path); + let mut url = path_without_suffix_to_file_url(abs_path); + url.push_str(suffix); + url +} + +fn path_without_suffix_to_file_url(path: &str) -> String { + let abs_path = if path.starts_with('/') { + Cow::Borrowed(path) + } else { + Cow::Owned(format!("/{path}")) + }; let mut url = String::from("file://"); for byte in abs_path.as_bytes() { match byte { @@ -4304,10 +4460,111 @@ fn path_to_file_url(path: &str) -> String { } } } - url.push_str(suffix); url } +fn path_with_preserved_escapes_to_file_url(path: &str) -> String { + let abs_path = if path.starts_with('/') { + Cow::Borrowed(path) + } else { + Cow::Owned(format!("/{path}")) + }; + let mut url = String::from("file://"); + let bytes = abs_path.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'%' if i + 2 < bytes.len() + && FileUrlResolver::hex_val(bytes[i + 1]).is_some() + && FileUrlResolver::hex_val(bytes[i + 2]).is_some() => + { + url.push('%'); + url.push(bytes[i + 1] as char); + url.push(bytes[i + 2] as char); + i += 3; + continue; + } + b'%' => url.push_str("%25"), + b' ' => url.push_str("%20"), + b'#' => url.push_str("%23"), + b'?' => url.push_str("%3F"), + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'~' + | b'/' + | b':' => url.push(bytes[i] as char), + _ => { + url.push_str(&format!("%{:02X}", bytes[i])); + } + } + i += 1; + } + url +} + +fn normalize_encoded_module_path(path: &str) -> String { + let is_absolute = path.starts_with('/'); + let mut parts = Vec::new(); + + for segment in path.split('/') { + if segment.is_empty() || is_encoded_dot_segment(segment, ".") { + continue; + } + if is_encoded_dot_segment(segment, "..") { + parts.pop(); + } else { + parts.push(segment); + } + } + + if is_absolute { + format!("/{}", parts.join("/")) + } else { + parts.join("/") + } +} + +fn is_encoded_dot_segment(segment: &str, expected: &str) -> bool { + if segment == expected { + return true; + } + percent_decode(segment).is_some_and(|decoded| decoded == expected) +} + +fn serialize_url_preserving_escapes(input: &str) -> String { + let bytes = input.as_bytes(); + let mut encoded = String::with_capacity(input.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'%' if i + 2 < bytes.len() + && FileUrlResolver::hex_val(bytes[i + 1]).is_some() + && FileUrlResolver::hex_val(bytes[i + 2]).is_some() => + { + encoded.push('%'); + encoded.push(bytes[i + 1] as char); + encoded.push(bytes[i + 2] as char); + i += 3; + continue; + } + b' ' => encoded.push_str("%20"), + 0x00..=0x20 | b'"' | b'<' | b'>' | b'`' => { + encoded.push_str(&format!("%{:02X}", bytes[i])); + } + _ if bytes[i] > 0x7F => { + encoded.push_str(&format!("%{:02X}", bytes[i])); + } + _ => encoded.push(bytes[i] as char), + } + i += 1; + } + encoded +} + fn split_module_path_suffix(path: &str) -> (&str, &str) { let suffix_start = path.find(|ch| ch == '?' || ch == '#').unwrap_or(path.len()); (&path[..suffix_start], &path[suffix_start..]) diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 542c1353..dbae58e7 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -291,6 +291,98 @@ export const testSyncBuiltinEsmExports = async () => { } }; +export const testEsmResolutionErrorUrls = async () => { + try { + fs.mkdirSync('/esm-error-url-app/dir', { recursive: true }); + fs.mkdirSync('/esm-error-url-app/sub', { recursive: true }); + fs.writeFileSync('/esm-error-url-app/entry.mjs', "await import('./miss%2Eing');\n"); + fs.writeFileSync('/esm-error-url-app/entry-dot.mjs', "await import('./sub/%2e%2e/missing');\n"); + const originalError = globalThis.Error; + const originalTypeError = globalThis.TypeError; + const poisonUrl = { + configurable: true, + get() { + throw new originalError('prototype url getter should not be read'); + }, + set() { + throw new originalError('prototype url setter should not be called'); + }, + }; + Object.defineProperty(Error.prototype, 'url', poisonUrl); + Object.defineProperty(Object.prototype, 'url', poisonUrl); + const originalDefineProperty = Object.defineProperty; + Object.defineProperty = () => { + throw new originalError('patched Object.defineProperty should not be called'); + }; + globalThis.Error = function PatchedError() { + throw new originalError('patched Error constructor should not be called'); + }; + globalThis.TypeError = function PatchedTypeError() { + throw new originalError('patched TypeError constructor should not be called'); + }; + const cases = [ + ['/esm-error-url-app/dir', 'ERR_UNSUPPORTED_DIR_IMPORT'], + ['/esm-error-url-app/missing', 'ERR_MODULE_NOT_FOUND'], + ['/esm-error-url-app/miss%2Eing', 'ERR_MODULE_NOT_FOUND', 'file:///esm-error-url-app/miss%2Eing'], + ['/esm-error-url-app/missing?x= a#b c', 'ERR_MODULE_NOT_FOUND', 'file:///esm-error-url-app/missing?x=%20a#b%20c'], + ['/esm-error-url-app/entry.mjs', 'ERR_MODULE_NOT_FOUND', 'file:///esm-error-url-app/miss%2Eing'], + ['/esm-error-url-app/sub/%2e%2e/missing', 'ERR_MODULE_NOT_FOUND', 'file:///esm-error-url-app/missing'], + ['/esm-error-url-app/entry-dot.mjs', 'ERR_MODULE_NOT_FOUND', 'file:///esm-error-url-app/missing'], + ['file:///esm-error-url-app/miss%23ing', 'ERR_MODULE_NOT_FOUND', 'file:///esm-error-url-app/miss%23ing'], + ['file:///esm-error-url-app/miss%2Eing', 'ERR_MODULE_NOT_FOUND', 'file:///esm-error-url-app/miss%2Eing'], + ['file:///esm-error-url-app/missing?x= a#b c', 'ERR_MODULE_NOT_FOUND', 'file:///esm-error-url-app/missing?x=%20a#b%20c'], + ['file:///esm-error-url-app/sub/%2e%2e/missing', 'ERR_MODULE_NOT_FOUND', 'file:///esm-error-url-app/missing'], + ['file://localhost/esm-error-url-app/sub/%2e%2e/missing', 'ERR_MODULE_NOT_FOUND', 'file:///esm-error-url-app/missing'], + ['file://LOCALHOST/esm-error-url-app/sub/%2e%2e/missing', 'ERR_MODULE_NOT_FOUND', 'file:///esm-error-url-app/missing'], + ['file://example.com/esm-error-url-app/missing', 'ERR_INVALID_FILE_URL_HOST', null], + ]; + + try { + for (const [specifier, code, expectedUrl = pathToFileURL(specifier).href] of cases) { + await assert.rejects( + import(specifier), + (error) => { + assert.strictEqual(error.code, code); + if (expectedUrl === null) { + assert(!Object.prototype.hasOwnProperty.call(error, 'url')); + assert(error instanceof originalError || error.name === 'TypeError'); + } else { + assert(Object.prototype.hasOwnProperty.call(error, 'url')); + assert.strictEqual(error.url, expectedUrl); + } + return true; + } + ); + const dataSpecifier = expectedUrl === null ? specifier : expectedUrl; + await assert.rejects( + import(`data:text/javascript,import${encodeURIComponent(JSON.stringify(dataSpecifier))}`), + (error) => { + assert.strictEqual(error.code, code); + if (expectedUrl === null) { + assert(!Object.prototype.hasOwnProperty.call(error, 'url')); + assert(error instanceof originalError || error.name === 'TypeError'); + } else { + assert(Object.prototype.hasOwnProperty.call(error, 'url')); + assert.strictEqual(error.url, expectedUrl); + } + return true; + } + ); + } + } finally { + globalThis.TypeError = originalTypeError; + globalThis.Error = originalError; + Object.defineProperty = originalDefineProperty; + delete Error.prototype.url; + delete Object.prototype.url; + } + return true; + } catch (error) { + console.error(error); + throw error; + } +}; + export const testCjsDirectNamedExports = async () => { try { fs.mkdirSync('/cjs-named-export-app', { recursive: true }); diff --git a/examples/runtime/module-resolution/wit/module-resolution.wit b/examples/runtime/module-resolution/wit/module-resolution.wit index 81f90b1b..46b07241 100644 --- a/examples/runtime/module-resolution/wit/module-resolution.wit +++ b/examples/runtime/module-resolution/wit/module-resolution.wit @@ -5,6 +5,7 @@ world module-resolution { export test-esm-encoded-relative-paths: func() -> bool; export test-esm-invalid-package-specifiers: func() -> bool; export test-sync-builtin-esm-exports: func() -> bool; + export test-esm-resolution-error-urls: func() -> bool; export test-cjs-direct-named-exports: func() -> bool; export test-cjs-define-property-named-exports: func() -> bool; export test-cjs-reexport-named-exports: func() -> bool; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d276751c..3822b69e 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -486,6 +486,24 @@ pub fn setup_node_compat_test_files(temp: &Utf8Path, test_rel_path: &str) -> any let dst_test = suite_dir.join(test_filename); fs::copy(&src_test, &dst_test)?; + // Some vendored ESM tests import sibling test files with relative specifiers. + // The split runner still executes one configured test at a time, but those + // relative imports need the original suite directory shape. + let src_suite_dir = std::path::Path::new("tests/node_compat/suite").join(suite); + if suite == "es-module" && src_suite_dir.exists() { + for entry in fs::read_dir(&src_suite_dir)? { + let entry = entry?; + if entry.file_type()?.is_file() { + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + let dst = suite_dir.join(file_name_str.as_ref()); + if !dst.exists() { + fs::copy(entry.path(), dst)?; + } + } + } + } + // Copy the common shim let src_shim = "tests/node_compat/common-shim/index.js"; let dst_shim = common_dir.join("index.js"); @@ -514,6 +532,22 @@ pub fn setup_node_compat_test_files(temp: &Utf8Path, test_rel_path: &str) -> any } } + // Copy vendored ESM common helpers that are not replaced by local shims. + let vendored_common_dir = std::path::Path::new("tests/node_compat/suite/common"); + if vendored_common_dir.exists() { + for entry in fs::read_dir(vendored_common_dir)? { + let entry = entry?; + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + if entry.file_type()?.is_file() + && file_name_str.ends_with(".mjs") + && !common_dir.join(file_name_str.as_ref()).exists() + { + fs::copy(entry.path(), common_dir.join(file_name_str.as_ref()))?; + } + } + } + // Create /tmp directory for tmpdir shim let tmp_dir = temp.join("tmp"); fs::create_dir_all(&tmp_dir)?; diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index c66a0add..a276d5e0 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5984,7 +5984,7 @@ // === es-module completeness sweep (tracked after coverage audit) === "es-module/test-esm-assert-strict.mjs": {}, - "es-module/test-esm-basic-imports.mjs": { "category": "known-gap", "reason": "same-directory relative ESM import resolution differs in the node_compat split runner" }, + "es-module/test-esm-basic-imports.mjs": { "category": "runnable" }, "es-module/test-esm-child-process-fork-main.mjs": { "category": "wasi-impossible", "reason": "requires child_process.fork IPC semantics, which are not available in WASM" }, "es-module/test-esm-cjs-load-error-note.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-cjs-named-error.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, @@ -6005,7 +6005,7 @@ "es-module/test-esm-exports.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-extension-lookup-deprecation.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-extensionless-esm-and-wasm.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-forbidden-globals.mjs": { "category": "known-gap", "reason": "ESM compatibility shim still exposes CJS globals such as __filename and __dirname" }, + "es-module/test-esm-forbidden-globals.mjs": { "category": "known-gap", "reason": "ESM compatibility shim intentionally exposes CommonJS-style __filename/__dirname; switching to strict Node ESM globals needs a product compatibility decision" }, "es-module/test-esm-fs-promises.mjs": {}, "es-module/test-esm-import-assertion-warning.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-import-attributes-1.mjs": {}, @@ -6040,7 +6040,7 @@ "es-module/test-esm-loader-with-source.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, "es-module/test-esm-loader-with-syntax-error.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-loader.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, - "es-module/test-esm-main-lookup.mjs": { "category": "known-gap", "reason": "ESM main lookup and error-url behavior still differ from Node's resolver" }, + "es-module/test-esm-main-lookup.mjs": { "category": "runnable" }, "es-module/test-esm-module-not-found-commonjs-hint.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-named-exports.mjs": { "category": "known-gap", "reason": "requires loader pre-import fixture support for --import setup modules" }, "es-module/test-esm-namespace.mjs": {}, @@ -6060,7 +6060,7 @@ "es-module/test-esm-scope-node-modules.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-shared-loader-dep.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, "es-module/test-esm-shebang.mjs": {}, - "es-module/test-esm-snapshot.mjs": { "category": "known-gap", "reason": "newly tracked module coverage; same-process ESM behavior has not been triaged yet" }, + "es-module/test-esm-snapshot.mjs": { "category": "known-gap", "reason": "V8 startup snapshot fixture mutates CommonJS require.cache; the WASM runner does not model Node/V8 startup snapshot and cache coupling" }, "es-module/test-esm-source-map.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-syntax-error.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-throw-undefined.mjs": {}, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 2c6120c4..67ae4fd4 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3130/4404 (71.1%) +**Primary compatibility (CI-enforced):** 3132/4404 (71.1%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3130 | 71.1% | 54.7% | 45.7% | -| 🧩 known gap | 1274 | 28.9% | 22.3% | 18.6% | +| ✅ passing (runnable) | 3132 | 71.1% | 54.7% | 45.8% | +| 🧩 known gap | 1272 | 28.9% | 22.2% | 18.6% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3130/5721 (54.7%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3132/5721 (54.7%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 131 | 171 | 85 | 11 | 0 | 183 | 43.4% | 32.9% | +| other | 581 | 133 | 169 | 85 | 11 | 0 | 183 | 44.0% | 33.4% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1274) +### known gap (1272) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -865,9 +865,8 @@ Secondary full-public compatibility, including public tests that are currently e | ECDH key import/deriveKey compatibility for test vectors is incomplete | 1 | `parallel/test-webcrypto-derivekey-ecdh.js` | | ECDSA key import/sign/verify compatibility for test vectors is incomplete | 1 | `parallel/test-webcrypto-sign-verify-ecdsa.js` | | ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG behavior is not implemented | 1 | `parallel/test-vm-dynamic-import-callback-missing-flag.js` | -| ESM compatibility shim still exposes CJS globals such as __filename and __dirname | 1 | `es-module/test-esm-forbidden-globals.mjs` | +| ESM compatibility shim intentionally exposes CommonJS-style __filename/__dirname; switching to strict Node ESM globals needs a product compatibility decision | 1 | `es-module/test-esm-forbidden-globals.mjs` | | ESM directory import errors do not match Node ERR_UNSUPPORTED_DIR_IMPORT behavior | 1 | `parallel/test-directory-import.js` | -| ESM main lookup and error-url behavior still differ from Node's resolver | 1 | `es-module/test-esm-main-lookup.mjs` | | EdDSA sign/verify vector compatibility is incomplete | 1 | `parallel/test-webcrypto-sign-verify-eddsa.js` | | Error.prepareStackTrace default behavior is incomplete | 1 | `parallel/test-error-prepare-stack-trace.js` | | EventEmitter captureRejections option validation/behavior is incomplete | 1 | `parallel/test-event-capture-rejections.js` | @@ -962,6 +961,7 @@ Secondary full-public compatibility, including public tests that are currently e | SourceTextModule import.meta initialization hook is not implemented | 1 | `parallel/test-vm-module-import-meta.js` | | SourceTextModule linker/dependency parsing semantics are incomplete (imports, cycles, and attributes) | 1 | `parallel/test-vm-module-link.js` | | Timeout listener bookkeeping on keep-alive sockets is not Node-compatible | 1 | `parallel/test-http-client-timeout-option-listeners.js` | +| V8 startup snapshot fixture mutates CommonJS require.cache; the WASM runner does not model Node/V8 startup snapshot and cache coupling | 1 | `es-module/test-esm-snapshot.mjs` | | WASI UDP ping-pong over loopback does not reliably deliver datagrams in the local runtime despite Node-compatible hostname resolution | 1 | `sequential/test-dgram-pingpong.js` | | WASM child emulation does not support --experimental-test-module-mocks CLI flag | 1 | `parallel/test-runner-module-mocking.js#test_11_node_modules_can_be_used_by_both_module_systems` | | WASM child emulation does not support --experimental-test-module-mocks/--experimental-default-type flags | 1 | `parallel/test-runner-module-mocking.js#test_16_wrong_import_syntax_should_throw_error_after_module_mocking` | @@ -1165,7 +1165,6 @@ Secondary full-public compatibility, including public tests that are currently e | net.BlockList with autoSelectFamily and multiple lookup addresses does not yet raise ERR_IP_BLOCKED before connection attempts | 1 | `parallel/test-net-blocklist.js#block_03_connect_with_autoselectfamily_and_multiple_ips` | | net.Server blockList enforcement is incomplete | 1 | `parallel/test-net-server-blocklist.js` | | net.Server captureRejections async error propagation is incomplete | 1 | `parallel/test-net-server-capture-rejection.js` | -| newly tracked module coverage; same-process ESM behavior has not been triaged yet | 1 | `es-module/test-esm-snapshot.mjs` | | node-compat runner drainAsync() relies on global setTimeout after this test deletes timer globals | 1 | `parallel/test-timers-api-refs.js` | | node:http abort/destroy response lifecycle (aborted/error/close ordering) is incomplete | 1 | `parallel/test-http-abort-client.js` | | node:http client path does not honor/verify net.Socket connect noDelay semantics like Node | 1 | `parallel/test-http-nodelay.js` | @@ -1255,7 +1254,6 @@ Secondary full-public compatibility, including public tests that are currently e | runInNewContext sandbox binding and write-back semantics are incomplete | 1 | `parallel/test-vm-run-in-new-context.js` | | runInThisContext/runInContext sloppy-mode var/delete semantics are incorrect | 1 | `parallel/test-vm-not-strict.js` | | same-component node:http client->server calls via wasi:http can deadlock in this scenario | 1 | `parallel/test-http-write-head-after-set-header.js` | -| same-directory relative ESM import resolution differs in the node_compat split runner | 1 | `es-module/test-esm-basic-imports.mjs` | | sendBlockList connect path can crash in WASI UDP implementation | 1 | `parallel/test-dgram-blocklist.js#block_00_block_00` | | sendBlockList send() callback path is not Node-compatible and can hang | 1 | `parallel/test-dgram-blocklist.js#block_01_block_01` | | sequential path is stale in vendored suite; equivalent Upgrade timeout-disabling semantics are not Node-compatible | 1 | `sequential/test-http-server-request-timeout-upgrade.js` | diff --git a/tests/runtime/module_resolution.rs b/tests/runtime/module_resolution.rs index 0a38d7ff..bec345c3 100644 --- a/tests/runtime/module_resolution.rs +++ b/tests/runtime/module_resolution.rs @@ -79,6 +79,23 @@ async fn sync_builtin_esm_exports( Ok(()) } +#[test] +async fn esm_resolution_error_urls( + #[tagged_as("module_resolution")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-esm-resolution-error-urls", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + #[test] async fn cjs_direct_named_exports( #[tagged_as("module_resolution")] compiled_test: &CompiledTest, From 84939db5c26daeb3fef6950c94378cb1e94b0504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 15:12:25 +0200 Subject: [PATCH 35/42] Promote passing CJS ESM interop tests --- tests/node_compat/config.jsonc | 4 ++-- tests/node_compat/report.md | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index a276d5e0..bdd2a557 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5995,7 +5995,7 @@ "es-module/test-esm-dns-promises.mjs": { "category": "runnable" }, "es-module/test-esm-double-encoding.mjs": { "category": "runnable" }, "es-module/test-esm-dynamic-import-attribute.mjs": {}, - "es-module/test-esm-dynamic-import-commonjs.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-esm-dynamic-import-commonjs.mjs": { "category": "runnable" }, "es-module/test-esm-dynamic-import-mutating-fs.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-encoded-path.mjs": { "category": "runnable" }, "es-module/test-esm-example-loader.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, @@ -6054,7 +6054,7 @@ "es-module/test-esm-preserve-symlinks-not-found.mjs": { "category": "known-gap", "reason": "ESM preserve-symlinks / preserve-symlinks-main behavior is incomplete" }, "es-module/test-esm-process.mjs": {}, "es-module/test-esm-prototype-pollution.mjs": {}, - "es-module/test-esm-recursive-cjs-dependencies.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-esm-recursive-cjs-dependencies.mjs": { "category": "runnable" }, "es-module/test-esm-require-cache.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, "es-module/test-esm-resolve-type.mjs": { "category": "node-internals", "reason": "requires --expose-internals and node:internal/modules/esm/resolve" }, "es-module/test-esm-scope-node-modules.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 67ae4fd4..0b219ffc 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3132/4404 (71.1%) +**Primary compatibility (CI-enforced):** 3134/4404 (71.2%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3132 | 71.1% | 54.7% | 45.8% | -| 🧩 known gap | 1272 | 28.9% | 22.2% | 18.6% | +| ✅ passing (runnable) | 3134 | 71.2% | 54.8% | 45.8% | +| 🧩 known gap | 1270 | 28.8% | 22.2% | 18.6% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3132/5721 (54.7%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3134/5721 (54.8%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 133 | 169 | 85 | 11 | 0 | 183 | 44.0% | 33.4% | +| other | 581 | 135 | 167 | 85 | 11 | 0 | 183 | 44.7% | 33.9% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1272) +### known gap (1270) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -712,7 +712,6 @@ Secondary full-public compatibility, including public tests that are currently e | vm.constants.DONT_CONTEXTIFY and vanilla-context behavior are not implemented | 8 | `parallel/test-vm-context-dont-contextify.js#block_00_block_00`, `parallel/test-vm-context-dont-contextify.js#block_01_block_01`, `parallel/test-vm-context-dont-contextify.js#block_02_block_02`, ... (+5) | | common-shim spawnPromisified child emulation does not support --experimental-webstorage/--localstorage-file flags | 7 | `parallel/test-webstorage.js#test_01_emits_a_warning_when_used`, `parallel/test-webstorage.js#test_02_storage_instances_cannot_be_created_in_userland`, `parallel/test-webstorage.js#test_03_sessionstorage_is_not_persisted`, ... (+4) | | inherited: Intl is not available in current runtime | 7 | `parallel/test-icu-transcode.js#block_00_block_00`, `parallel/test-icu-transcode.js#block_01_block_01`, `parallel/test-icu-transcode.js#block_02_test_that_uint8array_arguments_are_okay`, ... (+4) | -| CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage | 6 | `es-module/test-esm-cjs-named-error.mjs`, `es-module/test-esm-cyclic-dynamic-import.mjs`, `es-module/test-esm-dynamic-import-commonjs.mjs`, ... (+3) | | WebAssembly global is missing in current runtime | 6 | `es-module/test-wasm-memory-out-of-bound.js`, `es-module/test-wasm-simple.js`, `es-module/test-wasm-web-api.js`, ... (+3) | | fork() AbortSignal handling is incomplete (exit code/signal/error semantics differ from Node) | 6 | `parallel/test-child-process-fork-abort-signal.js#block_00_block_00`, `parallel/test-child-process-fork-abort-signal.js#block_01_block_01`, `parallel/test-child-process-fork-abort-signal.js#block_02_block_02`, ... (+3) | | inherited: common.canCreateSymLink shim always returns false, so symlink permission tests are skipped | 6 | `parallel/test-permission-fs-symlink-target-write.js#block_00_block_00`, `parallel/test-permission-fs-symlink-target-write.js#block_01_block_01`, `parallel/test-permission-fs-symlink.js#block_00_block_00`, ... (+3) | @@ -725,6 +724,7 @@ Secondary full-public compatibility, including public tests that are currently e | process.getActiveResourcesInfo() is not implemented | 5 | `parallel/test-process-getactiveresources-track-active-handles.js`, `parallel/test-process-getactiveresources-track-active-requests.js`, `parallel/test-process-getactiveresources-track-interval-lifetime.js`, ... (+2) | | requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR | 5 | `es-module/test-typescript-commonjs.mjs`, `es-module/test-typescript-eval.mjs`, `es-module/test-typescript-module.mjs`, ... (+2) | | util.format output formatting differences | 5 | `parallel/test-util-format.js#block_00_block_00`, `parallel/test-util-format.js#block_01_string_format_specifier_including_tostring_properties_on_the`, `parallel/test-util-format.js#block_02_symbol_toprimitive_handling_for_string_format_specifier`, ... (+2) | +| CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage | 4 | `es-module/test-esm-cjs-named-error.mjs`, `es-module/test-esm-cyclic-dynamic-import.mjs`, `es-module/test-esm-require-cache.mjs`, ... (+1) | | WASM child emulation does not support Node.js --test CLI output behavior | 4 | `parallel/test-runner-extraneous-async-activity.js#block_00_block_00`, `parallel/test-runner-extraneous-async-activity.js#block_01_block_01`, `parallel/test-runner-extraneous-async-activity.js#block_02_block_02`, ... (+1) | | crypto.scrypt/scryptSync support is missing (test reports 'no scrypt support') | 4 | `parallel/test-crypto-scrypt.js#block_00_block_00`, `parallel/test-crypto-scrypt.js#block_01_block_01`, `parallel/test-crypto-scrypt.js#block_02_block_02`, ... (+1) | | inherited: --frozen-intrinsics flag semantics are not implemented | 4 | `parallel/test-freeze-intrinsics.js#block_00_ensure_we_can_extend_console`, `parallel/test-freeze-intrinsics.js#block_01_ensure_we_can_write_override_object_prototype_properties_on_`, `parallel/test-freeze-intrinsics.js#block_02_ensure_we_can_not_override_globalthis`, ... (+1) | From 61f675dd9814c403faf04fc8d463d2dc8d6ffad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 15:45:34 +0200 Subject: [PATCH 36/42] Export createRequire from node compat common shim --- tests/node_compat/common-shim/index.mjs | 1 + tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 14 +++++++------- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/node_compat/common-shim/index.mjs b/tests/node_compat/common-shim/index.mjs index 5d21f650..43f538f0 100644 --- a/tests/node_compat/common-shim/index.mjs +++ b/tests/node_compat/common-shim/index.mjs @@ -59,6 +59,7 @@ const { } = common; export { + createRequire, isWindows, isAIX, isSunOS, diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index bdd2a557..0c35fcf1 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -6055,7 +6055,7 @@ "es-module/test-esm-process.mjs": {}, "es-module/test-esm-prototype-pollution.mjs": {}, "es-module/test-esm-recursive-cjs-dependencies.mjs": { "category": "runnable" }, - "es-module/test-esm-require-cache.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-esm-require-cache.mjs": { "category": "runnable" }, "es-module/test-esm-resolve-type.mjs": { "category": "node-internals", "reason": "requires --expose-internals and node:internal/modules/esm/resolve" }, "es-module/test-esm-scope-node-modules.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-shared-loader-dep.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 0b219ffc..e3659951 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3134/4404 (71.2%) +**Primary compatibility (CI-enforced):** 3135/4404 (71.2%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3134 | 71.2% | 54.8% | 45.8% | -| 🧩 known gap | 1270 | 28.8% | 22.2% | 18.6% | +| ✅ passing (runnable) | 3135 | 71.2% | 54.8% | 45.8% | +| 🧩 known gap | 1269 | 28.8% | 22.2% | 18.5% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3134/5721 (54.8%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3135/5721 (54.8%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 135 | 167 | 85 | 11 | 0 | 183 | 44.7% | 33.9% | +| other | 581 | 136 | 166 | 85 | 11 | 0 | 183 | 45.0% | 34.2% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1270) +### known gap (1269) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -724,7 +724,6 @@ Secondary full-public compatibility, including public tests that are currently e | process.getActiveResourcesInfo() is not implemented | 5 | `parallel/test-process-getactiveresources-track-active-handles.js`, `parallel/test-process-getactiveresources-track-active-requests.js`, `parallel/test-process-getactiveresources-track-interval-lifetime.js`, ... (+2) | | requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR | 5 | `es-module/test-typescript-commonjs.mjs`, `es-module/test-typescript-eval.mjs`, `es-module/test-typescript-module.mjs`, ... (+2) | | util.format output formatting differences | 5 | `parallel/test-util-format.js#block_00_block_00`, `parallel/test-util-format.js#block_01_string_format_specifier_including_tostring_properties_on_the`, `parallel/test-util-format.js#block_02_symbol_toprimitive_handling_for_string_format_specifier`, ... (+2) | -| CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage | 4 | `es-module/test-esm-cjs-named-error.mjs`, `es-module/test-esm-cyclic-dynamic-import.mjs`, `es-module/test-esm-require-cache.mjs`, ... (+1) | | WASM child emulation does not support Node.js --test CLI output behavior | 4 | `parallel/test-runner-extraneous-async-activity.js#block_00_block_00`, `parallel/test-runner-extraneous-async-activity.js#block_01_block_01`, `parallel/test-runner-extraneous-async-activity.js#block_02_block_02`, ... (+1) | | crypto.scrypt/scryptSync support is missing (test reports 'no scrypt support') | 4 | `parallel/test-crypto-scrypt.js#block_00_block_00`, `parallel/test-crypto-scrypt.js#block_01_block_01`, `parallel/test-crypto-scrypt.js#block_02_block_02`, ... (+1) | | inherited: --frozen-intrinsics flag semantics are not implemented | 4 | `parallel/test-freeze-intrinsics.js#block_00_ensure_we_can_extend_console`, `parallel/test-freeze-intrinsics.js#block_01_ensure_we_can_write_override_object_prototype_properties_on_`, `parallel/test-freeze-intrinsics.js#block_02_ensure_we_can_not_override_globalthis`, ... (+1) | @@ -737,6 +736,7 @@ Secondary full-public compatibility, including public tests that are currently e | timeout enforcement with microtaskMode='afterEvaluate' is incomplete | 4 | `parallel/test-vm-timeout-escape-promise-2.js`, `parallel/test-vm-timeout-escape-promise-module.js`, `parallel/test-vm-timeout-escape-promise.js`, ... (+1) | | unhandled-rejection mode and uncaughtException bridging semantics are incomplete | 4 | `parallel/test-promise-unhandled-default.js`, `parallel/test-promise-unhandled-error.js`, `parallel/test-promise-unhandled-throw-handler.js`, ... (+1) | | wasi:http client does not surface 103 Early Hints as 'information' events | 4 | `parallel/test-http-early-hints.js#block_00_block_00`, `parallel/test-http-early-hints.js#block_01_block_01`, `parallel/test-http-early-hints.js#block_03_block_03`, ... (+1) | +| CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage | 3 | `es-module/test-esm-cjs-named-error.mjs`, `es-module/test-esm-cyclic-dynamic-import.mjs`, `es-module/test-require-as-esm-interop.mjs` | | DOMException options bag ({ name, cause }) is not implemented | 3 | `parallel/test-domexception-cause.js#block_01_block_01`, `parallel/test-domexception-cause.js#block_02_block_02`, `parallel/test-domexception-cause.js#block_03_block_03` | | MessagePort close callback, close-state checks, and closed-port errors are incomplete | 3 | `parallel/test-worker-message-port-close.js#block_00_block_00`, `parallel/test-worker-message-port-close.js#block_01_block_01`, `parallel/test-worker-message-port-close.js#block_02_block_02` | | WASM child emulation does not support Node.js --test TAP filtering behavior | 3 | `parallel/test-runner-no-isolation-filtering.js#test_00_works_with_test_only`, `parallel/test-runner-no-isolation-filtering.js#test_01_works_with_test_name_pattern`, `parallel/test-runner-no-isolation-filtering.js#test_02_works_with_test_skip_pattern` | From a9997e0ecc7b9faad79ff588f1f4b341677b7612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 15:55:38 +0200 Subject: [PATCH 37/42] Promote cyclic dynamic import ESM test --- tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 0c35fcf1..3b89b1fe 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5989,7 +5989,7 @@ "es-module/test-esm-cjs-load-error-note.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-cjs-named-error.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, "es-module/test-esm-custom-exports.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, - "es-module/test-esm-cyclic-dynamic-import.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-esm-cyclic-dynamic-import.mjs": { "category": "runnable" }, "es-module/test-esm-default-type.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-detect-ambiguous.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-dns-promises.mjs": { "category": "runnable" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index e3659951..5a6f0079 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3135/4404 (71.2%) +**Primary compatibility (CI-enforced):** 3136/4404 (71.2%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3135 | 71.2% | 54.8% | 45.8% | -| 🧩 known gap | 1269 | 28.8% | 22.2% | 18.5% | +| ✅ passing (runnable) | 3136 | 71.2% | 54.8% | 45.8% | +| 🧩 known gap | 1268 | 28.8% | 22.2% | 18.5% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3135/5721 (54.8%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3136/5721 (54.8%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 136 | 166 | 85 | 11 | 0 | 183 | 45.0% | 34.2% | +| other | 581 | 137 | 165 | 85 | 11 | 0 | 183 | 45.4% | 34.4% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1269) +### known gap (1268) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -736,7 +736,6 @@ Secondary full-public compatibility, including public tests that are currently e | timeout enforcement with microtaskMode='afterEvaluate' is incomplete | 4 | `parallel/test-vm-timeout-escape-promise-2.js`, `parallel/test-vm-timeout-escape-promise-module.js`, `parallel/test-vm-timeout-escape-promise.js`, ... (+1) | | unhandled-rejection mode and uncaughtException bridging semantics are incomplete | 4 | `parallel/test-promise-unhandled-default.js`, `parallel/test-promise-unhandled-error.js`, `parallel/test-promise-unhandled-throw-handler.js`, ... (+1) | | wasi:http client does not surface 103 Early Hints as 'information' events | 4 | `parallel/test-http-early-hints.js#block_00_block_00`, `parallel/test-http-early-hints.js#block_01_block_01`, `parallel/test-http-early-hints.js#block_03_block_03`, ... (+1) | -| CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage | 3 | `es-module/test-esm-cjs-named-error.mjs`, `es-module/test-esm-cyclic-dynamic-import.mjs`, `es-module/test-require-as-esm-interop.mjs` | | DOMException options bag ({ name, cause }) is not implemented | 3 | `parallel/test-domexception-cause.js#block_01_block_01`, `parallel/test-domexception-cause.js#block_02_block_02`, `parallel/test-domexception-cause.js#block_03_block_03` | | MessagePort close callback, close-state checks, and closed-port errors are incomplete | 3 | `parallel/test-worker-message-port-close.js#block_00_block_00`, `parallel/test-worker-message-port-close.js#block_01_block_01`, `parallel/test-worker-message-port-close.js#block_02_block_02` | | WASM child emulation does not support Node.js --test TAP filtering behavior | 3 | `parallel/test-runner-no-isolation-filtering.js#test_00_works_with_test_only`, `parallel/test-runner-no-isolation-filtering.js#test_01_works_with_test_name_pattern`, `parallel/test-runner-no-isolation-filtering.js#test_02_works_with_test_skip_pattern` | @@ -767,6 +766,7 @@ Secondary full-public compatibility, including public tests that are currently e | setUncaughtExceptionCaptureCallback does not fully intercept thrown uncaught exceptions | 3 | `parallel/test-process-exception-capture-should-abort-on-uncaught-setflagsfromstring.js`, `parallel/test-process-exception-capture-should-abort-on-uncaught.js`, `parallel/test-process-exception-capture.js` | | spawn() stdio validation/pipe semantics are not Node-compatible in WASM emulation | 3 | `parallel/test-child-process-stdio.js#block_00_test_stdio_piping`, `parallel/test-child-process-stdio.js#block_02_asset_options_invariance`, `parallel/test-child-process-stdio.js#block_03_test_stdout_buffering` | | test runner edge case | 3 | `parallel/test-runner-filetest-location.js`, `parallel/test-runner-root-after-with-refed-handles.js`, `parallel/test-runner-todo-skip-tests.js` | +| CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage | 2 | `es-module/test-esm-cjs-named-error.mjs`, `es-module/test-require-as-esm-interop.mjs` | | CLI/NODE_OPTIONS max-http-header-size propagation in child process emulation is incomplete | 2 | `parallel/test-set-http-max-http-headers.js#test_01_test_01`, `parallel/test-set-http-max-http-headers.js#test_02_same_checks_using_node_options_if_it_is_supported` | | DSA keygen currently supports only modern key sizes; legacy 512-bit variant fails | 2 | `parallel/test-crypto-keygen-async-dsa-key-object.js`, `parallel/test-crypto-keygen-async-dsa.js` | | ESM preserve-symlinks / preserve-symlinks-main behavior is incomplete | 2 | `es-module/test-esm-preserve-symlinks-not-found-plain.mjs`, `es-module/test-esm-preserve-symlinks-not-found.mjs` | From e031eeb688bb5179d632eb937673e766e6351b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 16:24:03 +0200 Subject: [PATCH 38/42] Harden CJS object literal export analysis --- crates/wasm-rquickjs/skeleton/src/internal.rs | 49 ++++++++++++++++++- .../src/module-resolution.js | 48 ++++++++++++++++++ tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 3 +- 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 70f757ea..36961af4 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -3756,6 +3756,17 @@ fn skip_object_literal_value(source: &str, pos: usize, object_end: usize) -> usi object_end } +fn is_named_export_object_literal_value(source: &str, pos: usize, object_end: usize) -> bool { + let Some((name, mut next)) = read_ident(source, pos) else { + return false; + }; + if matches!(name.as_str(), "true" | "false" | "null" | "undefined") { + return false; + } + next = skip_ws_comments(source, next); + next >= object_end || matches!(source.as_bytes()[next], b',' | b'(') +} + fn parse_module_exports_object_literal(source: &str, pos: usize) -> Option<(Vec, Vec, usize)> { let bytes = source.as_bytes(); let (target, mut i) = parse_exports_target(source, pos)?; @@ -3803,8 +3814,13 @@ fn parse_module_exports_object_literal(source: &str, pos: usize) -> Option<(Vec< let mut next = skip_ws_comments(source, key_end); if next < object_end && bytes[next] == b':' { next = skip_ws_comments(source, next + 1); - add_unique(&mut exports, name); if parse_require_string_loose(source, next).is_some() { + add_unique(&mut exports, name); + break; + } + if is_named_export_object_literal_value(source, next, object_end) { + add_unique(&mut exports, name); + } else { break; } cursor = skip_ws_comments(source, skip_object_literal_value(source, next, object_end)); @@ -6126,6 +6142,37 @@ mod cjs_export_analyzer_tests { &[], ); + assert_analysis( + r#" + module.exports = { + identifierValue: value, + callExpression: factory(), + memberExpression: ns.x, + booleanLiteral: true, + nullLiteral: null, + undefinedLiteral: undefined, + }; + "#, + true, + &["identifierValue", "callExpression"], + &[], + ); + + assert_analysis( + r#" + module.exports = { + stringLiteral: "not-detected", + numberLiteral: 1, + objectLiteral: {}, + callExpression: factory(), + identifierValue: value, + }; + "#, + true, + &[], + &[], + ); + assert_analysis( r#" const a = 1; diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index dbae58e7..f29fe42d 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -625,6 +625,36 @@ export const testCjsAnalyzerFalsePositiveGuards = async () => { '});', 'exports.own = "own";', ].join('\n')); + fs.writeFileSync('/cjs-analyzer-guards-app/object-literal-values.cjs', [ + 'const identifierValue = "identifier";', + 'const memberSource = { x: "member" };', + 'module.exports = {', + ' identifierValue,', + ' callExpression: factory(),', + ' memberExpression: memberSource.x,', + ' booleanLiteral: true,', + ' nullLiteral: null,', + ' undefinedLiteral: undefined,', + '};', + 'function factory() { return "call"; }', + ].join('\n')); + fs.writeFileSync('/cjs-analyzer-guards-app/object-literal-require-value.cjs', [ + 'module.exports = {', + ' requireValue: require("./dep.cjs"),', + ' afterRequire: "not-detected",', + '};', + ].join('\n')); + fs.writeFileSync('/cjs-analyzer-guards-app/object-literal-unsupported.cjs', [ + 'const identifierValue = "identifier";', + 'module.exports = {', + ' stringLiteral: "not-detected",', + ' numberLiteral: 1,', + ' objectLiteral: {},', + ' callExpression: factory(),', + ' identifierValue,', + '};', + 'function factory() { return "not-detected"; }', + ].join('\n')); fs.writeFileSync('/cjs-analyzer-guards-app/guards-entry.mjs', [ 'import * as fp from "./false-positives.cjs";', 'import * as unsafe from "./unsafe-define.cjs";', @@ -632,6 +662,9 @@ export const testCjsAnalyzerFalsePositiveGuards = async () => { 'import * as unicodeReexport from "./unicode-reexport.cjs";', 'import * as continuation from "./continuation.cjs";', 'import * as bindingContinuation from "./binding-continuation.cjs";', + 'import * as objectLiteralValues from "./object-literal-values.cjs";', + 'import * as objectLiteralRequireValue from "./object-literal-require-value.cjs";', + 'import * as objectLiteralUnsupported from "./object-literal-unsupported.cjs";', 'export default {', ' fpKeys: Object.keys(fp).filter((key) => key !== "default" && key !== "real"),', ' real: fp.real,', @@ -643,6 +676,12 @@ export const testCjsAnalyzerFalsePositiveGuards = async () => { ' continuationKeys: Object.keys(continuation).filter((key) => key !== "default"),', ' bindingContinuationKeys: Object.keys(bindingContinuation).filter((key) => key !== "default" && key !== "own"),', ' bindingContinuationOwn: bindingContinuation.own,', + ' objectLiteralValueKeys: Object.keys(objectLiteralValues).filter((key) => key !== "default").sort(),', + ' identifierValue: objectLiteralValues.identifierValue,', + ' callExpression: objectLiteralValues.callExpression,', + ' objectLiteralRequireValueKeys: Object.keys(objectLiteralRequireValue).filter((key) => key !== "default").sort(),', + ' requireValue: objectLiteralRequireValue.requireValue,', + ' objectLiteralUnsupportedKeys: Object.keys(objectLiteralUnsupported).filter((key) => key !== "default").sort(),', '};', ].join('\n')); @@ -657,6 +696,15 @@ export const testCjsAnalyzerFalsePositiveGuards = async () => { assert.deepStrictEqual(result.continuationKeys, []); assert.deepStrictEqual(result.bindingContinuationKeys, []); assert.strictEqual(result.bindingContinuationOwn, 'own'); + assert.deepStrictEqual(result.objectLiteralValueKeys, [ + 'callExpression', + 'identifierValue', + ]); + assert.strictEqual(result.identifierValue, 'identifier'); + assert.strictEqual(result.callExpression, 'call'); + assert.deepStrictEqual(result.objectLiteralRequireValueKeys, ['requireValue']); + assert.deepStrictEqual(result.requireValue, { alpha: 'alpha' }); + assert.deepStrictEqual(result.objectLiteralUnsupportedKeys, []); return true; } catch (error) { console.error(error); diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 3b89b1fe..d6af3f1b 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5987,7 +5987,7 @@ "es-module/test-esm-basic-imports.mjs": { "category": "runnable" }, "es-module/test-esm-child-process-fork-main.mjs": { "category": "wasi-impossible", "reason": "requires child_process.fork IPC semantics, which are not available in WASM" }, "es-module/test-esm-cjs-load-error-note.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-cjs-named-error.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-esm-cjs-named-error.mjs": { "category": "known-gap", "reason": "CJS named-export misses reject, but the loader does not yet emit Node-shaped CommonJS named-export diagnostics" }, "es-module/test-esm-custom-exports.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-cyclic-dynamic-import.mjs": { "category": "runnable" }, "es-module/test-esm-default-type.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 5a6f0079..2c817fec 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -766,7 +766,6 @@ Secondary full-public compatibility, including public tests that are currently e | setUncaughtExceptionCaptureCallback does not fully intercept thrown uncaught exceptions | 3 | `parallel/test-process-exception-capture-should-abort-on-uncaught-setflagsfromstring.js`, `parallel/test-process-exception-capture-should-abort-on-uncaught.js`, `parallel/test-process-exception-capture.js` | | spawn() stdio validation/pipe semantics are not Node-compatible in WASM emulation | 3 | `parallel/test-child-process-stdio.js#block_00_test_stdio_piping`, `parallel/test-child-process-stdio.js#block_02_asset_options_invariance`, `parallel/test-child-process-stdio.js#block_03_test_stdout_buffering` | | test runner edge case | 3 | `parallel/test-runner-filetest-location.js`, `parallel/test-runner-root-after-with-refed-handles.js`, `parallel/test-runner-todo-skip-tests.js` | -| CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage | 2 | `es-module/test-esm-cjs-named-error.mjs`, `es-module/test-require-as-esm-interop.mjs` | | CLI/NODE_OPTIONS max-http-header-size propagation in child process emulation is incomplete | 2 | `parallel/test-set-http-max-http-headers.js#test_01_test_01`, `parallel/test-set-http-max-http-headers.js#test_02_same_checks_using_node_options_if_it_is_supported` | | DSA keygen currently supports only modern key sizes; legacy 512-bit variant fails | 2 | `parallel/test-crypto-keygen-async-dsa-key-object.js`, `parallel/test-crypto-keygen-async-dsa.js` | | ESM preserve-symlinks / preserve-symlinks-main behavior is incomplete | 2 | `es-module/test-esm-preserve-symlinks-not-found-plain.mjs`, `es-module/test-esm-preserve-symlinks-not-found.mjs` | @@ -845,6 +844,8 @@ Secondary full-public compatibility, including public tests that are currently e | AsyncLocalStorage deep nesting/recursion handling is unstable | 1 | `parallel/test-async-local-storage-deep-stack.js` | | AsyncLocalStorage.bind argument validation is incomplete | 1 | `parallel/test-async-local-storage-bind.js` | | AsyncLocalStorage.snapshot is missing or incomplete | 1 | `parallel/test-async-local-storage-snapshot.js` | +| CJS named-export misses reject, but the loader does not yet emit Node-shaped CommonJS named-export diagnostics | 1 | `es-module/test-esm-cjs-named-error.mjs` | +| CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage | 1 | `es-module/test-require-as-esm-interop.mjs` | | CLI --security-revert behavior in child_process spawnSync is not fully implemented | 1 | `parallel/test-security-revert-unknown.js` | | CLI --title flag does not update process.title | 1 | `parallel/test-process-title-cli.js` | | CLI --unhandled-rejections flag parsing/validation is incomplete | 1 | `parallel/test-promise-unhandled-flag.js` | From fb256999963dd651153aed6820284c655f8a97d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 16:40:26 +0200 Subject: [PATCH 39/42] Support require esm module exports interop --- .../skeleton/src/builtin/module.js | 6 +++++- crates/wasm-rquickjs/skeleton/src/internal.rs | 17 ++++++++++++++++- .../module-resolution/src/module-resolution.js | 6 ++++++ tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 13 ++++++------- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 45c283f6..529abffb 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -2038,7 +2038,11 @@ function requireEsmWithCacheGuard(mod, resolvedFilename) { enumerable: false, }); try { - return wrapEsmNamespace(_requireEsm(resolvedFilename)); + const namespace = _requireEsm(resolvedFilename); + if (namespace && typeof namespace === 'object' && Object.hasOwn(namespace, 'module.exports')) { + return namespace['module.exports']; + } + return wrapEsmNamespace(namespace); } finally { unmarkRequireEsmGraph(markedGraph); delete mod.__wasmRequireEsmInProgress; diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 36961af4..505125aa 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -1013,7 +1013,7 @@ fn import_attr_error_expression(code: &str, message: &str) -> String { fn esm_preflight_error_module_source(source: &str, package_type_module_js: bool) -> Option { if package_type_module_js { let cjs_global = find_bare_cjs_global_in_esm(source); - if !analyze_cjs_exports(source).is_cjs && cjs_global.is_none() { + if cjs_global.is_none() { return None; } let name = cjs_global.unwrap_or("module"); @@ -6294,6 +6294,21 @@ mod cjs_export_analyzer_tests { assert_cjs_global("function* module() { yield 1; } export default module;", None); } + #[test] + fn package_type_diagnostics_ignore_local_exports_binding() { + assert!(esm_preflight_error_module_source( + r#" + const exports = {}; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.default = "value"; + export default exports; + export { exports as "module.exports" }; + "#, + true, + ) + .is_none()); + } + #[test] fn package_type_diagnostics_use_first_cjs_global() { let require_diag = esm_preflight_error_module_source("require('x');", true).unwrap(); diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index f29fe42d..c5a8cc4f 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -1383,6 +1383,11 @@ export const testRequireEsmErrorHandling = async () => { 'Object.defineProperty(exports, "__esModule", { value: true });', 'exports.foo = "foo";', ].join('\n')); + fs.writeFileSync('/require-esm-errors-app/module-exports-marker.mjs', [ + 'const value = { marker: true };', + 'export default "namespace default";', + 'export { value as "module.exports" };', + ].join('\n')); const { createRequire } = await import('node:module'); const require = createRequire('/require-esm-errors-app/main.cjs'); @@ -1397,6 +1402,7 @@ export const testRequireEsmErrorHandling = async () => { name: 'ReferenceError', }); assert.strictEqual(require('/require-esm-errors-app/valid-transpiled.js').foo, 'foo'); + assert.deepStrictEqual(require('/require-esm-errors-app/module-exports-marker.mjs'), { marker: true }); return true; } catch (error) { diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index d6af3f1b..50967c00 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -6079,7 +6079,7 @@ "es-module/test-import-module-conditional-exports-module.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-loaders-unknown-builtin-module.mjs": { "category": "known-gap", "reason": "custom ESM loader hooks / module.register are not implemented" }, "es-module/test-loaders-workers-spawned.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-require-as-esm-interop.mjs": { "category": "known-gap", "reason": "CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage" }, + "es-module/test-require-as-esm-interop.mjs": { "category": "runnable" }, "es-module/test-typescript-commonjs.mjs": { "category": "known-gap", "reason": "requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR" }, "es-module/test-typescript-eval.mjs": { "category": "known-gap", "reason": "requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR" }, "es-module/test-typescript-module.mjs": { "category": "known-gap", "reason": "requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 2c817fec..eec4fde6 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3136/4404 (71.2%) +**Primary compatibility (CI-enforced):** 3137/4404 (71.2%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3136 | 71.2% | 54.8% | 45.8% | -| 🧩 known gap | 1268 | 28.8% | 22.2% | 18.5% | +| ✅ passing (runnable) | 3137 | 71.2% | 54.8% | 45.8% | +| 🧩 known gap | 1267 | 28.8% | 22.1% | 18.5% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3136/5721 (54.8%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3137/5721 (54.8%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 137 | 165 | 85 | 11 | 0 | 183 | 45.4% | 34.4% | +| other | 581 | 138 | 164 | 85 | 11 | 0 | 183 | 45.7% | 34.7% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1268) +### known gap (1267) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -845,7 +845,6 @@ Secondary full-public compatibility, including public tests that are currently e | AsyncLocalStorage.bind argument validation is incomplete | 1 | `parallel/test-async-local-storage-bind.js` | | AsyncLocalStorage.snapshot is missing or incomplete | 1 | `parallel/test-async-local-storage-snapshot.js` | | CJS named-export misses reject, but the loader does not yet emit Node-shaped CommonJS named-export diagnostics | 1 | `es-module/test-esm-cjs-named-error.mjs` | -| CJS/ESM interop behavior needs CJS lexer / require(esm) bridge triage | 1 | `es-module/test-require-as-esm-interop.mjs` | | CLI --security-revert behavior in child_process spawnSync is not fully implemented | 1 | `parallel/test-security-revert-unknown.js` | | CLI --title flag does not update process.title | 1 | `parallel/test-process-title-cli.js` | | CLI --unhandled-rejections flag parsing/validation is incomplete | 1 | `parallel/test-promise-unhandled-flag.js` | From 619f3ca3c97636e31113eda6d2fde5f52274201f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 17:39:33 +0200 Subject: [PATCH 40/42] Emit Node CJS named import diagnostics --- crates/wasm-rquickjs/skeleton/src/internal.rs | 274 ++++++++++++++++++ .../src/module-resolution.js | 46 +++ tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 13 +- 4 files changed, 327 insertions(+), 8 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/internal.rs b/crates/wasm-rquickjs/skeleton/src/internal.rs index 505125aa..5abd0e1f 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal.rs @@ -1043,6 +1043,112 @@ fn esm_preflight_error_module_source(source: &str, package_type_module_js: bool) )) } +#[derive(Debug, PartialEq, Eq)] +struct StaticNamedImport { + imported: String, + local: String, +} + +fn cjs_named_import_error_module_source(filename: &str, source: &str) -> Option { + find_cjs_named_import_error(filename, source).map(|message| { + let escaped = DataUrlLoader::js_string_escape(&message); + format!("await Promise.reject(new SyntaxError('{escaped}'));\n") + }) +} + +fn find_cjs_named_import_error(filename: &str, source: &str) -> Option { + let mut result = None; + scan_code_positions(source, true, |i, _| { + if let Some((specifier, named_imports, next)) = parse_static_named_import(source, i) { + if let Some(message) = cjs_named_import_error_message(filename, &specifier, &named_imports) { + result = Some(message); + return ControlFlow::Break(()); + } + return ControlFlow::Continue(Some(next)); + } + ControlFlow::Continue(None) + }); + result +} + +fn cjs_named_import_error_message( + filename: &str, + specifier: &str, + named_imports: &[StaticNamedImport], +) -> Option { + if named_imports.is_empty() || !could_resolve_to_cjs_for_named_import_error(specifier) { + return None; + } + let resolved = resolve_cjs_reexport_path(filename, specifier)?; + if !resolved.ends_with(".cjs") && !is_cjs_js_file_for_named_import_error(&resolved) { + return None; + } + let source = std::fs::read_to_string(&resolved).ok()?; + let analysis = analyze_cjs_exports_for_file(&resolved, &source, &mut HashSet::new()); + if !analysis.is_cjs && analysis.exports.is_empty() && analysis.reexports.is_empty() { + return None; + } + + for named_import in named_imports { + if named_import.imported == "default" { + continue; + } + if !analysis.exports.iter().any(|name| name == &named_import.imported) { + let mut message = format!( + "Named export '{}' not found. The requested module '{}' is a CommonJS module, which may not support all module.exports as named exports.\nCommonJS modules can always be imported via the default export, for example using:\n\nimport pkg from '{}';\n", + named_import.imported, specifier, specifier + ); + if named_imports.len() == 1 { + message.push_str(&format!( + "const {{ {} }} = pkg;\n", + format_cjs_named_import_binding(named_import) + )); + } + return Some(message); + } + } + None +} + +fn could_resolve_to_cjs_for_named_import_error(specifier: &str) -> bool { + if specifier.starts_with("node:") || specifier.starts_with("data:") || specifier.contains("://") { + return false; + } + if specifier.starts_with("./") || specifier.starts_with("../") || specifier.starts_with('/') { + let (path, _) = split_module_path_suffix(specifier); + return match std::path::Path::new(path).extension().and_then(|ext| ext.to_str()) { + Some("cjs" | "js") | None => true, + Some(_) => false, + }; + } + true +} + +fn format_cjs_named_import_binding(named_import: &StaticNamedImport) -> String { + let imported = if is_valid_js_identifier_name(&named_import.imported) { + named_import.imported.clone() + } else { + format!("\"{}\"", escape_js_string(&named_import.imported)) + }; + if named_import.imported == named_import.local { + imported + } else { + format!("{}: {}", imported, named_import.local) + } +} + +fn is_valid_js_identifier_name(value: &str) -> bool { + let bytes = value.as_bytes(); + let Some((&first, rest)) = bytes.split_first() else { + return false; + }; + is_ident_start(first) && rest.iter().copied().all(is_ident_continue) +} + +fn is_cjs_js_file_for_named_import_error(filename: &str) -> bool { + filename.ends_with(".js") && package_scope_type(filename).as_deref() != Some("module") +} + fn find_bare_cjs_global_in_esm(source: &str) -> Option<&'static str> { const NAMES: [&str; 5] = ["require", "exports", "module", "__filename", "__dirname"]; let bytes = source.as_bytes(); @@ -1207,6 +1313,89 @@ fn parse_import_declaration_bindings(source: &str, pos: usize) -> Option<(Vec Option<(String, Vec, usize)> { + let bytes = source.as_bytes(); + if !source[pos..].starts_with("import") + || !is_ident_start_boundary(bytes, pos) + || !is_ident_boundary(bytes, pos + 6) + { + return None; + } + let mut i = skip_ws_comments(source, pos + 6); + if i < bytes.len() && matches!(bytes[i], b'(' | b'\'' | b'"') { + return None; + } + + let mut named_imports = Vec::new(); + if i < bytes.len() && bytes[i] == b'{' { + collect_named_import_specifiers(source, i, &mut named_imports)?; + i = skip_ws_comments(source, find_matching_brace(source, i)? + 1); + } else { + if i < bytes.len() && bytes[i] == b'*' { + return None; + } + let (_, next) = read_ident(source, i)?; + i = skip_ws_comments(source, next); + if i >= bytes.len() || bytes[i] != b',' { + return None; + } + i = skip_ws_comments(source, i + 1); + if i >= bytes.len() || bytes[i] != b'{' { + return None; + } + collect_named_import_specifiers(source, i, &mut named_imports)?; + i = skip_ws_comments(source, find_matching_brace(source, i)? + 1); + } + + if !source[i..].starts_with("from") || !is_ident_boundary(bytes, i + 4) { + return None; + } + i = skip_ws_comments(source, i + 4); + let (specifier, next) = read_js_string(source, i)?; + Some((specifier, named_imports, find_statement_end(source, next))) +} + +fn collect_named_import_specifiers( + source: &str, + start: usize, + imports: &mut Vec, +) -> Option<()> { + let bytes = source.as_bytes(); + let end = find_matching_brace(source, start)?; + let mut i = start + 1; + while i < end { + i = skip_ws_comments(source, i); + if i >= end { + break; + } + let (imported, next, needs_alias) = if matches!(bytes[i], b'\'' | b'"') { + let (name, next) = read_js_string(source, i)?; + (name, next, true) + } else { + let (name, next) = read_ident(source, i)?; + (name, next, false) + }; + let mut local = imported.clone(); + i = skip_ws_comments(source, next); + if source[i..].starts_with("as") && is_ident_boundary(bytes, i + 2) { + i = skip_ws_comments(source, i + 2); + let (alias, next) = read_ident(source, i)?; + local = alias; + i = next; + } else if needs_alias { + return None; + } + imports.push(StaticNamedImport { imported, local }); + while i < end && bytes[i] != b',' { + i = next_char_boundary(source, i); + } + if i < end && bytes[i] == b',' { + i += 1; + } + } + Some(()) +} + fn collect_named_import_bindings(source: &str, start: usize, bindings: &mut Vec) -> Option<()> { let bytes = source.as_bytes(); let end = find_matching_brace(source, start)?; @@ -4393,6 +4582,9 @@ impl Loader for CjsCompatLoader { { return Module::declare(ctx.clone(), path, error_source.as_bytes().to_vec()); } + if let Some(error_source) = cjs_named_import_error_module_source(&fs_abs_path, &source) { + return Module::declare(ctx.clone(), path, error_source.as_bytes().to_vec()); + } // Treat as ESM — inject import.meta prologue (handles shebangs) let injected = inject_import_meta_prologue(&init, &source); return Module::declare(ctx.clone(), path, injected.as_bytes().to_vec()); @@ -4969,6 +5161,10 @@ impl Loader for ImportMetaLoader { return Err(ctx.throw(cached_error)); } + if let Some(error_source) = cjs_named_import_error_module_source(&fs_abs_path, &source) { + return Module::declare(ctx.clone(), path, error_source.as_bytes().to_vec()); + } + let mut injected = inject_import_meta_prologue(&init, &source); if source_has_top_level_await(&source) { let escaped_path = escape_js_string(&module_abs_path); @@ -6309,6 +6505,84 @@ mod cjs_export_analyzer_tests { .is_none()); } + #[test] + fn parses_static_named_import_specifiers_for_cjs_diagnostics() { + assert_eq!( + parse_static_named_import(r#"import { comeOn } from './fail.cjs';"#, 0), + Some(( + "./fail.cjs".to_string(), + vec![StaticNamedImport { + imported: "comeOn".to_string(), + local: "comeOn".to_string(), + }], + r#"import { comeOn } from './fail.cjs';"#.len() + )) + ); + assert_eq!( + parse_static_named_import(r#"import { comeOn as renamed } from "deep-fail""#, 0) + .map(|(specifier, imports, _)| (specifier, imports)), + Some(( + "deep-fail".to_string(), + vec![StaticNamedImport { + imported: "comeOn".to_string(), + local: "renamed".to_string(), + }], + )) + ); + assert_eq!( + parse_static_named_import( + r#"import defaultValue, { comeOn, everybody } from './fail.cjs';"#, + 0, + ) + .map(|(specifier, imports, _)| (specifier, imports)), + Some(( + "./fail.cjs".to_string(), + vec![ + StaticNamedImport { + imported: "comeOn".to_string(), + local: "comeOn".to_string(), + }, + StaticNamedImport { + imported: "everybody".to_string(), + local: "everybody".to_string(), + }, + ], + )) + ); + assert_eq!( + parse_static_named_import(r#"import { default as cjsDefault } from './dep.cjs';"#, 0) + .map(|(specifier, imports, _)| (specifier, imports)), + Some(( + "./dep.cjs".to_string(), + vec![StaticNamedImport { + imported: "default".to_string(), + local: "cjsDefault".to_string(), + }], + )) + ); + assert_eq!( + parse_static_named_import( + r#"import { "missing-name" as missingName } from './dep.cjs';"#, + 0, + ) + .map(|(specifier, imports, _)| (specifier, imports)), + Some(( + "./dep.cjs".to_string(), + vec![StaticNamedImport { + imported: "missing-name".to_string(), + local: "missingName".to_string(), + }], + )) + ); + assert_eq!( + format_cjs_named_import_binding(&StaticNamedImport { + imported: "missing-name".to_string(), + local: "missingName".to_string(), + }), + r#""missing-name": missingName"# + ); + } + #[test] fn package_type_diagnostics_use_first_cjs_global() { let require_diag = esm_preflight_error_module_source("require('x');", true).unwrap(); diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index c5a8cc4f..5d8d2281 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -1388,6 +1388,27 @@ export const testRequireEsmErrorHandling = async () => { 'export default "namespace default";', 'export { value as "module.exports" };', ].join('\n')); + fs.writeFileSync('/require-esm-errors-app/cjs-missing-named.cjs', [ + 'module.exports = { missing: "runtime" };', + ].join('\n')); + fs.writeFileSync('/require-esm-errors-app/cjs-default-named.cjs', [ + 'module.exports = { defaultNamed: true };', + ].join('\n')); + fs.writeFileSync('/require-esm-errors-app/cjs-quoted-named.cjs', [ + 'module.exports = {};', + ].join('\n')); + fs.writeFileSync('/require-esm-errors-app/import-missing-named.mjs', [ + 'import { missing } from "./cjs-missing-named.cjs";', + 'export default missing;', + ].join('\n')); + fs.writeFileSync('/require-esm-errors-app/import-default-named.mjs', [ + 'import { default as cjsDefault } from "./cjs-default-named.cjs";', + 'export default cjsDefault;', + ].join('\n')); + fs.writeFileSync('/require-esm-errors-app/import-quoted-named.mjs', [ + 'import { "missing-name" as missingName } from "./cjs-quoted-named.cjs";', + 'export default missingName;', + ].join('\n')); const { createRequire } = await import('node:module'); const require = createRequire('/require-esm-errors-app/main.cjs'); @@ -1403,6 +1424,31 @@ export const testRequireEsmErrorHandling = async () => { }); assert.strictEqual(require('/require-esm-errors-app/valid-transpiled.js').foo, 'foo'); assert.deepStrictEqual(require('/require-esm-errors-app/module-exports-marker.mjs'), { marker: true }); + assert.deepStrictEqual((await import('/require-esm-errors-app/import-default-named.mjs')).default, { + defaultNamed: true, + }); + await assert.rejects(() => import('/require-esm-errors-app/import-missing-named.mjs'), { + name: 'SyntaxError', + message: [ + "Named export 'missing' not found. The requested module './cjs-missing-named.cjs' is a CommonJS module, which may not support all module.exports as named exports.", + 'CommonJS modules can always be imported via the default export, for example using:', + '', + "import pkg from './cjs-missing-named.cjs';", + 'const { missing } = pkg;', + '', + ].join('\n'), + }); + await assert.rejects(() => import('/require-esm-errors-app/import-quoted-named.mjs'), { + name: 'SyntaxError', + message: [ + 'Named export \'missing-name\' not found. The requested module \'./cjs-quoted-named.cjs\' is a CommonJS module, which may not support all module.exports as named exports.', + 'CommonJS modules can always be imported via the default export, for example using:', + '', + "import pkg from './cjs-quoted-named.cjs';", + 'const { "missing-name": missingName } = pkg;', + '', + ].join('\n'), + }); return true; } catch (error) { diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 50967c00..cf0faa82 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5987,7 +5987,7 @@ "es-module/test-esm-basic-imports.mjs": { "category": "runnable" }, "es-module/test-esm-child-process-fork-main.mjs": { "category": "wasi-impossible", "reason": "requires child_process.fork IPC semantics, which are not available in WASM" }, "es-module/test-esm-cjs-load-error-note.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-cjs-named-error.mjs": { "category": "known-gap", "reason": "CJS named-export misses reject, but the loader does not yet emit Node-shaped CommonJS named-export diagnostics" }, + "es-module/test-esm-cjs-named-error.mjs": { "category": "runnable" }, "es-module/test-esm-custom-exports.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-cyclic-dynamic-import.mjs": { "category": "runnable" }, "es-module/test-esm-default-type.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index eec4fde6..5e626564 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3137/4404 (71.2%) +**Primary compatibility (CI-enforced):** 3138/4404 (71.3%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3137 | 71.2% | 54.8% | 45.8% | -| 🧩 known gap | 1267 | 28.8% | 22.1% | 18.5% | +| ✅ passing (runnable) | 3138 | 71.3% | 54.9% | 45.9% | +| 🧩 known gap | 1266 | 28.7% | 22.1% | 18.5% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3137/5721 (54.8%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3138/5721 (54.9%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 138 | 164 | 85 | 11 | 0 | 183 | 45.7% | 34.7% | +| other | 581 | 139 | 163 | 85 | 11 | 0 | 183 | 46.0% | 34.9% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1267) +### known gap (1266) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -844,7 +844,6 @@ Secondary full-public compatibility, including public tests that are currently e | AsyncLocalStorage deep nesting/recursion handling is unstable | 1 | `parallel/test-async-local-storage-deep-stack.js` | | AsyncLocalStorage.bind argument validation is incomplete | 1 | `parallel/test-async-local-storage-bind.js` | | AsyncLocalStorage.snapshot is missing or incomplete | 1 | `parallel/test-async-local-storage-snapshot.js` | -| CJS named-export misses reject, but the loader does not yet emit Node-shaped CommonJS named-export diagnostics | 1 | `es-module/test-esm-cjs-named-error.mjs` | | CLI --security-revert behavior in child_process spawnSync is not fully implemented | 1 | `parallel/test-security-revert-unknown.js` | | CLI --title flag does not update process.title | 1 | `parallel/test-process-title-cli.js` | | CLI --unhandled-rejections flag parsing/validation is incomplete | 1 | `parallel/test-promise-unhandled-flag.js` | From 22986477bb71496e9375a1914a499b9e058cbd9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 17:56:16 +0200 Subject: [PATCH 41/42] Validate vm compileFunction offsets --- .../wasm-rquickjs/skeleton/src/builtin/vm.js | 43 +++++++++++++++++++ tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 13 +++--- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/vm.js b/crates/wasm-rquickjs/skeleton/src/builtin/vm.js index ad287e38..4368caa8 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/vm.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/vm.js @@ -233,9 +233,52 @@ export function runInThisContext(code, options) { export function compileFunction(code, params, options) { params = params || []; + options = validateOptionsObject(options); + validateInt32Option(options.lineOffset, 'options.lineOffset'); + validateInt32Option(options.columnOffset, 'options.columnOffset'); return new Function(...params, code); } +function validateOptionsObject(options) { + if (options === undefined) return {}; + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throwInvalidArgType('options', 'object', options); + } + return options; +} + +function validateInt32Option(value, name) { + if (value === undefined) return; + if (typeof value !== 'number') { + throwInvalidArgType(name, 'number', value); + } + if (!Number.isInteger(value)) { + throwOutOfRange(name, 'an integer', value); + } + if (value < -2147483648 || value > 2147483647) { + throwOutOfRange(name, '>= -2147483648 && <= 2147483647', value); + } +} + +function throwInvalidArgType(name, expected, value) { + const err = new TypeError('The "' + name + '" argument must be of type ' + expected + '. Received ' + formatReceived(value)); + err.code = 'ERR_INVALID_ARG_TYPE'; + throw err; +} + +function throwOutOfRange(name, range, value) { + const err = new RangeError('The value of "' + name + '" is out of range. It must be ' + range + '. Received ' + formatReceived(value)); + err.code = 'ERR_OUT_OF_RANGE'; + throw err; +} + +function formatReceived(value) { + if (value === null) return 'null'; + if (typeof value === 'string') return "'" + value + "'"; + if (typeof value === 'symbol') return value.toString(); + return String(value); +} + export class Script { constructor(code, options) { this._code = String(code); diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index cf0faa82..5f3dd5ec 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5974,7 +5974,7 @@ } }, "es-module/test-require-node-modules-warning.js": { "category": "known-gap", "reason": "child_process execPath emulation does not implement --trace-require-module warning output" }, - "es-module/test-vm-compile-function-lineoffset.js": { "category": "known-gap", "reason": "vm.compileFunction options range validation (lineOffset/columnOffset) is incomplete" }, + "es-module/test-vm-compile-function-lineoffset.js": { "category": "runnable" }, "es-module/test-vm-main-context-default-loader.js": { "category": "known-gap", "reason": "vm.USE_MAIN_CONTEXT_DEFAULT_LOADER behavior for dynamic import resolution is incomplete" }, "es-module/test-vm-source-text-module-leak.js": { "category": "known-gap", "reason": "common-shim gc helper does not provide V8-style collectability checks used by this leak test" }, "es-module/test-vm-synthetic-module-leak.js": { "category": "known-gap", "reason": "common-shim gc helper does not provide V8-style collectability checks used by this leak test" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 5e626564..842ae43f 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3138/4404 (71.3%) +**Primary compatibility (CI-enforced):** 3139/4404 (71.3%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3138 | 71.3% | 54.9% | 45.9% | -| 🧩 known gap | 1266 | 28.7% | 22.1% | 18.5% | +| ✅ passing (runnable) | 3139 | 71.3% | 54.9% | 45.9% | +| 🧩 known gap | 1265 | 28.7% | 22.1% | 18.5% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3138/5721 (54.9%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3139/5721 (54.9%)**. ## Inventory by Module @@ -81,7 +81,7 @@ Secondary full-public compatibility, including public tests that are currently e | url | 29 | 28 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | | util | 174 | 90 | 8 | 0 | 0 | 0 | 76 | 91.8% | 91.8% | | v8 | 45 | 14 | 1 | 0 | 30 | 0 | 0 | 93.3% | 31.1% | -| vm | 121 | 25 | 84 | 3 | 9 | 0 | 0 | 22.9% | 20.7% | +| vm | 121 | 26 | 83 | 3 | 9 | 0 | 0 | 23.9% | 21.5% | | webcrypto | 107 | 43 | 21 | 1 | 0 | 0 | 42 | 67.2% | 66.2% | | webstreams | 68 | 67 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | | whatwg | 261 | 54 | 21 | 0 | 0 | 0 | 186 | 72.0% | 72.0% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1266) +### known gap (1265) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -1342,7 +1342,6 @@ Secondary full-public compatibility, including public tests that are currently e | vm.Script.sourceMapURL parsing for //# sourceMappingURL comments is not implemented | 1 | `parallel/test-vm-source-map-url.js` | | vm.SyntheticModule API behavior is missing/incomplete | 1 | `parallel/test-vm-module-synthetic.js` | | vm.USE_MAIN_CONTEXT_DEFAULT_LOADER behavior for dynamic import resolution is incomplete | 1 | `es-module/test-vm-main-context-default-loader.js` | -| vm.compileFunction options range validation (lineOffset/columnOffset) is incomplete | 1 | `es-module/test-vm-compile-function-lineoffset.js` | | vm.compileFunction validation, options handling, and error fidelity are incomplete | 1 | `parallel/test-vm-basic.js#block_06_vm_compilefunction` | | vm.createContext argument type validation and error codes are incomplete | 1 | `parallel/test-vm-create-context-arg.js` | | vm.createContext argument validation and error codes are incomplete | 1 | `parallel/test-vm-basic.js#block_04_vm_createcontext` | From 8dfad7147b6d4065f0c368fb39c6f8466bfd9e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 25 Jun 2026 19:54:27 +0200 Subject: [PATCH 42/42] Promote passing ESM package type tests --- tests/node_compat/config.jsonc | 6 +++--- tests/node_compat/report.md | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 5f3dd5ec..c240b667 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -5990,7 +5990,7 @@ "es-module/test-esm-cjs-named-error.mjs": { "category": "runnable" }, "es-module/test-esm-custom-exports.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, "es-module/test-esm-cyclic-dynamic-import.mjs": { "category": "runnable" }, - "es-module/test-esm-default-type.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-default-type.mjs": { "category": "runnable" }, "es-module/test-esm-detect-ambiguous.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-dns-promises.mjs": { "category": "runnable" }, "es-module/test-esm-double-encoding.mjs": { "category": "runnable" }, @@ -6066,13 +6066,13 @@ "es-module/test-esm-throw-undefined.mjs": {}, "es-module/test-esm-tla-unfinished.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-tla.mjs": {}, - "es-module/test-esm-type-field.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-type-field.mjs": { "category": "runnable" }, "es-module/test-esm-type-flag-cli-entry.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-type-flag-errors.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-type-flag-loose-files.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-type-flag-package-scopes.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, "es-module/test-esm-type-flag-string-input.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, - "es-module/test-esm-type-main.mjs": { "category": "known-gap", "reason": "ESM package type/exports/imports behavior needs resolver unification triage" }, + "es-module/test-esm-type-main.mjs": { "category": "runnable" }, "es-module/test-esm-util-types.mjs": {}, "es-module/test-esm-virtual-json.mjs": { "category": "known-gap", "reason": "requires module.register loader hooks to synthesize virtual JSON modules" }, "es-module/test-esm-wasm.mjs": { "category": "known-gap", "reason": "requires simulated process.execPath / Node CLI mode support deferred to follow-up PR" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 842ae43f..f624ddc6 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3139/4404 (71.3%) +**Primary compatibility (CI-enforced):** 3142/4404 (71.3%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3139 | 71.3% | 54.9% | 45.9% | -| 🧩 known gap | 1265 | 28.7% | 22.1% | 18.5% | +| ✅ passing (runnable) | 3142 | 71.3% | 54.9% | 45.9% | +| 🧩 known gap | 1262 | 28.7% | 22.1% | 18.4% | | 🚫 WASI-impossible (excluded) | 1155 | — | 20.2% | 16.9% | | ⚙️ engine difference (excluded) | 162 | — | 2.8% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1122 | — | — | 16.4% | | **Total** | **6843** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3139/5721 (54.9%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3142/5721 (54.9%)**. ## Inventory by Module @@ -57,7 +57,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 147 | 39 | 19 | 1 | 0 | 17 | 79.0% | 71.4% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 581 | 139 | 163 | 85 | 11 | 0 | 183 | 46.0% | 34.9% | +| other | 581 | 142 | 160 | 85 | 11 | 0 | 183 | 47.0% | 35.7% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -681,7 +681,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1265) +### known gap (1262) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -699,7 +699,6 @@ Secondary full-public compatibility, including public tests that are currently e | net.js TCP implementation incomplete - needs event handling and API fixes | 11 | `parallel/test-net-connect-nodelay.js`, `parallel/test-net-connect-paused-connection.js`, `parallel/test-net-during-close.js`, ... (+8) | | remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct node modules app same-process module graph coverage lives in tests/node_modules_apps | 11 | `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_00_a_mjs_b_cjs_c_mjs_a_mjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_01_b_cjs_c_mjs_a_mjs_b_cjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_02_c_mjs_a_mjs_b_cjs_c_mjs`, ... (+8) | | wasi:sockets UDP implementation hangs in wasmtime | 11 | `parallel/test-dgram-implicit-bind.js`, `parallel/test-dgram-multicast-set-interface.js#block_00_block_00`, `parallel/test-dgram-multicast-set-interface.js#block_02_block_02`, ... (+8) | -| ESM package type/exports/imports behavior needs resolver unification triage | 10 | `es-module/test-esm-custom-exports.mjs`, `es-module/test-esm-default-type.mjs`, `es-module/test-esm-exports-deprecations.mjs`, ... (+7) | | dgram multicast membership APIs are not implemented (ENOSYS) | 10 | `parallel/test-dgram-membership.js#block_02_addmembership_with_no_argument_should_throw`, `parallel/test-dgram-membership.js#block_03_dropmembership_with_no_argument_should_throw`, `parallel/test-dgram-membership.js#block_04_addmembership_with_invalid_multicast_address_should_throw`, ... (+7) | | async_hooks not fully implemented | 9 | `parallel/test-async-hooks-destroy-on-gc.js`, `parallel/test-async-hooks-disable-during-promise.js`, `parallel/test-async-hooks-disable-gc-tracking.js`, ... (+6) | | module SourceMap/findSourceMap API is not fully implemented | 9 | `parallel/test-source-map-api.js#block_00_it_should_throw_with_invalid_args`, `parallel/test-source-map-api.js#block_01_findsourcemap_should_return_undefined_when_no_source_map_is_`, `parallel/test-source-map-api.js#block_02_non_exceptional_case`, ... (+6) | @@ -710,6 +709,7 @@ Secondary full-public compatibility, including public tests that are currently e | Intl is not available in current runtime | 8 | `parallel/test-intl-v8BreakIterator.js`, `parallel/test-intl.js`, `parallel/test-whatwg-encoding-custom-textdecoder-fatal.js`, ... (+5) | | process unhandledRejection/rejectionHandled/warning mode behavior is incomplete | 8 | `parallel/test-promise-unhandled-silent-no-hook.js`, `parallel/test-promise-unhandled-silent.js`, `parallel/test-promise-unhandled-warn-no-hook.js`, ... (+5) | | vm.constants.DONT_CONTEXTIFY and vanilla-context behavior are not implemented | 8 | `parallel/test-vm-context-dont-contextify.js#block_00_block_00`, `parallel/test-vm-context-dont-contextify.js#block_01_block_01`, `parallel/test-vm-context-dont-contextify.js#block_02_block_02`, ... (+5) | +| ESM package type/exports/imports behavior needs resolver unification triage | 7 | `es-module/test-esm-custom-exports.mjs`, `es-module/test-esm-exports-deprecations.mjs`, `es-module/test-esm-exports.mjs`, ... (+4) | | common-shim spawnPromisified child emulation does not support --experimental-webstorage/--localstorage-file flags | 7 | `parallel/test-webstorage.js#test_01_emits_a_warning_when_used`, `parallel/test-webstorage.js#test_02_storage_instances_cannot_be_created_in_userland`, `parallel/test-webstorage.js#test_03_sessionstorage_is_not_persisted`, ... (+4) | | inherited: Intl is not available in current runtime | 7 | `parallel/test-icu-transcode.js#block_00_block_00`, `parallel/test-icu-transcode.js#block_01_block_01`, `parallel/test-icu-transcode.js#block_02_test_that_uint8array_arguments_are_okay`, ... (+4) | | WebAssembly global is missing in current runtime | 6 | `es-module/test-wasm-memory-out-of-bound.js`, `es-module/test-wasm-simple.js`, `es-module/test-wasm-web-api.js`, ... (+3) |