diff --git a/apps/chuzz/Cargo.toml b/apps/chuzz/Cargo.toml index b26a7f5..e8720a0 100644 --- a/apps/chuzz/Cargo.toml +++ b/apps/chuzz/Cargo.toml @@ -116,6 +116,10 @@ tauri.workspace = true tauri-runtime-blitz.workspace = true [dev-dependencies] +# `flavor = "multi_thread"` in the script-fetch test: the blocking fetch takes +# the `block_in_place` path, which only the multi-threaded runtime has, and +# that is the path the capture actually uses. +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time"] } # Assembles `fixtures/panel.wat` into a module in-process, so the capture test # owns its guest instead of borrowing blitz-wasm's demo, and so it needs no # wasm32 target and no nested cargo build. See the header of that file. diff --git a/apps/chuzz/frontend/bun.lock b/apps/chuzz/frontend/bun.lock index c72e9bd..3b0b29e 100644 --- a/apps/chuzz/frontend/bun.lock +++ b/apps/chuzz/frontend/bun.lock @@ -7,17 +7,19 @@ "dependencies": { "@chuzz/ui": "workspace:*", "@iconify/tailwind4": "^1.0.6", - "@pathscale/ui": "2.7.4", - "@solidjs/web": "2.0.0-rc.0", + "@pathscale/ui": "^2.11.10", + "@solidjs/web": "2.0.0-rc.4", "@tauri-apps/api": "^2.1.1", "clsx": "^2.1.1", "popmotion": "^11.0.5", - "solid-js": "2.0.0-rc.0", + "solid-js": "2.0.0-rc.4", "solid-layouts": "^0.2.1", "tailwind-merge": "^3.6.0", }, "devDependencies": { "@biomejs/biome": "^2.4.15", + "@iconify-json/lucide": "^1.2.127", + "@iconify-json/mdi": "^1.2.3", "@rsbuild/core": "^1.3.20", "@rsbuild/plugin-babel": "^1.0.5", "@rsbuild/plugin-solid": "^1.0.5", @@ -25,7 +27,7 @@ "@tailwindcss/postcss": "^4.1.7", "@testing-library/jest-dom": "^7.0.0", "@types/node": "^22.15.17", - "babel-preset-solid": "2.0.0-rc.0", + "babel-preset-solid": "^2.0.0-rc.2", "fork-ts-checker-webpack-plugin": "^9.1.0", "jsdom": "^30.0.0", "rsbuild-plugin-solid-layouts": "^0.2.1", @@ -40,14 +42,14 @@ "name": "@chuzz/ui", "version": "0.1.0", "peerDependencies": { - "@pathscale/ui": "^2.5.0", - "solid-js": "^1.9.5", - "solid-layouts": "^0.1.3", + "@pathscale/ui": "^2.11.9", + "solid-js": ">=2.0.0-rc.0", + "solid-layouts": "^0.2.1", }, }, }, "overrides": { - "babel-preset-solid": "2.0.0-rc.0", + "babel-preset-solid": "^2.0.0-rc.2", }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], @@ -164,7 +166,7 @@ "@cyberalien/svg-utils": ["@cyberalien/svg-utils@1.2.19", "", { "dependencies": { "@iconify/types": "^2.0.0" } }, "sha512-paDJoDu+LhuH5Ma1q0BDqBpg6d16Xk22cZZrlg/s/J9oB8KgnnpU/snaEETJu2G1pwYcWQINUz6WUqu0Wm9k6A=="], - "@dom-expressions/babel-plugin-jsx": ["@dom-expressions/babel-plugin-jsx@0.50.0-next.42", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2", "validate-html-nesting": "^1.2.1" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-ol24x9RW8loPyOTzC/mQzh/zAsrsPxyTG4WRxxRlwzNK2uBWIBftWN5IwmSV51zDQa7JcT6sE6kkXFCLUvsYIQ=="], + "@dom-expressions/babel-plugin-jsx": ["@dom-expressions/babel-plugin-jsx@0.50.0-next.44", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2", "validate-html-nesting": "^1.2.1" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-f1kx6TeMQgaLVkGfuxMn0BkH0+HvsycFf1qPHbYoBqmBQd6Ag/EXOxkSpfcaC+UOBMSaF4BKXegGDchRNTWKrg=="], "@dom-expressions/compiler": ["@dom-expressions/compiler@0.50.0-next.43", "", { "optionalDependencies": { "@dom-expressions/compiler-darwin-arm64": "0.50.0-next.43", "@dom-expressions/compiler-darwin-x64": "0.50.0-next.43", "@dom-expressions/compiler-linux-arm64-gnu": "0.50.0-next.43", "@dom-expressions/compiler-linux-x64-gnu": "0.50.0-next.43", "@dom-expressions/compiler-wasm32-wasi": "0.50.0-next.43", "@dom-expressions/compiler-win32-x64-msvc": "0.50.0-next.43" } }, "sha512-ekEuAFLb868iGu6bw8KA2wgD3gYJpYYTjSbOtuXGlCZnWFA6Wx8Qf720ZYNlb40QjGe3/2kUHTtn47JfaCwPcA=="], @@ -188,6 +190,10 @@ "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@iconify-json/lucide": ["@iconify-json/lucide@1.2.127", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-7KuCCpkly00kefna4wEnCr8l2HFRkbn4mD4zRib8wCSbrBmnR/l57ExpU6j5jv/Bo+qHpWuHpDtcqceQ0ptArw=="], + + "@iconify-json/mdi": ["@iconify-json/mdi@1.2.3", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-O3cLwbDOK7NNDf2ihaQOH5F9JglnulNDFV7WprU2dSoZu3h3cWH//h74uQAB87brHmvFVxIOkuBX2sZSzYhScg=="], + "@iconify/tailwind4": ["@iconify/tailwind4@1.2.3", "", { "dependencies": { "@iconify/tools": "^5.0.5", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.0" }, "peerDependencies": { "tailwindcss": ">= 4.0.0" } }, "sha512-z8SKiMHRASJKF/IY//87MF88lcB7ulxh8vlhQXXLWsBkNtOh6ese9R41MyGpQeqXdRvQVt+/fX2glQtHFjQ+MA=="], "@iconify/tools": ["@iconify/tools@5.0.12", "", { "dependencies": { "@cyberalien/svg-utils": "^1.2.15", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.3", "fflate": "^0.8.3", "modern-tar": "^0.7.6", "pathe": "^2.0.3", "svgo": "^4.0.1" } }, "sha512-aFPwSFmFphUPVjNLUkgxgUPSPVgTEEjv0mE7NgvfoQBuE5TtsMrKa4HTY65cM5oXZ2a1xKQcW/RKhiOKEiAarw=="], @@ -224,7 +230,7 @@ "@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="], - "@pathscale/ui": ["@pathscale/ui@2.7.4", "", { "dependencies": { "@solidjs/web": "2.0.0-rc.0", "clsx": "^2.1.1", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "@standard-schema/spec": "^1.0.0", "popmotion": "^11.0.5", "solid-js": ">=2.0.0-rc.0", "solid-layouts": "^0.2.0" }, "optionalPeers": ["@standard-schema/spec", "popmotion"] }, "sha512-aPKnbtFEi3tDqYUEv4Vp+nlOPryH/51geJfY8o09ybSrFzC7dqMCl3vR4HaSDCwH+fLjihZZCLQwv8SYpvTkow=="], + "@pathscale/ui": ["@pathscale/ui@2.11.10", "", { "dependencies": { "clsx": "^2.1.1", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "@solidjs/web": ">=2.0.0-rc.0", "@standard-schema/spec": "^1.0.0", "popmotion": "^11.0.5", "solid-js": ">=2.0.0-rc.0", "solid-layouts": "^0.2.0" }, "optionalPeers": ["@standard-schema/spec", "popmotion"] }, "sha512-3h03mALgWQse1Ebr6CusnYOz/CWGRHIxMcMdM3K7DoBdAq5VKtXdkIF7BQoOlHBnLv/l2grNdaQz2M3oLHpG0A=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.3", "", { "os": "android", "cpu": "arm64" }, "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="], @@ -288,13 +294,13 @@ "@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.5", "", {}, "sha512-uzB782zJbFTM3ta+e2Glikx36dca/6Y+DXyvFN+wb0Tx5ItIW+g03A0t3amP3LGzPHSkb0k81VHCm4jxLQwfag=="], - "@solidjs/signals": ["@solidjs/signals@2.0.0-rc.0", "", {}, "sha512-oKZSfvsCcKw1uJjOGbUkJ+OqlhXLHtZ+rShSyu9KH0lUH7UUwfMfsKeh81JPiQxDDg4YLhEwI38hg0JkwzTdvA=="], + "@solidjs/signals": ["@solidjs/signals@2.0.0-rc.4", "", {}, "sha512-l7P0g8+2pnNscaIPOGDMhv0boaidGAsvR8QN66JySIWNMvVnuq2ayv7ZnvP+IR00DJC+RqCNnnTG/UCdxf+h8g=="], "@solidjs/testing-library": ["@solidjs/testing-library@1.0.0-beta.2", "", { "dependencies": { "@testing-library/dom": "^10.4.1" }, "peerDependencies": { "@solidjs/web": ">=2.0.0", "solid-js": ">=2.0.0" } }, "sha512-TLhQ5IUT/fdDfqa4X2rkQWB28Y+zEwi6mK/TVTeiQlEHG63eK2jfgwNYf2NtQoPh2c3ihLilsCzxABiSTP3JoQ=="], "@solidjs/vite-plugin": ["@solidjs/vite-plugin@3.0.0-next.31", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@babel/core": "^7.23.3", "@dom-expressions/compiler": "^0.50.0-next.43", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^2.0.0-rc.0", "merge-anything": "^5.1.7", "vitefu": "^1.0.4" }, "peerDependencies": { "@solidjs/start-devtools": "^1.0.0-next.0", "@solidjs/web": "^2.0.0-rc.0", "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^2.0.0-rc.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" }, "optionalPeers": ["@solidjs/start-devtools", "@testing-library/jest-dom"] }, "sha512-iS9zz4MdzQwZyXGtEYKxFu9fHjX6fs0CWWQTQZ54xaaPKPOcv1sXJtxZ+th7EsmGlmeUNP0jBYoB9qxkd47tiQ=="], - "@solidjs/web": ["@solidjs/web@2.0.0-rc.0", "", { "dependencies": { "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" }, "peerDependencies": { "solid-js": "^2.0.0-rc.0" } }, "sha512-pYSaA9+dH8H1h/d/ZF/P2kR6omfzFGNcdzKhWTcg9fJghXhn8+5UrXUr2iYxDdYNOXZzxxFQhYHSJ7P4HKDqgw=="], + "@solidjs/web": ["@solidjs/web@2.0.0-rc.4", "", { "dependencies": { "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" }, "peerDependencies": { "solid-js": "^2.0.0-rc.4" } }, "sha512-acM9W0FByf0aJDMG5kMbHr0BLTjvFIg8lxwLwHzsu3ybWSmsP1FiOOs2RHNItve/AiX+MGR9CQBCazmnfPykEA=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -454,7 +460,7 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "babel-preset-solid": ["babel-preset-solid@2.0.0-rc.0", "", { "dependencies": { "@dom-expressions/babel-plugin-jsx": "0.50.0-next.42" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^2.0.0-rc.0" }, "optionalPeers": ["solid-js"] }, "sha512-Ap2/QQY3pICj+Q0VM/RnIOpZo7e6icZnUA0oBJuhqzoCrljqMNo3eFb2OeEa4pUQeFREJOlex4Bt1ggwrcgC8w=="], + "babel-preset-solid": ["babel-preset-solid@2.0.0-rc.2", "", { "dependencies": { "@dom-expressions/babel-plugin-jsx": "0.50.0-next.44" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^2.0.0-rc.2" }, "optionalPeers": ["solid-js"] }, "sha512-Venq++Aa6+RzGIAyS5vSo3kX1fE8zRreEPCh1eTh5DTjlTft9qZGIG14RSZDv/sjVxT8x5Gs9+21PzxuLe8U+g=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -814,11 +820,11 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "solid-js": ["solid-js@2.0.0-rc.0", "", { "dependencies": { "@solidjs/signals": "^2.0.0-rc.0", "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-3enTJ71VL69nM5p/it2InVBDBt316Cqfij+F0S7VuHZLITc8YV7Rvavjoy52nAKKxYWXM+BcXh2K7KcLTf1zdQ=="], + "solid-js": ["solid-js@2.0.0-rc.4", "", { "dependencies": { "@solidjs/signals": "^2.0.0-rc.4", "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-hSkDmtduesjFtvzjNyqLphrqiSvhSD5+njkPQUR+9YEoR60MMWtuv36SbLL4OucI0Ronof3Jc+AsUp0oWffwMg=="], "solid-layouts": ["solid-layouts@0.2.1", "", { "peerDependencies": { "@solidjs/web": "^2.0.0-rc.0", "solid-js": "^1.9.14 || ^2.0.0-rc.0" }, "optionalPeers": ["@solidjs/web"] }, "sha512-afgDiwRhBNimxCrEUfh2+6rO18ti6X1gFz5peZ4BPycDYGyeBrxLDy97C4BmmmwgqPkLZrIbbQEUJ4i32W9TPA=="], - "solid-layouts-oxc": ["solid-layouts-oxc@0.2.1", "", { "bin": { "solid-layouts-library": "bin/solid-layouts-library.js", "solid-layouts-application": "bin/solid-layouts-application.js", "solid-layouts-lint": "bin/solid-layouts-lint.js" } }, "sha512-u245kTqsNzYOEwpKxaTQOIubOgkXBQDuwP7xKzQ8MNFMHsNP6FrgtTA0tEf5UcUcE+WEyjl17VRRKnmUChat2w=="], + "solid-layouts-oxc": ["solid-layouts-oxc@0.2.1", "", { "bin": { "solid-layouts-lint": "bin/solid-layouts-lint.js", "solid-layouts-library": "bin/solid-layouts-library.js", "solid-layouts-application": "bin/solid-layouts-application.js" } }, "sha512-u245kTqsNzYOEwpKxaTQOIubOgkXBQDuwP7xKzQ8MNFMHsNP6FrgtTA0tEf5UcUcE+WEyjl17VRRKnmUChat2w=="], "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], @@ -980,6 +986,8 @@ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "rsbuild-plugin-solid-layouts/solid-layouts-oxc": ["solid-layouts-oxc@0.2.2", "", { "bin": { "solid-layouts-lint": "bin/solid-layouts-lint.js", "solid-layouts-library": "bin/solid-layouts-library.js", "solid-layouts-application": "bin/solid-layouts-application.js" } }, "sha512-45rU4MTsRj9FNzVaXDtttVsSDb+/bojuqUT9IJy875u7bGboz616vkr2S9jg/FKDj3rjFz8pDLKlhcAtHDL6fg=="], + "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], diff --git a/apps/chuzz/frontend/local-ui/bundle/package.json b/apps/chuzz/frontend/local-ui/bundle/package.json index 8f61f39..d99691f 100644 --- a/apps/chuzz/frontend/local-ui/bundle/package.json +++ b/apps/chuzz/frontend/local-ui/bundle/package.json @@ -21,9 +21,9 @@ "**/*" ], "peerDependencies": { - "@pathscale/ui": "^2.5.0", - "solid-js": "^1.9.5", - "solid-layouts": "^0.1.3" + "@pathscale/ui": "^2.11.9", + "solid-js": ">=2.0.0-rc.0", + "solid-layouts": "^0.2.1" }, "solidLayouts": "./layouts.manifest.json" } diff --git a/apps/chuzz/frontend/local-ui/package.json b/apps/chuzz/frontend/local-ui/package.json index 09203a8..2e390f3 100644 --- a/apps/chuzz/frontend/local-ui/package.json +++ b/apps/chuzz/frontend/local-ui/package.json @@ -9,8 +9,8 @@ "**/*.css" ], "peerDependencies": { - "@pathscale/ui": "^2.5.0", - "solid-js": "^1.9.5", - "solid-layouts": "^0.1.3" + "@pathscale/ui": "^2.11.9", + "solid-js": ">=2.0.0-rc.0", + "solid-layouts": "^0.2.1" } } diff --git a/apps/chuzz/frontend/package.json b/apps/chuzz/frontend/package.json index 6792f87..5e09972 100644 --- a/apps/chuzz/frontend/package.json +++ b/apps/chuzz/frontend/package.json @@ -29,17 +29,19 @@ "dependencies": { "@chuzz/ui": "workspace:*", "@iconify/tailwind4": "^1.0.6", - "@pathscale/ui": "2.7.4", - "@solidjs/web": "2.0.0-rc.0", + "@pathscale/ui": "^2.11.10", + "@solidjs/web": "2.0.0-rc.4", "@tauri-apps/api": "^2.1.1", "clsx": "^2.1.1", "popmotion": "^11.0.5", - "solid-js": "2.0.0-rc.0", + "solid-js": "2.0.0-rc.4", "solid-layouts": "^0.2.1", "tailwind-merge": "^3.6.0" }, "devDependencies": { "@biomejs/biome": "^2.4.15", + "@iconify-json/lucide": "^1.2.127", + "@iconify-json/mdi": "^1.2.3", "@rsbuild/core": "^1.3.20", "@rsbuild/plugin-babel": "^1.0.5", "@rsbuild/plugin-solid": "^1.0.5", @@ -47,7 +49,7 @@ "@tailwindcss/postcss": "^4.1.7", "@testing-library/jest-dom": "^7.0.0", "@types/node": "^22.15.17", - "babel-preset-solid": "2.0.0-rc.0", + "babel-preset-solid": "^2.0.0-rc.2", "fork-ts-checker-webpack-plugin": "^9.1.0", "jsdom": "^30.0.0", "rsbuild-plugin-solid-layouts": "^0.2.1", @@ -58,6 +60,6 @@ "vitest": "^4.1.10" }, "overrides": { - "babel-preset-solid": "2.0.0-rc.0" + "babel-preset-solid": "^2.0.0-rc.2" } } diff --git a/apps/chuzz/frontend/src/styles/theme.css b/apps/chuzz/frontend/src/styles/theme.css index 1e2fee1..455d212 100644 --- a/apps/chuzz/frontend/src/styles/theme.css +++ b/apps/chuzz/frontend/src/styles/theme.css @@ -598,9 +598,9 @@ } /* - * `@theme` emits the app-specific variables outside the DaisyUI theme layer. - * Keep their light counterparts outside that layer too, or the unlayered dark - * values win the cascade even when the root carries `data-color-mode=light`. + * `@theme` emits the app-specific variables outside the theme layer. Keep their + * light counterparts outside that layer too, or the unlayered dark values win + * the cascade even when the root carries `data-color-mode=light`. */ [data-theme="24x-dark"][data-color-mode="light"] { /* Tailwind's `white/N` utilities are translucent interface surfaces in this app. */ diff --git a/apps/chuzz/src/browser.rs b/apps/chuzz/src/browser.rs index 01200a1..d635bb1 100644 --- a/apps/chuzz/src/browser.rs +++ b/apps/chuzz/src/browser.rs @@ -274,6 +274,27 @@ struct WasmPage { module: std::path::PathBuf, } +/// How long the window waits for a script the page asked for while running. +/// +/// Shorter than the capture's, and the reason is worth stating plainly: +/// `ScriptFetcher::fetch` is synchronous and the page's scripts run on the UI +/// thread, so this blocks the whole window, other tabs included, for as long +/// as it waits. The alternative is what happened before, which was to drop the +/// script and render a page missing whatever it was going to build. A short +/// stall is the better of the two, but only a short one; a page cannot be +/// allowed to freeze the browser because one of its servers went quiet. +/// +/// The real answer is an asynchronous script-loading path in the engine, which +/// would not need to choose. +const WINDOW_SCRIPT_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5); + +/// How long one `fetch` or `XMLHttpRequest` from the page may take. +/// +/// Generous compared to the script deadline, and it can afford to be: this one +/// blocks nothing. The request is asynchronous, the window keeps painting, and +/// the answer arrives on a later poll. +const WINDOW_NETWORK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30); + struct BrowserInner { state: Mutex, log: Mutex, @@ -556,8 +577,17 @@ impl Browser { // certain the document is exactly what the page said. let mut page = blitz_script::ScriptDocument::from_html(&html, make_config()) - .with_fetcher(PrefetchedScripts { scripts }); + .with_fetcher(crate::script_fetch::PageScripts::new( + scripts, + Arc::clone(&self.0.net), + WINDOW_SCRIPT_DEADLINE, + )); page.eval(WEB_API_SHIM); + crate::net_bridge::install( + &mut page, + Arc::clone(&self.0.net), + WINDOW_NETWORK_DEADLINE, + ); page.execute_scripts(); let title = page .inner() @@ -573,8 +603,17 @@ impl Browser { wasm: None, } => { let mut page = blitz_script::ScriptDocument::from_html(&html, make_config()) - .with_fetcher(PrefetchedScripts { scripts }); + .with_fetcher(crate::script_fetch::PageScripts::new( + scripts, + Arc::clone(&self.0.net), + WINDOW_SCRIPT_DEADLINE, + )); page.eval(WEB_API_SHIM); + crate::net_bridge::install( + &mut page, + Arc::clone(&self.0.net), + WINDOW_NETWORK_DEADLINE, + ); page.execute_scripts(); let title = page .inner() @@ -653,20 +692,6 @@ impl NavigationProvider for PageNavigation { } } -struct PrefetchedScripts { - scripts: HashMap, -} - -impl blitz_script::ScriptFetcher for PrefetchedScripts { - fn fetch(&self, url: &Url) -> Result { - self.scripts - .get(url) - .cloned() - .map(Ok) - .unwrap_or_else(|| blitz_script::DefaultScriptFetcher.fetch(url)) - } -} - /// Build a page document by letting a WebAssembly guest construct it. /// /// The config is the page config the HTML path uses, so the guest's document diff --git a/apps/chuzz/src/capture.rs b/apps/chuzz/src/capture.rs index 7fd9ae7..1d8b0c9 100644 --- a/apps/chuzz/src/capture.rs +++ b/apps/chuzz/src/capture.rs @@ -45,6 +45,29 @@ fn capture_scale() -> f32 { .unwrap_or(1.0) } +/// CSS pixel size to lay the page out at. Defaults to 1440 by 960. +/// +/// A capture is only comparable against a reference browser when both laid the +/// page out at the same width: every responsive breakpoint, every percentage +/// width and every centred box moves with it. These were exported by +/// `scripts/render-check.sh` and read by nothing, so a run at another size +/// silently produced the default and the resulting diff was all viewport and +/// no signal. +pub fn capture_viewport() -> (u32, u32) { + fn dimension(name: &str, fallback: u32) -> u32 { + std::env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .filter(|pixels: &u32| *pixels > 0) + .unwrap_or(fallback) + } + + ( + dimension("CHUZZ_CAPTURE_WIDTH", 1440), + dimension("CHUZZ_CAPTURE_HEIGHT", 960), + ) +} + /// Which colour scheme to render at. Defaults to dark. /// /// A site that respects `prefers-color-scheme` is a different page in each, so diff --git a/apps/chuzz/src/document_loader.rs b/apps/chuzz/src/document_loader.rs index b29b79d..d3937dc 100644 --- a/apps/chuzz/src/document_loader.rs +++ b/apps/chuzz/src/document_loader.rs @@ -29,6 +29,21 @@ use std::collections::HashMap; pub type NetProvider = blitz_net::Provider; +/// How long a capture waits for a script the page asked for while running. +/// +/// Longer than the window's, because nobody is watching a capture and a +/// dropped script costs the very fidelity the capture exists to measure. +#[cfg(all(feature = "capture", feature = "javascript"))] +const CAPTURE_SCRIPT_DEADLINE: std::time::Duration = std::time::Duration::from_secs(10); + +/// How long one `fetch` or `XMLHttpRequest` from the page may take. +/// +/// Longer than the script deadline, because nothing waits on it: the request is +/// asynchronous and the pump keeps running. It is bounded anyway so a server +/// that never answers cannot hold the capture open past its own watchdog. +#[cfg(all(feature = "capture", feature = "javascript"))] +const CAPTURE_NETWORK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(15); + /// Web APIs the script engine does not provide. /// /// Boa is a JavaScript engine, not a browser: it supplies the language, and @@ -65,6 +80,49 @@ pub(crate) const WEB_API_SHIM: &str = r#" if (typeof globalThis.sessionStorage === 'undefined') { globalThis.sessionStorage = MemoryStorage(); } + /* + * `performance`, and specifically `getEntriesByType`. + * + * This is not a nicety. @solidjs/router's scroll restoration ends its setup + * with + * + * const [nav] = performance.getEntriesByType && performance.getEntriesByType("navigation"); + * + * The guard protects the *call*, not the destructuring: without the method + * the whole expression is `undefined`, and destructuring that throws + * "Cannot destructure 'undefined' value" before the router renders anything. + * Every site built on the router therefore painted a blank page here, which + * reads as the application being broken rather than one absent method. + * + * An empty list is the honest answer: nothing here measures navigation + * timing, and a made-up entry would be worse than none. The router treats an + * absent entry as a fresh navigation, which is what a first load is. + */ + if (typeof globalThis.performance === 'undefined') { + globalThis.performance = {}; + } + if (typeof globalThis.performance.now !== 'function') { + var started = Date.now(); + globalThis.performance.now = function () { + return Date.now() - started; + }; + } + if (typeof globalThis.performance.getEntriesByType !== 'function') { + globalThis.performance.getEntriesByType = function () { + return []; + }; + } + if (typeof globalThis.performance.getEntriesByName !== 'function') { + globalThis.performance.getEntriesByName = function () { + return []; + }; + } + if (typeof globalThis.performance.mark !== 'function') { + globalThis.performance.mark = function () {}; + } + if (typeof globalThis.performance.measure !== 'function') { + globalThis.performance.measure = function () {}; + } if (typeof globalThis.URL === 'undefined') { // Enough of the URL interface for routing: parse, read the parts, and // resolve against a base. Not a WHATWG-conformant implementation. @@ -179,19 +237,63 @@ pub(crate) const WEB_API_SHIM: &str = r#" this.takeRecords = function () { return []; }; }; } + /* + * The viewport size, and the single source of truth for it. + * + * Everything that reports a size reads these two numbers: `screen`, + * `innerWidth`/`innerHeight`, and the dimension branch of `matchMedia`. + * They previously pointed at each other — `screen.width` returned + * `innerWidth || 1440` while an `innerWidth` shim returned `screen.width` + * — which is unbounded recursion the moment both exist, and it took the + * whole shim down with it. + * + * The engine does not expose its real size to script: `innerWidth`, + * `outerWidth` and the client dimensions all read 0, and the layout rect + * comes back zero-width. So this is a stated default rather than a + * measurement, chosen to match the driver's default screenshot size. A page + * asking whether it has room for the desktop layout gets a truthful-looking + * desktop answer instead of the zero that silently forces every responsive + * design into its narrowest branch. + */ + var CHUZZ_VIEWPORT_WIDTH = 1440; + var CHUZZ_VIEWPORT_HEIGHT = 960; if (typeof globalThis.screen === 'undefined') { - // Read from the window rather than invented, so a page branching on screen - // size gets an answer consistent with the one it gets from window.innerWidth. globalThis.screen = { - get width() { return globalThis.innerWidth || 1440; }, - get height() { return globalThis.innerHeight || 960; }, - get availWidth() { return globalThis.innerWidth || 1440; }, - get availHeight() { return globalThis.innerHeight || 960; }, + get width() { return CHUZZ_VIEWPORT_WIDTH; }, + get height() { return CHUZZ_VIEWPORT_HEIGHT; }, + get availWidth() { return CHUZZ_VIEWPORT_WIDTH; }, + get availHeight() { return CHUZZ_VIEWPORT_HEIGHT; }, colorDepth: 24, pixelDepth: 24, orientation: { type: 'landscape-primary', angle: 0 } }; } + if (typeof globalThis.top === 'undefined') { + // Real, and the answer a browser gives: there are no frames here, so a + // document is its own top, parent and self. Frame-busting code compares + // `window.top !== window.self` and gets `false`, which is correct rather + // than convenient. + globalThis.top = globalThis; + globalThis.parent = globalThis; + globalThis.self = globalThis; + globalThis.frames = globalThis; + globalThis.frameElement = null; + } + if (typeof globalThis.scrollX === 'undefined') { + // The document's scroll offset is the engine's and does not reach here, so + // these report the position a page loads at and never move. That is right + // at load, which is when the scripts that read them run, and it is the same + // choice `IntersectionObserver` above makes: a lazy loader reading `scrollY` + // concludes it is at the top of the page and shows what is above the fold. + // A page that binds a scroll handler and recomputes from these will not see + // the view move. Making them true is engine work. + globalThis.scrollX = 0; + globalThis.scrollY = 0; + globalThis.pageXOffset = 0; + globalThis.pageYOffset = 0; + globalThis.scrollTo = function () {}; + globalThis.scrollBy = function () {}; + } if (typeof globalThis.requestIdleCallback === 'undefined') { globalThis.requestIdleCallback = function (callback) { return setTimeout(function () { @@ -297,11 +399,135 @@ pub(crate) const WEB_API_SHIM: &str = r#" } }); } + /* + * Publish the size on the global, for the scripts that read it there. + * + * Assignment can silently fail when the engine already owns the name as a + * read-only accessor, so each one is attempted independently: one refusal + * must not skip the rest, and none of them may throw out of the shim. + */ + (function () { + var assign = function (name, value) { + try { + if (globalThis[name]) { return; } + /* + * `defineProperty`, not assignment. The engine owns these names as + * read-only accessors, so `globalThis.innerWidth = 1440` fails + * silently and the page keeps reading 0. Redefining the property is + * what actually takes. The value is a plain number, never a getter: + * a getter that read another shimmed size recursed without bound. + */ + Object.defineProperty(globalThis, name, { + configurable: true, + enumerable: true, + writable: true, + value: value + }); + } catch (error) {} + }; + assign('innerWidth', CHUZZ_VIEWPORT_WIDTH); + assign('innerHeight', CHUZZ_VIEWPORT_HEIGHT); + assign('outerWidth', CHUZZ_VIEWPORT_WIDTH); + assign('outerHeight', CHUZZ_VIEWPORT_HEIGHT); + })(); + /* + * `location.origin`, and `location.host` with its port. + * + * The engine populates href, protocol, hostname, port and pathname, but not + * `origin`, and it leaves the port off `host`. Both are load-bearing: + * `new URL(path, location.origin)` with an undefined base returns the + * relative input unchanged, so the caller passes a bare "/x.json" to fetch, + * which rejects with "invalid URL". + * + * That is not a small gap. A page whose bootstrap resolves its own asset + * URLs that way — hiding the body until it finishes, as a FOUC guard — + * fails inside an async handler, never unhides, and renders a blank white + * page with nothing in the console to say why. + */ + (function () { + var loc = globalThis.location; + if (!loc) { return; } + + var authority = function () { + var host = loc.hostname || ''; + if (!host) { return ''; } + // A port belongs in both `host` and `origin`; only the scheme's default + // is omitted, which is what a browser reports. + var port = loc.port ? String(loc.port) : ''; + var isDefault = (loc.protocol === 'http:' && port === '80') || + (loc.protocol === 'https:' && port === '443'); + return host + (port && !isDefault ? ':' + port : ''); + }; + + var define = function (name, value) { + if (!value) { return; } + try { + Object.defineProperty(loc, name, { + configurable: true, + enumerable: true, + writable: true, + value: value + }); + } catch (error) {} + }; + + var host = authority(); + if (!loc.origin && loc.protocol && host) { + define('origin', loc.protocol + '//' + host); + } + // Only widen `host` when the port is genuinely missing from it. + if (host && loc.host !== host) { + define('host', host); + } + })(); if (typeof globalThis.matchMedia === 'undefined') { + /* + * Answering false to everything is not neutral, it is wrong, and it is + * wrong in a way that shows. + * + * A site that themes itself from `prefers-color-scheme: dark` reads false + * and renders its light palette, so every page came out pale against this + * browser's dark interface while the same build elsewhere was dark. The + * same applies to `no-preference` queries, which are true by definition + * when no preference is expressed. + * + * So: report dark, matching this browser's own interface, and answer the + * negative and no-preference forms consistently with it. Anything not + * recognised still falls through to false rather than guessing. + */ globalThis.matchMedia = function (query) { + var text = String(query).toLowerCase(); + var matches = false; + if (text.indexOf('prefers-color-scheme') !== -1) { + matches = text.indexOf('dark') !== -1; + } else if (text.indexOf('no-preference') !== -1) { + matches = true; + } else if (text.indexOf('prefers-reduced-motion') !== -1) { + matches = false; + } else if (text.indexOf('pointer') !== -1) { + matches = text.indexOf('fine') !== -1; + } else if (text.indexOf('hover') !== -1) { + matches = text.indexOf('none') === -1; + } else { + /* + * Dimension queries, which are the ones a responsive layout actually + * asks. Answering false to every one of them puts every site on its + * narrowest branch: a desktop window renders the phone layout, and the + * page looks broken rather than small. + */ + var dimension = /\((min|max)-(width|height):\s*([0-9.]+)(px|em|rem)?\)/.exec(text); + if (dimension) { + var bound = parseFloat(dimension[3]); + if (dimension[4] === 'em' || dimension[4] === 'rem') bound = bound * 16; + var actual = dimension[2] === 'width' + ? CHUZZ_VIEWPORT_WIDTH + : CHUZZ_VIEWPORT_HEIGHT; + matches = dimension[1] === 'min' ? actual >= bound : actual <= bound; + } + } return { media: String(query), - matches: false, + matches: matches, addListener: function () {}, removeListener: function () {}, addEventListener: function () {}, @@ -310,25 +536,501 @@ pub(crate) const WEB_API_SHIM: &str = r#" }; }; } + // `String.prototype.substr`. Annex B, and the engine does not have it. + // + // This one is not on the corpus's missing-globals list and cannot be: the + // report counts names a page looked up and did not find, and a missing method + // on an existing prototype is a `TypeError: not a callable function` instead, + // which is a different error class counted nowhere. It was found by writing + // `unescape` in terms of it. Real, not a stub; the negative `start` and + // omitted `length` cases are the ones old code actually uses. + // + // Defined rather than assigned, because a plain assignment is enumerable and + // this is a prototype: `for (var key in 'abc')` would start yielding 'substr' + // alongside the indices, on every string in the page. + if (typeof String.prototype.substr !== 'function') { + Object.defineProperty(String.prototype, 'substr', { + configurable: true, + writable: true, + enumerable: false, + value: function (start, length) { + var text = String(this); + var from = start === undefined ? 0 : Math.trunc(Number(start)) || 0; + if (from < 0) { from = Math.max(text.length + from, 0); } + if (length === undefined) { return text.slice(from); } + var count = Math.trunc(Number(length)) || 0; + if (count <= 0) { return ''; } + return text.slice(from, from + count); + } + }); + } + // Annex B string escaping. Real implementations, not stubs: both are pure + // string transforms with a specification, so there is nothing to fake. + if (typeof globalThis.unescape === 'undefined') { + globalThis.unescape = function (input) { + var text = String(input); + var out = ''; + var index = 0; + while (index < text.length) { + var character = text.charAt(index); + if (character === '%') { + var wide = text.slice(index + 2, index + 6); + if (text.charAt(index + 1) === 'u' && /^[0-9a-fA-F]{4}$/.test(wide)) { + out += String.fromCharCode(parseInt(wide, 16)); + index += 6; + continue; + } + var narrow = text.slice(index + 1, index + 3); + if (/^[0-9a-fA-F]{2}$/.test(narrow)) { + out += String.fromCharCode(parseInt(narrow, 16)); + index += 3; + continue; + } + } + out += character; + index += 1; + } + return out; + }; + } + if (typeof globalThis.escape === 'undefined') { + globalThis.escape = function (input) { + var text = String(input); + // The unreserved set Annex B names, verbatim. + var keep = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@*_+-./'; + var out = ''; + for (var index = 0; index < text.length; index++) { + var character = text.charAt(index); + if (keep.indexOf(character) >= 0) { + out += character; + continue; + } + var code = text.charCodeAt(index); + if (code < 256) { + out += '%' + (code < 16 ? '0' : '') + code.toString(16).toUpperCase(); + } else { + var hex = code.toString(16).toUpperCase(); + while (hex.length < 4) { hex = '0' + hex; } + out += '%u' + hex; + } + } + return out; + }; + } + if (typeof globalThis.atob === 'undefined') { + // Real base64, both ways, and the largest gap the corpus had not yet + // reported: once `String.prototype.substr` above let those scripts run past + // their first TypeError, `atob` became the next wall on 4 of the 12 pages + // re-captured. A missing global only gets counted once something reaches + // it, which is why the fix for one defect is what surfaces the next. + var BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + globalThis.atob = function (input) { + // Whitespace is allowed anywhere in the input and padding is optional, + // which is what a page decoding a header or a data URL relies on. + var text = String(input).replace(/[ \t\n\f\r]/g, '').replace(/=+$/, ''); + if (text.length % 4 === 1) { + throw new globalThis.DOMException('invalid base64', 'InvalidCharacterError'); + } + var out = ''; + var buffer = 0; + var bits = 0; + for (var index = 0; index < text.length; index++) { + var digit = BASE64.indexOf(text.charAt(index)); + if (digit < 0) { + throw new globalThis.DOMException('invalid base64', 'InvalidCharacterError'); + } + buffer = (buffer << 6) | digit; + bits += 6; + if (bits >= 8) { + bits -= 8; + out += String.fromCharCode((buffer >> bits) & 0xff); + // Masked back down, or the accumulator keeps every group it has seen + // and overflows the 32 bits the shift operators work in. + buffer &= (1 << bits) - 1; + } + } + return out; + }; + globalThis.btoa = function (input) { + var text = String(input); + var out = ''; + for (var index = 0; index < text.length; index += 3) { + var first = text.charCodeAt(index); + var second = text.charCodeAt(index + 1); + var third = text.charCodeAt(index + 2); + // btoa is defined over a byte string; anything above 255 is the caller + // passing text it should have encoded first, and throwing says so. + if (first > 0xff || (second > 0xff) || (third > 0xff)) { + throw new globalThis.DOMException('not a byte string', 'InvalidCharacterError'); + } + var chunk = (first << 16) | ((second || 0) << 8) | (third || 0); + out += BASE64.charAt((chunk >> 18) & 0x3f) + BASE64.charAt((chunk >> 12) & 0x3f); + out += isNaN(second) ? '=' : BASE64.charAt((chunk >> 6) & 0x3f); + out += isNaN(third) ? '=' : BASE64.charAt(chunk & 0x3f); + } + return out; + }; + } + if (typeof globalThis.DOMException === 'undefined') { + // Real. A DOMException is a name, a message and a legacy code, and the + // reason pages reach for it is `error.name === 'AbortError'` rather than + // anything the platform has to provide. Building it here also gives the + // abort machinery below the type a browser would actually throw. + var LEGACY_CODES = { + IndexSizeError: 1, HierarchyRequestError: 3, WrongDocumentError: 4, + InvalidCharacterError: 5, NoModificationAllowedError: 7, NotFoundError: 8, + NotSupportedError: 9, InUseAttributeError: 10, InvalidStateError: 11, + SyntaxError: 12, InvalidModificationError: 13, NamespaceError: 14, + InvalidAccessError: 15, TypeMismatchError: 17, SecurityError: 18, + NetworkError: 19, AbortError: 20, URLMismatchError: 21, + QuotaExceededError: 22, TimeoutError: 23, InvalidNodeTypeError: 24, + DataCloneError: 25 + }; + globalThis.DOMException = function (message, name) { + this.message = message === undefined ? '' : String(message); + this.name = name === undefined ? 'Error' : String(name); + this.code = LEGACY_CODES[this.name] || 0; + // Not inherited from Error, because Boa's Error does not take to being + // subclassed from a plain constructor. A stack is attached instead, since + // that is the one property a reporter reads off a caught exception. + this.stack = this.name + ': ' + this.message; + }; + globalThis.DOMException.prototype.toString = function () { + return this.name + ': ' + this.message; + }; + } + if (typeof globalThis.TextEncoder === 'undefined') { + // Real UTF-8, including surrogate pairs, because the callers that reach for + // this are hashing, signing or framing bytes. An encoder that got the + // multi-byte cases wrong would hand them a plausible array of the wrong + // length, and they would fail somewhere else entirely. + globalThis.TextEncoder = function () {}; + Object.defineProperty(globalThis.TextEncoder.prototype, 'encoding', { + configurable: true, + get: function () { return 'utf-8'; } + }); + globalThis.TextEncoder.prototype.encode = function (input) { + var text = input === undefined ? '' : String(input); + var bytes = []; + for (var index = 0; index < text.length; index++) { + var code = text.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + // A high surrogate followed by its low half is one code point; a lone + // one is not representable, and the spec says to emit U+FFFD. + var low = index + 1 < text.length ? text.charCodeAt(index + 1) : 0; + if (low >= 0xdc00 && low <= 0xdfff) { + code = 0x10000 + ((code - 0xd800) * 0x400) + (low - 0xdc00); + index += 1; + } else { + code = 0xfffd; + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + code = 0xfffd; + } + if (code < 0x80) { + bytes.push(code); + } else if (code < 0x800) { + bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); + } else if (code < 0x10000) { + bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); + } else { + bytes.push( + 0xf0 | (code >> 18), + 0x80 | ((code >> 12) & 0x3f), + 0x80 | ((code >> 6) & 0x3f), + 0x80 | (code & 0x3f) + ); + } + } + return typeof Uint8Array === 'function' ? new Uint8Array(bytes) : bytes; + }; + globalThis.TextEncoder.prototype.encodeInto = function (input, destination) { + var text = input === undefined ? '' : String(input); + var encoded = this.encode(text); + var written = Math.min(encoded.length, destination ? destination.length : 0); + for (var index = 0; index < written; index++) { destination[index] = encoded[index]; } + // `read` counts the UTF-16 units consumed, and is only exact when the + // whole string fitted: stopping part way would need the encoder to encode + // incrementally, which this one does not. + return { read: written === encoded.length ? text.length : 0, written: written }; + }; + } + if (typeof globalThis.TextDecoder === 'undefined') { + globalThis.TextDecoder = function (label) { + this._encoding = label === undefined ? 'utf-8' : String(label).toLowerCase(); + }; + Object.defineProperty(globalThis.TextDecoder.prototype, 'encoding', { + configurable: true, + get: function () { return this._encoding || 'utf-8'; } + }); + globalThis.TextDecoder.prototype.decode = function (input) { + if (input === undefined || input === null) { return ''; } + var bytes = input; + // Accept an ArrayBuffer or any view over one, which is what a caller + // holding the result of a slice or a DataView actually has. + if (typeof ArrayBuffer === 'function' && input instanceof ArrayBuffer) { + bytes = new Uint8Array(input); + } else if (typeof Uint8Array === 'function' && !(input instanceof Uint8Array) + && input.buffer && typeof input.byteOffset === 'number') { + bytes = new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } + var out = ''; + var index = 0; + var length = bytes.length; + while (index < length) { + var lead = bytes[index++] & 0xff; + var code; + var trailing; + if (lead < 0x80) { out += String.fromCharCode(lead); continue; } + // 0xc0 and 0xc1 are excluded here rather than checked for afterwards: + // they can only ever start an overlong two-byte sequence. + else if (lead >= 0xc2 && lead <= 0xdf) { code = lead & 0x1f; trailing = 1; } + else if (lead >= 0xe0 && lead <= 0xef) { code = lead & 0x0f; trailing = 2; } + else if (lead >= 0xf0 && lead <= 0xf4) { code = lead & 0x07; trailing = 3; } + else { out += '\uFFFD'; continue; } + var complete = true; + for (var step = 0; step < trailing; step++) { + var next = index < length ? bytes[index] & 0xff : -1; + if (next < 0x80 || next > 0xbf) { complete = false; break; } + code = (code * 64) + (next & 0x3f); + index += 1; + } + if (!complete + || code > 0x10ffff + || (code >= 0xd800 && code <= 0xdfff) + || (trailing === 2 && code < 0x800) + || (trailing === 3 && code < 0x10000)) { + out += '\uFFFD'; + continue; + } + if (code <= 0xffff) { + out += String.fromCharCode(code); + } else { + code -= 0x10000; + out += String.fromCharCode(0xd800 + (code >> 10), 0xdc00 + (code & 0x3ff)); + } + } + return out; + }; + } + if (typeof globalThis.AbortController === 'undefined') { + // Real, not a stub: the whole of AbortController is bookkeeping over a flag + // and a listener list, and there is no engine support to wait for. The half + // that is missing is on the consumer side, where a request already in + // flight cannot be cancelled at the socket. The page's own `signal.aborted` + // checks, `throwIfAborted`, and its abort handlers all behave. + var abortReason = function (reason) { + if (reason !== undefined) { return reason; } + return new globalThis.DOMException('signal is aborted without reason', 'AbortError'); + }; + var AbortSignal = function () { + this.aborted = false; + this.reason = undefined; + this.onabort = null; + this._handlers = []; + }; + AbortSignal.prototype.addEventListener = function (type, handler) { + if (type === 'abort' && typeof handler === 'function') { this._handlers.push(handler); } + }; + AbortSignal.prototype.removeEventListener = function (type, handler) { + if (type !== 'abort') { return; } + var at = this._handlers.indexOf(handler); + if (at >= 0) { this._handlers.splice(at, 1); } + }; + AbortSignal.prototype.dispatchEvent = function () { return false; }; + AbortSignal.prototype.throwIfAborted = function () { + if (this.aborted) { throw this.reason; } + }; + var fireAbort = function (signal, reason) { + if (signal.aborted) { return; } + signal.aborted = true; + signal.reason = abortReason(reason); + var event = { type: 'abort', target: signal }; + if (typeof signal.onabort === 'function') { + try { signal.onabort(event); } catch (error) { /* the page's handler threw */ } + } + var handlers = signal._handlers.slice(); + signal._handlers.length = 0; + for (var index = 0; index < handlers.length; index++) { + try { handlers[index](event); } catch (error) { /* likewise */ } + } + }; + AbortSignal.abort = function (reason) { + var signal = new AbortSignal(); + signal.aborted = true; + signal.reason = abortReason(reason); + return signal; + }; + AbortSignal.timeout = function (milliseconds) { + var signal = new AbortSignal(); + setTimeout(function () { + fireAbort(signal, new globalThis.DOMException('signal timed out', 'TimeoutError')); + }, milliseconds); + return signal; + }; + AbortSignal.any = function (signals) { + var combined = new AbortSignal(); + var list = signals || []; + for (var index = 0; index < list.length; index++) { + if (list[index] && list[index].aborted) { + combined.aborted = true; + combined.reason = list[index].reason; + return combined; + } + } + for (var each = 0; each < list.length; each++) { + if (!list[each] || typeof list[each].addEventListener !== 'function') { continue; } + (function (source) { + source.addEventListener('abort', function () { fireAbort(combined, source.reason); }); + })(list[each]); + } + return combined; + }; + globalThis.AbortSignal = AbortSignal; + globalThis.AbortController = function () { this.signal = new AbortSignal(); }; + globalThis.AbortController.prototype.abort = function (reason) { + fireAbort(this.signal, reason); + }; + } + if (typeof globalThis.ResizeObserver === 'undefined') { + // A stub, and deliberately silent rather than firing once the way + // `IntersectionObserver` above does. The difference is what an invented + // entry would have to say: visibility has an answer that is right for most + // of a page ("yes"), and a size does not. Nothing here can measure a box + // from JavaScript, so the only entry this could deliver carries a zero + // `contentRect`, and a grid or carousel that divides by that width computes + // zero columns and renders nothing. Never firing leaves such a component on + // whatever it renders before it has measured, which is the better of the + // two wrong answers. Backing this with real box data is engine work. + globalThis.ResizeObserver = function (callback) { + this.callback = callback; + this.observe = function () {}; + this.unobserve = function () {}; + this.disconnect = function () {}; + }; + } + if (typeof globalThis.Image === 'undefined') { + // A stub. It reports every image as loaded without fetching anything, so a + // preloader, which is what most constructed `Image`s are, runs its + // callback and the page proceeds. Code that waits for the load and then + // reads pixels or natural dimensions gets nothing useful, and the zero + // dimensions below are left honest rather than invented for that reason. + // Images the *document* references are fetched and painted by the engine; + // this is only the JavaScript constructor. + globalThis.Image = function (width, height) { + var image = this; + var handlers = []; + var source = ''; + this.width = width === undefined ? 0 : width; + this.height = height === undefined ? 0 : height; + this.naturalWidth = 0; + this.naturalHeight = 0; + this.complete = false; + this.onload = null; + this.onerror = null; + this.crossOrigin = null; + this.decoding = 'auto'; + this.loading = 'eager'; + this.addEventListener = function (type, handler) { + if (typeof handler === 'function') { handlers.push([String(type), handler]); } + }; + this.removeEventListener = function (type, handler) { + for (var index = handlers.length - 1; index >= 0; index--) { + if (handlers[index][0] === String(type) && handlers[index][1] === handler) { + handlers.splice(index, 1); + } + } + }; + this.dispatchEvent = function () { return false; }; + this.decode = function () { return Promise.resolve(); }; + Object.defineProperty(this, 'src', { + configurable: true, + get: function () { return source; }, + set: function (value) { + source = String(value); + // Asynchronously, the way a real load completes. Firing during the + // assignment would reach a handler the caller attaches on the next + // line, which is the ordinary way this is written. + setTimeout(function () { + image.complete = true; + var event = { type: 'load', target: image }; + if (typeof image.onload === 'function') { + try { image.onload(event); } catch (error) { /* the page's handler threw */ } + } + var listeners = handlers.slice(); + for (var index = 0; index < listeners.length; index++) { + if (listeners[index][0] !== 'load') { continue; } + try { listeners[index][1](event); } catch (error) { /* likewise */ } + } + }, 0); + } + }); + }; + } + if (typeof globalThis.Path2D === 'undefined') { + // The path is really accumulated; what is missing is anything that reads + // it. A page constructing a Path2D is about to hand it to a canvas context, + // and that is the part the engine does not have, so this keeps the + // construction from throwing and no more. + globalThis.Path2D = function (path) { + this.commands = path && path.commands ? path.commands.slice() : []; + var record = function (name) { + return function () { + this.commands.push([name].concat(Array.prototype.slice.call(arguments))); + }; + }; + this.addPath = function (other) { + if (other && other.commands) { this.commands = this.commands.concat(other.commands); } + }; + this.closePath = record('closePath'); + this.moveTo = record('moveTo'); + this.lineTo = record('lineTo'); + this.bezierCurveTo = record('bezierCurveTo'); + this.quadraticCurveTo = record('quadraticCurveTo'); + this.arc = record('arc'); + this.arcTo = record('arcTo'); + this.ellipse = record('ellipse'); + this.rect = record('rect'); + this.roundRect = record('roundRect'); + }; + } + if (typeof globalThis.ShadowRoot === 'undefined') { + // Declared so `node instanceof ShadowRoot` and `x.constructor === ShadowRoot` + // are answerable, and nothing is an instance of it. That is the truthful + // answer here: this engine builds no shadow trees, so every node really is + // in the light DOM, and a test that asks gets "no" instead of a ReferenceError. + globalThis.ShadowRoot = function () {}; + } + // Deliberately absent, so nobody adds them from the corpus report alone: + // + // - `getComputedStyle`. A stub answering '' for every property is worse than + // the ReferenceError it replaces. Today the script throws and stops, which + // is visible; with a lying stub it continues, measures nothing, and lays the + // page out wrongly, which looks like an engine bug. It needs real style data + // from the document, which is engine work rather than a shim. + // - `ReadableStream`. A page reaching for it wants incremental delivery, and a + // stub can only hand over the whole body at once or nothing. Both read as a + // working stream to the code and neither is one. + // - The DOM interface constructors the corpus also reported missing: + // `NodeList`, `DocumentFragment`, `CharacterData`, `KeyboardEvent`, + // `HTMLVideoElement`. `ShadowRoot` above is declared precisely because + // nothing in this engine is one, so answering `false` to `instanceof` is + // true. These are the opposite case: the document really does contain node + // lists and fragments, so an empty constructor would answer `false` about + // objects that genuinely are instances, and a branch that meant to take the + // DOM path would silently take the other one. They belong with the engine's + // DOM bindings, next to the prototypes they have to be related to. + // - `Intl`. Faking `NumberFormat` and `DateTimeFormat` as `String(value)` + // would keep a script alive at the cost of rendering unformatted numbers + // and raw date strings as though they were the page's own output, and the + // locale data behind a real one is not a shim. + // - `ActiveXObject`, reported by one site. No browser has it, and a page that + // reaches for it without a `typeof` guard throws in Chrome too. Absent is + // the correct answer and the report is not a defect of ours. + // - `WebAssembly`, `define` and `require` are module and engine support, + // which is not something JavaScript in this string can supply. })(); "#; -/// Serves the sources prefetched above, falling back to the default fetcher -/// for `file:` and `data:` URLs. -#[cfg(all(feature = "capture", feature = "javascript"))] -struct PrefetchedScripts { - scripts: HashMap, -} - -#[cfg(all(feature = "capture", feature = "javascript"))] -impl blitz_script::ScriptFetcher for PrefetchedScripts { - fn fetch(&self, url: &Url) -> Result { - if let Some(source) = self.scripts.get(url) { - return Ok(source.clone()); - } - blitz_script::DefaultScriptFetcher.fetch(url) - } -} - /// Load a page outside the browser, for headless capture. /// /// Shares the fetch, decompression, script execution and web-API shim with the @@ -403,8 +1105,17 @@ pub async fn load_for_capture( scripts.insert(url, decode_body(&bytes)); } } - let mut document = document.with_fetcher(PrefetchedScripts { scripts }); + let mut document = document.with_fetcher(crate::script_fetch::PageScripts::new( + scripts, + Arc::clone(&net_provider), + CAPTURE_SCRIPT_DEADLINE, + )); document.eval(WEB_API_SHIM); + crate::net_bridge::install( + &mut document, + Arc::clone(&net_provider), + CAPTURE_NETWORK_DEADLINE, + ); document.execute_scripts(); // Pump the script runtime until the page has built its DOM, then keep // pumping for a few more passes. @@ -473,3 +1184,320 @@ impl CapturedDocument { } } } + +/// The shim is a three-hundred-line JavaScript string in a Rust file, and +/// nothing else in the build parses it. A syntax error in it is not a compile +/// error: it is a page that renders as if the shim were absent, on every site. +/// These evaluate it the way a page does and read the answers back. +#[cfg(all(test, feature = "javascript"))] +mod tests { + use super::WEB_API_SHIM; + + fn shimmed() -> blitz_script::ScriptDocument { + let mut document = blitz_script::ScriptDocument::from_html( + "", + blitz_dom::DocumentConfig::default(), + ); + document.eval(WEB_API_SHIM); + document + } + + fn value(document: &mut blitz_script::ScriptDocument, script: &str) -> serde_json::Value { + document + .eval_json(script) + .unwrap_or_else(|error| panic!("evaluating `{script}` failed: {error:?}")) + } + + /// Run timers until a probe answers, or give up. + /// + /// `setTimeout` fires from the document's own polling, so a shim that + /// defers its callback has nothing to fire it in a test that only evals. + fn pump_for(document: &mut blitz_script::ScriptDocument, probe: &str) -> serde_json::Value { + use blitz_dom::Document as _; + for _ in 0..100 { + document.poll(None); + let seen = value(document, probe); + if !seen.is_null() { + return seen; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + serde_json::Value::Null + } + + /// The whole string parses and every global it promises is installed. + /// + /// One bad token anywhere silently costs all of them, so this asserts the + /// list rather than each name where it is tested. + #[test] + fn the_shim_installs_every_global_it_claims() { + let mut document = shimmed(); + for name in [ + "localStorage", + "sessionStorage", + "URLSearchParams", + "MutationObserver", + "IntersectionObserver", + "requestIdleCallback", + "matchMedia", + "unescape", + "escape", + "TextEncoder", + "TextDecoder", + "AbortController", + "AbortSignal", + "ResizeObserver", + "Image", + "Path2D", + "ShadowRoot", + "DOMException", + "atob", + "btoa", + "top", + "scrollX", + ] { + assert_ne!( + value(&mut document, &format!("typeof globalThis.{name}")), + serde_json::json!("undefined"), + "the shim should define {name}" + ); + } + } + + /// ASCII, two-byte, three-byte and a surrogate pair, both ways. + /// + /// The callers that reach for `TextEncoder` are hashing or framing bytes, so + /// a wrong length is worse than a missing constructor: it fails somewhere + /// else, later, as a bad digest. + #[test] + fn text_encoding_is_real_utf8() { + let mut document = shimmed(); + assert_eq!( + value( + &mut document, + "Array.from(new TextEncoder().encode('A\u{00e9}\u{20ac}\u{1f600}'))" + ), + serde_json::json!([0x41, 0xc3, 0xa9, 0xe2, 0x82, 0xac, 0xf0, 0x9f, 0x98, 0x80]), + "one ASCII, one two-byte, one three-byte and one four-byte code point" + ); + assert_eq!( + value( + &mut document, + "new TextDecoder().decode(new TextEncoder().encode('A\u{00e9}\u{20ac}\u{1f600}'))" + ), + serde_json::json!("A\u{00e9}\u{20ac}\u{1f600}"), + "decoding what the encoder produced returns the original string" + ); + assert_eq!( + value( + &mut document, + "new TextDecoder().decode(new Uint8Array([0xc0, 0x80, 0x41]))" + ), + serde_json::json!("\u{fffd}\u{fffd}A"), + "an overlong sequence is replaced rather than decoded" + ); + } + + /// Annex B escaping, which is a pure string transform with a specification. + #[test] + fn escape_and_unescape_round_trip() { + let mut document = shimmed(); + assert_eq!( + value(&mut document, "escape('a b/\u{00e9}\u{20ac}')"), + serde_json::json!("a%20b/%E9%u20AC"), + "space and Latin-1 as %XX, above 255 as %uXXXX, and `/` left alone" + ); + assert_eq!( + value(&mut document, "unescape(escape('a b/\u{00e9}\u{20ac}'))"), + serde_json::json!("a b/\u{00e9}\u{20ac}") + ); + } + + /// `substr` is Annex B, the engine lacks it, and old code still calls it. + /// + /// A missing prototype method reads as `TypeError: not a callable function` + /// rather than a missing global, which is why no corpus count found it. + #[test] + fn substr_handles_the_cases_old_code_uses() { + let mut document = shimmed(); + assert_eq!( + value( + &mut document, + "['abcdef'.substr(2), 'abcdef'.substr(1, 3), 'abcdef'.substr(-2), 'abcdef'.substr(1, 0)]" + ), + serde_json::json!(["cdef", "bcd", "ef", ""]) + ); + assert_eq!( + value( + &mut document, + "(function () { var keys = []; for (var key in 'ab') { keys.push(key); } return keys; })()" + ), + serde_json::json!(["0", "1"]), + "a prototype addition must not become enumerable on every string" + ); + } + + /// A controller aborts its signal, and the abort is observable three ways. + #[test] + fn aborting_a_controller_notifies_its_signal() { + let mut document = shimmed(); + assert_eq!( + value( + &mut document, + "(function () { + var controller = new AbortController(); + var seen = 0; + controller.signal.addEventListener('abort', function () { seen++; }); + controller.signal.onabort = function () { seen++; }; + var before = controller.signal.aborted; + controller.abort(); + var threw = false; + try { controller.signal.throwIfAborted(); } catch (error) { threw = error.name; } + return [before, controller.signal.aborted, seen, threw]; + })()" + ), + serde_json::json!([false, true, 2, "AbortError"]) + ); + assert_eq!( + value(&mut document, "AbortSignal.abort('gone').reason"), + serde_json::json!("gone"), + "an explicit reason is kept rather than replaced with an AbortError" + ); + } + + /// Setting `src` reports a load, asynchronously, to both handler styles. + /// + /// Asynchronously matters: a preloader attaches `onload` on the line after + /// the assignment, and a callback fired during the setter would miss it. + #[test] + fn an_image_reports_a_load_after_its_src_is_set() { + let mut document = shimmed(); + document.eval( + "globalThis.__loaded = null; + var image = new Image(); + var seen = []; + image.addEventListener('load', function () { seen.push('listener'); }); + image.onload = function () { seen.push('onload'); globalThis.__loaded = seen; }; + globalThis.__during = image.complete; + image.src = 'https://example.invalid/pixel.png';", + ); + assert_eq!( + value(&mut document, "globalThis.__during"), + serde_json::json!(false), + "the load must not be reported from inside the setter" + ); + assert_eq!( + pump_for(&mut document, "globalThis.__loaded"), + serde_json::json!(["onload", "listener"]), + "both handler styles run once the timer fires" + ); + } + + /// Base64 both ways, including the unpadded and whitespaced inputs pages send. + /// + /// This is the one addition here the corpus did not ask for and measurement + /// did: `substr` let four of the twelve re-captured pages run past their + /// first TypeError, and `atob` was the wall they hit next. + #[test] + fn base64_round_trips() { + let mut document = shimmed(); + assert_eq!( + value(&mut document, "btoa('any carnal pleasure.')"), + serde_json::json!("YW55IGNhcm5hbCBwbGVhc3VyZS4=") + ); + assert_eq!( + value(&mut document, "atob('YW55IGNhcm5hbCBwbGVhc3VyZS4=')"), + serde_json::json!("any carnal pleasure.") + ); + assert_eq!( + value( + &mut document, + "[btoa('a'), btoa('ab'), btoa('abc'), atob('YQ'), atob('YWJj')]" + ), + serde_json::json!(["YQ==", "YWI=", "YWJj", "a", "abc"]), + "every padding length, and an unpadded input decoding anyway" + ); + assert_eq!( + value(&mut document, "atob(' YW Jj\\n')"), + serde_json::json!("abc"), + "whitespace anywhere is stripped rather than rejected" + ); + assert_eq!( + value( + &mut document, + "(function () { try { atob('!'); } catch (error) { return error.name; } return 'no throw'; })()" + ), + serde_json::json!("InvalidCharacterError") + ); + } + + /// A DOMException carries the name a page branches on, and a legacy code. + #[test] + fn dom_exception_is_the_type_a_browser_throws() { + let mut document = shimmed(); + assert_eq!( + value( + &mut document, + "(function () { + var error = new DOMException('nope', 'AbortError'); + return [error.name, error.message, error.code, String(error)]; + })()" + ), + serde_json::json!(["AbortError", "nope", 20, "AbortError: nope"]) + ); + assert_eq!( + value( + &mut document, + "(function () { + var controller = new AbortController(); + controller.abort(); + return controller.signal.reason instanceof DOMException; + })()" + ), + serde_json::json!(true), + "an abort with no reason throws what a browser throws" + ); + } + + /// A document with no frames is its own top, which is the true answer. + #[test] + fn the_window_is_its_own_top() { + let mut document = shimmed(); + assert_eq!( + value( + &mut document, + "[globalThis.top === globalThis.self, globalThis.parent === globalThis, globalThis.frameElement]" + ), + serde_json::json!([true, true, serde_json::Value::Null]), + "frame-busting code must not conclude it is framed" + ); + } + + /// The omissions are deliberate, and this is the record of that. + /// + /// Both are on the corpus's missing-globals list, and both are cheap to + /// stub and wrong to stub: `getComputedStyle` returning `''` for every + /// property turns a visible error into a silently mislaid-out page, and a + /// `ReadableStream` that cannot stream reads as one to the code using it. + /// A future change that adds either should be a change that backs it with + /// real data, and should delete this test rather than edit it. + #[test] + fn the_lying_stubs_are_left_out() { + let mut document = shimmed(); + for name in [ + "getComputedStyle", + "ReadableStream", + "NodeList", + "DocumentFragment", + "CharacterData", + "Intl", + "ActiveXObject", + ] { + assert_eq!( + value(&mut document, &format!("typeof globalThis.{name}")), + serde_json::json!("undefined"), + "{name} is deliberately not shimmed" + ); + } + } +} diff --git a/apps/chuzz/src/net_bridge.rs b/apps/chuzz/src/net_bridge.rs new file mode 100644 index 0000000..34c8a09 --- /dev/null +++ b/apps/chuzz/src/net_bridge.rs @@ -0,0 +1,531 @@ +//! `fetch` and `XMLHttpRequest`, built out of what the script engine already +//! exposes. +//! +//! Most of the web fetches its own content. A page that renders server-side is +//! the exception now, so an engine without these does not render a slightly +//! incomplete page: it renders the shell and stops. Measured over a +//! hundred-site corpus, `XMLHttpRequest` was missing on 6 sites and `fetch` on +//! 4 by name, and the pages laying out at 37% and 44% of a reference browser's +//! height are the same story counted a different way. +//! +//! `blitz-script` has no way to register a host function, so this is built from +//! the three things it does expose: +//! +//! - `window.ipc.postMessage(text)`, a string channel from JavaScript to here; +//! - `eval`, the same channel in reverse; +//! - `add_poll_hook`, work run on the document thread during polling. +//! +//! JavaScript posts a request and parks a promise. The handler here spawns the +//! real fetch on the page's own provider, so it obeys the same per-origin +//! connection cap as every other load. When it finishes, the result goes on a +//! queue, and the next poll hands it back by evaluating a call to the resolver +//! the shim installed. Nothing blocks: unlike a `