fix(cli): prepare Next.js cache dir on Android/Termux before serve (#8593)

* 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 <diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
AmirHossein Rezaei
2026-07-28 01:37:19 +03:30
committed by GitHub
parent 92c18a1440
commit df9550fce9
9 changed files with 492 additions and 31 deletions

View File

@@ -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"));
}
}

View File

@@ -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) {

View File

@@ -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`
);
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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",

View File

@@ -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.

View File

@@ -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 <homedir>/.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();
}
});