diff --git a/.changeset/happy-kids-admire.md b/.changeset/happy-kids-admire.md new file mode 100644 index 000000000..1161033e4 --- /dev/null +++ b/.changeset/happy-kids-admire.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Avoid loading OpenTUI's embedded native library for headless commands. Help, version, session polling, daemon serving, markup rendering, and non-interactive pager paths now stay behind a lightweight CLI entrypoint, preventing Bun from leaking a native temp file for commands that never open the review UI. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c37e209ba..6e16d71cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,10 +157,13 @@ jobs: strategy: fail-fast: false matrix: - os: - - ubuntu-latest - - macos-latest - - windows-latest + include: + - os: ubuntu-latest + executable: dist/hunk + - os: macos-latest + executable: dist/hunk + - os: windows-latest + executable: dist/hunk.exe steps: - name: Check out repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -181,6 +184,11 @@ jobs: - name: Stage prebuilt npm packages run: bun run build:prebuilt:npm + - name: Verify compiled headless commands skip OpenTUI + run: bun test ./test/cli/compiled-headless-native-lib.test.ts + env: + HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/${{ matrix.executable }} + - name: Verify staged prebuilt packs run: bun run check:prebuilt-pack diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 0adb8fae8..75d9d883a 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -91,6 +91,39 @@ jobs: - name: Test suite run: bun test ./src ./packages ./scripts ./test/cli ./test/session + compiled-headless-portability: + name: Compiled headless portability (${{ matrix.os }}) + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + executable: dist/hunk + - os: windows-latest + executable: dist/hunk.exe + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build binary + run: bun run build:bin + + - name: Verify compiled headless commands skip OpenTUI + run: bun test ./test/cli/compiled-headless-native-lib.test.ts + env: + HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/${{ matrix.executable }} + pr-validate: name: Typecheck + Test + Smoke needs: changes @@ -180,6 +213,11 @@ jobs: - name: Stage prebuilt npm packages run: bun run build:prebuilt:npm + - name: Verify compiled headless commands skip OpenTUI + run: bun test ./test/cli/compiled-headless-native-lib.test.ts + env: + HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/dist/hunk + - name: Verify compiled binary watch mode run: bun test ./test/pty/watch.test.ts env: diff --git a/src/core/config.ts b/src/core/config.ts index 25364e9ed..eea103bac 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import { dirname, join } from "node:path"; -import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes"; -import { normalizeBuiltInThemeId } from "../ui/themes"; +import { BUNDLED_SHIKI_THEME_IDS, resolveBundledShikiThemeId } from "../ui/lib/shikiThemes"; import { LEGACY_CUSTOM_SYNTAX_COLOR_KEYS, resolveSyntaxScopeOverrides } from "./legacySyntaxScopes"; import { resolveGlobalConfigPath } from "./paths"; import { LEGACY_CUSTOM_SYNTAX_NOTICES, type StartupNotice } from "./startupNotice"; @@ -183,7 +182,7 @@ function normalizeCustomThemeBase(value: unknown) { ); } - const resolvedThemeId = normalizeBuiltInThemeId(value); + const resolvedThemeId = resolveBundledShikiThemeId(value); if (!resolvedThemeId) { throw new Error( `Expected custom_theme.base to be a built-in theme id. Known themes: ${BUILT_IN_THEME_IDS.join(", ")}.`, diff --git a/src/main.tsx b/src/main.tsx index 47cf376b1..873830420 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,34 +1,10 @@ #!/usr/bin/env bun -import { createCliRenderer } from "@opentui/core"; -import { createRoot } from "@opentui/react"; import { formatCliError } from "./core/errors"; -import { - installJobControlInterruptSupport, - installJobControlSuspendSupport, - type JobControlInterruptSupport, - type JobControlSuspendSupport, -} from "./core/jobControl"; import { pagePlainText } from "./core/pager"; -import { sanitizeTerminalText } from "./lib/terminalText"; -import { shutdownSession } from "./core/shutdown"; -import { renderStaticDiffPager } from "./ui/staticDiffPager"; import { prepareStartupPlan } from "./core/startup"; -import { shouldUseMouseForApp } from "./core/terminal"; -import { resolveStartupUpdateNotice } from "./core/updateNotice"; -import { AppHost } from "./ui/AppHost"; -import { SessionBrokerClient } from "./session-broker/brokerClient"; +import { sanitizeTerminalText } from "./lib/terminalText"; import { serveSessionBrokerDaemon } from "./session-broker/brokerServer"; -import { - createInitialSessionSnapshot, - createSessionRegistration, -} from "./hunk-session/sessionRegistration"; -import type { - HunkSessionCommandResult, - HunkSessionInfo, - HunkSessionServerMessage, - HunkSessionState, -} from "./hunk-session/types"; import { runSessionCommand } from "./session/commands"; async function main() { @@ -78,6 +54,7 @@ async function main() { } if (startupPlan.kind === "static-diff-pager") { + const { renderStaticDiffPager } = await import("./ui/staticDiffPager"); process.stdout.write( await renderStaticDiffPager(startupPlan.text, startupPlan.options, { customTheme: startupPlan.customTheme, @@ -91,66 +68,10 @@ async function main() { throw new Error("Unreachable startup plan."); } - const { bootstrap, controllingTerminal } = startupPlan; - const hostClient = new SessionBrokerClient< - HunkSessionInfo, - HunkSessionState, - HunkSessionServerMessage, - HunkSessionCommandResult - >(createSessionRegistration(bootstrap), createInitialSessionSnapshot(bootstrap)); - hostClient.start(); - - // Keep OpenTUI's platform-safe threading default (enabled on macOS, disabled on Linux). - const renderer = await createCliRenderer({ - stdin: controllingTerminal?.stdin, - stdout: process.stdout, - useMouse: shouldUseMouseForApp({ - hasControllingTerminal: Boolean(controllingTerminal), - }), - screenMode: "alternate-screen", - exitOnCtrlC: false, - openConsoleOnError: true, - onDestroy: () => controllingTerminal?.close(), - }); - - const appRenderer = renderer; - const root = createRoot(appRenderer); - const shutdownSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; - let shuttingDown = false; - let jobControlSuspendSupport: JobControlSuspendSupport = { dispose: () => undefined }; - let jobControlInterruptSupport: JobControlInterruptSupport = { dispose: () => undefined }; - - /** Tear down the renderer before exit so the primary terminal screen comes back cleanly. */ - function shutdown() { - if (shuttingDown) { - return; - } - - shuttingDown = true; - for (const signal of shutdownSignals) { - process.off(signal, shutdown); - } - jobControlInterruptSupport.dispose(); - jobControlSuspendSupport.dispose(); - hostClient.stop(); - shutdownSession({ root, renderer: appRenderer }); - } - - for (const signal of shutdownSignals) { - process.once(signal, shutdown); - } - jobControlInterruptSupport = installJobControlInterruptSupport(appRenderer, shutdown); - jobControlSuspendSupport = installJobControlSuspendSupport(appRenderer); - - // The app owns the full alternate screen session from this point on. - root.render( - , - ); + // OpenTUI stays behind the interactive plan so headless commands never + // materialize its embedded native library. + const { runInteractiveApp } = await import("./ui/runInteractiveApp"); + await runInteractiveApp(startupPlan); } await main().catch((error) => { diff --git a/src/ui/lib/shikiThemes.ts b/src/ui/lib/shikiThemes.ts index 99aa44906..e759568fd 100644 --- a/src/ui/lib/shikiThemes.ts +++ b/src/ui/lib/shikiThemes.ts @@ -83,6 +83,16 @@ export function resolveLegacyThemeId(themeId: string | undefined) { : undefined; } +/** Resolve a current or legacy id when it names one bundled theme. */ +export function resolveBundledShikiThemeId( + themeId: string | undefined, +): BundledShikiThemeId | undefined { + const resolvedThemeId = resolveLegacyThemeId(themeId); + return BUNDLED_SHIKI_THEME_IDS.includes(resolvedThemeId as BundledShikiThemeId) + ? (resolvedThemeId as BundledShikiThemeId) + : undefined; +} + export interface BundledShikiThemeDiffColors { added?: string; removed?: string; diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 1a01e7776..4f678507f 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -500,7 +500,7 @@ describe("ui helpers", () => { ).toBe(16); }); - test("resolveTheme falls back to GitHub defaults while lazily exposing syntax styles", () => { + test("resolveTheme falls back to GitHub defaults while exposing semantic syntax colors", () => { const dracula = resolveTheme("dracula", null); const missingLight = resolveTheme("missing", "light"); const missingDark = resolveTheme("missing", "dark"); @@ -527,11 +527,6 @@ describe("ui helpers", () => { expect(custom.accent).toBe("#7755aa"); expect(custom.syntaxScopeOverrides).toEqual({ "keyword.control": "#123456" }); expect(missingCustom.id).toBe("github-dark-default"); - expect(resolveTheme("github-dark-default", null).syntaxStyle).toBeDefined(); - expect(custom.syntaxStyle).toBeDefined(); - expect(resolveTheme("catppuccin-latte", null).syntaxStyle).toBeDefined(); - expect(resolveTheme("catppuccin-frappe", null).syntaxStyle).toBeDefined(); - expect(resolveTheme("catppuccin-macchiato", null).syntaxStyle).toBeDefined(); - expect(resolveTheme("catppuccin-mocha", null).syntaxStyle).toBeDefined(); + expect(custom.syntaxColors.default).toBe("#1f2328"); }); }); diff --git a/src/ui/runInteractiveApp.tsx b/src/ui/runInteractiveApp.tsx new file mode 100644 index 000000000..c8d28d4cb --- /dev/null +++ b/src/ui/runInteractiveApp.tsx @@ -0,0 +1,95 @@ +import { createCliRenderer } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import { + installJobControlInterruptSupport, + installJobControlSuspendSupport, + type JobControlInterruptSupport, + type JobControlSuspendSupport, +} from "../core/jobControl"; +import { shutdownSession } from "../core/shutdown"; +import { shouldUseMouseForApp, type ControllingTerminal } from "../core/terminal"; +import type { AppBootstrap } from "../core/types"; +import { resolveStartupUpdateNotice } from "../core/updateNotice"; +import { + createInitialSessionSnapshot, + createSessionRegistration, +} from "../hunk-session/sessionRegistration"; +import type { + HunkSessionCommandResult, + HunkSessionInfo, + HunkSessionServerMessage, + HunkSessionState, +} from "../hunk-session/types"; +import { SessionBrokerClient } from "../session-broker/brokerClient"; +import { AppHost } from "./AppHost"; + +export interface InteractiveAppInput { + bootstrap: AppBootstrap; + controllingTerminal: ControllingTerminal | null; +} + +/** Load and run the OpenTUI review app after startup has selected an interactive plan. */ +export async function runInteractiveApp({ + bootstrap, + controllingTerminal, +}: InteractiveAppInput): Promise { + const hostClient = new SessionBrokerClient< + HunkSessionInfo, + HunkSessionState, + HunkSessionServerMessage, + HunkSessionCommandResult + >(createSessionRegistration(bootstrap), createInitialSessionSnapshot(bootstrap)); + hostClient.start(); + + // Keep OpenTUI's platform-safe threading default (enabled on macOS, disabled on Linux). + const renderer = await createCliRenderer({ + stdin: controllingTerminal?.stdin, + stdout: process.stdout, + useMouse: shouldUseMouseForApp({ + hasControllingTerminal: Boolean(controllingTerminal), + }), + screenMode: "alternate-screen", + exitOnCtrlC: false, + openConsoleOnError: true, + onDestroy: () => controllingTerminal?.close(), + }); + + const appRenderer = renderer; + const root = createRoot(appRenderer); + const shutdownSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; + let shuttingDown = false; + let jobControlSuspendSupport: JobControlSuspendSupport = { dispose: () => undefined }; + let jobControlInterruptSupport: JobControlInterruptSupport = { dispose: () => undefined }; + + /** Tear down the renderer before exit so the primary terminal screen comes back cleanly. */ + function shutdown() { + if (shuttingDown) { + return; + } + + shuttingDown = true; + for (const signal of shutdownSignals) { + process.off(signal, shutdown); + } + jobControlInterruptSupport.dispose(); + jobControlSuspendSupport.dispose(); + hostClient.stop(); + shutdownSession({ root, renderer: appRenderer }); + } + + for (const signal of shutdownSignals) { + process.once(signal, shutdown); + } + jobControlInterruptSupport = installJobControlInterruptSupport(appRenderer, shutdown); + jobControlSuspendSupport = installJobControlSuspendSupport(appRenderer); + + // The app owns the full alternate screen session from this point on. + root.render( + , + ); +} diff --git a/src/ui/themes.test.ts b/src/ui/themes.test.ts index 5685f86a3..845f28f1b 100644 --- a/src/ui/themes.test.ts +++ b/src/ui/themes.test.ts @@ -65,7 +65,7 @@ describe("themes", () => { expect(theme.id).toBe(themeId); expect(theme.label).toBe(themeId); expect(theme.syntaxTheme).toBe(themeId); - expect(theme.syntaxStyle).toBeDefined(); + expect(theme.syntaxColors.default).toBeTruthy(); } }); diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 7a68c295f..1244ad0bb 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -4,14 +4,13 @@ import type { CustomThemeConfig } from "../core/types"; import { blendHex, contrastRatio, relativeLuminance } from "./lib/color"; import { BUNDLED_SHIKI_THEME_IDS, - resolveLegacyThemeId, + resolveBundledShikiThemeId, getBundledShikiThemeBackground, getBundledShikiThemeDiffColors, getBundledShikiThemeForeground, type BundledShikiThemeDiffColors, type BundledShikiThemeId, } from "./lib/shikiThemes"; -import { withLazySyntaxStyle } from "./themes/syntax"; import type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types"; export type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types"; @@ -232,7 +231,7 @@ function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme { syntaxTheme: themeId, }; - return withLazySyntaxStyle(themeBase, syntaxColors); + return { ...themeBase, syntaxColors }; } export const THEMES: AppTheme[] = BUNDLED_SHIKI_THEME_IDS.map((themeId) => @@ -241,7 +240,7 @@ export const THEMES: AppTheme[] = BUNDLED_SHIKI_THEME_IDS.map((themeId) => /** Return the built-in theme by id so config-defined themes can inherit from it. */ function builtInThemeById(themeId: string | undefined) { - const resolvedThemeId = resolveLegacyThemeId(themeId); + const resolvedThemeId = resolveBundledShikiThemeId(themeId); return THEMES.find((theme) => theme.id === resolvedThemeId); } @@ -298,7 +297,7 @@ function buildCustomTheme(customTheme: CustomThemeConfig) { syntaxScopeOverrides: resolveSyntaxScopeOverrides(customTheme.syntax, customTheme.syntaxScopes), }; - return withLazySyntaxStyle(themeBase, baseTheme.syntaxColors); + return { ...themeBase, syntaxColors: baseTheme.syntaxColors }; } /** Return the theme ids the app should expose based on whether config defines a custom palette. */ @@ -337,16 +336,6 @@ export function resolveTheme( return fallbackTheme(themeMode); } -/** Return whether a custom theme base id can inherit from a built-in theme. */ -export function isBuiltInThemeId(themeId: string) { - return builtInThemeById(themeId) !== undefined; -} - -/** Return the canonical built-in theme id, preserving legacy config compatibility. */ -export function normalizeBuiltInThemeId(themeId: string) { - return isBuiltInThemeId(themeId) ? resolveLegacyThemeId(themeId) : undefined; -} - /** Return known semantic diff colors for a bundled Shiki-backed theme. */ export function bundledThemeDiffColors(themeId: string): BundledShikiThemeDiffColors | undefined { return getBundledShikiThemeDiffColors(themeId); diff --git a/src/ui/themes/syntax.ts b/src/ui/themes/syntax.ts deleted file mode 100644 index 8a9d06995..000000000 --- a/src/ui/themes/syntax.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { RGBA, SyntaxStyle } from "@opentui/core"; -import type { AppTheme, SyntaxColors, ThemeBase } from "./types"; - -/** Build the syntax palette OpenTUI should use for in-terminal code rendering. */ -export function createSyntaxStyle(colors: SyntaxColors) { - return SyntaxStyle.fromStyles({ - default: { fg: RGBA.fromHex(colors.default) }, - keyword: { fg: RGBA.fromHex(colors.keyword), bold: true }, - string: { fg: RGBA.fromHex(colors.string) }, - comment: { fg: RGBA.fromHex(colors.comment), italic: true }, - number: { fg: RGBA.fromHex(colors.number) }, - function: { fg: RGBA.fromHex(colors.function) }, - method: { fg: RGBA.fromHex(colors.function) }, - property: { fg: RGBA.fromHex(colors.property) }, - variable: { fg: RGBA.fromHex(colors.variable ?? colors.default) }, - constant: { fg: RGBA.fromHex(colors.number), bold: true }, - type: { fg: RGBA.fromHex(colors.type) }, - class: { fg: RGBA.fromHex(colors.type) }, - operator: { fg: RGBA.fromHex(colors.operator ?? colors.punctuation) }, - punctuation: { fg: RGBA.fromHex(colors.punctuation) }, - }); -} - -/** Lazily attach syntax colors so startup only pays for the active theme's token style. */ -export function withLazySyntaxStyle(theme: ThemeBase, syntaxColors: SyntaxColors): AppTheme { - let syntaxStyle: SyntaxStyle | null = null; - - return { - ...theme, - syntaxColors, - get syntaxStyle() { - syntaxStyle ??= createSyntaxStyle(syntaxColors); - return syntaxStyle; - }, - }; -} diff --git a/src/ui/themes/types.ts b/src/ui/themes/types.ts index 1b9d879e5..0b5b0da84 100644 --- a/src/ui/themes/types.ts +++ b/src/ui/themes/types.ts @@ -1,5 +1,3 @@ -import type { SyntaxStyle } from "@opentui/core"; - export interface AppTheme { id: string; label: string; @@ -42,7 +40,6 @@ export interface AppTheme { /** Exact Shiki/TextMate scope colors layered onto the base syntax theme. */ syntaxScopeOverrides?: Record; syntaxColors: SyntaxColors; - syntaxStyle: SyntaxStyle; } export type SyntaxColors = { @@ -59,4 +56,4 @@ export type SyntaxColors = { punctuation: string; }; -export type ThemeBase = Omit; +export type ThemeBase = Omit; diff --git a/test/cli/compiled-headless-native-lib.test.ts b/test/cli/compiled-headless-native-lib.test.ts new file mode 100644 index 000000000..d513f2591 --- /dev/null +++ b/test/cli/compiled-headless-native-lib.test.ts @@ -0,0 +1,231 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { createServer } from "node:net"; +import { mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +const executable = process.env.HUNK_TEST_EXECUTABLE + ? resolve(process.env.HUNK_TEST_EXECUTABLE) + : undefined; +const compiledTest = executable ? test : test.skip; +const compiledLinuxTest = executable && process.platform === "linux" ? test : test.skip; +const BUN_NATIVE_ARTIFACT_PATTERN = /^\.[0-9a-f]{16}-[0-9a-f]{8}\.(?:so|dylib|dll)$/; +const positiveControlBuildRoot = executable + ? mkdtempSync(resolve(tmpdir(), "hunk-compiled-opentui-control-")) + : undefined; +const positiveControlExecutable = positiveControlBuildRoot + ? resolve( + positiveControlBuildRoot, + process.platform === "win32" ? "opentui-control.exe" : "opentui-control", + ) + : undefined; + +let rootsToClean: string[] = []; + +beforeAll(() => { + if (!positiveControlExecutable) { + return; + } + + const source = resolve(import.meta.dir, "fixtures", "compiled-opentui-positive-control.ts"); + const build = Bun.spawnSync( + [ + process.execPath, + "build", + "--compile", + "--no-compile-autoload-bunfig", + source, + "--outfile", + positiveControlExecutable, + ], + { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ); + if (build.exitCode !== 0) { + throw new Error( + `Failed to build the OpenTUI positive control: ${Buffer.from(build.stderr).toString("utf8")}`, + ); + } +}); + +afterAll(() => { + if (positiveControlBuildRoot) { + rmSync(positiveControlBuildRoot, { recursive: true, force: true }); + } +}); + +afterEach(() => { + for (const root of rootsToClean) { + rmSync(root, { recursive: true, force: true }); + } + rootsToClean = []; +}); + +/** Create isolated home, cache, runtime, and temp directories for one compiled-binary test. */ +function createTestEnvironment(port?: number) { + const root = mkdtempSync(resolve(tmpdir(), "hunk-compiled-headless-test-")); + rootsToClean.push(root); + const home = resolve(root, "home"); + const cache = resolve(root, "cache"); + const runtime = resolve(root, "runtime"); + const temp = resolve(root, "tmp"); + for (const dir of [home, cache, runtime, temp]) { + mkdirSync(dir, { recursive: true }); + } + + return { + temp, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CACHE_HOME: cache, + XDG_RUNTIME_DIR: runtime, + TMPDIR: temp, + BUN_TMPDIR: temp, + TEMP: temp, + TMP: temp, + ...(port === undefined ? {} : { HUNK_MCP_PORT: String(port) }), + }, + }; +} + +/** Return Bun's hidden native-library extraction artifacts from an isolated temp directory. */ +function nativeArtifacts(temp: string) { + return readdirSync(temp).filter((name) => BUN_NATIVE_ARTIFACT_PATTERN.test(name)); +} + +/** Quote one path for the Bash command used to provide file-backed pager stdin. */ +function shellQuote(value: string) { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +/** Reserve and release one loopback port for the compiled daemon test. */ +async function reserveFreePort() { + const listener = createServer(() => undefined); + await new Promise((resolveListen, reject) => { + listener.once("error", reject); + listener.listen(0, "127.0.0.1", resolveListen); + }); + const address = listener.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolveClose) => listener.close(() => resolveClose())); + return port; +} + +/** Wait until the compiled session daemon responds to its health endpoint. */ +async function waitForDaemon(port: number) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${port}/health`); + if (response.ok) { + return; + } + } catch { + // The daemon may still be binding its loopback listener. + } + await Bun.sleep(50); + } + throw new Error("Timed out waiting for the compiled Hunk daemon."); +} + +describe("compiled headless native-library loading", () => { + // Calibrate the platform temp path and filename matcher before trusting negative assertions. + compiledTest("detects extraction from an eager OpenTUI positive control", () => { + const { env, temp } = createTestEnvironment(); + const proc = Bun.spawnSync([positiveControlExecutable!], { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + expect(proc.exitCode).toBe(0); + expect(nativeArtifacts(temp).length).toBeGreaterThan(0); + }); + + compiledTest("does not extract OpenTUI for short-lived headless commands", () => { + const { env, temp } = createTestEnvironment(); + const commands: Array<{ args: string[]; stdin?: string }> = [ + { args: ["--help"] }, + { args: ["--version"] }, + { args: ["session", "--help"] }, + { args: ["skill", "path"] }, + { args: ["markup", "guide"] }, + { args: ["markup", "render", "-"], stdin: "Hello\n" }, + { args: ["pager"], stdin: "plain pager text\n" }, + { + args: ["pager"], + stdin: "diff --git a/a.txt b/a.txt\n@@ -1 +1 @@\n-old\n+new\n", + }, + ]; + + for (const command of commands) { + const proc = Bun.spawnSync([executable!, ...command.args], { + env, + stdin: command.stdin === undefined ? "ignore" : Buffer.from(command.stdin), + stdout: "pipe", + stderr: "pipe", + }); + expect(proc.exitCode).toBe(0); + expect(nativeArtifacts(temp)).toEqual([]); + } + }); + + compiledLinuxTest("keeps captured-host static pager rendering OpenTUI-free", () => { + const { env, temp } = createTestEnvironment(); + const patch = + "diff --git a/a.txt b/a.txt\nindex 7898192..6178079 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"; + const proc = Bun.spawnSync( + ["script", "-qec", `${shellQuote(executable!)} pager`, "/dev/null"], + { + env: { + ...env, + TERM: "dumb", + LAZYGIT_CONFIG_DIR: temp, + }, + stdin: Buffer.from(patch), + stdout: "pipe", + stderr: "pipe", + }, + ); + + expect(proc.exitCode).toBe(0); + expect(Buffer.from(proc.stdout).toString("utf8")).toContain("a.txt"); + expect(nativeArtifacts(temp)).toEqual([]); + }); + + compiledTest("keeps the daemon and session polling paths OpenTUI-free", async () => { + const port = await reserveFreePort(); + const { env, temp } = createTestEnvironment(port); + const daemon = Bun.spawn([executable!, "daemon", "serve"], { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + try { + await waitForDaemon(port); + const sessionList = Bun.spawnSync([executable!, "session", "list"], { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + expect(sessionList.exitCode).toBe(0); + expect(Buffer.from(sessionList.stdout).toString("utf8")).toContain( + "No active Hunk sessions.", + ); + expect(nativeArtifacts(temp)).toEqual([]); + } finally { + daemon.kill("SIGTERM"); + await daemon.exited; + } + }); +}); diff --git a/test/cli/fixtures/compiled-opentui-positive-control.ts b/test/cli/fixtures/compiled-opentui-positive-control.ts new file mode 100644 index 000000000..761a1aa78 --- /dev/null +++ b/test/cli/fixtures/compiled-opentui-positive-control.ts @@ -0,0 +1,3 @@ +import { RGBA } from "@opentui/core"; + +process.stdout.write(RGBA.fromHex("#ffffff").toString());