mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
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:
committed by
GitHub
parent
92c18a1440
commit
df9550fce9
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
122
bin/cli/utils/ensureAndroidCacheDir.mjs
Normal file
122
bin/cli/utils/ensureAndroidCacheDir.mjs
Normal 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`
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
1
changelog.d/fixes/8519-termux-android-cache-dir.md
Normal file
1
changelog.d/fixes/8519-termux-android-cache-dir.md
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
231
tests/unit/termux-android-cache-dir.test.ts
Normal file
231
tests/unit/termux-android-cache-dir.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user