diff --git a/.github/workflows/real-termux.yml b/.github/workflows/real-termux.yml new file mode 100644 index 0000000000..204acc1c6f --- /dev/null +++ b/.github/workflows/real-termux.yml @@ -0,0 +1,57 @@ +name: Real Termux browser verification + +on: + workflow_dispatch: + +concurrency: + group: real-termux-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + HUSKY: "0" + +jobs: + real-termux: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - name: Download the pinned Termux app and verify its release checksum + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/rea-termux" + cd "$RUNNER_TEMP/rea-termux" + release=https://github.com/termux/termux-app/releases/download/v0.118.3 + apk=termux-app_v0.118.3+github-debug_x86_64.apk + sums=termux-app_v0.118.3+github-debug_sha256sums + curl --fail --location --retry 3 --output "$apk" "$release/$apk" + curl --fail --location --retry 3 --output "$sums" "$release/$sums" + awk -v apk="$apk" '$2 == apk || $2 == "*" apk' "$sums" > selected.sha256 + test "$(wc -l < selected.sha256)" -eq 1 + sha256sum --check selected.sha256 + - name: Enable emulator acceleration + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Build and capture a page inside Termux + uses: reactivecircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2 + with: + api-level: 30 + arch: x86_64 + target: google_apis + ram-size: 4096M + heap-size: 512M + disk-size: 8192M + disable-animations: true + script: bash scripts/verify/termux/emulator.sh + - name: Upload Termux and Android diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: termux-browser-diagnostics + path: ${{ runner.temp }}/rea-termux/diagnostics + if-no-files-found: warn diff --git a/docs/testing.md b/docs/testing.md index 3f91188e82..2dc5b40acf 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -801,6 +801,35 @@ separate Apple-artifact and real-provider lanes. Actual Hopper, Ghidra, IDA, browser and managed-tool claims require their corresponding verification lanes. Runner labels follow the [GitHub hosted-runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). +### Termux Android browser smoke test + +The **Real Termux browser verification** workflow is manual-only while its +emulator setup is being validated. After the workflow reaches the default +branch, select a revision under **Actions → Real Termux browser verification → +Run workflow**. It boots an Android 11 (API 30) x86_64 emulator, installs the +checksum-verified Termux 0.118.3 APK, and lets the app initialize its bootstrap. +The host stages the selected revision using `git archive HEAD`; no workstation +build outputs or dependencies are copied into Android. + +`scripts/verify/termux/emulator.sh` sends a `RUN_COMMAND` intent to Termux's app +service. Root access is limited to staging files, sending the intent, and reading +diagnostics; installation, compilation, Node, and Chromium run as Termux's app +UID. The in-app script installs Termux's Android-linked Node distribution, +checks its version against `.nvmrc`, pins npm from `packageManager`, runs +`npm ci`, and builds with `build:termux`. Termux repository packages, including +Chromium, are resolved at run time; their installed versions are logged. + +The verifier leaves Playwright's cache environment overrides unset, requires +a healthy public MCP doctor result, and captures URL plus DOM from a loopback +HTTP fixture through `capture-browser-scenario`. It checks completed steps, +the exact URL, a DOM marker, and reported browser cleanup. This lane covers one +Android emulator and Termux combination, not physical ARM devices, Electron, +or the complete desktop browser suite. A workflow definition alone does not +establish passing Android coverage; inspect its execution receipt. + +The `termux-browser-diagnostics` artifact retains the Termux command log, exit +status, and Android logcat on success or failure. The emulator is disposable. + ## Developer commands Use source feedback while editing, explicit boundary checks for the changed diff --git a/package-lock.json b/package-lock.json index 4b668f7989..7edec6958f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "oxlint": "1.87.0", "turbo": "2.11.7", "typescript": "7.0.2", + "typescript-termux": "npm:typescript@5.9.3", "vitepress": "1.6.4", "vitest": "5.0.3" }, @@ -7367,6 +7368,21 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/typescript-termux": { + "name": "typescript", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbash": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.12.tgz", diff --git a/package.json b/package.json index fbd1e08254..37eae2b939 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,8 @@ "precheck:ci": "npm run deps:check", "check:ci": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint format:check knip metadata:check jscpd scan:todos && npm run verify:architecture-guards", "build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills", + "build:termux": "node scripts/run-exclusive.mjs artifacts npm run build:termux:run", + "build:termux:run": "npm run compile:termux && npm run artifacts:skills", "format": "oxfmt .", "format:check": "oxfmt --check .", "lint": "oxlint && npm run verify:module-boundaries", @@ -239,6 +241,7 @@ "verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs", "mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check", "compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json", + "compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json", "artifacts:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs", "artifacts:skills": "node scripts/generate-skill-metadata.mjs", "artifacts:product-catalog": "node scripts/generate-product-catalog.mjs", @@ -312,6 +315,7 @@ "oxlint": "1.87.0", "turbo": "2.11.7", "typescript": "7.0.2", + "typescript-termux": "npm:typescript@5.9.3", "vitepress": "1.6.4", "vitest": "5.0.3" } diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 2e544487e0..769fa2fe0a 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { access } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; // Route production MCP before importing Incur. Incur owns registration helpers @@ -15,6 +15,21 @@ const isMcpMode = args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp"); const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +// Playwright's registry assumes one of its three desktop host platforms when +// it chooses the browser cache directory. Termux reports `android`, although +// its Node runtime and filesystem are POSIX-compatible. Keep the cache path +// explicit so REA can start on Termux. Browser launch still requires a +// caller-supplied Android-compatible Chromium executable. +if ( + process.platform === "android" && + process.env.PLAYWRIGHT_BROWSERS_PATH === undefined +) { + const cacheRoot = + process.env.XDG_CACHE_HOME ?? join(process.env.HOME ?? ".", ".cache"); + process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright"); +} + const runtimeFiles = isMcpMode ? ["dist/main.js"] : isMcpDoctorMode diff --git a/scripts/verify/termux/browser.mjs b/scripts/verify/termux/browser.mjs new file mode 100644 index 0000000000..ab9dfe0d07 --- /dev/null +++ b/scripts/verify/termux/browser.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createServer } from "node:http"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); +assert.equal(process.platform, "android", "Run this verifier inside Termux"); +assert.equal(process.env.PLAYWRIGHT_BROWSERS_PATH, undefined); +// Import no browser provider here: the public dispatcher must establish its +// Android cache fallback before Playwright initializes in each subprocess. +const { stdout: doctorOutput } = await execute( + process.execPath, + ["scripts/rea.mjs", "mcp", "doctor", "--format", "json"], + { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, +); +assert.equal(JSON.parse(doctorOutput).healthy, true, doctorOutput); +const executable = `${process.env.PREFIX}/bin/chromium`; +const { stdout: chromiumVersion } = await execute(executable, ["--version"], { + timeout: 30_000, +}); +const marker = "rea-termux-browser-smoke"; +const server = createServer((_request, response) => { + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + response.end(`

${marker}

`); +}); +await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); +}); +try { + const url = `http://127.0.0.1:${server.address().port}/smoke`; + const scenario = { + browser: { mode: "launch", executable_path: executable }, + start_url: { url }, + actions: [ + { + step_id: "ready", + action: "wait_for", + locator: { kind: "css", selector: "h1" }, + state: "visible", + }, + ], + capture: { at_end: ["url", "dom"] }, + }; + const { stdout } = await execute( + process.execPath, + [ + "scripts/rea.mjs", + "capture-browser-scenario", + JSON.stringify(scenario), + "--json", + ], + { timeout: 180_000, maxBuffer: 16 * 1024 * 1024 }, + ); + const evidence = JSON.parse(stdout); + assert.equal(evidence.error, undefined, stdout); + const capture = evidence.normalized_result; + assert.ok( + capture.steps.every((step) => step.status === "completed"), + stdout, + ); + const artifacts = capture.steps.at(-1).artifacts; + assert.equal(artifacts.url.state, "captured", stdout); + assert.equal(artifacts.url.value.url, url); + assert.equal(artifacts.dom.state, "captured", stdout); + assert.ok(artifacts.dom.value.text.includes(`

${marker}

`), stdout); + assert.equal(capture.browser.cleanup, "terminated-owned-process"); + console.log( + JSON.stringify({ + platform: process.platform, + architecture: process.arch, + node: process.version, + chromium: chromiumVersion.trim(), + mcp_doctor: "healthy", + url_capture: "passed", + dom_capture: "passed", + cleanup: capture.browser.cleanup, + }), + ); +} finally { + await new Promise((resolve, reject) => + server.close((cause) => (cause ? reject(cause) : resolve())), + ); +} diff --git a/scripts/verify/termux/emulator.sh b/scripts/verify/termux/emulator.sh new file mode 100644 index 0000000000..0d9a55e963 --- /dev/null +++ b/scripts/verify/termux/emulator.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Host-side orchestration. Root is used only to stage/read files and send the +# intent; TermuxService launches all package/build/browser work as the app UID. +set -euo pipefail + +staging="${RUNNER_TEMP:?}/rea-termux" +app_home=/data/data/com.termux/files/home +prefix=/data/data/com.termux/files/usr +mkdir -p "$staging/diagnostics" +collect_diagnostics() { + adb logcat -d > "$staging/diagnostics/logcat.txt" 2>&1 || true + adb exec-out cat "$app_home/rea-ci/run.log" > "$staging/diagnostics/termux.log" 2>&1 || true + adb exec-out cat "$app_home/rea-ci/status" > "$staging/diagnostics/status.txt" 2>&1 || true +} +trap collect_diagnostics EXIT + +adb root +adb wait-for-device +adb install "$staging/termux-app_v0.118.3+github-debug_x86_64.apk" +adb shell am start -n com.termux/.app.TermuxActivity +# First launch unpacks the APK's bundled bootstrap. Do not replace it with a +# root-installed userland: that would miss Termux's own initialization. +deadline=$((SECONDS + 180)) +until adb shell test -x "$prefix/bin/bash"; do + if (( SECONDS >= deadline )); then + echo 'Termux bootstrap did not finish within 180 seconds' >&2 + exit 1 + fi + sleep 2 +done + +git archive --format=tar HEAD > "$staging/source.tar" +adb push "$staging/source.tar" /data/local/tmp/rea-source.tar +adb push scripts/verify/termux/in-app.sh /data/local/tmp/rea-in-app.sh +adb shell chmod 644 /data/local/tmp/rea-source.tar /data/local/tmp/rea-in-app.sh +app_uid=$(adb shell stat -c %u /data/data/com.termux | tr -d '\r') +[[ "$app_uid" =~ ^[0-9]+$ && "$app_uid" != 0 ]] +adb shell mkdir -p "$app_home/rea-ci" "$app_home/.termux" +adb shell "echo allow-external-apps=true > $app_home/.termux/termux.properties" +adb shell cp /data/local/tmp/rea-in-app.sh "$app_home/rea-ci/in-app.sh" +adb shell cp /data/local/tmp/rea-source.tar "$app_home/rea-ci/source.tar" +adb shell chown -R "$app_uid:$app_uid" "$app_home/rea-ci" "$app_home/.termux" +adb shell restorecon -RF "$app_home/rea-ci" "$app_home/.termux" +# Reload the external-command policy in a fresh app process before sending it. +adb shell am force-stop com.termux +adb shell am start -n com.termux/.app.TermuxActivity +# Root may send this permission-protected intent; the service executes as the +# application, rather than running Node/Chromium through adb shell or su. +adb shell am startservice \ + -n com.termux/.app.RunCommandService \ + -a com.termux.RUN_COMMAND \ + --es com.termux.RUN_COMMAND_PATH "$prefix/bin/bash" \ + --esa com.termux.RUN_COMMAND_ARGUMENTS "-l,$app_home/rea-ci/in-app.sh" \ + --es com.termux.RUN_COMMAND_WORKDIR "$app_home" \ + --ez com.termux.RUN_COMMAND_BACKGROUND true + +deadline=$((SECONDS + 2100)) +until adb shell test -f "$app_home/rea-ci/status"; do + if (( SECONDS >= deadline )); then + echo 'Termux verification exceeded 35 minutes' >&2 + exit 1 + fi + sleep 5 +done +adb exec-out cat "$app_home/rea-ci/run.log" +status=$(adb shell cat "$app_home/rea-ci/status" | tr -d '\r') +[[ "$status" == 0 ]] diff --git a/scripts/verify/termux/in-app.sh b/scripts/verify/termux/in-app.sh new file mode 100644 index 0000000000..c0db638870 --- /dev/null +++ b/scripts/verify/termux/in-app.sh @@ -0,0 +1,33 @@ +#!/data/data/com.termux/files/usr/bin/bash +set -euo pipefail + +report="$HOME/rea-ci" +exec > "$report/run.log" 2>&1 +# Publish the result only after all commands have settled. The host reads it as +# root; no shared-storage permission or helper Android application is needed. +trap 'result=$?; printf "%s\n" "$result" > "$report/status"' EXIT +set -x +[[ "$(id -u)" != 0 ]] +export DEBIAN_FRONTEND=noninteractive +export HUSKY=0 +pkg update -y +pkg upgrade -y +pkg install -y nodejs-lts npm git x11-repo termux-exec +pkg install -y chromium +mkdir -p "$report/source" +tar -xf "$report/source.tar" -C "$report/source" +cd "$report/source" +node -e 'if (process.platform !== "android") throw new Error("Expected Android Node.js")' +node -e 'const expected = require("node:fs").readFileSync(".nvmrc", "utf8").trim().replace(/^v/, ""); if (process.versions.node !== expected) throw new Error(`Termux Node ${process.versions.node} differs from .nvmrc ${expected}`)' +# Android uses Termux's Bionic-linked Node distribution; the desktop .nvmrc +# binary cannot run here. Keep npm at the repository's exact pinned version. +npm install --prefix "$report/toolchain" --no-package-lock --ignore-scripts \ + "$(node -p 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).packageManager')" +export PATH="$report/toolchain/node_modules/.bin:$PATH" +node --version +npm --version +dpkg-query -W nodejs-lts chromium termux-exec +npm ci +npm run build:termux +unset PLAYWRIGHT_BROWSERS_PATH npm_config_playwright_browsers_path npm_package_config_playwright_browsers_path +node scripts/verify/termux/browser.mjs diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts index 40d5896a3b..79e9039751 100644 --- a/src/browser/PlaywrightScenarioBrowser.test.ts +++ b/src/browser/PlaywrightScenarioBrowser.test.ts @@ -8,7 +8,12 @@ import { PlaywrightScenarioBrowserCleanupOwner, } from "./PlaywrightScenarioBrowser.js"; -afterEach(() => vi.unstubAllEnvs()); +const hostPlatform = process.platform; + +afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(process, "platform", { value: hostPlatform }); +}); it("locks one attached browser for spellings that discover the same endpoint", () => { expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).toBe( @@ -73,6 +78,43 @@ it("passes the selected environment to the actual browser launch boundary", asyn }); }); +it("keeps Termux execution support when launching Chromium on Android", async () => { + Object.defineProperty(process, "platform", { value: "android" }); + vi.stubEnv("PREFIX", "/data/data/com.termux/files/usr"); + const selected = { LANG: "selected-language", PREFIX: process.env.PREFIX }; + let launchOptions: Parameters< + typeof import("playwright-core").chromium.launchPersistentContext + >[1]; + const scenario = browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: process.execPath }, + start_url: { url: "https://example.test" }, + actions: [ + { step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }, + ], + }); + + await expect( + openPlaywrightScenarioBrowser(scenario, selected, { + launcher: { + connectOverCDP: async () => { + throw new Error("Unexpected connection"); + }, + launchPersistentContext: async (_profile, options) => { + launchOptions = options; + throw new Error("launch boundary reached"); + }, + }, + }), + ).rejects.toThrow("launch boundary reached"); + + expect(launchOptions?.args).toEqual(["--no-sandbox", "--single-process"]); + expect(launchOptions?.env).toEqual({ + LANG: "selected-language", + LD_PRELOAD: "/data/data/com.termux/files/usr/lib/libtermux-exec.so", + TERMUX_EXEC__PROC_SELF_EXE: process.execPath, + }); +}); + it("keeps attached-target ownership until cancelled cleanup actually settles", async () => { const controller = new AbortController(); const closeStarted = deferred(); diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts index 4cac279b63..4aa2c50fca 100644 --- a/src/browser/PlaywrightScenarioBrowser.ts +++ b/src/browser/PlaywrightScenarioBrowser.ts @@ -273,13 +273,26 @@ const allowedBrowserEnvironment = ( "XAUTHORITY", "XDG_RUNTIME_DIR", ]; - return Object.fromEntries( + const selected = Object.fromEntries( allowed .map((name) => [name, environment[name]] as const) .filter( (entry): entry is readonly [string, string] => entry[1] !== undefined, ), ); + if (process.platform !== "android") return selected; + + // Android rejects execve() for binaries below Termux's application data + // directory. Termux supplies an exec interceptor that rewrites those calls + // through the system linker and removes itself for the launched program. + // Playwright must retain it for its Chromium child process. + const prefix = environment.PREFIX; + if (prefix === undefined) return selected; + return { + ...selected, + LD_PRELOAD: `${prefix}/lib/libtermux-exec.so`, + TERMUX_EXEC__PROC_SELF_EXE: process.execPath, + }; }; const findConnectedPage = async ( browser: Pick, @@ -495,6 +508,13 @@ export const openPlaywrightScenarioBrowser = async ( handleSIGINT: false, handleSIGTERM: false, timeout: 0, + // Termux's Chromium cannot create its renderer process through Android's + // executable boundary. Single-process mode keeps the browser and page in + // the Termux-launched process so scenario navigation remains available. + args: + process.platform === "android" + ? ["--no-sandbox", "--single-process"] + : [], }); } catch (cause: unknown) { const cleanup = () => diff --git a/vitest.config.ts b/vitest.config.ts index 93ecbb64c8..099d2c2956 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,18 @@ import { join } from "node:path"; import { defineConfig } from "vitest/config"; +// Playwright's registry does not derive a cache directory for Termux's +// `android` platform. Match the production CLI fallback before workers import +// browser modules. +if ( + process.platform === "android" && + process.env.PLAYWRIGHT_BROWSERS_PATH === undefined +) { + const cacheRoot = + process.env.XDG_CACHE_HOME ?? join(process.env.HOME ?? ".", ".cache"); + process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright"); +} + const CANONICAL_TEMPORARY_DIRECTORY = realpathSync(tmpdir()); const COVERAGE_ENABLED = process.argv.some((argument) => argument.startsWith("--coverage"),