diff --git a/.gitattributes b/.gitattributes index 145c06043bd..8bfd4043164 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,6 +23,9 @@ # runs `git apply` on one must force `-c core.autocrlf=input` rather than trust # the host's setting. See config/scripts/windows-process-tree-gyp-rebuild.mjs. /config/patches/*.patch -text +# Same reason, and pnpm parses these too: a CRLF checkout makes the mobile +# patches unparseable, so Windows packaging dies on ERR_PNPM_INVALID_PATCH. +/mobile/patches/*.patch -text # The xterm bundle hunks also make a diff nobody can read; review the hand-written # source patch under xterm-src/ instead. The sibling patches stay diffable. /config/patches/@xterm__xterm@*.patch -diff @@ -48,3 +51,41 @@ /src/mobile-web/src/*.ts text eol=lf /src/mobile-web/src/*.css text eol=lf /src/mobile-web/src/*.png -text +# Mobile web page source. Same buildId hazard as src/mobile-web above: these bytes are +# hashed into the Phase C bundle, so a CRLF Windows checkout would ship a different +# buildId for identical source. web-entry/ does not exist yet; the pin lands ahead of it. +/mobile/src/** text eol=lf +/mobile/app/** text eol=lf +/mobile/web-entry/** text eol=lf +# The blanket pin above would mark a future binary as text; exempt the asset types an +# RN page actually carries, the same way src/mobile-web exempts its PNG. +/mobile/src/**/*.png -text +/mobile/src/**/*.jpg -text +/mobile/src/**/*.jpeg -text +/mobile/src/**/*.gif -text +/mobile/src/**/*.ico -text +/mobile/src/**/*.webp -text +/mobile/src/**/*.ttf -text +/mobile/src/**/*.otf -text +/mobile/src/**/*.woff -text +/mobile/src/**/*.woff2 -text +/mobile/app/**/*.png -text +/mobile/app/**/*.jpg -text +/mobile/app/**/*.jpeg -text +/mobile/app/**/*.gif -text +/mobile/app/**/*.ico -text +/mobile/app/**/*.webp -text +/mobile/app/**/*.ttf -text +/mobile/app/**/*.otf -text +/mobile/app/**/*.woff -text +/mobile/app/**/*.woff2 -text +/mobile/web-entry/**/*.png -text +/mobile/web-entry/**/*.jpg -text +/mobile/web-entry/**/*.jpeg -text +/mobile/web-entry/**/*.gif -text +/mobile/web-entry/**/*.ico -text +/mobile/web-entry/**/*.webp -text +/mobile/web-entry/**/*.ttf -text +/mobile/web-entry/**/*.otf -text +/mobile/web-entry/**/*.woff -text +/mobile/web-entry/**/*.woff2 -text diff --git a/.github/actions/install-mobile-dependencies/action.yml b/.github/actions/install-mobile-dependencies/action.yml new file mode 100644 index 00000000000..0ed2b45bb9a --- /dev/null +++ b/.github/actions/install-mobile-dependencies/action.yml @@ -0,0 +1,22 @@ +name: Install mobile dependencies +description: Frozen pnpm install for the mobile/ project, whose node_modules the mobile web bundle build and the mobile-aware lint passes resolve React Native and Expo from. + +runs: + using: composite + steps: + # Why a separate install: mobile is its own pnpm project, so the root install leaves + # mobile/node_modules empty and every mobile import resolves to nothing. + # Why no --ignore-scripts, unlike the root install: mobile's postinstall generates the + # gitignored terminal/mermaid webview engine modules that tracked source imports. + # The drift guard mirrors the root install so a stale mobile lockfile fails by name -- + # mobile's lockfile carries patchedDependencies that a silent rewrite would drop. + - name: Install mobile dependencies + shell: bash + working-directory: mobile + run: | + pnpm install --frozen-lockfile + # Job containers can run composite steps from a source mirror without .git. + if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then + git -C "$GITHUB_WORKSPACE" diff --exit-code -- \ + mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml + fi diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 4f667bf2778..c081831c28b 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -184,6 +184,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads uses: actions/cache@v5 @@ -205,6 +208,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: signing is what makes an adhoc build installable over an existing # Orca, so a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/daemon-relocation-spike.yml b/.github/workflows/daemon-relocation-spike.yml index 2ffd4a58661..ac9c1bd4ba4 100644 --- a/.github/workflows/daemon-relocation-spike.yml +++ b/.github/workflows/daemon-relocation-spike.yml @@ -57,7 +57,28 @@ jobs: uses: actions/cache@v4 with: path: dist/win-unpacked - key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }} + # mobile/ is in the key because beforePack requires out/mobile-web, whose bytes come from + # the mobile install and, once Phase C flips the bundle, from the page trees below; a + # mobile-only change must miss this cache, not reuse a stale installer. src/** and + # config/** already cover src/mobile-web and the two bundle builders. + key: >- + win-unpacked-${{ hashFiles( + 'src/**', + 'config/**', + 'package.json', + 'pnpm-lock.yaml', + 'mobile/package.json', + 'mobile/pnpm-lock.yaml', + 'mobile/app/**', + 'mobile/src/**', + 'mobile/web-entry/**' + ) }} + + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. Gated with the + # build it feeds, so a cache hit does not pay for an install nothing consumes. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.cache-unpacked.outputs.cache-hit != 'true' - name: Build unpacked app if: steps.cache-unpacked.outputs.cache-hit != 'true' diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 41b87526fea..4e89b1f5a4b 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -156,6 +156,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads if: steps.freshness.outputs.should_build == 'true' @@ -179,6 +182,11 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.freshness.outputs.should_build == 'true' + # Why: signing is what makes a daily installable over an existing Orca, so # a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/dev-channel-win-build.yml b/.github/workflows/dev-channel-win-build.yml index 7913e7e6e80..3f8e162e073 100644 --- a/.github/workflows/dev-channel-win-build.yml +++ b/.github/workflows/dev-channel-win-build.yml @@ -203,6 +203,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # Caches the Electron binary and electron-builder's tool downloads (nsis, # winCodeSign). Same key shape as release-cut's Windows leg. @@ -229,6 +232,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why the packaging check runs before the 20-minute build: it only needs # node_modules, and a stale config should cost seconds rather than a build. - name: Verify dev-channel packaging identity diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index 1aed485a666..8e061b83563 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -164,6 +164,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads uses: actions/cache@v5 @@ -185,6 +188,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: signing is what makes an hourly installable over an existing Orca, so # a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e2112de521b..bb9bccdfa25 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -29,6 +29,7 @@ jobs: should_run: ${{ steps.filter.outputs.should_run }} native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} + mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }} static_analysis: ${{ steps.filter.outputs.static_analysis }} typecheck: ${{ steps.filter.outputs.typecheck }} git_compatibility: ${{ steps.filter.outputs.git_compatibility }} @@ -142,24 +143,11 @@ jobs: - name: Enforce type-aware code-quality baseline run: pnpm run audit:code-quality:type-aware - # Why: the changed-code gate lints mobile files too, and its type-aware pass - # resolves types from mobile/node_modules. Mobile is a separate pnpm project, - # so the root install above leaves it empty and every mobile type degrades to - # an `error` type — reported as phantom findings against the changed lines. - # Why no --ignore-scripts, unlike the root install: mobile's postinstall generates - # the gitignored terminal/mermaid webview engine modules that tracked source imports, - # and skipping it degrades those very types the step exists to resolve. The drift - # guard mirrors the root install so a stale mobile lockfile fails by name — mobile's - # lockfile carries patchedDependencies that a silent rewrite would drop. - - name: Install mobile dependencies + # Why here: the changed-code gate lints mobile files too, and its type-aware pass + # resolves types from mobile/node_modules. Without the install every mobile type + # degrades to an `error` type — reported as phantom findings against the changed lines. + - uses: ./.github/actions/install-mobile-dependencies if: needs.code_paths.outputs.mobile_dependencies == 'true' - working-directory: mobile - run: | - pnpm install --frozen-lockfile - if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then - git -C "$GITHUB_WORKSPACE" diff --exit-code -- \ - mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml - fi - name: Enforce changed-code quality run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" @@ -661,6 +649,64 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/main/orcad/external-chromium-browser-process.integration.test.ts + # Why its own job: it needs mobile/node_modules and a real browser, and the sharded `test` + # matrix would pay for both on every shard to run two files. Dark through Phase C: this proves + # `build:mobile-web:app` on every PR that touches the page, and ships nothing -- packaging still + # builds the Phase A bootstrap via build:mobile-web. + mobile_web_app: + name: mobile web app bundle + needs: [code_paths] + if: needs.code_paths.outputs.mobile_web_app == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + # Why no native-runtime: the builder is esbuild and the render check is a browser. Nothing + # in this job loads node-pty. + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml + + # The entry lives in mobile/ so one React resolves; without this every RN import is nothing. + - uses: ./.github/actions/install-mobile-dependencies + + # Why the runner's Google Chrome and not a downloaded chromium: same reason as the orcad + # browser job -- Ubuntu 24.04 only ships an AppArmor userns profile for the Chrome .deb. + # Why fail instead of skip: a silently skipped render check is the failure this job exists + # to prevent. + - name: Resolve Chrome for the render check + run: | + set -euo pipefail + chrome="$(command -v google-chrome || command -v google-chrome-stable || true)" + if [ -z "$chrome" ]; then + echo "::error::No Google Chrome on the runner; the render check would silently skip." + exit 1 + fi + "$chrome" --version + echo "ORCA_MOBILE_WEB_RENDER_BROWSER=$chrome" >> "$GITHUB_ENV" + + - name: Build and verify the app bundle + run: pnpm run build:mobile-web:app + + # The bundling tests skip themselves where mobile dependencies are absent, which is how they + # stay green in the sharded `test` job. This is the job that installs them, so here a missing + # install has to fail rather than skip everything the job exists to run. + - name: Builder, override census and render check + env: + ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1' + run: | + pnpm exec vitest run --config config/vitest.config.ts \ + config/scripts/build-mobile-web-app-bundle.test.mjs \ + config/scripts/mobile-web-app-web-overrides.test.mjs \ + config/scripts/mobile-web-app-render.test.mjs + cross-version-wire: name: cross-version wire compatibility needs: [code_paths] @@ -695,6 +741,8 @@ jobs: tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts + tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts + tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 @@ -746,6 +794,13 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: electron + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml + + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies # Why --no-file-parallelism: every file here launches a full Electron stack twice, and each # probe carries its own in-process deadline. Four at once on a 4-vCPU runner starve each other @@ -859,6 +914,13 @@ jobs: with: native-runtime: node persist-native-cache: 'false' + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml + + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies - name: Save compiled Node native modules if: steps.deps.outputs.native-cache-hit != 'true' @@ -1017,6 +1079,7 @@ jobs: - shell_contracts - test - orcad_browser + - mobile_web_app - cross-version-wire - managed_hook_node18 - package @@ -1053,6 +1116,8 @@ jobs: TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }} ORCAD_BROWSER: ${{ needs.orcad_browser.result }} ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }} + MOBILE_WEB_APP: ${{ needs.mobile_web_app.result }} + MOBILE_WEB_APP_SHOULD_RUN: ${{ needs.code_paths.outputs.mobile_web_app }} CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }} CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }} MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }} @@ -1095,6 +1160,7 @@ jobs: check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN" check_job test "$TEST" "$TEST_SHOULD_RUN" check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN" + check_job mobile_web_app "$MOBILE_WEB_APP" "$MOBILE_WEB_APP_SHOULD_RUN" check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN" check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN" check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN" diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index ac2e7f904e3..cd940482917 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -871,8 +871,17 @@ jobs: npm install -g node-gyp@11.5.0 echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + # Why: this install runs lifecycle scripts, so node-gyp rebuilds + # native/windows-registry and fetches that Node version's headers from + # nodejs.org. One `read ECONNRESET` there failed this blocking gate and the + # whole cut. Retry like the release build's install below. - name: Install dependencies - run: pnpm install --frozen-lockfile + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile - name: Build Electron app for platform golden run: npx electron-vite build --mode e2e @@ -1088,8 +1097,14 @@ jobs: npm install -g node-gyp@11.5.0 echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + # Same node-gyp header fetch as the blocking golden gate above. - name: Install dependencies - run: pnpm install --frozen-lockfile + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile - name: Build Electron app for terminal rendering evidence run: npx electron-vite build --mode e2e @@ -1204,22 +1219,33 @@ jobs: # ref, so cutting from an older/off-main ref whose tree predates a composite # action would fail the step with "Can't find 'action.yml'". Restore the # actions directory from the commit this workflow file itself came from. + # Not Windows-only: every platform now consumes install-mobile-dependencies, so + # any of them can be the one whose cut ref predates the action. - name: Restore composite actions from the workflow ref - if: matrix.platform == 'win' && github.run_attempt == 1 shell: bash env: WORKFLOW_SHA: ${{ github.workflow_sha }} + PLATFORM: ${{ matrix.platform }} run: | set -euo pipefail - action_path=".github/actions/install-signpath-module/action.yml" - if [ -f "$action_path" ]; then + required=(.github/actions/install-mobile-dependencies/action.yml) + if [ "$PLATFORM" = win ] && [ "$GITHUB_RUN_ATTEMPT" = 1 ]; then + required+=(.github/actions/install-signpath-module/action.yml) + fi + missing=() + for action_path in "${required[@]}"; do + [ -f "$action_path" ] || missing+=("$action_path") + done + if [ "${#missing[@]}" -eq 0 ]; then echo "Composite actions already present at the cut ref." exit 0 fi - echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA." + echo "Cut ref predates ${missing[*]}; restoring from $WORKFLOW_SHA." git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" git checkout "$WORKFLOW_SHA" -- .github/actions - test -f "$action_path" + for action_path in "${required[@]}"; do + test -f "$action_path" + done # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. - name: Setup pnpm @@ -1232,6 +1258,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # Why: release builds hit the same native-module postinstall path as # PR CI, so keep the pinned node-gyp override here too instead of @@ -1272,6 +1301,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: `pnpm build:release` verifies the Linux computer-use provider by # importing AT-SPI bindings, which are runtime package deps but are not # present on stock GitHub Ubuntu release runners. diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 3d7e4dd05bf..45193dfe1ae 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -47,6 +47,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # Cache the Electron binary + electron-builder tool downloads (notarytool, # winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac. @@ -74,6 +77,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + - name: Verify macOS signing environment run: node config/scripts/verify-macos-release-env.mjs env: diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index f3d22cc1227..91ac8fcf226 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -55,6 +55,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile @@ -67,6 +70,9 @@ jobs: uses: actions/cache@v4 with: path: dist/orca-windows-setup.exe + # The mobile page trees are in the key because beforePack builds the mobile web bundle + # into the installer; src/** and config/** already cover src/mobile-web and the two + # bundle builders. A mobile-only change must miss this cache, not reuse a stale exe. key: >- crash-survival-installer-${{ hashFiles( 'src/**', @@ -85,7 +91,12 @@ jobs: '.npmrc', 'package.json', 'pnpm-lock.yaml', - 'pnpm-workspace.yaml' + 'pnpm-workspace.yaml', + 'mobile/package.json', + 'mobile/pnpm-lock.yaml', + 'mobile/app/**', + 'mobile/src/**', + 'mobile/web-entry/**' ) }} # Why: production edits miss the installer cache by design, but Electron @@ -101,6 +112,12 @@ jobs: restore-keys: | crash-survival-electron-builder- + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. Gated with the + # build it feeds, so a cache hit does not pay for an install nothing consumes. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.cache-installer.outputs.cache-hit != 'true' + - name: Build Windows installer (unsigned) if: steps.cache-installer.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/win-update-survival-e2e.yml b/.github/workflows/win-update-survival-e2e.yml index e7ed41125e9..50c38f2e3ad 100644 --- a/.github/workflows/win-update-survival-e2e.yml +++ b/.github/workflows/win-update-survival-e2e.yml @@ -75,6 +75,12 @@ jobs: path: dist/orca-windows-setup.exe key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }} + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. Gated with the + # build it feeds, so a cache hit does not pay for an install nothing consumes. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.cache-installer.outputs.cache-hit != 'true' + - name: Build Windows installer (unsigned) if: steps.cache-installer.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 244ee4d3e08..0fa2a31cd97 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -57,6 +57,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads uses: actions/cache@v5 @@ -78,6 +81,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: rehearsal builds are never published, so the official-build # secrets (telemetry key, diagnostics URL) are intentionally omitted. - name: Build app diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs new file mode 100644 index 00000000000..6ea2bc19663 --- /dev/null +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -0,0 +1,403 @@ +import { readFile } from 'node:fs/promises' +import { realpathSync } from 'node:fs' +import { basename, extname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as esbuild from 'esbuild' +import { + MOBILE_WEB_BUNDLE_ENTRYPOINT, + hashedAsset, + isDirectInvocation, + readDesktopVersion, + readProtocolWindow, + sha256Hex, + writeMobileWebBundleTree, + contentTypeForExtension +} from './build-mobile-web-bundle.mjs' +import { + ROUTE_SOURCE_LOADERS, + assertRoutesCarryNoSynchronousExports, + collectMobileWebAppRoutes, + renderMobileWebAppRouteManifest +} from './mobile-web-app-route-manifest.mjs' + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) +const mobileDir = join(projectDir, 'mobile') +const defaultAppDir = join(mobileDir, 'app') +const entryPoint = join(mobileDir, 'web-entry', 'index.tsx') +const defaultOutDir = join(projectDir, 'out', 'mobile-web-app') + +/** + * Every shim the app bundle needs, each one a documented Metro/RN-Web gap. `appliesTo` reads the + * esbuild option that implements the shim, so the list cannot claim a shim the build does not + * apply and a dropped option fails the named shim rather than the whole build. + */ +export const MOBILE_WEB_APP_SHIMS = [ + { + // react-native has no browser build; react-native-web is the whole point of Route A. + name: 'react-native-web-alias', + appliesTo: (options) => options.alias?.['react-native'] === 'react-native-web' + }, + { + // RN ships untranspiled JSX inside .js files (expo-router's own build/ included). + name: 'js-as-jsx', + appliesTo: (options) => options.loader?.['.js'] === 'jsx' + }, + { + // RN code assumes a Hermes/Metro `global`; the browser only has `globalThis`. + name: 'global-as-globalthis', + appliesTo: (options) => options.define?.global === 'globalThis' + }, + { + // RN and Expo modules read process.env at module scope, before any of our code runs. + name: 'process-banner', + appliesTo: (options) => options.banner?.js?.includes('globalThis.process ??=') === true + }, + { + // lucide-react-native@1.14.0's barrel re-exports LucideProvider from a context.mjs that does + // not export it. Metro's loose CJS interop tolerates it; esbuild's strict ESM does not. + // Web-build only: patching the package would change what the shipped native app consumes. + name: 'lucide-barrel-provider', + appliesTo: (options) => + options.plugins?.some((plugin) => plugin.name === LUCIDE_PLUGIN_NAME) === true + }, + { + // esbuild has no require.context, so the route tree is generated and injected. + name: 'route-manifest', + appliesTo: (options) => + options.plugins?.some((plugin) => plugin.name === ROUTE_MANIFEST_PLUGIN_NAME) === true + } +] + +const ROUTE_MANIFEST_PLUGIN_NAME = 'orca-route-manifest' +const LUCIDE_PLUGIN_NAME = 'orca-lucide-barrel-provider' + +/** The entry output's name, so classifying the outputs never has to guess which one it is. */ +const ENTRY_CHUNK_NAME = 'entry' + +// mobile/web-entry/route-manifest.ts is a real typed file rather than a virtual specifier, so the +// entry typechecks and Metro can still resolve it; only its body is replaced here. +function routeManifestPlugin(manifestSource) { + return { + name: ROUTE_MANIFEST_PLUGIN_NAME, + setup(build) { + build.onLoad({ filter: /web-entry[\\/]route-manifest\.ts$/ }, () => ({ + contents: manifestSource, + loader: 'js', + resolveDir: mobileDir + })) + } + } +} + +const lucideBarrelPlugin = { + name: LUCIDE_PLUGIN_NAME, + setup(build) { + build.onLoad({ filter: /lucide-react-native[\\/].*[\\/]context\.mjs$/ }, async (args) => ({ + contents: `${await readFile(args.path, 'utf8')}\nexport const LucideProvider = ({ children }) => children;\n`, + loader: 'js' + })) + } +} + +/** Split out so a test can read the options MOBILE_WEB_APP_SHIMS claims, without a build. */ +export function mobileWebAppBuildOptions(routes) { + return { + // Fixed so no absolute path of this checkout can reach the output. + absWorkingDir: mobileDir, + entryPoints: [entryPoint], + bundle: true, + minify: true, + // Virtual: write is false, so outdir only names the emitted files esbuild hands back. + outdir: 'dist', + write: false, + // esm, because `splitting` requires it and a per-route chunk is the point: with iife and + // static imports esbuild emitted one 8.16 MB script for all 14 routes. + format: 'esm', + splitting: true, + // esbuild's `[hash]` is over the metafile's input keys, which are paths relative to + // absWorkingDir, so this name is not a function of the bytes and differs between two + // checkouts of one commit. It is a placeholder: renameOutputsByContent replaces it below. + chunkNames: '[hash]', + // Pinned rather than defaulted, so the entry is found by name and not by elimination. + entryNames: ENTRY_CHUNK_NAME, + target: ['es2022'], + charset: 'utf8', + legalComments: 'none', + // No sourcemap: it is an emitted file and would carry this checkout's absolute paths into the + // bundle. The metafile carries them too but is never written and never hashed; it is the only + // thing that says which output is the entry, which of its imports are static, and which + // outputs each one names. + sourcemap: false, + metafile: true, + logLevel: 'silent', + jsx: 'automatic', + // One React: resolve everything from mobile/node_modules, which is where the entry lives. + nodePaths: [join(mobileDir, 'node_modules')], + alias: { 'react-native': 'react-native-web' }, + plugins: [routeManifestPlugin(renderMobileWebAppRouteManifest(routes)), lucideBarrelPlugin], + resolveExtensions: [ + '.web.tsx', + '.web.ts', + '.web.jsx', + '.web.js', + '.tsx', + '.ts', + '.jsx', + '.js', + '.json' + ], + // Images are emitted as same-origin assets, not data: URLs: the shell's CSP sets + // img-src 'self', which refuses data:. Content-hashed names keep the buildId reproducible. + // A font would fail the build here rather than silently ship under font-src 'none'. + loader: { + ...ROUTE_SOURCE_LOADERS, + '.png': 'file', + '.jpg': 'file', + '.jpeg': 'file', + '.gif': 'file', + '.webp': 'file', + '.svg': 'file' + }, + assetNames: '[hash]', + // Absolute, because the document is served at every route depth and a path relative to the + // script would resolve against the route instead. + publicPath: '/assets', + banner: { + js: "globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };" + }, + define: { + global: 'globalThis', + __DEV__: 'false', + 'process.env.NODE_ENV': '"production"', + 'process.env.EXPO_OS': '"web"', + 'process.env.EXPO_ROUTER_IMPORT_MODE': '"sync"' + } + } +} + +/** + * What the browser must have before the first route can paint: the entry plus every chunk it + * reaches by static import, transitively. A dynamic import is what the split exists to defer, so + * it is where this stops. + * + * The bound the verifier holds is this number and not the entry file alone, because esbuild puts + * the code shared by entry and routes in a chunk the entry imports statically: budgeting the entry + * file on its own would fall as the shared chunk grew. + */ +export function entryStaticClosure(metafile, entryOutputPath) { + const reached = new Set([entryOutputPath]) + const queue = [entryOutputPath] + while (queue.length > 0) { + const current = queue.shift() + for (const imported of metafile.outputs[current]?.imports ?? []) { + if (imported.kind !== 'import-statement' || reached.has(imported.path)) { + continue + } + reached.add(imported.path) + queue.push(imported.path) + } + } + return reached +} + +/** + * Every emitted output, renamed to the sha256 of its own final bytes. + * + * esbuild's `[hash]` is computed over the metafile's input keys, and those keys are paths + * relative to absWorkingDir. A tree whose mobile/node_modules is a symlink keys most of its + * inputs as `../..//...`, a tree that holds a real directory keys them as + * `node_modules/...`, and a byte-identical chunk comes out under a different name in each. The + * name is embedded in every importer, so the difference cascades into a different buildId for one + * commit -- and every phone re-downloads a bundle whose bytes never changed. + * + * Renaming here is what removes the path from the output. Leaves first, so an importer is hashed + * only once the names written inside it are final: an image before the chunk that loads it, a + * chunk before the chunk that imports it, the entry last. The result is what `hashedAsset` would + * name each of these anyway, which is how the name inside the bytes and the manifest's own sha256 + * stay the same string. + */ +export function renameOutputsByContent(metafile, outputFiles) { + const emitted = new Map( + outputFiles.map((file) => [basename(file.path), Buffer.from(file.contents)]) + ) + const importsOf = new Map( + Object.entries(metafile.outputs).map(([output, { imports }]) => [ + basename(output), + (imports ?? []).map((entry) => basename(entry.path)).filter((name) => emitted.has(name)) + ]) + ) + const renamed = new Map() + const open = new Set() + function rename(name) { + const done = renamed.get(name) + if (done) { + return done + } + if (open.has(name)) { + // Two outputs naming each other have no content hash at all, so this is a hard stop rather + // than a fallback. esbuild's splitting emits a DAG; nothing in the tree has produced one. + throw new Error( + `[build-mobile-web-app-bundle] ${name} is in an output cycle and cannot be content-named` + ) + } + open.add(name) + let bytes = emitted.get(name) + for (const child of importsOf.get(name) ?? []) { + const { name: childName } = rename(child) + // publicPath already rewrote the specifier to this exact shape, and an esbuild output name + // is a token that appears nowhere else. + bytes = Buffer.from( + bytes.toString('utf8').split(`/assets/${child}`).join(`/assets/${childName}`), + 'utf8' + ) + } + open.delete(name) + const result = { name: `${sha256Hex(bytes)}${extname(name)}`, bytes } + renamed.set(name, result) + return result + } + for (const name of [...emitted.keys()].sort()) { + rename(name) + } + return renamed +} + +/** + * Which emitted chunk each route key's `import()` lands in. esbuild puts a route module in exactly + * one output, so the metafile's own inputs answer it; nothing downstream can, because by then + * every name is a hash of bytes and the route's source path is gone from the bundle. + */ +export function routeChunkNames(metafile, routes, renamed) { + const owner = new Map() + for (const [output, { inputs }] of Object.entries(metafile.outputs)) { + for (const input of Object.keys(inputs ?? {})) { + // Absolute, and through realpath on the lookup side below: esbuild writes its input keys + // relative to absWorkingDir after resolving symlinks, so a route reached through one (every + // scratch tree under /var on macOS) is keyed by a path the caller never spelled. + owner.set(resolve(mobileDir, input), basename(output)) + } + } + return Object.fromEntries( + routes.map(({ key, module }) => { + const emittedName = owner.get(realpathSync(module)) + if (!emittedName) { + throw new Error(`[build-mobile-web-app-bundle] ${key} reached no output`) + } + return [key, renamed.get(emittedName).name] + }) + ) +} + +const isScriptOutput = (path) => path.endsWith('.js') + +// appDir is a seam for the tests, which bundle a scratch route tree; production always uses mobile/app. +export async function bundleMobileWebApp({ appDir = defaultAppDir } = {}) { + const routes = await collectMobileWebAppRoutes(appDir) + await assertRoutesCarryNoSynchronousExports(routes) + const result = await esbuild.build(mobileWebAppBuildOptions(routes)) + const entryOutputPath = Object.keys(result.metafile.outputs).find( + (path) => basename(path) === `${ENTRY_CHUNK_NAME}.js` + ) + if (!entryOutputPath) { + throw new Error('[build-mobile-web-app-bundle] esbuild emitted no entry script') + } + const renamed = renameOutputsByContent(result.metafile, result.outputFiles) + const entry = renamed.get(basename(entryOutputPath)) + const byName = (left, right) => (left.name < right.name ? -1 : 1) + const others = [...renamed.entries()] + .filter(([emittedName]) => emittedName !== basename(entryOutputPath)) + .map(([emittedName, output]) => ({ emittedName, ...output })) + // Chunks keep their new name into the served path: the entry imports them by it, and + // publicPath has already made that specifier /assets/. + const chunks = others.filter(({ emittedName }) => isScriptOutput(emittedName)).sort(byName) + const images = others.filter(({ emittedName }) => !isScriptOutput(emittedName)).sort(byName) + const closure = entryStaticClosure(result.metafile, entryOutputPath) + return { + script: entry.bytes, + chunks, + images, + // Counted off the renamed bytes rather than the metafile's own sizes, which are from before + // the names inside each output grew. Only the metafile knows which import is static; see + // entryStaticClosure. + entryStaticBytes: [...closure].reduce( + (total, path) => total + (renamed.get(basename(path))?.bytes.byteLength ?? 0), + 0 + ), + routeKeys: routes.map((route) => route.key), + routeChunks: routeChunkNames(result.metafile, routes, renamed) + } +} + +export async function buildMobileWebAppBundle({ appDir, outDir = defaultOutDir } = {}) { + const [ + desktopVersion, + protocolWindow, + { script, chunks, images, entryStaticBytes, routeChunks, routeKeys } + ] = await Promise.all([ + readDesktopVersion(), + readProtocolWindow(), + bundleMobileWebApp({ appDir }) + ]) + // Every output is already named by its own bytes, and a name is written inside whatever imports + // it, so hashedAsset here reproduces the name rather than choosing one. + const scriptAsset = hashedAsset(script, 'js') + const written = [ + scriptAsset, + ...[...chunks, ...images].map(({ name, bytes }) => hashedAsset(bytes, extname(name).slice(1))) + ] + + // Root-absolute, unlike the Phase A bootstrap's bare relative src: this document is served at + // every route depth (/h//tasks), where a relative href resolves against the route and + // 404s. A tag would be the other fix, but the shell's CSP sets base-uri 'none'. + // type="module", because the entry is esm and reaches its routes through import(). Same-origin + // module and chunk both load under the shell's script-src 'self'; the policy is unchanged. + const html = + '\n\n\n\n' + + '\n' + + 'Orca\n\n\n
\n' + + `\n\n\n` + const indexBytes = Buffer.from(html, 'utf8') + const indexAsset = { + bytes: indexBytes, + path: MOBILE_WEB_BUNDLE_ENTRYPOINT, + sha256: sha256Hex(indexBytes), + byteLength: indexBytes.byteLength, + contentType: contentTypeForExtension('html') + } + + const { manifest } = await writeMobileWebBundleTree({ + outDir, + written: [indexAsset, ...written], + desktopVersion, + protocolWindow + }) + return { + manifest, + outDir, + routeChunks, + routeKeys, + entryStaticBytes, + // The entry counts: it is a chunk the browser fetches, and the budget is about how many. + chunkCount: chunks.length + 1, + // Everything the routes import that is not a script, which is the rest of the asset budget. + imageCount: images.length + } +} + +if (isDirectInvocation(import.meta.url, process.argv[1])) { + try { + const { manifest, outDir, routeKeys, entryStaticBytes, chunkCount } = + await buildMobileWebAppBundle() + console.log( + `[build-mobile-web-app-bundle] OK — ${String(routeKeys.length)} route(s), ` + + `${String(chunkCount)} chunk(s), ${String(entryStaticBytes)} bytes before the first route, ` + + `${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes, ` + + `buildId ${manifest.buildId} -> ${outDir}` + ) + } catch (error) { + // The route guards fail here by design, and every throw on this path already names its + // source, so a stack only buries which route and which export. + console.error(error.message) + process.exit(1) + } +} diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs new file mode 100644 index 00000000000..9e20952ed6a --- /dev/null +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -0,0 +1,608 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + MOBILE_WEB_APP_SHIMS, + bundleMobileWebApp, + buildMobileWebAppBundle, + entryStaticClosure, + mobileWebAppBuildOptions, + renameOutputsByContent, + routeChunkNames +} from './build-mobile-web-app-bundle.mjs' +import { + MOBILE_WEB_APP_ROUTE_ROOT, + ROUTE_SOURCE_LOADERS, + collectMobileWebAppRouteKeys, + collectMobileWebAppRoutes +} from './mobile-web-app-route-manifest.mjs' +import { + MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES, + MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES, + MOBILE_WEB_APP_SOURCE_DIRS, + assertAssetCeilingFitsShell, + mobileWebAppBundleMaxAssets, + mobileWebAppBundleMaxChunks, + readMobileWebBundleMaxAssets, + verifyMobileWebAppBundle +} from './verify-mobile-web-app-bundle.mjs' +import { + BINARY_SOURCE_EXTENSIONS, + assertNoCarriageReturnsInSource +} from './verify-mobile-web-bundle.mjs' +import { + hashedAsset, + readDesktopVersion, + readProtocolWindow, + sha256Hex, + writeMobileWebBundleTree +} from './build-mobile-web-bundle.mjs' +import { + MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, + MOBILE_WEB_BUNDLE_MAX_ASSETS +} from '../../src/shared/mobile-web-bundle/manifest-contract.js' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) +const appDir = join(projectDir, 'mobile', 'app') + +// The sharded `test` job does not install mobile dependencies, so anything that runs esbuild over +// the route tree is skipped there and run for real in pr.yml's mobile_web_app job. +const bundles = mobileWebAppDependenciesPresent() +const describeBundling = bundles ? describe : describe.skip +const itBundling = bundles ? it : it.skip + +/** Every script the page loads. A route's code is in a chunk now, not in the entry. */ +function allScriptSource({ script, chunks }) { + return [script, ...chunks.map((chunk) => chunk.bytes)].map((bytes) => bytes.toString('utf8')) +} + +async function withScratch(run) { + const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-test-')) + try { + return await run(scratch) + } finally { + await rm(scratch, { recursive: true, force: true }) + } +} + +describe('the CRLF pin', () => { + it('exempts the same extensions in .gitattributes as the CRLF scan skips', async () => { + const attributes = await readFile(join(projectDir, '.gitattributes'), 'utf8') + for (const tree of MOBILE_WEB_APP_SOURCE_DIRS) { + const pattern = `/${relative(projectDir, tree).split('\\').join('/')}/**` + for (const extension of BINARY_SOURCE_EXTENSIONS) { + // Without the exemption the blanket `text eol=lf` pin above it rewrites the binary and + // every asset hash with it. + expect(attributes, `${pattern}/*${extension} is not exempt`).toContain( + `${pattern}/*${extension} -text` + ) + } + } + }) +}) + +describeBundling('the app bundle', () => { + it('resolves react-native to react-native-web and leaves no require.context', async () => { + const sources = allScriptSource(await bundleMobileWebApp()) + for (const source of sources) { + expect(source).not.toContain('require.context') + } + // react-native-web's touch responder is proof the alias resolved rather than the native stub. + expect(sources.some((source) => source.includes('ResponderTouchHistoryStore'))).toBe(true) + }, 120_000) + + it('cuts the routes into chunks the entry does not load', async () => { + const { script, chunks, entryStaticBytes } = await bundleMobileWebApp() + expect(chunks.length).toBeGreaterThan(1) + // The entry's own bytes plus the chunks it imports statically, which is what the browser + // parses before any route paints. Every route chunk is outside it. + expect(entryStaticBytes).toBeGreaterThan(script.byteLength) + const allBytes = + script.byteLength + chunks.reduce((total, chunk) => total + chunk.bytes.byteLength, 0) + expect(entryStaticBytes).toBeLessThan(allBytes) + }, 120_000) + + it('names the chunk each route lands in', async () => { + const { chunks, routeChunks, routeKeys } = await bundleMobileWebApp() + expect(Object.keys(routeChunks).sort()).toEqual([...routeKeys].sort()) + const emitted = new Set(chunks.map((chunk) => chunk.name)) + for (const [key, name] of Object.entries(routeChunks)) { + expect(emitted, key).toContain(name) + } + // One chunk per route, never the entry: that is what a client-side navigation fetches. + expect(new Set(Object.values(routeChunks)).size).toBe(routeKeys.length) + }, 120_000) + + it('counts only static imports into what loads before the first route', () => { + const metafile = { + outputs: { + 'dist/entry.js': { + bytes: 10, + imports: [ + { path: 'dist/shared.js', kind: 'import-statement' }, + { path: 'dist/route.js', kind: 'dynamic-import' } + ] + }, + 'dist/shared.js': { + bytes: 20, + imports: [{ path: 'dist/deep.js', kind: 'import-statement' }] + }, + 'dist/deep.js': { bytes: 30, imports: [] }, + 'dist/route.js': { bytes: 40, imports: [] } + } + } + expect([...entryStaticClosure(metafile, 'dist/entry.js')]).toEqual([ + 'dist/entry.js', + 'dist/shared.js', + 'dist/deep.js' + ]) + }) + + it('does not walk a chunk cycle forever', () => { + const metafile = { + outputs: { + 'dist/entry.js': { bytes: 1, imports: [{ path: 'dist/a.js', kind: 'import-statement' }] }, + 'dist/a.js': { bytes: 1, imports: [{ path: 'dist/entry.js', kind: 'import-statement' }] } + } + } + expect(entryStaticClosure(metafile, 'dist/entry.js').size).toBe(2) + }) + + itBundling( + 'refuses to build a route the lazy manifest would strip an export from', + async () => { + await withScratch(async (scratch) => { + const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(directory, { recursive: true }) + await writeFile( + join(directory, 'index.tsx'), + 'export default function Route() { return null }\n' + ) + await expect(bundleMobileWebApp({ appDir: scratch })).resolves.toBeTruthy() + await writeFile( + join(directory, 'settings.tsx'), + 'const anchor = { anchor: "index" }\nexport { anchor as unstable_settings }\nexport default function Route() { return null }\n' + ) + // The build is where this has to fail: the page it would otherwise emit mounts with the + // export silently gone, which is a blank screen on a phone and nothing in any log. + await expect(bundleMobileWebApp({ appDir: scratch })).rejects.toThrow( + /settings\.tsx.*unstable_settings/s + ) + }) + }, + 240_000 + ) + + itBundling( + 'refuses a route whose star re-export it cannot read', + async () => { + await withScratch(async (scratch) => { + const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'boundary.ts'), 'export const value = 1\n') + await writeFile( + join(directory, 'index.tsx'), + 'export * from "./boundary"\nexport default function Route() { return null }\n' + ) + await expect(bundleMobileWebApp({ appDir: scratch })).rejects.toThrow( + /index\.tsx.*boundary/s + ) + }) + }, + 240_000 + ) + + it('bundles every route module', async () => { + const { routeKeys } = await bundleMobileWebApp() + expect(routeKeys).toEqual(await collectMobileWebAppRouteKeys(appDir)) + }, 120_000) + + it("bundles a route's .web.tsx sibling instead of the native file, changing the bytes", async () => { + await withScratch(async (scratch) => { + const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(directory, { recursive: true }) + const route = (marker) => `export default function Route() { return '${marker}' }\n` + await writeFile(join(directory, 'index.tsx'), route('native-route-marker')) + const before = await bundleMobileWebApp({ appDir: scratch }) + const has = (bundle, marker) => + allScriptSource(bundle).some((source) => source.includes(marker)) + expect(has(before, 'native-route-marker')).toBe(true) + + await writeFile(join(directory, 'index.web.tsx'), route('web-route-marker')) + const after = await bundleMobileWebApp({ appDir: scratch }) + expect(has(after, 'web-route-marker')).toBe(true) + expect(has(after, 'native-route-marker')).toBe(false) + // Different script bytes means a different asset sha and so a different buildId. + expect(after.script.equals(before.script)).toBe(false) + }) + }, 240_000) + + /** + * The same route tree, bundled from two directories at different depths. esbuild's own `[hash]` + * is computed over the metafile's input keys, which are paths relative to absWorkingDir, so two + * checkouts of one commit -- at different depths, or one with mobile/node_modules as a symlink + * and one with it as a directory -- name a byte-identical chunk differently. The rename + * cascades through every importer into a different buildId, and every phone re-downloads a + * bundle whose bytes did not change. + */ + async function bundleFromDepth(root, depth) { + const nested = join(root, ...Array.from({ length: depth }, (_, index) => `d${String(index)}`)) + const directory = join(nested, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(directory, { recursive: true }) + // Two routes over one import, which is what makes esbuild emit a shared chunk to name. + await writeFile(join(directory, 'shared.ts'), 'export const marker = "shared-marker"\n') + for (const name of ['index.tsx', 'other.tsx']) { + await writeFile( + join(directory, name), + `import { marker } from "./shared"\nexport default function Route() { return marker + "${name}" }\n` + ) + } + return { appDir: nested, bundle: await bundleMobileWebApp({ appDir: nested }) } + } + + it('names every output by its bytes, so another checkout path builds the same bundle', async () => { + await withScratch(async (shallow) => { + await withScratch(async (deep) => { + const near = await bundleFromDepth(shallow, 1) + const far = await bundleFromDepth(deep, 5) + const names = ({ bundle }) => [...bundle.chunks, ...bundle.images].map((one) => one.name) + expect(names(far)).toEqual(names(near)) + expect(far.bundle.script.equals(near.bundle.script)).toBe(true) + // The whole point: the manifest the phone compares is the same document. + const buildIdFrom = async ({ appDir }) => + withScratch(async (out) => { + const { manifest } = await buildMobileWebAppBundle({ appDir, outDir: join(out, 'x') }) + return manifest.buildId + }) + expect(await buildIdFrom(far)).toBe(await buildIdFrom(near)) + }) + }) + }, 240_000) + + it("names an output the same way the manifest's own asset hash does", async () => { + const { script, chunks } = await bundleMobileWebApp() + // The name is embedded in the importer, so it cannot be recomputed later; this is what says + // the name inside the bytes and the manifest's sha256 of those bytes are the same string. + expect(hashedAsset(script, 'js').path).toBe(`assets/${sha256Hex(script)}.js`) + for (const chunk of chunks) { + expect(chunk.name).toBe(`${sha256Hex(chunk.bytes)}.js`) + } + }, 120_000) + + it('asks esbuild for the split the budgets assume', async () => { + const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) + // Each of these is load-bearing for a budget below: esm and splitting are what make a route a + // chunk, and the metafile is the only thing that says which imports are static. + expect(options.format).toBe('esm') + expect(options.splitting).toBe(true) + expect(options.chunkNames).toBe('[hash]') + expect(options.metafile).toBe(true) + }) + + it('reads a route source the same way the export guard does', async () => { + const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) + // The guard parses each route on its own, outside this build. Sharing the table is what stops + // a loader the bundle relies on from being missing there and reported as a syntax error. + for (const [extension, loader] of Object.entries(ROUTE_SOURCE_LOADERS)) { + expect(options.loader[extension], extension).toBe(loader) + } + }) + + it('applies every shim it names', async () => { + const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) + for (const shim of MOBILE_WEB_APP_SHIMS) { + expect(shim.appliesTo(options), `${shim.name} is named but not applied`).toBe(true) + } + }) + + it('fails the named shim, not the whole build, when its option goes missing', async () => { + const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) + // Each shim reads a different option, so removing one leaves the other five true. Without + // that, the list could name a shim the build stopped applying. + const stripped = { + ...options, + alias: {}, + loader: {}, + define: {}, + banner: {}, + plugins: [] + } + expect(MOBILE_WEB_APP_SHIMS.filter((shim) => shim.appliesTo(stripped))).toEqual([]) + }) + + it('keeps the shims out of the shipped Phase A bootstrap builder', async () => { + const shipped = await readFile( + join(projectDir, 'config', 'scripts', 'build-mobile-web-bundle.mjs'), + 'utf8' + ) + for (const { name } of MOBILE_WEB_APP_SHIMS) { + expect(shipped, `the Phase A bootstrap builder mentions ${name}`).not.toContain(name) + } + expect(shipped).not.toContain('react-native-web') + expect(shipped).not.toContain('lucide') + }) + + it('embeds no absolute path from this checkout', async () => { + // Every chunk, not only the entry: the route manifest names each route by absolute path, and + // the chunk that import resolves to is where such a path would survive. + for (const source of allScriptSource(await bundleMobileWebApp())) { + expect(source).not.toContain(projectDir) + } + }, 120_000) + + it('builds the same buildId twice', async () => { + const first = await withScratch((scratch) => + buildMobileWebAppBundle({ outDir: join(scratch, 'a') }) + ) + const second = await withScratch((scratch) => + buildMobileWebAppBundle({ outDir: join(scratch, 'b') }) + ) + expect(first.manifest.buildId).toBe(second.manifest.buildId) + }, 120_000) + + it('loads the entry as a module, so its route imports resolve', async () => { + await withScratch(async (scratch) => { + const outDir = join(scratch, 'module-tag') + const { manifest } = await buildMobileWebAppBundle({ outDir }) + const html = await readFile(join(outDir, 'index.html'), 'utf8') + // import() in a classic script is a syntax error, so the tag and the format are one fact. + expect(html).toContain('