From f4790e9fac29f6d51173dab65c4528e208c24d64 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:22:00 -0700 Subject: [PATCH] Fix blank mobile terminal on Android devices with outdated WebViews or blocked CDN (#7186) * fix(mobile): bundle terminal engine and show load errors instead of a blank pane The mobile terminal WebView loaded xterm.js from cdn.jsdelivr.net at runtime; old WebViews (< Chrome 85) fail to parse the modern bundle and blocked-CDN networks fail to fetch it, and the resulting error was silently dropped, leaving the pane permanently blank (#7030). Bundle the engine into the app via exact-pinned npm deps + a postinstall esbuild step (chrome74 target, guarded WeakRef/structuredClone/ replaceChildren shims) emitting a gitignored generated module, inline it into the terminal document, and surface fatal engine failures as a visible overlay with diagnostics and a Reload wired into the existing resubscribe path. Non-fatal errors log without covering a live terminal. Co-authored-by: Orca * fix(mobile): add a native watchdog so a dead terminal document can't stay silently blank CodeRabbit round: if the webview document dies before the glue can post anything (or the RN message bridge never comes up), no error message and no native handler fires. Arm a 15s foreground-gated watchdog per document generation that paints the fatal overlay when web-ready never arrives; first fatal diagnostics win over later cascades. Extract the watchdog and the public contract types to keep TerminalWebView under the line cap, and document the SVG xmlns percent-encoding transform. Co-authored-by: Orca * test(mobile): unmount TerminalWebView renderers so watchdog timers can't leak across tests Co-authored-by: Orca --------- Co-authored-by: Orca --- mobile/.gitignore | 1 + mobile/.oxlintrc.json | 1 + mobile/package.json | 6 + mobile/pnpm-lock.yaml | 364 +++++++++++++++--- .../scripts/build-terminal-webview-engine.mjs | 108 ++++++ mobile/src/terminal/TerminalWebView.tsx | 170 ++++---- .../src/terminal/terminal-webview-contract.ts | 64 +++ .../terminal-webview-engine-error-state.tsx | 130 +++++++ .../terminal-webview-engine-error.test.ts | 178 +++++++++ .../terminal/terminal-webview-engine.test.ts | 109 ++++++ .../terminal/terminal-webview-frame-styles.ts | 13 + mobile/src/terminal/terminal-webview-html.ts | 93 ++++- .../terminal-webview-ready-watchdog.ts | 48 +++ .../terminal-webview-tap-routing.test.ts | 2 +- .../terminal-webview-text-zoom.test.ts | 11 +- 15 files changed, 1137 insertions(+), 161 deletions(-) create mode 100644 mobile/scripts/build-terminal-webview-engine.mjs create mode 100644 mobile/src/terminal/terminal-webview-contract.ts create mode 100644 mobile/src/terminal/terminal-webview-engine-error-state.tsx create mode 100644 mobile/src/terminal/terminal-webview-engine-error.test.ts create mode 100644 mobile/src/terminal/terminal-webview-engine.test.ts create mode 100644 mobile/src/terminal/terminal-webview-frame-styles.ts create mode 100644 mobile/src/terminal/terminal-webview-ready-watchdog.ts diff --git a/mobile/.gitignore b/mobile/.gitignore index 9fa2fa13bb7..449fa3f2439 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -1,4 +1,5 @@ node_modules/ +src/terminal/terminal-webview-engine.generated.ts .expo/ dist/ /android/ diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 828d42615a1..4929f3c562d 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -1,6 +1,7 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "extends": ["../.oxlintrc.json"], + "ignorePatterns": ["src/terminal/terminal-webview-engine.generated.ts"], "rules": { "react-hooks/exhaustive-deps": "off", "react/no-unescaped-entities": "off", diff --git a/mobile/package.json b/mobile/package.json index 9d75502765f..948191886d1 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -7,6 +7,7 @@ "start": "node scripts/start-expo.mjs", "android": "expo run:android", "ios": "expo run:ios", + "postinstall": "node scripts/build-terminal-webview-engine.mjs", "test": "vitest run", "lint": "oxlint", "format": "oxfmt --write .", @@ -18,6 +19,9 @@ "dependencies": { "@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio", "@react-native-async-storage/async-storage": "^2.2.0", + "@xterm/addon-unicode11": "0.10.0-beta.285", + "@xterm/addon-webgl": "0.20.0-beta.284", + "@xterm/xterm": "6.1.0-beta.285", "buffer": "^6.0.3", "expo": "^55.0.23", "expo-build-properties": "^55.0.13", @@ -62,6 +66,8 @@ "@types/react-native": "^0.73.0", "@types/react-test-renderer": "19.1.0", "@types/ws": "^8.18.1", + "acorn": "8.15.0", + "esbuild": "0.25.4", "expo-module-scripts": "^55.0.2", "oxfmt": "^0.52.0", "oxlint": "^1.71.0", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index aecf79fe0ee..f3c4b72ed1c 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -14,6 +14,15 @@ importers: '@react-native-async-storage/async-storage': specifier: ^2.2.0 version: 2.2.0(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) + '@xterm/addon-unicode11': + specifier: 0.10.0-beta.285 + version: 0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285) + '@xterm/addon-webgl': + specifier: 0.20.0-beta.284 + version: 0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285) + '@xterm/xterm': + specifier: 6.1.0-beta.285 + version: 6.1.0-beta.285 buffer: specifier: ^6.0.3 version: 6.0.3 @@ -141,9 +150,15 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + acorn: + specifier: 8.15.0 + version: 8.15.0 + esbuild: + specifier: 0.25.4 + version: 0.25.4 expo-module-scripts: specifier: ^55.0.2 - version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) + version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) oxfmt: specifier: ^0.52.0 version: 0.52.0 @@ -161,10 +176,10 @@ importers: version: 5.9.3 vite: specifier: ^8.0.16 - version: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + version: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -1157,126 +1172,252 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.25.4': + resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.4': + resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.4': + resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.4': + resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.4': + resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.4': + resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.4': + resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.4': + resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.4': + resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.4': + resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.4': + resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.4': + resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.4': + resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.4': + resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.4': + resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.4': + resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.4': + resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.4': + resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.4': + resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.4': + resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.4': + resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} @@ -1289,24 +1430,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.4': + resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.4': + resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.4': + resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.4': + resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -1730,56 +1895,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.52.0': resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.52.0': resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.52.0': resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.52.0': resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.52.0': resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.52.0': resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.52.0': resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxfmt/binding-openharmony-arm64@0.52.0': resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} @@ -1852,56 +2009,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -2363,42 +2512,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -2653,6 +2796,19 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + '@xterm/addon-unicode11@0.10.0-beta.285': + resolution: {integrity: sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.285 + + '@xterm/addon-webgl@0.20.0-beta.284': + resolution: {integrity: sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.285 + + '@xterm/xterm@6.1.0-beta.285': + resolution: {integrity: sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -2681,8 +2837,8 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true @@ -3378,6 +3534,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.25.4: + resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -4660,28 +4821,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -7675,81 +7832,156 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.4': + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.25.4': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.25.4': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.25.4': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.25.4': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.25.4': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.25.4': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.25.4': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.25.4': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.25.4': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.25.4': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.25.4': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.25.4': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.25.4': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.25.4': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.25.4': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.25.4': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.25.4': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.25.4': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.25.4': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.25.4': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.25.4': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.25.4': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.25.4': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.25.4': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true @@ -9327,13 +9559,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -9363,6 +9595,16 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + '@xterm/addon-unicode11@0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)': + dependencies: + '@xterm/xterm': 6.1.0-beta.285 + + '@xterm/addon-webgl@0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)': + dependencies: + '@xterm/xterm': 6.1.0-beta.285 + + '@xterm/xterm@6.1.0-beta.285': {} + abab@2.0.6: {} abort-controller@3.0.0: @@ -9381,7 +9623,7 @@ snapshots: acorn-globals@7.0.1: dependencies: - acorn: 8.16.0 + acorn: 8.15.0 acorn-walk: 8.3.5 acorn-jsx@5.3.2(acorn@8.17.0): @@ -9390,9 +9632,9 @@ snapshots: acorn-walk@8.3.5: dependencies: - acorn: 8.16.0 + acorn: 8.15.0 - acorn@8.16.0: {} + acorn@8.15.0: {} acorn@8.17.0: {} @@ -10235,6 +10477,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.25.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.4 + '@esbuild/android-arm': 0.25.4 + '@esbuild/android-arm64': 0.25.4 + '@esbuild/android-x64': 0.25.4 + '@esbuild/darwin-arm64': 0.25.4 + '@esbuild/darwin-x64': 0.25.4 + '@esbuild/freebsd-arm64': 0.25.4 + '@esbuild/freebsd-x64': 0.25.4 + '@esbuild/linux-arm': 0.25.4 + '@esbuild/linux-arm64': 0.25.4 + '@esbuild/linux-ia32': 0.25.4 + '@esbuild/linux-loong64': 0.25.4 + '@esbuild/linux-mips64el': 0.25.4 + '@esbuild/linux-ppc64': 0.25.4 + '@esbuild/linux-riscv64': 0.25.4 + '@esbuild/linux-s390x': 0.25.4 + '@esbuild/linux-x64': 0.25.4 + '@esbuild/netbsd-arm64': 0.25.4 + '@esbuild/netbsd-x64': 0.25.4 + '@esbuild/openbsd-arm64': 0.25.4 + '@esbuild/openbsd-x64': 0.25.4 + '@esbuild/sunos-x64': 0.25.4 + '@esbuild/win32-arm64': 0.25.4 + '@esbuild/win32-ia32': 0.25.4 + '@esbuild/win32-x64': 0.25.4 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -10688,7 +10958,7 @@ snapshots: expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6): + expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@babel/cli': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) @@ -10708,7 +10978,7 @@ snapshots: jest-snapshot-prettier: prettier@2.8.8 jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.9.3)) resolve-workspace-root: 2.0.1 - ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3) + ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -11832,7 +12102,7 @@ snapshots: jsdom@20.0.3: dependencies: abab: 2.0.6 - acorn: 8.16.0 + acorn: 8.15.0 acorn-globals: 7.0.1 cssom: 0.5.0 cssstyle: 2.3.0 @@ -13463,7 +13733,7 @@ snapshots: terser@5.46.2: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.16.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -13527,7 +13797,7 @@ snapshots: picomatch: 4.0.4 typescript: 5.9.3 - ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3): + ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -13543,7 +13813,7 @@ snapshots: '@babel/core': 7.29.7 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.29.7) - esbuild: 0.28.1 + esbuild: 0.25.4 tsconfig-paths@3.15.0: dependencies: @@ -13700,7 +13970,7 @@ snapshots: - '@types/react' - '@types/react-dom' - vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): + vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -13709,16 +13979,16 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.3 - esbuild: 0.28.1 + esbuild: 0.25.4 fsevents: 2.3.3 terser: 5.48.0 tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -13735,7 +14005,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.3 diff --git a/mobile/scripts/build-terminal-webview-engine.mjs b/mobile/scripts/build-terminal-webview-engine.mjs new file mode 100644 index 00000000000..ef6bf0dc1b7 --- /dev/null +++ b/mobile/scripts/build-terminal-webview-engine.mjs @@ -0,0 +1,108 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { createRequire } from 'node:module' +import * as esbuild from 'esbuild' + +const require = createRequire(import.meta.url) +const scriptDir = import.meta.dirname +const mobileRoot = path.resolve(scriptDir, '..') +const outputPath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts') +const target = 'chrome74' + +const packages = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl'] + +async function readPackageVersion(packageName) { + // Why: a package.json module specifier must use '/' — path.join emits '\' on + // Windows, yielding an unresolvable bare specifier that fails postinstall there. + const packageJsonPath = require.resolve(`${packageName}/package.json`) + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) + return `${packageName}@${packageJson.version}` +} + +function htmlText(value, closingTag) { + return value.replace(new RegExp(` void - onSelectionCopy?: (text: string) => void - onSelectionEvicted?: () => void - onModesChanged?: (modes: TerminalModes) => void - onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void - onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void - onTerminalInput?: (bytes: string) => void - onTerminalTap?: () => void - // Tap landed on a detected file path; RN resolves + opens it. - onFileTap?: (pathText: string, line: number | null, column: number | null) => void - // WebView-detected URL tap; RN chooses the mobile routing destination. - onOpenUrl?: (url: string) => void - // Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it - // here so the app persists it and keeps Settings + other panes in sync. - onTextScaleChange?: (scale: number) => void -} - -export type TerminalWebViewHandle = { - write: (data: string) => void - init: ( - cols: number, - rows: number, - initialData?: string, - preserveScroll?: boolean, - oscLinks?: TerminalOscLinks - ) => void - resize: (cols: number, rows: number) => void - // Why: reflow the local xterm buffer (scrollback included) to a new width - // after a server-side PTY reflow, so older wrapped lines rewrap to match the - // latest output. No-op on the alternate screen. - reflow: (cols: number, rows: number) => void - clear: () => void - measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> - resetZoom: () => void - cancelSelect: () => void - doSelectAll: () => void - // Why: lets callers await the WebView-side `init` rAF chain (term.open - // → renderService population → first paint) so a follow-up measure - // doesn't race ahead and find term=null or cellWidth=0. Resolves on - // the next 'ready' notify after the most recent init. - awaitReady: () => Promise -} +export type { + MobileTerminalTheme, + TerminalKeyboardAvoidanceMetrics, + TerminalModes, + TerminalSelectionEvents, + TerminalWebViewHandle +} from './terminal-webview-contract' type Props = { style?: StyleProp @@ -79,18 +33,16 @@ type Props = { // scale; raw xterm fontSize can't drive apparent size because the fit cancels it. textScale?: number onWebReady?: () => void + onEngineError?: (message: string) => void } & TerminalSelectionEvents -// Why: WebView treats source identity as page identity on some platforms; keep -// parent/session re-renders from reloading xterm and forcing fresh snapshots. -const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } - export const TerminalWebView = forwardRef(function TerminalWebView( { style, terminalTheme, textScale = 1, onWebReady, + onEngineError, onSelectionMode, onSelectionCopy, onSelectionEvicted, @@ -119,6 +71,12 @@ export const TerminalWebView = forwardRef(function // race ahead of term.open() / renderService population. const readyPromiseRef = useRef | null>(null) const readyResolveRef = useRef<(() => void) | null>(null) + const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } = + useTerminalWebViewEngineErrorState(onEngineError) + const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog( + isWebReadyRef, + reportEngineError + ) const sendToWebView = useCallback((msg: TerminalWebViewCommand) => { messageIdRef.current += 1 @@ -151,6 +109,8 @@ export const TerminalWebView = forwardRef(function if (msg.type === 'web-ready') { isWebReadyRef.current = true + clearWebReadyWatchdog() + clearEngineError() onWebReady?.() flushPendingMessages() } else if (msg.type === 'ready') { @@ -175,6 +135,9 @@ export const TerminalWebView = forwardRef(function const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]' // eslint-disable-next-line no-console console.log(tag, msg.payload) + } else if (msg.type === 'error') { + const message = typeof msg.message === 'string' ? msg.message : 'Unknown terminal error' + reportEngineError(message, msg.fatal !== false) } else if (msg.type === 'set-select-mode') { onSelectionMode?.(!!msg.enabled) } else if (msg.type === 'selection') { @@ -246,6 +209,9 @@ export const TerminalWebView = forwardRef(function }, [ flushPendingMessages, + clearEngineError, + clearWebReadyWatchdog, + reportEngineError, onWebReady, onSelectionMode, onSelectionCopy, @@ -263,10 +229,16 @@ export const TerminalWebView = forwardRef(function const handleLoadStart = useCallback(() => { isWebReadyRef.current = false + armWebReadyWatchdog() // Why: messages queued for a previous WebView generation are stale after a reload; // dropping them avoids replaying terminal chunks before the next init snapshot. pendingMessages.clear() - }, [pendingMessages]) + }, [armWebReadyWatchdog, pendingMessages]) + + const handleReload = useCallback(() => { + clearEngineError() + webViewRef.current?.reload() + }, [clearEngineError]) useEffect(() => { postMessage({ type: 'set-theme', terminalTheme }) @@ -289,7 +261,7 @@ export const TerminalWebView = forwardRef(function rows: number, initialData?: string, preserveScroll?: boolean, - oscLinks?: TerminalOscLinks + oscLinks?: TerminalOscLinkRange[] ) { // Why: arm a fresh ready promise BEFORE posting init. The WebView // resolves it via the 'ready' notify at the end of its rAF chain. @@ -395,29 +367,35 @@ export const TerminalWebView = forwardRef(function ) return ( - + + reportNativeEngineError('Terminal WebView load failed', event)} + onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)} + onRenderProcessGone={(event) => + reportNativeEngineError('Terminal WebView render process ended', event) + } + onContentProcessDidTerminate={(event) => + reportNativeEngineError('Terminal WebView content process ended', event) + } + /> + {engineError ? ( + + ) : null} + ) }) - -const styles = StyleSheet.create({ - webview: { - flex: 1, - backgroundColor: colors.terminalBg - } -}) diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts new file mode 100644 index 00000000000..aff86cecad2 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -0,0 +1,64 @@ +import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' + +type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' + +export type TerminalModes = { + bracketedPasteMode: boolean + altScreen: boolean + mouseTrackingMode: TerminalMouseTrackingMode + sgrMouseMode: boolean + sgrMousePixelsMode: boolean +} + +export type TerminalKeyboardAvoidanceMetrics = { + cursorY: number + rows: number + altScreen: boolean +} + +export type MobileTerminalTheme = RuntimeMobileTerminalTheme + +export type TerminalSelectionEvents = { + onSelectionMode?: (active: boolean) => void + onSelectionCopy?: (text: string) => void + onSelectionEvicted?: () => void + onModesChanged?: (modes: TerminalModes) => void + onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void + onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void + onTerminalInput?: (bytes: string) => void + onTerminalTap?: () => void + // Tap landed on a detected file path; RN resolves + opens it. + onFileTap?: (pathText: string, line: number | null, column: number | null) => void + // WebView-detected URL tap; RN chooses the mobile routing destination. + onOpenUrl?: (url: string) => void + // Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it + // here so the app persists it and keeps Settings + other panes in sync. + onTextScaleChange?: (scale: number) => void +} + +export type TerminalWebViewHandle = { + write: (data: string) => void + init: ( + cols: number, + rows: number, + initialData?: string, + preserveScroll?: boolean, + oscLinks?: TerminalOscLinkRange[] + ) => void + resize: (cols: number, rows: number) => void + // Why: reflow the local xterm buffer (scrollback included) to a new width + // after a server-side PTY reflow, so older wrapped lines rewrap to match the + // latest output. No-op on the alternate screen. + reflow: (cols: number, rows: number) => void + clear: () => void + measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> + resetZoom: () => void + cancelSelect: () => void + doSelectAll: () => void + // Why: lets callers await the WebView-side `init` rAF chain (term.open + // → renderService population → first paint) so a follow-up measure + // doesn't race ahead and find term=null or cellWidth=0. Resolves on + // the next 'ready' notify after the most recent init. + awaitReady: () => Promise +} diff --git a/mobile/src/terminal/terminal-webview-engine-error-state.tsx b/mobile/src/terminal/terminal-webview-engine-error-state.tsx new file mode 100644 index 00000000000..0ba3e79db8d --- /dev/null +++ b/mobile/src/terminal/terminal-webview-engine-error-state.tsx @@ -0,0 +1,130 @@ +import { useCallback, useState } from 'react' +import { RefreshCw } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { colors } from '../theme/mobile-theme' + +export type NativeWebViewEngineEvent = { + readonly nativeEvent?: object +} + +type TerminalWebViewEngineErrorOverlayProps = { + readonly message: string + readonly onReload: () => void +} + +type NativeWebViewEngineFields = { + readonly description?: unknown + readonly code?: unknown + readonly statusCode?: unknown + readonly domain?: unknown + readonly didCrash?: unknown +} + +export function useTerminalWebViewEngineErrorState(onEngineError?: (message: string) => void) { + const [engineError, setEngineError] = useState(null) + const clearEngineError = useCallback(() => setEngineError(null), []) + const reportEngineError = useCallback( + (message: string, fatal: boolean) => { + onEngineError?.(message) + // eslint-disable-next-line no-console + console.warn('[terminal-webview] engine error', message) + if (fatal) { + // Why: the first fatal report is the root cause; later cascades (e.g. the + // web-ready watchdog firing after a process-crash report) must not + // overwrite its more specific diagnostics. clearEngineError resets. + setEngineError((previous) => previous ?? message) + } + }, + [onEngineError] + ) + const reportNativeEngineError = useCallback( + (context: string, event?: NativeWebViewEngineEvent) => { + reportEngineError(describeNativeWebViewEngineError(context, event), true) + }, + [reportEngineError] + ) + return { clearEngineError, engineError, reportEngineError, reportNativeEngineError } +} + +export function describeNativeWebViewEngineError( + context: string, + event?: NativeWebViewEngineEvent +): string { + const native = event?.nativeEvent as NativeWebViewEngineFields | undefined + const parts = [context] + const description = native?.description + const statusCode = native?.statusCode + const code = native?.code + const domain = native?.domain + if (typeof description === 'string') { + parts.push(description) + } + if (typeof statusCode === 'number') { + parts.push(`status ${statusCode}`) + } + if (typeof code === 'number') { + parts.push(`code ${code}`) + } + if (typeof domain === 'string') { + parts.push(domain) + } + if (native?.didCrash === true) { + parts.push('renderer crashed') + } + return parts.join(' - ') +} + +export function TerminalWebViewEngineErrorOverlay({ + message, + onReload +}: TerminalWebViewEngineErrorOverlayProps) { + return ( + + Terminal failed to load + + {message} + + + + Reload + + + ) +} + +const styles = StyleSheet.create({ + errorOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + padding: 24, + backgroundColor: colors.terminalBg + }, + errorTitle: { + color: colors.textPrimary, + fontSize: 16, + fontWeight: '700', + textAlign: 'center' + }, + errorDetail: { + color: colors.textSecondary, + fontSize: 13, + lineHeight: 18, + textAlign: 'center' + }, + reloadButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + minHeight: 36, + paddingHorizontal: 14, + borderRadius: 6, + backgroundColor: colors.surfaceBright + }, + reloadButtonText: { + color: colors.terminalBg, + fontSize: 14, + fontWeight: '700' + } +}) diff --git a/mobile/src/terminal/terminal-webview-engine-error.test.ts b/mobile/src/terminal/terminal-webview-engine-error.test.ts new file mode 100644 index 00000000000..82267967b7c --- /dev/null +++ b/mobile/src/terminal/terminal-webview-engine-error.test.ts @@ -0,0 +1,178 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalWebView } from './TerminalWebView' + +vi.mock('react-native', () => ({ + AppState: { currentState: 'active' }, + Pressable: 'Pressable', + StyleSheet: { + absoluteFillObject: { + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0 + }, + create: (styles: unknown) => styles + }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-webview', () => ({ + WebView: 'WebView', + default: 'WebView' +})) + +vi.mock('lucide-react-native', () => ({ + RefreshCw: 'RefreshCw' +})) + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +// Why: mounting TerminalWebView arms the web-ready watchdog; tests must +// unmount so the timer can't survive into later tests and fire in teardown. +let activeRenderer: ReactTestRenderer | null = null + +function createTerminalWebViewRenderer(onEngineError = vi.fn()) { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(TerminalWebView, { onEngineError })) + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('TerminalWebView did not render') + } + activeRenderer = renderer + return { onEngineError, renderer } +} + +function postWebViewMessage(renderer: ReactTestRenderer, payload: Record) { + const webView = renderer.root.findByType('WebView') + act(() => { + webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } }) + }) +} + +function renderedText(renderer: ReactTestRenderer): string { + return renderer.root + .findAllByType('Text') + .flatMap((node) => node.props.children) + .join(' ') +} + +describe('TerminalWebView engine errors', () => { + afterEach(() => { + if (activeRenderer) { + act(() => { + activeRenderer?.unmount() + }) + activeRenderer = null + } + vi.restoreAllMocks() + }) + + it('renders the reload overlay for fatal engine errors from the WebView', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { + fatal: true, + message: 'terminal engine missing - SyntaxError - Chrome 74', + type: 'error' + }) + + expect(onEngineError).toHaveBeenCalledWith('terminal engine missing - SyntaxError - Chrome 74') + expect(renderedText(renderer)).toContain('Terminal failed to load') + expect(renderedText(renderer)).toContain('terminal engine missing - SyntaxError - Chrome 74') + expect(renderedText(renderer)).toContain('Reload') + }) + + it('reports non-fatal engine errors without covering a live terminal', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { + fatal: false, + message: 'terminal message failed - malformed chunk', + type: 'error' + }) + + expect(onEngineError).toHaveBeenCalledWith('terminal message failed - malformed chunk') + expect(renderedText(renderer)).not.toContain('Terminal failed to load') + }) + + it('keeps the first fatal diagnostics when later fatal reports cascade', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { + fatal: true, + message: 'terminal engine missing - root cause', + type: 'error' + }) + postWebViewMessage(renderer, { + fatal: true, + message: 'Terminal did not initialize - watchdog cascade', + type: 'error' + }) + + expect(renderedText(renderer)).toContain('terminal engine missing - root cause') + expect(renderedText(renderer)).not.toContain('watchdog cascade') + }) + + // Why: if the document dies before the glue can post anything (or the RN + // bridge never comes up), no message and no native error handler fires — + // the watchdog must convert that silence into the visible fatal overlay. + it('paints the fatal overlay when web-ready never arrives', () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + act(() => { + vi.advanceTimersByTime(15000) + }) + + expect(onEngineError).toHaveBeenCalledWith( + 'Terminal did not initialize - no ready signal from the terminal view' + ) + expect(renderedText(renderer)).toContain('Terminal failed to load') + } finally { + vi.useRealTimers() + } + }) + + it('does not fire the watchdog once web-ready has arrived', () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { type: 'web-ready' }) + act(() => { + vi.advanceTimersByTime(60000) + }) + + expect(onEngineError).not.toHaveBeenCalled() + expect(renderedText(renderer)).not.toContain('Terminal failed to load') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts new file mode 100644 index 00000000000..36c8c039493 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from 'node:fs' +import { Script } from 'node:vm' +import { parse } from 'acorn' +import { describe, expect, it } from 'vitest' +import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' +import { XTERM_HTML } from './terminal-webview-html' + +const terminalHtmlSource = readFileSync( + new URL('./terminal-webview-html.ts', import.meta.url), + 'utf8' +) + +describe('terminal WebView bundled engine', () => { + it('keeps the assembled terminal HTML free of external engine URLs', () => { + expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//) + expect(XTERM_HTML).not.toContain('cdn.jsdelivr.net') + expect(XTERM_HTML).not.toContain(' { + const start = terminalHtmlSource.indexOf('function handleIncomingMessage') + const end = terminalHtmlSource.indexOf("window.addEventListener('resize'", start) + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + const handlerSource = terminalHtmlSource.slice(start, end) + + expect(handlerSource).toContain('reportEngineError(') + expect(handlerSource).toContain("'terminal init failed'") + expect(handlerSource).toContain("'terminal message failed'") + expect(handlerSource).not.toContain('catch(ex) {}') + }) + + it('classifies runtime errors by a document-scoped ever-ready latch', () => { + // Why: init() flips `ready` false on every re-init (live width reflow keeps the + // old surface visible meanwhile), so the fatal default and the init-catch must + // key off `everReady` — otherwise a transient reflow error blanks a live + // terminal behind the fatal overlay. The latch stays set for the document. + expect(terminalHtmlSource).toContain('var everReady = false;') + expect(terminalHtmlSource).toContain('everReady = true;') + expect(terminalHtmlSource).toContain('fatal === undefined ? !everReady : !!fatal') + expect(terminalHtmlSource).toContain("msg.type === 'init' && !everReady") + expect(terminalHtmlSource).not.toMatch(/fatal === undefined \? !ready\b/) + }) + + it('bounds error capture and non-fatal reporting on a degraded engine', () => { + // Why: a constructed-but-broken engine can throw per render frame; both + // onerror capture sites must cap the buffer and non-fatal notifies must + // stop flooding RN while fatal reports always emit. + const capSites = terminalHtmlSource.match(/__engineErrors\.length < 20/g) ?? [] + expect(capSites.length).toBe(2) + expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5') + }) +}) diff --git a/mobile/src/terminal/terminal-webview-frame-styles.ts b/mobile/src/terminal/terminal-webview-frame-styles.ts new file mode 100644 index 00000000000..907a1f3326d --- /dev/null +++ b/mobile/src/terminal/terminal-webview-frame-styles.ts @@ -0,0 +1,13 @@ +import { StyleSheet } from 'react-native' +import { colors } from '../theme/mobile-theme' + +export const TERMINAL_WEBVIEW_FRAME_STYLES = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.terminalBg + }, + webview: { + flex: 1, + backgroundColor: colors.terminalBg + } +}) diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index a1672f3f91a..f4de2783121 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -4,6 +4,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ import { colors } from '../theme/mobile-theme' import { TERMINAL_TEXT_SCALES } from '../storage/preferences' import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' +import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' @@ -47,7 +48,15 @@ export const XTERM_HTML = ` - + +