From df9550fce956b1e71e67fdb38dc21754e9449940 Mon Sep 17 00:00:00 2001 From: AmirHossein Rezaei <78272016+DinonowDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:37:19 +0330 Subject: [PATCH] fix(cli): prepare Next.js cache dir on Android/Termux before serve (#8593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): ensure `~/.cache` is created and `XDG_CACHE_HOME` is set before Next.js loads on Android/Termux to prevent silent HTTP 500 errors due to instrumentation hook failures (#8519) * chore(quality): ignore XDG_CACHE_HOME in the env/docs contract scanner XDG_CACHE_HOME is an XDG Base Directory spec variable set by the OS or the operator, never OmniRoute product config — the same reason XDG_CONFIG_HOME is already ignored. The Android/Termux cache-dir preparation added here reads it to honor an operator-set cache location, which made check-env-doc-sync demand an .env.example/ENVIRONMENT.md entry for a variable we do not own. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * build(pack): require bin/cli/utils/ensureAndroidCacheDir.mjs in the tarball bin/omniroute.mjs imports this module at startup to prepare the Next.js cache dir before serve on Android/Termux. bin/cli/ is only an allowlist PREFIX, so a file missing from the tarball would not fail the unexpected-paths check — it would ship a CLI that throws ERR_MODULE_NOT_FOUND on the very platform this change targets. Registering it makes the absence loud, same guard class as storageKeyProvision.mjs and versionFastPath.mjs. Caught by tests/unit/pack-artifact-entrypoint-closures.test.ts in the v3.8.49 merge-train. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- bin/cli/commands/serve.mjs | 85 +++++-- bin/cli/runtime/processSupervisor.mjs | 44 +++- bin/cli/utils/ensureAndroidCacheDir.mjs | 122 +++++++++ bin/omniroute.mjs | 9 + .../fixes/8519-termux-android-cache-dir.md | 1 + docs/guides/TERMUX_GUIDE.md | 27 +- scripts/build/pack-artifact-policy.ts | 1 + scripts/check/check-env-doc-sync.mjs | 3 + tests/unit/termux-android-cache-dir.test.ts | 231 ++++++++++++++++++ 9 files changed, 492 insertions(+), 31 deletions(-) create mode 100644 bin/cli/utils/ensureAndroidCacheDir.mjs create mode 100644 changelog.d/fixes/8519-termux-android-cache-dir.md create mode 100644 tests/unit/termux-android-cache-dir.test.ts diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index 7c98583e2a..8e819895d6 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -7,6 +7,11 @@ import { t } from "../i18n.mjs"; import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs"; import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs"; import { isTermux } from "../../../scripts/build/postinstallSupport.mjs"; +import { + ensureAndroidCacheDir, + isFatalInstrumentationHookFailure, + formatAndroidInstrumentationFailureHint, +} from "../utils/ensureAndroidCacheDir.mjs"; import { resolveMaxOldSpaceMb, calibrateHeapFallbackMb, @@ -62,9 +67,37 @@ export function registerServe(program) { }); } +/** Once-per-process guard so the Android/Termux cache hint is not spammed. */ +let instrumentationFailureHintPrinted = false; + +/** + * If child output looks like Next.js failed to load its instrumentation hook + * on Android/Termux, print a clear operator-facing fix hint. + * Exported for unit tests. + * + * @param {string} text + * @returns {boolean} true when a hint was printed + */ +export function maybeReportInstrumentationHookFailure(text) { + if (instrumentationFailureHintPrinted) return false; + if (!isFatalInstrumentationHookFailure(text)) return false; + instrumentationFailureHintPrinted = true; + process.stderr.write(formatAndroidInstrumentationFailureHint(process.env.XDG_CACHE_HOME)); + return true; +} + +/** Test-only reset for the once-per-process hint guard. */ +export function resetInstrumentationFailureHintForTests() { + instrumentationFailureHintPrinted = false; +} + export async function runServe(opts = {}) { const startedAt = performance.now(); + // Same prep as bin/omniroute.mjs — keep it here so a direct `runServe()` call + // (tests / programmatic) still gets a writable Next.js cache dir before spawn. + ensureAndroidCacheDir({ env: process.env }); + const { isNativeBinaryCompatible } = await import("../../../scripts/build/native-binary-compat.mjs"); const { getNodeRuntimeSupport, getNodeRuntimeWarning } = @@ -134,7 +167,11 @@ export async function runServe(opts = {}) { "Release", "better_sqlite3.node" ); - if (!process.versions.bun && existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) { + if ( + !process.versions.bun && + existsSync(sqliteBinary) && + !isNativeBinaryCompatible(sqliteBinary) + ) { console.error( "\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m" ); @@ -230,15 +267,16 @@ export async function runServe(opts = {}) { function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) { // #5238: skip the explicit CLI --max-old-space-size when the user pinned the // heap via NODE_OPTIONS (a CLI arg would shadow/override their value). - const server = spawn(process.versions.bun ? process.execPath : "node", [ - ...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), - serverJs, - ], { - cwd: APP_DIR, - env, - stdio: "ignore", - detached: true, - }); + const server = spawn( + process.versions.bun ? process.execPath : "node", + [...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), serverJs], + { + cwd: APP_DIR, + env, + stdio: "ignore", + detached: true, + } + ); writePidFile("server", server.pid); server.unref(); console.log(`\x1b[32m✔ OmniRoute started in background (PID: ${server.pid})\x1b[0m`); @@ -249,14 +287,15 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) { function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen, startedAt) { // #5238: skip the explicit CLI --max-old-space-size when the user pinned the // heap via NODE_OPTIONS (a CLI arg would shadow/override their value). - const server = spawn(process.versions.bun ? process.execPath : "node", [ - ...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), - serverJs, - ], { - cwd: APP_DIR, - env, - stdio: "pipe", - }); + const server = spawn( + process.versions.bun ? process.execPath : "node", + [...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), serverJs], + { + cwd: APP_DIR, + env, + stdio: "pipe", + } + ); writePidFile("server", server.pid); @@ -265,6 +304,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, server.stdout.on("data", (data) => { const text = data.toString(); process.stdout.write(text); + maybeReportInstrumentationHookFailure(text); if ( !started && (text.includes("Ready") || text.includes("started") || text.includes("listening")) @@ -274,7 +314,11 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, } }); - server.stderr.on("data", (data) => process.stderr.write(data)); + server.stderr.on("data", (data) => { + const text = data.toString(); + process.stderr.write(text); + maybeReportInstrumentationHookFailure(text); + }); server.on("error", (err) => { console.error("\x1b[31m✖ Failed to start server:\x1b[0m", err.message); @@ -383,6 +427,9 @@ export function reportReadinessTimeout(dashboardPort, supervisor) { console.error("--- Recent server output ---"); recentLog.forEach((l) => console.error(l)); console.error("--- End recent output ---\n"); + // If the buffered log already shows the Android instrumentation failure, + // print the actionable hint even when --log was off (default). + maybeReportInstrumentationHookFailure(recentLog.join("\n")); } } diff --git a/bin/cli/runtime/processSupervisor.mjs b/bin/cli/runtime/processSupervisor.mjs index 10a7730f74..3c79bd213c 100644 --- a/bin/cli/runtime/processSupervisor.mjs +++ b/bin/cli/runtime/processSupervisor.mjs @@ -10,11 +10,21 @@ import { } from "./supervisorPolicy.mjs"; import { buildNodeHeapArgs } from "../../../scripts/build/runtime-env.mjs"; import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts"; +import { + isFatalInstrumentationHookFailure, + formatAndroidInstrumentationFailureHint, +} from "../utils/ensureAndroidCacheDir.mjs"; const CRASH_LOG_LINES = 50; export class ServerSupervisor { - constructor({ serverPath, env, maxRestarts = DEFAULT_MAX_RESTARTS, memoryLimit = 512, onCrashCallback }) { + constructor({ + serverPath, + env, + maxRestarts = DEFAULT_MAX_RESTARTS, + memoryLimit = 512, + onCrashCallback, + }) { this.serverPath = serverPath; this.env = env; this.maxRestarts = maxRestarts; @@ -25,11 +35,13 @@ export class ServerSupervisor { this.crashLog = []; this.child = null; this.isShuttingDown = false; + this.instrumentationFailureHintPrinted = false; } start() { this.startedAt = Date.now(); this.crashLog = []; + this.instrumentationFailureHintPrinted = false; const showLog = process.env.OMNIROUTE_SHOW_LOG === "1"; // #5238: skip the explicit CLI --max-old-space-size when the user pinned the @@ -41,23 +53,35 @@ export class ServerSupervisor { // silently, so a boot that never becomes ready looked like a dead hang with zero // output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside // stderr so a readiness timeout can surface what the child actually printed. - this.child = spawn(process.versions.bun ? process.execPath : "node", [ - ...(process.versions.bun ? [] : heapArgs), - this.serverPath, - ], { - cwd: dirname(this.serverPath), - env: this.env, - stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"], - }); + this.child = spawn( + process.versions.bun ? process.execPath : "node", + [...(process.versions.bun ? [] : heapArgs), this.serverPath], + { + cwd: dirname(this.serverPath), + env: this.env, + stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"], + } + ); writePidFile("server", this.child.pid); const bufferOutput = (data) => { - const lines = data.toString().split("\n").filter(Boolean); + const text = data.toString(); + const lines = text.split("\n").filter(Boolean); this.crashLog.push(...lines); if (this.crashLog.length > CRASH_LOG_LINES) { this.crashLog = this.crashLog.slice(-CRASH_LOG_LINES); } + // Surface Android/Termux instrumentation-hook failures even when --log is + // off (output is only buffered otherwise). + if (!this.instrumentationFailureHintPrinted && isFatalInstrumentationHookFailure(text)) { + this.instrumentationFailureHintPrinted = true; + process.stderr.write( + formatAndroidInstrumentationFailureHint( + this.env?.XDG_CACHE_HOME || process.env.XDG_CACHE_HOME + ) + ); + } }; if (this.child.stdout) { diff --git a/bin/cli/utils/ensureAndroidCacheDir.mjs b/bin/cli/utils/ensureAndroidCacheDir.mjs new file mode 100644 index 0000000000..30fe073f8b --- /dev/null +++ b/bin/cli/utils/ensureAndroidCacheDir.mjs @@ -0,0 +1,122 @@ +/** + * Next.js cache-dir prep for Android / Termux. + * + * Next.js `getCacheDirectory()` has no dedicated branch for + * `process.platform === "android"`. On that path it only accepts a cache root + * that *already* exists (`fs.existsSync` on `~/.cache` or a generic tmp dir). + * If neither exists it prints `Unsupported platform: android` and exits — the + * CLI can still look "running" while every request returns a bare HTTP 500 + * because the instrumentation hook never loads (and so neither does logging). + * + * Termux Node sometimes reports `platform === "android"` and sometimes + * `"linux"` with Termux env signals (`TERMUX_VERSION` / `PREFIX`). Creating + * `~/.cache` (and pointing `XDG_CACHE_HOME` at it when unset) makes the probe + * succeed on both shapes. + * + * Call this *before* spawning or loading Next.js. Safe no-op on desktop + * platforms that are not Termux. + */ + +import { existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { isTermux } from "../../../scripts/build/postinstallSupport.mjs"; + +/** + * @param {string} [platform] + * @param {NodeJS.ProcessEnv} [env] + * @returns {boolean} + */ +export function needsAndroidCacheDirPrep(platform = process.platform, env = process.env) { + return platform === "android" || isTermux(env); +} + +/** + * @param {() => string} [homedirFn] + * @param {NodeJS.ProcessEnv} [env] + * @returns {string} + */ +export function resolveAndroidCacheDir(homedirFn = homedir, env = process.env) { + if (typeof env.XDG_CACHE_HOME === "string" && env.XDG_CACHE_HOME.trim()) { + return env.XDG_CACHE_HOME; + } + return join(homedirFn(), ".cache"); +} + +/** + * Ensure a writable cache directory exists for Next.js on Android/Termux. + * + * @param {object} [options] + * @param {string} [options.platform] + * @param {NodeJS.ProcessEnv} [options.env] + * @param {() => string} [options.homedirFn] + * @param {typeof mkdirSync} [options.mkdirSyncFn] + * @param {typeof existsSync} [options.existsSyncFn] + * @param {boolean} [options.setEnv] When true (default), set `XDG_CACHE_HOME` on `env` + * if unset so child processes inherit a known-writable cache root. + * @returns {{ prepared: boolean, cacheDir: string | null, created: boolean }} + */ +export function ensureAndroidCacheDir(options = {}) { + const { + platform = process.platform, + env = process.env, + homedirFn = homedir, + mkdirSyncFn = mkdirSync, + existsSyncFn = existsSync, + setEnv = true, + } = options; + + if (!needsAndroidCacheDirPrep(platform, env)) { + return { prepared: false, cacheDir: null, created: false }; + } + + const cacheDir = resolveAndroidCacheDir(homedirFn, env); + let created = false; + if (!existsSyncFn(cacheDir)) { + mkdirSyncFn(cacheDir, { recursive: true }); + created = true; + } + + if (setEnv && !(typeof env.XDG_CACHE_HOME === "string" && env.XDG_CACHE_HOME.trim())) { + env.XDG_CACHE_HOME = cacheDir; + } + + return { prepared: true, cacheDir, created }; +} + +/** + * Detect Next.js instrumentation-hook failures that leave the server looking + * "up" while requests get silent HTTP 500s (typical when the Android cache + * probe failed before logging started). + * + * @param {string} text + * @returns {boolean} + */ +export function isFatalInstrumentationHookFailure(text) { + if (!text) return false; + return ( + /Unsupported platform:\s*android/i.test(text) || + /error occurred while loading instrumentation hook/i.test(text) + ); +} + +/** + * Operator-facing hint when that instrumentation failure shows up in child + * output — defense in depth if prep was skipped or a future Next.js probe + * regresses. + * + * @param {string} [cacheDir] + * @returns {string} + */ +export function formatAndroidInstrumentationFailureHint(cacheDir) { + const dir = cacheDir || join(homedir(), ".cache"); + return ( + `\n\x1b[31m✖ Next.js instrumentation failed on Android/Termux (likely missing cache dir).\x1b[0m\n` + + ` OmniRoute tried to create a writable cache at:\n` + + ` \x1b[36m${dir}\x1b[0m\n` + + ` Manual workaround (survives reinstalls — do NOT patch dist/server.js):\n` + + ` \x1b[36mmkdir -p ~/.cache\x1b[0m\n` + + ` then restart: \x1b[36momniroute serve\x1b[0m\n` + + ` See: docs/guides/TERMUX_GUIDE.md → Troubleshooting → Unsupported platform: android\n` + ); +} diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index ce2c011d9a..fa216bb3ff 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -145,6 +145,15 @@ function loadEnvFile() { loadEnvFile(); +// Next.js has no android branch in getCacheDirectory(): if ~/.cache (and tmp) +// do not already exist it aborts the instrumentation hook, and every request +// then returns a silent HTTP 500 even though the CLI still looks "running". +// Create the cache dir (and set XDG_CACHE_HOME when unset) before serve/Next. +{ + const { ensureAndroidCacheDir } = await import("./cli/utils/ensureAndroidCacheDir.mjs"); + ensureAndroidCacheDir(); +} + // Generate STORAGE_ENCRYPTION_KEY if not set (persisted to ~/.omniroute/.env) // This ensures the key survives across upgrades and is not regenerated on each install. // See: https://github.com/diegosouzapw/OmniRoute/issues/1622 diff --git a/changelog.d/fixes/8519-termux-android-cache-dir.md b/changelog.d/fixes/8519-termux-android-cache-dir.md new file mode 100644 index 0000000000..e92c18588c --- /dev/null +++ b/changelog.d/fixes/8519-termux-android-cache-dir.md @@ -0,0 +1 @@ +- fix(cli): create `~/.cache` (and set `XDG_CACHE_HOME`) before Next.js loads on Android/Termux so `getCacheDirectory()` no longer aborts with `Unsupported platform: android`, which previously left every request as a silent HTTP 500 after the CLI still looked "running" (#8519) diff --git a/docs/guides/TERMUX_GUIDE.md b/docs/guides/TERMUX_GUIDE.md index a62ce0000f..43159ef1ee 100644 --- a/docs/guides/TERMUX_GUIDE.md +++ b/docs/guides/TERMUX_GUIDE.md @@ -1,7 +1,7 @@ --- title: "Termux Headless Setup" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.49 +lastUpdated: 2026-07-25 --- # Termux Headless Setup @@ -129,6 +129,29 @@ omniroute ## Troubleshooting +### Unsupported platform: android (every request returns HTTP 500) + +**Symptom:** `omniroute` / `omniroute serve` prints `✔ OmniRoute is running!`, but every dashboard or API request returns a bare `500 Internal Server Error`. `~/.omniroute/logs/application/app.log` stays empty, `APP_LOG_LEVEL=debug` prints nothing useful, and the response body is plain text (`Internal Server Error`) with no JSON detail. + +**Cause:** Some Termux/Node builds report `process.platform === "android"`. Next.js `getCacheDirectory()` does not handle that platform: it requires `~/.cache` (or a generic tmp dir) to _already_ exist, otherwise it fails while loading the instrumentation hook with: + +```text +Error: An error occurred while loading instrumentation hook: Unsupported platform: android +``` + +Because the hook never loads, logging never starts — the 500 looks completely undiagnosable. OmniRoute creates `~/.cache` (and sets `XDG_CACHE_HOME` when unset) in the CLI entrypoint before Next.js starts so this probe succeeds on Android/Termux. + +**Supported resolution (no package patching):** + +```bash +mkdir -p ~/.cache +omniroute serve +``` + +On current OmniRoute builds the CLI does this automatically on Android/Termux — a fresh `npx -y omniroute@latest` / global install should not require the manual step. If you still see the error after upgrading, create `~/.cache` once as above and restart. + +**Do not** patch `dist/server.js` to force `process.platform = "linux"`. That kind of package patch is overwritten on every reinstall/upgrade and is unnecessary once the cache directory exists. + ### better-sqlite3 Build Errors Install the Termux build toolchain: diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 2ac9dbf136..1decf97ff2 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -176,6 +176,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ // required entries make its absence loud (#7065 class; derived + enforced by // tests/unit/pack-artifact-entrypoint-closures.test.ts). "bin/cli/data-dir.mjs", + "bin/cli/utils/ensureAndroidCacheDir.mjs", "bin/cli/utils/storageKeyProvision.mjs", "bin/cli/utils/versionFastPath.mjs", "bin/mcp-server.mjs", diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 408c53d5ca..ffd4add27a 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -61,6 +61,9 @@ const IGNORE_FROM_CODE = new Set([ "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", + // XDG Base Directory cache root — read (never defined by OmniRoute) so the + // Android/Termux serve path can honor an operator-set cache location (#8519). + "XDG_CACHE_HOME", "USERPROFILE", "PREFIX", // X11 display server — set by the OS/session manager, not OmniRoute config. diff --git a/tests/unit/termux-android-cache-dir.test.ts b/tests/unit/termux-android-cache-dir.test.ts new file mode 100644 index 0000000000..09c6b3b535 --- /dev/null +++ b/tests/unit/termux-android-cache-dir.test.ts @@ -0,0 +1,231 @@ +/** + * Termux/Android: Next.js cache-dir prep and instrumentation-failure hints. + * + * Guards the behavior where a missing `~/.cache` on `platform === "android"` + * makes Next.js abort its instrumentation hook and leave every request as a + * silent HTTP 500. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +import { + needsAndroidCacheDirPrep, + resolveAndroidCacheDir, + ensureAndroidCacheDir, + isFatalInstrumentationHookFailure, + formatAndroidInstrumentationFailureHint, +} from "../../bin/cli/utils/ensureAndroidCacheDir.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, "../.."); + +test("needsAndroidCacheDirPrep: true for platform android", () => { + assert.equal(needsAndroidCacheDirPrep("android", {}), true); +}); + +test("needsAndroidCacheDirPrep: true for Termux env even when platform is linux", () => { + assert.equal(needsAndroidCacheDirPrep("linux", { TERMUX_VERSION: "0.119" }), true); + assert.equal( + needsAndroidCacheDirPrep("linux", { PREFIX: "/data/data/com.termux/files/usr" }), + true + ); +}); + +test("needsAndroidCacheDirPrep: false on desktop platforms without Termux signals", () => { + assert.equal(needsAndroidCacheDirPrep("darwin", {}), false); + assert.equal(needsAndroidCacheDirPrep("linux", {}), false); + assert.equal(needsAndroidCacheDirPrep("win32", { PREFIX: "/usr/local" }), false); +}); + +test("resolveAndroidCacheDir: prefers XDG_CACHE_HOME when set", () => { + assert.equal( + resolveAndroidCacheDir(() => "/home/u", { XDG_CACHE_HOME: "/custom/cache" }), + "/custom/cache" + ); +}); + +test("resolveAndroidCacheDir: falls back to /.cache", () => { + assert.equal( + resolveAndroidCacheDir(() => "/data/data/com.termux/files/home", {}), + join("/data/data/com.termux/files/home", ".cache") + ); +}); + +test("ensureAndroidCacheDir: no-op on darwin (does not mkdir, does not set env)", () => { + const env = {}; + const calls = []; + const result = ensureAndroidCacheDir({ + platform: "darwin", + env, + mkdirSyncFn: (...args) => { + calls.push(args); + }, + existsSyncFn: () => false, + }); + assert.deepEqual(result, { prepared: false, cacheDir: null, created: false }); + assert.equal(calls.length, 0); + assert.equal(env.XDG_CACHE_HOME, undefined); +}); + +test("ensureAndroidCacheDir: creates ~/.cache when missing on android", () => { + const home = mkdtempSync(join(tmpdir(), "omniroute-android-cache-home-")); + const cacheDir = join(home, ".cache"); + const env = {}; + + try { + assert.equal(existsSync(cacheDir), false); + const result = ensureAndroidCacheDir({ + platform: "android", + env, + homedirFn: () => home, + }); + assert.equal(result.prepared, true); + assert.equal(result.created, true); + assert.equal(result.cacheDir, cacheDir); + assert.equal(existsSync(cacheDir), true); + assert.equal(env.XDG_CACHE_HOME, cacheDir); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("ensureAndroidCacheDir: does not recreate when ~/.cache already exists", () => { + const home = mkdtempSync(join(tmpdir(), "omniroute-android-cache-existing-")); + const cacheDir = join(home, ".cache"); + mkdirSync(cacheDir); + const env = {}; + let mkdirCalls = 0; + + try { + const result = ensureAndroidCacheDir({ + platform: "android", + env, + homedirFn: () => home, + mkdirSyncFn: () => { + mkdirCalls += 1; + }, + }); + assert.equal(result.prepared, true); + assert.equal(result.created, false); + assert.equal(mkdirCalls, 0); + assert.equal(env.XDG_CACHE_HOME, cacheDir); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("ensureAndroidCacheDir: respects an existing XDG_CACHE_HOME and creates that path", () => { + const root = mkdtempSync(join(tmpdir(), "omniroute-android-cache-xdg-")); + const xdg = join(root, "xdg-cache"); + const env = { XDG_CACHE_HOME: xdg }; + + try { + const result = ensureAndroidCacheDir({ + platform: "android", + env, + homedirFn: () => root, + }); + assert.equal(result.cacheDir, xdg); + assert.equal(existsSync(xdg), true); + assert.equal(env.XDG_CACHE_HOME, xdg); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("ensureAndroidCacheDir: Termux-on-linux still prepares ~/.cache", () => { + const home = mkdtempSync(join(tmpdir(), "omniroute-android-cache-termux-")); + const env = { TERMUX_VERSION: "0.119" }; + + try { + const result = ensureAndroidCacheDir({ + platform: "linux", + env, + homedirFn: () => home, + }); + assert.equal(result.prepared, true); + assert.equal(existsSync(join(home, ".cache")), true); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("isFatalInstrumentationHookFailure: matches Next.js android + hook errors", () => { + assert.equal(isFatalInstrumentationHookFailure("Unsupported platform: android"), true); + assert.equal( + isFatalInstrumentationHookFailure( + "Error: An error occurred while loading instrumentation hook: Unsupported platform: android" + ), + true + ); + assert.equal(isFatalInstrumentationHookFailure("Ready in 1200ms"), false); + assert.equal(isFatalInstrumentationHookFailure(""), false); +}); + +test("formatAndroidInstrumentationFailureHint: names the cache dir and TERMUX_GUIDE", () => { + const hint = formatAndroidInstrumentationFailureHint("/data/home/.cache"); + assert.match(hint, /\/data\/home\/\.cache/); + assert.match(hint, /mkdir -p ~\/\.cache/); + assert.match(hint, /TERMUX_GUIDE/); + assert.match(hint, /do NOT patch dist\/server\.js/i); +}); + +test("CLI entrypoint calls ensureAndroidCacheDir before Commander/Next load", () => { + const src = readFileSync(join(ROOT, "bin/omniroute.mjs"), "utf8"); + assert.match(src, /ensureAndroidCacheDir\(\)/); + // Real import is join(ROOT, "bin", "cli", "program.mjs") — not a contiguous path. + // Header comments also mention program.mjs; compare call site vs last occurrence. + const callIdx = src.indexOf("ensureAndroidCacheDir();"); + const programImportIdx = src.lastIndexOf("program.mjs"); + assert.ok(callIdx > 0, "omniroute.mjs must call ensureAndroidCacheDir()"); + assert.ok(programImportIdx > 0, "omniroute.mjs must still load program.mjs"); + assert.ok( + callIdx < programImportIdx, + "ensureAndroidCacheDir() must run before program.mjs is imported" + ); +}); + +test("serve command prepares Android cache before spawning the Next.js server", () => { + const src = readFileSync(join(ROOT, "bin/cli/commands/serve.mjs"), "utf8"); + assert.match(src, /ensureAndroidCacheDir/); + assert.match(src, /maybeReportInstrumentationHookFailure|isFatalInstrumentationHookFailure/); + assert.match(src, /formatAndroidInstrumentationFailureHint/); +}); + +test("TERMUX_GUIDE documents Unsupported platform: android", () => { + const guide = readFileSync(join(ROOT, "docs/guides/TERMUX_GUIDE.md"), "utf8"); + assert.match(guide, /Unsupported platform:\s*android/); + assert.match(guide, /mkdir -p ~\/\.cache/); + assert.match(guide, /Do not[\s\S]*dist\/server\.js/i); +}); + +test("maybeReportInstrumentationHookFailure prints once then no-ops", async () => { + const serve = await import("../../bin/cli/commands/serve.mjs"); + serve.resetInstrumentationFailureHintForTests(); + const chunks = []; + const originalWrite = process.stderr.write; + process.stderr.write = ((chunk, ..._rest) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + assert.equal( + serve.maybeReportInstrumentationHookFailure( + "Error: An error occurred while loading instrumentation hook: Unsupported platform: android" + ), + true + ); + assert.equal( + serve.maybeReportInstrumentationHookFailure("Unsupported platform: android"), + false + ); + assert.match(chunks.join(""), /mkdir -p ~\/\.cache/); + } finally { + process.stderr.write = originalWrite; + serve.resetInstrumentationFailureHintForTests(); + } +});