import { chmodSync, cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { createPiSession, type PiSession, } from "../../../test/fixture/create-pi-session.ts"; const built = fileURLToPath( new URL("../../../dist/os-notifier", import.meta.url), ); describe.skipIf( process.env["PI_EXT_VERIFY_OS_NOTIFIER_PACKAGE"] !== "1" || process.platform === "win32", )("os-notifier relocated focus resource", () => { it("loads the JAR only on demand and makes an extracted writable copy safe for Android", async () => { const root = mkdtempSync(join(tmpdir(), "os-notifier-package-")); const target = join(root, "package"); let session: PiSession | undefined; try { cpSync(built, target, { recursive: true }); const pkg = JSON.parse( readFileSync(join(target, "package.json"), "utf8"), ) as { pi: { extensions: string[] }; }; const entry = pkg.pi.extensions[0]; if (!entry) throw new Error("os-notifier Pi extension entry missing"); const jar = join(target, "android/focus.jar"); expect(readdirSync(join(target, "android"))).toEqual(["focus.jar"]); expect(readFileSync(jar)).toEqual( readFileSync( fileURLToPath(new URL("../android/focus.jar", import.meta.url)), ), ); chmodSync(jar, 0o644); const bin = join(root, "bin"); mkdirSync(bin); const queryLog = join(root, "query.log"); const notifyLog = join(root, "notification.log"); writeFileSync( join(bin, "app_process"), `#!${process.execPath}\nrequire("node:fs").writeFileSync(process.env.TEST_FOCUS_LOG, process.env.CLASSPATH);process.exit(0);\n`, ); writeFileSync( join(bin, "termux-notification"), `#!${process.execPath}\nrequire("node:fs").writeFileSync(process.env.TEST_NOTIFY_LOG, "posted");\n`, ); writeFileSync( join(bin, "termux-vibrate"), `#!${process.execPath}\nprocess.exit(0);\n`, ); for (const name of readdirSync(bin)) chmodSync(join(bin, name), 0o755); session = await createPiSession({ cwd: root, extensionPaths: [join(target, entry)], env: { TERMUX_VERSION: "test", PATH: bin, TEST_FOCUS_LOG: queryLog, TEST_NOTIFY_LOG: notifyLog, }, }); expect(session.errors).toEqual([]); session.session.extensionRunner.setUIContext( session.session.extensionRunner.getUIContext(), "tui", ); await session.session.extensionRunner.emit({ type: "session_start", reason: "startup", }); expect(statSync(jar).mode & 0o222).toBe(0o200); expect(() => readFileSync(queryLog)).toThrow(); await session.session.extensionRunner.emit({ type: "input", text: "test", source: "interactive", } as never); await session.session.extensionRunner.emitBeforeAgentStart( { messageType: "user", prompt: "test" }, { cwd: root }, ); await session.session.extensionRunner.emit({ type: "agent_start", } as never); await session.session.extensionRunner.emit({ type: "agent_end", messages: [{ role: "assistant", stopReason: "stop" }], } as never); await session.session.extensionRunner.emit({ type: "agent_settled", } as never); expect(readFileSync(queryLog, "utf8")).toBe(jar); expect(statSync(jar).mode & 0o222).toBe(0); await expect .poll(() => { try { return readFileSync(notifyLog, "utf8"); } catch { return ""; } }) .toBe("posted"); await session.dispose(); session = undefined; if (process.platform === "android") { // Independent native check: the actual Android runtime loads relocated DEX. const chunk = readdirSync(target).find((name) => /^termux-focus-.*\.js$/.test(name), ); if (!chunk) throw new Error("packaged focus chunk missing"); const helper = (await import( pathToFileURL(join(target, chunk)).href )) as { termuxFocused: () => Promise }; expect([true, false]).toContain(await helper.termuxFocused()); } } finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }); } }); });