diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91376a02..e0c2619f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,16 +41,6 @@ jobs: - name: Build and test run: bun run release:build - # Platform probing only checks that a transport is theoretically - # supported. It cannot prove that this hosted runner has the required - # kernel capability, helper, and mount privileges. Keep the real mount - # test opt-in for a runner explicitly configured for Mountx; otherwise - # the regular scenario test still covers the probe and skip behavior. - - name: Run Mountx integration on an explicitly configured runner - if: ${{ vars.ZCODE_TEST_MOUNTX == '1' }} - timeout-minutes: 3 - run: timeout --foreground 120s env ZCODE_TEST_MOUNTX=1 bun test test/tui/scenario-mountx.test.ts - - name: Pack and install-test run: bun run release:pack @@ -125,7 +115,7 @@ jobs: # Business-logic regressions run once in validate. Exercise the boundaries # affected by Node/OS differences here: launch, HTTP streaming, app-server - # auth, both terminal modes, and the real SQLite driver below. + # auth and the real SQLite driver below; terminal wiring runs in validate. - name: Run Node and OS compatibility tests run: bun run test:runtime:compat diff --git a/README.md b/README.md index b4506ca5..4add6560 100644 --- a/README.md +++ b/README.md @@ -586,11 +586,14 @@ Run all validation layers: ```bash bun run typecheck -bun test +bun run test bun run check bun run check:tui ``` +The required contracts and the reason for every retained test file are listed in +[`test/README.md`](./test/README.md). `bun run test:unit` runs only the fast subset. + For the OAuth path, release workflow details, CI, and the full development guide, see [Development](./docs/DEVELOPMENT.md). For maintainer-only release and publishing workflows, see [Releasing](./docs/RELEASING.md). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f540b889..6a29bab4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -35,8 +35,10 @@ to these functions. Recovery reads optional metadata asynchronously and shares one in-flight restoration per app, so a concurrent send waits for registration. The runtime continues to own task lifecycle, registry state and message routing; the helper merges read models and preserves the existing restore/stop/send order. -`test/runtime-tui-bridge.test.ts` tests these operations directly, while -`test/sync-runtime.test.ts` exercises the injected calls and patch idempotency. +Required runtime patches are verified during synchronization and by `check`. +The minimal behavioral test inventory is documented in +[`test/README.md`](../test/README.md); implementation-string and patch-fixture +matrices are not part of the default gate. Run all validation layers: @@ -56,18 +58,15 @@ See [TUI scenario testing](./TUI_SCENARIO_TESTING.md) for fixture and PTY guidance, and [SQLite concurrency](./SQLITE_CONCURRENCY.md) for storage retry boundaries and real Node regression coverage. -`check:tui` runs real-PTY scenarios. The official runtime scenario completes +`check:tui` runs one real-PTY official runtime scenario. It completes masked Coding Plan API-key setup in a temporary home, verifies the official config output, executes `/help`, switches to plan mode, exits, and checks that -the launcher forwards terminal SIGHUP shutdown. The offline -feature scenario also covers suspended login restoration, selectors, image -attachments, nested Agent tools, Markdown, Mermaid, diffs, transcript -navigation, context/status details, MCP actions, task-scoped background output, -terminal-agent recovery and the -workflow panel. A pressure scenario verifies that steering, UTF-8 input and -Ctrl+C cancellation remain responsive during rapid Bash progress output. The -scenarios advance from observed terminal output instead of fixed timers and do -not make model API calls. +the launcher forwards terminal SIGHUP shutdown. The separate `test:tui` suite +covers queue/image ownership, permission ordering, startup cancellation, shell +cleanup in both terminal modes, and tool writes with Git diff inspection. +Tests advance from observed output rather than fixed sleeps and do not make +external model API calls. Visual matrices and output-pressure benchmarks are +not release requirements. Dependency security fixes and their regression commands are documented in [`patches/README.md`](../patches/README.md). Source installs must use Bun so @@ -83,9 +82,9 @@ the hardware cursor remains the only caret on the focused input. The PTY trace checks share `scripts/terminal-output.ts`, which handles both BEL and ST control-string terminators without consuming subsequent visible text. -After changing pi-tui, `bun run check:tui` verifies regular/fullscreen input, -focus, search, selection, images and overlays. `bun run verify:tui-perf` runs -the complete quality and performance gate, including those smoke checks. +After changing pi-tui, run `bun run test:tui` and `bun run check:tui` for the +required terminal contracts. Inspect focus, selection and overlays manually +when relevant. `bun run verify:tui-perf` adds optional performance measurements. The three runtime patches `http-no-content`, `bigmodel-oauth` and `cli-help-contract` also remain in the patch plan as required verification points: Desktop 3.16.3 already supplies @@ -103,8 +102,7 @@ Only the best requested number of matches are retained, with exact names before prefix and substring matches. Hidden entries require a query beginning with `.`; this preserves the runtime's existing rules rather than adding `.gitignore` parsing. -`bun test test/runtime-workspace-paths.test.ts test/runtime-workspace-path-patches.test.ts` -covers real file trees and patch compatibility. After syncing the built runtime, +After syncing the built runtime, `bun test test/runtime/workspace-paths.test.ts` checks the actual native provider and filesystem adapter in Node, including the nested file case from #240. @@ -125,7 +123,7 @@ bun run sync:local # macOS with /Applications/ZCode.app; otherwise use bun run s bun scripts/smoke-tui.ts ``` -The tests cover lazy and legacy command lists, custom commands, MCP picker +The tests cover lazy command lists, custom commands, MCP picker dispatch, cancellation, late rejection, and raw-mode restoration on failure. The runtime smoke test also checks `/mc` suggestions from the real upstream loader. For manual verification, launch `zcode`, type `/mc` without submitting, and check @@ -164,13 +162,13 @@ bun test test/runtime/session-model-recovery.test.ts Headless recovery exits with the invalid provider/model and instructions to resume interactively; it sends no model request. The regression tests also -cover `/resume` inside the TUI and restarting after a repair. +cover repairing a saved model and restarting after the repair. ## Runtime refresh regression -`bun test test/runtime-context-cache.test.ts test/tui/runtime-refresh.test.ts` -verifies that text/reasoning deltas do not schedule runtime queries and that -tool activity polling reuses a compact, session-scoped token summary. Completed +Runtime refresh behavior is not exhaustively tested by the minimal suite. +When changing it, verify that text/reasoning deltas do not schedule runtime +queries and that tool activity polling reuses a compact, session-scoped token summary. Completed requests, history changes and changed projection usage invalidate the summary. In-flight reads from a previous session or revision cannot overwrite newer statistics. Full message bodies are read for statistics only after invalidation @@ -178,9 +176,9 @@ or when the user opens `/context`; they are not retained in the cache. ## Context window projection (#212) -`bun test test/runtime-context-window-patches.test.ts test/runtime/context-window-projection.test.ts` -covers the patch that makes the product-state projection report the model's resolved -context window. The runtime assigns `contextWindow` from a `session_created` event +The required patch makes the product-state projection report the model's resolved +context window; synchronization and `check` verify it on the locked runtime. +The runtime assigned `contextWindow` from a `session_created` event that current runtimes no longer append for new sessions, so `/context`, the runtime state and the headless `--json` projection kept the 200000 default while compaction and the request budget used the configured window. @@ -267,8 +265,7 @@ and injects registry options plus the registry's auth port. Unrelated functions, partial patches, mismatched registries and ambiguous anchors cannot satisfy its verification. Acorn is a synchronization-time development dependency. -Run `bun test test/app-server-auth.test.ts test/runtime-app-server-patches.test.ts` -for mode selection and patch regression tests. After synchronization, run +Run `bun test test/app-server-auth.test.ts` for mode selection. After synchronization, run `bun test test/runtime/app-server-auth.test.ts` for real protocol turns against a local HTTPS model server: local login, credential rotation, host account updates and auth callbacks, missing credentials and invalid modes. Tests use @@ -331,13 +328,10 @@ the reviewed npm tarball and an isolated installed-package smoke test. The complete runtime suite runs once in that job. Additional Node 24/26 Linux and Node 24 macOS jobs reuse the same tarball and run `test:runtime:compat`, the installed-package checks and the real Node SQLite suite. The compatibility -subset covers launch, HTTP streaming/retry, app-server authentication and both -TUI modes; it does not repeat the entire model/configuration/workflow matrix. - -`check:tui:focus` uses three representative configurations: regular with -notifications off, fullscreen with unfocused BEL notifications, and the built -fullscreen adapter with notifications off. Backend selection is also covered -by the notification unit tests. +subset covers launch, HTTP streaming/retry and app-server authentication; +terminal modes run in the primary validation job. It does not repeat the whole +suite. Focus-dependent cursors and notification combinations are manual checks, +not a separate automatic matrix. A newer commit to the same pull request or branch automatically cancels its superseded CI run; unrelated pull requests continue independently. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 440a8654..7d945cfb 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -201,17 +201,16 @@ both credential owners against a local HTTPS model fixture. `bigmodel-oauth` is required. It replaces the runtime's direct exchange requiring an app secret with Desktop's official ZCode token service, and persists the -ZCode JWT alongside the BigModel tokens. Runtime tests use the native localhost -callback, stub token/API-key endpoints and the native encrypted credential store -to cover browser login, failure cleanup and manual Coding Plan setup. +ZCode JWT alongside the BigModel tokens. The minimal OAuth tests exercise native +token storage, account changes and HTTP failure handling through local fixtures. +They are not an exhaustive BigModel login matrix; inspect platform-specific +callbacks and manual Coding Plan setup when changing those paths. `browser-url-opening` is required. Windows browser launches use an encoded PowerShell command that decodes the URL as data, preserving OAuth query separators, -percent encoding and state. On Windows, run `bun test test/browser-open.test.ts` -locally to exercise PowerShell itself with the browser-launch action stubbed. -The cross-platform tests remain in the unit suite. Runtime login tests also -verify that the native browser opener invokes this command before completing -the callback. +percent encoding and state. Inspect PowerShell/browser opening on Windows when +changing this path; it is not a separate test in the minimal baseline. +The real-runtime smoke still exercises masked key setup and normal launcher exit. If a required compatibility check fails, synchronization writes JSON and Markdown reports under `.release/`. The scheduled workflow uploads both files diff --git a/docs/SQLITE_CONCURRENCY.md b/docs/SQLITE_CONCURRENCY.md index 2e9a22a5..26cdfdae 100644 --- a/docs/SQLITE_CONCURRENCY.md +++ b/docs/SQLITE_CONCURRENCY.md @@ -102,28 +102,26 @@ and independent Node processes. They do not use account credentials or send mode requests. They cover: - the effective `PRAGMA busy_timeout` after sync open, async startup, and reopen; -- a native session write blocked by another process for 6.5 seconds, longer than - the previous 5-second timeout, then succeeding exactly once; -- recovery after a 10.5-second lock, with timers progressing during backoff; +- a native session write recovering from a process-held lock while timers + progress during backoff; - a deliberately shortened recovery budget that reports `SQLITE_BUSY`, writes no session, and permits a later write after the lock is released; - four independent processes migrating the same fresh database and persisting distinct sessions without duplicate migrations or integrity errors; - killing a writer with uncommitted changes, then reopening without those changes and successfully writing another session; -- same-process connections completing an asynchronous native transaction without - starving its commit, plus caller-owned transactions retaining rollback ownership; +- caller-owned transactions retaining rollback ownership and never being replayed; - a failure after message/part insertion that rolls back before input promotion retries, and an idempotent partial part write that cannot overtake a newer update; - a partial message bundle rolling back before replay, without duplicate messages or parts, and rejection of stores missing the native bundle transaction; - non-BUSY errors and partially committed goal counters never being replayed; - closing a store cancelling both backoff and pending writes; -- 100 usage facts requiring one cleanup transaction when expired rows exist, - with recent records retained and explicit cleanup still honored; -- retrying usage writes yielding priority to conversation writes; -- twelve independent processes persisting 300 input promotions and matching - messages, parts and usage records, followed by integrity and foreign-key checks. +- integrity and foreign-key checks after migration and killed-writer recovery. + +The minimal suite does not retain long held-lock delays, maintenance-priority +matrices or large multi-process stress runs. Its boundary inventory is in +[`test/README.md`](../test/README.md). Lock holders use independent processes. One test schedules their release from the writer's event loop to verify that the new recovery yields instead of diff --git a/docs/TUI_SCENARIO_TESTING.md b/docs/TUI_SCENARIO_TESTING.md index 0352bd28..7fa4c3c8 100644 --- a/docs/TUI_SCENARIO_TESTING.md +++ b/docs/TUI_SCENARIO_TESTING.md @@ -12,16 +12,7 @@ Tests are grouped by execution cost and environment: # In-process application and pure unit tests. bun run test:unit -# In-process scenario harness components (no PTY or child TUI). -bun run test:tui:component - # Hermetic PTY scenarios with a fixture runtime. -bun run test:tui:e2e - -# Tests requiring a host kernel transport and mount privileges. -ZCODE_TEST_MOUNTX=1 bun run test:tui:host - -# Run component and PTY TUI layers (the default portable TUI suite). bun run test:tui # Tests that require vendor/zcode.cjs, Node, sockets, or local networking. @@ -37,7 +28,7 @@ bun run test:node bun run test:all ``` -`bun run test` intentionally aliases the unit group for normal development. +`bun run test` runs the complete required suite; `test:unit` is the fast subset. The built-in `bun test` command also discovers the Node-only `.test.cjs` files and runs them with the wrong SQLite implementation. Use `bun run test:all` for the complete suite; runtime and Node tests require a synchronized runtime. @@ -54,15 +45,6 @@ bun run test:tui-scenario --list # Run all portable TUI scenarios automatically. bun run test:tui -# Run only the in-process scenario harness tests. -bun run test:tui:component - -# Run only the real PTY TUI scenarios. -bun run test:tui:e2e - -# Run Mountx only on a runner with a configured transport. -ZCODE_TEST_MOUNTX=1 bun run test:tui:host - # Run one scenario automatically. bun run test:tui-scenario permission-request-queue @@ -205,10 +187,11 @@ host's selected transport. `MountxWorkspaceBackend` also accepts a custom Mountx driver for operation journaling or deterministic fault injection. The backend is opt-in because Mountx is alpha, requires OS mount support, and exposes the mount to other host processes. It is not a sandbox. -The gated integration check can be run explicitly: +There is no automatic Mountx gate in the minimal suite. Inspect a mounted +workspace explicitly on a host with the required transport: ```bash -ZCODE_TEST_MOUNTX=1 bun test test/tui/scenario-mountx.test.ts +bun run test:tui:manual allowlisted-shell --workspace-backend=mountx ``` The completed backend boundary is: @@ -233,6 +216,6 @@ container/VM runtime test instead of weakening the hermetic TUI suite. 2. Add a declaration under `test/tui/scenarios/` with optional baseline files and the automatic interaction. 3. Register it in `test/tui/scenarios/index.ts`. -4. Add a `bun:test` entry and register it in the matching `test:tui` package script - when the scenario should run in the automated suite. +4. Add a `bun:test` entry only when it prevents a distinct product failure not + already covered by the inventory in [`test/README.md`](../test/README.md). 5. Verify both automatic and `--manual` modes before enabling it in CI. diff --git a/package.json b/package.json index 0e598e2b..c02e5658 100644 --- a/package.json +++ b/package.json @@ -61,19 +61,15 @@ "sync:local": "bun run build && bun scripts/sync-runtime.ts --app /Applications/ZCode.app", "check": "bun run build && bun scripts/check-runtime.ts", "check:oauth-callback": "bun scripts/smoke-oauth-callback.ts", - "check:tui": "bun scripts/smoke-tui.ts && bun scripts/smoke-tui-features.ts && bun scripts/smoke-tui-clear.ts && bun scripts/smoke-tui-session-title.ts && bun scripts/smoke-tui-pressure.ts && bun scripts/smoke-tui-widths.ts && bun scripts/smoke-tui-fullscreen.ts && bun scripts/smoke-tui-fullscreen-switch.ts && bun scripts/smoke-tui-fullscreen-layout.ts && bun run check:tui:focus", - "check:tui:focus": "bun scripts/smoke-tui-focus.ts regular off always && bun scripts/smoke-tui-focus.ts fullscreen bel unfocused && bun scripts/smoke-tui-focus.ts fullscreen off always built", + "check:tui": "bun scripts/smoke-tui.ts", "check:tui:focus:manual": "bun scripts/manual-tui-focus.ts", - "test": "bun run test:unit", + "test": "bun run test:all", "test:all": "bun run test:unit && bun run test:tui && bun run test:runtime && bun run test:node", "test:unit": "bun test test/*.test.ts", "test:runtime": "bun test test/runtime/*.test.ts", - "test:runtime:compat": "bun test test/runtime/launcher.test.ts test/runtime/network-retry.test.ts test/runtime/app-server-auth.test.ts test/runtime/execution-state-tui.test.ts", + "test:runtime:compat": "bun test test/runtime/launcher.test.ts test/runtime/network-retry.test.ts test/runtime/app-server-auth.test.ts", "test:node": "node --test test/node/*.test.cjs", - "test:tui": "bun run test:tui:component && bun run test:tui:e2e", - "test:tui:component": "bun test test/tui/scenario-http.test.ts test/tui/scenario-runtime.test.ts test/tui/scenario-shell.test.ts test/tui/scenario-workspace.test.ts test/tui/terminal-screen.test.ts", - "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/model-status.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-lifecycle.test.ts test/tui/session-rename.test.ts test/tui/startup.test.ts test/tui/terminal-exit.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", - "test:tui:host": "bun test test/tui/scenario-mountx.test.ts", + "test:tui": "bun test test/tui/*.test.ts", "test:tui:manual": "bun scripts/tui-scenario.ts --manual", "test:tui-scenario": "bun scripts/tui-scenario.ts", "typecheck": "tsc --noEmit", diff --git a/patches/README.md b/patches/README.md index 81c4e0ac..b74dd281 100644 --- a/patches/README.md +++ b/patches/README.md @@ -17,12 +17,12 @@ use this parser. It does not patch the unused browser/minified entry points. The installed `just-bash` Node bundle imports this entry for fallback formats. Its ordinary floating-point `printf` implementation uses its own formatter and already converts precision errors into stderr and exit code 1. Its `awk` -formatter rejects precision above 100 with exit code 126. Regression coverage -checks these command boundaries and subsequent successful shell use, as well -as the patched formatter directly: +formatter rejects precision above 100 with exit code 126. The minimal regression +directly checks that the patched formatter bounds hostile precision and preserves +normal formatting; it does not repeat the shell implementation's own tests: ```sh -bun test test/sprintf-precision.test.ts test/tui/scenario-shell.test.ts +bun test test/shell-security.test.ts ``` `bun audit` still reports this one moderate advisory because the installed diff --git a/scripts/smoke-tui-clear.ts b/scripts/smoke-tui-clear.ts deleted file mode 100644 index 250b4f6e..00000000 --- a/scripts/smoke-tui-clear.ts +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bun - -import { terminalOutputText as plainText } from "./terminal-output.ts"; -import { runtimeTestEnv } from "./runtime-test-env.ts"; -// Verify /cls clears the transcript locally and /clear is forwarded to the runtime. - -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const root = join(import.meta.dir, ".."); -const fixture = join(root, "test", "fixtures", "tui-clear.ts"); -const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-clear-")); -const decoder = new TextDecoder(); -let output = ""; -const terminal = new Bun.Terminal({ - cols: 110, - rows: 40, - name: "xterm-256color", - data(_terminal, data) { - output += decoder.decode(data, { stream: true }); - } -}); - -const child = Bun.spawn([process.execPath, fixture], { - cwd: root, - env: { - ...runtimeTestEnv(temporaryHome, root), - CI: "1", - TERM: "xterm-256color", - TERM_PROGRAM: "iTerm.app" - }, - terminal, - stdout: "ignore", - stderr: "ignore" -}); - - -async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (pattern.test(plainText(output.slice(start)))) return; - if (child.exitCode !== null) break; - await Bun.sleep(25); - } - throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-6_000)}`); -} - -const timeout = setTimeout(() => child.kill("SIGKILL"), 30_000); -let failure: unknown; -try { - await waitFor("welcome screen", /ZCode/i); - await waitFor("restored transcript", /Restored startup response\./i); - terminal.write("/clear\r"); - await waitFor("runtime /clear response", /Runtime handled \/clear as a new session\./i); - await waitFor("restored transcript still visible", /Restored later response\./i); - terminal.write("/cls\r"); - await Bun.sleep(400); - // pi-tui repaints differentially; assert on the newest frame after the - // post-/cls full repaint (requestRender(true)) rather than all output. - const tail = plainText(output).slice(-12_000); - const lastFrame = tail.slice(Math.max(tail.lastIndexOf("◈"), tail.lastIndexOf("ZCode"))); - if (/Restored (startup|later) (prompt|response)\./.test(lastFrame)) { - throw new Error(`/cls did not clear the visible transcript:\n${lastFrame.slice(-2_000)}`); - } - console.log("PASS: /clear is forwarded to the runtime and /cls clears the transcript"); -} catch (error) { - failure = error; -} finally { - clearTimeout(timeout); - child.kill("SIGKILL"); - await rm(temporaryHome, { recursive: true, force: true }); - setTimeout(() => process.exit(failure ? 1 : 0), 50).unref(); -} -if (failure) { - console.error(failure instanceof Error ? failure.message : String(failure)); - process.exit(1); -} diff --git a/scripts/smoke-tui-features.ts b/scripts/smoke-tui-features.ts deleted file mode 100644 index a874dffc..00000000 --- a/scripts/smoke-tui-features.ts +++ /dev/null @@ -1,627 +0,0 @@ -#!/usr/bin/env bun - -import { terminalOutputText as plainText } from "./terminal-output.ts"; -import { runtimeTestEnv } from "./runtime-test-env.ts"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const root = join(import.meta.dir, ".."); -const fixture = join(root, "test", "fixtures", "tui-features.ts"); -const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-features-")); -const decoder = new TextDecoder(); -const renderSettleMilliseconds = 25; -let output = ""; -const terminal = new Bun.Terminal({ - cols: 110, - rows: 40, - name: "xterm-256color", - data(_terminal, data) { - output += decoder.decode(data, { stream: true }); - } -}); - -const child = Bun.spawn([process.execPath, fixture], { - cwd: root, - env: { - ...runtimeTestEnv(temporaryHome, root), - CI: "1", - TERM: "xterm-256color", - TERM_PROGRAM: "iTerm.app", - ZCODE_APP_CLI_EXECUTABLE: process.execPath, - ZCODE_APP_CLI_ENTRY: fixture, - ZCODE_TUI_FEATURE_SMOKE: "1", - ZCODE_TUI_NOTIFICATION_METHOD: "osc9", - ZCODE_TUI_NOTIFICATION_CONDITION: "unfocused" - }, - terminal -}); - - -async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (pattern.test(plainText(output.slice(start)))) return; - if (child.exitCode !== null) break; - await Bun.sleep(renderSettleMilliseconds); - } - throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-6_000)}`); -} - -async function sendAndWait(input: string, label: string, pattern: RegExp, timeoutMs?: number): Promise { - const start = output.length; - terminal.write(input); - await waitFor(label, pattern, start, timeoutMs); - await Bun.sleep(renderSettleMilliseconds); - return start; -} - -async function sendAndSettle(input: string): Promise { - const start = output.length; - terminal.write(input); - const startedAt = Date.now(); - while (output.length === start && Date.now() - startedAt < 2_000) await Bun.sleep(20); - await Bun.sleep(renderSettleMilliseconds); -} - -/** - * Like waitFor but for raw output (plainText strips CSI sequences) and - * returns the match index so absence checks can start after a known write - * instead of racing bytes still streaming through the pty. - */ -async function waitForRaw(label: string, pattern: RegExp, start: number, timeoutMs = 8_000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const index = output.slice(start).search(pattern); - if (index >= 0) return start + index; - if (child.exitCode !== null) break; - await Bun.sleep(renderSettleMilliseconds); - } - throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-6_000)}`); -} - -async function waitForLoginRefresh(label: string, start: number): Promise { - // Resuming the UI redraws earlier login notices before the new login finishes. - // Wait for the completed turn indicator before submitting another command. - await waitFor(label, - /External login command completed\.[\s\S]*Model access configured via[\s\S]*config\.json[\s\S]*\[ ✓ [^\]]+\]/i, - start); - await waitFor(`${label} model`, /◈ zai\/override-model/u, start); -} - -const timeout = setTimeout(() => child.kill("SIGKILL"), 45_000); - -let interactionError: unknown; -try { - await waitFor("welcome screen", /ZCode/i); - await waitFor("restored startup transcript", /Restored startup response\./i); - await waitFor("interactive editor", /◈ alpha\/model ─ ◉ build/i); - // Terminal focus reports drive hardware cursor visibility so only the - // focused pane (tmux splits, split terminal windows) keeps a live cursor. - // Anchor the absence check after the observed hide write: the pty may - // still be streaming an earlier frame's cursor-show bytes. - terminal.write("\x1b[O"); - const hiddenAt = await waitForRaw("cursor hidden on focus out", /\x1b\[\?25l/, output.length); - await Bun.sleep(150); - if (/\x1b\[\?25h/.test(output.slice(hiddenAt))) { - throw new Error("Cursor was re-shown while the terminal was unfocused."); - } - const cursorShowStart = output.length; - terminal.write("\x1b[I"); - await Bun.sleep(150); - if (!/\x1b\[\?25h/.test(output.slice(cursorShowStart))) { - throw new Error("Cursor was not restored after terminal focus regain."); - } - await sendAndWait("\x1b", "double-Esc rewind hint", /Esc again to rewind conversation/i); - await sendAndWait("\x1b", "rewind target picker", /Rewind conversation[\s\S]*Restored later prompt/i); - await sendAndWait("\x1b[B\r", "selected older rewind target", /Return to before: Restored startup prompt/i); - await sendAndWait("\x1b", "rewind scope returned to targets", /Choose the user input to return to/i); - await sendAndWait("\r", "latest rewind target scope", /Return to before: Restored later prompt/i); - await sendAndWait("\r", "conversation and workspace rewind", /Conversation and workspace rewound[\s\S]*selected input was restored to the editor/i); - await sendAndSettle("\x15"); - await sendAndWait("/settings\r", "settings picker", /ZCode settings/i); - await sendAndWait("\x1b[B\x1b[B\r", "notification timing picker", /When to notify/i); - await sendAndWait("\x1b[B\r", "saved setting returned to root", /When to notify: always saved · environment override remains active/i); - const savedConfig = (await Bun.file(join(temporaryHome, ".zcode", "cli", "setting.json")).json()) as { - ui?: { notifications?: { condition?: string } }; - }; - if (savedConfig.ui?.notifications?.condition !== "always") { - throw new Error("Settings picker did not persist the notification condition."); - } - await sendAndWait("\r", "reopened notification timing", /Select when completed and failed turns notify you\./i); - await sendAndWait("\x1b", "child escape returned to settings", /No changes · Esc closes settings/i); - await sendAndSettle("\x1b"); - await sendAndWait("/login\r", "login setup picker", /Set Up Coding Plan/i); - await sendAndWait("\r", "masked API key prompt", /Enter Z\.AI Coding Plan API Key/i); - await sendAndWait("\x1b", "return to login picker", /API key entry cancelled\.[\s\S]*Set Up Coding Plan/i); - await sendAndWait("\r", "reopened API key prompt", /Enter Z\.AI Coding Plan API Key/i); - await sendAndWait("feature-secret-api-key", "masked API key value", /\*{20,}/i); - await sendAndWait("\r", "API key setup", /Configured Z\.AI Coding Plan\./i); - const overrideLoginStart = await sendAndWait("/login\r", "suspended login command", /External login command completed\./i); - await waitForLoginRefresh("refreshed login state", overrideLoginStart); - await sendAndWait("/disable-login-override\r", "disable login override", /Login override disabled\./i); - await sendAndWait("/login\r", "reopened login setup picker", /Set Up Coding Plan/i); - const defaultLoginStart = await sendAndWait("\x1b[B\r", "default suspended OAuth command", /External login command completed\./i); - await waitForLoginRefresh("default OAuth login refresh", defaultLoginStart); - const directLoginStart = await sendAndWait( - "/login zai-coding-plan\r", - "direct suspended OAuth command", - /External login command completed\./i - ); - await waitForLoginRefresh("direct OAuth login refresh", directLoginStart); - await sendAndWait("/prepare-failing-login\r", "prepare OAuth failure", /Failing login prepared\./i); - await sendAndWait("/login\r", "failure login setup picker", /Set Up Coding Plan/i); - await sendAndWait("\x1b[B\r", "restored OAuth failure", /Login failed: OAuth HTTP error 404 \(empty or non-JSON response\)/i); - // Login now updates the session model; restore the shortcut scenario's model. - await sendAndWait("/model\r", "restore model picker", /Select model/i); - await sendAndWait("alpha\r", "restore model for shortcuts", /Session model now: alpha\/model/i); - await sendAndWait("/help\r", "long help", /Use \/help for details/i); - await sendAndWait("\x1b[Z", "edit mode shortcut", /◈ alpha\/model ─ ◉ edit ─ ⚡ low/i); - await Bun.sleep(1_100); - await sendAndWait("\x1b[Z", "guarded mode shortcut", /◈ alpha\/model ─ ◉ guarded ─ ⚡ low/i); - await sendAndWait("\x1b[Z", "yolo mode shortcut", /◈ alpha\/model ─ ◉ yolo ─ ⚡ low/i); - await sendAndWait("/plan on\r", "independent Plan switch", /Plan enabled · permission mode: yolo/i); - await sendAndWait("\x0e", "model shortcut", /◈ beta\/model ─ ◉ yolo ─ ⚡ low/i); - await sendAndWait("\t", "effort shortcut", /◈ beta\/model ─ ◉ yolo ─ ⚡ high/i); - // /model — flat quick-switch picker (session-level, not persistent) - await sendAndWait("/model\r", "model picker", /Select model/i); - await sendAndWait("alpha\r", "model picker selection", /◈ alpha\/model ─ ◉ yolo ─ ⚡ high/i); - await sendAndSettle("/settings\r"); - await waitFor("settings menu (re-opened)", /Model providers\s+(?:Session:|Saved:)/i); - await Bun.sleep(renderSettleMilliseconds * 2); - await sendAndWait("\r", "model providers entry", /Default model/i); - await sendAndWait("\x1b", "default model Esc back to settings", /ZCode settings/i); - await sendAndWait("\r", "default model picker re-opened", /Default model/i); - await sendAndWait("alpha/model\r", "default model saved", /Default model saved: alpha\/model/i); - await sendAndSettle("\x1b"); - await sendAndWait("/effort\r", "effort picker", /Select reasoning effort/i); - await sendAndWait("\x1b[B\r", "effort picker selection", /◈ alpha\/model ─ ◉ yolo ─ ⚡ low/i); - await sendAndWait("\x1b[Z", "build mode shortcut", /◈ alpha\/model ─ ◉ build ─ ⚡ low/i); - await sendAndWait( - "review long plan\r", - "plan approval choices", - /Ready to implement\?[\s\S]*Plan 1–\d+ of \d+[\s\S]*Approve and continue/i - ); - await sendAndWait( - "\x0f", - "expanded plan review", - /Ready to implement\? · Plan[\s\S]*Ctrl\+O or Esc return/i - ); - await sendAndWait( - "\x1b[6~", - "scrolled plan review", - /Plan (?:[2-9]|[1-9]\d+)–\d+ of \d+/i - ); - await sendAndWait("\x1b", "returned to plan choices", /Approve and continue/i); - await sendAndWait("\r", "approved reviewed plan", /Plan approval fixture complete: allow\./i); - await sendAndWait("review plan feedback\r", "plan feedback choices", /Ready to implement\?/i); - await sendAndWait("\x1b[B\r", "implementation instructions prompt", /What should ZCode keep in mind while implementing\?/i); - await sendAndWait( - "Preserve the verified terminal layout.\r", - "queued plan feedback", - /Plan approval fixture complete: deny · plan_approval_feedback\./i - ); - await sendAndWait("\x16", "first clipboard image", /Images[\s\S]*\[Image #1\]/i); - await sendAndWait("\x16", "second clipboard image", /Images[\s\S]*\[Image #1\][\s\S]*\[Image #2\]/i); - await sendAndWait("/attachments\r", "attachment command selection", /Images[\s\S]*› \[Image #2\]/i); - await sendAndWait("\x1b", "attachment command return", /Images · \[Image #1\] · \[Image #2\][\s\S]*↑ manage/i); - await sendAndWait("inspect @ind", "workspace path suggestions", /index\.ts[\s\S]*src\/index\.ts/i); - await sendAndWait("\r", "workspace path completion", /inspect @src\/index\.ts/i); - await sendAndWait("$au", "skill suggestions", /audit[\s\S]*Review technical quality\./i); - await sendAndWait("\r", "skill completion", /inspect @src\/index\.ts \$audit/i); - await sendAndSettle("\x01"); - await sendAndWait( - "\x1b[A", - "attachment selection", - /Images[\s\S]*› \[Image #2\][\s\S]*Backspace\/Delete remove/i - ); - await sendAndWait("\x1b[D", "previous attachment", /Images[\s\S]*› \[Image #1\]/i); - await sendAndWait("\x1b[3~", "removed attachment", /Images › \[Image #1\]/i); - await sendAndWait("\x1b[B", "returned from attachments", /Images · \[Image #1\][\s\S]*↑ manage/i); - await sendAndSettle("\x05"); - const featureTurnStart = await sendAndWait( - "\r", - "submitted image turn", - /›\s*inspect @src\/index\.ts \$audit\s+\[1 image\][\s\S]*◇ Thought/i, - 4_000 - ); - const activeTurnProjection = plainText(output.slice(featureTurnStart)); - if (/Images\s+·\s+\[Image #1\]/i.test(activeTurnProjection)) { - throw new Error("Submitted images remained in the pending attachment row during the active turn."); - } - await waitFor( - "model stream stalled status", - /Model stream stalled\s+·\s+idle 60s/i, - featureTurnStart - ); - await waitFor( - "model retry status", - /Retrying model request\s+·\s+1\/5\s+·\s+in 1s/i, - featureTurnStart - ); - await waitFor( - "advancing turn timer", - /[🕐-🕛] [1-9]\d*s/u, - featureTurnStart, - 4_000 - ); - await sendAndWait( - "Discard this active-turn draft.", - "active-turn draft", - /Discard this active-turn draft\./i - ); - terminal.write("\x03"); - await Bun.sleep(renderSettleMilliseconds); - if (child.exitCode !== null) throw new Error("Ctrl+C exited ZCode instead of clearing the active-turn draft."); - await sendAndWait( - "Keep the final response concise.\r", - "pending active-turn steer", - /Steering current turn · 1 waiting[\s\S]*↪ Keep the final response concise\.[\s\S]*waiting for the next model step/i - ); - await sendAndWait( - "Edit this rejected steer.\r", - "rejected steer fallback", - /Steer was not accepted \(turn not steerable\); queued for the next turn\.[\s\S]*Queued next turn · 1 input[\s\S]*Edit this rejected steer\./i - ); - await sendAndWait( - "\x1b[1;2D", - "restored rejected steer with Shift+Left", - /Edit this rejected steer\.[\s\S]*◈ alpha\/model/i - ); - await sendAndSettle("\x15"); - await sendAndWait( - "Revise this queued follow-up.", - "editable follow-up draft", - /Revise this queued follow-up\./i - ); - await sendAndWait( - "\t", - "queued follow-up", - /Queued next turn · 1 input[\s\S]*↳ Revise this queued follow-up\.[\s\S]*Alt\+Up/i - ); - await sendAndWait( - "\x1bp", - "restored queued follow-up", - /Revise this queued follow-up\.[\s\S]*◈ alpha\/model/i - ); - await sendAndSettle("\x15"); - await sendAndWait( - "Run this after the active turn.\t", - "automatic next-turn follow-up", - /Queued next turn · 1 input[\s\S]*Run this after the active turn\./i - ); - await waitFor("feature turn", /Feature prompt complete\./i, featureTurnStart, 12_000); - await waitFor( - "compact background agent reply", - /Background agent replied[\s\S]*agent_feature · \/tasks/i, - featureTurnStart, - 4_000 - ); - await waitFor( - "compact background task failure", - /Background task needs attention[\s\S]*agent_feature · \/tasks/i, - featureTurnStart, - 4_000 - ); - terminal.write("\x1b"); - await Bun.sleep(75); - if (child.exitCode !== null) throw new Error("Esc exited ZCode during a background handoff."); - await waitFor( - "compact handoff failure", - /Background result processing failed[\s\S]*agent_feature · \/tasks/i, - featureTurnStart, - 4_000 - ); - await waitFor("queued follow-up turn", /Queued follow-up started after the active turn\./i, featureTurnStart, 4_000); - await waitFor("feature turn completion", /Feature background audit · turn complete/i, 0, 4_000); - await sendAndWait( - "Continue after the stuck background handoff.\r", - "background handoff preemption", - /Background result processing was interrupted; starting your queued input\.[\s\S]*Queued input started after interrupting the stuck background handoff\./i - ); - const foregroundBeforeTaskCenter = plainText(output.slice(featureTurnStart)); - // Settled handoff responses are rendered into the foreground transcript on - // purpose; the task's internal activity must stay scoped to /tasks. - for (const response of ["Task-scoped agent handoff completed.", "Coordinator began processing the failed task result."]) { - if (!foregroundBeforeTaskCenter.includes(response)) { - throw new Error(`Settled background handoff response is missing from the foreground transcript: ${response}`); - } - } - if (/Background-only reasoning|background_fetch|Coordinator dispatching background research|background-research|Inspect nested rendering|RAW_CHILD_/i.test(foregroundBeforeTaskCenter)) { - throw new Error("Task-scoped background output leaked into the foreground transcript."); - } - if (!/TaskOutput task agent_feature[\s\S]*Compact child result preserved\./i.test(foregroundBeforeTaskCenter)) { - throw new Error("Session event filtering hid the parent TaskOutput result."); - } - if (/Turn cancelled\./i.test(foregroundBeforeTaskCenter)) { - throw new Error("Esc cancelled the foreground submission while a background handoff was active."); - } - const expandedForegroundStart = await sendAndWait("\x0f", "expanded foreground tool transcript", /source text/i); - const expandedForeground = plainText(output.slice(expandedForegroundStart)); - if (/Coordinator dispatching background research|background-research|Inspect nested rendering|Nested rendering inspected|RAW_CHILD_/i.test(expandedForeground)) { - throw new Error("Expanding foreground tools exposed a background Agent tree."); - } - await sendAndWait("/diff\r", "diff source picker", /Select current workspace changes or a completed turn/i); - await sendAndWait("\x1b[B\r", "turn diff file list", /Diff · Turn \d+/i); - await sendAndWait("\r", "turn diff detail", /Page 1\/\d+/i); - await sendAndSettle("\x1b"); - await sendAndSettle("\x1b"); - await sendAndSettle("\x1b"); - - await sendAndWait("/context\r", "context details", /Estimated prompt composition by characters/i); - await sendAndSettle("\r"); - await sendAndWait("/status\r", "status details", /ZCode Status/i); - await sendAndSettle("\r"); - await sendAndWait("/transcript latest\r", "transcript navigation", /Transcript \d+\/\d+/i); - await sendAndWait("\x1bp", "Alt+Up transcript navigation", /Transcript \d+\/\d+/i); - // While a background handoff is active the app swallows Esc, so close - // transcript views through their commands instead of relying on Esc. - await sendAndSettle("/transcript close\r"); - await sendAndWait("/search inspect\r", "transcript search", /Search 1\/\d+: inspect/i); - await sendAndWait("n", "next transcript search match", /Search 2\/\d+: inspect/i); - await sendAndSettle("/search clear\r"); - await sendAndWait("/search Slash commands\r", "oversized transcript match", /Page 1\/\d+ · ←\/→ or PageUp\/PageDown scroll/i); - await sendAndWait("\x1b[C", "transcript scroll right", /Page 2\/\d+ · ←\/→ or PageUp\/PageDown scroll/i); - await sendAndSettle("/search clear\r"); - - await sendAndWait("/mcp\r", "MCP picker", /MCP servers/i); - await sendAndWait("\r", "MCP action", /MCP connected: docs/i); - await sendAndWait("/workflows\r", "workflow picker", /Workflow runs/i); - await sendAndWait("\r", "workflow detail", /Workflow · run_feature/i); - await sendAndWait("\x1b[B\r", "workflow stop", /Status: cancelled/i); - await sendAndSettle("\x1b"); - await sendAndWait("/activity\r", "complete activity view", /Current activity[\s\S]*Verify the TUI/i); - await sendAndSettle("\r"); - await sendAndWait("/tasks\r", "background task picker", /Background tasks/i); - await sendAndWait("\r", "Bash task detail", /Bash task · bg_feature/i); - await sendAndWait("\x1b", "return to background task list", /Background tasks/i); - await sendAndWait( - "\x1b[B\r", - "agent task detail", - /Agent \(reviewer\) task · agent_feature[\s\S]*Background agent reply stored in task activity\.[\s\S]*Background result failed visibly\./i - ); - const resumedAgentStart = await sendAndWait( - "\r", - "resume agent directly", - /Agent "agent_feature" resumed in the background\./i - ); - await waitFor( - "resumed agent status", - /Status: running[\s\S]*You:\s*Continue the assigned task from the last completed step\.\s*Re-check the current workspace state and\s*finish the remaining work\./i, - resumedAgentStart - ); - await sendAndWait("\x1b[B\r", "restart active agent prompt", /Restart background agent/i); - await sendAndWait( - "Restart from the saved state and finish the remaining verification.\r", - "restarted active agent task", - /Status: running[\s\S]*Agent "agent_feature" restarted in the background\./i - ); - await sendAndWait("\x1b", "return to task list after resume", /Background tasks/i); - await sendAndSettle("\x1b"); - const escOwnershipStart = await sendAndWait( - "verify Esc task ownership\r", - "Esc task ownership turn", - /Esc ownership turn running\./i - ); - await waitFor( - "Esc task projection", - /Activity · 5 in background/i, - escOwnershipStart - ); - terminal.write("\x1b"); - await waitFor( - "Esc owned task cancellation", - /Esc also stopped background task: esc_owned\./i, - escOwnershipStart - ); - await waitFor( - "Esc declined cancellation warning", - /Esc could not stop background task esc_declined.*background task not running/i, - escOwnershipStart - ); - const escOwnershipOutput = plainText(output.slice(escOwnershipStart)); - if (/Esc also stopped background tasks?:[^\n]*(?:agent_feature|esc_unrelated|esc_declined)/i.test(escOwnershipOutput)) { - throw new Error("Esc stopped or reported an unrelated/declined background agent."); - } - const timerTurnStart = output.length; - terminal.write("verify aggregate timer\r"); - await waitFor( - "aggregate timer foreground completion", - /Timer foreground complete; background still running\./i, - timerTurnStart - ); - await waitFor( - "aggregate timer continuing after foreground", - /Timer foreground complete; background still running\.[\s\S]*[🕐-🕛] [1-9]\d*s/u, - timerTurnStart, - 4_000 - ); - const timerForegroundSettled = output.length; - await waitFor( - "aggregate timer settling after background failure", - /✓ [1-9]\d*s/u, - timerForegroundSettled, - 5_000 - ); - await sendAndWait("/goal pause\r", "paused goal", /Goal: Paused \(\/goal resume\)/i); - await sendAndWait("/resume\r", "resume picker", /Resume Session/i); - await sendAndWait("\r", "selected session transcript", /Restored selected response\./i); - terminal.write("\x03"); -} catch (error) { - interactionError = error; - child.kill("SIGKILL"); -} - -const code = await child.exited; -clearTimeout(timeout); -if (!terminal.closed) terminal.close(); -await rm(temporaryHome, { recursive: true, force: true }); -output += decoder.decode(); - -if (interactionError) throw interactionError; - -const plain = plainText(output); - -if (process.env.ZCODE_TUI_SMOKE_DEBUG === "1") console.log(plain); -if (code !== 0) throw new Error(`Feature TUI smoke exited with ${code}.\n${plain.slice(-6_000)}`); - -const turnNotifications = output.match(/\x1b\]9;ZCode ·/gu) ?? []; -if (turnNotifications.length !== 8 - || !output.includes("\x1b]9;ZCode · Plan approval fixture complete: allow.") - || !output.includes("\x1b]9;ZCode · Plan approval fixture complete: deny · plan_approval_feedback.") - || !output.includes("\x1b]9;ZCode · Queued follow-up started after the active turn.") - || !output.includes("\x1b]9;ZCode · Queued input started after interrupting the stuck background handoff.") - || !output.includes("\x1b]9;ZCode · Timer foreground complete; background still running.") - || !output.includes("\x1b]9;ZCode · Background agent replied · agent_feature · /tasks") - || !output.includes("\x1b]9;ZCode · Background task needs attention · agent_feature · /tasks") - || !output.includes("\x1b]9;ZCode · Background result processing failed · agent_feature · /tasks") - || output.includes("\x1b]9;ZCode · Feature prompt complete.")) { - throw new Error(`Expected eight idle-boundary and task notifications, received ${turnNotifications.length}.`); -} - -for (const [label, pattern] of [ - ["restored startup transcript", /Restored startup response\./i], - ["restored selected transcript", /Restored selected response\./i], - ["double-Esc rewind hint", /Esc again to rewind conversation/i], - ["rewind target picker", /Rewind conversation[\s\S]*Restored later prompt/i], - ["selected older rewind target", /Return to before: Restored startup prompt/i], - ["conversation and workspace rewind", /Conversation and workspace rewound/i], - ["settings picker", /ZCode settings/i], - ["saved setting returned to root", /When to notify: always saved · environment override remains active/i], - ["child escape returned to settings", /No changes · Esc closes settings/i], - ["login setup picker", /Set Up Coding Plan/i], - ["custom provider login entry", /Custom provider/i], - ["API key prompt cancellation", /API key entry cancelled\./i], - ["masked API key command", /\/login zai-coding-plan-api-key \[redacted\]/i], - ["API key setup", /Configured Z\.AI Coding Plan\./i], - ["suspended login command", /External login command completed\./i], - ["refreshed login state", /Model access configured via[\s\S]*config\.json/i], - ["default OAuth suspension", /Login override disabled\.[\s\S]*External login command completed\./i], - ["OAuth HTTP diagnostic", /Login failed: OAuth HTTP error 404 \(empty or non-JSON response\)/i], - ["resume picker", /Resume Session/i], - ["long help output", /Use \/help for details/i], - ["context remaining", /ctx 75% left/i], - ["session tokens", /18\.5K tokens/i], - ["active goal footer", /Goal: Active \(40K \/ 50K\)/i], - ["persistent runtime activity", /Activity · 1 in background · 1 open task · \/tasks/i], - ["background task summary", /Feature background audit · bg_feature/i], - ["Bash task dialog", /Bash task · bg_feature/i], - ["agent task dialog", /Agent \(reviewer\) task · agent_feature/i], - ["separate resume actions", /Resume agent[\s\S]*Resume with instructions/i], - ["task-scoped agent output", /Task activity[\s\S]*Background agent reply stored in task activity\./i], - ["task-scoped handoff failure", /Task activity[\s\S]*Background result failed visibly\./i], - ["resumed agent conversation", /(?=[\s\S]*You:\s*Continue the assigned task from the last completed step\.\s*Re-check the current workspace state and\s*finish the remaining work\.)(?=[\s\S]*Agent "agent_feature" resumed in the background\.)/i], - ["restarted active agent", /Agent "agent_feature" restarted in the background\./i], - ["paused goal footer", /Goal: Paused \(\/goal resume\)/i], - ["model picker", /Select model/i], - ["model providers entry", /Model providers/i], - ["effort picker", /Select reasoning effort/i], - ["image attachment", /1 image attached/i], - ["multiple attachment tokens", /Images[\s\S]*\[Image #1\][\s\S]*\[Image #2\]/i], - ["attachment selection", /› \[Image #2\][\s\S]*Backspace\/Delete remove/i], - ["attachment navigation", /› \[Image #1\]/i], - ["workspace file reference", /›\s*inspect @src\/index\.ts/i], - ["skill reference", /›\s*inspect @src\/index\.ts \$audit/i], - ["skill picker", /audit[\s\S]*Review technical quality\./i], - ["pending active-turn steering", /Steering current turn · 1 waiting[\s\S]*Keep the final response concise\./i], - ["committed active-turn steering", /› Keep the final response concise\./i], - ["rejected steer fallback", /Steer was not accepted \(turn not steerable\); queued for the next turn\./i], - ["editable follow-up queue", /Queued next turn · 1 input[\s\S]*Revise this queued follow-up\./i], - ["automatic queued follow-up", /Queued follow-up started after the active turn\./i], - ["background handoff preemption", /Queued input started after interrupting the stuck background handoff\./i], - ["compact background agent reply", /Background agent replied[\s\S]*agent_feature · \/tasks/i], - ["compact background task failure", /Background task needs attention[\s\S]*agent_feature · \/tasks/i], - ["compact handoff failure", /Background result processing failed[\s\S]*agent_feature · \/tasks/i], - ["completed thinking card", /◇ Thought/i], - ["updated plan", /● Updated Plan/i], - ["plan summary", /2 completed · 1 in progress · 0 pending/i], - ["active plan item", /□ Verify the TUI/i], - ["continuous interleaved model text", /我会检查 vercel-labs\/native 与 Compose Desktop 的 WebView 路径。/u], - ["pre-tool assistant commentary", /I will inspect the repository first\./i], - ["tool execution", /Read 1 file · ⎿ demo\.ts/i], - ["tool result", /source text/i], - ["file diff header", /✓ Edit demo\.ts \+1 -1/i], - ["diff browser", /Diff · Turn \d+/i], - ["diff detail paging", /Page 1\/\d+/i], - ["context detail", /Estimated prompt composition by characters/i], - ["status detail", /ZCode Status/i], - ["transcript navigation", /Transcript \d+\/\d+/i], - ["transcript search navigation", /Search 2\/\d+: inspect/i], - ["transcript page navigation", /Page 2\/\d+ · ←\/→ or PageUp\/PageDown scroll/i], - ["removed diff line", /│- const value = 1;/i], - ["added diff line", /│\+ const value = 2;/i], - ["final assistant response", /Feature prompt complete\./i], - ["Mermaid diagram", /◇ Mermaid · flowchart/i], - ["CJK Mermaid nodes", /用户输入[\s\S]*编辑器面板/i], - ["MCP picker", /MCP servers/i], - ["MCP action", /MCP connected: docs/i], - ["workflow picker", /Workflow runs/i], - ["workflow detail", /Feature workflow/i], - ["workflow stop", /Status: cancelled/i], - ["complete activity view", /Current activity[\s\S]*Verify the TUI/i] -] as const) { - if (!pattern.test(plain)) throw new Error(`Missing ${label} in feature TUI smoke.\n${plain.slice(-6_000)}`); -} - -if (/[🕐-🕚] 0s/u.test(plain)) { - throw new Error(`Turn timer animated more than once during its first second.\n${plain.slice(-6_000)}`); -} - -if (!/Token usage: total=9,500 input=5,000 \(\+ 9,000 cached\) output=4,000 \(reasoning 500\)/i.test(plain)) { - throw new Error(`Missing token usage exit summary.\n${plain.slice(-6_000)}`); -} - -if (!/To continue this session, run zcode --resume feature-session/i.test(plain)) { - throw new Error(`Missing session resume exit hint.\n${plain.slice(-6_000)}`); -} - -if (/Shift\+Tab mode · Ctrl\+N model/i.test(plain)) { - throw new Error(`Unexpected shortcut legend below the editor.\n${plain.slice(-6_000)}`); -} - -if (/(?:^|\n)[ \t]*(?:ready|switching(?:…|\.\.\.)?)[ \t]*(?:\n|$)/imu.test(plain)) { - throw new Error(`Unexpected transient state log in feature TUI smoke.\n${plain.slice(-6_000)}`); -} - -if (/Wait for the active turn or press Ctrl\+C before running a slash command/i.test(plain)) { - throw new Error(`Resume selection was blocked by the active submission.\n${plain.slice(-6_000)}`); -} - -if (/Feature smoke prompt did not include the selected file, skill and image attachment/i.test(plain)) { - throw new Error(`Coordinator task notices were rejected by the fixture.\n${plain.slice(-6_000)}`); -} - -if (plain.includes("feature-secret-api-key") || plain.includes("override-fixture-key")) { - throw new Error(`API key leaked into TUI output.\n${plain.slice(-6_000)}`); -} - -let stateOffset = 0; -for (const [label, pattern] of [ - ["edit mode shortcut", /◈ alpha\/model ─ ◉ edit ─ ⚡ low/i], - ["guarded mode shortcut", /◈ alpha\/model ─ ◉ guarded ─ ⚡ low/i], - ["yolo mode shortcut", /◈ alpha\/model ─ ◉ yolo ─ ⚡ low/i], - ["plan mode shortcut", /◈ alpha\/model ─ ◉ yolo ─ ⚡ low/i], - ["model shortcut preserving plan", /◈ beta\/model ─ ◉ yolo ─ ⚡ low/i], - ["effort shortcut preserving plan", /◈ beta\/model ─ ◉ yolo ─ ⚡ high/i], - ["model picker preserving plan", /◈ alpha\/model ─ ◉ yolo ─ ⚡ high/i], - ["effort picker preserving plan", /◈ alpha\/model ─ ◉ yolo ─ ⚡ low/i], - ["build mode shortcut", /◈ alpha\/model ─ ◉ build ─ ⚡ low/i], - ["scrollable plan approval", /Plan approval fixture complete: allow\./i], - ["plan feedback continuation", /Plan approval fixture complete: deny · plan_approval_feedback\./i] -] as const) { - const match = pattern.exec(plain.slice(stateOffset)); - if (!match) throw new Error(`Missing ordered ${label} state in feature TUI smoke.\n${plain.slice(-6_000)}`); - stateOffset += (match.index ?? 0) + match[0].length; -} - -for (const [label, pattern] of [ - ["mode transcript", /Mode: plan/i], - ["model command transcript", /›\s*\/model\s+(?:alpha|beta)\/model/i], - ["model response transcript", /Model switched to/i], - ["effort command transcript", /›\s*\/effort\s+(?:low|high)/i], - ["effort response transcript", /Reasoning effort switched to/i] -] as const) { - if (pattern.test(plain)) throw new Error(`Unexpected ${label} in feature TUI smoke.\n${plain.slice(-6_000)}`); -} - -console.log("TUI feature smoke test passed."); diff --git a/scripts/smoke-tui-focus.ts b/scripts/smoke-tui-focus.ts deleted file mode 100644 index 0e12ca40..00000000 --- a/scripts/smoke-tui-focus.ts +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env bun - -import { Terminal } from "@xterm/headless"; -import { strict as assert } from "node:assert"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { runtimeTestEnv } from "./runtime-test-env.ts"; - -const root = join(import.meta.dir, ".."); -const fixture = join(root, "test/fixtures/tui-focus.ts"); -const mode = process.argv[2] ?? "fullscreen"; -assert.ok(mode === "regular" || mode === "fullscreen", "Expected regular or fullscreen mode"); -const notificationMethod = process.argv[3] ?? "off"; -const notificationCondition = process.argv[4] ?? "always"; -const entry = process.argv[5] ?? "source"; -const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; -const testHome = await mkdtemp(join(tmpdir(), "zcode-tui-focus-")); -const screen = new Terminal({ cols: 100, rows: 32, allowProposedApi: true }); -let output = ""; -let pending = Promise.resolve(); -let cursorVisible = true; -for (const [final, visible] of [["h", true], ["l", false]] as const) { - screen.parser.registerCsiHandler({ prefix: "?", final }, (params) => { - if (params.includes(25)) cursorVisible = visible; - return false; - }); -} -const decoder = new TextDecoder(); -const terminal = new Bun.Terminal({ - cols: screen.cols, - rows: screen.rows, - name: "xterm-256color", - data(_terminal, data) { - const chunk = decoder.decode(data, { stream: true }); - output += chunk; - pending = pending.then(() => new Promise((resolve) => screen.write(chunk, resolve))); - } -}); -const child = Bun.spawn([process.execPath, fixture, entry], { - cwd: root, - env: { - ...runtimeTestEnv(testHome, root), CI: "1", NO_COLOR: "", TERM: "xterm-256color", SHELL: "/bin/sh", - TERM_PROGRAM: "iTerm.app", ZCODE_TUI_MODE: mode, - ZCODE_TUI_NOTIFICATION_METHOD: notificationMethod, - ZCODE_TUI_NOTIFICATION_CONDITION: notificationCondition, - ZCODE_TUI_LOGIN_CMD: `${shellQuote(process.execPath)} ${shellQuote(fixture)} login` - }, - terminal -}); -const timeout = setTimeout(() => child.kill("SIGKILL"), 90_000); - -function screenText(): string { - const buffer = screen.buffer.active; - return Array.from({ length: screen.rows }, (_, row) => ( - buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "" - )).join("\n"); -} - -function caretInverse(): boolean { - const buffer = screen.buffer.active; - return Boolean(buffer.getLine(buffer.baseY + buffer.cursorY)?.getCell(buffer.cursorX)?.isInverse()); -} - -async function waitFor(label: string, condition: () => boolean, timeoutMs = 8_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await pending; - if (condition()) return; - if (child.exitCode !== null) break; - await Bun.sleep(20); - } - throw new Error(`Timed out waiting for ${label} (${mode}, ${notificationMethod}/${notificationCondition}, exit=${child.exitCode}, bytes=${output.length}).\n${screenText()}`); -} - -async function send(input: string): Promise { - terminal.write(input); - await Bun.sleep(100); - await pending; -} - -async function focusCycle(label: string): Promise { - assert.equal(cursorVisible, true, `${label}: hardware cursor starts visible`); - assert.equal(caretInverse(), false, `${label}: hardware cursor is not doubled by a drawn caret`); - const before = screenText(); - const position = { x: screen.buffer.active.cursorX, y: screen.buffer.active.cursorY }; - terminal.write("\x1b[O"); - await waitFor(`${label}: hardware and drawn carets hidden`, () => !cursorVisible && !caretInverse()); - assert.equal(screenText(), before, `${label}: hiding the caret preserves text`); - await send("\x1b[O"); - assert.equal(cursorVisible, false, `${label}: repeated focus-out stays hidden`); - assert.equal(caretInverse(), false, `${label}: repeated focus-out keeps the drawn caret hidden`); - terminal.write("\x1b[I"); - await waitFor(`${label}: hardware cursor restored`, () => cursorVisible && !caretInverse()); - assert.equal(screenText(), before, `${label}: restoring the caret preserves text`); - assert.deepEqual( - { x: screen.buffer.active.cursorX, y: screen.buffer.active.cursorY }, - position, - `${label}: focus changes preserve the cursor position for IME placement` - ); -} - -async function editorReady(): Promise { - await waitFor("editor focus", () => cursorVisible && !caretInverse() && screen.buffer.active.cursorY >= screen.rows - 5); -} - -async function openPrompt(mask: boolean): Promise { - await send("\x1b[O"); - await send("/focus-prompt\r"); - await waitFor("prompt picker", () => screenText().includes("Choose an input.")); - await send(mask ? "\x1b[B\r" : "\r"); - await waitFor("text prompt", () => screenText().includes(mask ? "Focus masked prompt" : "Focus text prompt")); - assert.ok(screenText().includes(mask ? "Secret placeholder" : "Text placeholder"), "empty prompt retains its placeholder"); - assert.equal(cursorVisible || caretInverse(), false, "opening a prompt while unfocused keeps carets hidden"); - await send("\x1b[I"); - await waitFor("focused prompt", () => cursorVisible && !caretInverse()); -} - -async function resize(cols: number, rows: number): Promise { - const start = output.length; - screen.resize(cols, rows); - terminal.resize(cols, rows); - // Signal the child explicitly and wait for the new geometry before sending input. - child.kill("SIGWINCH"); - await waitFor("render at resized geometry", () => output.slice(start).includes(`\x1b[${rows};1H`)); -} - -function inverseCount(): number { - const buffer = screen.buffer.active; - let count = 0; - for (let row = 0; row < screen.rows; row += 1) { - const line = buffer.getLine(buffer.viewportY + row); - for (let col = 0; col < screen.cols; col += 1) { - if (line?.getCell(col)?.isInverse()) count += 1; - } - } - return count; -} - -async function switchMode(next: "regular" | "fullscreen"): Promise { - await send("/settings\r"); - await waitFor("settings", () => screenText().includes("ZCode settings")); - await send("\x1b[B\x1b[B\x1b[B\r"); - await waitFor("display picker", () => screenText().includes("Switch between regular and fullscreen")); - // Apply the switch while unfocused to exercise focus restoration on a new TUI. - await send(`\x1b[O${next === "fullscreen" ? "\x1b[B" : "\x1b[A"}\r`); - await waitFor(`display ${next}`, () => screenText().includes(`Display mode: ${next}`)); - for (let i = 0; i < 2 && screenText().includes("ZCode settings"); i += 1) await send("\x1b"); - assert.equal(cursorVisible, false, "mode switch preserves unfocused hardware cursor"); - assert.equal(caretInverse(), false, "mode switch preserves unfocused drawn caret"); - await send("\x1b[I"); - await waitFor("mode switch restores editor caret", () => cursorVisible && !caretInverse()); - await focusCycle(`editor after switch to ${next}`); - if (next === "fullscreen") { - await send("\x1b[102;6u"); - await waitFor("search after mode switch", () => screenText().includes("Find in transcript")); - await focusCycle("search after mode switch"); - await send("\x1b"); - } -} - -try { - await waitFor("interactive editor", () => screenText().includes("alpha/model") && cursorVisible && !caretInverse(), 20_000); - // No focus report has arrived: terminals without mode 1004 retain the hardware cursor. - assert.ok(output.includes("\x1b[?1004h"), "focus reporting is enabled regardless of notification settings"); - await focusCycle("empty editor"); - await send("中文👩‍💻é"); - await send("\x1b[D"); - await focusCycle("editor caret inside Unicode input"); - await send("\x03"); - - // One read can contain several reports; reports can also be split over reads. - await send("\x1b[O\x1b[I\x1b[O"); - assert.equal(cursorVisible || caretInverse(), false, "batched reports use the last state"); - terminal.write("\x1b["); - await Bun.sleep(5); // Stay within the terminal parser's incomplete-CSI timeout. - await send("I"); - await waitFor("split focus-in", () => cursorVisible && !caretInverse()); - await send("\x1b[I\x1b[I"); - assert.equal(cursorVisible && !caretInverse(), true, "duplicate focus-in preserves the hardware cursor without a drawn caret"); - - await send("Focus transcript fixture\r"); - await waitFor("transcript search fixture", () => screenText().includes("Focus fixture response.")); - if (mode === "fullscreen") { - // Focus assertions compare identical content; let the first-turn welcome - // animation finish before taking the screen and cursor snapshots. - await waitFor("fullscreen welcome transition", () => !screenText().includes("Ask a task about this workspace")); - } - await send("/search Focus\r"); - await waitFor("slash-command search", () => /Search 1\/\d+: Focus/.test(screenText())); - await focusCycle("slash-command search with matching text"); - await send("\x1b[O"); - assert.ok(inverseCount() > 0, "slash-command search highlighting survives focus loss"); - await send("\x1b[I"); - await waitFor("editor after slash-command search", () => cursorVisible && !caretInverse()); - await send("/search clear\r"); - await waitFor("slash-command search cleared", () => !/Search \d+\/\d+: Focus/.test(screenText())); - - if (mode === "fullscreen") { - await send("\x1b[102;6u"); // Ctrl+Shift+F, CSI-u encoding. - await waitFor("search overlay", () => screenText().includes("Find in transcript")); - await focusCycle("empty search with styled placeholder"); - for (const query of ["missing-query", "中文👩‍💻é", "long-query-".repeat(12)]) { - await send(`\x15${query}`); - await focusCycle(`search ${query.slice(0, 20)}`); - } - await send("\x15Focus"); - await waitFor("search result", () => /1\/\d/.test(screenText())); - await focusCycle("search with matching text"); - await send("\x1b[O"); - assert.ok(inverseCount() > 0, "search result highlighting survives focus loss"); - const matchHighlights = inverseCount(); - await send("\x1b[I\x1b[<0;10;30M\x1b[<0;10;30m"); - await editorReady(); - await send("\x1b[O"); - assert.equal(inverseCount(), matchHighlights, "an open search loses its caret even after clicking the editor"); - await send("\x1b[I\x1b[102;6u"); - await send("\x1b[102;6uFocus\x1b[O"); - await resize(48, 18); - assert.equal(cursorVisible || caretInverse(), false, "resizing preserves unfocused carets"); - await send("\x1b[I"); - await waitFor("search restored after resize", () => cursorVisible && !caretInverse()); - await focusCycle("narrow search"); - await resize(100, 32); - await send("\x1b"); - await editorReady(); - } - - for (const mask of [false, true]) { - await openPrompt(mask); - await focusCycle(mask ? "empty masked prompt" : "empty text prompt"); - const value = mask ? "focus-fixture-secret" : "中文👩‍💻 instructions ".repeat(8); - await send(value); - await focusCycle(mask ? "filled masked prompt" : "wrapped text prompt"); - if (mask) assert.equal(output.includes(value), false, "masked input never appears in output"); - await send("\x1b[O\x1b"); - await waitFor("returned to prompt picker", () => screenText().includes("Choose an input.")); - await send("\x1b"); - assert.equal(cursorVisible || caretInverse(), false, "closing a prompt while unfocused keeps carets hidden"); - await send("\x1b[I"); - await waitFor("editor after prompt", () => cursorVisible && !caretInverse()); - } - - await send("/focus-stream\r\x1b[O"); - await waitFor("background redraw", () => screenText().includes("Stream focus still running finished.")); - assert.equal(cursorVisible || caretInverse(), false, "stream redraw stays unfocused"); - await send("\x1b[I"); - await waitFor("editor after stream", () => cursorVisible && !caretInverse()); - // Establish the last known state before the login command disables reports. - await send("\x1b[O"); - await send("/login\r"); - await waitFor("suspended login resumed", () => output.includes("Focus fixture login finished.") && screenText().includes("Login command finished,")); - assert.equal(cursorVisible || caretInverse(), false, "login resume preserves last known focus"); - await send("\x1b[I"); - await waitFor("editor after login", () => cursorVisible && !caretInverse()); - await focusCycle("editor after suspended login"); - - await switchMode(mode === "fullscreen" ? "regular" : "fullscreen"); - await switchMode(mode); - await send("\x1b[O"); - child.kill("SIGTERM"); - assert.equal(await child.exited, 143, "SIGTERM exits after cleanup"); - await Bun.sleep(50); - await pending; - assert.equal(cursorVisible, true, "exit restores the shell cursor even when unfocused"); - assert.equal(screen.buffer.active.type, "normal", "exit leaves the alternate screen"); - assert.ok(output.includes("\x1b[?1004l"), "exit disables focus reports"); -} finally { - if (child.exitCode === null) child.kill("SIGTERM"); - await child.exited; - await Bun.sleep(30); - await pending; - clearTimeout(timeout); - if (!terminal.closed) terminal.close(); - screen.dispose(); - await rm(testHome, { recursive: true, force: true }); -} -console.log(`Terminal focus smoke test passed (${mode}, ${notificationMethod}/${notificationCondition}, ${entry}).`); diff --git a/scripts/smoke-tui-fullscreen-layout.ts b/scripts/smoke-tui-fullscreen-layout.ts deleted file mode 100644 index 1b6b4845..00000000 --- a/scripts/smoke-tui-fullscreen-layout.ts +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env bun - -import { terminalOutputText as plain } from "./terminal-output.ts"; -import { runtimeTestEnv } from "./runtime-test-env.ts"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const root = join(import.meta.dir, ".."); -const fixture = join(root, "test", "fixtures", "tui-fullscreen-layout.ts"); - -async function runPhase(options: { copyOnSelect: boolean; multiplexer?: boolean }): Promise { - const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-layout-")); - const clipboardPath = join(temporaryHome, "clipboard.txt"); - const decoder = new TextDecoder(); - // ui.copyOnSelect defaults to enabled; seed the user config to exercise the opt-out. - if (!options.copyOnSelect) { - const configDirectory = join(temporaryHome, ".zcode", "cli"); - await mkdir(configDirectory, { recursive: true, mode: 0o700 }); - await writeFile( - join(configDirectory, "setting.json"), - `${JSON.stringify({ ui: { copyOnSelect: false } })}\n` - ); - } - - let output = ""; - const terminal = new Bun.Terminal({ - cols: 80, - rows: 24, - name: "xterm-256color", - data(_terminal, data) { - output += decoder.decode(data, { stream: true }); - } - }); - - const child = Bun.spawn([process.execPath, fixture], { - cwd: root, - env: { - ...runtimeTestEnv(temporaryHome, root), - CI: "1", - TERM: "xterm-256color", - ...(options.multiplexer ? { TMUX: "1" } : {}), - ZCODE_TUI_MODE: "fullscreen", - ZCODE_TUI_NOTIFICATION_METHOD: "off", - ZCODE_TUI_TEST_CLIPBOARD_PATH: clipboardPath - }, - terminal - }); - - - async function waitFor(pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { - const startedAt = Date.now(); - while (!pattern.test(plain(output.slice(start))) && child.exitCode === null && Date.now() - startedAt < timeoutMs) { - await Bun.sleep(20); - } - if (!pattern.test(plain(output.slice(start)))) { - throw new Error(`Timed out waiting for ${pattern}.\n${plain(output).slice(-4_000)}`); - } - } - - function screenRows(): string[] { - const rows: string[] = []; - const writes = output.matchAll(/\x1b\[(\d+);1H\x1b\[2K([\s\S]*?)(?=\x1b\[\d+;1H\x1b\[2K|$)/g); - // Drop the trailing scrollbar column (0.85 paints track/thumb glyphs there). - for (const match of writes) rows[Number(match[1]) - 1] = plain(match[2] ?? "").replace(/[│┃█▐]\s*$/u, "").trimEnd(); - return rows; - } - - /** Rows with the scrollbar column intact, for thumb hit-testing. */ - function rawScreenRows(): string[] { - const rows: string[] = []; - const writes = output.matchAll(/\x1b\[(\d+);1H\x1b\[2K([\s\S]*?)(?=\x1b\[\d+;1H\x1b\[2K|$)/g); - for (const match of writes) rows[Number(match[1]) - 1] = plain(match[2] ?? ""); - return rows; - } - - // pi-tui 0.84 themed the thumb with a background color; 0.85 paints a block - // glyph in the scrollbar column. Accept either representation. - const SCROLLBAR_THUMB = /\x1b\[48;5;(?:238|252)m/u; - - const timeout = setTimeout(() => child.kill("SIGKILL"), 20_000); - let failure: unknown; - try { - const startupOutputStart = output.length; - await waitFor(/alpha\/model/i); - const startupRows = screenRows(); - if (/\x1b\[(?:100|48;5;(?:238|250|252))m/u.test(output.slice(startupOutputStart)) - || /[┃█▐]\s*$/mu.test(plain(output.slice(startupOutputStart)))) { - throw new Error(`Fullscreen startup rendered a scrollbar thumb before scrolling.\n${startupRows.join("\n")}`); - } - if (startupRows.some((row) => row.includes("SYSTEM INITIATED"))) { - throw new Error(`Fullscreen header used the wide banner unexpectedly.\n${startupRows.join("\n")}`); - } - if (!startupRows.some((row) => /^── ◆ ZCODE/u.test(row))) { - throw new Error(`Fullscreen header rail did not render its separator.\n${startupRows.join("\n")}`); - } - if (!startupRows.some((row) => /^╭─ Workspace .*─╮$/u.test(row))) { - throw new Error(`Fullscreen welcome card did not render its frame.\n${startupRows.join("\n")}`); - } - if (!startupRows.some((row) => row.includes("Ask a task about this workspace"))) { - throw new Error(`Fullscreen welcome surface was not rendered.\n${startupRows.join("\n")}`); - } - const copyRow = startupRows.findIndex((row) => row.includes("Ask a task about this workspace")); - const copyColumn = startupRows[copyRow]?.indexOf("Ask") ?? -1; - if (copyRow < 0 || copyColumn < 0) { - throw new Error(`Could not locate fullscreen text for mouse-copy verification.\n${startupRows.join("\n")}`); - } - // Focus and the first mouse gesture can arrive in a single input batch. - terminal.write( - "\x1b[O\x1b[I" - + `\x1b[<0;${copyColumn + 1};${copyRow + 1}M` - + `\x1b[<32;${copyColumn + 4};${copyRow + 1}M` - + `\x1b[<0;${copyColumn + 4};${copyRow + 1}m` - ); - if (options.copyOnSelect) { - await waitFor(/Copied!/i); - const copiedText = await Bun.file(clipboardPath).text(); - if (!copiedText.startsWith("Ask")) { - throw new Error(`Fullscreen selection did not reach the system clipboard writer: ${JSON.stringify(copiedText)}`); - } - } else { - // Copy-on-select is disabled: a drag must highlight only and leave the - // system clipboard untouched for the user to copy manually. - await Bun.sleep(250); - if (/Copied!/i.test(plain(output))) { - throw new Error("Fullscreen selection flashed a clipboard copy despite ui.copyOnSelect being false."); - } - if (await Bun.file(clipboardPath).exists()) { - throw new Error(`Fullscreen selection reached the system clipboard writer: ${JSON.stringify(await Bun.file(clipboardPath).text())}`); - } - const manualCopyStart = output.length; - terminal.write("/copy\r"); - await waitFor(/Copied!/i, manualCopyStart); - const copiedText = await Bun.file(clipboardPath).text(); - if (!copiedText.startsWith("Ask")) { - throw new Error(`Fullscreen /copy did not copy the active mouse selection: ${JSON.stringify(copiedText)}`); - } - } - const turnStart = output.length; - terminal.write("long transcript\r"); - await waitFor(/transcript line 80/i, turnStart); - const beforeRows = screenRows(); - const scrollOutputStart = output.length; - terminal.write("\x1b[5~"); - await Bun.sleep(100); - const rows = screenRows(); - const scrollChunk = output.slice(scrollOutputStart); - const thumbVisible = SCROLLBAR_THUMB.test(scrollChunk) || /[┃█▐]/u.test(scrollChunk); - if (!process.env.NO_COLOR && !thumbVisible) { - throw new Error(`Fullscreen transcript scroll did not render the themed scrollbar thumb.\n${rows.join("\n")}`); - } - const firstTranscript = (lines: string[]): number => { - const line = lines.find((value) => /^ transcript line \d+$/u.test(value)); - return line ? Number(line.match(/\d+/u)?.[0] ?? 0) : 0; - }; - const firstTranscriptRow = rows.findIndex((row) => /^ transcript line \d+$/u.test(row)); - if (firstTranscriptRow < 1 || firstTranscriptRow > 3) { - throw new Error(`Fullscreen header consumed too much space. transcriptRow=${firstTranscriptRow}\n${rows.join("\n")}`); - } - if (!rows.some((row) => row.includes("◆ ZCODE"))) { - throw new Error(`Fullscreen context rail was not rendered after the first turn.\n${rows.join("\n")}`); - } - if (rows.some((row) => row.includes("Ask a task about this workspace"))) { - throw new Error(`Fullscreen welcome surface did not collapse after the first turn.\n${rows.join("\n")}`); - } - if (firstTranscript(rows) >= firstTranscript(beforeRows)) { - throw new Error(`Fullscreen transcript did not scroll independently. before=${firstTranscript(beforeRows)} after=${firstTranscript(rows)}`); - } - const thumbRowsBeforeFade = rawScreenRows().flatMap((row, index) => (/[┃█▐]\s*$/u.test(row) ? [index] : [])); - // The scrollbar must be mouse-draggable, not just wheel/page scrollable. - // In a normal terminal passive motion reveals the hidden auto scrollbar; - // multiplexers do not forward that motion, so the app must prime the hit - // test when the press arrives instead. - await Bun.sleep(1_200); - if (!options.multiplexer) { - for (const row of [6, 9, 12, 15]) { - terminal.write(`\x1b[<35;80;${row}M`); - await Bun.sleep(45); - } - await Bun.sleep(200); - } - const dragOutputStart = output.length; - const dragRows = rawScreenRows(); - const thumbRows = dragRows.flatMap((row, index) => (/[┃█▐]\s*$/u.test(row) ? [index] : [])); - const candidateRows = thumbRows.length > 0 ? thumbRows : thumbRowsBeforeFade; - if (candidateRows.length === 0) { - throw new Error(`Fullscreen scrollbar thumb was not revealed by hover.\n${dragRows.join("\n")}`); - } - const dragFrom = candidateRows[Math.floor(candidateRows.length / 2)] + 1; - const dragTo = Math.min(21, dragFrom + 4); - const dragBefore = firstTranscript(screenRows()); - terminal.write(`\x1b[<0;80;${dragFrom}M`); - await Bun.sleep(60); - terminal.write(`\x1b[<32;80;${dragTo}M`); - await Bun.sleep(60); - terminal.write(`\x1b[<0;80;${dragTo}m`); - await Bun.sleep(250); - const dragAfter = firstTranscript(screenRows()); - if (dragAfter === dragBefore) { - throw new Error( - `Fullscreen scrollbar drag did not scroll the transcript. before=${dragBefore} after=${dragAfter}\n` - + `drag ${dragFrom} -> ${dragTo}, emitted ${output.length - dragOutputStart} bytes.\n${screenRows().join("\n")}` - ); - } - const statusRow = rows.findIndex((row) => row.includes("◈ alpha/model")); - if (statusRow < 0 || statusRow < 18) { - throw new Error(`Fullscreen composer was not fixed near the bottom. statusRow=${statusRow}\n${rows.join("\n")}`); - } - const resetStart = output.length; - terminal.write("/cls\r"); - await waitFor(/Ask a task about this workspace/i, resetStart); - const resetRows = screenRows(); - if (!resetRows.some((row) => /^╭─ Workspace .*─╮$/u.test(row))) { - throw new Error(`Fullscreen /cls did not restore the welcome frame.\n${resetRows.join("\n")}`); - } - if (!resetRows.some((row) => row.includes("Ask a task about this workspace"))) { - throw new Error(`Fullscreen /cls did not restore the welcome content.\n${resetRows.join("\n")}`); - } - terminal.write("\x03"); - await Bun.sleep(40); - terminal.write("\x03"); - } catch (error) { - failure = error; - child.kill("SIGKILL"); - } - - const code = await child.exited; - clearTimeout(timeout); - if (!terminal.closed) terminal.close(); - await rm(temporaryHome, { recursive: true, force: true }); - if (failure) throw failure; - if (code !== 0) throw new Error(`Fullscreen layout smoke exited with ${code}.`); - console.log(options.copyOnSelect - ? "Fullscreen fixed-composer layout smoke passed." - : "Fullscreen copy-on-select opt-out smoke passed."); -} - -await runPhase({ copyOnSelect: true }); -await runPhase({ copyOnSelect: false }); -await runPhase({ copyOnSelect: true, multiplexer: true }); diff --git a/scripts/smoke-tui-fullscreen-switch.ts b/scripts/smoke-tui-fullscreen-switch.ts deleted file mode 100644 index 757e346f..00000000 --- a/scripts/smoke-tui-fullscreen-switch.ts +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env bun - -import { terminalOutputText as plainText } from "./terminal-output.ts"; -import { runtimeTestEnv } from "./runtime-test-env.ts"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const root = join(import.meta.dir, ".."); -const fixture = join(root, "test", "fixtures", "tui-features.ts"); -const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-switch-")); -const decoder = new TextDecoder(); -const terminalRows = 40; -let output = ""; -const terminal = new Bun.Terminal({ - cols: 110, - rows: terminalRows, - name: "xterm-256color", - data(_terminal, data) { - output += decoder.decode(data, { stream: true }); - } -}); - -const child = Bun.spawn([process.execPath, fixture], { - cwd: root, - env: { - ...runtimeTestEnv(temporaryHome, root), - CI: "1", - TERM: "xterm-256color", - TERM_PROGRAM: "iTerm.app", - ZCODE_APP_CLI_EXECUTABLE: process.execPath, - ZCODE_APP_CLI_ENTRY: fixture, - ZCODE_TUI_NOTIFICATION_METHOD: "osc9", - ZCODE_TUI_NOTIFICATION_CONDITION: "unfocused" - }, - terminal -}); - - -function screenRows(): string[] { - const rows: string[] = []; - const writes = output.matchAll(/\x1b\[(\d+);1H\x1b\[2K([\s\S]*?)(?=\x1b\[\d+;1H\x1b\[2K|$)/g); - for (const match of writes) rows[Number(match[1]) - 1] = plainText(match[2] ?? "").trimEnd(); - return rows; -} - -async function waitFor( - label: string, - pattern: RegExp, - start = 0, - timeoutMs = 8_000, - raw = false -): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const text = raw ? output.slice(start) : plainText(output.slice(start)); - if (pattern.test(text)) return; - if (child.exitCode !== null) break; - await Bun.sleep(25); - } - throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-6_000)}`); -} - -async function sendAndWait(input: string, label: string, pattern: RegExp, timeoutMs?: number): Promise { - const start = output.length; - terminal.write(input); - await waitFor(label, pattern, start, timeoutMs); - await Bun.sleep(25); - return start; -} - -const timeout = setTimeout(() => child.kill("SIGKILL"), 60_000); - -let interactionError: unknown; -try { - await waitFor("welcome screen", /ZCode/i); - await waitFor("interactive editor", /alpha\/model/i); - await sendAndWait("/settings\r", "settings picker", /ZCode settings/i); - // Navigate to Display mode (4th item). - await sendAndWait("\x1b[B\x1b[B\x1b[B\r", "display mode picker", /Switch between regular and fullscreen/i); - // Select Fullscreen (second item, move down from Regular). - await sendAndWait("\x1b[B\r", "display mode applied", /Switched to fullscreen mode|Display mode: fullscreen/i, 10_000); - // The switch must enter the alternate screen. - await waitFor("alternate screen enter on switch", /\x1b\[\?1049h/, 0, 8_000, true); - await Bun.sleep(75); - const settingsRows = screenRows(); - const settingsTitleRow = settingsRows.findIndex((row) => row.includes("ZCode settings")); - const settingsRuleRow = settingsRows.findLastIndex( - (row, index) => index < settingsTitleRow && /^─{20,}$/u.test(row) - ); - if (settingsTitleRow < Math.floor(terminalRows / 2) || settingsRuleRow !== settingsTitleRow - 1) { - throw new Error(`Fullscreen settings did not render as a separated bottom pane.\n${settingsRows.join("\n")}`); - } - if (settingsRows.slice(settingsRuleRow).some((row) => /Restored (?:startup|later)/u.test(row))) { - throw new Error(`Fullscreen settings mixed with transcript content.\n${settingsRows.join("\n")}`); - } - await sendAndWait("\x1b", "close fullscreen settings", /alpha\/model/i); - // The settings loop redraws its root menu once after the mode switch. A - // second escape closes that menu before exercising the rebuilt editor. - terminal.write("\x1b"); - await Bun.sleep(75); - const restoredRows = screenRows(); - if (!restoredRows.some((row) => row.includes("◆ ZCODE"))) { - throw new Error(`Restored fullscreen session did not render the context rail.\n${restoredRows.join("\n")}`); - } - if (restoredRows.some((row) => row.includes("Ask a task about this workspace"))) { - throw new Error(`Restored fullscreen session rendered the empty-session welcome unexpectedly.\n${restoredRows.join("\n")}`); - } - // The rebuilt editor must own the new TUI and preserve ordinary navigation. - await sendAndWait("abc", "fullscreen editor input", /abc/); - terminal.write("\x1b[H"); - await Bun.sleep(50); - await sendAndWait("X", "fullscreen editor Home insertion", /Xabc/); - terminal.write("\x15"); - await Bun.sleep(50); - // Async completion must repaint without requiring a follow-up key. - const completionStart = output.length; - terminal.write("inspect @ind"); - await waitFor("fullscreen async completion", /src\/index\.ts/i, completionStart, 4_000); - // Ctrl+C first clears the draft; the second exits an idle TUI. - terminal.write("\x03"); - await Bun.sleep(50); - terminal.write("\x15"); - await Bun.sleep(50); - await sendAndWait("/settings\r", "settings after fullscreen switch", /ZCode settings/i); - await sendAndWait("\x1b[B\x1b[B\x1b[B\r", "display mode picker after fullscreen", /Switch between regular and fullscreen/i); - const regularStart = await sendAndWait("\x1b[A\r", "display mode returned to regular", /Display mode: regular/i, 10_000); - await waitFor("alternate screen exit on switch back", /\x1b\[\?1049l/, regularStart, 8_000, true); - await sendAndWait("\x1b", "close regular settings", /alpha\/model/i); - await sendAndWait("return", "regular editor after switch back", /return/); - terminal.write("\x03"); - await Bun.sleep(50); - const resetStart = await sendAndWait( - "/cls\r", - "regular welcome after transcript reset", - /Ask a task about this workspace/i - ); - const resetOutput = plainText(output.slice(resetStart)); - if (!resetOutput.includes("╭─ ◆ ZCODE") || !resetOutput.includes("╰─ /help commands · /status details")) { - throw new Error(`Regular /cls did not remount the framed session intro.\n${resetOutput.slice(-4_000)}`); - } - terminal.write("\x03"); -} catch (error) { - interactionError = error; - child.kill("SIGKILL"); -} - -const code = await child.exited; -clearTimeout(timeout); -if (!terminal.closed) terminal.close(); -await rm(temporaryHome, { recursive: true, force: true }); -output += decoder.decode(); - -if (interactionError) throw interactionError; -if (!/\x1b\[\?1049h/.test(output)) { - throw new Error(`Runtime switch to fullscreen did not enter the alternate screen.\n${plainText(output).slice(-6_000)}`); -} -if (!/\x1b\[\?1049l/.test(output)) { - throw new Error(`Runtime switch smoke did not restore the main screen on exit.\n${plainText(output).slice(-6_000)}`); -} -if (code !== 0) { - throw new Error(`Runtime switch smoke exited with status ${code}.\n${plainText(output).slice(-6_000)}`); -} - -console.log("Fullscreen runtime-switch smoke test passed."); diff --git a/scripts/smoke-tui-fullscreen.ts b/scripts/smoke-tui-fullscreen.ts deleted file mode 100644 index fa58ceea..00000000 --- a/scripts/smoke-tui-fullscreen.ts +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env bun - -import { terminalOutputText as plainText } from "./terminal-output.ts"; -import { runtimeTestEnv } from "./runtime-test-env.ts"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; - -const root = join(import.meta.dir, ".."); -const fixture = join(root, "test", "fixtures", "tui-fullscreen.ts"); -const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-fullscreen-")); -const configDir = join(temporaryHome, ".zcode", "cli"); -const configPath = join(configDir, "setting.json"); -const decoder = new TextDecoder(); -let output = ""; -const terminal = new Bun.Terminal({ - cols: 100, - rows: 32, - name: "xterm-256color", - data(_terminal, data) { - output += decoder.decode(data, { stream: true }); - } -}); - -// Pre-write setting.json with ui.tuiMode=fullscreen to verify P1-1: the TUI -// reads the persisted mode on startup even when the vendor runtime does not -// forward an initialTuiMode option. -await mkdir(configDir, { recursive: true }); -await writeFile(configPath, JSON.stringify({ ui: { tuiMode: "fullscreen" } }, null, 2) + "\n"); - -const child = Bun.spawn([process.execPath, fixture], { - cwd: root, - env: { - ...runtimeTestEnv(temporaryHome, root), - CI: "1", - TERM: "xterm-256color", - TERM_PROGRAM: "iTerm.app", - ZCODE_APP_CLI_EXECUTABLE: process.execPath, - ZCODE_APP_CLI_ENTRY: fixture, - ZCODE_TUI_NOTIFICATION_METHOD: "off" - }, - terminal -}); - - -async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const match = pattern.exec(output.slice(start)); - if (match) return start + match.index + match[0].length; - if (child.exitCode !== null) break; - await Bun.sleep(25); - } - throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-4_000)}`); -} - -async function waitForPlain(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (pattern.test(plainText(output.slice(start)))) return; - if (child.exitCode !== null) break; - await Bun.sleep(25); - } - throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-4_000)}`); -} - -const timeout = setTimeout(() => child.kill("SIGKILL"), 30_000); - -let interactionError: unknown; -try { - // P1-1: config-persisted fullscreen mode takes effect on startup. - await waitFor("alternate screen enter (config-driven)", /\x1b\[\?1049h/); - await waitFor("fullscreen focus reporting", /\x1b\[\?1004h/); - await waitForPlain("welcome banner", /ZCode/i); - await waitForPlain("interactive editor", /alpha\/model/i); - // Terminal tabs can lose visible rows without changing the logical frame or - // geometry. Focus-in must invalidate pi-tui's differential cache so the - // complete fullscreen frame is painted again. - const focusRecoveryStart = output.length; - terminal.write("\x1b[O\x1b[I"); - // pi-tui may delete Kitty image placements before clearing the text screen. - await waitFor("fullscreen focus recovery repaint", /\x1b\[\?2026h[\s\S]*?\x1b\[2J[\s\S]*alpha\/model/i, focusRecoveryStart); - // The editor must be constructed against the config-selected fullscreen TUI, - // so delayed autocomplete repaints without a follow-up key. - const completionStart = output.length; - terminal.write("inspect @ind"); - await waitForPlain("config-driven async completion", /src\/index\.ts/i, completionStart, 4_000); - // Terminal focus reports drive hardware cursor visibility so only the - // focused pane (tmux splits, split terminal windows) keeps a live cursor. - // Anchor the absence check after the observed hide write: the pty may - // still be streaming an earlier frame's cursor-show bytes. - terminal.write("\x1b[O"); - const hiddenAt = await waitFor("cursor hidden on focus out", /\x1b\[\?25l/, output.length); - await Bun.sleep(150); - if (/\x1b\[\?25h/.test(output.slice(hiddenAt))) { - throw new Error("Cursor was re-shown while the terminal was unfocused."); - } - const cursorShowStart = output.length; - terminal.write("\x1b[I"); - await waitFor("cursor restored on focus in", /\x1b\[\?25h/, cursorShowStart); - // P1-2: SIGTERM must trigger clean TUI teardown (exit alt screen, show cursor). - child.kill("SIGTERM"); - await waitFor("alternate screen exit on SIGTERM", /\x1b\[\?1049l/); - await waitFor("cursor restored on SIGTERM", /\x1b\[\?25h/); -} catch (error) { - interactionError = error; - child.kill("SIGKILL"); -} - -const code = await child.exited; -clearTimeout(timeout); -if (!terminal.closed) terminal.close(); -await rm(temporaryHome, { recursive: true, force: true }); -output += decoder.decode(); - -if (interactionError) throw interactionError; -// SIGTERM should result in a non-zero exit, but the TUI must have cleaned up. -if (!/\x1b\[\?1049h/.test(output)) { - throw new Error(`Fullscreen TUI did not enter the alternate screen.\n${plainText(output).slice(-4_000)}`); -} -if (!/\x1b\[\?1049l/.test(output)) { - throw new Error(`Fullscreen TUI did not exit the alternate screen on SIGTERM.\n${plainText(output).slice(-4_000)}`); -} -if (!/\x1b\[\?25h/.test(output)) { - throw new Error(`Fullscreen TUI did not restore the cursor on SIGTERM.\n${plainText(output).slice(-4_000)}`); -} -if (code !== 143) { - throw new Error(`Fullscreen TUI SIGTERM exit code was ${code}, expected 143.\n${plainText(output).slice(-4_000)}`); -} - -console.log("Fullscreen TUI smoke test passed."); diff --git a/scripts/smoke-tui-pressure.ts b/scripts/smoke-tui-pressure.ts deleted file mode 100644 index 2cae1dd4..00000000 --- a/scripts/smoke-tui-pressure.ts +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env bun - -import { terminalOutputText as plainText } from "./terminal-output.ts"; -import { runtimeTestEnv } from "./runtime-test-env.ts"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const root = join(import.meta.dir, ".."); -const fixture = join(root, "test", "fixtures", "tui-pressure.ts"); -const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-pressure-")); -const decoder = new TextDecoder(); -const recoveredSteerTexts = [ - "Esc 后立即提交第一条 steer。", - "合并第二条 steer 并继续当前上下文。" -] as const; -let output = ""; -let cancelTurnStart = 0; -const terminal = new Bun.Terminal({ - cols: 100, - rows: 32, - name: "xterm-256color", - data(_terminal, data) { - output += decoder.decode(data, { stream: true }); - } -}); -const child = Bun.spawn([process.execPath, fixture], { - cwd: root, - env: { - ...runtimeTestEnv(temporaryHome, root), - CI: "1", - TERM: "xterm-256color" - }, - terminal -}); - - -async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 4_000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (pattern.test(plainText(output.slice(start)))) return; - if (child.exitCode !== null) break; - await Bun.sleep(20); - } - throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-5_000)}`); -} - -const timeout = setTimeout(() => child.kill("SIGKILL"), 20_000); -let interactionError: unknown; -try { - await waitFor("pressure editor", /pressure\/model[\s\S]*build/i); - const steerTurnStart = output.length; - terminal.write("stress\r"); - await waitFor("streaming pressure output", /Bash steer-pressure/i, steerTurnStart); - const steerStartedAt = Date.now(); - terminal.write("长任务期间继续检查输入响应。\r"); - await waitFor( - "responsive steering input", - /Steering current turn · 1 waiting[\s\S]*↪ 长任务期间继续检查输入响应。/u, - steerTurnStart, - 2_000 - ); - const steerLatencyMs = Date.now() - steerStartedAt; - if (steerLatencyMs >= 2_000) throw new Error(`Steering input took ${steerLatencyMs}ms under output pressure.`); - await waitFor( - "committed steer while the turn is still active", - /› 长任务期间继续检查输入响应。/u, - steerTurnStart, - 6_000 - ); - terminal.write("\x1b"); - await waitFor( - "late Esc guard", - /Message received; Esc was ignored\. Press Esc again to interrupt\./i, - steerTurnStart, - 2_000 - ); - await waitFor("completed pressure turn", /Pressure turn complete\./i, steerTurnStart, 6_000); - - cancelTurnStart = output.length; - terminal.write("cancel stress\r"); - await waitFor("cancellable pressure output", /Bash cancel-pressure/i, cancelTurnStart); - terminal.write(`${recoveredSteerTexts[0]}\r`); - await waitFor( - "first hanging steer", - /Steering current turn · 1 waiting[\s\S]*Esc 后立即提交第一条 steer。/u, - cancelTurnStart, - 2_000 - ); - terminal.write(`${recoveredSteerTexts[1]}\r`); - await waitFor( - "all hanging steers", - /Steering current turn · 2 waiting[\s\S]*Esc 后立即提交第一条 steer。[\s\S]*合并第二条 steer 并继续当前上下文。/u, - cancelTurnStart, - 2_000 - ); - terminal.write("\x1b"); - await waitFor( - "immediate steer submission", - /Model interrupted to submit steer instructions\./i, - cancelTurnStart, - 2_000 - ); - await waitFor( - "preserved interrupted context", - /Context established before steer interrupt\./i, - cancelTurnStart, - 2_000 - ); - await waitFor( - "recovered steer transcript message", - /› Esc 后立即提交第一条 steer。[\s\S]*合并第二条 steer 并继续当前上下文。/u, - cancelTurnStart, - 2_000 - ); - await waitFor( - "steer continuation", - /Interrupted model step continued with steer instructions\./i, - cancelTurnStart, - 2_000 - ); - const foregroundCancelStart = output.length; - terminal.write("cancel stress\r"); - await waitFor("foreground Esc cancellation turn", /Bash cancel-pressure/i, foregroundCancelStart); - terminal.write("\x1b"); - await waitFor("foreground Esc cancellation", /Turn cancelled\./i, foregroundCancelStart, 2_000); - const cancellationSettled = output.length; - await Bun.sleep(1_200); - if (/[🕐-🕛] [1-9]\d*s/u.test(plainText(output.slice(cancellationSettled)))) { - throw new Error("Turn timer kept advancing after foreground Esc cancellation."); - } - if (child.exitCode !== null) throw new Error("Esc exited ZCode while cancelling a foreground turn."); - terminal.write("\x03"); -} catch (error) { - interactionError = error; - child.kill("SIGKILL"); -} - -const code = await child.exited; -clearTimeout(timeout); -if (!terminal.closed) terminal.close(); -await rm(temporaryHome, { recursive: true, force: true }); -output += decoder.decode(); - -if (interactionError) throw interactionError; -if (code !== 0) { - throw new Error(`Pressure TUI exited with ${code}.\n${plainText(output).slice(-5_000)}`); -} -const plain = plainText(output); -if (/Recovered steer started as a normal turn\./i.test(plain)) { - throw new Error(`Steer recovery regressed to a normal turn.\n${plain.slice(-5_000)}`); -} -if (/(?:Steer was|\d+ steers were) not consumed \(turn cancelled\); queued for the next turn\./i.test(plain)) { - throw new Error(`Immediate steer submission was mislabeled as a queued next turn.\n${plain.slice(-5_000)}`); -} -if (/Model request failed/i.test(plain.slice(cancelTurnStart))) { - throw new Error(`A semantic steer interrupt was rendered as a model failure.\n${plain.slice(-5_000)}`); -} -for (const [label, pattern] of [ - ["bounded active tool input", /input characters omitted from active tool stream/i], - ["bounded active tool output", /output characters omitted from active tool stream/i], - ["quiet cancelled tool", /■ Bash cancel-pressure · cancelled/i] -] as const) { - if (!pattern.test(plain)) throw new Error(`Missing ${label}.\n${plain.slice(-5_000)}`); -} -if (/TOOL_CANCELLED|internal vendor stack|Bash cancel-pressure · failed/i.test(plain)) { - throw new Error(`Cancelled tool leaked failure internals.\n${plain.slice(-5_000)}`); -} - -console.log("TUI output-pressure steering, Esc recovery, and cancellation smoke test passed."); diff --git a/scripts/smoke-tui-session-title.ts b/scripts/smoke-tui-session-title.ts deleted file mode 100644 index 6c6893ab..00000000 --- a/scripts/smoke-tui-session-title.ts +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env bun - -import { terminalOutputText as plainText } from "./terminal-output.ts"; -import { runtimeTestEnv } from "./runtime-test-env.ts"; -// Verify terminal session-title lifecycle behavior through a real PTY. - -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const root = join(import.meta.dir, ".."); -const fixture = join(root, "test", "fixtures", "tui-session-title.ts"); -const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-session-title-")); -const decoder = new TextDecoder(); -let output = ""; -const terminal = new Bun.Terminal({ - cols: 110, - rows: 40, - name: "xterm-256color", - data(_terminal, data) { - output += decoder.decode(data, { stream: true }); - } -}); - -const child = Bun.spawn([process.execPath, fixture], { - cwd: root, - env: { - ...runtimeTestEnv(temporaryHome, root), - CI: "1", - TERM: "xterm-256color", - TERM_PROGRAM: "iTerm.app", - ZCODE_TUI_LOGIN_CMD: "true" - }, - terminal, - stdout: "ignore", - stderr: "ignore" -}); - - -function terminalTitles(value: string): string[] { - return [...value.matchAll(/\x1b\]0;([^\x07]*)\x07/gu)].map((match) => match[1] ?? ""); -} - -function titlesSince(start: number): string[] { - return terminalTitles(output.slice(start)); -} - -async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (pattern.test(plainText(output.slice(start)))) return; - if (child.exitCode !== null) break; - await Bun.sleep(25); - } - throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-6_000)}`); -} - -async function waitForTitle(label: string, title: string, start = 0, timeoutMs = 8_000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (titlesSince(start).includes(title)) return; - if (child.exitCode !== null) break; - await Bun.sleep(25); - } - throw new Error(`Timed out waiting for ${label}: ${title}\n${titlesSince(start).join("\n")}`); -} - -function assertSingleIdleTitle(label: string, start: number, idleTitle: string, workingTitle: string): void { - const titles = titlesSince(start); - const idleCount = titles.filter((title) => title === idleTitle).length; - if (!titles.includes(workingTitle)) { - throw new Error(`${label} never emitted its working title.\n${titles.join("\n")}`); - } - if (idleCount !== 1 || titles.at(-1) !== idleTitle) { - throw new Error(`${label} emitted an unexpected title sequence.\n${titles.join("\n")}`); - } -} - -const timeout = setTimeout(() => child.kill("SIGKILL"), 30_000); -let failure: unknown; -let code = 1; -try { - await waitFor("welcome screen", /ZCode/i); - await waitFor("restored startup transcript", /Restored startup response\./i); - await waitForTitle("startup restored title", "ZC | Restored startup prompt."); - - const ordinaryStart = output.length; - terminal.write("ordinary prompt\r"); - await waitFor("ordinary response", /Echo: ordinary prompt/i, ordinaryStart); - await waitForTitle("ordinary idle title", "ZC | Restored startup prompt.", ordinaryStart); - await Bun.sleep(100); - assertSingleIdleTitle( - "ordinary turn", - ordinaryStart, - "ZC | Restored startup prompt.", - "ZC | ⠋ working…" - ); - - const loginStart = output.length; - terminal.write("/login\r"); - await waitFor( - "login completion", - /Login command finished, but no configured model access was found\./i, - loginStart - ); - await waitForTitle("login idle title", "ZC | Restored startup prompt.", loginStart); - await Bun.sleep(100); - assertSingleIdleTitle( - "suspended login", - loginStart, - "ZC | Restored startup prompt.", - "ZC | ⠋ signing in…" - ); - - const resumeStart = output.length; - terminal.write("/resume fixture-session\r"); - await waitFor("resume response", /Resumed session fixture-session\./i, resumeStart); - await waitForTitle("resumed session title", "ZC | Restored resumed prompt.", resumeStart); - await Bun.sleep(100); - const resumeTitles = titlesSince(resumeStart); - const resumedIdleCount = resumeTitles.filter((title) => title === "ZC | Restored resumed prompt.").length; - if (!resumeTitles.includes("") - || resumedIdleCount !== 1 - || resumeTitles.at(-1) !== "ZC | Restored resumed prompt.") { - throw new Error(`Resume did not clear and replace the terminal title.\n${resumeTitles.join("\n")}`); - } - - terminal.write("/exit\r"); - code = await child.exited; -} catch (error) { - failure = error; - child.kill("SIGKILL"); -} - -clearTimeout(timeout); -if (code === 1 && child.exitCode !== null) code = child.exitCode; -if (!terminal.closed) terminal.close(); -await rm(temporaryHome, { recursive: true, force: true }); - -if (failure) throw failure; -if (code !== 0) throw new Error(`Session-title smoke exited with ${code}.\n${plainText(output).slice(-6_000)}`); -console.log("PASS: terminal session-title lifecycle"); diff --git a/scripts/smoke-tui-widths.ts b/scripts/smoke-tui-widths.ts deleted file mode 100644 index ee25e241..00000000 --- a/scripts/smoke-tui-widths.ts +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bun - -import { terminalOutputText as plainText } from "./terminal-output.ts"; -import { runtimeTestEnv } from "./runtime-test-env.ts"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const root = join(import.meta.dir, ".."); -const fixture = join(root, "test", "fixtures", "tui-features.ts"); - - -async function verifyWidth( - width: number, - colorFgBg: string, - noColor = false, - theme?: string -): Promise { - const temporaryHome = await mkdtemp(join(tmpdir(), `zcode-tui-width-${width}-`)); - const decoder = new TextDecoder(); - let output = ""; - const terminal = new Bun.Terminal({ - cols: width, - rows: 40, - name: "xterm-256color", - data(_terminal, data) { - output += decoder.decode(data, { stream: true }); - } - }); - const child = Bun.spawn([process.execPath, fixture], { - cwd: root, - env: { - ...runtimeTestEnv(temporaryHome, root), - CI: "1", - COLORFGBG: colorFgBg, - NO_COLOR: noColor ? "1" : "", - TERM: "xterm-256color", - ZCODE_TUI_TEST_THEME: theme ?? "auto", - ZCODE_TUI_NOTIFICATION_METHOD: "none" - }, - terminal - }); - - const waitFor = async (label: string, pattern: RegExp, start = 0): Promise => { - const startedAt = Date.now(); - while (Date.now() - startedAt < 10_000) { - if (pattern.test(plainText(output.slice(start)))) return; - if (child.exitCode !== null) break; - await Bun.sleep(20); - } - throw new Error(`Width ${width}: timed out waiting for ${label}.\n${plainText(output).slice(-4_000)}`); - }; - const sendAndWait = async (input: string, label: string, pattern: RegExp): Promise => { - const start = output.length; - terminal.write(input); - await waitFor(label, pattern, start); - }; - - const timeout = setTimeout(() => child.kill("SIGKILL"), 20_000); - let interactionError: unknown; - try { - await waitFor("welcome screen", /ZCode/iu); - await sendAndWait("/status\r", "status dialog", /ZCode Status/iu); - terminal.write("\r"); - await Bun.sleep(75); - await sendAndWait("\x16", "attachment row", /\[Image #1\]/iu); - await sendAndWait("\x1b[A", "attachment selection", /› \[Image #1\][\s\S]*Del/iu); - await sendAndWait("\x1b[B", "attachment return", /Images · \[Image #1\]/iu); - await sendAndWait("/attachments clear\r", "attachment clear", /Pending attachments cleared\./iu); - await sendAndWait("review long plan\r", "plan dialog", /Ready to implement\?/iu); - await sendAndWait("\x0f", "expanded plan", /Ctrl\+O[\s\S]*Esc return/iu); - terminal.write("\x1b[6~"); - await Bun.sleep(25); - terminal.write("\x1b"); - await Bun.sleep(25); - await sendAndWait("\r", "approved plan", /Plan approval fixture complete: allow\./iu); - terminal.write("\x03"); - } catch (error) { - interactionError = error; - child.kill("SIGKILL"); - } - - const code = await child.exited; - clearTimeout(timeout); - if (!terminal.closed) terminal.close(); - output += decoder.decode(); - await rm(temporaryHome, { recursive: true, force: true }); - if (interactionError) throw interactionError; - const plain = plainText(output); - if (code !== 0 || /exceeds terminal width/iu.test(plain)) { - throw new Error(`Width ${width}: TUI exited with ${code}.\n${plain.slice(-4_000)}`); - } - if (!noColor && theme === "light") { - if (!output.includes("\x1b[38;5;25m")) { - throw new Error(`Width ${width}: explicit light theme was not applied.`); - } - if (!output.includes("\x1b[1;38;5;236m")) { - throw new Error(`Width ${width}: strong text still relies on the terminal foreground.`); - } - } - if (!noColor && theme === "dark" && !output.includes("\x1b[38;5;75m")) { - throw new Error(`Width ${width}: explicit dark theme was not applied.`); - } -} - -await verifyWidth(40, "0;15", false, "dark"); -await verifyWidth(60, "15;0", false, "light"); -await verifyWidth(80, "invalid", true, "unsupported"); -await verifyWidth(100, "0;15"); - -console.log("TUI width smoke passed at 40, 60, 80 and 100 columns."); diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..480e46f3 --- /dev/null +++ b/test/README.md @@ -0,0 +1,110 @@ +# Required test contracts + +This suite is rebuilt around failures that can lose user data, cross an account +or permission boundary, block a command, or ship an unusable package. It targets +the runtime in `zcode-runtime.lock.json`, not historical runtime implementations. +Passing this suite is a release baseline, not proof that every feature is covered. + +## Run + +```sh +bun run sync:locked +bun run typecheck +bun run test +bun run check +bun run check:tui +bun run release:pack +``` + +`test` and `test:all` run unit, TUI, native runtime and real Node storage tests +sequentially. `test:unit` is the fast offline subset. Use the package commands: +bare `bun test` incorrectly discovers the Node-only `.test.cjs` files with Bun. +Runtime and storage tests require a built, synchronized runtime; no user API key +or model service is required. HTTP endpoints and credentials are local fixtures. + +The rebuilt baseline contains 97 cases in 29 files: + +| Group | Files | Cases | +| --- | ---: | ---: | +| Offline contracts | 11 | 37 | +| Terminal wiring | 5 | 14 | +| Native runtime | 10 | 31 | +| Real Node persistence | 3 | 15 | + +Counts describe this reset, not an assertion that future suites must have a fixed size. + +## Why each file exists + +### Offline contracts: `test:unit` + +| File | Failure this must prevent | +| --- | --- | +| `config-bootstrap.test.ts` | Startup overwrites user settings, creates public settings, races another startup, or ignores a host-selected file. | +| `app-server-client.test.ts` | A response goes to the wrong request, protocol errors disappear, or cancellation leaves a process running. | +| `app-server-auth.test.ts` | Local credentials are used in host mode, or an invalid mode silently selects an account. | +| `shared-oauth.test.ts` | Logout/account switching reuses stale identity, or a login publishes the wrong token kind. | +| `prompt-preflight.test.ts` | A keyless prompt starts work, an explicit model is ignored, or a malformed config is silently replaced. | +| `input-queue.test.ts` | Accepted input is edited, steers are lost/duplicated, or delayed events attach input to the wrong turn. | +| `permission-request-queue.test.ts` | Permissions overlap, survive cancellation, or stop processing after one failure. | +| `attachments.test.ts` | Malformed image data reaches the runtime. | +| `rewind.test.ts` | Rewind selects the wrong message or admits an invalid workspace operation. | +| `release-contract.test.ts` | Package and runtime versions diverge, installer verification is weakened, or a lagging manifest downgrades the lock. | +| `shell-security.test.ts` | The shell formatter's known unbounded-precision denial of service returns. | + +### Real runtime boundaries: `test:runtime` + +| File | Failure this must prevent | +| --- | --- | +| `launcher.test.ts` | Keyless prompts run, missing sessions leak diagnostics, or a local plugin cannot be installed and discovered. | +| `app-server-auth.test.ts` | Real protocol turns use the wrong credential owner, fail to rotate keys, or fall back from missing credentials to host authentication. | +| `settings-file.test.ts` | Runtime writes and trust changes escape a host-selected settings store or change native MCP precedence. | +| `config-migration.test.ts` | Migration loses providers, overwrites conflicting/invalid configuration, or runs twice concurrently. | +| `shared-oauth.test.ts` | Native login stores plaintext/wrong tokens, ignores Desktop account changes, or damages shared login state on an HTTP failure. | +| `network-retry.test.ts` | A reset/unfinished stream silently truncates output, duplicates partial output on retry, or reports success after exhaustion. | +| `session-lifecycle.test.ts` | An untouched session is saved, rename/resume loses data, or failed work becomes unresumable. | +| `session-model-recovery.test.ts` | An invalid saved model sends a request, cannot be repaired and persisted, or falls back from an unavailable explicit model. | +| `guarded-permission-tui.test.ts` | A destructive command executes before single-use approval. | +| `workspace-paths.test.ts` | The actual runtime cannot find nested workspace files. | + +### Real Node persistence: `test:node` + +| File | Failure this must prevent | +| --- | --- | +| `session-lifecycle.test.cjs` | Concurrent first writes create duplicate sessions or failed title writes claim success. | +| `sqlite-session-store.test.cjs` | Migration races corrupt the database, lock exhaustion commits data, killed writes survive rollback, or native timeout policy disappears. | +| `sqlite-recovery.test.cjs` | Retrying duplicates records/counters, enters a caller's transaction, blocks the event loop, loses update order, or persists work after close. | + +Use Node's SQLite driver, never Bun's substitute. Lock holders are separate +processes; databases and homes are temporary. Production-length lock delays and +large process-count stress runs are not part of this baseline. + +### Real terminal wiring: `test:tui` + +| File | Failure this must prevent | +| --- | --- | +| `input-actions.test.ts` | Keyboard queue operations lose drafts or attach an image to a different question. | +| `permission-request-queue.test.ts` | The actual TUI cannot answer concurrent permissions in order. | +| `startup.test.ts` | Input submits before initialization, cancelled startup reopens the UI, or a failure leaves raw mode enabled. | +| `terminal-exit.test.ts` | Regular/fullscreen exit or cancellation leaks input into the parent shell or returns the wrong status. | +| `write-and-diff.test.ts` | A tool's workspace write cannot be inspected through the TUI and real Git diff. | + +`check:tui` adds one real-runtime first-run/login/command/SIGHUP flow. The release +pipeline also synchronizes and verifies required runtime patches, validates the +package tree, then packs and installs the resulting tarball. These checks replace +tests that only searched implementation text for patch strings or YAML commands. +Additional CI jobs reuse that exact tarball on other Node/OS versions; they do +not retest old runtime versions. + +## What is deliberately outside the baseline + +Exact rendered strings, Markdown prefix matrices, theme/font/spacing details, +notification combinations, benchmark thresholds and test-harness self-tests are +not default gates. Manual scenarios and performance tools remain available for +targeted investigation, but are not implied to run in CI. This removes coverage +as well as noise; restore a focused behavioral test when a concrete risk warrants it. + +Add a test only when its observable failure is not already exercised here. +Prefer one success and one meaningful failure boundary over a combinatorial +matrix. A test using fake runtime objects cannot replace a native persistence, +authentication or permission test. Keep cold-start budgets separate from normal +interaction deadlines; do not fix failures by skipping assertions or adding sleeps. diff --git a/test/app-server-client.test.ts b/test/app-server-client.test.ts index a9c7a2b0..10da5952 100644 --- a/test/app-server-client.test.ts +++ b/test/app-server-client.test.ts @@ -19,20 +19,6 @@ function transport(script: string) { } describe("app-server NDJSON client", () => { - test("makes successful request diagnostics available without mixing them into the result", async () => { - const diagnostics: string[] = []; - expect(await requestAppServer({ - method: "session/list", params: {}, onStderr: stderr => diagnostics.push(stderr), - transport: transport(` - process.stdin.resume(); - process.stdin.once("data", () => { - process.stderr.write("fixture diagnostic\\n"); - console.log(JSON.stringify({id:1,result:{sessions:[]}})); - }); - `) - })).toEqual({ sessions: [] }); - expect(diagnostics).toEqual(["fixture diagnostic\n"]); - }); test("returns the matching response envelope", async () => { const script = ` @@ -75,20 +61,6 @@ describe("app-server NDJSON client", () => { } }); - test("preserves app-server process exit codes", async () => { - try { - await requestAppServer({ - method: "plugins/overview", - params: {}, - transport: transport("process.stdin.resume(); process.stdin.once('data', () => process.exit(7));") - }); - throw new Error("Expected request to fail."); - } catch (error) { - expect(error).toBeInstanceOf(AppServerProcessError); - expect(error).toMatchObject({ exitCode: 7 }); - } - }); - test("rejects missing envelopes and honours cancellation", async () => { await expect(requestAppServer({ method: "plugins/list", @@ -106,26 +78,6 @@ describe("app-server NDJSON client", () => { })).rejects.toMatchObject({ name: "AbortError" }); }); - test("keeps stdin open through unrelated messages and a chunked UTF-8 response", async () => { - expect(await requestAppServer({ - method: "session/list", params: {}, transport: transport(` - let responded = false; - process.stdin.resume(); - process.stdin.on("end", () => { if (!responded) process.exit(9); }); - process.stdin.once("data", () => { - console.log(JSON.stringify({method:"startup/storageState",params:{phase:"ready"}})); - console.log(JSON.stringify({id:2,result:{ignored:true}})); - const response = Buffer.from(JSON.stringify({id:1,result:{sessions:[{title:"你好,世界"}]}}) + "\\n"); - const split = response.indexOf(Buffer.from("界")) + 1; - setTimeout(() => { - process.stdout.write(response.subarray(0, split)); - setTimeout(() => {responded=true;process.stdout.write(response.subarray(split));}, 30); - }, 150); - }); - `) - })).toEqual({ sessions: [{ title: "你好,世界" }] }); - }); - test("finishes cancellation when the app-server ignores SIGTERM", async () => { const controller = new AbortController(); const pending = requestAppServer({ @@ -143,21 +95,4 @@ describe("app-server NDJSON client", () => { await expect(pending).rejects.toMatchObject({ name: "AbortError" }); }, 3_000); - test("forwards SIGHUP and returns its conventional cancellation status", async () => { - if (process.platform === "win32") return; - const controller = new AbortController(); - const pending = requestAppServer({ - method: "plugins/list", - params: {}, - signal: controller.signal, - transport: transport(` - process.on("SIGHUP", () => process.exit(0)); - process.stdin.resume(); - setInterval(() => {}, 1000); - `) - }); - setTimeout(() => controller.abort("SIGHUP"), 150); - - await expect(pending).rejects.toMatchObject({ name: "AbortError", exitCode: 129 }); - }, 3_000); }); diff --git a/test/assistant-stream.test.ts b/test/assistant-stream.test.ts deleted file mode 100644 index f47c1e90..00000000 --- a/test/assistant-stream.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Text } from "@earendil-works/pi-tui"; - -import { AssistantStream } from "../packages/zcode-tui/src/assistant-stream.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; -import { Transcript } from "../packages/zcode-tui/src/transcript.ts"; - -function renderedLines(transcript: Transcript): string[] { - return transcript.render(80).map((line) => line.trimEnd()); -} - -describe("TUI assistant stream", () => { - test("starts a new assistant block after a tool boundary", () => { - const transcript = new Transcript(); - const stream = new AssistantStream(createTheme(false), (component) => transcript.addBlock(component)); - - stream.beginTurn(); - stream.append("I will inspect the repository first."); - stream.breakSegment(); - transcript.addBlock(new Text("● Read README.md", 1, 0)); - stream.append("The project is ready."); - - const lines = renderedLines(transcript); - const commentary = lines.findIndex((line) => line.includes("I will inspect")); - const tool = lines.findIndex((line) => line.includes("● Read")); - const final = lines.findIndex((line) => line.includes("The project is ready")); - expect(commentary).toBeGreaterThanOrEqual(0); - expect(tool).toBeGreaterThan(commentary); - expect(final).toBeGreaterThan(tool); - }); - - test("does not duplicate an already streamed final response", () => { - const transcript = new Transcript(); - const stream = new AssistantStream(createTheme(false), (component) => transcript.addBlock(component)); - - stream.beginTurn(); - stream.append("Checking files. "); - stream.breakSegment(); - transcript.addBlock(new Text("✓ Read README.md", 1, 0)); - stream.append("Final result."); - expect(stream.reconcile("Final result.")).toBe("Final result."); - - expect(renderedLines(transcript).join("\n").match(/Final result\./g)).toHaveLength(1); - }); - - test("places a non-streamed authoritative response after tools", () => { - const transcript = new Transcript(); - const stream = new AssistantStream(createTheme(false), (component) => transcript.addBlock(component)); - - stream.beginTurn(); - stream.append("Checking files."); - stream.breakSegment(); - transcript.addBlock(new Text("✓ Bash bun test", 1, 0)); - stream.reconcile("Authoritative final response."); - - const lines = renderedLines(transcript); - const tool = lines.findIndex((line) => line.includes("✓ Bash")); - const final = lines.findIndex((line) => line.includes("Authoritative final response")); - expect(final).toBeGreaterThan(tool); - }); - - test("reconciles identified protocol parts without duplicate blocks", () => { - const transcript = new Transcript(); - const stream = new AssistantStream( - createTheme(false), - (component, options) => transcript.addBlock(component, options) - ); - - stream.beginTurn(); - stream.append("Hel", "part_1", "message_1"); - stream.upsert("Hello", "part_1", "message_1"); - stream.append("!", "part_1", "message_1"); - - expect(transcript.blockCount).toBe(1); - expect(renderedLines(transcript).join("\n").match(/Hello!/g)).toHaveLength(1); - stream.removePart("part_1"); - }); - - test("releases identified part references at each turn boundary", () => { - const transcript = new Transcript(); - const stream = new AssistantStream( - createTheme(false), - (component, options) => transcript.addBlock(component, options) - ); - const internal = stream as unknown as { - partSegments: Map; - }; - - for (let turn = 0; turn < 1_000; turn += 1) { - stream.beginTurn(); - stream.append(`response ${turn}`, `part_${turn}`, `message_${turn}`); - expect(internal.partSegments.size).toBe(1); - } - - expect(internal.partSegments.size).toBe(1); - expect(Array.from(internal.partSegments.values(), (segment) => segment.text)) - .toEqual(["response 999"]); - expect(transcript.render(80).join("\n")).toContain("response 999"); - }); -}); diff --git a/test/attachment-bar.test.ts b/test/attachment-bar.test.ts deleted file mode 100644 index 66f00656..00000000 --- a/test/attachment-bar.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { AttachmentBar } from "../packages/zcode-tui/src/attachment-bar.ts"; -import type { PromptImageAttachment } from "../packages/zcode-tui/src/attachments.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -const images = (count: number): PromptImageAttachment[] => Array.from( - { length: count }, - (_, index) => ({ - type: "image", - content: `data:image/png;base64,${index}`, - mediaType: "image/png", - sizeBytes: 1_024 - }) -); - -describe("TUI attachment bar", () => { - test("renders complete image tokens and a discoverable management hint", () => { - const bar = new AttachmentBar(createTheme(false), { - onExit: () => {}, - onRemove: () => {}, - onRender: () => {} - }); - bar.setAttachments(images(2)); - - const lines = bar.render(80); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain("Images · [Image #1] · [Image #2]"); - expect(lines[0]).toContain("↑ manage"); - }); - - test("selects, wraps, removes, and relabels attachments without touching the editor", () => { - let attachments = images(2); - const removed: number[] = []; - let renderRequests = 0; - let exits = 0; - let bar!: AttachmentBar; - bar = new AttachmentBar(createTheme(false), { - onExit: () => { - exits += 1; - bar.deactivate(); - }, - onRemove: (index) => { - removed.push(index); - attachments.splice(index, 1); - bar.setAttachments(attachments); - }, - onRender: () => { - renderRequests += 1; - } - }); - bar.setAttachments(attachments); - - expect(bar.activate()).toBeTrue(); - expect(bar.getSelectedIndex()).toBe(1); - expect(bar.render(80).join("\n")).toContain("› [Image #2]"); - - bar.handleInput("\x1b[D"); - expect(bar.getSelectedIndex()).toBe(0); - bar.handleInput("\x1b[C"); - expect(bar.getSelectedIndex()).toBe(1); - bar.handleInput("\x1b[C"); - expect(bar.getSelectedIndex()).toBe(0); - bar.handleInput("\x1b[D"); - expect(bar.getSelectedIndex()).toBe(1); - expect(renderRequests).toBe(4); - - bar.handleInput("\x1b[3~"); - expect(removed).toEqual([1]); - expect(bar.getSelectedIndex()).toBe(0); - expect(bar.render(80).join("\n")).toContain("› [Image #1]"); - expect(bar.render(80).join("\n")).not.toContain("Image #2"); - - bar.handleInput("\x1b[B"); - expect(exits).toBe(1); - expect(bar.isActive()).toBeFalse(); - - bar.activate(); - bar.handleInput("\x7f"); - expect(removed).toEqual([1, 0]); - expect(bar.render(80)).toEqual([]); - expect(bar.isActive()).toBeFalse(); - }); - - test("uses a complete compact selection at narrow terminal widths", () => { - const bar = new AttachmentBar(createTheme(false), { - onExit: () => {}, - onRemove: () => {}, - onRender: () => {} - }); - bar.setAttachments(images(3)); - bar.activate(); - - const lines = bar.render(24); - expect(lines[0]).toContain("› Image 3/3"); - expect(lines).toHaveLength(2); - expect(lines.every((line) => visibleWidth(line) <= 24)).toBeTrue(); - }); - - test("returns to the editor through every documented exit key", () => { - let exits = 0; - let bar!: AttachmentBar; - bar = new AttachmentBar(createTheme(false), { - onExit: () => { - exits += 1; - bar.deactivate(); - }, - onRemove: () => {}, - onRender: () => {} - }); - bar.setAttachments(images(1)); - - for (const key of ["\x1b[B", "\x1b", "\x03", "\r"]) { - bar.activate(); - bar.handleInput(key); - expect(bar.isActive()).toBeFalse(); - } - expect(exits).toBe(4); - }); - - test("marks selection structurally when colors are disabled", () => { - const bar = new AttachmentBar(createTheme(false), { - onExit: () => {}, - onRemove: () => {}, - onRender: () => {} - }); - bar.setAttachments(images(1)); - bar.activate(); - - expect(bar.render(80)[0]).toContain("› [Image #1]"); - }); -}); diff --git a/test/background-task-events.test.ts b/test/background-task-events.test.ts deleted file mode 100644 index 8307dc0d..00000000 --- a/test/background-task-events.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { BackgroundTaskEventStore } from "../packages/zcode-tui/src/background-task-events.ts"; -import { normalizeEvent } from "../packages/zcode-tui/src/events.ts"; - -function event(value: unknown) { - const normalized = normalizeEvent(value); - if (!normalized) throw new Error("Expected a normalized event."); - return normalized; -} - -describe("background task event store", () => { - test("exposes the settled handoff turn's response text exactly once", () => { - const store = new BackgroundTaskEventStore(); - store.handle(event({ - id: "text-task-completed", - type: "background_task_completed", - payload: { taskId: "bg-text", status: "completed" } - })); - store.handle(event({ - id: "text-handoff-started", - type: "turn_started", - turnId: "handoff-text-turn", - payload: { inputSource: "background_task" } - })); - store.handle(event({ - id: "text-handoff-delta-1", - type: "model.streaming", - turnId: "handoff-text-turn", - payload: { kind: "text_delta", delta: "Result processed." } - })); - store.handle(event({ - id: "text-handoff-delta-2", - type: "model.streaming", - turnId: "handoff-text-turn", - payload: { kind: "text_delta", delta: " All clear." } - })); - const settled = store.handle(event({ - id: "text-handoff-completed", - type: "turn_complete", - turnId: "handoff-text-turn", - payload: {} - })); - - expect(settled.handoffSettled).toBe(true); - expect(settled.settledTurnId).toBe("handoff-text-turn"); - expect(store.takeHandoffText("handoff-text-turn")).toBe("Result processed. All clear."); - expect(store.takeHandoffText("handoff-text-turn")).toBeUndefined(); - }); - - test("drops pending handoff response text when the user preempts the handoff", () => { - const store = new BackgroundTaskEventStore(); - store.handle(event({ - id: "preempt-handoff-started", - type: "turn_started", - turnId: "preempted-handoff-turn", - payload: { inputSource: "background_task" } - })); - store.handle(event({ - id: "preempt-handoff-delta", - type: "model.streaming", - turnId: "preempted-handoff-turn", - payload: { kind: "text_delta", delta: "Partial response." } - })); - - store.settleActiveHandoffs(); - - expect(store.takeHandoffText("preempted-handoff-turn")).toBeUndefined(); - }); - - test("scopes subagent execution without starting a foreground result handoff", () => { - const store = new BackgroundTaskEventStore(); - const started = event({ - type: "turn_started", - turnId: "child-turn", - payload: { inputSource: "subagent" } - }); - store.handle(started); - - expect(store.isTaskScoped(started)).toBe(true); - expect(store.hasActiveHandoffs()).toBe(false); - expect(store.isTaskScoped(event({ - type: "model.streaming", - turnId: "child-turn", - payload: { kind: "text_delta", delta: "Child final answer." } - }))).toBe(true); - expect(store.isTaskScoped(event({ - type: "tool_call_started", - turnId: "child-turn", - payload: { toolCallId: "child-bash", toolName: "Bash" } - }))).toBe(true); - expect(store.isTaskScoped(event({ - type: "model.streaming", - payload: { inputSource: "subagent", kind: "text_delta", delta: "Unscoped child text." } - }))).toBe(true); - expect(store.isTaskScoped(event({ - type: "model.streaming", - turnId: "parent-turn", - payload: { kind: "text_delta", delta: "Parent final answer." } - }))).toBe(false); - expect(store.handle(event({ - type: "turn_complete", turnId: "child-turn", payload: {} - })).handoffSettled).toBe(false); - }); - - test("routes autonomous output to its task without treating handoff completion as task completion", () => { - const store = new BackgroundTaskEventStore(); - const completed = store.handle(event({ - id: "task-completed", - type: "background_task_completed", - turnId: "parent-turn", - payload: { taskId: "bg-1", status: "completed", taskKind: "bash" } - })); - expect(completed.notices).toEqual([expect.objectContaining({ - title: "Background task completed", - summary: "bg-1 · /tasks" - })]); - - store.handle(event({ - id: "handoff-started", - type: "turn_started", - turnId: "handoff-turn", - payload: { - inputSource: "background_task", - originMeta: { workId: "bg-1" } - } - })); - expect(store.hasActiveHandoffs()).toBe(true); - store.handle(event({ - id: "handoff-text", - type: "model.streaming", - turnId: "handoff-turn", - payload: { kind: "text_delta", delta: "Task output was reviewed." } - })); - const settled = store.handle(event({ - id: "handoff-completed", - type: "turn_complete", - turnId: "handoff-turn", - payload: {} - })); - - expect(settled.handoffSettled).toBe(true); - expect(settled.notices).toEqual([]); - expect(store.hasActiveHandoffs()).toBe(false); - expect(store.entries("bg-1").map((entry) => entry.text)).toEqual([ - "Task completed.", - "Task output was reviewed." - ]); - }); - - test("keeps result-processing failure separate from the underlying task status", () => { - const store = new BackgroundTaskEventStore(); - store.handle(event({ - id: "task-completed", - type: "background_task_completed", - payload: { taskId: "bg-2", status: "completed" } - })); - store.handle(event({ - id: "handoff-started", - type: "turn_started", - turnId: "handoff-failed", - payload: { inputSource: "background_task" } - })); - const failed = store.handle(event({ - id: "handoff-error", - type: "turn_error", - turnId: "handoff-failed", - payload: { error: { message: "Provider unavailable during handoff." } } - })); - - expect(failed.notices).toEqual([expect.objectContaining({ - title: "Background result processing failed", - summary: "bg-2 · /tasks" - })]); - expect(store.entries("bg-2").at(-1)).toMatchObject({ - kind: "error", - text: "Provider unavailable during handoff." - }); - expect(store.isTaskScoped(event({ - id: "late-handoff-event", - type: "model.streaming", - turnId: "handoff-failed", - payload: { kind: "reasoning_delta", delta: "late autonomous output" } - }))).toBe(true); - }); - - test("settles a stuck autonomous handoff when the user preempts it", () => { - const store = new BackgroundTaskEventStore(); - store.handle(event({ - id: "stuck-handoff-started", - type: "turn_started", - turnId: "stuck-handoff", - payload: { inputSource: "background_task" } - })); - - expect(store.hasActiveHandoffs()).toBe(true); - expect(store.settleActiveHandoffs()).toBe(1); - expect(store.hasActiveHandoffs()).toBe(false); - expect(store.settleActiveHandoffs()).toBe(0); - }); - - test("records agent replies once and allows a task-scoped user follow-up", () => { - const store = new BackgroundTaskEventStore(); - const reply = event({ - id: "agent-reply", - type: "subagent_message", - payload: { - agentId: "agent-1", - agentType: "reviewer", - message: "I found the failing test." - } - }); - store.handle(reply); - store.handle(reply); - store.recordUserMessage("agent-1", "Fix it and rerun the focused test."); - - expect(store.entries("agent-1").map(({ kind, text }) => ({ kind, text }))).toEqual([ - { kind: "assistant", text: "I found the failing test." }, - { kind: "user", text: "Fix it and rerun the focused test." } - ]); - }); - - test("deduplicates paired agent terminal events without dropping the error detail", () => { - const store = new BackgroundTaskEventStore(); - const task = store.handle(event({ - id: "agent-task-failed", - type: "background_task_completed", - payload: { taskId: "agent-2", toolName: "Agent", status: "failed" } - })); - const lifecycle = store.handle(event({ - id: "agent-stopped", - type: "subagent_stopped", - payload: { - agentId: "agent-2", - status: "failed", - error: "The provider connection closed." - } - })); - - expect(task.notices).toHaveLength(1); - expect(lifecycle.notices).toEqual([]); - expect(store.entries("agent-2").at(-1)).toMatchObject({ - kind: "error", - text: "The provider connection closed." - }); - }); - - test("treats the runtime killed status as a terminal task update", () => { - const store = new BackgroundTaskEventStore(); - const update = store.handle(event({ - id: "agent-killed-update", - type: "background_task_updated", - payload: { taskId: "agent-killed", taskKind: "local_agent", status: "killed" } - })); - - expect(update.changed).toBeTrue(); - expect(update.notices).toEqual([]); - }); - - test("scopes Agent tool trees and their descendants to the task center", () => { - const store = new BackgroundTaskEventStore(); - const agent = event({ - id: "agent-part", - type: "part.started", - payload: { - part: { - type: "tool", - partId: "part-agent", - messageId: "message-coordinator", - callId: "call-agent", - tool: "Agent", - state: { - status: "running", - input: { description: "Research rendering", run_in_background: true } - } - } - } - }); - store.handle(agent); - - const child = event({ - id: "child-part", - type: "part.started", - payload: { - part: { - type: "tool", - partId: "part-fetch", - messageId: "message-coordinator", - callId: "call-fetch", - tool: "Fetch", - state: { - status: "running", - input: { url: "https://example.com" }, - metadata: { parentToolCallId: "call-agent" } - } - } - } - }); - store.handle(child); - - const unrelated = event({ - id: "foreground-fetch", - type: "tool_call_started", - payload: { toolCallId: "call-foreground", toolName: "Fetch" } - }); - store.handle(unrelated); - - expect(store.isBackgroundToolScoped(agent)).toBe(true); - expect(store.isTaskScoped(child)).toBe(true); - expect(store.isTaskScoped(unrelated)).toBe(false); - }); - - test("treats the Task compatibility alias as an Agent dispatch", () => { - const store = new BackgroundTaskEventStore(); - const task = event({ - id: "task-alias-part", - type: "part.started", - payload: { - part: { - type: "tool", - partId: "part-task-alias", - callId: "call-task-alias", - tool: "Task", - state: { - status: "running", - input: { description: "Review aliases", run_in_background: true } - } - } - } - }); - store.handle(task); - expect(store.isBackgroundToolScoped(task)).toBe(true); - }); - - test("keeps synchronous agents in the foreground until the runtime backgrounds them", () => { - const store = new BackgroundTaskEventStore(); - const started = event({ - id: "foreground-agent-started", - type: "part.started", - payload: { - part: { - type: "tool", - partId: "part-agent-auto", - messageId: "message-auto", - callId: "call-agent-auto", - tool: "Agent", - state: { status: "running", input: { description: "Research runtime" } } - } - } - }); - store.handle(started); - expect(store.isTaskScoped(started)).toBe(false); - - const child = event({ - id: "foreground-agent-child", - type: "part.started", - payload: { - part: { - type: "tool", - partId: "part-agent-child", - callId: "call-agent-child", - tool: "Fetch", - state: { status: "running", metadata: { parentToolCallId: "call-agent-auto" } } - } - } - }); - store.handle(child); - expect(store.isTaskScoped(child)).toBe(false); - - const backgrounded = event({ - id: "foreground-agent-backgrounded", - type: "part.upserted", - payload: { - part: { - type: "tool", - partId: "part-agent-auto", - messageId: "message-auto", - callId: "call-agent-auto", - tool: "Agent", - state: { - status: "completed", - input: { description: "Research runtime" }, - output: { - status: "async_launched", - isAsync: true, - backgroundTaskId: "agent-auto" - } - } - } - } - }); - store.handle(backgrounded); - - expect(store.isTaskScoped(backgrounded)).toBe(true); - expect(store.isTaskScoped(child)).toBe(true); - }); -}); diff --git a/test/background-task-notifications.test.ts b/test/background-task-notifications.test.ts deleted file mode 100644 index 0a662dd4..00000000 --- a/test/background-task-notifications.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { deliveredBackgroundTaskIds } from "../packages/zcode-tui/src/background-task-notifications.ts"; - -describe("background task notifications", () => { - test("collects delivered task ids from user notification messages only", () => { - const delivered = deliveredBackgroundTaskIds([ - { - role: "user", - parts: [{ - type: "text", - text: "\nbg-1\ncompleted\n" - }] - }, - { role: "assistant", parts: [{ type: "text", text: "bg-9" }] }, - { role: "user", parts: [{ type: "text", text: "a plain user message" }] } - ]); - - expect([...delivered]).toEqual(["bg-1"]); - }); -}); diff --git a/test/background-task-output.test.ts b/test/background-task-output.test.ts deleted file mode 100644 index cc1259bb..00000000 --- a/test/background-task-output.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { readBackgroundTaskOutput } from "../packages/zcode-tui/src/background-task-output.ts"; - -const directories: string[] = []; - -afterEach(async () => { - await Promise.all(directories.splice(0).map(async (directory) => { - await rm(directory, { recursive: true, force: true }); - })); -}); - -describe("background task output", () => { - test("reads a saved task result", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-task-output-")); - directories.push(directory); - const path = join(directory, "output.txt"); - await writeFile(path, " completed result\n"); - - expect(readBackgroundTaskOutput(path)).toEqual({ - text: "completed result", - truncated: false - }); - }); - - test("bounds large results and ignores unavailable files", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-task-output-")); - directories.push(directory); - const path = join(directory, "output.txt"); - await writeFile(path, "older output\nlatest result"); - - expect(readBackgroundTaskOutput(path, 13)).toEqual({ - text: "latest result", - truncated: true - }); - expect(readBackgroundTaskOutput(join(directory, "missing.txt"))).toBeUndefined(); - }); -}); diff --git a/test/bigmodel-oauth.test.ts b/test/bigmodel-oauth.test.ts deleted file mode 100644 index 62138a55..00000000 --- a/test/bigmodel-oauth.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { join } from "node:path"; -import { exchangeBigmodelOAuthCode } from "../src/bigmodel-oauth.ts"; -import { hasRuntimeBigmodelOAuth, patchRuntimeBigmodelOAuth } from "../scripts/runtime-bigmodel-oauth-patches.ts"; - -const source = ` -function label(fn){return fn} -const keys={bigmodelAccessToken:"access",zcodeJwtToken:"jwt"}; -function client(options){return {buildAuthorizeUrl:({redirectUri,state})=>redirectUri+"?state="+state,exchangeCode:()=>{throw new Error("BigModel OAuth appSecret is required.")}}} -async function login(e={}){let http=e.httpClient,state=e.state,callback=e.callbackServer,oauth=client({httpClient:http}),url=oauth.buildAuthorizeUrl({redirectUri:callback.callbackUrl,state:state});try{let result=await callback.waitForCallback(),tokens=await oauth.exchangeCode({code:result.code});await e.credentialStore.saveMany({[keys.bigmodelAccessToken]:tokens.accessToken,other:"preserved"});return tokens}finally{await callback.close()}} -label(client,"createBigmodelOAuthClient");label(login,"loginBigmodelCodingPlan"); -`; -const payload = { code: 0, data: { token: "fixture-jwt", bigmodel: { access_token: "fixture-access", refresh_token: "fixture-refresh" } } }; -const input = { code: "fixture-code", state: "fixture-state", redirectUri: "http://127.0.0.1:12345/callback" }; - -function httpResponse(body: unknown, status = 200) { - return { status, body: new TextEncoder().encode(JSON.stringify(body)) }; -} - -describe("BigModel Desktop token exchange", () => { - test("sends the authorization context without an app secret and returns both token types", async () => { - const controller = new AbortController(), trace = {}; - const result = await exchangeBigmodelOAuthCode({ ...input, abortSignal: controller.signal, trace, - httpClient: { async request(request, signal) { - expect(request).toMatchObject({ url: "https://zcode.z.ai/api/v1/oauth/token", method: "POST", - headers: { "Content-Type": "application/json" }, maxResponseBytes: 65_536, trace }); - expect(signal).toBe(controller.signal); - expect(JSON.parse(new TextDecoder().decode(request.body))).toEqual({ - provider: "bigmodel", code: input.code, state: input.state, redirect_uri: input.redirectUri - }); - return httpResponse(payload); - } } - }); - expect(result).toEqual({ accessToken: "fixture-access", refreshToken: "fixture-refresh", zcodeJwtToken: "fixture-jwt" }); - }); - - test.each([ - { bigmodel: { accessToken: " fixture-access ", refreshToken: " fixture-refresh " } }, - { access_token: "fixture-access" }, - { accessToken: "fixture-access" } - ])("supports the token field variants accepted by Desktop (%j)", async data => { - const result = await exchangeBigmodelOAuthCode({ ...input, - httpClient: { request: async () => httpResponse({ data: { token: " fixture-jwt ", ...data } }) } - }); - expect(result).toMatchObject({ accessToken: "fixture-access", zcodeJwtToken: "fixture-jwt" }); - }); - - test.each([ - { body: { msg: "Expired authorization code" }, status: 401, error: "HTTP 401: Expired authorization code" }, - { body: { ...payload, code: 2007, msg: "Rejected" }, status: 200, error: "token exchange failed: Rejected" }, - { body: { data: { bigmodel: { access_token: "fixture-access" } } }, status: 200, error: "missing data.token" }, - { body: { data: { token: "fixture-jwt" } }, status: 200, error: "missing data.bigmodel.access_token" }, - { body: { data: { token: " ", bigmodel: { access_token: "fixture-access" } } }, status: 200, error: "missing data.token" } - ])("rejects failed or incomplete exchanges (%j)", async ({ body, status, error }) => { - await expect(exchangeBigmodelOAuthCode({ ...input, - httpClient: { request: async () => httpResponse(body, status) } - })).rejects.toThrow(error); - }); - - test("reports a non-JSON error response without echoing its body", async () => { - await expect(exchangeBigmodelOAuthCode({ ...input, - httpClient: { request: async () => ({ status: 502, body: new TextEncoder().encode("gateway failure") }) } - })).rejects.toThrow("HTTP 502: token response is not valid JSON"); - }); - - test("validates context and cancellation before making requests", async () => { - let requests = 0; - const httpClient = { request: async () => { requests++; return httpResponse(payload); } }; - for (const key of ["code", "state", "redirectUri"]) { - await expect(exchangeBigmodelOAuthCode({ ...input, [key]: " ", httpClient })).rejects.toThrow("requires an authorization code"); - } - const controller = new AbortController(); - controller.abort(new Error("cancelled")); - await expect(exchangeBigmodelOAuthCode({ ...input, httpClient, abortSignal: controller.signal })).rejects.toThrow("cancelled"); - expect(requests).toBe(0); - }); -}); - -describe("BigModel runtime patch", () => { - test("accepts the native polling flow only when it delegates BigModel login and persists both tokens", () => { - const native = 'function label(fn){return fn}function poll(options){return options}async function shared(e={}){let tokens=await poll({});await e.credentialStore.saveMany({[keys.zcodeJwtToken]:tokens.token,[keys.bigmodelAccessToken]:tokens.accessToken,other:"preserved"});return persist({authSource:"oauth"})}async function login(e={}){return{...await shared({...e,providerId:"bigmodel"}),providerId:"bigmodel"}}label(poll,"pollUntilReady");label(shared,"loginZCodeCli");label(login,"loginBigmodelCodingPlan");'; - expect(hasRuntimeBigmodelOAuth(native)).toBe(true); - expect(patchRuntimeBigmodelOAuth(native)).toBe(native); - for (const broken of [native + native, native.replace("zcodeJwtToken", "otherToken"), native.replace('authSource:"oauth"', 'authSource:"api-key"')]) { - expect(hasRuntimeBigmodelOAuth(broken)).toBe(false); - expect(() => patchRuntimeBigmodelOAuth(broken)).toThrow("incompatible with BigModel OAuth"); - } - }); - - test("replaces the failing exchange and persists the ZCode JWT alongside the provider token", async () => { - const login = new Function("require", "__dirname", `${patchRuntimeBigmodelOAuth(source)};return login;`)( - (id: string) => id === "node:path" ? { join } : { exchangeBigmodelOAuthCode }, "/fixture" - ); - let closed = false, saved: unknown; - const result = await login({ state: input.state, httpClient: { request: async () => httpResponse(payload) }, - callbackServer: { callbackUrl: input.redirectUri, waitForCallback: async () => ({ code: input.code }), close: async () => { closed = true; } }, - credentialStore: { saveMany: async (value: unknown) => { saved = value; } } - }); - expect(result.accessToken).toBe("fixture-access"); - expect(saved).toEqual({ access: "fixture-access", jwt: "fixture-jwt", other: "preserved" }); - expect(closed).toBe(true); - }); - - test("patch is idempotent and rejects missing, ambiguous or partial anchors", () => { - const patched = patchRuntimeBigmodelOAuth(source); - expect(hasRuntimeBigmodelOAuth(source)).toBe(false); - expect(hasRuntimeBigmodelOAuth(patched)).toBe(true); - expect(patchRuntimeBigmodelOAuth(patched)).toBe(patched); - for (const broken of ["incompatible runtime", source + source, - source.replace("bigmodelAccessToken]:", "otherToken]:"), - patched.replace("[keys.zcodeJwtToken]:tokens.zcodeJwtToken,", "") - ]) { - expect(hasRuntimeBigmodelOAuth(broken)).toBe(false); - expect(() => patchRuntimeBigmodelOAuth(broken)).toThrow("incompatible with BigModel OAuth"); - } - }); -}); diff --git a/test/bounded-tool-text.test.ts b/test/bounded-tool-text.test.ts deleted file mode 100644 index 737e2e53..00000000 --- a/test/bounded-tool-text.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - BoundedToolText, - MAX_ACTIVE_TOOL_TEXT_CHARACTERS -} from "../packages/zcode-tui/src/bounded-tool-text.ts"; - -describe("active tool text buffer", () => { - test("keeps text below the limit byte-for-byte", () => { - const buffer = new BoundedToolText(); - buffer.append("first"); - buffer.append(" second"); - - expect(buffer.value()).toBe("first second"); - expect(buffer.totalCharacters).toBe(12); - expect(buffer.omittedCharacters).toBe(0); - expect(buffer.isTruncated()).toBeFalse(); - }); - - test("retains bounded head and tail across many deltas", () => { - const buffer = new BoundedToolText(); - for (let index = 0; index < 100_000; index += 1) buffer.append("0123456789"); - const retained = buffer.value(); - - expect(buffer.totalCharacters).toBe(1_000_000); - expect(buffer.retainedCharacters).toBeLessThan(MAX_ACTIVE_TOOL_TEXT_CHARACTERS); - expect(retained.length).toBeLessThanOrEqual(MAX_ACTIVE_TOOL_TEXT_CHARACTERS); - expect(retained.startsWith("0123456789")).toBeTrue(); - expect(retained.endsWith("0123456789")).toBeTrue(); - expect(retained).toContain(`${buffer.omittedCharacters} characters omitted`); - expect(buffer.isTruncated()).toBeTrue(); - }); - - test("does not split surrogate pairs while rotating the tail", () => { - const buffer = new BoundedToolText("start 👨‍👩‍👧‍👦", 80); - buffer.append(" x".repeat(100)); - buffer.append(" finish ✅"); - const retained = buffer.value(); - - expect(retained).not.toMatch(/^[\uDC00-\uDFFF]/u); - expect(retained).not.toMatch(/[\uD800-\uDBFF]$/u); - expect(retained).toContain("finish ✅"); - }); - - test("replaces and clears previous stream state", () => { - const buffer = new BoundedToolText("x".repeat(100), 40); - expect(buffer.isTruncated()).toBeTrue(); - buffer.replace("authoritative"); - expect(buffer.value()).toBe("authoritative"); - buffer.clear(); - expect(buffer.value()).toBe(""); - expect(buffer.totalCharacters).toBe(0); - }); -}); diff --git a/test/browser-open.test.ts b/test/browser-open.test.ts deleted file mode 100644 index 4634f9e7..00000000 --- a/test/browser-open.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Buffer } from "node:buffer"; -import { spawnSync } from "node:child_process"; -import { join } from "node:path"; -import { browserOpenCommand } from "../src/browser-open.ts"; -import { hasRuntimeBrowserUrlOpening, patchRuntimeBrowserUrlOpening } from "../scripts/runtime-browser-patches.ts"; - -const source = `function label(fn){return fn} -function browser(platform,url){return platform==="darwin"?{executable:"open",args:[url]}:platform==="win32"?{executable:"cmd.exe",args:["/c","start","",url]}:{executable:"xdg-open",args:[url]}} -label(browser,"browserOpenCommand"); -async function openUrl(url,options={}){let platform=options.platform??process.platform,command=browser(platform,url),spawnProcess=options.spawnProcess??require("node:child_process").spawn;let child=spawnProcess(command.executable,command.args,{detached:!0,stdio:"ignore",windowsHide:!0});return child} -label(openUrl,"openUrlInBrowser");`; - -function loadBrowserRuntime() { - return new Function("require", "__dirname", `${patchRuntimeBrowserUrlOpening(source)};return {browser,openUrl};`)( - (id: string) => id === "node:path" ? { join } : { browserOpenCommand }, "/fixture" - ); -} - -const urls = [ - "https://bigmodel.cn/login?appId=zcode&redirect=http%3A%2F%2F127.0.0.1%3A54321%2Fcallback&state=fixture-state", - "https://chat.z.ai/api/oauth/authorize?redirect_uri=http%3A%2F%2F127.0.0.1%3A54321%2Fcallback&response_type=code&client_id=fixture&state=fixture-state", - "https://example.com/?value=%PATH%&state=中文 space 'quote' \"double\" $() `backtick` | < > ^ !", - "https://example.com/?value='; throw 'URL executed as code'; #", - "https://example.com/?value=‘’; throw ‘URL executed as code’; #" -]; - -function decodeLiteral(args: string[]): string { - expect(args.slice(0, 3)).toEqual(["-NoProfile", "-NonInteractive", "-EncodedCommand"]); - expect(args).toHaveLength(4); - expect(args[3]).toMatch(/^[A-Za-z0-9+/]+=*$/u); - const script = Buffer.from(args[3]!, "base64").toString("utf16le"); - const literal = /^Start-Process -FilePath \(\[Text.Encoding\]::UTF8.GetString\(\[Convert\]::FromBase64String\('([A-Za-z0-9+/]*=*)'\)\)\)$/u.exec(script); - expect(literal).not.toBeNull(); - return Buffer.from(literal![1]!, "base64").toString("utf8"); -} - -describe("browser URL command", () => { - test.each(urls)("preserves the full URL as data in the Windows command: %s", url => { - const command = browserOpenCommand("win32", url); - expect(command.executable).toBe("powershell.exe"); - expect(decodeLiteral(command.args)).toBe(url); - }); - - test("preserves direct process arguments on macOS and Linux", () => { - expect(browserOpenCommand("darwin", urls[0]!)).toEqual({ executable: "open", args: [urls[0]] }); - expect(browserOpenCommand("linux", urls[0]!)).toEqual({ executable: "xdg-open", args: [urls[0]] }); - }); - -}); - -describe("runtime browser URL patch", () => { - test("routes the native command builder through the bridge on every platform", () => { - const { browser } = loadBrowserRuntime(); - expect(decodeLiteral(browser("win32", urls[0]).args)).toBe(urls[0]); - expect(browser("darwin", urls[0])).toEqual({ executable: "open", args: [urls[0]] }); - expect(browser("linux", urls[0])).toEqual({ executable: "xdg-open", args: [urls[0]] }); - }); - - test.each(["win32", "darwin", "linux"] as const)("uses the appropriate browser process options on %s", async platform => { - const { openUrl } = loadBrowserRuntime(); - let called = false; - await openUrl(urls[0], { platform, spawnProcess: (_executable: string, _args: string[], options: unknown) => { - called = true; - expect(options).toEqual({ detached: platform !== "win32", stdio: "ignore", windowsHide: true }); - } }); - expect(called).toBe(true); - }); - - test.skipIf(process.platform !== "win32")("Node executes the PowerShell browser action with the runtime spawn options", () => { - // Run under Node, as the packaged CLI does. A detached PowerShell process can - // exit successfully without executing any script; spawnSync alone misses it. - // Replace only Start-Process, preserving real parsing and URL transport. - const script = ` - const assert = require("node:assert/strict"); - const { spawn } = require("node:child_process"); - const commands = ${JSON.stringify(urls.map(url => browserOpenCommand("win32", url)))}; - const urls = ${JSON.stringify(urls)}; - const bridge = { browserOpenCommand: (_platform, url) => commands[urls.indexOf(url)] }; - const { openUrl } = new Function("require", "__dirname", ${JSON.stringify(`${patchRuntimeBrowserUrlOpening(source)};return {openUrl};`)})( - id => id === "node:path" ? require(id) : bridge, "/fixture" - ); - (async () => { - for (const url of urls) { - let completed; - await openUrl(url, { platform: "win32", spawnProcess: (executable, args, options) => { - const action = '[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)\\n' - + 'function Start-Process { param([string]$FilePath) ConvertTo-Json -Compress -InputObject $FilePath }\\n' - + Buffer.from(args[3], "base64").toString("utf16le"); - const child = spawn(executable, [...args.slice(0, 3), Buffer.from(action, "utf16le").toString("base64")], { - ...options, stdio: ["ignore", "pipe", "pipe"] - }); - completed = new Promise((resolve, reject) => { - let stdout = "", stderr = ""; - child.stdout.on("data", chunk => stdout += chunk); - child.stderr.on("data", chunk => stderr += chunk); - child.once("error", reject); - child.once("close", code => { - try { - assert.equal(code, 0, stderr); - assert.equal(JSON.parse(stdout.replace(/^\\uFEFF/u, "").trim()), url); - resolve(); - } catch (error) { reject(error); } - }); - }); - return child; - } }); - await completed; - } - })().catch(error => { console.error(error); process.exitCode = 1; }); - `; - const result = spawnSync(Bun.which("node")!, ["--eval", script], { encoding: "utf8", timeout: 30_000, windowsHide: true }); - expect(result.error).toBeUndefined(); - expect(result.status, result.stderr).toBe(0); - }, 35_000); - - test("is idempotent, tolerates minifier renaming, and rejects changed or ambiguous anchors", () => { - const renamed = source.replaceAll("browser(", "$browser(").replaceAll("label(browser,", "label($browser,") - .replaceAll("options", "$options").replaceAll("platform", "$platform").replace("$options.$platform", "$options.platform").replace("process.$platform", "process.platform"); - for (const original of [source, renamed]) { - const patched = patchRuntimeBrowserUrlOpening(original); - expect(hasRuntimeBrowserUrlOpening(original)).toBe(false); - expect(hasRuntimeBrowserUrlOpening(patched)).toBe(true); - expect(patchRuntimeBrowserUrlOpening(patched)).toBe(patched); - } - for (const broken of ["incompatible runtime", source + source, source.replace('"cmd.exe"', '"other.exe"'), - source.replace("detached:!0", "detached:!1"), source.replace("options.platform??process.platform", "process.platform"), - patchRuntimeBrowserUrlOpening(source).replace(".browserOpenCommand(platform,url)", ".browserOpenCommand(url,platform)")]) { - expect(hasRuntimeBrowserUrlOpening(broken)).toBe(false); - expect(() => patchRuntimeBrowserUrlOpening(broken)).toThrow("incompatible with browser URL opening"); - } - }); - - test("upgrades a runtime that already has the encoded URL command patch", () => { - const patched = patchRuntimeBrowserUrlOpening(source); - const previous = patched.replace('detached:platform!=="win32"', "detached:!0"); - expect(hasRuntimeBrowserUrlOpening(previous)).toBe(false); - expect(patchRuntimeBrowserUrlOpening(previous)).toBe(patched); - }); -}); diff --git a/test/choice-dialog-number-shortcuts.test.ts b/test/choice-dialog-number-shortcuts.test.ts deleted file mode 100644 index b8435e88..00000000 --- a/test/choice-dialog-number-shortcuts.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Container, type Component, type TUI } from "@earendil-works/pi-tui"; - -import { choose } from "../packages/zcode-tui/src/choice-dialog.ts"; -import type { ChoiceItem } from "../packages/zcode-tui/src/choice-dialog.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -const theme = createTheme(false); - -// Inline-mode harness matching choice-dialog.test.ts: real pi-tui Container, -// dialog mounts into `host`, render `root` to assert on actual dialog output. -function makeHarness() { - const root = new Container(); - const host = new Container(); - const focusState: { current: Component | null } = { current: null }; - const ui = { - terminal: { rows: 24 }, - requestRender() {}, - setFocus(component: Component | null) { - focusState.current = component; - } - } as unknown as TUI; - root.addChild(host); - return { root, host, focusState, ui }; -} - -function rendered(root: Container): string { - return root.render(80).join("\n"); -} - -function items(count: number): ChoiceItem[] { - return Array.from({ length: count }, (_, i) => ({ - value: `opt${i + 1}`, - label: `Option ${i + 1}`, - description: `desc ${i + 1}` - })); -} - -describe("choice dialog number shortcuts", () => { - test("digit '1' confirms the first option in one keystroke", async () => { - const { host, focusState, ui } = makeHarness(); - const promise = choose(ui, host, theme, { - title: "T", - prompt: "P", - items: items(4), - numberShortcuts: true - }); - const dialog = focusState.current as Component; - expect(dialog).toBeTruthy(); - dialog.handleInput!("1"); - const result = await promise; - expect(result?.label).toBe("Option 1"); - }); - - test("digit '3' confirms the third option", async () => { - const { host, focusState, ui } = makeHarness(); - const promise = choose(ui, host, theme, { - title: "T", - prompt: "P", - items: items(4), - numberShortcuts: true - }); - (focusState.current as Component).handleInput!("3"); - const result = await promise; - expect(result?.label).toBe("Option 3"); - }); - - test("digits beyond the item count do nothing", async () => { - const { host, focusState, ui } = makeHarness(); - const promise = choose(ui, host, theme, { - title: "T", - prompt: "P", - items: items(4), - numberShortcuts: true - }); - const dialog = focusState.current as Component; - dialog.handleInput!("9"); // out of range -> no confirm - // settle the promise via Escape - dialog.handleInput!("\x1b"); - const result = await promise; - expect(result).toBeNull(); - }); - - test("digit is filter input when filter is active (no confirm)", async () => { - const { root, host, focusState, ui } = makeHarness(); - const promise = choose(ui, host, theme, { - title: "T", - prompt: "P", - items: items(4), - numberShortcuts: true - }); - const dialog = focusState.current as Component; - dialog.handleInput!("O"); // starts filter with "O" - dialog.handleInput!("2"); // continues filter, must NOT confirm option 2 - expect(rendered(root)).toContain("Filter: O2"); - // Cancel to settle the promise; result should be null (nothing was confirmed) - dialog.handleInput!("\x1b"); - const result = await promise; - expect(result).toBeNull(); - }); - - test("labels carry number hints and the number-selects help by default", async () => { - const { root, host, focusState, ui } = makeHarness(); - const promise = choose(ui, host, theme, { - title: "T", - prompt: "P", - items: items(4), - numberShortcuts: true - }); - const output = rendered(root); - expect(output).toContain("1. Option 1"); - expect(output).toContain("4. Option 4"); - expect(output).not.toContain("5. Option"); - expect(output.replace(/\n/g, " ")).toContain("number selects"); - focusState.current?.handleInput?.("\x1b"); - expect(await promise).toBeNull(); - }); - - test("number shortcuts are opt-in for generic choice dialogs", async () => { - const { root, host, focusState, ui } = makeHarness(); - const promise = choose(ui, host, theme, { - title: "T", - prompt: "P", - items: items(4) - }); - const output = rendered(root); - expect(output).not.toContain("1. Option 1"); - expect(output).not.toContain("number selects"); - focusState.current?.handleInput?.("1"); - expect(rendered(root)).toContain("Filter: 1"); - focusState.current?.handleInput?.("\x1b"); - expect(await promise).toBeNull(); - }); - - test("numberShortcuts: false omits hints and keeps digits as filter input", async () => { - const { root, host, focusState, ui } = makeHarness(); - const promise = choose(ui, host, theme, { - title: "T", - prompt: "P", - items: items(4), - numberShortcuts: false - }); - const output = rendered(root); - expect(output).not.toContain("1. Option 1"); - expect(output).toContain("Option 1"); - expect(output.replace(/\n/g, " ")).not.toContain("number selects"); - // The shortcut is off: the digit must remain filter input. - focusState.current?.handleInput?.("1"); - expect(rendered(root)).toContain("Filter: 1"); - focusState.current?.handleInput?.("\x1b"); - const result = await promise; - expect(result).toBeNull(); - }); - - test("more than 9 items: no hints, no shortcut, help line not advertised", async () => { - const { root, host, focusState, ui } = makeHarness(); - const promise = choose(ui, host, theme, { - title: "T", - prompt: "P", - items: items(12), - numberShortcuts: true - }); - const output = rendered(root); - expect(output).not.toContain("1. Option 1"); - expect(output.replace(/\n/g, " ")).not.toContain("number selects"); - // A digit must remain filter input because lists over 9 items never expose - // single-key numeric selection. - focusState.current?.handleInput?.("3"); - expect(rendered(root)).toContain("Filter: 3"); - focusState.current?.handleInput?.("\x1b"); - const result = await promise; - expect(result).toBeNull(); - }); - - test("custom help suppresses hints and the shortcut by default", async () => { - const { root, host, focusState, ui } = makeHarness(); - const promise = choose(ui, host, theme, { - title: "T", - prompt: "P", - help: "custom help line", - items: items(4) - }); - const output = rendered(root); - expect(output).toContain("custom help line"); - expect(output).not.toContain("1. Option 1"); - focusState.current?.handleInput?.("1"); - expect(rendered(root)).toContain("Filter: 1"); - focusState.current?.handleInput?.("\x1b"); - const result = await promise; - expect(result).toBeNull(); - }); - - test("numberShortcuts: true force-enables the shortcut despite custom help", async () => { - const { host, focusState, ui } = makeHarness(); - const promise = choose(ui, host, theme, { - title: "T", - prompt: "P", - help: "custom help line", - items: items(4), - numberShortcuts: true - }); - const dialog = focusState.current as Component; - dialog.handleInput!("2"); - const result = await promise; - expect(result?.label).toBe("Option 2"); - }); -}); diff --git a/test/choice-dialog.test.ts b/test/choice-dialog.test.ts deleted file mode 100644 index 598bef66..00000000 --- a/test/choice-dialog.test.ts +++ /dev/null @@ -1,579 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - Container, - Text, - visibleWidth, - type Component, - type TUI -} from "@earendil-works/pi-tui"; - -import { choose, promptText } from "../packages/zcode-tui/src/choice-dialog.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -describe("TUI choice dialog", () => { - test("finds model names and aliases inside provider-qualified IDs", async () => { - const host = new Container(); - let focused: Component | null = null; - const ui = { - terminal: { rows: 24 }, requestRender() {}, - setFocus(component: Component | null) { focused = component; } - } as unknown as TUI; - const pending = choose(ui, host, createTheme(false), { - title: "Select model", prompt: "Choose a model", filterMode: "substring", - items: [ - { value: "account:zai-individual-coding-plan/GLM-5.3", label: "account:zai-individual-coding-plan/GLM-5.3" }, - { value: "opencode-go/glm-5.3", label: "opencode-go/glm-5.3", description: "Fast coding alias", payload: "original-model" }, - { value: "command-code/kimi-k3", label: "command-code/kimi-k3" } - ] - }); - const input = (text: string) => focused?.handleInput?.(text); - const output = () => host.render(140).join("\n"); - for (const query of ["glm", "GLM"]) { - input(query); - expect(output()).toContain("account:zai-individual-coding-plan/GLM-5.3"); - expect(output()).toContain("opencode-go/glm-5.3"); - expect(output()).not.toContain("command-code/kimi-k3"); - input("\x15"); - } - input("coding alias"); - expect(output()).toContain("opencode-go/glm-5.3"); - expect(output()).not.toContain("account:zai-individual-coding-plan/GLM-5.3"); - input("\x15"); - input("no-such-model"); - expect(output()).not.toContain("opencode-go/glm-5.3"); - input("\x15"); - expect(output()).toContain("command-code/kimi-k3"); - input("glm"); - input("\x1b[B"); - input("\r"); - expect(await pending).toMatchObject({ value: "opencode-go/glm-5.3", payload: "original-model" }); - }); - - test("renders fullscreen dialogs as isolated bottom panes", async () => { - const host = new Container(); - const focusState: { current: Component | null } = { current: null }; - let overlay: { component: Component; options?: Record } | undefined; - let hidden = false; - const ui = { - mode: "fullscreen", - terminal: { columns: 120, rows: 30 }, - requestRender() {}, - setFocus(component: Component | null) { - focusState.current = component; - if (component && "focused" in component) { - (component as Component & { focused: boolean }).focused = true; - } - }, - showOverlay(component: Component, options?: Record) { - overlay = { component, options }; - return { - focus() {}, - hide() { hidden = true; }, - isFocused: () => true, - isHidden: () => false, - setHidden() {}, - unfocus() {} - }; - } - } as unknown as TUI; - const overlayComponent = (): Component => { - const component = overlay?.component; - if (!component) throw new Error("Fullscreen dialog did not mount an overlay."); - return component; - }; - - const pending = choose(ui, host, createTheme(false), { - title: "ZCode settings", - prompt: "Display mode: fullscreen · applied", - items: [ - { value: "providers", label: "Model providers", description: "Saved: local/GLM-5.2" }, - { value: "display", label: "Display mode", description: "Current: Fullscreen" } - ] - }); - - expect(host.children).toHaveLength(0); - expect(overlay?.options).toMatchObject({ - anchor: "bottom-left", - maxHeight: "100%", - width: "100%" - }); - const lines = overlayComponent().render(120); - expect(lines[0]).toBe("─".repeat(120)); - expect(lines.at(-1)).toBe("─".repeat(120)); - expect(lines.join("\n")).toContain(" ZCode settings"); - expect(lines.every((line) => visibleWidth(line) <= 120)).toBe(true); - expect(focusState.current).toBe(overlayComponent()); - - focusState.current?.handleInput?.("\x1b"); - expect(await pending).toBeNull(); - expect(hidden).toBeTrue(); - - hidden = false; - overlay = undefined; - const promptPending = promptText(ui, host, createTheme(false), { - title: "Implementation instructions", - prompt: "Enter guidance for the active turn." - }); - const promptLines = overlayComponent().render(120); - expect(promptLines[0]).toBe("─".repeat(120)); - expect(promptLines.at(-1)).toBe("─".repeat(120)); - focusState.current?.handleInput?.("Keep the bottom pane isolated."); - expect(overlayComponent().render(120).join("\n")).toContain("Keep the bottom pane isolated."); - focusState.current?.handleInput?.("\r"); - expect(await promptPending).toBe("Keep the bottom pane isolated."); - expect(hidden).toBeTrue(); - }); - - test("renders after a long transcript instead of compositing into its viewport", async () => { - const root = new Container(); - const transcript = new Text( - Array.from({ length: 40 }, (_, index) => `help line ${index + 1}`).join("\n"), - 0, - 0 - ); - const host = new Container(); - const status = new Text("model · build · max", 0, 0); - const focusState: { current: Component | null } = { current: null }; - const ui = { - terminal: { rows: 24 }, - requestRender() {}, - setFocus(component: Component | null) { - focusState.current = component; - } - } as unknown as TUI; - - root.addChild(transcript); - root.addChild(host); - root.addChild(status); - - const pending = choose(ui, host, createTheme(false), { - title: "Select reasoning effort", - prompt: "Current reasoning effort: max.", - items: [ - { value: "low", label: "Low" }, - { value: "max", label: "Max" } - ], - selectedIndex: 1 - }); - - const lines = root.render(80); - const helpIndex = lines.findIndex((line) => line.includes("help line 40")); - const dialogIndex = lines.findIndex((line) => line.includes("Select reasoning effort")); - const statusIndex = lines.findIndex((line) => line.includes("model · build")); - - expect(helpIndex).toBeGreaterThanOrEqual(0); - expect(dialogIndex).toBeGreaterThan(helpIndex); - expect(statusIndex).toBeGreaterThan(dialogIndex); - expect(focusState.current).toBe(host.children[0] ?? null); - - host.children[0]?.handleInput?.("\r"); - expect(await pending).toMatchObject({ value: "max" }); - expect(host.children).toHaveLength(0); - }); - - test("ignores decoded and raw Kitty functional-key text in choice filters", async () => { - const root = new Container(); - const host = new Container(); - const focusState: { current: Component | null } = { current: null }; - const ui = { - terminal: { rows: 24 }, - requestRender() {}, - setFocus(component: Component | null) { - focusState.current = component; - } - } as unknown as TUI; - root.addChild(host); - - const pending = choose(ui, host, createTheme(false), { - title: "Select model", - prompt: "Current model: local/GLM-5.2.", - items: [ - { value: "alpha", label: "Alpha" }, - { value: "beta", label: "Beta" }, - { value: "1-model", label: "1 Numpad model" } - ] - }); - - for (const sequence of [ - "\x1b[57361u", // PrintScreen press - "\x1b[57361:51;2u", // PrintScreen with shifted alternate key "3" - "\x1b[57362;1:1u", // Pause press with event type - "\x1b[57363;1:2u", // Menu repeat - "\x1b[57361;1:3u", // PrintScreen release (defensive direct dispatch) - "\x1b[101;1:3u", // Letter "e" release must not type (issue #109) - "\uE011", // Raw Kitty PrintScreen functional codepoint - "\uF72E" // Raw macOS NSPrintScreenFunctionKey codepoint - ]) { - focusState.current?.handleInput?.(sequence); - const output = root.render(80).join("\n"); - expect(output).toContain("Filter: type to search"); - expect(output).not.toContain("No matching commands"); - expect(output).toContain("Alpha"); - expect(output).toContain("Beta"); - } - - focusState.current?.handleInput?.("\x1b[57400u"); - const keypadFiltered = root.render(80).join("\n"); - expect(keypadFiltered).toContain("Filter: 1"); - expect(keypadFiltered).toContain("1 Numpad model"); - expect(keypadFiltered).not.toContain("No matching commands"); - - // Nerd Font glyphs sit outside the reserved key ranges and stay typeable. - for (const glyph of [ - "\uE0A0", // Powerline branch - "\uE0B0", // Powerline separator - "\uE5FA", // Seti - "\uF07C", // Font Awesome folder - "\uE06F" // Just past Kitty's reserved block (57455) - ]) { - focusState.current?.handleInput?.("\x15"); - focusState.current?.handleInput?.(glyph); - expect(root.render(80).join("\n")).toContain(`Filter: ${glyph}`); - } - - // Held text keys must still repeat; only functional keys and releases are dropped. - focusState.current?.handleInput?.("\x15"); - focusState.current?.handleInput?.("\x1b[98u"); - focusState.current?.handleInput?.("\x1b[98;1:2u"); - expect(root.render(80).join("\n")).toContain("Filter: bb"); - - focusState.current?.handleInput?.("\x15"); - focusState.current?.handleInput?.("\x1b[97u"); - const filtered = root.render(80).join("\n"); - expect(filtered).toContain("Filter: a"); - expect(filtered).toContain("Alpha"); - expect(filtered).not.toContain("Beta"); - - focusState.current?.handleInput?.("\x1b"); - expect(await pending).toBeNull(); - expect(host.children).toHaveLength(0); - }); - - test("text prompts drop the drawn caret while the terminal is unfocused", async () => { - const root = new Container(); - const host = new Container(); - const focusState: { current: Component | null } = { current: null }; - const ui = { - terminal: { rows: 24 }, - requestRender() {}, - setFocus(component: Component | null) { - focusState.current = component; - } - } as unknown as TUI; - root.addChild(host); - const caret = () => root.render(80).join("\n").includes("\x1b_pi:fc\x07"); - - let terminalFocused = false; - const pending = promptText(ui, host, createTheme(false), { - title: "Implementation instructions", - prompt: "Enter guidance for the active turn.", - isTerminalFocused: () => terminalFocused - }); - focusState.current?.handleInput?.("keep it simple"); - expect(caret()).toBe(false); - terminalFocused = true; - expect(caret()).toBe(true); - terminalFocused = false; - focusState.current?.handleInput?.("\r"); - expect(await pending).toBe("keep it simple"); - - let maskedFocused = false; - const masked = promptText(ui, host, createTheme(false), { - title: "Enter API key", - prompt: "The key stays hidden.", - mask: true, - isTerminalFocused: () => maskedFocused - }); - focusState.current?.handleInput?.("secret-value-123"); - expect(caret()).toBe(false); - maskedFocused = true; - expect(caret()).toBe(true); - focusState.current?.handleInput?.("\r"); - expect(await masked).toBe("secret-value-123"); - }); - - test("masked prompts return the secret without rendering it", async () => { const root = new Container(); - const host = new Container(); - const focusState: { current: Component | null } = { current: null }; - const ui = { - terminal: { rows: 24 }, - requestRender() {}, - setFocus(component: Component | null) { - focusState.current = component; - } - } as unknown as TUI; - root.addChild(host); - - const pending = promptText(ui, host, createTheme(false), { - title: "Enter API key", - prompt: "The key stays hidden.", - mask: true, - placeholder: "Paste API key" - }); - - expect(root.render(80).join("\n")).toContain("Paste API key"); - focusState.current?.handleInput?.("secret-value-123"); - const rendered = root.render(80).join("\n"); - expect(rendered).not.toContain("secret-value-123"); - expect(rendered).toContain("****************"); - - focusState.current?.handleInput?.("\r"); - expect(await pending).toBe("secret-value-123"); - expect(host.children).toHaveLength(0); - }); - - test("wraps long unmasked input and submits the complete value", async () => { - const root = new Container(); - const host = new Container(); - const focusState: { current: Component | null } = { current: null }; - const ui = { - terminal: { rows: 24, columns: 24 }, - requestRender() {}, - setFocus(component: Component | null) { - focusState.current = component; - } - } as unknown as TUI; - root.addChild(host); - - const pending = promptText(ui, host, createTheme(false), { - title: "Other answer", - prompt: "Enter a different answer." - }); - const emptyLineCount = root.render(24).length; - const answer = "这是一个需要自动换行并完整提交的长回答,包含中文、emoji 👨‍👩‍👧‍👦 和 unbroken-answer-marker。"; - focusState.current?.handleInput?.(answer); - - const lines = root.render(24); - const normalized = lines.join("").replace(/\x1b\[[0-9;]*m/gu, "").replace(/\s+/gu, ""); - expect(lines.length).toBeGreaterThan(emptyLineCount); - expect(lines.every((line) => visibleWidth(line) <= 24)).toBe(true); - expect(normalized).toContain(answer.replace(/\s+/gu, "")); - - focusState.current?.handleInput?.("\r"); - expect(await pending).toBe(answer); - expect(host.children).toHaveLength(0); - }); - - test("expands and scrolls long plan details without changing the selected action", async () => { - const root = new Container(); - const host = new Container(); - const focusState: { current: Component | null } = { current: null }; - const ui = { - terminal: { rows: 18 }, - requestRender() {}, - setFocus(component: Component | null) { - focusState.current = component; - } - } as unknown as TUI; - root.addChild(host); - - const pending = choose(ui, host, createTheme(false), { - title: "Ready to implement?", - prompt: "Review the plan and choose how ZCode should continue.", - contentLabel: "Plan", - content: new Text( - Array.from({ length: 30 }, (_, index) => `plan line ${index + 1}`).join("\n"), - 0, - 0 - ), - items: [ - { value: "approve", label: "Approve and continue" }, - { value: "refine", label: "Keep planning" } - ] - }); - - let output = root.render(60).join("\n"); - expect(output).toContain("plan line 1"); - expect(output).not.toContain("plan line 30"); - expect(output).toContain("Plan 1–6 of 30"); - expect(output.replace(/\n/g, " ")).toContain("Ctrl+O details"); - - focusState.current?.handleInput?.("\x0f"); - output = root.render(60).join("\n"); - expect(output).toContain("Ready to implement? · Plan"); - expect(output).not.toContain("Approve and continue"); - expect(output).toContain("Plan 1–9 of 30"); - - focusState.current?.handleInput?.("\x1b[6~"); - output = root.render(60).join("\n"); - expect(output).toContain("plan line 9"); - expect(output).toContain("Plan 9–17 of 30"); - - focusState.current?.handleInput?.("\x1b[F"); - output = root.render(60).join("\n"); - expect(output).toContain("plan line 30"); - expect(output).toContain("Plan 22–30 of 30"); - - focusState.current?.handleInput?.("\x1b"); - output = root.render(60).join("\n"); - expect(output).toContain("Approve and continue"); - focusState.current?.handleInput?.("\x1b[B"); - focusState.current?.handleInput?.("\r"); - expect(await pending).toMatchObject({ value: "refine" }); - }); - - test("pages windowed plan content without materializing the full document", async () => { - const root = new Container(); - const host = new Container(); - const focusState: { current: Component | null } = { current: null }; - let fullRenders = 0; - const content: Component & { - renderWindow(width: number, start: number, count: number): { lines: string[]; totalLines: number }; - } = { - invalidate() {}, - render() { - fullRenders += 1; - throw new Error("windowed content must not use full render"); - }, - renderWindow(_width, start, count) { - return { - lines: Array.from({ length: Math.min(count, 50_000 - start) }, (_, index) => `plan line ${start + index + 1}`), - totalLines: 50_000 - }; - } - }; - const ui = { - terminal: { rows: 18, columns: 60 }, - requestRender() {}, - setFocus(component: Component | null) { - focusState.current = component; - } - } as unknown as TUI; - root.addChild(host); - - const pending = choose(ui, host, createTheme(false), { - title: "Ready to implement?", - prompt: "Review the plan.", - contentLabel: "Plan", - content, - items: [{ value: "approve", label: "Approve" }] - }); - expect(root.render(60).join("\n")).toContain("Plan 1–7 of 50000"); - focusState.current?.handleInput?.("\x0f"); - focusState.current?.handleInput?.("\x1b[6~"); - expect(root.render(60).join("\n")).toContain("plan line 9"); - expect(fullRenders).toBe(0); - focusState.current?.handleInput?.("\x1b"); - focusState.current?.handleInput?.("\x1b"); - expect(await pending).toBeNull(); - }); - - test("keeps the moved selection explicit on light terminals", async () => { - const root = new Container(); - const host = new Container(); - const focusState: { current: Component | null } = { current: null }; - const ui = { - terminal: { rows: 24 }, - requestRender() {}, - setFocus(component: Component | null) { - focusState.current = component; - } - } as unknown as TUI; - root.addChild(host); - - const pending = choose(ui, host, createTheme(true, "light"), { - title: "Choose action", - prompt: "Select one.", - items: [ - { value: "first", label: "First action" }, - { value: "second", label: "Second action" } - ] - }); - - expect(root.render(60).find((line) => line.includes("First action"))) - .toContain("\x1b[38;5;25m"); - focusState.current?.handleInput?.("\x1b[B"); - expect(root.render(60).find((line) => line.includes("Second action"))) - .toContain("\x1b[38;5;25m"); - focusState.current?.handleInput?.("\r"); - expect(await pending).toMatchObject({ value: "second" }); - }); - - test("wraps the selected item's complete label and description in a details viewport", async () => { - const root = new Container(); - const host = new Container(); - const focusState: { current: Component | null } = { current: null }; - const ui = { - terminal: { rows: 24, columns: 52 }, - requestRender() {}, - setFocus(component: Component | null) { - focusState.current = component; - } - } as unknown as TUI; - root.addChild(host); - - const firstDescription = "Reuse the deterministic offline fixture so the recording remains reproducible without provider credentials."; - const secondDescription = "Record a live provider session whose unique ending remains visible after moving the selection."; - const pending = choose(ui, host, createTheme(false), { - title: "Demo driver · 1/4", - prompt: "How should the recording be produced?", - contentLabel: "Option details", - showSelectedItemDetails: true, - items: [ - { - value: "fixture", - label: "Fixture-based recording with a deliberately long recommended label", - description: firstDescription - }, - { value: "live", label: "Live headless prompt", description: secondDescription } - ] - }); - - const normalized = () => root.render(52).join("\n").replace(/\s+/gu, " "); - expect(normalized()).toContain("Fixture-based recording with a deliberately long recommended label"); - expect(normalized()).toContain(firstDescription); - expect(normalized()).not.toContain("unique ending remains visible"); - expect(root.render(52).every((line) => visibleWidth(line) <= 52)).toBe(true); - - focusState.current?.handleInput?.("\x1b[B"); - expect(normalized()).toContain(secondDescription); - expect(normalized()).not.toContain("remains reproducible without provider credentials"); - focusState.current?.handleInput?.("\r"); - expect(await pending).toMatchObject({ value: "live" }); - }); - - test("keeps choice and text prompt chrome within narrow terminal widths", async () => { - for (const width of [8, 20, 40, 60, 80, 100, 120]) { - const choiceRoot = new Container(); - const choiceHost = new Container(); - const choiceFocus: { current: Component | null } = { current: null }; - const choiceUi = { - terminal: { rows: 24, columns: width }, - requestRender() {}, - setFocus(component: Component | null) { - choiceFocus.current = component; - } - } as unknown as TUI; - choiceRoot.addChild(choiceHost); - const choicePending = choose(choiceUi, choiceHost, createTheme(true, "dark"), { - title: "A deliberately long choice dialog title that must remain width-safe", - prompt: "Review every available action and select how ZCode should continue from this point.", - help: "Type to filter · Up/Down choose · Ctrl+O details · PgUp/PgDn scroll · Enter confirm · Esc cancel", - items: [{ value: "close", label: "Close this width verification dialog" }] - }); - expect(choiceRoot.render(width).every((line) => visibleWidth(line) <= width)).toBe(true); - choiceFocus.current?.handleInput?.("\x1b"); - expect(await choicePending).toBeNull(); - - const promptRoot = new Container(); - const promptHost = new Container(); - const promptFocus: { current: Component | null } = { current: null }; - const promptUi = { - terminal: { rows: 24, columns: width }, - requestRender() {}, - setFocus(component: Component | null) { - promptFocus.current = component; - } - } as unknown as TUI; - promptRoot.addChild(promptHost); - const promptPending = promptText(promptUi, promptHost, createTheme(true, "light"), { - title: "A deliberately long prompt title that must remain width-safe", - prompt: "Enter a value while retaining all instructions on narrow terminal screens.", - help: "Enter confirm · Esc cancel · pasted values remain local to this prompt" - }); - expect(promptRoot.render(width).every((line) => visibleWidth(line) <= width)).toBe(true); - promptFocus.current?.handleInput?.("\x1b"); - expect(await promptPending).toBeNull(); - } - }); -}); diff --git a/test/code-highlighter.test.ts b/test/code-highlighter.test.ts deleted file mode 100644 index 2c5b738a..00000000 --- a/test/code-highlighter.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - ACTIVE_TYPESCRIPT_HIGHLIGHT_MAX_CHARACTERS, - CODE_HIGHLIGHT_CACHE_MAX_CHARACTERS, - CODE_HIGHLIGHT_CACHE_MAX_ENTRIES, - CodeHighlighter, - isIncrementalTypescriptSource, - isLineLocalFunctionScript, - isLineLocalScript, - isSingleOpenScriptFunction, - languageForFilename -} from "../packages/zcode-tui/src/code-highlighter.ts"; - -describe("TUI code highlighter", () => { - test("maps common source filenames to supported languages", () => { - expect(languageForFilename("src/index.ts")).toBe("typescript"); - expect(languageForFilename("component.tsx")).toBe("typescript"); - expect(languageForFilename("scripts/run.sh")).toBe("bash"); - expect(languageForFilename("README.unknown")).toBeUndefined(); - }); - - test("keeps plain and oversized input safe while preserving line count", () => { - const highlighter = new CodeHighlighter(false); - expect(highlighter.highlight("const value = 1;\nvalue += 1;", "ts")).toEqual([ - "const value = 1;", - "value += 1;" - ]); - expect(highlighter.highlight("\u001b[2Junsafe", "ts")).toEqual(["unsafe"]); - }); - - test("highlights supported languages without changing visible source", () => { - const lines = new CodeHighlighter(true).highlight("const answer: number = 42;", "typescript"); - expect(lines.join("\n")).toContain("\x1b["); - const plain = lines.join("\n").replace(/\x1b\[[0-?]*[ -/]*[@-~]/gu, ""); - expect(plain).toBe("const answer: number = 42;"); - }); - - test("switches to a high-contrast light-terminal palette", () => { - const highlighter = new CodeHighlighter(true, "dark"); - const dark = highlighter.highlight("const answer = 42;", "typescript").join("\n"); - highlighter.setColorScheme("light"); - const light = highlighter.highlight("const answer = 42;", "typescript").join("\n"); - - expect(dark).toContain("\x1b[1;38;5;75mconst"); - expect(light).toContain("\x1b[1;38;5;25mconst"); - }); - - test("classifies only context-free completed script lines as reusable", () => { - expect(isLineLocalScript([ - "const answer = 42;", - "const matcher = /hello\\s+world/iu;", - "const optional = payload?.value ?? \"fallback\";", - "" - ].join("\n"))).toBeTrue(); - - for (const source of [ - "const template = `first\nsecond`;\n", - "/* open\nstill comment */\n", - "const value = {\n id: 1\n};\n", - "const value = 1 + \\\n 2;\n", - "const value = [1, 2};\n" - ]) { - expect(isLineLocalScript(source)).toBeFalse(); - } - }); - - test("classifies only bounded adjacent function physical lines as reusable", () => { - const source = [ - "function first(input) {", - " const doubled = input * 2;", - " return doubled;", - "}", - "export async function second(input: number): Promise {", - " await Promise.resolve(input);", - " return input + 1;", - "}" - ].join("\n"); - expect(isLineLocalFunctionScript(source)).toBeTrue(); - expect(isLineLocalFunctionScript(`${source}\nfunction partial`, true)).toBeTrue(); - expect(isLineLocalFunctionScript(`${source}\nfunction partial`)).toBeFalse(); - expect(isLineLocalFunctionScript("function open(input) {\n return input;")) - .toBeTrue(); - expect(isIncrementalTypescriptSource("function open(input: number): number {")).toBeTrue(); - expect(isIncrementalTypescriptSource( - "function open(input: number): number {\n return input" - )).toBeTrue(); - - const nestedIf = [ - "function nested(input) {", - " const matcher = /value+/g;", - " if (matcher.test(input)) {", - " return \"中文 ✅\";", - " }", - " return input;", - "}" - ].join("\n"); - expect(isLineLocalFunctionScript(nestedIf)).toBeTrue(); - expect(isLineLocalFunctionScript("function nested(input) {\n if (input) {")) - .toBeTrue(); - expect(isLineLocalFunctionScript(`${nestedIf}\nfunction partial`, true)).toBeTrue(); - - for (const unsafe of [ - "function empty() {\n}", - "function blank() {\n\n return 1;\n}", - "function multiline(\n input\n) {\n return input;\n}", - "function emptyNested(input) {\n if (input) {\n }\n return input;\n}", - "function secondNested(input) {\n if (input) {\n return input;\n }\n if (!input) {\n return 0;\n }\n}", - "function deeper(input) {\n if (input) {\n if (input.value) {\n return input.value;\n }\n }\n}", - "function alternative(input) {\n if (input) {\n return input;\n } else {\n return 0;\n }\n}", - "function loop(input) {\n for (const item of input) {\n consume(item);\n }\n}", - "function condition(input) {\n if (\n input\n ) {\n return input;\n }\n}", - "function object(input) {\n const value = {\n input\n };\n}", - "function template(input) {\n return `value ${input}`;\n}", - "function blocked(input) {\n /* comment */\n return input;\n}", - "const arrow = (input) => {\n return input;\n}", - "class Box {\n value = 1;\n}", - `${source}\nconst notAFunction = true;\n` - ]) { - expect(isLineLocalFunctionScript(unsafe)).toBeFalse(); - } - }); - - test("keeps safe TypeScript streaming prefixes byte-identical to full highlighting", () => { - const source = [ - "const answer: number = 42;", - "const label: string = \"value\";", - "const matcher: RegExp = /foo\\/bar/gi;", - "function double(input: number): number { return input * 2; }", - "// line-local comment", - "console.log(label, matcher, double(answer));" - ].join("\n"); - - for (const scheme of ["dark", "light"] as const) { - const streamed = new CodeHighlighter(true, scheme); - const full = new CodeHighlighter(true, scheme); - let prefix = ""; - for (const character of source) { - prefix += character; - expect(streamed.highlight(prefix, "typescript")) - .toEqual(full.highlight(prefix, "tsx")); - } - const internal = streamed as unknown as { - activeTypescript?: { mode: string; source: string }; - }; - expect(internal.activeTypescript).toMatchObject({ - mode: "incremental", - source - }); - } - }); - - test("reuses complete TypeScript lines while only re-highlighting the partial tail", () => { - const streamed = new CodeHighlighter(true); - const full = new CodeHighlighter(true); - let source = "const seed: number = 0;\n"; - expect(streamed.highlight(source, "ts")).toEqual(full.highlight(source, "tsx")); - - for (const chunk of [ - "const next", - ": string = \"value\";\nconst matcher", - ": RegExp = /next+/g;\n", - "console.log(seed, next, matcher);" - ]) { - source += chunk; - expect(streamed.highlight(source, "ts")).toEqual(full.highlight(source, "tsx")); - } - - const internal = streamed as unknown as { - activeTypescript?: { - lines?: string[]; - mode: string; - source: string; - }; - cache: Map; - }; - expect(internal.activeTypescript?.mode).toBe("incremental"); - expect((internal.activeTypescript?.source.length ?? 0) - + (internal.activeTypescript?.lines ?? []).reduce((total, line) => total + line.length, 0)) - .toBeLessThanOrEqual(ACTIVE_TYPESCRIPT_HIGHLIGHT_MAX_CHARACTERS); - expect(internal.cache.size).toBe(0); - }); - - test("reuses line-local body rows inside one still-open TypeScript function", () => { - const source = [ - "export async function generated(input: number): Promise {", - " const doubled: number = input * 2;", - " // line-local comment", - " const label: string = \"中文 ✅\";", - " const matcher: RegExp = /value+/g;", - " await Promise.resolve(label);", - " return matcher.test(label) ? doubled : input;" - ].join("\n"); - - expect(isSingleOpenScriptFunction(source)).toBeTrue(); - for (const scheme of ["dark", "light"] as const) { - const streamed = new CodeHighlighter(true, scheme); - const full = new CodeHighlighter(true, scheme); - let prefix = ""; - for (const character of source) { - prefix += character; - expect(streamed.highlight(prefix, "typescript")) - .toEqual(full.highlight(prefix, "tsx")); - } - const internal = streamed as unknown as { - activeTypescript?: { - lines?: string[]; - mode: string; - stableLines?: string[]; - strategy?: string; - }; - }; - expect(internal.activeTypescript).toMatchObject({ - mode: "incremental", - strategy: "lines" - }); - expect(internal.activeTypescript?.stableLines?.length).toBe(6); - - const closed = `${source}\n}\n`; - expect(streamed.highlight(closed, "typescript")) - .toEqual(full.highlight(closed, "tsx")); - expect(internal.activeTypescript).toMatchObject({ - mode: "incremental", - strategy: "top-level" - }); - } - }); - - test("rejects contextual or non-function single-open TypeScript bodies", () => { - const sources = [ - "function open(): void {\n /* block starts\n still open", - "function open(): void {\n const template = `first\n second", - "function open(\n input: number\n): void {\n return;", - "function open(): void {\n if (ready) {\n console.log(ready);", - "function open(): void {\n const value = {\n ready: true", - "class Open {\n value = 1;", - "const open = (): void => {\n const value = 1;", - "function open(): void {\n const value = (1;" - ]; - - for (const source of sources) { - expect(isSingleOpenScriptFunction(source)).toBeFalse(); - const streamed = new CodeHighlighter(true); - expect(streamed.highlight(source, "typescript")) - .toEqual(new CodeHighlighter(true).highlight(source, "tsx")); - const internal = streamed as unknown as { - activeTypescript?: { mode: string }; - }; - expect(internal.activeTypescript?.mode).toBe("fallback"); - } - }); - - test("reuses blank-separated multiline TypeScript blocks byte-for-byte", () => { - const source = [ - "function first(value: number): number {\n return value + 1;\n}", - "interface User {\n id: string;\n name: string;\n}", - "const user = {\n id: \"1\",\n name: \"Ada\"\n};", - "class Box {\n value: T;\n constructor(value: T) { this.value = value; }\n}" - ].join("\n\n"); - - for (const scheme of ["dark", "light"] as const) { - const streamed = new CodeHighlighter(true, scheme); - const full = new CodeHighlighter(true, scheme); - let prefix = ""; - for (const character of source) { - prefix += character; - expect(streamed.highlight(prefix, "typescript")) - .toEqual(full.highlight(prefix, "tsx")); - } - const internal = streamed as unknown as { - activeTypescript?: { mode: string; strategy?: string }; - cache: Map; - }; - expect(internal.activeTypescript?.mode).toBe("incremental"); - expect(["blocks", "top-level"]).toContain(internal.activeTypescript?.strategy ?? ""); - expect(internal.cache.size).toBeLessThanOrEqual(CODE_HIGHLIGHT_CACHE_MAX_ENTRIES); - } - }); - - test("stabilizes adjacent top-level TypeScript blocks at closed line boundaries", () => { - const source = [ - "function first(value: number): number {\n return value + 1;\n}", - "interface User {\n id: string;\n name: string;\n}", - "const user = {\n id: \"1\",\n name: \"Ada\"\n};", - "class Box {\n value: T;\n constructor(value: T) { this.value = value; }\n}" - ].join("\n"); - - for (const scheme of ["dark", "light"] as const) { - const streamed = new CodeHighlighter(true, scheme); - const full = new CodeHighlighter(true, scheme); - let prefix = ""; - for (const character of source) { - prefix += character; - expect(streamed.highlight(prefix, "typescript")) - .toEqual(full.highlight(prefix, "tsx")); - } - const internal = streamed as unknown as { - activeTypescript?: { mode: string; strategy?: string }; - }; - expect(internal.activeTypescript).toMatchObject({ - mode: "incremental", - strategy: "top-level" - }); - } - }); - - test("falls back for TypeScript constructs whose highlighting can cross lines", () => { - const sources = [ - "/* open\nstill comment */", - "const template = `first\nsecond`;", - "const continued = \"first\\\nsecond\";", - "interface User {\n id: string;\n}", - "const user = {\n id: \"one\"\n};", - "function open(): void {\n\n console.log(\"not top-level\");\n}" - ]; - - for (const source of sources) { - const streamed = new CodeHighlighter(true); - expect(streamed.highlight(source, "typescript")) - .toEqual(new CodeHighlighter(true).highlight(source, "tsx")); - const internal = streamed as unknown as { - activeTypescript?: { mode: string }; - }; - expect(internal.activeTypescript?.mode).toBe("fallback"); - } - }); - - test("keeps TSX on the full highlighter and clears active state on theme changes", () => { - const source = "const first: number = 1;\nconst second: number = 2;"; - const tsx = new CodeHighlighter(true, "dark"); - tsx.highlight(source, "tsx"); - const tsxInternal = tsx as unknown as { - activeTypescript?: { mode: string }; - }; - expect(tsxInternal.activeTypescript).toBeUndefined(); - - const highlighter = new CodeHighlighter(true, "dark"); - const dark = highlighter.highlight(source, "typescript"); - let internal = highlighter as unknown as { - activeTypescript?: { mode: string }; - }; - expect(internal.activeTypescript?.mode).toBe("incremental"); - highlighter.setColorScheme("light"); - internal = highlighter as unknown as { - activeTypescript?: { mode: string }; - }; - expect(internal.activeTypescript).toBeUndefined(); - const light = highlighter.highlight(source, "typescript"); - expect(light).toEqual(new CodeHighlighter(true, "light").highlight(source, "tsx")); - expect(light).not.toEqual(dark); - }); - - test("bounds cached source and ANSI output by entries and total characters", () => { - const highlighter = new CodeHighlighter(true); - const internal = highlighter as unknown as { - cache: Map; - cacheCharacters: number; - }; - - for (let entry = 0; entry < 40; entry += 1) { - const line = `const value_${entry}: number = ${entry}; // bounded cache line\n`; - const source = line.repeat(Math.ceil(20_000 / line.length)).slice(0, 20_000); - highlighter.highlight(source, "typescript"); - expect(internal.cacheCharacters).toBeLessThanOrEqual(CODE_HIGHLIGHT_CACHE_MAX_CHARACTERS); - expect(internal.cache.size).toBeLessThanOrEqual(CODE_HIGHLIGHT_CACHE_MAX_ENTRIES); - } - expect(internal.cacheCharacters).toBeGreaterThan(0); - - highlighter.setColorScheme("light"); - expect(internal.cache.size).toBe(0); - expect(internal.cacheCharacters).toBe(0); - }); - - test("keeps recently hit entries when the entry limit evicts an older item", () => { - const highlighter = new CodeHighlighter(true); - const internal = highlighter as unknown as { - cache: Map; - }; - const source = (entry: number): string => `const cached_${entry} = ${entry};`; - for (let entry = 0; entry < CODE_HIGHLIGHT_CACHE_MAX_ENTRIES; entry += 1) { - highlighter.highlight(source(entry), "typescript"); - } - - highlighter.highlight(source(0), "typescript"); - highlighter.highlight(source(CODE_HIGHLIGHT_CACHE_MAX_ENTRIES), "typescript"); - expect(internal.cache.has(`typescript\u0000${source(0)}`)).toBeTrue(); - expect(internal.cache.has(`typescript\u0000${source(1)}`)).toBeFalse(); - expect(internal.cache.size).toBe(CODE_HIGHLIGHT_CACHE_MAX_ENTRIES); - }); -}); diff --git a/test/config-bootstrap.test.ts b/test/config-bootstrap.test.ts index 3b4974cd..780a67a0 100644 --- a/test/config-bootstrap.test.ts +++ b/test/config-bootstrap.test.ts @@ -29,17 +29,6 @@ function homeEnvironment(home: string): NodeJS.ProcessEnv { } describe("user config bootstrap", () => { - test("resolves the official config location on POSIX and Windows", () => { - expect(cliSettingsPath({ HOME: "/home/alice" }, "linux", "/fallback")).toBe( - "/home/alice/.zcode/cli/setting.json" - ); - expect(cliSettingsPath({ USERPROFILE: "C:\\Users\\Alice" }, "win32", "C:\\fallback")).toBe( - "C:\\Users\\Alice\\.zcode\\cli\\setting.json" - ); - expect(cliSettingsPath({}, "win32", "D:\\Profiles\\Default")).toBe( - "D:\\Profiles\\Default\\.zcode\\cli\\setting.json" - ); - }); test("lets a host choose the settings file without moving Desktop settings", async () => { const home = await temporaryHome(); @@ -53,36 +42,6 @@ describe("user config bootstrap", () => { await expect(stat(join(home, ".zcode", "cli", "setting.json"))).rejects.toMatchObject({ code: "ENOENT" }); }); - test("supports Windows overrides and ignores blank values", () => { - expect(cliSettingsPath({ ZCODE_CLI_SETTINGS_FILE: " C:\\Host\\card.json " }, "win32", "D:\\Default")) - .toBe("C:\\Host\\card.json"); - expect(cliSettingsPath({ HOME: "/home/alice", ZCODE_CLI_SETTINGS_FILE: " \t " }, "linux", "/fallback")) - .toBe("/home/alice/.zcode/cli/setting.json"); - }); - - test("migrates and resets host settings independently of the home migration", async () => { - const home = await temporaryHome(), homeEnv = homeEnvironment(home); - await ensureCliSettings(homeEnv); - const original = await readFile(cliSettingsPath(homeEnv), "utf8"); - const originalMarker = await readFile(settingsMigrationMarkerPath(homeEnv), "utf8"); - await writeFile(legacyCliConfigPath(homeEnv), "invalid home legacy config"); - - const env = { ...homeEnv, ZCODE_CLI_SETTINGS_FILE: join(home, "host", "card.json") }; - await mkdir(dirname(legacyCliConfigPath(env)), { recursive: true }); - const hooks = { enabled: true, events: {} }; - await writeFile(legacyCliConfigPath(env), JSON.stringify({ hooks, provider: { old: {} } })); - expect(await ensureCliSettings(env)).toEqual({ configPath: cliSettingsPath(env), created: true, migrated: true }); - expect(JSON.parse(await readFile(cliSettingsPath(env), "utf8"))).toEqual({ hooks }); - expect(JSON.parse(await readFile(settingsMigrationMarkerPath(env), "utf8"))).toEqual({ schemaVersion: 1 }); - - await rm(cliSettingsPath(env)); - await writeFile(legacyCliConfigPath(env), "invalid host legacy config after migration"); - expect(await ensureCliSettings(env)).toEqual({ configPath: cliSettingsPath(env), created: true }); - expect(JSON.parse(await readFile(cliSettingsPath(env), "utf8")).hooks.enabled).toBe(false); - expect(await readFile(cliSettingsPath(homeEnv), "utf8")).toBe(original); - expect(await readFile(settingsMigrationMarkerPath(homeEnv), "utf8")).toBe(originalMarker); - }); - test("recursively creates a private, credential-free config", async () => { const home = await temporaryHome(); const env = homeEnvironment(home); @@ -131,11 +90,4 @@ describe("user config bootstrap", () => { expect(() => JSON.parse(serialized)).not.toThrow(); }); - test("rejects a config path occupied by a directory", async () => { - const home = await temporaryHome(); - const env = homeEnvironment(home); - await mkdir(cliSettingsPath(env), { recursive: true }); - - await expect(ensureCliSettings(env)).rejects.toThrow(/exists but is not a file/u); - }); }); diff --git a/test/config-diagnostics.test.ts b/test/config-diagnostics.test.ts deleted file mode 100644 index 8bd7db51..00000000 --- a/test/config-diagnostics.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { expect, test } from "bun:test"; -import { inspectRuntimeConfiguration, writeConfigurationDoctor } from "../src/config-diagnostics.ts"; -import { hasRuntimeConfigurationDiagnostics, patchRuntimeConfigurationDiagnostics } from "../scripts/runtime-config-diagnostic-patches.ts"; - -const env = { HOME: "/fixture-home", USERPROFILE: "/fixture-home" }; - -test("doctor projects names and sources without exposing MCP configuration values", () => { - const source = { path: "/fixture-home/.zcode/cli/setting.json", loaded: true, diagnostics: [], mcpServerNames: ["test"] }; - const report = inspectRuntimeConfiguration("/workspace", options => { - expect(options.workingDirectory).toBe("/workspace"); - return { - config: { features: { mcp: true }, mcp: { servers: { - test: { type: "http", url: "https://private.test/?token=secret", headers: { Authorization: "secret" } } - } } }, - sources: { user: source, project: { ...source, path: undefined, loaded: false, mcpServerNames: [] }, mcp: { serverSources: { test: "user" } } } - }; - }, env); - expect(report.ok).toBe(true); - expect(report.mcp.servers).toEqual([{ name: "test", type: "http", enabled: true, source: "user" }]); - expect(JSON.stringify(report)).not.toContain("secret"); - expect(JSON.stringify(report)).not.toContain("private.test"); -}); - -test("doctor keeps schema errors useful while suppressing JSON input fragments and terminal controls", () => { - const report = inspectRuntimeConfiguration("/workspace", () => ({ - config: { features: { mcp: true }, mcp: { servers: {} } }, - sources: { - user: { path: "/config", loaded: false, mcpServerNames: [], diagnostics: [ - { code: "config_file_invalid", severity: "error", message: 'Unexpected token in JSON: "SECRET_VALUE"', filePath: "/config" } - ] }, - project: { paths: [], loaded: false, mcpServerNames: [], diagnostics: [ - { code: "config_file_invalid", severity: "error", message: 'ui.theme: Invalid option: expected one of "auto"|"dark"|"light"', filePath: "/project/config.json" }, - { code: "config_mcp_server_invalid", severity: "warning", message: "command: expected string", path: "mcp.servers.bad\u001b[2J", filePath: "/project/config.json" } - ] }, - mcp: { serverSources: {} } - } - }), env); - expect(report.ok).toBe(false); - expect(report.user.status).toBe("invalid"); - expect(report.project.paths).toEqual(["/project/config.json"]); - expect(JSON.stringify(report)).not.toContain("SECRET_VALUE"); - expect(report.diagnostics[1]?.message).toContain("ui.theme"); - let text = ""; - expect(writeConfigurationDoctor({ write(value) { text += value; } }, report)).toBe(1); - expect(text).toContain("\\u001b"); - expect(text).not.toContain("\u001b"); -}); - -const source = ` -var init=once(()=>{label(factory,"createConfig")}); -var doctor=label((io,options,cwd)=>{let report={cli:{version:"fixture"},runtime:{cwd},packaging:{default:"node-bundle"}}; -if(options.json)return io.stdout.write(JSON.stringify(report)),0; -return io.stdout.write("doctor\\n"),0},"runDoctor"); -`; - -test("doctor bundle patch preserves metadata, emits one JSON document and returns configuration status", () => { - const patched = patchRuntimeConfigurationDiagnostics(source); - expect(hasRuntimeConfigurationDiagnostics(patched)).toBe(true); - expect(patchRuntimeConfigurationDiagnostics(patched)).toBe(patched); - let text = ""; - const report = { ok: false }; - const doctor = new Function("require", "__dirname", "once", "label", "factory", `${patched};return doctor;`)( - (name: string) => name === "node:path" ? { join: (...parts: string[]) => parts.join("/") } : { - inspectRuntimeConfiguration(cwd: string, createConfig: (options: unknown) => unknown) { - expect(cwd).toBe("/workspace"); - expect(createConfig({})).toBe("native-config"); - return report; - }, - writeConfigurationDoctor(output: { write(value: string): void }, value: unknown) { - expect(value).toBe(report); output.write("config-invalid\n"); return 1; - } - }, "/fixture", (fn: () => void) => fn, (fn: unknown) => fn, () => "native-config" - ); - const io = { stdout: { write(value: string) { text += value; } } }; - expect(doctor(io, { json: true }, "/workspace")).toBe(1); - expect(JSON.parse(text)).toEqual({ cli: { version: "fixture" }, runtime: { cwd: "/workspace" }, packaging: { default: "node-bundle" }, configuration: report }); - text = ""; - expect(doctor(io, { json: false }, "/workspace")).toBe(1); - expect(text).toBe("doctor\nconfig-invalid\n"); -}); - -test("doctor patch rejects missing, ambiguous and incomplete upstream boundaries", () => { - expect(() => patchRuntimeConfigurationDiagnostics(source + source)).toThrow("ambiguous"); - expect(() => patchRuntimeConfigurationDiagnostics(source.replace('"createConfig"', '"changed"'))).toThrow("missing"); - const partial = patchRuntimeConfigurationDiagnostics(source).replace(".writeConfigurationDoctor(", ".changed("); - expect(hasRuntimeConfigurationDiagnostics(partial)).toBe(false); - expect(() => patchRuntimeConfigurationDiagnostics(partial)).toThrow("partial"); -}); diff --git a/test/config-template.test.ts b/test/config-template.test.ts deleted file mode 100644 index 12df441d..00000000 --- a/test/config-template.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { expect, test } from "bun:test"; -import { join } from "node:path"; - -interface ConfigTemplate { - modelStream: { - idleTimeoutMs: number; - }; - subagents: { - autoBackgroundMs: number; - }; - ui: { - theme: string; - copyOnSelect: boolean; - notifications: { - method: string; - condition: string; - }; - }; -} - -test("general config template keeps runtime settings separate from providers", async () => { - const file = Bun.file(join(import.meta.dir, "..", "setting.example.json")); - const config = (await file.json()) as ConfigTemplate; - expect(config.modelStream.idleTimeoutMs).toBe(60_000); - expect(config.subagents.autoBackgroundMs).toBe(1_000); - expect(config.ui.theme).toBe("auto"); - expect(config.ui.copyOnSelect).toBe(true); - expect(config.ui.notifications).toEqual({ method: "auto", condition: "unfocused" }); - expect(config).not.toHaveProperty("provider"); - expect(config).not.toHaveProperty("model"); -}); - -test("provider template uses the native registry schema", async () => { - const config = await Bun.file(join(import.meta.dir, "..", "provider.example.json")).json(); - const selection = config.config.defaultModelSelection; - const provider = config.config.providerConfigRules.providerRules.find((rule: { providerId: string }) => rule.providerId === selection.providerId); - expect(config.schemaVersion).toBe(1); - expect(provider.config.personalModelIds).toContain(selection.modelId); - expect(provider.config.access.apiKey).toBe(""); - const rules = config.config.modelConfigRules; - const automatic = rules.providerModelRules.find((rule: { modelId: string }) => rule.modelId === selection.modelId); - expect(automatic.config).toEqual({ enabled: true }); - expect(rules.providerModelRules.find((rule: { modelId: string }) => rule.modelId === "overrides-reference").config.enabled).toBe(false); - expect(rules.manualProviderModelRules.find((rule: { modelId: string }) => rule.modelId === "manual-reference").config.enabled).toBe(false); - expect(config.config.providerOrder).toContain(selection.providerId); -}); diff --git a/test/context-breakdown.test.ts b/test/context-breakdown.test.ts deleted file mode 100644 index f5a46796..00000000 --- a/test/context-breakdown.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { estimateTranscriptContextBreakdown } from "../packages/zcode-tui/src/context-breakdown.ts"; - -describe("context breakdown estimation", () => { - test("separates retained user, assistant and tool content", () => { - expect(estimateTranscriptContextBreakdown([ - { role: "user", content: "hello" }, - { - role: "agent", - content: "done", - parts: [ - { type: "text", text: "done" }, - { type: "thought", text: "reason" }, - { type: "tool", input: { path: "src/index.ts" }, output: "contents" } - ] - } - ])).toEqual([ - { source: "user_messages", chars: 5 }, - { source: "assistant_messages", chars: 10 }, - { source: "tool_io", chars: 31 } - ]); - }); - - test("accepts wrapped transcript data and ignores unsupported entries", () => { - expect(estimateTranscriptContextBreakdown({ - messages: [null, { role: "system", content: "hidden" }, { role: "user", content: "go" }] - })).toEqual([{ source: "user_messages", chars: 2 }]); - }); -}); diff --git a/test/context-cache.test.ts b/test/context-cache.test.ts deleted file mode 100644 index 7f3afdbc..00000000 --- a/test/context-cache.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - extractContextCacheTrend, - findActiveBranchMessageIds -} from "../packages/zcode-tui/src/context-cache.ts"; - -describe("context cache trend", () => { - test("keeps request gaps and separates active branch from whole tree", () => { - const messages = [ - { info: { id: "u-1", role: "user" } }, - { info: { id: "a-1", parentID: "u-1", role: "assistant", tokens: { input: 100, cache: { read: 80, write: 20 } } } }, - { info: { id: "u-2", role: "user" } }, - { info: { id: "a-2", parentID: "u-2", role: "assistant" } }, - { info: { id: "u-branch", role: "user" } }, - { info: { id: "a-branch", parentID: "u-branch", role: "assistant", tokens: { input: 200, cache: { read: 0, write: 200 } } } } - ]; - const activeIds = findActiveBranchMessageIds(messages); - const trend = extractContextCacheTrend(messages, activeIds); - - expect(trend.turns).toHaveLength(3); - expect(trend.turns[0]).toMatchObject({ state: "hit", hitRate: 0.8, active: false }); - expect(trend.turns[1]).toMatchObject({ state: "unknown", active: false }); - expect(trend.turns[2]).toMatchObject({ state: "miss", hitRate: 0, active: true }); - expect(trend.wholeTree).toMatchObject({ requests: 2, inputTokens: 300, cacheReadTokens: 80 }); - expect(trend.active).toMatchObject({ requests: 1, inputTokens: 200, cacheReadTokens: 0, hitRate: 0 }); - }); - - test("uses step-finish tokens when assistant message tokens are absent", () => { - const trend = extractContextCacheTrend([{ - info: { id: "assistant-1", role: "assistant", tokens: { input: 0, cache: { read: 0, write: 0 } } }, - parts: [{ type: "step-finish", tokens: { input: 160, cache: { read: 120, write: 40 } } }] - }, { - info: { id: "summary-1", role: "assistant", summary: "compacted", tokens: { input: 500, cache: { read: 500, write: 0 } } } - }]); - expect(trend.wholeTree).toMatchObject({ requests: 1, inputTokens: 160, cacheReadTokens: 120, hitRate: 0.75 }); - expect(trend.turns).toHaveLength(1); - expect(trend.turns[0]).toMatchObject({ state: "hit", hitRate: 0.75 }); - }); -}); diff --git a/test/context-status-view.test.ts b/test/context-status-view.test.ts deleted file mode 100644 index 5323b17f..00000000 --- a/test/context-status-view.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - ContextDetailView, - StatusDetailView -} from "../packages/zcode-tui/src/context-status-view.ts"; -import type { ContextCacheTrend } from "../packages/zcode-tui/src/context-cache.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -describe("context and status detail views", () => { - test("renders context breakdown, cache usage and cost", () => { - const output = new ContextDetailView(createTheme(false), { - used: 30_000, - size: 100_000, - breakdown: [ - { source: "messages", chars: 80_000 }, - { source: "system_prompt", chars: 20_000 } - ], - cache: { - inputTokens: 12_000, - cacheReadTokens: 10_000, - latestHitRate: 0.75, - hitRate: 0.99, - hitRateRequestCount: 42, - totalInputTokens: 1_044_000_000, - totalCacheReadTokens: 1_036_000_000, - totalCacheWriteTokens: 0 - }, - cost: { amount: 0.2, currency: "USD" } - }).render(100).join("\n"); - expect(output).toContain("30K / 100K tokens · 30% used"); - expect(output).toContain("70K remaining"); - expect(output).toContain("Messages"); - expect(output).toContain("Latest request"); - expect(output).toContain("75% hit rate"); - expect(output).toContain("Session total"); - expect(output).toContain("42 requests"); - expect(output).toContain("1G read"); - expect(output).toContain("0.2 USD"); - }); - - test("explains when prompt source composition is unavailable", () => { - const output = new ContextDetailView(createTheme(false), { - used: 90_000, - size: 100_000, - breakdown: [] - }).render(80).join("\n"); - expect(output).toContain("90K / 100K tokens · 90% used · 10K remaining"); - expect(output).toContain("Prompt source composition is unavailable"); - }); - - test("switches to the request trend view without losing gaps", () => { - const trend: ContextCacheTrend = { - turns: [ - { index: 1, messageId: "a1", inputTokens: 100, cacheReadTokens: 100, cacheWriteTokens: 0, hitRate: 1, state: "hit", active: true }, - { index: 2, messageId: "a2", state: "unknown", active: true }, - { index: 3, messageId: "a3", inputTokens: 100, cacheReadTokens: 0, cacheWriteTokens: 100, hitRate: 0, state: "miss", active: true } - ], - active: { requests: 2, inputTokens: 200, cacheReadTokens: 100, cacheWriteTokens: 100, hitRate: 0.5, latestHitRate: 0, minHitRate: 0, maxHitRate: 1 }, - wholeTree: { requests: 2, inputTokens: 200, cacheReadTokens: 100, cacheWriteTokens: 100, hitRate: 0.5, latestHitRate: 0, minHitRate: 0, maxHitRate: 1 } - }; - const view = new ContextDetailView(createTheme(false), { - used: 20_000, - size: 100_000, - breakdown: [] - }, trend); - view.handleInput("2"); - const output = view.render(100).join("\n"); - expect(output).toContain("Context cache · per request"); - expect(output).toContain("Recent 8 requests"); - expect(output).toContain("a2"); - expect(output).toContain("cache data unavailable"); - }); - - test("keeps status details separate from the compact statusline", () => { - const output = new StatusDetailView(createTheme(false), { - cliVersion: "3.3.5-1", - version: "1.0.0", - model: "custom/glm", - mode: "build", - effort: "high", - workspace: "/repo", - branch: "main", - metrics: { totalTokens: 18_000, turnCount: 4 }, - goal: { status: "active", tokenBudget: 50_000, tokensUsed: 40_000, timeUsedSeconds: 120 }, - openTodos: 2, - mcpSummary: "2 connected" - }).render(80).join("\n"); - expect(output).toContain("ZCode Status"); - expect(output).toMatch(/CLI version\s+3.3.5-1/); - expect(output).toMatch(/Runtime version\s+1.0.0/); - expect(output).toMatch(/Plan\s+off/); - expect(output).toContain("custom/glm"); - expect(output).toMatch(/Goal\s+Active \(40K \/ 50K\)/u); - expect(output).not.toContain("[ Goal:"); - expect(output).toContain("2 connected"); - }); -}); diff --git a/test/copy-on-select.test.ts b/test/copy-on-select.test.ts deleted file mode 100644 index 416f699d..00000000 --- a/test/copy-on-select.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { - readCopyOnSelect, - writeCopyOnSelect -} from "../packages/zcode-tui/src/copy-on-select.ts"; - -describe("fullscreen copy on select", () => { - test("defaults to enabled and ignores unsupported values", async () => { - const home = await mkdtemp(join(tmpdir(), "zcode-copy-on-select-default-")); - const env = { HOME: home, USERPROFILE: home }; - try { - expect(await readCopyOnSelect(env)).toBe(true); - await Bun.write( - join(home, ".zcode", "cli", "setting.json"), - JSON.stringify({ ui: { copyOnSelect: "disabled" } }) - ); - expect(await readCopyOnSelect(env)).toBe(true); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - test("reads and writes the setting without replacing other config", async () => { - const home = await mkdtemp(join(tmpdir(), "zcode-copy-on-select-test-")); - const configDirectory = join(home, ".zcode", "cli"); - const configPath = join(configDirectory, "setting.json"); - const env = { HOME: home, USERPROFILE: home }; - try { - await mkdir(configDirectory, { recursive: true }); - await Bun.write(configPath, JSON.stringify({ - model: { main: "zai/glm-5.2" }, - ui: { tuiMode: "fullscreen", copyOnSelect: false } - })); - - expect(await readCopyOnSelect(env)).toBe(false); - await writeCopyOnSelect(true, env); - expect(await Bun.file(configPath).json()).toEqual({ - model: { main: "zai/glm-5.2" }, - ui: { tuiMode: "fullscreen", copyOnSelect: true } - }); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); -}); diff --git a/test/darwin-oauth-callback.test.ts b/test/darwin-oauth-callback.test.ts deleted file mode 100644 index 9ab9515e..00000000 --- a/test/darwin-oauth-callback.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { - createDarwinUrlCallbackReceiver, - recoverStaleDarwinOAuthHandler, - type CommandRunner -} from "../src/darwin-oauth-callback.ts"; - -type FailureTarget = - | "read_handler" - | "compile" - | "bundle_id" - | "ui_element" - | "url_types" - | "codesign" - | "launch_services" - | "activate_handler" - | "verify_handler"; - -interface FakeRunnerOptions { - failure?: FailureTarget; - initialHandler?: string; - mismatchOnVerify?: boolean; -} - -function fakeRunner(options: FakeRunnerOptions = {}): CommandRunner { - let currentHandler = options.initialHandler ?? "com.example.previous"; - let handlerReads = 0; - let handlerWrites = 0; - - return async (command, args) => { - let target: FailureTarget | undefined; - if (command === "/usr/bin/osascript") { - const settingHandler = args.length === 6; - if (settingHandler) { - handlerWrites += 1; - target = handlerWrites === 1 ? "activate_handler" : undefined; - if (options.failure !== target) currentHandler = args.at(-1) ?? "none"; - } else { - handlerReads += 1; - target = handlerReads === 1 - ? "read_handler" - : handlerReads === 2 - ? "verify_handler" - : undefined; - } - } else if (command === "/usr/bin/osacompile") { - target = "compile"; - } else if (command === "/usr/bin/plutil") { - if (args[1] === "CFBundleIdentifier") target = "bundle_id"; - if (args[1] === "LSUIElement") target = "ui_element"; - if (args[1] === "CFBundleURLTypes") target = "url_types"; - } else if (command === "/usr/bin/codesign") { - target = "codesign"; - } else if (command.endsWith("/lsregister") && args[0] === "-f") { - target = "launch_services"; - } - - if (target && options.failure === target) { - return { code: 1, stderr: `simulated ${target} failure`, stdout: "" }; - } - if (command === "/usr/bin/osascript") { - if (args.length === 6) return { code: 0, stderr: "", stdout: "0" }; - const handler = options.mismatchOnVerify && handlerReads === 2 - ? "com.example.stale" - : currentHandler; - return { code: 0, stderr: "", stdout: handler }; - } - return { code: 0, stderr: "", stdout: "" }; - }; -} - -async function withTemporaryHome(action: (home: string) => Promise): Promise { - const home = await mkdtemp(join(tmpdir(), "zcode-oauth-test-")); - try { - return await action(home); - } finally { - await rm(home, { recursive: true, force: true }); - } -} - -function createReceiver(home: string, runCommand: CommandRunner) { - const platform = Object.getOwnPropertyDescriptor(process, "platform"); - Object.defineProperty(process, "platform", { ...platform, value: "darwin" }); - try { - return createDarwinUrlCallbackReceiver({ - env: { HOME: home }, - runCommand, - scheme: "zcode" - }); - } finally { - if (platform) Object.defineProperty(process, "platform", platform); - } -} - -describe("native macOS OAuth callback diagnostics", () => { - test("labels every external setup step", async () => { - const cases: Array<[FailureTarget, string]> = [ - ["read_handler", "reading current OAuth callback handler"], - ["compile", "compiling OAuth callback app"], - ["bundle_id", "setting CFBundleIdentifier"], - ["ui_element", "setting LSUIElement"], - ["url_types", "setting CFBundleURLTypes"], - ["codesign", "ad-hoc codesigning callback app"], - ["launch_services", "registering callback app with LaunchServices"], - ["activate_handler", "activating OAuth callback handler"], - ["verify_handler", "verifying OAuth callback handler"] - ]; - - for (const [failure, step] of cases) { - await withTemporaryHome(async (home) => { - try { - await createReceiver(home, fakeRunner({ failure })); - throw new Error(`Expected ${failure} to fail.`); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toStartWith(`${step}: `); - expect((error as Error).cause).toBeInstanceOf(Error); - } - }); - } - }); - - test("labels stale-handler restoration failures", async () => { - await withTemporaryHome(async (home) => { - const appPath = join(home, "Applications", "ZCode CLI OAuth Callback stale.app"); - const recoveryDirectory = join(home, ".zcode", "cli"); - await mkdir(recoveryDirectory, { recursive: true }); - await writeFile(join(recoveryDirectory, "oauth-handler-recovery.json"), JSON.stringify({ - appPath, - bundleId: "dev.zcode.cli.oauth-callback.stale", - pid: process.pid, - previousHandler: "com.example.previous", - scheme: "zcode" - })); - - await expect(recoverStaleDarwinOAuthHandler({ - env: { HOME: home }, - runCommand: fakeRunner({ - failure: "activate_handler", - initialHandler: "dev.zcode.cli.oauth-callback.stale" - }), - scheme: "zcode" - })).rejects.toThrow(/^restoring stale OAuth callback handler: /); - }); - }); - - test("gives actionable handler activation and timeout guidance", async () => { - await withTemporaryHome(async (home) => { - await expect(createReceiver(home, fakeRunner({ mismatchOnVerify: true }))).rejects.toThrow( - /Remove stale `ZCode CLI OAuth Callback \*\.app` entries from `~\/Applications`/ - ); - - const receiver = await createReceiver(home, fakeRunner()); - try { - await expect(receiver.waitForCallback(undefined, 0)).rejects.toThrow( - /remove it before retrying/ - ); - } finally { - await receiver.dispose(); - } - }); - }); -}); diff --git a/test/diff-browser.test.ts b/test/diff-browser.test.ts deleted file mode 100644 index 153e8aee..00000000 --- a/test/diff-browser.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - DiffDetailPage, - diffBrowserSources, - diffFileDescription -} from "../packages/zcode-tui/src/diff-browser.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -const file = { - filePath: "src/index.ts", - additions: 1, - deletions: 1, - structuredPatch: [{ oldStart: 1, newStart: 1, lines: ["-old", "+new"] }], - status: "modified" as const -}; - -describe("diff browser", () => { - test("orders current changes before newest turn sources", () => { - const sources = diffBrowserSources( - { files: [file], truncated: false }, - [{ id: "turn_1", index: 1, prompt: "Change it", files: [file], additions: 1, deletions: 1 }] - ); - expect(sources.map((source) => source.label)).toEqual(["Current changes", "Turn 1"]); - expect(sources[1]?.description).toContain("Change it"); - }); - - test("renders bounded detail pages and file state", () => { - const page = new DiffDetailPage(createTheme(false), file, 0, 2); - expect(page.pageCount(80)).toBeGreaterThan(1); - expect(page.render(80).at(-1)).toContain("Page 1/"); - expect(diffFileDescription({ ...file, isBinary: true })).toContain("binary"); - }); -}); diff --git a/test/dynamic-workflows.test.ts b/test/dynamic-workflows.test.ts deleted file mode 100644 index 552b92a3..00000000 --- a/test/dynamic-workflows.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { expect, test } from "bun:test"; -import { DynamicWorkflows, workflowRunDetail } from "../packages/zcode-tui/src/dynamic-workflows.ts"; -import { patchRuntimeWorkflowReducer } from "../scripts/sync-runtime.ts"; - -test("workflow bridge exposes the native reducer and is idempotent", () => { - const source = 'var init=boot(()=>{label(reduce,"reduceWorkflowRunsState")});function reduce(state,event){return{state,event}};const opts={replayWorkflowRuns:handler.replayWorkflowRuns};'; - const patched = patchRuntimeWorkflowReducer(source); - expect(patchRuntimeWorkflowReducer(patched)).toBe(patched); - let initialized = false; - const options = new Function("boot", "label", "handler", `${patched};return opts`)( - (fn: () => void) => () => { initialized = true; fn(); }, () => {}, {} - ); - expect(options.reduceWorkflowRuns("state", "event")).toEqual({ state: "state", event: "event" }); - expect(initialized).toBeTrue(); - expect(() => patchRuntimeWorkflowReducer("unsupported")).toThrow("incompatible"); -}); - -test("replay precedes live updates and duplicate sequences do not regress progress", async () => { - let release!: (value: unknown) => void; - const sequences: number[] = []; - const workflows = new DynamicWorkflows({ - listWorkflowRuns: async () => [{ runId: "run", label: "Review", status: "stopped", resumable: true }], - replayWorkflowRuns: () => new Promise((resolve) => { release = resolve; }), - reduceWorkflowRuns: (_state, value) => { - const event = value as { sequence: number }; - sequences.push(event.sequence); - return { runs: [{ runId: "run", status: "running", resumable: false }] }; - } - }); - const hydration = workflows.hydrate(); - await Promise.resolve(); - workflows.accept({ runId: "run", sequence: 2, eventType: "run-started" }); - release([{ runId: "run", sequence: 1, eventType: "run-started" }]); - await hydration; - workflows.accept({ runId: "run", sequence: 1, eventType: "run-settled" }); - expect(sequences).toEqual([1, 2]); - expect(workflows.runs()[0]).toMatchObject({ label: "Review", status: "running", resumable: false }); -}); - -test("reset rejects late old-session hydration and exposes recoverable errors", async () => { - let release!: (value: unknown) => void; - const workflows = new DynamicWorkflows({ listWorkflowRuns: () => new Promise((resolve) => { release = resolve; }) }); - const hydration = workflows.hydrate(); - await Promise.resolve(); - workflows.reset(); - release([{ runId: "old", status: "completed" }]); - await hydration; - expect(workflows.runs()).toEqual([]); - const failing = new DynamicWorkflows({ listWorkflowRuns: async () => { throw new Error("Store unavailable"); } }); - await failing.hydrate(); - expect(failing.error).toContain("/workflows to retry"); -}); - -test("a resumed run replaces synthetic interrupted replay at the same sequence", async () => { - const events: string[] = []; - const workflows = new DynamicWorkflows({ - replayWorkflowRuns: async () => [{ runId: "run", sequence: 2, eventType: "run-settled", payload: { resumable: true } }], - reduceWorkflowRuns: (_state, value) => { - const event = value as { eventType: string }; - events.push(event.eventType); - return { runs: [{ runId: "run", status: event.eventType === "run-started" ? "running" : "stopped" }] }; - } - }); - await workflows.hydrate(); - const start = { runId: "run", sequence: 2, eventType: "run-started", payload: {} }; - workflows.accept(start); - workflows.accept(start); - expect(events).toEqual(["run-settled", "run-started"]); - expect(workflows.runs()[0]?.status).toBe("running"); -}); - -test("details use observed counts and sanitize runtime text", () => { - const detail = workflowRunDetail({ runId: "run", label: "Review\x1b[31m", status: "running", - nodes: [{ phase: "settled" }, { phase: "executing" }], usage: { spentTokens: 123 }, - artifacts: [{ title: "report.md" }], actors: [{ name: "Reviewer", status: "working" }] }); - expect(detail).toContain("1/2 observed steps settled"); - expect(detail).toContain("123"); - expect(detail).toContain("report.md"); - expect(detail).not.toContain("\x1b"); -}); diff --git a/test/events.test.ts b/test/events.test.ts deleted file mode 100644 index 64db68c6..00000000 --- a/test/events.test.ts +++ /dev/null @@ -1,597 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - createStreamEventRedeliveryFilter, - isToolCancellation, - historyText, - isModelCancellationEvent, - modelLabel, - normalizeEvent, - restoredMessages -} from "../packages/zcode-tui/src/events.ts"; - -describe("ZCode event adapter", () => { - test("distinguishes a cancelled model request from a real model failure", () => { - const cancelled = normalizeEvent({ - type: "model.network_status", - payload: { - type: "model_request_failed", - errorCode: "model_request_cancelled", - exceptionType: "AbortError", - message: "Model request was cancelled.", - reason: "cancelled", - retryable: false - } - }); - const failed = normalizeEvent({ - type: "model.network_status", - payload: { - type: "model_request_failed", - message: "Provider unavailable.", - reason: "server_error", - retryable: false - } - }); - - expect(cancelled).toMatchObject({ - type: "model_request_failed", - errorCode: "model_request_cancelled", - exceptionType: "AbortError", - reason: "cancelled", - retryable: false - }); - expect(cancelled && isModelCancellationEvent(cancelled)).toBeTrue(); - expect(failed && isModelCancellationEvent(failed)).toBeFalse(); - }); - - test("distinguishes tool cancellation payloads from real tool failures", () => { - expect(isToolCancellation({ - type: "tool_cancelled", - message: "Bash was cancelled and the child process was asked to stop", - code: "TOOL_CANCELLED", - stack: "internal stack" - })).toBeTrue(); - expect(isToolCancellation({ status: "failed", error: { code: "TOOL_CANCELLED" } })).toBeTrue(); - expect(isToolCancellation(new DOMException("Aborted", "AbortError"))).toBeTrue(); - expect(isToolCancellation({ - type: "tool_error", - message: "Command exited with code 1", - code: "COMMAND_FAILED" - })).toBeFalse(); - }); - - test("normalizes protocol streaming events", () => { - expect(normalizeEvent({ - id: "event_stream_delta", - type: "model.streaming", - payload: { - id: "message_entity", - kind: "text_delta", - delta: "你好" - } - })).toMatchObject({ - eventId: "event_stream_delta", - type: "model.streaming", - kind: "text_delta", - delta: "你好" - }); - }); - - test("does not treat a payload message id as a stream event id", () => { - const first = normalizeEvent({ - type: "model.streaming", - payload: { id: "message_1", kind: "text_delta", delta: "hello" } - }); - const second = normalizeEvent({ - type: "model.streaming", - payload: { id: "message_1", kind: "text_delta", delta: " world" } - }); - - expect(first?.eventId).toBeUndefined(); - expect(second?.eventId).toBeUndefined(); - - const filter = createStreamEventRedeliveryFilter(); - expect(filter.duplicate(first!.raw, first!)).toBe(false); - expect(filter.duplicate(second!.raw, second!)).toBe(false); - }); - - test("uses the official assistant message identity and result duration", () => { - expect(normalizeEvent({ - type: "model.streaming", - payload: { - kind: "tool_call", - assistantMessageId: "message_assistant", - toolCallId: "call_1", - toolName: "Bash" - } - })).toMatchObject({ - messageId: "message_assistant", - toolCallId: "call_1" - }); - - expect(normalizeEvent({ - type: "tool_call_result", - payload: { - toolCallId: "call_1", - duration: 1_250, - result: { success: true } - } - })).toMatchObject({ - durationMs: 1_250, - progress: { durationMs: 1_250 } - }); - }); - - test("normalizes app-server envelopes", () => { - expect(normalizeEvent({ - method: "session/event", - params: { - type: "model.streaming", - payload: { - kind: "tool_input_start", - toolName: "Read", - toolCallId: "call_1" - } - } - })).toMatchObject({ - type: "model.streaming", - kind: "tool_input_start", - toolName: "Read", - toolCallId: "call_1" - }); - }); - - test.each([ - { type: "model.streaming", sessionId: "session_child", payload: { kind: "text_delta", delta: "child" } }, - { method: "session/event", params: { type: "model.streaming", sessionID: "session_child", payload: { kind: "text_delta", delta: "child" } } }, - { type: "model.streaming", payload: { sessionId: "session_child", event: { kind: "text_delta", delta: "child" } } }, - { type: "model.streaming", payload: { event: { sessionID: "session_child", kind: "text_delta", delta: "child" } } }, - { type: "part.upserted", payload: { part: { type: "text", sessionId: "session_child", text: "child" } } } - ])("preserves the owning session across event envelopes: %j", (value) => { - expect(normalizeEvent(value)?.sessionId).toBe("session_child"); - }); - - test("keeps parent event ownership separate from child-session metadata", () => { - expect(normalizeEvent({ - type: "part.started", - sessionId: "session_parent", - payload: { - childSessionId: "session_child", - part: { type: "text", sessionId: "session_child", text: "mirrored" } - } - })).toMatchObject({ - sessionId: "session_parent", - childSessionId: "session_child" - }); - expect(normalizeEvent({ - type: "subagent_spawned", - payload: { childSessionId: "session_child" } - })?.sessionId).toBeUndefined(); - }); - - test("normalizes raw autonomous turn lifecycle metadata and failures", () => { - expect(normalizeEvent({ - id: "event_background_start", - type: "turn_started", - turnId: "turn_background", - payload: { - inputSource: "background_task", - originMeta: { workId: "task_background", workIds: ["task_background", "task_two"] } - } - })).toMatchObject({ - eventId: "event_background_start", - type: "turn_started", - turnId: "turn_background", - inputSource: "background_task", - taskId: "task_background", - taskIds: ["task_background", "task_two"] - }); - - expect(normalizeEvent({ - id: "event_background_failure", - type: "turn_error", - turnId: "turn_background", - payload: { error: { message: "Background result failed." } } - })).toMatchObject({ - eventId: "event_background_failure", - type: "turn_error", - turnId: "turn_background", - message: "Background result failed." - }); - - expect(normalizeEvent({ - id: "event_agent_reply", - type: "subagent_message", - payload: { - agentId: "agent_background", - agentType: "reviewer", - childSessionId: "child_background", - status: "failed", - summaryText: "Review failed after the final tool call." - } - })).toMatchObject({ - agentId: "agent_background", - agentType: "reviewer", - childSessionId: "child_background", - taskStatus: "failed", - message: "Review failed after the final tool call." - }); - }); - - test("uses nested runtime model-network event types and retry metadata", () => { - expect(normalizeEvent({ - type: "model.network_status", - payload: { - type: "model_retry_scheduled", - attempt: 1, - nextAttempt: 2, - maxAttempts: 6, - delayMs: 2_000, - retryable: true, - message: "Provider overloaded" - } - })).toMatchObject({ - type: "model_retry_scheduled", - attempt: 1, - nextAttempt: 2, - maxAttempts: 6, - delayMs: 2_000, - retryable: true, - message: "Provider overloaded" - }); - - expect(normalizeEvent({ - type: "model.network_status", - payload: { - type: "model_stream_stalled", - idleMs: 60_000, - timeoutMs: 60_000 - } - })).toMatchObject({ - type: "model_stream_stalled", - idleMs: 60_000, - timeoutMs: 60_000 - }); - - expect(normalizeEvent({ - type: "streamRecovery.updated", - payload: { - streamRecovery: { - retryNumber: 2, - maxRetries: 5 - } - } - })).toMatchObject({ - type: "streamRecovery.updated", - attempt: 2, - maxRetries: 5 - }); - }); - - test("normalizes raw runtime tool lifecycle events", () => { - expect(normalizeEvent({ - type: "tool_call_started", - payload: { - toolCallId: "call_1", - toolName: "Read", - startedAt: 1_752_400_000_000 - } - })).toMatchObject({ - type: "tool_call_started", - kind: "started", - toolCallId: "call_1", - toolName: "Read" - }); - - expect(normalizeEvent({ - type: "tool_call_result", - payload: { - toolCallId: "call_1", - result: { success: true, content: "source text" } - } - })).toMatchObject({ - type: "tool_call_result", - kind: "result", - toolCallId: "call_1", - result: { success: true, content: "source text" } - }); - - expect(normalizeEvent({ - type: "tool_call_error", - payload: { - toolCallId: "call_2", - error: { message: "Command failed" } - } - })).toMatchObject({ - type: "tool_call_error", - kind: "error", - toolCallId: "call_2", - error: { message: "Command failed" } - }); - }); - - test("normalizes the pending and committed identities for active-turn steering", () => { - expect(normalizeEvent({ - method: "session/event", - params: { - type: "turn_started", - turnId: "turn_1", - payload: { inputId: "input_primary" } - } - })).toMatchObject({ - type: "turn_started", - turnId: "turn_1", - inputId: "input_primary" - }); - - expect(normalizeEvent({ - type: "turn_steer_queued", - payload: { - inputId: "input_1", - pendingInputId: "pending_1", - targetTurnId: "turn_1" - } - })).toMatchObject({ - type: "turn_steer_queued", - inputId: "input_1", - pendingInputId: "pending_1", - targetTurnId: "turn_1" - }); - - expect(normalizeEvent({ - type: "turn_steer_drained", - payload: { - pendingInputIds: ["pending_1"], - injectedMessageIds: ["message_steer_1"], - targetTurnId: "turn_1" - } - })).toMatchObject({ - type: "turn_steer_drained", - pendingInputIds: ["pending_1"], - injectedMessageIds: ["message_steer_1"], - targetTurnId: "turn_1" - }); - }); - - test("normalizes subagent lifecycle metadata for the parent tool card", () => { - expect(normalizeEvent({ - type: "subagent_stopped", - payload: { - parentToolCallId: "call_agent", - agentId: "agent_1", - agentType: "explore", - childSessionId: "session_child", - totalToolUseCount: 4, - totalTokens: 2_000, - outputFile: "/tmp/agent.output" - } - })?.progress).toMatchObject({ - parentToolCallId: "call_agent", - agentId: "agent_1", - agentType: "explore", - childSessionId: "session_child", - totalToolUseCount: 4, - totalTokens: 2_000, - outputFile: "/tmp/agent.output" - }); - }); - - test("formats model, history and restored transcript shapes", () => { - expect(modelLabel({ providerId: "zai", modelId: "glm-5" })).toBe("zai/glm-5"); - expect(historyText({ text: "previous prompt" })).toBe("previous prompt"); - expect(restoredMessages([ - { info: { role: "user" }, parts: [{ text: "hello" }] }, - { info: { role: "assistant" }, parts: [{ text: "world" }] } - ])).toEqual([ - { role: "user", parts: [{ type: "text", text: "hello" }] }, - { role: "assistant", parts: [{ type: "text", text: "world" }] } - ]); - expect(restoredMessages([{ - info: { role: "assistant", providerID: "zai", modelID: "glm-5.3-flash" }, - parts: [{ type: "text", text: "latest" }] - }])).toEqual([{ - role: "assistant", - model: "zai/glm-5.3-flash", - parts: [{ type: "text", text: "latest" }] - }]); - }); - - test("preserves official rich transcript parts", () => { - expect(restoredMessages([{ - info: { role: "agent" }, - content: "final answer", - parts: [ - { type: "text", text: "final answer" }, - { type: "thought", text: "reasoning" }, - { - type: "tool", - toolCallId: "call_1", - toolName: "Bash", - input: { command: "bun test" }, - output: "ok", - status: "completed" - } - ] - }])).toEqual([{ - role: "assistant", - parts: [ - { type: "text", text: "final answer" }, - { type: "thought", text: "reasoning" }, - { - type: "tool", - toolCallId: "call_1", - toolName: "Bash", - input: { command: "bun test" }, - output: "ok", - resultDisplay: undefined, - error: undefined, - status: "completed", - title: undefined, - parentToolCallId: undefined, - childToolCallId: undefined, - agentId: undefined, - agentType: undefined, - childSessionId: undefined - } - ] - }]); - }); - - test("reads official nested tool state without losing failures or inputs", () => { - expect(restoredMessages([{ - info: { role: "assistant", messageId: "message_1" }, - parts: [{ - type: "tool", - partId: "part_1", - messageId: "message_1", - sessionId: "session_1", - callId: "call_1", - tool: "Bash", - state: { - status: "error", - input: { command: "false" }, - error: "Command exited with code 1", - startedAt: "2026-07-14T00:00:00Z", - completedAt: "2026-07-14T00:00:01Z" - } - }] - }])).toEqual([{ - messageId: "message_1", - role: "assistant", - parts: [{ - partId: "part_1", - messageId: "message_1", - sessionId: "session_1", - type: "tool", - toolCallId: "call_1", - toolName: "Bash", - input: { command: "false" }, - output: undefined, - resultDisplay: undefined, - error: "Command exited with code 1", - status: "error", - title: undefined, - metadata: undefined, - parentToolCallId: undefined, - childToolCallId: undefined, - agentId: undefined, - agentType: undefined, - childSessionId: undefined - }] - }]); - }); - - test("preserves restored tool relationships from state metadata", () => { - expect(restoredMessages([{ - info: { role: "assistant" }, - parts: [{ - type: "tool", - callId: "child_1", - tool: "Read", - state: { - status: "completed", - metadata: { - parentToolCallId: "agent_1", - agentId: "researcher", - agentType: "explore", - childSessionId: "session_child" - } - } - }] - }])[0]?.parts[0]).toMatchObject({ - type: "tool", - toolCallId: "child_1", - parentToolCallId: "agent_1", - agentId: "researcher", - agentType: "explore", - childSessionId: "session_child" - }); - }); - - test("normalizes official part mutation events", () => { - expect(normalizeEvent({ - type: "part.upserted", - payload: { - part: { - type: "text", - partId: "part_text", - messageId: "message_1", - sessionId: "session_1", - text: "updated" - } - } - })).toMatchObject({ - type: "part.upserted", - partId: "part_text", - messageId: "message_1", - part: { type: "text", text: "updated" } - }); - - expect(normalizeEvent({ - method: "session/event", - params: { - type: "part.delta", - payload: { messageId: "message_1", partId: "part_text", field: "text", delta: "!" } - } - })).toMatchObject({ - type: "part.delta", - messageId: "message_1", - partId: "part_text", - field: "text", - delta: "!" - }); - }); -}); - -describe("stream event redelivery filter", () => { - test("flags an exact redelivery of the same delivered object", () => { - const filter = createStreamEventRedeliveryFilter(); - const delivered = { kind: "text_delta", delta: "방", messageId: "message_1" }; - expect(filter.duplicate(delivered, normalizeEvent(delivered)!)).toBe(false); - expect(filter.duplicate(delivered, normalizeEvent(delivered)!)).toBe(true); - }); - - test("keeps legitimately repeated deltas with identical content", () => { - const filter = createStreamEventRedeliveryFilter(); - const first = normalizeEvent({ kind: "text_delta", delta: "-", messageId: "message_1" })!; - const second = normalizeEvent({ kind: "text_delta", delta: "-", messageId: "message_1" })!; - expect(filter.duplicate({}, first)).toBe(false); - expect(filter.duplicate({}, second)).toBe(false); - }); - - test("flags redeliveries that carry the same eventId in fresh objects", () => { - const filter = createStreamEventRedeliveryFilter(); - expect(filter.duplicate( - {}, - normalizeEvent({ eventId: "event_1", kind: "part.delta", partId: "part_1", field: "text", delta: "!" })! - )).toBe(false); - expect(filter.duplicate( - {}, - normalizeEvent({ eventId: "event_1", kind: "part.delta", partId: "part_1", field: "text", delta: "!" })! - )).toBe(true); - }); - - test("renders a triple-delivered notification-turn stream once per token", () => { - const filter = createStreamEventRedeliveryFilter(); - const tokens = ["방", "금", "완", "료"]; - let rendered = ""; - for (const token of tokens) { - // Each subscriber forwards the same event object, back to back. - const delivered = { kind: "text_delta", delta: token, messageId: "message_1" }; - for (let delivery = 0; delivery < 3; delivery += 1) { - const event = normalizeEvent(delivered)!; - if (!filter.duplicate(delivered, event)) rendered += event.delta; - } - } - expect(rendered).toBe("방금완료"); - }); - - test("forgets the oldest keys once capacity is exceeded", () => { - const filter = createStreamEventRedeliveryFilter(2); - const first = { kind: "text_delta", delta: "a" }; - expect(filter.duplicate(first, normalizeEvent(first)!)).toBe(false); - filter.duplicate({}, normalizeEvent({ kind: "text_delta", delta: "b" })!); - filter.duplicate({}, normalizeEvent({ kind: "text_delta", delta: "c" })!); - expect(filter.duplicate(first, normalizeEvent(first)!)).toBe(false); - }); -}); diff --git a/test/exit-summary.test.ts b/test/exit-summary.test.ts deleted file mode 100644 index fe3a5dd1..00000000 --- a/test/exit-summary.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - buildExitSummary, - formatTokenUsage, - resumeCommand -} from "../packages/zcode-tui/src/exit-summary.ts"; - -describe("TUI exit summary", () => { - test("matches Codex-style elapsed time, token accounting, and resume guidance", () => { - const summary = buildExitSummary({ - elapsedMilliseconds: 105_000, - metrics: { - totalTokens: 417_202, - inputTokens: 411_755, - outputTokens: 5_447, - reasoningTokens: 2_008, - cacheReadTokens: 345_600 - }, - sessionId: "sess_019f5575", - width: 80 - }); - - expect(summary.divider).toStartWith("─ Worked for 1m 45s ─"); - expect(summary.divider).toHaveLength(80); - expect(summary.tokenUsage).toBe( - "Token usage: total=71,602 input=66,155 (+ 345,600 cached) output=5,447 (reasoning 2,008)" - ); - expect(summary.resumeCommand).toBe("zcode --resume sess_019f5575"); - }); - - test("omits unavailable values without inventing metrics", () => { - expect(buildExitSummary({ - elapsedMilliseconds: 30_000, - metrics: {}, - sessionId: "sess_resume", - width: 40 - })).toEqual({ - divider: undefined, - tokenUsage: undefined, - resumeCommand: "zcode --resume sess_resume" - }); - expect(formatTokenUsage({ totalTokens: 0 })).toBeUndefined(); - }); - - test("quotes unusual session identifiers and rejects terminal control text", () => { - expect(resumeCommand("session with space")).toBe("zcode --resume='session with space'"); - expect(resumeCommand("line\nbreak")).toBeUndefined(); - expect(resumeCommand("\x1b[31m\x1b[0m")).toBeUndefined(); - expect(resumeCommand("sess_\x1b[31munsafe\x1b[0m")).toBeUndefined(); - }); -}); diff --git a/test/file-diff-budget.test.ts b/test/file-diff-budget.test.ts deleted file mode 100644 index 341b68dd..00000000 --- a/test/file-diff-budget.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - boundedFileDiffs, - fileDiffRetentionSize, - MAX_RETAINED_DIFF_CHARACTERS, - MAX_RETAINED_DIFF_FILES, - MAX_RETAINED_DIFF_LINES -} from "../packages/zcode-tui/src/file-diff-budget.ts"; -import type { FileDiffData } from "../packages/zcode-tui/src/file-diff-view.ts"; - -function diff(file: number, lines = 100, width = 200): FileDiffData { - return { - filePath: `src/file_${file}.ts`, - additions: lines, - deletions: 0, - structuredPatch: [{ - header: `@@ file ${file} @@`, - oldStart: 0, - oldLines: 0, - newStart: 1, - newLines: lines, - lines: Array.from({ length: lines }, (_, index) => `+${file}:${index}:${"x".repeat(width)}`) - }] - }; -} - -describe("retained file diff budget", () => { - test("leaves ordinary diffs untouched", () => { - const source = [diff(1, 2, 20)]; - expect(boundedFileDiffs(source)).toBe(source); - }); - - test("bounds files, lines, and characters while keeping visible prefixes and stats", () => { - const source = Array.from({ length: 40 }, (_, index) => diff(index)); - const retained = boundedFileDiffs(source); - const size = fileDiffRetentionSize(retained); - - expect(size.files).toBeLessThanOrEqual(MAX_RETAINED_DIFF_FILES); - expect(size.lines).toBeLessThanOrEqual(MAX_RETAINED_DIFF_LINES); - expect(size.characters).toBeLessThanOrEqual(MAX_RETAINED_DIFF_CHARACTERS); - expect(retained[0]).toMatchObject({ - filePath: "src/file_0.ts", - additions: 100, - deletions: 0 - }); - expect(retained[0]?.structuredPatch[0]?.lines[0]).toBe(`+0:0:${"x".repeat(200)}`); - expect(retained.some((file) => file.truncated)).toBeTrue(); - }); - - test("enforces the file-count limit independently of line and character limits", () => { - const retained = boundedFileDiffs( - Array.from({ length: 40 }, (_, index) => diff(index, 1, 1)) - ); - - expect(retained).toHaveLength(MAX_RETAINED_DIFF_FILES); - expect(retained.at(-1)?.truncated).toBeTrue(); - }); - - test("clips a final line without splitting a surrogate pair", () => { - const source: FileDiffData[] = [{ - filePath: "a.ts", - additions: 1, - deletions: 0, - structuredPatch: [{ lines: ["+prefix 👨‍👩‍👧‍👦 suffix"] }] - }]; - const retained = boundedFileDiffs(source, { characters: 15, files: 1, lines: 1 }); - const line = retained[0]?.structuredPatch[0]?.lines[0] ?? ""; - - expect(fileDiffRetentionSize(retained).characters).toBeLessThanOrEqual(15); - expect(retained[0]?.truncated).toBeTrue(); - expect(line).not.toMatch(/[\uD800-\uDBFF]$/u); - }); -}); diff --git a/test/file-diff-view.test.ts b/test/file-diff-view.test.ts deleted file mode 100644 index 79432725..00000000 --- a/test/file-diff-view.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { - fileDiffCard, - fileDiffsForPermission, - fileDiffsForTool, - FileDiffView, - wordDiffLines -} from "../packages/zcode-tui/src/file-diff-view.ts"; -import { - fileDiffRetentionSize, - MAX_RETAINED_DIFF_CHARACTERS, - MAX_RETAINED_DIFF_FILES, - MAX_RETAINED_DIFF_LINES -} from "../packages/zcode-tui/src/file-diff-budget.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; -import { toolCard } from "../packages/zcode-tui/src/tool-view.ts"; - -const officialDisplay = { - kind: "file_diff", - filePath: "packages/zcode-tui/src/events.ts", - additions: 2, - deletions: 1, - structuredPatch: [{ - oldStart: 20, - oldLines: 3, - newStart: 20, - newLines: 4, - lines: [ - " function normalizeEvent(value: unknown) {", - "- kind: asString(body.kind),", - "+ const type = asString(value.type);", - "+ kind: asString(body.kind) ?? runtimeToolKind(type),", - " }" - ] - }] -}; - -describe("TUI file diff view", () => { - test("marks changed words independently inside paired diff lines", () => { - const themed = wordDiffLines("const value = 1;", "const value = 20;", createTheme(true)); - expect(themed.removed).toContain("\x1b[1;38;5;231;48;5;88m1"); - expect(themed.added).toContain("\x1b[1;38;5;231;48;5;28m20"); - }); - - test("renders official file_diff displays with Pierre-style gutters", () => { - const card = toolCard({ - name: "Edit", - state: "complete", - input: { - file_path: "packages/zcode-tui/src/events.ts", - old_string: "kind: asString(body.kind)", - new_string: "kind: asString(body.kind) ?? runtimeToolKind(type)" - }, - result: { success: true, display: officialDisplay } - }); - - expect(card).toContain("✓ Edit packages/zcode-tui/src/events.ts +2 -1"); - expect(card).toContain("@@ -20,3 +20,4 @@"); - expect(card).toContain("21 │- kind: asString(body.kind),"); - expect(card).toContain(" 21 │+ const type = asString(value.type);"); - expect(card).not.toContain("old_string"); - expect(card).not.toContain('"display"'); - }); - - test("parses official ApplyPatch patch_text including multiple files", () => { - const patchText = [ - "*** Begin Patch", - "*** Update File: src/app.ts", - "@@ -1,2 +1,2 @@", - "-const oldValue = 1;", - "+const newValue = 2;", - " export default newValue;", - "*** Add File: src/new.ts", - "+export const created = true;", - "*** End Patch" - ].join("\n"); - const diffs = fileDiffsForTool("ApplyPatch", { patch_text: patchText }, undefined, "running"); - const card = fileDiffCard({ toolName: "ApplyPatch", state: "running", diffs }); - - expect(diffs).toHaveLength(2); - expect(card).toContain("● ApplyPatch src/app.ts +1 -1"); - expect(card).toContain("↳ src/new.ts +1 -0"); - expect(card).toContain("│- const oldValue = 1;"); - expect(card).toContain("│+ const newValue = 2;"); - expect(card).not.toContain("*** Begin Patch"); - }); - - test("renders successful new Write content as additions", () => { - const card = toolCard({ - name: "Write", - state: "complete", - input: { file_path: "src/new.ts", content: "export const one = 1;\nexport const two = 2;\n" }, - result: { success: true } - }); - - expect(card).toContain("✓ Write src/new.ts +2 -0"); - expect(card).toContain("│+ export const one = 1;"); - expect(card).toContain("│+ export const two = 2;"); - }); - - test("uses distinct added, removed, and hunk backgrounds", () => { - const view = new FileDiffView(createTheme(true), { - toolName: "Edit", - state: "complete", - diffs: [officialDisplay] - }); - const rendered = view.render(72).join("\n"); - - expect(rendered).toContain("\x1b[38;5;120;48;5;22m"); - expect(rendered).toContain("\x1b[38;5;210;48;5;52m"); - expect(rendered).toContain("\x1b[38;5;159;48;5;24m"); - }); - - test("uses readable pastel diff colors on light terminals", () => { - const view = new FileDiffView(createTheme(true, "light"), { - toolName: "Edit", - state: "complete", - diffs: [officialDisplay] - }); - const rendered = view.render(72).join("\n"); - - expect(rendered).toContain("\x1b[38;5;22;48;5;194m"); - expect(rendered).toContain("\x1b[38;5;88;48;5;224m"); - expect(rendered).toContain("\x1b[38;5;24;48;5;189m"); - }); - - test("uses an explicit readable foreground for diff headers on light terminals", () => { - const view = new FileDiffView(createTheme(true, "light"), { - toolName: "Edit", - state: "complete", - diffs: [officialDisplay] - }); - const rendered = view.render(120).join("\n"); - - expect(rendered).toContain("\x1b[1;38;5;236mEdit"); - expect(rendered).toContain("\x1b[1;38;5;236mpackages/zcode-tui/src/events.ts"); - }); - - test("wraps CJK changes within narrow terminals", () => { - const view = new FileDiffView(createTheme(false), { - toolName: "Edit", - state: "complete", - diffs: [{ - filePath: "src/中文.ts", - additions: 1, - deletions: 0, - structuredPatch: [{ - oldStart: 1, - oldLines: 0, - newStart: 1, - newLines: 1, - lines: ["+这是一个需要在窄终端中正确换行的中文代码修改"] - }] - }] - }); - const lines = view.render(28); - - expect(lines.join("\n")).toContain("中文.ts"); - expect(lines.every((line) => visibleWidth(line) <= 28)).toBe(true); - }); - - test("caches repeated renders at the same width until invalidated", () => { - const view = new FileDiffView(createTheme(false), { - toolName: "Edit", - state: "complete", - diffs: [{ - filePath: "src/app.ts", - additions: 1, - deletions: 1, - structuredPatch: [{ - oldStart: 1, - oldLines: 1, - newStart: 1, - newLines: 1, - lines: ["-const value = 1;", "+const value = 2;"] - }] - }] - }); - - const first = view.render(80); - expect(view.render(80)).toBe(first); - - const wider = view.render(120); - expect(wider).not.toBe(first); - expect(view.render(120)).toBe(wider); - expect(view.render(120).join("\n")).toContain("src/app.ts"); - - view.invalidate(); - expect(view.render(120)).not.toBe(wider); - }); - - test("bounds very large diffs", () => { - const lines = Array.from({ length: 200 }, (_, index) => `+line ${index + 1}`); - const card = fileDiffCard({ - toolName: "Write", - state: "complete", - diffs: [{ - filePath: "large.ts", - additions: lines.length, - deletions: 0, - structuredPatch: [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines }] - }] - }); - - expect(card).toContain("… diff truncated"); - expect(card).not.toContain("line 200"); - }); - - test("distinguishes retained truncation from an expandable viewport preview", () => { - const diffs = fileDiffsForTool( - "Write", - { - file_path: "large.ts", - content: Array.from({ length: 2_100 }, (_, index) => `line ${index}`).join("\n") - }, - { success: true }, - "complete" - ); - const collapsed = new FileDiffView(createTheme(false), { - toolName: "Write", - state: "complete", - diffs, - expanded: false - }).render(80).join("\n"); - const expanded = new FileDiffView(createTheme(false), { - toolName: "Write", - state: "complete", - diffs, - expanded: true - }).render(80).join("\n"); - - expect(collapsed).toContain("Ctrl+O to expand retained preview"); - expect(expanded).toContain("diff truncated at retention limit"); - expect(expanded).not.toContain("Ctrl+O to expand"); - }); - - test("applies one retained budget to every mutation diff source", () => { - const added = Array.from({ length: 2_100 }, (_, index) => `new line ${index}`).join("\n"); - const removed = Array.from({ length: 2_100 }, (_, index) => `old line ${index}`).join("\n"); - const patch = [ - "*** Begin Patch", - "*** Update File: src/patch.ts", - "@@ -1,2100 +1,2100 @@", - ...removed.split("\n").map((line) => `-${line}`), - ...added.split("\n").map((line) => `+${line}`), - "*** End Patch" - ].join("\n"); - const officialLines = added.split("\n").map((line) => `+${line}`); - const cases = [ - fileDiffsForTool( - "Write", - { file_path: "src/write.ts", content: added }, - { success: true }, - "complete" - ), - fileDiffsForPermission("Edit", { - file_path: "src/edit.ts", - old_string: removed, - new_string: added - }), - fileDiffsForTool("ApplyPatch", { patch_text: patch }, undefined, "running"), - fileDiffsForTool("Edit", {}, { - display: { - kind: "file_diff", - filePath: "src/official.ts", - additions: 2_100, - deletions: 0, - structuredPatch: [{ lines: officialLines }] - } - }, "complete") - ]; - - for (const diffs of cases) { - const size = fileDiffRetentionSize(diffs); - expect(size.files).toBeLessThanOrEqual(MAX_RETAINED_DIFF_FILES); - expect(size.lines).toBeLessThanOrEqual(MAX_RETAINED_DIFF_LINES); - expect(size.characters).toBeLessThanOrEqual(MAX_RETAINED_DIFF_CHARACTERS); - expect(diffs.some((file) => file.truncated)).toBeTrue(); - } - expect(cases[0]?.[0]?.additions).toBe(2_100); - expect(cases[1]?.[0]).toMatchObject({ additions: 2_100, deletions: 2_100 }); - }); -}); diff --git a/test/fixtures/runtime-subagents.ts b/test/fixtures/runtime-subagents.ts deleted file mode 100644 index 28826f95..00000000 --- a/test/fixtures/runtime-subagents.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { RuntimeSessionInfo, RuntimeSubagent, RuntimeSubagentQueries, RuntimeTask, RuntimeTuiApp } from "../../src/runtime-tui-bridge.ts"; - -// Stub only the native projection. Real runtime semantics are covered by the -// Node integration fixture in subagent-restoration.cjs. -export const fixtureSubagentQueries: RuntimeSubagentQueries = { - collectChildSessionIds: (_session, messages) => (messages as RuntimeSubagent[]).map((agent) => agent.childSessionId), - projectChildEvents: (events) => ({ events }), - projectSubagents: ({ messages, childSessionsById }) => { - const agents = (messages as RuntimeSubagent[]).filter((agent) => childSessionsById.has(agent.childSessionId)); - return { - running: agents.filter((agent) => agent.status === "running"), - ended: agents.filter((agent) => agent.status !== "running") - }; - } -}; - -export function subagentRestoreFixture(status = "success") { - const agent: RuntimeSubagent = { - agentId: "agent", childSessionId: "child", toolCallId: "call", - subagentType: "Explore", title: "Review", status, startedAt: 10, endedAt: 20 - }; - const sessions = new Map([ - ["parent", { id: "parent" }], - ["child", { id: "child", parentID: "parent", taskType: "subagent_child" }] - ]); - const tasks = new Map(); - const reminders: string[] = []; - const app: RuntimeTuiApp = { - sessionId: "parent", readExecutionState: () => ({}), - runtime: { - sessionStore: { - getSession: async (id) => sessions.get(id) ?? null, - messages: async ({ sessionID }) => sessionID === "parent" ? [agent] : [] - }, - getSessionEventStore: () => ({ getEvents: async () => [] }), - runtimeTaskRegistry: { - get: (id) => tasks.get(id), register: (task) => tasks.set(task.taskId, task) - }, - messageHistory: { addAttachment: (_source, text) => reminders.push(text) } - } - }; - return { app, agent, sessions, tasks, reminders }; -} diff --git a/test/fixtures/subagent-restoration.cjs b/test/fixtures/subagent-restoration.cjs deleted file mode 100644 index 8e12e13b..00000000 --- a/test/fixtures/subagent-restoration.cjs +++ /dev/null @@ -1,103 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const Module = require("node:module"); -const path = require("node:path"); -// Matrix jobs restore the release tarball, which contains the bridge in vendor. -const { restoreTuiBackgroundTasks } = require("../../vendor/cli-config.cjs"); - -// Load the actual extracted query functions without starting the CLI or a model. -const file = path.resolve(__dirname, "../../vendor/zcode.cjs"); -let source = fs.readFileSync(file, "utf8"); -const main = /async function [A-Za-z_$][\w$]*\(\)\{let [A-Za-z_$][\w$]*=process\.argv\.slice\(2\);/u.exec(source); -assert.ok(main, "Missing native runtime test entry"); -source = source.replace(main[0], `${main[0]}module.exports=${process.argv[2]};return;`); -const runtime = new Module(file, module); -runtime.filename = file; -runtime.paths = Module._nodeModulePaths(path.dirname(file)); -runtime._compile(source, file); -const queries = runtime.exports; -assert.equal(typeof queries.projectSubagents, "function"); - -function fixture({ outcome = "success", events = [], plainText = false, reverted = false, foreign = false } = {}) { - const childId = "sess_subagent_agent_1"; - const parent = { id: "parent", time: { created: 1, updated: 20 }, - ...(reverted ? { revert: { targetMessageID: "msg-launch", keptMessageIDs: ["msg-before"] } } : {}) }; - const child = { id: childId, parentID: foreign ? "other" : "parent", taskType: "subagent_child", time: { created: 10, updated: 20 } }; - const parentMessages = [ - { info: { id: "msg-before", sessionID: "parent", role: "user", time: { created: 1 } }, parts: [] }, - { info: { id: "msg-launch", sessionID: "parent", role: "assistant", time: { created: 10 } }, parts: [{ - type: "tool", tool: "Agent", callID: "call-1", - state: { status: "completed", time: { start: 10, end: 11 }, - input: { subagent_type: "Explore", description: "Review", run_in_background: true }, - output: plainText ? "Async agent launched successfully.\nagentId: agent_1 (use SendMessage to continue)" - : JSON.stringify({ status: "async_launched", agentId: "agent_1", childSessionId: childId, agentType: "Explore" }) - } - }] } - ]; - const childMessages = outcome === "running" ? [] : [{ - info: { id: "msg-result", sessionID: childId, role: "assistant", time: { created: 15, completed: 20 }, - ...(outcome === "failed" ? { error: { name: "ProviderError", data: { message: "Provider failed" } } } - : outcome === "cancelled" ? { error: { name: "AbortError", data: { message: "Cancelled" } } } : {}) }, - parts: [{ type: "text", text: "Review finished" }] - }]; - const tasks = new Map(); - const app = { - sessionId: "parent", readExecutionState: () => ({}), - loadSessionTranscript: async () => { throw new Error("Legacy transcript must not be used"); }, - runtime: { - sessionStore: { - getSession: async (id) => id === "parent" ? parent : id === childId ? child : null, - messages: async ({ sessionID }) => sessionID === "parent" ? parentMessages : childMessages - }, - getSessionEventStore: () => ({ getEvents: async (id) => id === "parent" ? events : [] }), - runtimeTaskRegistry: { get: (id) => tasks.get(id), register: (task) => tasks.set(task.taskId, task) } - } - }; - return { app, tasks, parentMessages }; -} - -async function run() { - for (const [outcome, status] of [["success", "completed"], ["failed", "failed"], ["cancelled", "stopped"], ["running", "running"]]) { - const { app, tasks } = fixture({ outcome }); - await restoreTuiBackgroundTasks(app, queries); - assert.equal(tasks.size, 1, `cold resume: ${outcome}`); - const task = tasks.get("agent_1"); - assert.equal(task.status, status, `cold resume: ${outcome}`); - assert.equal(task.childSessionId, "sess_subagent_agent_1"); - assert.equal(task.parentToolCallId, "call-1"); - assert.equal(task.agentType, "Explore"); - if (outcome === "success") assert.equal(task.output.content[0].text, "Review finished", "TaskOutput retains the native result summary"); - } - // Preserve the fixed runtime's read-model behavior for plain-text launch ACKs. - // It currently reports a failed cold child as completed; status interpretation - // stays upstream-owned rather than reintroducing local output parsing. - const plainText = fixture({ outcome: "failed", plainText: true }); - await restoreTuiBackgroundTasks(plainText.app, queries); - assert.equal(plainText.tasks.get("agent_1")?.status, "completed"); - const activeChild = fixture({ outcome: "running" }); - activeChild.app.runtime.getSessionEventStore = () => ({ getEvents: async (id) => id === "parent" ? [] : [{ - type: "turn_started", sessionId: id, timestamp: new Date(10), payload: { input: "Continue" } - }] }); - await restoreTuiBackgroundTasks(activeChild.app, queries); - assert.equal(activeChild.tasks.get("agent_1")?.status, "running", "live child events use the native reducer"); - for (const options of [{ foreign: true }, { reverted: true }]) { - const { app, tasks } = fixture(options); - await restoreTuiBackgroundTasks(app, queries); - assert.equal(tasks.size, 0, JSON.stringify(options)); - } - const parentEvents = [ - { type: "subagent_spawned", sessionId: "parent", timestamp: new Date(10), payload: { - agentId: "agent_1", childSessionId: "sess_subagent_agent_1", parentToolCallId: "call-1", agentType: "Explore" - } }, - { type: "subagent_stopped", sessionId: "parent", timestamp: new Date(20), payload: { - agentId: "agent_1", childSessionId: "sess_subagent_agent_1", parentToolCallId: "call-1", status: "failed" - } } - ]; - const live = fixture({ outcome: "running", events: parentEvents }); - live.parentMessages[1].parts[0].state.output = "Launch acknowledged"; - await restoreTuiBackgroundTasks(live.app, queries); - assert.equal(live.tasks.get("agent_1")?.status, "failed", "lifecycle events belong to the parent"); - process.stdout.write("Native subagent restoration passed: cold outcomes, active branch, ownership, parent events.\n"); -} - -run().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/test/fixtures/tui-clear.ts b/test/fixtures/tui-clear.ts deleted file mode 100644 index 49e7bbbd..00000000 --- a/test/fixtures/tui-clear.ts +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bun -// Minimal runTui fixture for the /clear and /cls smoke check. - -import { runTui } from "../../packages/zcode-tui/src/index.ts"; - -const sessionTranscript = [ - { messageId: "message_startup", role: "user", content: "Restored startup prompt." }, - { messageId: "message_startup_reply", role: "agent", content: "Restored startup response." }, - { messageId: "message_later", role: "user", content: "Restored later prompt." }, - { messageId: "message_later_reply", role: "agent", content: "Restored later response." } -]; - -await runTui({ - model: "alpha/model", - modelOptions: [{ alias: "main", id: "alpha/model", name: "Alpha" }], - slashCommands: [{ name: "new", summary: "Start a fresh session." }], - loadSessionTranscript: async () => sessionTranscript, - submitPrompt: async (input) => { - if (input === "/clear") { - return { response: "Runtime handled /clear as a new session.", sessionId: "sess_new" }; - } - return { response: `Echo: ${String(input)}` }; - }, - stdout: process.stdout, - stderr: process.stderr, - stdin: process.stdin -} as Parameters[0]); diff --git a/test/fixtures/tui-features.ts b/test/fixtures/tui-features.ts deleted file mode 100644 index 4d516d35..00000000 --- a/test/fixtures/tui-features.ts +++ /dev/null @@ -1,1155 +0,0 @@ -#!/usr/bin/env bun - -import { join } from "node:path"; - -import { runTui } from "../../packages/zcode-tui/src/index.ts"; -import type { PromptCallOptions } from "../../packages/zcode-tui/src/types.ts"; - -if (process.argv[2] === "login") { - if (!process.argv.includes("--oauth")) { - console.error("Error: explicit Z.AI login did not force OAuth"); - process.exit(1); - } - if (process.env.ZCODE_FIXTURE_LOGIN_FAIL === "1") { - console.error("Error: OAuth HTTP error 404 (empty or non-JSON response)"); - process.exit(1); - } - await import("./tui-login-override.ts"); - process.exit(0); -} - -let model = "alpha/model"; -let effort = "low"; -let defaultModel = "alpha/model"; -let backgroundStatus = "running"; -let agentStatus = "failed"; -let agentMessageCount = 0; -let timerTaskStatus: "failed" | "running" | undefined; -let turnCompleted = false; -let featureTurnActive = false; -let stuckBackgroundHandoff = false; -let escOwnedStatus: "cancelled" | "running" | undefined; -let escDeclinedStatus: "completed" | "running" | undefined; -let escUnrelatedStatus: "completed" | "running" | undefined; -const backgroundCancellationAttempts: string[] = []; -let coordinatorNoticeCount = 0; -let sessionEventListener: ((event: unknown) => void | Promise) | undefined; -let featureSteerInput: string | undefined; -let featurePendingInputId: string | undefined; -let resolveFeatureSteer!: () => void; -const featureSteerReceived = new Promise((resolve) => { - resolveFeatureSteer = resolve; -}); -let resolveFeatureTurnFinished!: () => void; -const featureTurnFinished = new Promise((resolve) => { - resolveFeatureTurnFinished = resolve; -}); -let permissionMode = "build", planEnabled = false; -let goal = { - status: "active", - tokenBudget: 50_000, - tokensUsed: 40_000, - timeUsedSeconds: 120 -}; - -// Model status rows are replaced in place and cleared once a request completes, -// so the smoke test needs a window to observe each step. -const statusObservationMilliseconds = 400; - -const assistantResponse = [ - "Feature prompt complete.", - "", - "```mermaid", - "graph TD", - "Input[用户输入] --> Editor[编辑器面板]", - "```" -].join("\n"); - -const planTodos = [ - { content: "Map runtime events", status: "completed", priority: "high" }, - { content: "Render plan updates", status: "completed", priority: "high" }, - { content: "Verify the TUI", status: "in_progress", priority: "medium" } -]; -const approvalPlan = [ - "# Implementation Plan", - "", - ...Array.from({ length: 36 }, (_, index) => ( - `${index + 1}. Complete detailed implementation step ${index + 1}.` - )) -].join("\n"); - -let sessionTranscript = [ - { messageId: "message_startup", role: "user", content: "Restored startup prompt." }, - { messageId: "message_startup_reply", role: "agent", content: "Restored startup response." }, - { messageId: "message_later", role: "user", content: "Restored later prompt." }, - { messageId: "message_later_reply", role: "agent", content: "Restored later response." } -]; - -const workflowPanel = (status = "running") => ({ - title: "/workflows", - selectedRunId: "run_feature", - updatedAt: "2026-07-13T10:00:00Z", - runs: [{ - runId: "run_feature", - status, - task: "Feature workflow", - kind: "script", - updatedAt: "2026-07-13T10:00:00Z" - }], - detail: { - snapshot: { - runId: "run_feature", - status, - task: "Feature workflow", - kind: "script", - updatedAt: "2026-07-13T10:00:00Z" - }, - scheduler: { active: status === "running" ? 1 : 0, completed: status === "running" ? 0 : 1, total: 1 }, - events: [{ timestamp: "2026-07-13T10:00:00Z", type: "phase.started", status }] - } -}); - -async function emit(options: PromptCallOptions, event: unknown): Promise { - await options.onEvent?.({ type: "model.streaming", payload: event }); -} - -async function emitRuntime(options: PromptCallOptions, type: string, payload: unknown): Promise { - await options.onEvent?.({ type, payload }); -} - -async function emitSessionEvent( - event: unknown, - submissionListener?: (event: unknown) => void | Promise -): Promise { - await sessionEventListener?.(event); - await submissionListener?.(event); -} - -async function emitChildSessionActivity(options: PromptCallOptions): Promise { - const childSessionId = "session_feature_child"; - for (const sessionId of [childSessionId, "session_feature_child_two"]) { - await emitSessionEvent({ - type: "model.streaming", - sessionId, - payload: { kind: "text_delta", delta: "RAW_CHILD_TEXT_129", messageId: `${sessionId}_message` } - }, options.onEvent); - } - await emitSessionEvent({ - type: "tool_call_started", - sessionId: childSessionId, - payload: { - toolCallId: "raw_child_bash_129", - toolName: "Bash", - input: { command: "printf RAW_CHILD_TOOL_129" } - } - }, options.onEvent); - await emitSessionEvent({ - type: "tool_call_result", - sessionId: childSessionId, - payload: { toolCallId: "raw_child_bash_129", toolName: "Bash", result: "RAW_CHILD_TOOL_129" } - }, options.onEvent); - await emitSessionEvent({ - type: "part.started", - payload: { - part: { - type: "text", - sessionId: childSessionId, - partId: "raw_child_part_129", - messageId: "raw_child_message_129", - text: "RAW_CHILD_PART_129" - } - } - }, options.onEvent); - await emitSessionEvent({ - type: "background_task_completed", - sessionId: childSessionId, - payload: { taskId: "RAW_CHILD_NESTED_TASK_129", status: "completed" } - }, options.onEvent); - await emitSessionEvent({ - type: "tool_call_started", - sessionId: "feature-session", - payload: { - toolCallId: "call_task_output_129", - toolName: "TaskOutput", - input: { task_id: "agent_feature", block: true } - } - }, options.onEvent); - await emitSessionEvent({ - type: "tool_call_result", - sessionId: "feature-session", - payload: { - toolCallId: "call_task_output_129", - toolName: "TaskOutput", - childSessionId, - result: { - success: true, - display: { - kind: "task_output", - retrievalStatus: "success", - taskStatus: "completed", - output: "Compact child result preserved." - } - } - } - }, options.onEvent); - await Bun.sleep(80); -} - -async function emitBackgroundResultTurn( - turnId: string, - eventPrefix: string, - text: string, - inputSource: "background_task" | "subagent_message", - duplicateText = false, - submissionListener?: (event: unknown) => void | Promise -): Promise { - await emitSessionEvent({ - id: `${eventPrefix}_start`, type: "turn_started", turnId, - payload: { inputSource } - }, submissionListener); - const textEvent = { - id: `${eventPrefix}_text`, type: "model.streaming", turnId, - payload: { - kind: "text_delta", - delta: text, - messageId: `${eventPrefix}_message`, - partId: `${eventPrefix}_part` - } - }; - await emitSessionEvent(textEvent, submissionListener); - if (duplicateText) await emitSessionEvent(textEvent, submissionListener); - await emitSessionEvent({ - id: `${eventPrefix}_complete`, type: "turn_complete", turnId, payload: {} - }, submissionListener); -} - -async function runBackgroundCompletionTurn( - submissionListener?: (event: unknown) => void | Promise -): Promise { - await emitSessionEvent({ - id: "event_agent_reply", - type: "subagent_message", - payload: { - agentId: "agent_feature", - agentType: "reviewer", - message: "Background agent reply stored in task activity." - } - }, submissionListener); - await emitBackgroundResultTurn( - "turn_agent_handoff", - "event_agent_handoff", - "Task-scoped agent handoff completed.", - "subagent_message", - true, - submissionListener - ); - await emitSessionEvent({ - id: "event_background_task_failed", - type: "background_task_completed", - payload: { taskId: "agent_feature", taskKind: "local_agent", status: "failed" } - }, submissionListener); - await emitSessionEvent({ - id: "event_background_failure_start", type: "turn_started", turnId: "turn_background_failure", - payload: { inputSource: "background_task", originMeta: { workId: "agent_feature" } } - }, submissionListener); - await emitSessionEvent({ - id: "event_background_failure_reasoning", type: "model.streaming", turnId: "turn_background_failure", - payload: { kind: "reasoning_delta", delta: "Background-only reasoning must stay in task activity." } - }, submissionListener); - await emitSessionEvent({ - id: "event_background_failure_tool", type: "model.streaming", turnId: "turn_background_failure", - payload: { kind: "tool_input_start", toolCallId: "background_fetch", toolName: "Fetch" } - }, submissionListener); - await emitSessionEvent({ - id: "event_background_failure_text", type: "model.streaming", turnId: "turn_background_failure", - payload: { kind: "text_delta", delta: "Coordinator began processing the failed task result." } - }, submissionListener); - await Bun.sleep(400); - await emitSessionEvent({ - id: "event_background_failure", type: "turn_error", turnId: "turn_background_failure", - payload: { error: { message: "Background result failed visibly." } } - }, submissionListener); -} - -await runTui({ - version: "feature-smoke", - theme: process.env.ZCODE_TUI_TEST_THEME, - workspaceDirectory: process.cwd(), - initialMode: "build", - initialModel: model, - initialThoughtLevel: effort, - modelOptions: [ - { alias: "main", id: "alpha/model", name: "Alpha" }, - { alias: "lite", id: "beta/model", name: "Beta" } - ], - readDefaultModel: async () => defaultModel, - setDefaultModel: async (modelId: string) => { defaultModel = model = modelId; return { model: modelId }; }, - setTransientModel: async (modelId: string) => { - model = modelId; - return { model: modelId }; - }, - effortOptions: [ - { id: "low", label: "Low" }, - { id: "high", label: "High" } - ], - slashCommands: [ - { name: "model", description: "Select model" }, - { name: "effort", description: "Select effort" }, - { name: "login", description: "Configure model access" }, - { name: "mcp", description: "Manage MCP" }, - { name: "workflows", description: "Manage workflows" }, - { name: "goal", description: "Manage the session goal" } - ], - loadSessionTranscript: async () => sessionTranscript, - readClipboardImage: async () => ({ - dataUrl: "data:image/png;base64,aGVsbG8=", - mediaType: "image/png", - sizeBytes: 5 - }), - listMcpServers: async () => ({ - docs: { status: "disconnected", transport: "stdio", toolCount: 2 } - }), - listWorkspacePathSuggestions: async ({ token, abortSignal }) => { - if (abortSignal?.aborted) return { items: [], truncated: false }; - return token === "@ind" - ? { items: [{ kind: "file" as const, path: "src/index.ts" }], truncated: false } - : { items: [], truncated: false }; - }, - listSkills: async () => ({ - skills: [{ name: "audit", description: "Review technical quality." }] - }), - refreshWorkflowPanel: async () => workflowPanel(), - stopWorkflow: async () => workflowPanel("cancelled"), - readGoal: async () => goal, - readTodos: async () => planTodos, - readRuntimeProjection: async () => ({ - id: "feature-session", - status: "idle", - mode: permissionMode, - planEnabled, - turnCount: 4, - totalTokenCount: 18_500, - contextUsed: 32_000, - contextWindow: 128_000, - contextUsage: { - used: 32_000, - size: 128_000, - cache: { inputTokens: 12_000, cacheReadTokens: 9_000, cacheWriteTokens: 1_000, latestHitRate: 0.75 }, - breakdown: [ - { source: "system_prompt", chars: 12_000 }, - { source: "skills", chars: 4_000 }, - { source: "system_tool_schemas", chars: 8_000 }, - { source: "messages", chars: 40_000 } - ] - }, - activeToolCalls: [], - backgroundTasks: [ - { - taskId: "bg_feature", - taskKind: "bash", - toolName: "Bash", - description: turnCompleted ? "Feature background audit · turn complete" : "Feature background audit", - command: "bun test", - status: backgroundStatus, - cancellable: true, - pid: 4242, - startedAt: Date.now() - 5_000, - stdoutBytes: 512, - stdoutTail: "Background audit running" - }, - { - taskId: "agent_feature", - toolName: "Agent", - description: "Review task recovery", - status: agentStatus, - cancellable: agentStatus === "running", - startedAt: Date.now() - 4_000 - }, - ...(timerTaskStatus ? [{ - taskId: "timer_feature", - toolName: "Agent", - description: "Verify aggregate task timing", - status: timerTaskStatus, - cancellable: timerTaskStatus === "running", - startedAt: Date.now() - 1_000 - }] : []), - ...(escOwnedStatus ? [{ - taskId: "esc_owned", - toolName: "Agent", - description: "Current turn background agent", - status: escOwnedStatus, - cancellable: escOwnedStatus === "running", - startedAt: Date.now() - 800 - }] : []), - ...(escDeclinedStatus ? [{ - taskId: "esc_declined", - toolName: "Agent", - description: "Already settled background agent", - status: escDeclinedStatus, - cancellable: escDeclinedStatus === "running", - startedAt: Date.now() - 700 - }] : []), - ...(escUnrelatedStatus ? [{ - taskId: "esc_unrelated", - toolName: "Agent", - description: "Unrelated background agent", - status: escUnrelatedStatus, - cancellable: escUnrelatedStatus === "running", - startedAt: Date.now() - 600 - }] : []) - ], - backgroundTaskDetails: [ - { - taskId: "agent_feature", - taskKind: "local_agent", - agentId: "agent_feature", - agentType: "reviewer", - childSessionId: "child_feature", - parentSessionId: "feature-session", - turnId: "turn_feature", - prompt: "Review the recovery path and report findings.", - error: agentStatus === "failed" ? "The first attempt lost its provider connection." : undefined, - status: agentStatus - }, - ...(timerTaskStatus ? [{ - taskId: "timer_feature", - taskKind: "local_agent", - agentId: "timer_feature", - agentType: "reviewer", - childSessionId: "child_timer_feature", - parentSessionId: "feature-session", - turnId: "turn_timer_feature", - prompt: "Verify aggregate task timing.", - error: timerTaskStatus === "failed" ? "Expected timer fixture failure." : undefined, - status: timerTaskStatus - }] : []), - ...(escOwnedStatus ? [{ - taskId: "esc_owned", - taskKind: "local_agent", - agentId: "esc_owned", - agentType: "reviewer", - childSessionId: "child_esc_owned", - parentSessionId: "feature-session", - turnId: "turn_esc_ownership", - status: escOwnedStatus - }] : []), - ...(escDeclinedStatus ? [{ - taskId: "esc_declined", - taskKind: "local_agent", - agentId: "esc_declined", - agentType: "reviewer", - childSessionId: "child_esc_declined", - parentSessionId: "feature-session", - turnId: "turn_esc_ownership", - status: escDeclinedStatus - }] : []), - ...(escUnrelatedStatus ? [{ - taskId: "esc_unrelated", - taskKind: "local_agent", - agentId: "esc_unrelated", - agentType: "reviewer", - childSessionId: "child_esc_unrelated", - parentSessionId: "feature-session", - turnId: "turn_older_unrelated", - status: escUnrelatedStatus - }] : []) - ] - }), - cancelBackgroundTask: async (taskId) => { - backgroundCancellationAttempts.push(taskId); - if (taskId === "bg_feature") { - backgroundStatus = "cancelled"; - return { cancelled: true, status: backgroundStatus, taskId }; - } - if (taskId === "esc_owned") { - escOwnedStatus = "cancelled"; - return { cancelled: true, status: escOwnedStatus, taskId }; - } - if (taskId === "esc_declined") { - escDeclinedStatus = "completed"; - return { - cancelled: false, - reason: "background_task_not_running", - status: escDeclinedStatus, - taskId - }; - } - throw new Error(`Unexpected background task: ${taskId}`); - }, - sendBackgroundTaskMessage: async ({ taskId, message, summary, restart }) => { - if (taskId !== "agent_feature") throw new Error(`Unexpected agent task: ${taskId}`); - agentMessageCount += 1; - const expectedMessage = agentMessageCount === 1 - ? "Continue the assigned task from the last completed step. Re-check the current workspace state and finish the remaining work." - : "Restart from the saved state and finish the remaining verification."; - if (message !== expectedMessage) throw new Error(`Unexpected agent message: ${message}`); - if (summary !== message) throw new Error(`Unexpected agent summary: ${summary}`); - if (restart !== (agentMessageCount === 2)) { - throw new Error(`Unexpected restart flag for agent message ${agentMessageCount}.`); - } - agentStatus = "running"; - return { - status: "success", - delivery: "resumed_background", - taskId, - message: restart - ? `Agent "${taskId}" restarted in the background.` - : `Agent "${taskId}" resumed in the background.` - }; - }, - interruptTurn: async ({ pendingInputIds, reason, reservationId, waitForIdle }) => { - if (reason === "TUI interrupted the active foreground turn.") { - if (pendingInputIds !== undefined || reservationId !== undefined || waitForIdle !== undefined) { - throw new Error("Foreground interrupt carried background-handoff options."); - } - return { kind: "stopped", foregroundExecutionId: "fixture-esc-ownership" }; - } - if (!stuckBackgroundHandoff) throw new Error("Unexpected background handoff interrupt."); - if (pendingInputIds?.length !== 0 - || reason !== "User input preempted background result processing." - || !reservationId?.startsWith("background_handoff_") - || waitForIdle !== true) { - throw new Error("Background handoff interrupt did not request an idle runtime boundary."); - } - stuckBackgroundHandoff = false; - return { kind: "stopped", foregroundExecutionId: "fixture-background-handoff" }; - }, - previewFileRewind: async (targetMessageIds) => { - if (!targetMessageIds.every((messageId) => sessionTranscript.some((message) => message.messageId === messageId))) { - throw new Error(`Unexpected rewind preview targets: ${targetMessageIds.join(", ")}`); - } - return { - canApply: true, - safeFiles: [{ path: "src/rewind-fixture.ts", action: "restore", operationCount: 1, toolNames: ["Edit"] }], - unsafeFiles: [], - ignoredFiles: [{ path: "tmp/bash-output.txt", reason: "bash_ignored", operationCount: 1, toolNames: ["Bash"] }] - }; - }, - applyFileRewind: async (targetMessageIds) => { - if (!targetMessageIds.includes("message_later") || !targetMessageIds.includes("message_later_reply")) { - throw new Error(`Unexpected file rewind targets: ${targetMessageIds.join(", ")}`); - } - return { applied: true, response: "Rewound fixture workspace files." }; - }, - readSessionResumeTarget: async () => "feature-session", - readSessionUsage: async () => ({ - totalTokens: 18_500, - inputTokens: 14_000, - outputTokens: 4_000, - reasoningTokens: 500, - cacheCreationTokens: 0, - cacheReadTokens: 9_000, - modelRequestCount: 3, - modelErrorCount: 0 - }), - subscribeSessionEvents: (listener) => { - sessionEventListener = listener; - return () => { - if (sessionEventListener === listener) sessionEventListener = undefined; - }; - }, - sendInput: async (input, options) => { - const prompt = typeof input === "object" && input !== null ? input as Record : {}; - const promptText = typeof input === "string" ? input : prompt.text; - if (featureTurnActive) { - if (options.delivery !== "steer_active_turn") { - throw new Error(`Active-turn input used unexpected delivery mode: ${String(options.delivery)}`); - } - if (options.queueDelivery !== "guide") { - throw new Error(`Active steer used unexpected queue delivery: ${String(options.queueDelivery)}`); - } - if (promptText === "Edit this rejected steer.") { - if (options.expectedTurnId !== "turn_feature") { - throw new Error(`Rejected steer used unexpected expected turn: ${String(options.expectedTurnId)}`); - } - return { - kind: "rejected", - activeTurnId: "turn_feature", - reason: "turn_not_steerable" - }; - } - if (promptText !== "Keep the final response concise.") { - throw new Error(`Unexpected active-turn steer: ${String(promptText)}`); - } - if (options.expectedTurnId !== "turn_feature") { - throw new Error(`Active steer used unexpected expected turn: ${String(options.expectedTurnId)}`); - } - if (!options.pendingInputId) throw new Error("Active steer did not provide a stable pending input ID."); - featureSteerInput = promptText; - featurePendingInputId = options.pendingInputId; - await emitRuntime(options, "turn_steer_queued", { - input: promptText, - inputId: options.inputId, - pendingInputId: featurePendingInputId, - queueLength: 1, - targetTurnId: "turn_feature" - }); - resolveFeatureSteer(); - await featureTurnFinished; - await Bun.sleep(50); - return { - kind: "queued", - pendingInputId: featurePendingInputId, - queueLength: 1, - turnId: "turn_feature" - }; - } - if (options.delivery !== "start_turn") { - throw new Error(`Idle input used unexpected delivery mode: ${String(options.delivery)}`); - } - if (typeof promptText === "string" && promptText.startsWith("Background task agent_feature")) { - const expectedLead = coordinatorNoticeCount === 0 - ? "Background task agent_feature (Review task recovery) was resumed from the task center" - : "Background task agent_feature (Review task recovery) was restarted from the task center"; - const expectedInstruction = coordinatorNoticeCount === 0 - ? "Continue the assigned task from the last completed step. Re-check the current workspace state and finish the remaining work." - : "Restart from the saved state and finish the remaining verification."; - if (!promptText.startsWith(expectedLead) - || !promptText.includes(`\"${expectedInstruction}\"`) - || !promptText.includes("TaskOutput with block=true")) { - throw new Error(`Unexpected coordinator notice: ${promptText}`); - } - coordinatorNoticeCount += 1; - return { - kind: "started_turn", - result: { turnId: `turn_task_coordinator_${coordinatorNoticeCount}` } - }; - } - if (promptText === "verify Esc task ownership") { - escOwnedStatus = "running"; - escDeclinedStatus = "running"; - escUnrelatedStatus = "running"; - await emitRuntime(options, "turn_started", { - inputId: options.inputId, - turnId: "turn_esc_ownership" - }); - for (const taskId of ["esc_owned", "esc_declined"]) { - await emitRuntime(options, "background_task_started", { - taskId, - taskKind: "local_agent", - status: "running", - turnId: "turn_esc_ownership" - }); - } - await emit(options, { - kind: "text_delta", - delta: "Esc ownership turn running.\n" - }); - await new Promise((_resolve, reject) => { - const abort = () => reject(options.abortSignal?.reason ?? new DOMException("Aborted", "AbortError")); - if (options.abortSignal?.aborted) abort(); - else options.abortSignal?.addEventListener("abort", abort, { once: true }); - }); - } - if (promptText === "verify aggregate timer") { - escUnrelatedStatus = "completed"; - timerTaskStatus = "running"; - await emitRuntime(options, "turn_started", { - inputId: options.inputId, - turnId: "turn_timer_feature" - }); - await emitRuntime(options, "background_task_started", { - taskId: "timer_feature", - taskKind: "local_agent", - status: "running", - turnId: "turn_timer_feature" - }); - setTimeout(() => { - timerTaskStatus = "failed"; - void emitSessionEvent({ - id: "event_timer_task_failed", - type: "background_task_updated", - turnId: "turn_timer_feature", - payload: { - taskId: "timer_feature", - taskKind: "local_agent", - status: "failed" - } - }); - }, 3_500).unref?.(); - return { - kind: "started_turn", - result: { - response: "Timer foreground complete; background still running.", - model, - thoughtLevel: effort - } - }; - } - if (promptText === "Run this after the active turn.") { - stuckBackgroundHandoff = true; - await emitSessionEvent({ - id: "event_stuck_background_handoff", - type: "turn_started", - turnId: "turn_stuck_background_handoff", - payload: { inputSource: "background_task" } - }, options.onEvent); - return { - kind: "started_turn", - result: { - response: "Queued follow-up started after the active turn.", - model, - thoughtLevel: effort - } - }; - } - if (promptText === "Continue after the stuck background handoff.") { - if (stuckBackgroundHandoff) throw new Error("Stuck background handoff was not interrupted."); - return { - kind: "started_turn", - result: { - response: "Queued input started after interrupting the stuck background handoff.", - model, - thoughtLevel: effort - } - }; - } - if (promptText === "review long plan" || promptText === "review plan feedback") { - if (!options.requestPermission) throw new Error("Plan approval callback is unavailable."); - const approval = await options.requestPermission({ - input: { plan: approvalPlan }, - toolCallId: "call_exit_plan", - toolName: "ExitPlanMode" - }, { abortSignal: options.abortSignal }); - const decision = typeof approval === "object" && approval !== null - ? String((approval as Record).decision ?? "unknown") - : "unknown"; - const reasonSource = typeof approval === "object" && approval !== null - ? (approval as Record).reasonSource - : undefined; - return { - kind: "started_turn", - result: { - response: `Plan approval fixture complete: ${decision}${reasonSource ? ` · ${String(reasonSource)}` : ""}.`, - model, - thoughtLevel: effort - } - }; - } - const attachments = Array.isArray(prompt.attachments) ? prompt.attachments : []; - const image = attachments[0] as Record | undefined; - if ( - typeof promptText !== "string" || - !promptText.startsWith("Use the skill named `audit` for this turn.\n") || - !promptText.includes("First call the `Skill` tool with name `audit` before doing the task.\n") || - !promptText.endsWith("User request:\ninspect @src/index.ts $audit") || - image?.type !== "image" || - typeof image.content !== "string" - ) { - throw new Error("Feature smoke prompt did not include the selected file, skill and image attachment."); - } - featureTurnActive = true; - await emitRuntime(options, "turn_started", { - inputId: options.inputId, - turnId: "turn_feature" - }); - await emitRuntime(options, "model.network_status", { - type: "model_request_started", - attempt: 1, - maxAttempts: 6 - }); - await emitRuntime(options, "model.network_status", { - type: "model_stream_stalled", - attempt: 1, - maxAttempts: 6, - idleMs: 60_000, - timeoutMs: 60_000, - message: "Model stream stalled: no event received for 60000ms." - }); - // The status row is replaced in place and cleared once a request completes, - // so keep each step observable for the smoke assertions. - await Bun.sleep(statusObservationMilliseconds); - await emitRuntime(options, "model.network_status", { - type: "model_request_failed", - attempt: 1, - maxAttempts: 6, - retryable: true, - message: "Model stream idle timeout." - }); - await emitRuntime(options, "model.network_status", { - type: "model_retry_scheduled", - attempt: 1, - nextAttempt: 2, - maxAttempts: 6, - delayMs: 1_000, - retryable: true, - message: "Retrying after model stream idle timeout." - }); - await Bun.sleep(statusObservationMilliseconds); - await emitRuntime(options, "model.network_status", { - type: "model_request_started", - attempt: 2, - maxAttempts: 6 - }); - await emitRuntime(options, "model.network_status", { - type: "model_request_completed", - attempt: 2, - maxAttempts: 6 - }); - const interleavedModelBlocks = [ - { reasoning: "Inspecting ", text: "我会检查 vercel-l" }, - { reasoning: "the repository ", text: "abs/native 与 Compose Desktop" }, - { reasoning: "before using tools.", text: " 的 WebView 路径。" } - ]; - for (const [index, block] of interleavedModelBlocks.entries()) { - const reasoningPartId = `stream_reasoning_${index}`; - const textPartId = `stream_text_${index}`; - await emit(options, { kind: "reasoning_start", partId: reasoningPartId }); - await emit(options, { kind: "reasoning_delta", partId: reasoningPartId, delta: block.reasoning }); - await emit(options, { kind: "reasoning_end", partId: reasoningPartId }); - await emit(options, { kind: "text_start", partId: textPartId }); - await emit(options, { kind: "text_delta", partId: textPartId, delta: block.text }); - await emit(options, { kind: "text_end", partId: textPartId }); - } - await emit(options, { kind: "tool_input_start", toolCallId: "call_plan", toolName: "TodoWrite" }); - await emit(options, { - kind: "tool_input_delta", - toolCallId: "call_plan", - delta: JSON.stringify({ todos: planTodos }) - }); - await emit(options, { - kind: "tool_call", - toolCallId: "call_plan", - toolName: "TodoWrite", - input: { todos: planTodos } - }); - await emitRuntime(options, "tool_call_scheduled", { - toolCallId: "call_plan", - toolName: "TodoWrite", - input: { todos: planTodos } - }); - await emitRuntime(options, "tool_call_started", { - toolCallId: "call_plan", - toolName: "TodoWrite", - startedAt: Date.now() - }); - await emitRuntime(options, "tool_call_result", { - toolCallId: "call_plan", - result: { success: true, output: { todos: planTodos } } - }); - await emitRuntime(options, "part.started", { - part: { - type: "text", - partId: "part_commentary", - messageId: "message_assistant", - text: "I will inspect the repository first." - } - }); - const readPart = { - type: "tool", - partId: "part_read", - messageId: "message_assistant", - callId: "call_read", - tool: "Read", - state: { status: "running", input: { file_path: "demo.ts" } } - }; - await emitRuntime(options, "part.started", { part: readPart }); - await emitRuntime(options, "part.upserted", { - part: { - ...readPart, - state: { status: "completed", input: { file_path: "demo.ts" }, output: "source text" } - } - }); - const agentPart = { - type: "tool", - partId: "part_agent", - messageId: "message_assistant", - callId: "call_agent", - tool: "Agent", - state: { - status: "running", - input: { - agentType: "explore", - description: "Inspect nested rendering", - prompt: "Read child.ts", - run_in_background: true - } - } - }; - await emitRuntime(options, "part.started", { part: agentPart }); - await emitRuntime(options, "part.started", { - part: { - type: "reasoning", - partId: "part_agent_coordination", - messageId: "message_assistant", - text: "Coordinator dispatching background research; this belongs in Tasks." - } - }); - await emitRuntime(options, "subagent_spawned", { - parentToolCallId: "call_agent", - agentId: "agent_feature", - agentType: "explore", - childSessionId: "session_feature_child", - description: "Inspect nested rendering" - }); - await emitChildSessionActivity(options); - const childPart = { - type: "tool", - partId: "part_child_fetch", - messageId: "message_assistant", - callId: "call_child_fetch", - tool: "Fetch", - state: { - status: "running", - input: { url: "https://github.com/example/background-research" }, - metadata: { parentToolCallId: "call_agent" } - } - }; - await emitRuntime(options, "part.started", { part: childPart }); - await emitRuntime(options, "part.upserted", { - part: { - ...childPart, - state: { - ...childPart.state, - status: "completed", - output: "Background research result" - } - } - }); - await emitRuntime(options, "subagent_stopped", { - parentToolCallId: "call_agent", - agentId: "agent_feature", - agentType: "explore", - childSessionId: "session_feature_child", - totalToolUseCount: 1, - totalTokens: 800, - outputFile: "/tmp/agent-feature.output" - }); - await emitRuntime(options, "part.upserted", { - part: { - ...agentPart, - state: { - ...agentPart.state, - status: "completed", - output: { - status: "completed", - agentId: "agent_feature", - agentType: "explore", - prompt: "Read child.ts", - content: [{ type: "text", text: "Nested rendering inspected." }], - totalToolUseCount: 1, - totalDurationMs: 900, - totalTokens: 800 - } - } - } - }); - const editInput = { - file_path: "demo.ts", - old_string: "const value = 1;", - new_string: "const value = 2;" - }; - const editPart = { - type: "tool", - partId: "part_edit", - messageId: "message_assistant", - callId: "call_edit", - tool: "Edit", - state: { status: "running", input: editInput } - }; - await emitRuntime(options, "part.started", { part: editPart }); - await emitRuntime(options, "part.upserted", { - part: { - ...editPart, - state: { - status: "completed", - input: editInput, - output: "Updated demo.ts", - metadata: { - display: { - kind: "file_diff", - filePath: "demo.ts", - additions: 1, - deletions: 1, - structuredPatch: [{ - oldStart: 1, - oldLines: 1, - newStart: 1, - newLines: 1, - lines: ["-const value = 1;", "+const value = 2;"] - }] - } - } - } - } - }); - await Promise.race([featureSteerReceived, Bun.sleep(8_000)]); - if (!featureSteerInput) throw new Error("Feature smoke did not receive active-turn steering."); - if (!featurePendingInputId) throw new Error("Feature smoke did not retain the pending input ID."); - await Bun.sleep(150); - await emitRuntime(options, "turn_steer_drained", { - injectedMessageIds: ["message_steer"], - pendingInputIds: [featurePendingInputId], - targetTurnId: "turn_feature" - }); - await emit(options, { kind: "reasoning_delta", delta: "Synthesizing the final response." }); - await emitRuntime(options, "part.started", { - part: { - type: "text", - partId: "part_final", - messageId: "message_assistant", - text: assistantResponse - } - }); - await Bun.sleep(1_100); - featureTurnActive = false; - turnCompleted = true; - resolveFeatureTurnFinished(); - await runBackgroundCompletionTurn(options.onEvent); - return { - kind: "started_turn", - result: { - response: assistantResponse, - model, - thoughtLevel: effort, - projection: { - contextUsed: 32_000, - contextWindow: 128_000, - totalTokenCount: 19_400, - turnCount: 4 - } - } - }; - }, - submitPrompt: async (input) => { - if (typeof input !== "string") return { response: "Unexpected structured slash command." }; - if (input.startsWith("/rewind cascade conversation ")) { - const targetMessageId = input.slice("/rewind cascade conversation ".length); - const targetIndex = sessionTranscript.findIndex((message) => message.messageId === targetMessageId); - if (targetIndex < 0) throw new Error(`Unexpected conversation rewind target: ${targetMessageId}`); - sessionTranscript = sessionTranscript.slice(0, targetIndex); - return { response: `Rewound conversation before ${targetMessageId}.`, model, thoughtLevel: effort }; - } - if (input === "/login") { - return { - response: "Choose how to configure model access.", - selection: { - title: "Set Up Coding Plan", - prompt: "Choose a setup method.", - items: [ - { - command: "/login zai-coding-plan-api-key", - id: "zai-coding-plan-api-key", - primary: "Z.AI Coding Plan API Key", - secondary: "Paste an API key manually.", - input: { - cancelStatus: "API key entry cancelled.", - emptyStatus: "API key is required.", - help: "Enter saves the key. Esc cancels.", - mask: true, - placeholder: "Paste API key", - primary: "Enter Z.AI Coding Plan API Key", - secondary: "The key is hidden while typing.", - submitStatus: "Saving API key..." - } - }, - { - command: "/login zai-coding-plan", - id: "zai-coding-plan", - primary: "Z.AI Coding Plan", - secondary: "Run the official browser OAuth flow.", - pending: { - cancelStatus: "Login cancelled.", - help: "Esc cancels.", - primary: "Waiting for Z.AI authorization", - secondary: "Complete sign-in in the browser.", - status: "Waiting for browser authorization..." - } - } - ] - } - }; - } - if (input === "/login zai-coding-plan-api-key feature-secret-api-key") { - const override = join(import.meta.dir, "tui-login-override.ts").replaceAll("'", "'\\''"); - process.env.ZCODE_TUI_LOGIN_CMD = `'${process.execPath}' '${override}'`; - return { - loginRequired: false, - model, - response: "Configured Z.AI Coding Plan." - }; - } - if (input === "/resume") { - return { - response: "Select a session to resume.", - selection: { - title: "Resume Session", - prompt: "Choose a session to resume.", - items: [{ - command: "/resume fixture-session", - id: "fixture-session", - primary: "Fixture session", - secondary: "fixture-session" - }] - } - }; - } - if (input === "/disable-login-override") { - delete process.env.ZCODE_TUI_LOGIN_CMD; - return { response: "Login override disabled.", model, thoughtLevel: effort }; - } - if (input === "/prepare-failing-login") { - process.env.ZCODE_FIXTURE_LOGIN_FAIL = "1"; - return { response: "Failing login prepared.", model, thoughtLevel: effort }; - } - if (input === "/resume fixture-session") { - return { - resetSessionProjection: true, - restoredMessages: [ - { role: "user", content: "Restored selected prompt." }, - { role: "agent", content: "Restored selected response." } - ], - response: "Resumed session fixture-session.", - model, - thoughtLevel: effort - }; - } - if (input === "/help") { - return { - response: [ - "Slash commands:", - ...Array.from({ length: 36 }, (_, index) => `- /fixture-${index + 1}: Feature command ${index + 1}.`), - "", - "Use /help for details." - ].join("\n"), - model, - thoughtLevel: effort - }; - } - if (input.startsWith("/model ")) { - model = input.slice("/model ".length); - return { - response: `Model switched to ${model}.`, - mode: permissionMode, - planEnabled, - model, - thoughtLevel: effort, - effortOptions: [{ id: "low", label: "Low" }, { id: "high", label: "High" }] - }; - } - if (input.startsWith("/effort ")) { - effort = input.slice("/effort ".length); - return { response: `Reasoning effort switched to ${effort}.`, mode: permissionMode, - planEnabled, model, thoughtLevel: effort }; - } - if (input === "/mcp connect docs") return { response: "MCP connected: docs." }; - if (input === "/workflows") return { response: "", workflowPanel: workflowPanel() }; - if (input === "/goal pause") { - goal = { ...goal, status: "paused" }; - return { response: "Goal paused.", model, thoughtLevel: effort }; - } - return { response: `Handled ${input}.`, model, thoughtLevel: effort }; - }, - readExecutionState: async () => ({ mode: permissionMode, planEnabled }), - setPlanEnabled: async (enabled) => { planEnabled = enabled; return { mode: permissionMode, planEnabled }; }, - setMode: async (nextMode) => { permissionMode = nextMode; return { mode: permissionMode, planEnabled }; } -}); - -if (process.env.ZCODE_TUI_FEATURE_SMOKE === "1") { - if (coordinatorNoticeCount !== 2) { - throw new Error(`Expected two coordinator notices, received ${coordinatorNoticeCount}.`); - } - if (backgroundCancellationAttempts.join(",") !== "esc_owned,esc_declined") { - throw new Error(`Unexpected Esc cancellation attempts: ${backgroundCancellationAttempts.join(",")}`); - } -} diff --git a/test/fixtures/tui-focus.ts b/test/fixtures/tui-focus.ts deleted file mode 100644 index e192f218..00000000 --- a/test/fixtures/tui-focus.ts +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bun - -export {}; - -if (process.argv[2] === "login") { - console.log("Focus fixture login finished."); - process.exit(0); -} - -const entry = new URL(process.argv[2] === "built" - ? "../../packages/zcode-tui/dist/index.js" - : "../../packages/zcode-tui/src/index.ts", import.meta.url); -const { runTui } = await import(entry.href) as typeof import("../../packages/zcode-tui/src/index.ts"); - -await runTui({ - version: "focus-smoke", - workspaceDirectory: process.cwd(), - initialMode: "build", - initialModel: "alpha/model", - initialThoughtLevel: "low", - submitPrompt: async (input, options) => { - if (input === "/focus-prompt") { - return { - selection: { - title: "Focus test prompts", - prompt: "Choose an input.", - items: [false, true].map((mask) => ({ - command: mask ? "/focus-secret" : "/focus-text", - primary: mask ? "Masked input" : "Text input", - input: { - primary: mask ? "Focus masked prompt" : "Focus text prompt", - secondary: "Enter a fixture value.", - placeholder: mask ? "Secret placeholder" : "Text placeholder", - mask - } - })) - } - }; - } - if (input === "/focus-stream") { - for (const text of ["Stream focus", " still running", " finished."]) { - await Bun.sleep(120); - await options.onEvent?.({ - type: "model.streaming", - payload: { type: "text-delta", textDelta: text } - }); - } - return { response: "Stream focus still running finished." }; - } - return { response: "Focus fixture response." }; - } -}); diff --git a/test/fixtures/tui-fullscreen-layout.ts b/test/fixtures/tui-fullscreen-layout.ts deleted file mode 100644 index 41511c30..00000000 --- a/test/fixtures/tui-fullscreen-layout.ts +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bun - -import { runTui } from "../../packages/zcode-tui/src/index.ts"; - -const response = Array.from({ length: 80 }, (_, index) => `transcript line ${index + 1}`).join("\n"); - -await runTui({ - version: "fullscreen-layout-smoke", - workspaceDirectory: process.cwd(), - initialMode: "build", - initialModel: "alpha/model", - initialThoughtLevel: "low", - loginRequired: true, - modelOptions: [{ id: "alpha/model", name: "Alpha" }], - effortOptions: [{ id: "low", label: "Low" }], - writeClipboardText: process.env.ZCODE_TUI_TEST_CLIPBOARD_PATH - ? async (text) => { - await Bun.write(process.env.ZCODE_TUI_TEST_CLIPBOARD_PATH!, text); - } - : undefined, - submitPrompt: async () => ({ response, model: "alpha/model", thoughtLevel: "low" }) -}); diff --git a/test/fixtures/tui-fullscreen.ts b/test/fixtures/tui-fullscreen.ts deleted file mode 100644 index 042bf441..00000000 --- a/test/fixtures/tui-fullscreen.ts +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bun - -// Minimal fixture that boots ZCodeTui in fullscreen mode via the -// ZCODE_TUI_MODE environment variable. The smoke script -// (scripts/smoke-tui-fullscreen.ts) asserts that TuiAltScreen enters the -// alternate screen (DEC private mode 1049). The fixture exits immediately -// after the editor becomes interactive so the smoke layer can observe the -// startup escape sequence. - -import { runTui } from "../../packages/zcode-tui/src/index.ts"; - -let model = "alpha/model"; -let effort = "low"; - -await runTui({ - version: "fullscreen-smoke", - workspaceDirectory: process.cwd(), - initialMode: "build", - initialModel: model, - initialThoughtLevel: effort, - modelOptions: [ - { alias: "main", id: "alpha/model", name: "Alpha" }, - { alias: "lite", id: "beta/model", name: "Beta" } - ], - effortOptions: [ - { id: "low", label: "Low" }, - { id: "high", label: "High" } - ], - listWorkspacePathSuggestions: async ({ token }) => { - await Bun.sleep(120); - return token === "@ind" - ? { items: [{ kind: "file" as const, path: "src/index.ts" }], truncated: false } - : { items: [], truncated: false }; - }, - submitPrompt: async () => ({ response: "ok", model, thoughtLevel: effort }) -}); diff --git a/test/fixtures/tui-login-model.ts b/test/fixtures/tui-login-model.ts deleted file mode 100644 index 209d0c6a..00000000 --- a/test/fixtures/tui-login-model.ts +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bun - -import { runTui } from "../../packages/zcode-tui/src/index.ts"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -// Like the runtime, the login writes the default model and credentials to disk, -// but the provider registry only knows the new model after it is reloaded. -const model = "account:bigmodel-individual-coding-plan/GLM-5.3"; -let loggedIn = false; -let reloaded = false; -let sessionModel: string | undefined; - -delete process.env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE; -delete process.env.ZCODE_CLI_CREDENTIALS_FILE; -delete process.env.ZCODE_DATA_BASE_DIR; - -async function writeLogin(): Promise { - const directory = join(process.env.HOME!, ".zcode", "v2"); - await mkdir(directory, { recursive: true }); - await writeFile(join(directory, "provider_config.json"), JSON.stringify({ - schemaVersion: 1, - config: { - providerConfigRules: { providerRules: [] }, - modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] }, - defaultModelSelection: { providerId: "account:bigmodel-individual-coding-plan", modelId: "GLM-5.3" } - } - }), "utf8"); - await writeFile(join(directory, "credentials.json"), JSON.stringify({ - "account-provider:account:bigmodel-individual-coding-plan:identity": "identity", - "account-provider:coding-plan:account:bigmodel-individual-coding-plan:account:identity:api-key": "api-key" - }), "utf8"); -} - -await runTui({ - loginRequired: true, - modelOptions: [], - reloadModelOptions: async () => { - reloaded = loggedIn; - return reloaded ? [{ id: model, name: "GLM-5.3" }] : []; - }, - setTransientModel: async (modelId: string) => { - if (!reloaded) throw new Error(`Unknown model: ${modelId}`); - sessionModel = modelId; - return { model: modelId }; - }, - submitPrompt: async (input: unknown) => { - if (input === "/login bigmodel-coding-plan") { - await writeLogin(); - loggedIn = true; - return { loginRequired: false, response: `Configured BigModel Coding Plan.\nModel: ${model}` }; - } - return { response: sessionModel ? `Echo from ${sessionModel}: ${String(input)}` : "Select a model before continuing" }; - }, - stdout: process.stdout, - stderr: process.stderr, - stdin: process.stdin -} as Parameters[0]); diff --git a/test/fixtures/tui-login-override.ts b/test/fixtures/tui-login-override.ts deleted file mode 100644 index a9a12fcc..00000000 --- a/test/fixtures/tui-login-override.ts +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bun -import { writeProviderFixture } from "./provider-config.ts"; -await writeProviderFixture(process.env, { providerId: "zai", modelId: "override-model", apiKey: "override-fixture-key" }); -console.log("External login command completed."); diff --git a/test/fixtures/tui-pressure.ts b/test/fixtures/tui-pressure.ts deleted file mode 100644 index 5d959d26..00000000 --- a/test/fixtures/tui-pressure.ts +++ /dev/null @@ -1,280 +0,0 @@ -#!/usr/bin/env bun - -import { runTui } from "../../packages/zcode-tui/src/index.ts"; -import type { PromptCallOptions } from "../../packages/zcode-tui/src/types.ts"; - -const steerText = "长任务期间继续检查输入响应。"; -const recoveredSteerTexts = [ - "Esc 后立即提交第一条 steer。", - "合并第二条 steer 并继续当前上下文。" -] as const; -const recoveredSteerText = recoveredSteerTexts.join("\n"); -const outputTail = Array.from( - { length: 120 }, - (_, index) => `pressure line ${index + 1} ${"x".repeat(64)}` -).join("\n"); - -let active = false; -let activeTurnInterrupt: AbortController | undefined; -let steerReceived = false; -const hangingSteerPendingIds: string[] = []; -let interruptedContextEstablished = false; -let interruptReservationId: string | undefined; -let promotedPendingInputIds: string[] | undefined; -let pressurePendingInputId: string | undefined; -const pressureSteerTurnId = "pressure_steer_turn"; -const pressureCancelTurnId = "pressure_cancel_turn"; -let resolveSteer!: () => void; -const steerPromise = new Promise((resolve) => { - resolveSteer = resolve; -}); - -function inputText(input: unknown): string { - if (typeof input === "string") return input; - if (typeof input !== "object" || input === null) return ""; - const text = (input as Record).text; - return typeof text === "string" ? text : ""; -} - -async function emit(options: PromptCallOptions, type: string, payload: unknown): Promise { - await options.onEvent?.({ type, payload }); -} - -async function runPressureTurn( - options: PromptCallOptions, - cancellable: boolean -): Promise { - const toolCallId = cancellable ? "pressure_cancel" : "pressure_steer"; - const command = cancellable ? "cancel-pressure" : "steer-pressure"; - const turnId = cancellable ? pressureCancelTurnId : pressureSteerTurnId; - const partId = `${toolCallId}_part`; - const semanticInterrupt = new AbortController(); - active = true; - activeTurnInterrupt = semanticInterrupt; - try { - await emit(options, "turn_started", { - inputId: options.inputId, - turnId - }); - if (cancellable) interruptedContextEstablished = true; - if (cancellable) { - await options.onEvent?.({ - kind: "text_delta", - delta: "Context established before steer interrupt.\n", - messageId: "pressure_context_message", - partId: "pressure_context_part" - }); - } - await options.onEvent?.({ kind: "tool_input_start", toolCallId, toolName: "Bash" }); - for (let index = 0; index < 10_000; index += 1) { - void options.onEvent?.({ - kind: "tool_input_delta", - delta: "0123456789", - toolCallId, - toolName: "Bash" - }); - } - await Bun.sleep(30); - await options.onEvent?.({ - kind: "tool_call", - input: { command }, - toolCallId, - toolName: "Bash" - }); - await emit(options, "tool_call_started", { - input: { command }, - toolCallId, - toolName: "Bash" - }); - await emit(options, "part.started", { - part: { - type: "tool", - partId, - callId: toolCallId, - tool: "Bash", - state: { status: "running", input: { command } } - } - }); - for (let index = 0; index < 10_000; index += 1) { - void options.onEvent?.({ - type: "part.delta", - payload: { partId, field: "output", delta: "0123456789" } - }); - } - await Bun.sleep(30); - for (let index = 0; index < (cancellable ? 100_000 : 5_000); index += 1) { - if (options.abortSignal?.aborted || semanticInterrupt.signal.aborted) { - await emit(options, "tool_call_error", { - error: { - type: "tool_cancelled", - message: "Bash was cancelled and the child process was asked to stop", - code: "TOOL_CANCELLED", - stack: "Error: internal vendor stack" - }, - input: { command }, - toolCallId, - toolName: "Bash" - }); - if (semanticInterrupt.signal.aborted) { - await emit(options, "model.network_status", { - type: "model_request_failed", - attempt: 1, - errorCode: "model_request_cancelled", - exceptionType: "AbortError", - message: "Model request was cancelled.", - reason: "cancelled", - retryable: false - }); - } - throw new Error("Pressure turn cancelled."); - } - await emit(options, "tool_call_progress", { - elapsedMs: index, - input: { command }, - stdoutBytes: index * 80, - stdoutTail: `${outputTail}\n${cancellable ? "cancel" : "steer"} frame ${index}`, - toolCallId, - toolName: "Bash" - }); - if (index % 50 === 49) await Bun.sleep(5); - } - if (!cancellable) { - await Promise.race([steerPromise, Bun.sleep(2_000)]); - if (!steerReceived) throw new Error("Pressure fixture did not receive steering input."); - if (!pressurePendingInputId) throw new Error("Pressure steer did not receive a pending input ID."); - } - await emit(options, "tool_call_result", { - input: { command }, - result: { exitCode: 0, stdout: "Pressure output complete.", success: true }, - toolCallId, - toolName: "Bash" - }); - await emit(options, "turn_steer_drained", { - injectedMessageIds: ["pressure_steer_message"], - pendingInputIds: [pressurePendingInputId], - targetTurnId: turnId - }); - if (!cancellable) await Bun.sleep(250); - return { - kind: "started_turn", - result: { - response: "Pressure turn complete.", - model: "pressure/model", - thoughtLevel: "medium" - } - }; - } finally { - active = false; - if (activeTurnInterrupt === semanticInterrupt) activeTurnInterrupt = undefined; - } -} - -await runTui({ - initialMode: "build", - initialModel: "pressure/model", - initialThoughtLevel: "medium", - noColor: true, - version: "pressure-smoke", - workspaceDirectory: process.cwd(), - readSessionUsage: async () => ({ totalTokens: 0 }), - interruptTurn: async ({ pendingInputIds, reason, reservationId }) => { - if (!active || !activeTurnInterrupt) return { kind: "idle" }; - if (reason?.includes("active foreground turn")) { - if (pendingInputIds?.length) { - throw new Error(`Foreground interrupt received pending inputs: ${pendingInputIds.join(", ")}`); - } - // Model runtimes may acknowledge stop without aborting the TUI's local - // submission signal. ZCode must settle that signal and timer itself. - return { kind: "stopped" }; - } - if (!reason?.includes("steer instructions")) { - throw new Error(`Unexpected semantic interrupt reason: ${String(reason)}`); - } - if (pendingInputIds?.length !== hangingSteerPendingIds.length - || pendingInputIds.some((pendingInputId, index) => pendingInputId !== hangingSteerPendingIds[index])) { - throw new Error(`Semantic interrupt received unexpected pending inputs: ${pendingInputIds?.join(", ")}`); - } - if (!reservationId) throw new Error("Semantic interrupt did not provide a queue reservation ID."); - interruptReservationId = reservationId; - activeTurnInterrupt.abort(new Error(reason)); - return { kind: "stopped" }; - }, - sendInput: async (input, options) => { - const text = inputText(input); - if (active) { - if (options.delivery !== "steer_active_turn") { - throw new Error(`Pressure steer used unexpected delivery mode: ${String(options.delivery)}`); - } - if (text !== steerText && !recoveredSteerTexts.includes(text as typeof recoveredSteerTexts[number])) { - throw new Error(`Unexpected pressure steer: ${text}`); - } - const recovery = text !== steerText; - const expectedTurnId = recovery ? pressureCancelTurnId : pressureSteerTurnId; - if (options.expectedTurnId !== expectedTurnId) { - throw new Error(`Pressure steer used unexpected expected turn: ${String(options.expectedTurnId)}`); - } - if (!options.pendingInputId) throw new Error("Pressure steer did not provide a stable pending input ID."); - await emit(options, "turn_steer_queued", { - input: text, - inputId: options.inputId, - pendingInputId: options.pendingInputId, - queueLength: 1, - targetTurnId: expectedTurnId - }); - if (recovery) { - hangingSteerPendingIds.push(options.pendingInputId); - return await new Promise(() => {}); - } - pressurePendingInputId = options.pendingInputId; - steerReceived = true; - resolveSteer(); - return { kind: "queued", pendingInputId: options.pendingInputId, queueLength: 1 }; - } - if (options.delivery !== "start_turn") { - throw new Error(`Pressure turn used unexpected delivery mode: ${String(options.delivery)}`); - } - if (text === "stress") return await runPressureTurn(options, false); - if (text === "cancel stress") return await runPressureTurn(options, true); - if (text === recoveredSteerText) { - throw new Error("Recovered steer bypassed queued-input promotion."); - } - throw new Error(`Unexpected pressure prompt: ${text}`); - }, - promoteQueuedInput: async (input, pendingInputIds, options) => { - const text = inputText(input); - if (text !== recoveredSteerText) throw new Error(`Unexpected promoted steer: ${text}`); - if (pendingInputIds.length !== hangingSteerPendingIds.length - || pendingInputIds.some((pendingInputId, index) => pendingInputId !== hangingSteerPendingIds[index])) { - throw new Error(`Promoted unexpected pending inputs: ${pendingInputIds.join(", ")}`); - } - if (promotedPendingInputIds) throw new Error("Recovered steers were promoted more than once."); - if (hangingSteerPendingIds.length !== recoveredSteerTexts.length) { - throw new Error("Recovered steer waited for the original steer RPC to finish."); - } - if (!interruptedContextEstablished) { - throw new Error("Recovered steer lost the interrupted turn context."); - } - if (active) throw new Error("Recovered steer was promoted before the interrupted turn stopped."); - if (options.delivery !== "start_turn") { - throw new Error(`Promoted steer used unexpected delivery mode: ${String(options.delivery)}`); - } - if (!interruptReservationId || options.pendingInputReservationId !== interruptReservationId) { - throw new Error("Promoted steer did not reuse the semantic interrupt reservation."); - } - promotedPendingInputIds = [...pendingInputIds]; - await emit(options, "turn_started", { - inputId: options.inputId, - turnId: "pressure_continuation_turn" - }); - return { - kind: "started_turn", - result: { - response: "Interrupted model step continued with steer instructions.", - turnId: "pressure_continuation_turn", - model: "pressure/model", - thoughtLevel: "medium" - } - }; - }, - submitPrompt: async () => ({ response: "Unexpected fallback submission." }) -}); diff --git a/test/fixtures/tui-session-title.ts b/test/fixtures/tui-session-title.ts deleted file mode 100644 index ab9a4e6c..00000000 --- a/test/fixtures/tui-session-title.ts +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bun -// Minimal runTui fixture for terminal session-title lifecycle coverage. - -import { runTui } from "../../packages/zcode-tui/src/index.ts"; - -const sessionTranscript = [ - { messageId: "message_startup", role: "user", content: "Restored startup prompt." }, - { messageId: "message_startup_reply", role: "agent", content: "Restored startup response." } -]; - -await runTui({ - model: "alpha/model", - slashCommands: [ - { name: "login", summary: "Sign in" }, - { name: "resume", summary: "Resume a session" } - ], - loadSessionTranscript: async () => sessionTranscript, - submitPrompt: async (input) => { - if (input === "/resume fixture-session") { - return { - resetSessionProjection: true, - restoredMessages: [ - { role: "user", content: "Restored resumed prompt." }, - { role: "agent", content: "Restored resumed response." } - ], - response: "Resumed session fixture-session." - }; - } - return { response: `Echo: ${String(input)}` }; - }, - stdout: process.stdout, - stderr: process.stderr, - stdin: process.stdin -} as Parameters[0]); diff --git a/test/footer-bar.test.ts b/test/footer-bar.test.ts deleted file mode 100644 index 7123292f..00000000 --- a/test/footer-bar.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { FooterBar } from "../packages/zcode-tui/src/footer-bar.ts"; - -describe("TUI footer bar", () => { - test("keeps turn timing on the left and goal status right-aligned", () => { - const footer = new FooterBar(); - footer.setContent( - "thinking… ── [ 🕛 12s ]", - "[ Goal: Active (40K / 50K) ]", - "[ Goal: Active ]" - ); - - const [line] = footer.render(60); - expect(line).toContain("thinking… ── [ 🕛 12s ]"); - expect(line).toContain("[ Goal: Active (40K / 50K) ]"); - expect(visibleWidth(line ?? "")).toBe(60); - }); - - test("drops supplementary goal text before the turn timer on narrow terminals", () => { - const footer = new FooterBar(); - footer.setContent( - "working… ── [ 🕛 3s ]", - "[ Goal: Active (40K / 50K) ]", - "[ Goal: Active ]" - ); - - const lines = footer.render(24); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain("working… ── [ 🕛 3s ]"); - expect(lines[0]).not.toContain("Goal:"); - expect(visibleWidth(lines[0] ?? "")).toBeLessThanOrEqual(24); - }); - - test("uses a complete compact goal instead of truncating its label", () => { - const footer = new FooterBar(); - footer.setContent( - "[ 🕛 0s ]", - "[ Goal: Active (40K / 50K) ]", - "[ Goal: Active ]" - ); - - expect(footer.render(36)[0]).toContain("[ Goal: Active ]"); - expect(footer.render(36)[0]).not.toContain("40K"); - expect(footer.render(24)[0]).not.toContain("Goal:"); - }); - - test("does not reserve an idle row without timing or goal content", () => { - const footer = new FooterBar(); - footer.setContent(""); - expect(footer.render(80)).toEqual([]); - }); -}); diff --git a/test/fullscreen-header.test.ts b/test/fullscreen-header.test.ts deleted file mode 100644 index 52943e8b..00000000 --- a/test/fullscreen-header.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { - displayWorkspacePath, - FullscreenHeader, - SessionWelcome -} from "../packages/zcode-tui/src/fullscreen-header.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -const options = { - branch: "feat-1", - distributionVersion: "3.9.2-16", - runtimeVersion: "0.16.5", - workspace: "/Users/alice/Documents/code/zcode-cli", - homeDirectory: "/Users/alice" -}; - -describe("fullscreen session header", () => { - test("shortens workspace paths using the home directory", () => { - expect(displayWorkspacePath(options.workspace, options.homeDirectory)) - .toBe("~/Documents/code/zcode-cli"); - expect(displayWorkspacePath("/tmp/zcode", options.homeDirectory)).toBe("/tmp/zcode"); - }); - - test("keeps a one-line rail through narrow widths", () => { - const header = new FullscreenHeader(createTheme(false), options); - header.setPhase("rail"); - for (const width of [1, 8, 20, 40, 80, 120]) { - const lines = header.render(width); - expect(lines).toHaveLength(1); - expect(visibleWidth(lines[0] ?? "")).toBe(width); - } - expect(header.render(80)[0]).toStartWith("── "); - expect(header.render(80)[0]).toContain("◆ ZCODE"); - expect(header.render(80)[0]).toContain("feat-1"); - }); - - test("keeps welcome frames within tiny and wide terminal widths", () => { - const theme = createTheme(false); - const header = new FullscreenHeader(theme, options); - const welcome = new SessionWelcome( - theme, - (width) => header.location(width), - (width) => header.identity(width), - { includeIdentity: true, loginRequired: true } - ); - - for (const width of [1, 2, 5, 8, 20, 40, 76, 100, 160]) { - const lines = welcome.render(width); - expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true); - } - expect(welcome.render(160).every((line) => visibleWidth(line) <= 76)).toBe(true); - }); - - test("uses the versioned identity only for the welcome phase", () => { - const header = new FullscreenHeader(createTheme(false), options); - expect(header.render(80)[0]).toContain("v3.9.2-16"); - header.setPhase("transition"); - expect(header.render(80)[0]).toContain("◆ ZCODE"); - expect(header.render(80)[0]).not.toContain("v3.9.2-16"); - }); - - test("renders regular and fullscreen welcome surfaces without a wide logo", () => { - const theme = createTheme(false); - const header = new FullscreenHeader(theme, options); - const regular = new SessionWelcome( - theme, - (width) => header.location(width), - (width) => header.identity(width), - { includeIdentity: true } - ); - const fullscreen = new SessionWelcome( - theme, - (width) => header.location(width), - (width) => header.identity(width), - { includeIdentity: false, loginRequired: true } - ); - - const regularLines = regular.render(100); - const fullscreenLines = fullscreen.render(100); - expect(regularLines).toHaveLength(4); - expect(regularLines[0]).toStartWith("╭─ "); - expect(regularLines[0]).toContain(" ─"); - expect(regularLines.at(-1)).toStartWith("╰─ "); - expect(regularLines.at(-1)).toContain(" ─"); - expect(regularLines.join("\n")).toContain("ZCODE"); - expect(fullscreenLines).toHaveLength(5); - expect(fullscreenLines[0]).toStartWith("╭─ "); - expect(fullscreenLines[0]).toContain(" ─"); - expect(fullscreenLines.at(-1)).toStartWith("╰─ "); - expect(fullscreenLines.join("\n")).toContain("Run /login"); - expect(fullscreenLines.join("\n")).not.toContain("SYSTEM INITIATED"); - }); -}); diff --git a/test/goal-status.test.ts b/test/goal-status.test.ts deleted file mode 100644 index bc25e784..00000000 --- a/test/goal-status.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - formatTokens, - goalStatusLabel, - goalStatusText, - normalizeGoal -} from "../packages/zcode-tui/src/goal-status.ts"; - -describe("TUI goal status", () => { - const active = { - status: "active", - tokenBudget: 50_000, - tokensUsed: 40_000, - timeUsedSeconds: 120 - } as const; - - test("normalizes official ZCode goal snapshots", () => { - expect(normalizeGoal(active)).toEqual(active); - expect(normalizeGoal(null)).toBeUndefined(); - expect(normalizeGoal({ ...active, status: "unknown" })).toBeUndefined(); - }); - - test("formats goal lifecycle states without repeating the active turn timer", () => { - expect(goalStatusText(active)).toBe("Active (40K / 50K)"); - expect(goalStatusText({ ...active, status: "paused" })).toBe("Paused (/goal resume)"); - expect(goalStatusText({ ...active, status: "budget_limited" })).toBe("Unmet (40K / 50K)"); - expect(goalStatusText({ ...active, status: "complete", timeUsedSeconds: 7_200 })).toBe("Achieved (2h)"); - expect(goalStatusLabel(active)).toBe("Active"); - expect(goalStatusLabel({ ...active, status: "budget_limited", tokenBudget: null })).toBe("Abandoned"); - expect(formatTokens(1_250_000)).toBe("1.3M"); - expect(formatTokens(1_250_000_000)).toBe("1.3G"); - }); -}); diff --git a/test/input-queue.test.ts b/test/input-queue.test.ts index 6891a306..6040725c 100644 --- a/test/input-queue.test.ts +++ b/test/input-queue.test.ts @@ -49,33 +49,6 @@ describe("TUI input queue", () => { expect(queue.takeNextFollowUp()?.input).toBe("second"); }); - test("explicit pause survives turn resets and resume preserves recovery hold", () => { - const { queue, states } = queueHarness(); - queue.paused = true; - queue.resetAutoSend(); - expect(queue.autoSend).toBeFalse(); - expect(states.at(-1)?.paused).toBeTrue(); - queue.autoSend = false; - queue.paused = false; - expect(queue.autoSend).toBeFalse(); - queue.resetAutoSend(); - expect(queue.autoSend).toBeTrue(); - }); - test("publishes every editable follow-up transition", () => { - const { queue, states } = queueHarness(); - - queue.queueFollowUp(submission("first")); - queue.queueFollowUp(submission("second")); - expect(states.at(-1)).toEqual({ pendingSteers: [], queuedInputs: ["first", "second"] }); - expect(queue.hasFollowUps()).toBeTrue(); - - expect(queue.editLatestFollowUp()?.input).toBe("second"); - expect(states.at(-1)?.queuedInputs).toEqual(["first"]); - expect(queue.takeNextFollowUp()?.input).toBe("first"); - expect(states.at(-1)).toEqual({ pendingSteers: [], queuedInputs: [] }); - expect(queue.hasFollowUps()).toBeFalse(); - }); - test("tracks, associates and commits active-turn steers", () => { const { committed, queue, states } = queueHarness(); @@ -198,115 +171,4 @@ describe("TUI input queue", () => { expect(states.at(-1)?.pendingSteers).toEqual(["New turn input."]); }); - test("merges every pending steer ahead of drafts for immediate Esc submission", () => { - const { discarded, queue, states } = queueHarness(); - - queue.queueFollowUp(submission("Keep this draft queued.")); - queue.trackSteer(submission("First correction."), "input_first", "turn_merge", "pending_first"); - queue.associateSteer("input_first", "pending_first", "turn_merge"); - queue.trackSteer(submission("Second correction."), "input_second", "turn_merge", "pending_second"); - queue.associateSteer("input_second", "pending_second", "turn_merge"); - - expect(queue.admittedPendingInputIds()).toEqual(["pending_first", "pending_second"]); - expect(queue.requeuePendingSteers( - "turn_cancelled", - "turn_merge", - true, - "steer_interrupt_reservation" - )).toBe(2); - expect(discarded).toEqual([]); - expect(states.at(-1)).toEqual({ - pendingSteers: [], - queuedInputs: ["First correction.\nSecond correction.", "Keep this draft queued."] - }); - expect(queue.takeNextFollowUp()).toMatchObject({ - displayInput: "First correction.\nSecond correction.", - input: "First correction.\nSecond correction.", - pendingInputReservationId: "steer_interrupt_reservation", - pendingInputIds: ["pending_first", "pending_second"], - recordHistory: false - }); - expect(queue.takeNextFollowUp()?.input).toBe("Keep this draft queued."); - }); - - test("does not bind a pending steer to a different turn", () => { - const { committed, queue } = queueHarness(); - - queue.trackSteer(submission("Current turn only."), "input_current", "turn_current"); - queue.handleLifecycleEvent(event({ - type: "turn_steer_queued", - inputId: "input_current", - pendingInputId: "pending_stale", - targetTurnId: "turn_stale" - })); - queue.handleLifecycleEvent(event({ - type: "turn_steer_drained", - pendingInputIds: ["pending_stale"], - injectedMessageIds: ["message_stale"], - targetTurnId: "turn_stale" - })); - - expect(committed).toEqual([]); - expect(queue.hasPendingSteers()).toBeTrue(); - - queue.handleLifecycleEvent(event({ - type: "turn_steer_queued", - inputId: "input_current", - pendingInputId: "pending_current", - targetTurnId: "turn_current" - })); - queue.handleLifecycleEvent(event({ - type: "turn_steer_drained", - pendingInputIds: ["pending_current"], - injectedMessageIds: ["message_current"], - targetTurnId: "turn_current" - })); - - expect(committed).toEqual([[ - { displayInput: "Current turn only.", messageId: "message_current" } - ]]); - }); - - test("records a completed turn even after all steers were already settled", () => { - const { committed, queue } = queueHarness(); - - queue.trackSteer(submission("Already consumed."), "input_done", "turn_done"); - queue.associateSteer("input_done", "pending_done", "turn_done"); - queue.handleLifecycleEvent(event({ - type: "turn_steer_drained", - pendingInputIds: ["pending_done"], - injectedMessageIds: ["message_done"], - targetTurnId: "turn_done" - })); - expect(queue.requeuePendingSteers("turn_ended", "turn_done")).toBe(0); - - queue.trackSteer(submission("Next turn."), "input_next", "turn_next"); - queue.associateSteer("input_next", "pending_next", "turn_next"); - queue.handleLifecycleEvent(event({ - type: "turn_steer_drained", - pendingInputIds: ["pending_done"], - injectedMessageIds: ["message_duplicate"], - targetTurnId: "turn_done" - })); - - expect(committed).toEqual([[ - { displayInput: "Already consumed.", messageId: "message_done" } - ]]); - expect(queue.hasPendingSteers()).toBeTrue(); - expect(queue.handleLifecycleEvent(event({ - type: "tool_call_started", - targetTurnId: "turn_done" - }))).toBeFalse(); - }); - - test("keeps auto-send control and unrelated events explicit", () => { - const { queue, states } = queueHarness(); - - queue.autoSend = false; - expect(queue.autoSend).toBeFalse(); - queue.resetAutoSend(); - expect(queue.autoSend).toBeTrue(); - expect(queue.handleLifecycleEvent(event({ type: "tool_call_started" }))).toBeFalse(); - expect(states).toEqual([]); - }); }); diff --git a/test/interactions.test.ts b/test/interactions.test.ts deleted file mode 100644 index bf13d7ce..00000000 --- a/test/interactions.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - answeredQuestionInput, - collectUserQuestionAnswers, - defaultPermissionChoices, - isAskUserQuestionTool, - isExitPlanModeTool, - parseUserQuestions, - planText -} from "../packages/zcode-tui/src/interactions.ts"; - -describe("TUI structured interactions", () => { - test("parses single and multi-select AskUserQuestion inputs", () => { - expect(parseUserQuestions({ - questions: [{ - question: "Which renderer?", - header: "Renderer", - options: [ - { label: "Compact", description: "One line", preview: "```ts\nconst compact = true\n```" }, - { label: "Detailed", description: "Full output" } - ] - }, { - question: "Which extras?", - header: "Extras", - multiSelect: true, - options: [{ label: "Diff" }, { label: "Search" }] - }] - })).toEqual([{ - question: "Which renderer?", - header: "Renderer", - multiSelect: false, - options: [ - { - value: "Compact", - label: "Compact", - description: "One line", - preview: "```ts\nconst compact = true\n```" - }, - { value: "Detailed", label: "Detailed", description: "Full output", preview: undefined } - ] - }, { - question: "Which extras?", - header: "Extras", - multiSelect: true, - options: [ - { value: "Diff", label: "Diff", description: undefined, preview: undefined }, - { value: "Search", label: "Search", description: undefined, preview: undefined } - ] - }]); - }); - - test("preserves question input while adding answers", () => { - const input = { questions: [{ question: "Mode?", options: [] }], metadata: { source: "tool" } }; - expect(answeredQuestionInput(input, { "Mode?": "Compact" })).toEqual({ - ...input, - answers: { "Mode?": "Compact" } - }); - }); - - test("collects multi-step answers while allowing backtracking", async () => { - const questions = parseUserQuestions({ - questions: [{ - question: "Which renderer?", - options: [{ label: "Compact" }, { label: "Detailed" }] - }, { - question: "Which extras?", - options: [{ label: "Diff" }, { label: "Search" }] - }] - }); - const seen: Array<{ index: number; previousAnswer?: string }> = []; - const responses = [ - { kind: "answer", answer: "Compact" }, - { kind: "back" }, - { kind: "answer", answer: "Detailed" }, - { kind: "answer", answer: "Search" } - ] as const; - - const answers = await collectUserQuestionAnswers(questions, async (_question, index, _total, previousAnswer) => { - seen.push({ index, previousAnswer }); - return responses[seen.length - 1] ?? { kind: "cancel" }; - }); - - expect(answers).toEqual({ - "Which renderer?": "Detailed", - "Which extras?": "Search" - }); - expect(seen).toEqual([ - { index: 0, previousAnswer: undefined }, - { index: 1, previousAnswer: undefined }, - { index: 0, previousAnswer: "Compact" }, - { index: 1, previousAnswer: undefined } - ]); - }); - - test("builds project-scoped permission updates from tool input", () => { - const choices = defaultPermissionChoices("Bash", { command: "bun test" }); - expect(choices.map((choice) => choice.value)).toEqual([ - "allow_once", - "allow_project", - "deny_feedback", - "deny" - ]); - expect(choices[1]?.response).toEqual({ - decision: "allow", - permissionUpdates: [{ - behavior: "allow", - rules: [{ toolName: "Bash", ruleContent: "bun test" }], - type: "addRules" - }], - reason: "Approved for this project" - }); - }); - - test("limits guarded risk approval to allow once or deny without persistent updates", () => { - const choices = defaultPermissionChoices("Bash", { command: "rm -rf build" }, "user-once"); - expect(choices.map(choice => choice.value)).toEqual(["allow_once", "deny"]); - expect(choices[0]?.response).toEqual({ decision: "allow", reason: "Approved once" }); - expect(choices.every(choice => !choice.response.permissionUpdates && !choice.response.sessionPermissionUpdates)).toBe(true); - }); - - test("recognizes question and plan tools and extracts plan Markdown", () => { - expect(isAskUserQuestionTool("AskUserQuestion")).toBe(true); - expect(isExitPlanModeTool("ExitPlanModeV2")).toBe(true); - expect(planText({ plan: "# Implementation\n\n1. Build it" })).toBe("# Implementation\n\n1. Build it"); - }); -}); diff --git a/test/launcher.test.ts b/test/launcher.test.ts deleted file mode 100644 index 190f8957..00000000 --- a/test/launcher.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { writeProviderFixture } from "./fixtures/provider-config.ts"; -import { describe, expect, test } from "bun:test"; - -import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { - clearSetupPending, - ensureCliSettings, - markSetupPending, - readConfiguredModelAccess, - readSetupPending, - cliSettingsPath -} from "../src/model-access.ts"; -import { - firstRunSetupEnv, - formatVersionOutput, - isTuiRuntimeInvocation, - isDoctorRuntimeInvocation, - isVersionInvocation, - normalizeLoginArgs, - readRuntimeCliOptionTypes, - readDistributionVersion, - readRuntimeVersion, - resolveModelRetryMaxRetries, - resolveZCodeBaseUrl, - withDefaultBrowserUse -} from "../src/launcher.ts"; -import { classifyZaiOAuthInvocation } from "../src/zai-oauth.ts"; - -describe("launcher routing", () => { - const runtime39OptionTypes = { - ...readRuntimeCliOptionTypes(), - "output-format": "string" as const - }; - - test("uses five runtime retries by default and preserves an explicit override", () => { - expect(resolveModelRetryMaxRetries({})).toBe("5"); - expect(resolveModelRetryMaxRetries({ ZCODE_MODEL_RETRY_MAX_RETRIES: " 2 " })).toBe("2"); - expect(resolveModelRetryMaxRetries({ ZCODE_MODEL_RETRY_MAX_RETRIES: " " })).toBe("5"); - }); - - test("supplies the official plugin API origin while preserving an explicit override", () => { - expect(resolveZCodeBaseUrl({})).toBe("https://zcode.z.ai"); - expect(resolveZCodeBaseUrl({ ZCODE_BASE_URL: " https://example.test " })).toBe("https://example.test"); - }); - - test("reads a safe npm distribution version", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-version-")); - const manifest = join(directory, "package.json"); - try { - await writeFile(manifest, JSON.stringify({ version: "3.3.5-1" })); - expect(readDistributionVersion(manifest)).toBe("3.3.5-1"); - await writeFile(manifest, JSON.stringify({ version: "bad\u001b[2J" })); - expect(readDistributionVersion(manifest)).toBeUndefined(); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); - - test("reads and labels both npm package and bundled runtime versions", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-runtime-version-")); - const metadata = join(directory, "extraction.json"); - try { - await writeFile(metadata, JSON.stringify({ cliVersion: "0.15.2" })); - expect(readRuntimeVersion(metadata)).toBe("0.15.2"); - await writeFile(metadata, JSON.stringify({ cliVersion: "bad\u001b[2J" })); - expect(readRuntimeVersion(metadata)).toBeUndefined(); - } finally { - await rm(directory, { recursive: true, force: true }); - } - - expect(formatVersionOutput("3.3.6-3", "0.15.2")).toBe( - "zcode-app-cli 3.3.6-3\nzcode-runtime 0.15.2" - ); - expect(isVersionInvocation(["version"])).toBe(true); - expect(isVersionInvocation(["--version"])).toBe(true); - expect(isVersionInvocation(["-v"])).toBe(true); - expect(isVersionInvocation(["--json", "version"])).toBe(false); - }); - - test("checks configured access by default and keeps an explicit OAuth escape hatch", () => { - expect(normalizeLoginArgs(["login"])).toEqual({ - args: ["login"], - checkConfiguredAccess: true - }); - expect(normalizeLoginArgs(["login", "--oauth"])).toEqual({ - args: ["login"], - checkConfiguredAccess: false - }); - expect(normalizeLoginArgs(["login", "--no-browser"])).toEqual({ - args: ["login", "--no-browser"], - checkConfiguredAccess: false - }); - }); - - test("signals the first-run setup wizard only for a fresh TUI invocation", () => { - expect(firstRunSetupEnv(true, [])).toEqual({ ZCODE_CLI_FIRST_RUN: "1" }); - expect(firstRunSetupEnv(true, ["--browser-use=headless"])).toEqual({ ZCODE_CLI_FIRST_RUN: "1" }); - expect(firstRunSetupEnv(false, [])).toBeUndefined(); - expect(firstRunSetupEnv(true, ["app-server"])).toBeUndefined(); - expect(firstRunSetupEnv(true, ["login"])).toBeUndefined(); - expect(firstRunSetupEnv(true, ["-p", "hi"])).toBeUndefined(); - }); - - test("keeps setup pending across non-TUI commands until the wizard clears it", async () => { - const home = await mkdtemp(join(tmpdir(), "zcode-setup-pending-")); - const env = { HOME: home, USERPROFILE: home }; - try { - // First invocation creates the config via a non-TUI command (plugin list): - // the pending marker must survive so the wizard still appears later. - const bootstrap = await ensureCliSettings(env); - expect(bootstrap.created).toBe(true); - await markSetupPending(env); - expect(await readSetupPending(env)).toBe(true); - expect(firstRunSetupEnv(true, ["plugin", "list"])).toBeUndefined(); - expect(firstRunSetupEnv(true, ["plugin", "list"])).toBeUndefined(); - - // The next interactive TUI start still triggers the wizard… - expect(firstRunSetupEnv(await readSetupPending(env), [])).toEqual({ ZCODE_CLI_FIRST_RUN: "1" }); - - // …and once the user skips or completes setup, it stops appearing. - await clearSetupPending(env); - expect(firstRunSetupEnv(await readSetupPending(env), [])).toBeUndefined(); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - test("clearing setup after a successful login is reflected in the wizard trigger", async () => { - const home = await mkdtemp(join(tmpdir(), "zcode-setup-login-")); - const env = { HOME: home, USERPROFILE: home }; - try { - await ensureCliSettings(env); - await markSetupPending(env); - - // `zcode login` succeeds and writes model access; the launcher then - // clears the marker, so the next TUI start must not open the wizard. - await writeProviderFixture(env, { apiKey: "login-written-key" }); - expect(await readConfiguredModelAccess(env)).not.toBeNull(); - - await clearSetupPending(env); - expect(firstRunSetupEnv(await readSetupPending(env), [])).toBeUndefined(); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - test("enables Browser Use only for agent-producing runtime invocations", () => { - expect(withDefaultBrowserUse([])).toEqual(["--browser-use=headless"]); - expect(withDefaultBrowserUse(["tui"])).toEqual(["--browser-use=headless", "tui"]); - expect(withDefaultBrowserUse(["--cwd", "/tmp/project", "--continue"])).toEqual([ - "--browser-use=headless", - "--cwd", - "/tmp/project", - "--continue" - ]); - expect(withDefaultBrowserUse(["--prompt", "inspect this page"])).toEqual([ - "--browser-use=headless", - "--prompt", - "inspect this page" - ]); - expect(withDefaultBrowserUse([ - "--output-format", - "json", - "--surface", - "terminal", - "--prompt", - "inspect this page" - ], runtime39OptionTypes)).toEqual([ - "--browser-use=headless", - "--output-format", - "json", - "--surface", - "terminal", - "--prompt", - "inspect this page" - ]); - expect(withDefaultBrowserUse(["--target=verify the site"])).toEqual([ - "--browser-use=headless", - "--target=verify the site" - ]); - expect(withDefaultBrowserUse(["--print", "inspect this page"])).toEqual([ - "--browser-use=headless", - "--print", - "inspect this page" - ]); - expect(withDefaultBrowserUse(["--browser-executable", "/opt/chrome", "tui"])).toEqual([ - "--browser-use=headless", - "--browser-executable", - "/opt/chrome", - "tui" - ]); - }); - - test("preserves explicit Browser Use and never injects it into management commands", () => { - const explicit = ["--browser-use", "headless", "tui"]; - expect(withDefaultBrowserUse(explicit)).toBe(explicit); - for (const args of [ - ["plugins", "list", "--json"], - ["--settings", "custom.json", "plugins", "list"], - ["skills", "list"], - ["doctor"], - ["app-server"], - ["login"], - ["commands", "list"], - ["--surface", "terminal", "tui"], - ["--settings", "custom.json", "--prompt", "inspect this page"], - ["--help"], - ["--version"], - ["--unknown"] - ]) { - expect(withDefaultBrowserUse(args)).toBe(args); - } - }); - - test("recognizes TUI invocations after consuming global option values", () => { - expect(isTuiRuntimeInvocation([])).toBe(true); - expect(isTuiRuntimeInvocation(["--cwd", "/tmp/project", "--mode", "edit", "tui"])).toBe(true); - expect(isTuiRuntimeInvocation(["--output-format", "json", "tui"], runtime39OptionTypes)).toBe(true); - expect(isTuiRuntimeInvocation(["--browser-use", "headless", "--cwd", "/tmp/project", "tui"])).toBe(true); - expect(isTuiRuntimeInvocation(["--surface", "terminal", "tui"])).toBe(false); - expect(isTuiRuntimeInvocation(["--prompt", "inspect this page"])).toBe(false); - expect(isTuiRuntimeInvocation(["plugins", "list"])).toBe(false); - expect(isTuiRuntimeInvocation(["--help"])).toBe(false); - expect(isTuiRuntimeInvocation(["--unknown"])).toBe(false); - }); - - test("routes doctor around settings bootstrap without mistaking prompt text for a command", () => { - expect(isDoctorRuntimeInvocation(["doctor", "--json"])).toBe(true); - expect(isDoctorRuntimeInvocation(["--cwd", "/project", "doctor"])).toBe(true); - expect(isDoctorRuntimeInvocation(["doctor", "--help"])).toBe(true); - expect(isDoctorRuntimeInvocation(["--prompt", "doctor"])).toBe(false); - expect(isDoctorRuntimeInvocation(["--prompt", "hello", "doctor"])).toBe(false); - expect(isDoctorRuntimeInvocation(["--cwd", "doctor"])).toBe(false); - expect(isDoctorRuntimeInvocation(["doctor", "--unknown"])).toBe(false); - }); - - test("recognizes plain Z.AI OAuth login for platform-specific routing", () => { - expect(classifyZaiOAuthInvocation(["login"])).toEqual({ - json: false, - noBrowser: false, - runtimeArgs: ["login"] - }); - expect(classifyZaiOAuthInvocation(["login", "--oauth", "--no-browser"])).toEqual({ - json: false, - noBrowser: true, - runtimeArgs: ["login", "--no-browser"] - }); - expect(classifyZaiOAuthInvocation(["--json", "login", "--oauth"])).toEqual({ - json: true, - noBrowser: false, - runtimeArgs: ["--json", "login"] - }); - expect(classifyZaiOAuthInvocation(["login", "zai-coding-plan-api-key", "secret"])).toBeNull(); - expect(classifyZaiOAuthInvocation(["login", "--unknown"])).toBeNull(); - }); -}); diff --git a/test/login-flow.test.ts b/test/login-flow.test.ts deleted file mode 100644 index c35dba59..00000000 --- a/test/login-flow.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - loginFailureDiagnostic, - shouldSuspendForLoginCommand, - shouldUseNoBrowserForLogin, - suppressTuiAiSdkWarnings, - suspendedZaiLoginCommand -} from "../packages/zcode-tui/src/index.ts"; - -describe("TUI external login routing", () => { - test("suspends for the Z.AI browser OAuth selection only", () => { - expect(shouldSuspendForLoginCommand("/login zai-coding-plan")).toBe(true); - expect(shouldSuspendForLoginCommand("/login bigmodel-coding-plan")).toBe(false); - expect(shouldSuspendForLoginCommand("/login zai-coding-plan-api-key")).toBe(false); - }); - - test("uses browserless login for SSH and displayless Linux sessions", () => { - expect(shouldUseNoBrowserForLogin({ SSH_CONNECTION: "host 1" }, "darwin")).toBe(true); - expect(shouldUseNoBrowserForLogin({}, "linux")).toBe(true); - expect(shouldUseNoBrowserForLogin({ DISPLAY: ":0" }, "linux")).toBe(false); - expect(shouldUseNoBrowserForLogin({}, "darwin")).toBe(false); - }); - - test("re-enters the CLI launcher instead of passing --oauth to the official runtime", () => { - expect(suspendedZaiLoginCommand({ - ZCODE_APP_CLI_EXECUTABLE: "/opt/node", - ZCODE_APP_CLI_ENTRY: "/package/bin/zcode.js" - }, "/opt/node", "/package/vendor/zcode.cjs")).toEqual({ - args: ["/package/bin/zcode.js", "login", "--oauth"], - program: "/opt/node" - }); - expect(suspendedZaiLoginCommand({}, "/opt/node", "/package/vendor/zcode.cjs")).toEqual({ - args: ["/package/vendor/zcode.cjs", "login"], - program: "/opt/node" - }); - }); - - test("prefers the actual error over the final help line", () => { - expect(loginFailureDiagnostic("", [ - "Unknown option '--oauth'", - "Usage: zcode [command]", - "/goal [action] Show or set the current session goal" - ].join("\n"))).toBe("Unknown option '--oauth'"); - }); - - test("disables the AI SDK console banner without replacing a runtime handler", () => { - const runtimeGlobal = globalThis as typeof globalThis & { - AI_SDK_LOG_WARNINGS?: unknown; - }; - const previous = runtimeGlobal.AI_SDK_LOG_WARNINGS; - const handler = () => {}; - try { - runtimeGlobal.AI_SDK_LOG_WARNINGS = undefined; - suppressTuiAiSdkWarnings(); - expect(runtimeGlobal.AI_SDK_LOG_WARNINGS).toBe(false); - - runtimeGlobal.AI_SDK_LOG_WARNINGS = handler; - suppressTuiAiSdkWarnings(); - expect(runtimeGlobal.AI_SDK_LOG_WARNINGS).toBe(handler); - } finally { - if (previous === undefined) delete runtimeGlobal.AI_SDK_LOG_WARNINGS; - else runtimeGlobal.AI_SDK_LOG_WARNINGS = previous; - } - }); -}); diff --git a/test/model-access.test.ts b/test/model-access.test.ts deleted file mode 100644 index d5f93441..00000000 --- a/test/model-access.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { writeProviderFixture } from "./fixtures/provider-config.ts"; - -import { readConfiguredModelAccess, providerConfigPath, providerConfigPathHint } from "../src/model-access.ts"; - -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((directory) => ( - rm(directory, { recursive: true, force: true }) - ))); -}); - -async function temporaryHome(): Promise { - const home = await mkdtemp(join(tmpdir(), "zcode-model-access-")); - temporaryDirectories.push(home); - return home; -} - -describe("configured model access", () => { - test("reads registry defaults and account credential presence after native login", async () => { - const home = await temporaryHome(); - const env = { HOME: home, USERPROFILE: home }; - const directory = join(home, ".zcode", "v2"); - await mkdir(directory, { recursive: true }); - const configPath = join(directory, "provider_config.json"); - const providerId = "account:zai-individual-coding-plan"; - await writeFile(configPath, JSON.stringify({ schemaVersion: 1, config: { - defaultModelSelection: { providerId, modelId: "GLM-5.3" }, - providerConfigRules: { providerRules: [] } - } })); - expect(await readConfiguredModelAccess(env)).toBeNull(); - await writeFile(join(directory, "credentials.json"), JSON.stringify({ - [`account-provider:${providerId}:identity`]: "opaque-encrypted-identity", - [`account-provider:coding-plan:${providerId}:account:fixture-user:api-key`]: "opaque-encrypted-key" - })); - expect(await readConfiguredModelAccess(env)).toEqual({ configPath, providerId, model: `${providerId}/GLM-5.3` }); - const isolatedPath = join(home, "cli-credentials.json"); - const isolatedEnv = { ...env, ZCODE_CLI_CREDENTIALS_FILE: isolatedPath }; - expect(await readConfiguredModelAccess(isolatedEnv)).toBeNull(); - await writeFile(isolatedPath, JSON.stringify({ - [`account-provider:${providerId}:identity`]: "cli-identity", - [`account-provider:coding-plan:${providerId}:account:cli-user:api-key`]: "cli-key" - })); - await writeFile(join(directory, "credentials.json"), JSON.stringify({ - [`account-provider:${providerId}:identity`]: "opaque-encrypted-identity", - "account-provider:coding-plan:account:bigmodel-individual-coding-plan:account:fixture-user:api-key": "different-provider" - })); - expect(await readConfiguredModelAccess(env)).toBeNull(); - expect(await readConfiguredModelAccess(isolatedEnv)).toEqual({ configPath, providerId, model: `${providerId}/GLM-5.3` }); - }); - - test("uses personal registry config as authoritative after migration", async () => { - const home = await temporaryHome(); - const configPath = join(home, "personal.json"); - const env = { HOME: home, USERPROFILE: home, ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: configPath }; - const config = { defaultModelSelection: { providerId: "custom", modelId: "org/model" }, - providerConfigRules: { providerRules: [{ providerId: "custom", config: { access: { apiKey: "configured" }, personalModelIds: ["org/model"] } }] } - }; - await writeFile(configPath, JSON.stringify({ schemaVersion: 1, config })); - expect(await readConfiguredModelAccess(env)).toEqual({ configPath, providerId: "custom", model: "custom/org/model" }); - config.providerConfigRules.providerRules[0]!.config.access.apiKey = ""; - await writeFile(configPath, JSON.stringify({ schemaVersion: 1, config })); - expect(await readConfiguredModelAccess(env)).toBeNull(); - await writeFile(configPath, JSON.stringify({ schemaVersion: 2, config })); - expect(await readConfiguredModelAccess(env)).toBeNull(); - }); - - test("formats the config path hint for each supported platform", () => { - expect(providerConfigPathHint("linux")).toBe("~/.zcode/v2/provider_config.json"); - expect(providerConfigPathHint("darwin")).toBe("~/.zcode/v2/provider_config.json"); - expect(providerConfigPathHint("win32")).toBe("%USERPROFILE%\\.zcode\\v2\\provider_config.json"); - }); - - test("detects custom registry providers and rejects missing keys or models", async () => { - const home = await temporaryHome(), env = { HOME: home, USERPROFILE: home }; - const { config, path } = await writeProviderFixture(env, { providerId: "custom", modelId: "org/model", apiKey: "fixture-key" }); - expect(await readConfiguredModelAccess(env)).toEqual({ configPath: path, providerId: "custom", model: "custom/org/model" }); - config.config.defaultModelSelection.modelId = "missing"; - await writeFile(path, JSON.stringify(config)); - expect(await readConfiguredModelAccess(env)).toBeNull(); - await writeProviderFixture(env, { providerId: "custom", modelId: "org/model" }); - expect(await readConfiguredModelAccess(env)).toBeNull(); - await writeFile(path, "not-json"); - expect(await readConfiguredModelAccess(env)).toBeNull(); - }); -}); diff --git a/test/model-catalog-reload.test.ts b/test/model-catalog-reload.test.ts deleted file mode 100644 index 9acba496..00000000 --- a/test/model-catalog-reload.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { patchRuntimeModelCatalogReload } from "../scripts/sync-runtime.ts"; - -describe("runtime model catalog reload bridge", () => { - test.each(["inline", "attached"])("refreshes a registry in place and propagates failures (%s)", async layout => { - const source = [ - 'const kind="ProviderRegistryService";class Registry{refresh(reason="explicit"){}}', - 'function makeApp(ctx){return{listModels:label(()=>listRegistry(ctx.providerRegistry),"listModels")}}', - layout === "inline" - ? 'function makeBridge(host){const bridge={},pending=Promise.resolve(host);const create=async()=>{let state=await pending,selection=state?.modelSelectionConfigRepository?await state.modelSelectionConfigRepository.read():undefined};' - : 'const attach=label((bridge,getApp)=>{', - 'bridge.listModelOptions=async()=>(await getApp()).listModels?.()??[];', - 'bridge.setTransientModel=async model=>(await getApp()).setModel(model,{transient:true});', - layout === "inline" ? "" : '},"attachTuiAppQueries");function makeBridge(host){const bridge={},state={providerRegistryRuntimePromise:Promise.resolve(host)};attach(bridge,getApp),bridge.subscribeSessionEvents=()=>{},bridge.close=async()=>{(await state.providerRegistryRuntimePromise)?.dispose()};', - 'return{listModelOptions:bridge.listModelOptions}}' - ].join(""); - const patched = patchRuntimeModelCatalogReload(source); - let models = ["old"], refreshes = 0; - let failure: Error | undefined; - const registry = { - async refresh(reason: string) { - expect(reason).toBe("cli-model-catalog"); - if (failure) throw failure; - models = ["new"]; - refreshes++; - } - }; - let saved = { providerId: "custom", modelId: "old" }; - const repository = { - read: async () => saved, - saveConfiguredDefault: async (value: typeof saved) => { saved = value; } - }; - const { app, bridge } = new Function("listRegistry", "ctx", "repository", ` - const label = (fn) => fn; - ${patched} - const app = makeApp(ctx), getApp = async () => app; - app.getModelOption = (selection) => selection.modelId === "new"; - app.setModel = async (model, options) => ({model, options}); - return {app, bridge: makeBridge({modelSelectionConfigRepository: repository})}; - `)(() => models, { providerRegistry: registry, sessionId: "existing" }, repository); - expect(await bridge.listModelOptions()).toEqual(["old"]); - expect(await bridge.reloadModelOptions()).toEqual(["new"]); - expect(await bridge.listModelOptions()).toEqual(app.listModels()); - expect(refreshes).toBe(1); - expect(await bridge.readDefaultModel()).toBe("custom/old"); - expect(await bridge.setDefaultModel("custom/new")).toMatchObject({ model: "custom/new", options: { transient: true } }); - expect(saved).toEqual({ providerId: "custom", modelId: "new" }); - await expect(bridge.setDefaultModel("custom/missing")).rejects.toThrow("Unknown default model"); - expect(await bridge.readDefaultModel()).toBe("custom/new"); - failure = new Error("invalid catalog"); - await expect(bridge.reloadModelOptions()).rejects.toThrow("invalid catalog"); - expect(patchRuntimeModelCatalogReload(patched)).toBe(patched); - expect(() => patchRuntimeModelCatalogReload(source.replace('reason="explicit"', 'reason'))).toThrow("registry refresh anchor"); - }); - - test("rejects incompatible bundles instead of silently omitting runtime refresh", () => { - expect(() => patchRuntimeModelCatalogReload("unrecognized bundle")).toThrow("anchor missing"); - }); -}); diff --git a/test/native-oauth-launcher.test.ts b/test/native-oauth-launcher.test.ts deleted file mode 100644 index 348206cf..00000000 --- a/test/native-oauth-launcher.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { spawnSync } from "node:child_process"; - -test.skipIf(process.platform === "darwin")("native OAuth preserves flags, isolated credentials and login outcome", async () => { - const root = await mkdtemp(join(tmpdir(), "zcode-native-login-")); - try { - const entry = join(root, "package", "bin", "zcode.js"); - const build = await Bun.build({ entrypoints: [resolve("bin/zcode.ts")], target: "node" }); - expect(build.success).toBe(true); - await mkdir(join(root, "package", "bin"), { recursive: true }); - await mkdir(join(root, "package", "vendor"), { recursive: true }); - await writeFile(join(root, "package", "package.json"), '{"type":"module"}'); - await writeFile(entry, await build.outputs[0]!.text()); - await writeFile(join(root, "package", "vendor", "extraction.json"), JSON.stringify({ - appVersion: "3.14.4", - runtimeCapabilities: { schemaVersion: 1, cli: { globalOptions: { "no-browser": { type: "boolean" } } } } - })); - await writeFile(join(root, "package", "vendor", "zcode.cjs"), ` - const fs = require("node:fs"); - const env = process.env; - fs.writeFileSync(env.ZCODE_TEST_CAPTURE, JSON.stringify({ - args: process.argv.slice(2), credentials: env.ZCODE_CLI_CREDENTIALS_FILE, - desktopBridge: env.ZCODE_CLI_OAUTH_CALLBACK_STDIN, appVersion: env.ZCODE_APP_VERSION - })); - if (env.ZCODE_TEST_WRITE_CREDENTIALS === "1") { - const providerId = "account:zai-individual-coding-plan"; - fs.writeFileSync(env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE, JSON.stringify({ - schemaVersion: 1, config: { defaultModelSelection: { providerId, modelId: "GLM-5.3" } } - })); - fs.writeFileSync(env.ZCODE_CLI_CREDENTIALS_FILE, JSON.stringify({ - ["account-provider:" + providerId + ":identity"]: "fixture-identity", - ["account-provider:coding-plan:" + providerId + ":account:fixture:api-key"]: "fixture-key" - })); - } - process.exit(Number(env.ZCODE_TEST_EXIT)); - `); - for (const [status, writeCredentials, pending] of [[0, true, false], [7, false, true], [0, false, true]] as const) { - const home = join(root, `home-${status}-${writeCredentials}`); - const cli = join(home, ".zcode", "cli"); - await mkdir(cli, { recursive: true }); - const credentials = join(cli, "credentials.json"); - const capture = join(home, "capture.json"); - const appVersion = status === 7 ? "9.9.9" : "3.14.4"; - const child = spawnSync(Bun.which("node")!, [entry, "login", "--oauth", "--no-browser"], { - cwd: home, - env: { ...process.env, HOME: home, USERPROFILE: home, ZCODE_DATA_BASE_DIR: home, - ZCODE_NODE: Bun.which("node")!, ZCODE_CLI_CREDENTIALS_FILE: credentials, - ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: join(cli, "provider_config.json"), - ZCODE_TEST_CAPTURE: capture, ZCODE_TEST_EXIT: String(status), - ZCODE_APP_VERSION: status === 7 ? appVersion : "", - ZCODE_TEST_WRITE_CREDENTIALS: writeCredentials ? "1" : "0" }, - encoding: "utf8", timeout: 10_000 - }); - expect({ status: child.status, stderr: child.stderr }).toEqual({ status, stderr: "" }); - expect(JSON.parse(await readFile(capture, "utf8"))).toEqual({ - args: ["login", "--no-browser"], credentials, appVersion - }); - expect(await Bun.file(join(cli, "setup-pending")).exists()).toBe(pending); - } - } finally { - await rm(root, { recursive: true, force: true }); - } -}); diff --git a/test/node/sqlite-recovery.test.cjs b/test/node/sqlite-recovery.test.cjs index f2e6c55c..8eb29aa0 100644 --- a/test/node/sqlite-recovery.test.cjs +++ b/test/node/sqlite-recovery.test.cjs @@ -66,44 +66,6 @@ test("lock recovery yields to timers and restores the native timeout", { timeout }); }); -test("a write recovers after contention exceeds the former ten-second timeout", { timeout: 25_000 }, async () => { - await withDirectory(async (directory, dbPath) => { - const store = await Store.openStartup({ dbPath }); - let holder; - try { - holder = await startWorker("hold", { dbPath, holdMs: 10_500 }); - holder.send("wait"); - const startedAt = performance.now(); - await store.createSession(sessionInput(directory, "long-contention")); - assert.ok(performance.now() - startedAt >= 10_000); - assert.equal(store.db.prepare("select count(*) AS n from session").get().n, 1); - assert.ok(sqliteRecoveryStats(store).lastRecovery.attempts > 1); - await holder.wait(); - } finally { await holder?.stop(); store.close(); } - }); -}); - -test("connections in one process commit a native bundle atomically without blocking", { timeout: 10_000 }, async () => { - await withDirectory(async (directory, dbPath) => { - const first = await Store.openStartup({ dbPath }); - const second = await Store.openStartup({ dbPath }); - try { - const id = "imported", now = Date.now(); - // Runtime 0.17.0 in Desktop 3.16.3 runs bundle commits synchronously, so no - // transaction is observable between the call and its resolution. - const importing = first.commitSharedContextImportBundle({ - session: sessionInput(directory, id), - contextMessage: { info: { id: "context", sessionID: id, role: "user", visibility: "model-only", source: "shared_context", time: { created: now } }, parts: [] }, - provenance: { id: `provenance:${id}`, sessionID: id, type: "v4/shared_context_import", time: { created: now, updated: now }, data: { contextId: id, status: "pending" } } - }); - await Promise.all([importing, second.createSession(sessionInput(directory, "concurrent"))]); - assert.equal(first.db.isTransaction, false); - assert.equal(first.db.prepare("select count(*) AS n from session").get().n, 2); - assert.equal(sqliteRecoveryStats(second).retries, 0); - } finally { first.close(); second.close(); } - }); -}); - test("promotion rolls back a partial write before retrying and commits each message once", async () => { await withDirectory(async (directory, dbPath) => { const store = await Store.openStartup({ dbPath }); @@ -246,38 +208,6 @@ test("caller-owned transactions retain rollback ownership and are never replayed }); }); -test("a caller-owned transaction can finish while another connection waits in the write queue", { timeout: 5_000 }, async () => { - await withDirectory(async (directory, dbPath) => { - const owner = await Store.openStartup({ dbPath }); - const waiting = await Store.openStartup({ dbPath }); - try { - await owner.createSession(sessionInput(directory, "session")); - owner.db.exec("begin immediate"); - const write = waiting.createSession(sessionInput(directory, "waiting")); - await owner.saveSessionInput(input("session", "inside-transaction")); - owner.db.exec("commit"); - await write; - assert.equal(owner.db.prepare("select count(*) AS n from session").get().n, 2); - assert.equal(owner.db.prepare("select count(*) AS n from session_input").get().n, 1); - } finally { if (owner.db.isTransaction) owner.db.exec("rollback"); owner.close(); waiting.close(); } - }); -}); - -test("a queued write does not join a transaction opened after its submission", { timeout: 5_000 }, async () => { - await withDirectory(async (directory, dbPath) => { - const store = await Store.openStartup({ dbPath }); - try { - await store.createSession(sessionInput(directory, "session")); - const preceding = store.upsertToolUsage(usage("preceding")); - const queued = store.saveSessionInput(input("session", "queued-before-transaction")); - store.db.exec("begin immediate"); - const rollback = new Promise(resolve => setTimeout(() => { store.db.exec("rollback"); resolve(); }, 60)); - await Promise.all([preceding, queued, rollback]); - assert.equal(store.db.prepare("select count(*) AS n from session_input").get().n, 1); - } finally { if (store.db.isTransaction) store.db.exec("rollback"); store.close(); } - }); -}); - test("closing a store cancels backoff and queued writes without persisting them", { timeout: 10_000 }, async () => { await withDirectory(async (directory, dbPath) => { const store = await Store.openStartup({ dbPath }); @@ -300,110 +230,3 @@ test("closing a store cancels backoff and queued writes without persisting them" } finally { await holder?.stop(); if (!closed) store.close(); } }); }); - -test("closing one store immediately rejects its queued writes behind a different recovering store", { timeout: 10_000 }, async () => { - await withDirectory(async (directory, dbPath) => { - const active = await Store.openStartup({ dbPath }); - const queued = await Store.openStartup({ dbPath }); - let holder, closed = false, deadline; - try { - holder = await startWorker("hold", { dbPath }); - const activeWrite = active.createSession(sessionInput(directory, "active")); - const pending = queued.createSession(sessionInput(directory, "cancelled")); - const observed = pending.then(() => "written", error => /closed/u.test(error.message) ? "closed" : "unexpected error"); - queued.close(); closed = true; - assert.equal(await Promise.race([ - observed, - new Promise(resolve => { deadline = setTimeout(() => resolve("still queued"), 150); }) - ]), "closed"); - holder.send("release"); - await activeWrite; - assert.deepEqual(active.db.prepare("select id from session").all().map(row => row.id), ["active"]); - } finally { clearTimeout(deadline); await holder?.stop(); if (!closed) queued.close(); active.close(); } - }); -}); - -test("usage cleanup retains recent data without a cleanup transaction for every fact", async () => { - await withDirectory(async (directory, dbPath) => { - const store = await Store.openStartup({ dbPath }); - try { - await store.createSession(sessionInput(directory, "session")); - // Seed an expired row using the explicit native schema; automatic cleanup is still due. - store.db.prepare("insert into tool_usage (id,session_id,tool_call_id,tool_name,approval_status,status,started_at) values (?,?,?,?,?,?,?)") - .run("expired", "session", "expired", "Read", "none", "completed", 1); - let transactions = 0, deletes = 0; - const execute = store.db.exec.bind(store.db); - store.db.exec = sql => { if (sql === "begin immediate") transactions++; return execute(sql); }; - interceptRuns(store.db, sql => { if (/^delete from (model|turn|tool)_usage/u.test(sql)) deletes++; }); - for (let index = 0; index < 100; index++) await store.upsertToolUsage(usage(`current-${index}`)); - assert.equal(transactions, 1); - assert.equal(deletes, 3); - assert.equal(store.db.prepare("select count(*) AS n from tool_usage").get().n, 100); - await store.pruneUsage({ beforeTime: Date.now() + 1 }); - assert.equal(transactions, 2); - assert.equal(store.db.prepare("select count(*) AS n from tool_usage").get().n, 0); - } finally { store.close(); } - }); -}); - -test("queued conversation writes precede usage maintenance and each class remains ordered", async () => { - await withDirectory(async (directory, dbPath) => { - const store = await Store.openStartup({ dbPath }); - try { - await store.createSession(sessionInput(directory, "session")); - const order = []; - interceptRuns(store.db, (sql, args) => { - if (sql.startsWith("insert into tool_usage") || sql.startsWith("insert into session (")) order.push(args[0]); - }); - await Promise.all([ - store.upsertToolUsage(usage("usage-1")), - store.upsertToolUsage(usage("usage-2")), - store.createSession(sessionInput(directory, "conversation")) - ]); - assert.deepEqual(order, ["usage-1", "conversation", "usage-2"]); - } finally { store.close(); } - }); -}); - -test("a retrying usage write yields to conversation writes without reordering usage facts", async () => { - await withDirectory(async (directory, dbPath) => { - const store = await Store.openStartup({ dbPath }); - try { - await store.createSession(sessionInput(directory, "session")); - const order = []; - interceptRuns(store.db, (sql, args) => { - if (sql.startsWith("insert into tool_usage")) { - if (!order.includes("conversation")) throw Object.assign(new Error("busy usage"), { errcode: 5 }); - order.push(args[0]); - } else if (sql.startsWith("insert into session (")) order.push(args[0]); - }); - await Promise.all([ - store.upsertToolUsage(usage("usage-1")), - store.upsertToolUsage(usage("usage-2")), - store.createSession(sessionInput(directory, "conversation")) - ]); - assert.deepEqual(order, ["conversation", "usage-1", "usage-2"]); - assert.equal(sqliteRecoveryStats(store).lastRecovery.operation, "upsertToolUsage"); - } finally { store.close(); } - }); -}); - -test("twelve independent processes preserve inputs, transcripts and usage under sustained writes", { timeout: 60_000 }, async () => { - await withDirectory(async (_directory, dbPath) => { - const workers = [], processes = 12, turns = 25; - try { - for (let index = 0; index < processes; index++) workers.push(await startWorker("stress", { dbPath, id: `writer-${index}`, turns })); - for (const worker of workers) worker.send("go"); - await Promise.all(workers.map(worker => worker.wait())); - const store = await Store.openStartup({ dbPath }); - try { - for (const table of ["session_input", "message", "part", "tool_usage"]) { - assert.equal(store.db.prepare(`select count(*) AS n from ${table}`).get().n, processes * turns, table); - } - assert.equal(store.db.prepare("select count(*) AS n from session_input where status = 'promoted'").get().n, processes * turns); - assert.deepEqual(store.db.prepare("pragma foreign_key_check").all(), []); - assert.equal(store.db.prepare("pragma integrity_check").get().integrity_check, "ok"); - } finally { store.close(); } - } finally { await Promise.all(workers.map(worker => worker.stop())); } - }); -}); diff --git a/test/node/sqlite-session-store.test.cjs b/test/node/sqlite-session-store.test.cjs index 51852bcf..c8e2220b 100644 --- a/test/node/sqlite-session-store.test.cjs +++ b/test/node/sqlite-session-store.test.cjs @@ -23,27 +23,6 @@ test("real runtime keeps the write timeout after open and reopen", { timeout: 15 }); }); -test("a native session write survives a lock held beyond the old five-second window", { timeout: 20_000 }, async () => { - await withDirectory(async (directory, dbPath) => { - const store = await Store.openStartup({ dbPath }); - let holder; - try { - assert.equal(store.db.prepare("PRAGMA busy_timeout").get().timeout, 10_000); - holder = await startWorker("hold", { dbPath, holdMs: 6500 }); - // Keep the held interval independent of the writer's retry scheduling. - holder.send("wait"); - const startedAt = performance.now(); - await store.createSession(sessionInput(directory, "waited-session")); - assert.ok(performance.now() - startedAt >= 6000, "The write must overlap the held lock"); - await holder.wait(); - assert.equal(store.db.prepare("SELECT COUNT(*) AS count FROM session WHERE id = ?").get("waited-session").count, 1); - } finally { - await holder?.stop(); - store.close(); - } - }); -}); - test("lock timeout is bounded, writes nothing, and the same connection works after release", { timeout: 15_000 }, async () => { await withDirectory(async (directory, dbPath) => { const store = await BoundedStore.openStartup({ dbPath }); diff --git a/test/notifications.test.ts b/test/notifications.test.ts deleted file mode 100644 index 49778174..00000000 --- a/test/notifications.test.ts +++ /dev/null @@ -1,422 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { EventEmitter } from "node:events"; -import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { - detectedTerminal, - nativeNotificationCommand, - notificationDeliveryLabel, - notificationPreview, - notificationSettings, - osc9NotificationSequence, - readNotificationSettings, - readStoredNotificationSettings, - resolveNotificationBackend, - supportsOsc9, - terminalBundleIdentifier, - terminalNotifierCommand, - TurnNotifier, - writeNotificationSettings, - type NotificationCondition, - type NotificationMethod, - type NativeNotificationSender -} from "../packages/zcode-tui/src/notifications.ts"; -import { watchStreamErrors } from "../packages/zcode-tui/src/stream-error-guard.ts"; - -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((directory) => ( - rm(directory, { recursive: true, force: true }) - ))); -}); - -describe("TUI turn notifications", () => { - for (const method of ["off", "auto", "osc9", "bel", "native"] satisfies NotificationMethod[]) { - for (const condition of ["always", "unfocused"] satisfies NotificationCondition[]) { - test(`tracks focus independently of ${method}/${condition} notifications`, async () => { - const writes: string[] = []; - const states: string[] = []; - const notifier = new TurnNotifier({ - settings: { method, condition }, - env: { TERM_PROGRAM: "iTerm.app" }, - nativeNotify: async () => true, - writeTerminal: (data) => writes.push(data), - onFocusChange: (state) => states.push(state) - }); - notifier.start(); - expect(writes).toEqual(["\x1b[?1004h"]); - // No reports is a supported fallback, not evidence of focus loss. - expect(notifier.diagnostics().focus).toBe("unknown"); - expect(await notifier.notify("completed")).toBe(method !== "off"); - notifier.handleInput("\x1b[I"); - expect(await notifier.notify("completed")).toBe(method !== "off" && condition === "always"); - notifier.handleInput("\x1b[O"); - notifier.handleInput("\x1b[O"); - expect(await notifier.notify("completed")).toBe(method !== "off"); - notifier.setSettings({ method, condition: condition === "always" ? "unfocused" : "always" }); - expect(notifier.diagnostics().focus).toBe("unfocused"); - expect(writes.filter((data) => data.includes("1004"))).toEqual(["\x1b[?1004h"]); - notifier.stop(); - notifier.start(); - expect(states).toEqual(["focused", "unfocused"]); - notifier.handleInput("\x1b[I"); - expect(states).toEqual(["focused", "unfocused", "focused"]); - notifier.stop(); - expect(writes.at(-1)).toBe("\x1b[?1004l"); - }); - } - } - - test("uses quiet defaults and accepts explicit delivery settings", () => { - expect(notificationSettings({})).toEqual({ method: "auto", condition: "unfocused" }); - expect(notificationSettings({ - ZCODE_TUI_NOTIFICATION_METHOD: "native", - ZCODE_TUI_NOTIFICATION_CONDITION: "always" - })).toEqual({ method: "native", condition: "always" }); - expect(notificationSettings({ - ZCODE_TUI_NOTIFICATION_METHOD: "unknown", - ZCODE_TUI_NOTIFICATION_CONDITION: "unknown" - })).toEqual({ method: "auto", condition: "unfocused" }); - const config = { ui: { notifications: { method: "native", condition: "always" } } }; - expect(notificationSettings({}, config)).toEqual({ method: "native", condition: "always" }); - expect(notificationSettings({ ZCODE_TUI_NOTIFICATION_METHOD: "off" }, config)).toEqual({ - method: "off", - condition: "always" - }); - }); - - test("persists notification settings without replacing the rest of config.json", async () => { - const home = await mkdtemp(join(tmpdir(), "zcode-notification-config-")); - temporaryDirectories.push(home); - const env = { HOME: home, USERPROFILE: home }; - - const configPath = await writeNotificationSettings({ method: "native", condition: "always" }, env); - const config = JSON.parse(await readFile(configPath, "utf8")) as Record; - - expect(await readNotificationSettings(env)).toEqual({ method: "native", condition: "always" }); - expect(await readStoredNotificationSettings({ - ...env, - ZCODE_TUI_NOTIFICATION_METHOD: "off" - })).toEqual({ method: "native", condition: "always" }); - expect(config.modelStream).toBeDefined(); - expect(config.subagents).toBeDefined(); - expect(config.ui).toMatchObject({ - theme: "auto", - notifications: { method: "native", condition: "always" } - }); - }); - - test("builds safe bounded previews from untrusted assistant text", () => { - expect(notificationPreview(" Hello\n\tworld \x1b]9;injected\x07 ")).toBe("Hello world"); - expect(notificationPreview("A👨‍👩‍👧‍👦BCDE", 4)).toBe("A👨‍👩‍👧‍👦B…"); - expect(notificationPreview("\x1b[31m\x1b[0m")).toBeUndefined(); - }); - - test("matches Codex OSC 9 framing with and without tmux", () => { - expect(osc9NotificationSequence("done")).toBe("\x1b]9;done\x07"); - expect(osc9NotificationSequence("done", true)).toBe( - "\x1bPtmux;\x1b\x1b]9;done\x07\x1b\\" - ); - }); - - test("detects known OSC 9 terminals", () => { - expect(supportsOsc9({ TERM_PROGRAM: "iTerm.app" })).toBe(true); - expect(supportsOsc9({ WEZTERM_PANE: "1" })).toBe(true); - expect(supportsOsc9({ TERM_PROGRAM: "Apple_Terminal" })).toBe(false); - expect(detectedTerminal({ TERM_PROGRAM: "Apple_Terminal" })).toBe("Apple_Terminal"); - expect(resolveNotificationBackend("auto", { TERM_PROGRAM: "Apple_Terminal" })).toBe("bel"); - expect(resolveNotificationBackend("osc9", { TERM_PROGRAM: "Apple_Terminal" })).toBe("bel"); - expect(resolveNotificationBackend("osc9", { TERM_PROGRAM: "iTerm.app" })).toBe("osc9"); - expect(resolveNotificationBackend("native", { TERM_PROGRAM: "iTerm.app" })).toBe("native"); - expect(notificationDeliveryLabel("native", "native")).toBe("native"); - expect(notificationDeliveryLabel("auto", "bel")).toBe("auto → bel"); - expect(notificationDeliveryLabel("osc9", "bel")).toBe("osc9 → bel fallback"); - }); - - test("builds dependency-free native commands without shell interpolation", () => { - const commands = new Map([ - ["terminal-notifier", "/opt/bin/terminal-notifier"], - ["notify-send", "/usr/bin/notify-send"], - ["SnoreToast.exe", "C:\\Tools\\SnoreToast.exe"] - ]); - const which = (name: string) => commands.get(name) ?? null; - - expect(terminalBundleIdentifier({ TERM_PROGRAM: "iTerm.app" })).toBe("com.googlecode.iterm2"); - expect(terminalBundleIdentifier({ TERM_PROGRAM: "vscode" })).toBe("com.microsoft.VSCode"); - expect(terminalBundleIdentifier({})).toBeUndefined(); - const command = terminalNotifierCommand("title ' quoted", "body \" quoted", { - TERM_PROGRAM: "Apple_Terminal" - }, which); - expect(command).toEqual({ - command: "/opt/bin/terminal-notifier", - args: [ - "-title", "title ' quoted", - "-message", "body \" quoted", - "-group", "zcode-cli-turn", - "-sender", "com.apple.Terminal", - "-activate", "com.apple.Terminal" - ] - }); - expect(terminalNotifierCommand("Title", "Body", {}, () => null)).toBeUndefined(); - expect(nativeNotificationCommand("linux", "Title", "Body", {}, which)).toEqual({ - command: "/usr/bin/notify-send", - args: ["--app-name=ZCode", "Title", "Body"] - }); - expect(nativeNotificationCommand("win32", "Title", "Body", {}, which)).toEqual({ - command: "C:\\Tools\\SnoreToast.exe", - args: ["-t", "Title", "-m", "Body", "-appID", "ZCode CLI"] - }); - expect(nativeNotificationCommand("freebsd", "Title", "Body", {}, which)).toBeUndefined(); - expect(command?.args).toEqual([ - "-title", "title ' quoted", - "-message", "body \" quoted", - "-group", "zcode-cli-turn", - "-sender", "com.apple.Terminal", - "-activate", "com.apple.Terminal" - ]); - }); - - test("resolves native notification commands from PATH without Bun", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-notification-path-")); - temporaryDirectories.push(directory); - const executable = join(directory, "notify-send"); - await writeFile(executable, "#!/bin/sh\nexit 0\n"); - await chmod(executable, 0o755); - - expect(nativeNotificationCommand("linux", "Title", "Body", { PATH: directory })).toEqual({ - command: executable, - args: ["--app-name=ZCode", "Title", "Body"] - }); - }); - - test("uses reported focus and does not suppress notifications while focus is unknown", async () => { - const writes: string[] = []; - const notifier = new TurnNotifier({ - env: { TERM_PROGRAM: "iTerm.app" }, - platform: "darwin", - settings: { method: "auto", condition: "unfocused" }, - writeTerminal: (data) => writes.push(data) - }); - - notifier.start(); - expect(writes).toEqual(["\x1b[?1004h"]); - expect(notifier.diagnostics()).toEqual({ - configuredMethod: "auto", - backend: "osc9", - focus: "unknown", - terminal: "iTerm.app" - }); - expect(await notifier.notify("completed", "Focus support is not known yet.")).toBe(true); - expect(notifier.handleInput("\x1b[I")).toBe(true); - expect(await notifier.notify("completed", "Finished the task.")).toBe(false); - expect(notifier.handleInput("\x1b[O")).toBe(true); - expect(await notifier.notify("completed", "Finished\n the task.")).toBe(true); - expect(writes.at(-1)).toBe("\x1b]9;ZCode · Finished the task.\x07"); - expect(notifier.handleInput("\x1b[I")).toBe(true); - expect(notifier.handleInput("x")).toBe(false); - notifier.stop(); - expect(writes.at(-1)).toBe("\x1b[?1004l"); - }); - - test("keeps focus reporting enabled across notification settings", () => { - const writes: string[] = []; - const notifier = new TurnNotifier({ - settings: { method: "auto", condition: "unfocused" }, - writeTerminal: (data) => writes.push(data) - }); - - notifier.start(); - expect(notifier.handleInput("\x1b[I")).toBe(true); - notifier.setSettings({ method: "off", condition: "unfocused" }); - notifier.setSettings({ method: "bel", condition: "always" }); - notifier.setSettings({ method: "bel", condition: "unfocused" }); - - expect(notifier.currentSettings()).toEqual({ method: "bel", condition: "unfocused" }); - expect(writes).toEqual(["\x1b[?1004h"]); - // Reporting never turns off mid-session, so focus knowledge survives. - expect(notifier.diagnostics().focus).toBe("focused"); - }); - - test("reports focus transitions even when notifications are off", () => { - const writes: string[] = []; - const states: string[] = []; - const notifier = new TurnNotifier({ - settings: { method: "off", condition: "always" }, - onFocusChange: (state) => states.push(state), - writeTerminal: (data) => writes.push(data) - }); - - notifier.start(); - expect(writes).toEqual(["\x1b[?1004h"]); - - expect(notifier.handleInput("\x1b[O")).toBe(true); - // Repeated reports of the same state must not re-fire the callback. - expect(notifier.handleInput("\x1b[O")).toBe(true); - expect(notifier.handleInput("\x1b[I")).toBe(true); - expect(notifier.handleInput("x")).toBe(false); - expect(states).toEqual(["unfocused", "focused"]); - - notifier.stop(); - expect(writes.at(-1)).toBe("\x1b[?1004l"); - // Focus knowledge resets do not fire the callback, so cursor owners can - // keep their last known state across stop/start cycles. - expect(states).toEqual(["unfocused", "focused"]); - }); - - test("uses Codex-style BEL fallback in Apple Terminal without requiring focus support", async () => { - let nativeCalls = 0; - const writes: string[] = []; - const automatic = new TurnNotifier({ - env: { TERM_PROGRAM: "Apple_Terminal" }, - platform: "linux", - settings: { method: "osc9", condition: "unfocused" }, - writeTerminal: (data) => writes.push(data), - nativeNotify: async () => { - nativeCalls += 1; - return true; - } - }); - - automatic.start(); - expect(await automatic.notify("failed", "Build failed")).toBe(true); - expect(nativeCalls).toBe(0); - expect(writes).toEqual(["\x1b[?1004h", "\x07"]); - expect(automatic.diagnostics()).toEqual({ - configuredMethod: "osc9", - backend: "bel", - focus: "unknown", - terminal: "Apple_Terminal" - }); - automatic.stop(); - - const native = new TurnNotifier({ - env: {}, - platform: "linux", - settings: { method: "native", condition: "always" }, - writeTerminal: (data) => writes.push(data), - nativeNotify: async () => false - }); - expect(await native.notify("completed")).toBe(true); - expect(writes.at(-1)).toBe("\x07"); - }); - - test("uses the explicitly selected native backend when its command succeeds", async () => { - const calls: Parameters[] = []; - const writes: string[] = []; - const notifier = new TurnNotifier({ - platform: "linux", - settings: { method: "native", condition: "always" }, - writeTerminal: (data) => writes.push(data), - nativeNotify: async (...args) => { - calls.push(args); - return true; - } - }); - - expect(await notifier.notify("failed", "Build failed")).toBe(true); - expect(calls).toEqual([["linux", "ZCode · Task failed", "Build failed"]]); - expect(writes).toEqual([]); - }); - - test("treats synchronous terminal write failures as sticky", async () => { - const writes: string[] = []; - let failing = false; - const notifier = new TurnNotifier({ - env: {}, - settings: { method: "auto", condition: "always" }, - writeTerminal: (data) => { - if (failing) { - const error = new Error("write EIO") as NodeJS.ErrnoException; - error.code = "EIO"; - throw error; - } - writes.push(data); - } - }); - - notifier.start(); - expect(await notifier.notify("completed", "Done")).toBe(true); - expect(writes).toEqual(["\x1b[?1004h", "\x07"]); - - failing = true; - expect(await notifier.notify("completed", "Done")).toBe(false); - failing = false; - // The failure is sticky: writes stay suppressed even after the underlying - // stream would accept data again. - expect(await notifier.notify("completed", "Done")).toBe(false); - expect(writes).toEqual(["\x1b[?1004h", "\x07"]); - - notifier.stop(); - expect(writes).toEqual(["\x1b[?1004h", "\x07"]); - }); - - test("stops terminal writes after an asynchronous stream error", async () => { - const writes: string[] = []; - const stdout = new EventEmitter(); - const stderr = new EventEmitter(); - const ignoreError = () => {}; - stdout.on("error", ignoreError); - stderr.on("error", ignoreError); - const notifier = new TurnNotifier({ - env: {}, - settings: { method: "auto", condition: "always" }, - writeTerminal: (data) => writes.push(data) - }); - let handledErrors = 0; - const dispose = watchStreamErrors([stdout, stderr], () => { - handledErrors += 1; - notifier.markTerminalUnavailable(); - }); - - notifier.start(); - expect(await notifier.notify("completed", "Done")).toBe(true); - await new Promise((resolve) => { - setImmediate(() => { - stdout.emit("error", Object.assign(new Error("write EIO"), { code: "EIO" })); - resolve(); - }); - }); - - expect(handledErrors).toBe(1); - expect(await notifier.notify("completed", "Done")).toBe(false); - notifier.stop(); - expect(writes).toEqual(["\x1b[?1004h", "\x07"]); - - dispose(); - dispose(); - expect(stdout.listenerCount("error")).toBe(1); - expect(stderr.listenerCount("error")).toBe(1); - stderr.emit("error", new Error("detached")); - expect(handledErrors).toBe(1); - }); - - test("uses BEL for automatic SSH notifications and supports disabling notifications", async () => { - let commandRuns = 0; - const writes: string[] = []; - const remote = new TurnNotifier({ - env: { SSH_CONNECTION: "client server" }, - platform: "linux", - settings: { method: "auto", condition: "always" }, - writeTerminal: (data) => writes.push(data), - nativeNotify: async () => { - commandRuns += 1; - return true; - } - }); - expect(await remote.notify("completed", "Done")).toBe(true); - expect(commandRuns).toBe(0); - expect(writes).toEqual(["\x07"]); - - const disabled = new TurnNotifier({ - settings: { method: "off", condition: "always" }, - writeTerminal: (data) => writes.push(data) - }); - disabled.start(); - expect(await disabled.notify("completed", "Done")).toBe(false); - disabled.stop(); - expect(writes).toEqual(["\x07", "\x1b[?1004h", "\x1b[?1004l"]); - }); -}); diff --git a/test/package-command.test.ts b/test/package-command.test.ts deleted file mode 100644 index 540e77b8..00000000 --- a/test/package-command.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { runPackageCommand } from "../scripts/package-command.ts"; - -const node = Bun.which("node")!; - -test("package commands preserve stdout and nonzero exit codes", async () => { - const result = await runPackageCommand(node, ["--eval", "console.log('检查完成');process.exitCode=7"], { - cwd: process.cwd(), label: "Fixture check", timeoutMs: 5_000 - }); - expect(result).toEqual({ code: 7, stdout: "检查完成\n" }); -}); - -test("package command timeouts terminate the process", async () => { - await expect(runPackageCommand(node, ["--eval", "process.on('SIGTERM',()=>{});setInterval(()=>{},1000)"], { - cwd: process.cwd(), label: "Fixture initialization", timeoutMs: 500 - })).rejects.toThrow("Fixture initialization timed out after 500 ms."); -}, 10_000); - -test.skipIf(process.platform === "win32").each([false, true])( - "package timeouts kill descendants holding stdout open (leader exits: %p)", - async (leaderExits) => { - const directory = await mkdtemp(join(tmpdir(), "zcode-command-timeout-")); - const heartbeat = join(directory, "heartbeat"), pidsPath = join(directory, "pids.json"); - const descendant = ` - const fs = require("node:fs"); - process.on("SIGTERM", () => {}); - let tick = 0; - setInterval(() => fs.writeFileSync(${JSON.stringify(heartbeat)}, String(++tick)), 20); - `; - const script = ` - const fs = require("node:fs"); - const child = require("node:child_process").spawn(process.execPath, ["--eval", ${JSON.stringify(descendant)}], { - stdio: ["ignore", "inherit", "ignore"] - }); - fs.writeFileSync(${JSON.stringify(pidsPath)}, JSON.stringify([process.pid, child.pid])); - if (${leaderExits}) { child.unref(); process.exit(0); } - process.on("SIGTERM", () => {}); - setInterval(() => {}, 1000); - `; - try { - await expect(runPackageCommand(node, ["--eval", script], { - cwd: directory, label: "Descendant fixture", timeoutMs: 1_500 - })).rejects.toThrow("Descendant fixture timed out after 1500 ms."); - const stopped = await readFile(heartbeat, "utf8"); - await Bun.sleep(100); - expect(await readFile(heartbeat, "utf8")).toBe(stopped); - } finally { - if (await Bun.file(pidsPath).exists()) { - for (const pid of JSON.parse(await readFile(pidsPath, "utf8")) as number[]) { - try { process.kill(pid, "SIGKILL"); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; - } - } - } - await rm(directory, { recursive: true, force: true }); - } - }, 10_000 -); diff --git a/test/panels.test.ts b/test/panels.test.ts deleted file mode 100644 index 92501b9c..00000000 --- a/test/panels.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - formatWorkflowPanel, - isMcpPickerRequest, - isTerminalWorkflowStatus, - mcpPicker, - workflowRunPicker -} from "../packages/zcode-tui/src/panels.ts"; - -describe("TUI operational panels", () => { - test("turns MCP status entries into connect and disconnect actions", () => { - const picker = mcpPicker({ - alpha: { status: "connected", toolCount: 2, transport: "stdio" }, - beta: { status: "disconnected", toolCount: 0, transport: "http", error: "offline" } - }); - - expect(picker.items.map((item) => item.command)).toEqual([ - "/mcp disconnect alpha", - "/mcp connect beta" - ]); - expect(picker.items[1]?.description).toContain("offline"); - expect(isMcpPickerRequest("/mcp status")).toBe(true); - expect(isMcpPickerRequest("/mcp connect alpha")).toBe(false); - }); - - test("builds workflow run choices and a bounded detail projection", () => { - const panel = { - title: "/workflows", - selectedRunId: "run-2", - runs: [ - { runId: "run-1", status: "completed", task: "First" }, - { runId: "run-2", status: "running", task: "Second", kind: "script" } - ], - detail: { - snapshot: { runId: "run-2", status: "running", task: "Second", updatedAt: "2026-07-13T10:00:00Z" }, - scheduler: { active: 2, completed: 3, total: 5 }, - events: [ - { timestamp: "2026-07-13T10:00:00Z", type: "phase.started", status: "running" } - ] - } - }; - const picker = workflowRunPicker(panel); - const text = formatWorkflowPanel(panel); - - expect(picker.selectedIndex).toBe(1); - expect(picker.items[1]?.command).toBe("run-2"); - expect(text).toContain("**Second**"); - expect(text).toContain("Status: running"); - expect(text).toContain("active: 2"); - expect(text).toContain("phase.started"); - expect(isTerminalWorkflowStatus("completed")).toBe(true); - expect(isTerminalWorkflowStatus("running")).toBe(false); - }); -}); diff --git a/test/permission-request-queue.test.ts b/test/permission-request-queue.test.ts index 8a24ffc4..c0d20f71 100644 --- a/test/permission-request-queue.test.ts +++ b/test/permission-request-queue.test.ts @@ -118,16 +118,6 @@ describe("permission request queue", () => { expect(await next).toMatchObject({ value: "allow" }); }); - test("reads the request signal from runtime or TUI options", () => { - const runtime = new AbortController(); - const tui = new AbortController(); - expect(permissionRequestSignal({ signal: runtime.signal })).toBe(runtime.signal); - expect(permissionRequestSignal({ abortSignal: tui.signal })).toBe(tui.signal); - expect(permissionRequestSignal({ signal: runtime.signal, abortSignal: tui.signal })).toBe(runtime.signal); - expect(permissionRequestSignal({ signal: "nope" })).toBeUndefined(); - expect(permissionRequestSignal(undefined)).toBeUndefined(); - }); - test("continues with the next request after an exception", async () => { const queue = new PermissionRequestQueue(); const { host, ui } = dialogHarness(); diff --git a/test/permission-view.test.ts b/test/permission-view.test.ts deleted file mode 100644 index 68632208..00000000 --- a/test/permission-view.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { PermissionPreview } from "../packages/zcode-tui/src/permission-view.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -describe("TUI permission preview", () => { - test("uses readable light-terminal emphasis and strips external SGR", () => { - const view = new PermissionPreview( - createTheme(true, "light"), - "Bash", - { command: "\x1b[47;8;7mbun test\x1b[0m" }, - "medium" - ); - const rendered = view.render(72).join("\n"); - expect(rendered).toContain("\x1b[38;5;58;48;5;230m"); - expect(rendered).toContain("bun test"); - expect(rendered).not.toContain("\x1b[47;8;7m"); - }); - - test("reserves the error surface for high-risk permission requests", () => { - const view = new PermissionPreview( - createTheme(true, "dark"), - "Bash", - { command: "rm -rf build" }, - "high" - ); - expect(view.render(72).join("\n")).toContain("\x1b[38;5;252;48;5;52m"); - }); -}); diff --git a/test/plan-editor.test.ts b/test/plan-editor.test.ts deleted file mode 100644 index df2af1d9..00000000 --- a/test/plan-editor.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { expect, test } from "bun:test"; -import { CURSOR_MARKER, Editor, visibleWidth, type TUI } from "@earendil-works/pi-tui"; -import { PlanEditor } from "../packages/zcode-tui/src/plan-editor.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -function buildEditor(): { editor: PlanEditor; original: Editor } { - const tui = { terminal: { rows: 24 }, requestRender() {} } as unknown as TUI; - const theme = createTheme(false); - const editor = new PlanEditor(tui, theme.editor, { paddingX: 1 }); - const original = new Editor(tui, theme.editor, { paddingX: 1 }); - editor.focused = original.focused = true; - return { editor, original }; -} - -test("Plan uses the existing border without moving the editor cursor or adding rows", () => { - const { editor, original } = buildEditor(); - for (const text of ["", "修改登录逻辑 👩‍💻", Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n")]) { - editor.setText(text); - original.setText(text); - for (const width of [4, 8, 20, 40, 80, 120]) { - editor.planEnabled = false; - const before = original.render(width); - expect(editor.render(width)).toEqual(before); - editor.planEnabled = true; - const after = editor.render(width); - expect(after.length).toBe(before.length); - expect(after[0]).toContain("Plan"); - expect(after[0]?.endsWith(width >= 10 ? " Plan ──" : "Plan")).toBe(true); - expect(visibleWidth(after[0]!)).toBe(width); - if (text) expect(after.slice(1)).toEqual(before.slice(1)); - else { - expect(editor.getText()).toBe(""); - expect(after[1]).toContain(CURSOR_MARKER); - expect(after[1]?.slice(0, after[1].indexOf(CURSOR_MARKER) + CURSOR_MARKER.length)) - .toBe(before[1]?.slice(0, before[1].indexOf(CURSOR_MARKER) + CURSOR_MARKER.length)); - expect(visibleWidth(after[1]!)).toBe(width); - } - if (width >= 40 && before[0]?.includes("↑")) expect(after[0]).toContain("↑"); - } - } -}); - -test("terminal-unfocused rendering drops the drawn caret without changing cells", () => { - const { editor } = buildEditor(); - for (const text of ["", "inspect @src", "修改登录逻辑 👩‍💻"]) { - editor.setText(text); - for (const width of [8, 40, 80]) { - editor.isTerminalFocused = () => true; - const focused = editor.render(width); - editor.isTerminalFocused = () => false; - const unfocused = editor.render(width); - expect(focused.join("")).toContain("\x1b_pi:fc\x07"); - expect(unfocused.join("")).not.toContain("\x1b_pi:fc\x07"); - expect(unfocused.length).toBe(focused.length); - for (let i = 0; i < focused.length; i++) { - expect(visibleWidth(unfocused[i]!)).toBe(visibleWidth(focused[i]!)); - } - editor.isTerminalFocused = () => true; - expect(editor.render(width)).toEqual(focused); - } - } -}); diff --git a/test/plan-view.test.ts b/test/plan-view.test.ts deleted file mode 100644 index 75762d0e..00000000 --- a/test/plan-view.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { - isPlanUpdateTool, - planCard, - PlanUpdateView -} from "../packages/zcode-tui/src/plan-view.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; -import { ToolExecutionView } from "../packages/zcode-tui/src/tool-view.ts"; - -const todos = [ - { content: "映射官方生命周期事件", status: "completed", priority: "high" }, - { content: "渲染计划更新", status: "in_progress", priority: "high" }, - { content: "同步本地 runtime", status: "pending", priority: "medium" } -]; - -describe("TUI plan update view", () => { - test("renders official TodoWrite items as a compact checklist", () => { - const card = planCard({ state: "complete", input: { todos } }); - - expect(card).toContain("● Updated Plan"); - expect(card).toContain("└ 1 completed · 1 in progress · 1 pending"); - expect(card).toContain("✓ 映射官方生命周期事件"); - expect(card).toContain("□ 渲染计划更新"); - expect(card).toContain("□ 同步本地 runtime"); - expect(card).not.toContain("priority"); - expect(card).not.toContain("TodoWrite"); - }); - - test("shows live and failed plan update states", () => { - expect(planCard({ state: "running", input: { todos } })).toContain("● Updating Plan · running"); - expect(planCard({ state: "failed", input: { todos }, error: { message: "Todo storage failed" } })) - .toContain("✗ Plan update failed"); - expect(planCard({ state: "failed", input: { todos }, error: { message: "Todo storage failed" } })) - .toContain("Todo storage failed"); - }); - - test("prefers authoritative result todos and stays within narrow terminals", () => { - const view = new PlanUpdateView(createTheme(false), { - state: "complete", - input: { todos }, - result: { - output: { - todos: todos.map((todo) => ({ ...todo, status: "completed" })) - } - } - }); - const lines = view.render(32); - - expect(lines.join("\n")).toContain("3 completed · 0 in progress"); - expect(lines.every((line) => visibleWidth(line) <= 32)).toBe(true); - }); - - test("integrates with the existing tool card without raw JSON", () => { - const view = new ToolExecutionView(createTheme(false), { - name: "TodoWrite", - state: "running", - inputText: JSON.stringify({ todos }) - }); - const output = view.render(72).map((line) => line.trimEnd()).join("\n"); - - expect(isPlanUpdateTool("TodoWrite")).toBe(true); - expect(output).toContain("Updating Plan"); - expect(output).toContain("渲染计划更新"); - expect(output).not.toContain('"todos"'); - }); -}); diff --git a/test/plugin-cli.test.ts b/test/plugin-cli.test.ts deleted file mode 100644 index 0ca86e0e..00000000 --- a/test/plugin-cli.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Readable, Writable } from "node:stream"; - -import { AppServerProcessError } from "../src/app-server-client.ts"; -import { runPluginCommand, type PluginRequestInput } from "../src/plugin-cli.ts"; - -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { - recursive: true, - force: true - }))); -}); - -function output(): { stream: Writable & { isTTY?: boolean }; text: () => string } { - let value = ""; - const stream = new Writable({ - write(chunk, _encoding, callback) { - value += String(chunk); - callback(); - } - }) as Writable & { isTTY?: boolean }; - return { stream, text: () => value }; -} - -function harness(responses: Record = {}) { - const stdout = output(); - const stderr = output(); - const calls: PluginRequestInput[] = []; - return { - calls, - options: { - confirm: async () => true, - cwd: "/workspace", - request: async (request: PluginRequestInput) => { - calls.push(request); - return responses[request.method] ?? {}; - }, - stderr: stderr.stream, - stdout: stdout.stream - }, - stderr, - stdout - }; -} - -describe("plugin CLI routing", () => { - test("delegates runtime-owned commands and prompt text", async () => { - const testHarness = harness(); - expect(await runPluginCommand(["plugins", "list", "--json"], testHarness.options)).toBeUndefined(); - expect(await runPluginCommand(["--prompt", "plugins", "install"], testHarness.options)).toBeUndefined(); - expect(testHarness.calls).toHaveLength(0); - }); - - test("shows extended plugin help and rejects unknown options locally", async () => { - const helpHarness = harness(); - expect(await runPluginCommand(["plugins", "--help"], helpHarness.options)).toBe(0); - expect(helpHarness.stdout.text()).toContain("zcode plugins marketplace add"); - - const invalidHarness = harness(); - expect(await runPluginCommand( - ["plugins", "install", "audit@example", "--unknown"], - invalidHarness.options - )).toBe(1); - expect(invalidHarness.stderr.text()).toContain("Unknown option"); - expect(invalidHarness.calls).toHaveLength(0); - }); - - test("lists marketplace discovery through the public protocol", async () => { - const overview = { - marketplaces: [{ id: "official", pluginCount: 2, isOfficial: true }], - availablePlugins: [], - installedPlugins: [], - restorableBuiltins: [] - }; - const testHarness = harness({ "plugins/overview": overview }); - - expect(await runPluginCommand(["--json", "plugins", "discover"], testHarness.options)).toBe(0); - expect(testHarness.calls).toHaveLength(1); - expect(testHarness.calls[0]).toMatchObject({ - method: "plugins/overview", - params: { - workspace: { workspacePath: "/workspace", workspaceKey: "/workspace" } - }, - workingDirectory: "/workspace" - }); - expect(JSON.parse(testHarness.stdout.text())).toEqual(overview); - }); - - test("returns the app-server process exit code", async () => { - const testHarness = harness(); - testHarness.options.request = async () => { - throw new AppServerProcessError("app-server failed", 7); - }; - - expect(await runPluginCommand(["plugins", "discover"], testHarness.options)).toBe(7); - }); - - test("previews plugin components and dependencies before installing", async () => { - const testHarness = harness({ - "plugins/describe": { - components: [{ kind: "skill", items: [{ name: "audit" }] }], - metadata: { author: "Example", version: "1.0.0" } - }, - "plugins/install": { - dependencyClosure: ["base@example"], - installedPlugins: [{ id: "audit@example" }] - } - }); - - expect(await runPluginCommand( - ["plugins", "install", "audit@example", "--scope", "workspace", "--yes"], - testHarness.options - )).toBe(0); - expect(testHarness.calls.map((call) => call.method)).toEqual([ - "plugins/overview", - "plugins/describe", - "plugins/install", - "plugins/install" - ]); - expect(testHarness.calls[2]?.params).toMatchObject({ - dryRun: true, - marketplace: "example", - pluginName: "audit", - scope: "workspace" - }); - expect(testHarness.calls[3]?.params).not.toHaveProperty("dryRun"); - expect(testHarness.stdout.text()).toContain("Components: skill: audit"); - expect(testHarness.stdout.text()).toContain("Dependencies: base@example"); - }); - - test("does not invent dependency information for describe", async () => { - const testHarness = harness({ - "plugins/describe": { - components: [{ kind: "skill", items: [{ name: "audit" }] }] - } - }); - - expect(await runPluginCommand( - ["plugins", "describe", "audit@example"], - testHarness.options - )).toBe(0); - expect(testHarness.stdout.text()).toContain("Components: skill: audit"); - expect(testHarness.stdout.text()).not.toContain("Dependencies:"); - }); - - test("does not mutate when installation confirmation is declined", async () => { - const testHarness = harness({ - "plugins/describe": { components: [] }, - "plugins/install": { dependencyClosure: [], installedPlugins: [] } - }); - testHarness.options.confirm = async () => false; - - expect(await runPluginCommand(["plugins", "install", "audit@example"], testHarness.options)).toBe(1); - expect(testHarness.calls.map((call) => call.method)).toEqual([ - "plugins/overview", - "plugins/describe", - "plugins/install" - ]); - expect(testHarness.stderr.text()).toContain("installation cancelled"); - }); - - test("validates a marketplace before adding it", async () => { - const testHarness = harness({ - "plugins/marketplace/add": { marketplace: { id: "example" }, diagnostics: [] } - }); - - expect(await runPluginCommand( - ["plugins", "marketplace", "add", "owner/repository", "--yes"], - testHarness.options - )).toBe(0); - expect(testHarness.calls.map((call) => call.params.dryRun)).toEqual([true, undefined]); - expect(testHarness.stdout.text()).not.toContain("capability changes apply"); - }); - - test("rejects ignored dry-run flags before destructive operations", async () => { - const testHarness = harness(); - - expect(await runPluginCommand([ - "plugins", - "marketplace", - "remove", - "example", - "--dry-run", - "--yes" - ], testHarness.options)).toBe(1); - expect(testHarness.stderr.text()).toContain("--dry-run is not supported"); - expect(testHarness.calls).toHaveLength(0); - }); - - test("keeps JSON stdout parseable while prompting on stderr", async () => { - const testHarness = harness({ - "plugins/marketplace/add": { marketplace: { id: "example" }, diagnostics: [] } - }); - const stdin = Readable.from(["yes\n"]) as Readable & { isTTY?: boolean }; - stdin.isTTY = true; - testHarness.stdout.stream.isTTY = true; - testHarness.stderr.stream.isTTY = true; - - expect(await runPluginCommand( - ["plugins", "marketplace", "add", "owner/repository", "--json"], - { - ...testHarness.options, - confirm: undefined, - stdin - } - )).toBe(0); - expect(JSON.parse(testHarness.stdout.text())).toMatchObject({ marketplace: { id: "example" } }); - expect(testHarness.stderr.text()).toContain("Add marketplace from owner/repository?"); - }); - - test("loads plugin configuration from a JSON file", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-plugin-options-")); - temporaryDirectories.push(directory); - await writeFile(join(directory, "options.json"), JSON.stringify({ endpoint: "https://example.com" })); - const testHarness = harness({ "plugins/configure": { pluginId: "audit@example", diagnostics: [] } }); - testHarness.options.cwd = directory; - - expect(await runPluginCommand([ - "plugins", - "configure", - "audit@example", - "--options-file", - "options.json", - "--dry-run" - ], testHarness.options)).toBe(0); - expect(testHarness.calls[0]?.params).toMatchObject({ - dryRun: true, - options: { endpoint: "https://example.com" }, - pluginId: "audit@example" - }); - }); - - test("rejects malformed coordinates before calling the runtime", async () => { - const testHarness = harness(); - expect(await runPluginCommand( - ["plugins", "describe", "missing-marketplace"], - testHarness.options - )).toBe(1); - expect(testHarness.calls).toHaveLength(0); - expect(testHarness.stderr.text()).toContain("Expected @"); - }); - - test("removes terminal control characters from errors", async () => { - const testHarness = harness(); - expect(await runPluginCommand( - ["plugins", "describe", "bad\u001b[31m@example"], - testHarness.options - )).toBe(1); - expect(testHarness.stderr.text()).not.toContain("\u001b"); - expect(testHarness.stderr.text()).toContain("bad?[31m@example"); - }); - - test("does not install when dry-run diagnostics contain errors", async () => { - const testHarness = harness({ - "plugins/describe": { - components: [], - diagnostics: [{ code: "plugin_not_found", message: "Missing", severity: "error" }] - }, - "plugins/install": { - dependencyClosure: [], - installedPlugins: [], - diagnostics: [{ code: "plugin_not_found", message: "Missing", severity: "error" }] - } - }); - - expect(await runPluginCommand( - ["plugins", "install", "missing@example", "--yes", "--json"], - testHarness.options - )).toBe(1); - expect(testHarness.calls.map((call) => call.method)).toEqual([ - "plugins/overview", - "plugins/describe", - "plugins/install" - ]); - }); - - test("maps validation error diagnostics to a failed exit status and message", async () => { - const testHarness = harness({ - "plugins/validate": { - diagnostics: [{ message: "Invalid manifest", severity: "error" }] - } - }); - - expect(await runPluginCommand( - ["plugins", "validate", "--source", "./plugin"], - testHarness.options - )).toBe(1); - expect(testHarness.stdout.text()).toContain("Plugin validation failed."); - expect(testHarness.stdout.text()).not.toContain("changes apply"); - }); -}); diff --git a/test/plugin-references.test.ts b/test/plugin-references.test.ts deleted file mode 100644 index 00831ba7..00000000 --- a/test/plugin-references.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { - createRuntimePluginReferenceLister, - isPluginReferenceValue, - normalizePluginReferenceEntries, - PluginReferenceCatalog, - pluginReferenceMarkdown, - pluginReferenceSuggestions -} from "../packages/zcode-tui/src/plugin-references.ts"; - -const browserPlugin = { - conflictingPluginIds: [], - enabled: true, - marketplace: "zcode-plugins-official", - mcpServerNames: [], - name: "browser-use", - pluginId: "browser-use@zcode-plugins-official", - skillQualifiedNames: ["browser-use:control-browser", "browser-use:web-gui-tester"], - subagentNames: [] -}; - -describe("runtime Plugin references", () => { - test("keeps only unambiguous enabled plugins with referenceable capabilities", () => { - expect(normalizePluginReferenceEntries({ - authority: "workspace", - plugins: [ - browserPlugin, - { ...browserPlugin, pluginId: "disabled@example", name: "disabled", marketplace: "example", enabled: false }, - { - ...browserPlugin, - pluginId: "ambiguous@example", - name: "ambiguous", - marketplace: "example", - conflictingPluginIds: ["other@example"] - }, - { ...browserPlugin, pluginId: "empty@example", name: "empty", marketplace: "example", skillQualifiedNames: [] }, - { ...browserPlugin, pluginId: "bad-id", name: "bad", marketplace: "example" } - ] - })).toEqual([{ - description: "Plugin | zcode-plugins-official | 2 skills", - marketplace: "zcode-plugins-official", - name: "browser-use", - pluginId: "browser-use@zcode-plugins-official" - }]); - }); - - test("inserts the runtime-native plugin Markdown link", () => { - const plugin = normalizePluginReferenceEntries({ plugins: [browserPlugin] })[0]!; - const value = pluginReferenceMarkdown(plugin); - expect(value).toBe("[@browser-use](plugin://browser-use@zcode-plugins-official)"); - expect(isPluginReferenceValue(value)).toBe(true); - expect(isPluginReferenceValue("@browser-use")).toBe(false); - expect(pluginReferenceSuggestions([plugin], "browser", 20)).toEqual([{ - value, - label: "@browser-use", - description: "Plugin | zcode-plugins-official | 2 skills" - }]); - }); - - test("caches successful discovery and retries transient catalog errors", async () => { - let calls = 0; - const catalog = new PluginReferenceCatalog(async () => { - calls += 1; - if (calls > 1) throw new Error("unavailable"); - return { plugins: [browserPlugin] }; - }); - - expect(await catalog.list()).toHaveLength(1); - expect(await catalog.list()).toHaveLength(1); - expect(calls).toBe(1); - - let transientCalls = 0; - const unavailable = new PluginReferenceCatalog(async () => { - transientCalls += 1; - if (transientCalls === 1) throw new Error("unavailable"); - return { plugins: [browserPlugin] }; - }); - expect(await unavailable.list()).toEqual([]); - expect(await unavailable.list()).toHaveLength(1); - expect(transientCalls).toBe(2); - }); - - test("queries app-server directly from the running runtime without a bridge patch", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-plugin-reference-runtime-")); - const runtime = join(directory, "zcode.cjs"); - await writeFile(runtime, ` - let input = ""; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", chunk => { - input += chunk; - if (!input.includes("\\n")) return; - const request = JSON.parse(input.trim()); - console.log(JSON.stringify({ id: request.id, result: { - authority: "workspace", - plugins: [], - received: request - } })); - }); - `); - try { - const list = createRuntimePluginReferenceLister(directory, {}, [process.execPath, runtime]); - expect(list).toBeFunction(); - expect(await list!()).toMatchObject({ - authority: "workspace", - received: { - method: "plugins/referenceCatalog", - params: { - workspace: { workspacePath: directory, workspaceKey: directory } - } - } - }); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); -}); diff --git a/test/preview-comment.test.ts b/test/preview-comment.test.ts deleted file mode 100644 index 3683d847..00000000 --- a/test/preview-comment.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { authorizePreview, previewComment, previewPackageLinks, resolvePreviewTarget, type PreviewContext, - type PreviewPullRequest } from "../scripts/preview-comment.ts"; - -const mainSha = "a".repeat(40), headSha = "b".repeat(40); -function context(): PreviewContext { - return { repository: "kingsword09/zcode-cli", actorId: "19650362", triggeringActor: "kingsword09", - eventName: "issue_comment", ref: "refs/heads/main", sha: mainSha, - event: { action: "created", repository: { default_branch: "main" }, - comment: { body: "/pkg-pr-new", user: { id: 19650362 } }, issue: { number: 180, pull_request: {} } } }; -} -function pull(): PreviewPullRequest { - return { number: 180, state: "open", base: { repo: { full_name: "kingsword09/zcode-cli" } }, - head: { sha: headSha, repo: { full_name: "contributor/zcode-cli" } } }; -} -function ciContext(): PreviewContext { - return { repository: "kingsword09/zcode-cli", actorId: "1", triggeringActor: "contributor", - eventName: "workflow_run", ref: "refs/heads/main", sha: mainSha, - event: { action: "completed", repository: { default_branch: "main" }, workflow_run: { - name: "CI", path: ".github/workflows/ci.yml", event: "pull_request", conclusion: "success", - head_sha: headSha, head_branch: "fix/example", head_repository: { full_name: "contributor/zcode-cli" }, - repository: { full_name: "kingsword09/zcode-cli" }, pull_requests: [{ number: 180 }] - } } }; -} - -test("successful PR CI automatically resolves the validated head for a non-owner contributor", async () => { - const requested: number[] = []; - const target = await resolvePreviewTarget(ciContext(), async number => { requested.push(number); return pull(); }, - async () => { throw new Error("The run already identifies its PR"); }); - expect(requested).toEqual([180]); - expect(target).toEqual({ sha: headSha, pullRequest: 180 }); -}); - -test.each([ - ["failed CI", (c: PreviewContext) => { c.event.workflow_run!.conclusion = "failure"; }], - ["cancelled CI", (c: PreviewContext) => { c.event.workflow_run!.conclusion = "cancelled"; }], - ["unfinished CI", (c: PreviewContext) => { c.event.action = "requested"; }], - ["push CI", (c: PreviewContext) => { c.event.workflow_run!.event = "push"; }], - ["manual CI", (c: PreviewContext) => { c.event.workflow_run!.event = "workflow_dispatch"; }], - ["another workflow", (c: PreviewContext) => { c.event.workflow_run!.name = "Other"; }], - ["a different CI file", (c: PreviewContext) => { c.event.workflow_run!.path = ".github/workflows/other.yml"; }], - ["another repository's CI", (c: PreviewContext) => { c.event.workflow_run!.repository!.full_name = "contributor/zcode-cli"; }], - ["an invalid CI SHA", (c: PreviewContext) => { c.event.workflow_run!.head_sha = "main\npr_number=999"; }], - ["a missing run", (c: PreviewContext) => { delete c.event.workflow_run; }], - ["a fork workflow", (c: PreviewContext) => { c.repository = "contributor/zcode-cli"; }], - ["an untrusted workflow branch", (c: PreviewContext) => { c.ref = "refs/heads/contributor"; }] -] as const)("rejects %s before automatic preview API requests", async (_name, change) => { - const input = ciContext(); - change(input); - let apiCalls = 0; - await expect(resolvePreviewTarget(input, - async () => { apiCalls++; return pull(); }, - async () => { apiCalls++; return [{ number: 180 }]; })).rejects.toThrow(); - expect(apiCalls).toBe(0); -}); - -test.each([ - ["superseded CI", (p: PreviewPullRequest) => { p.head.sha = "c".repeat(40); }], - ["closed PR", (p: PreviewPullRequest) => { p.state = "closed"; }], - ["deleted fork", (p: PreviewPullRequest) => { p.head.repo = null; }], - ["another repository", (p: PreviewPullRequest) => { p.base.repo.full_name = "other/repo"; }], - ["a mismatched PR", (p: PreviewPullRequest) => { p.number = 181; }] -] as const)("skips %s without selecting a commit to publish", async (_name, change) => { - const response = pull(); - change(response); - expect(await resolvePreviewTarget(ciContext(), async () => response)).toBeUndefined(); -}); - -test("fork CI with an empty PR list finds the open PR by its validated commit", async () => { - const input = ciContext(); - input.event.workflow_run!.pull_requests = []; - const requested: number[] = [], heads: unknown[] = []; - const target = await resolvePreviewTarget(input, async number => { - requested.push(number); - return number === 180 ? pull() : { ...pull(), number, state: "closed" }; - }, async head => { heads.push(head); return [{ number: 179 }, { number: 180 }, { number: 180 }]; }); - expect(heads).toEqual([{ repository: "contributor/zcode-cli", branch: "fix/example" }]); - expect(requested).toEqual([179, 180]); - expect(target).toEqual({ sha: headSha, pullRequest: 180 }); -}); - -test("CI with no associated open PR never falls back to publishing the default branch", async () => { - const input = ciContext(); - delete input.event.workflow_run!.pull_requests; - expect(await resolvePreviewTarget(input, - async () => { throw new Error("No PR lookup expected"); }, async () => [])).toBeUndefined(); -}); - -test("ambiguous commit associations cannot report a preview on an arbitrary PR", async () => { - const input = ciContext(); - input.event.workflow_run!.pull_requests = [{ number: 180 }, { number: 181 }]; - await expect(resolvePreviewTarget(input, async number => ({ ...pull(), number }))) - .rejects.toThrow("more than one open PR"); -}); - -test("an owner comment resolves the fork's exact head, not the default branch or merge commit", async () => { - const requested: number[] = []; - const target = await resolvePreviewTarget(context(), async number => { requested.push(number); return pull(); }); - expect(requested).toEqual([180]); - expect(target).toEqual({ sha: headSha, pullRequest: 180 }); -}); - -test.each([ - ["another commenter", (c: PreviewContext) => { c.actorId = "1"; c.triggeringActor = "collaborator"; c.event.comment!.user!.id = 1; }], - ["another rerunner", (c: PreviewContext) => { c.triggeringActor = "collaborator"; }], - ["an owner actor with someone else's comment", (c: PreviewContext) => { c.event.comment!.user!.id = 1; }], - ["an edited comment", (c: PreviewContext) => { c.event.action = "edited"; }], - ["a regular issue", (c: PreviewContext) => { delete c.event.issue!.pull_request; }], - ["a quoted command", (c: PreviewContext) => { c.event.comment!.body = "Please run /pkg-pr-new"; }], - ["shell input", (c: PreviewContext) => { c.event.comment!.body = "/pkg-pr-new; echo nope"; }], - ["a code block", (c: PreviewContext) => { c.event.comment!.body = "```\n/pkg-pr-new\n```"; }], - ["a fork workflow", (c: PreviewContext) => { c.repository = "contributor/zcode-cli"; }], - ["an untrusted workflow branch", (c: PreviewContext) => { c.ref = "refs/heads/contributor"; }], - ["a PR push event", (c: PreviewContext) => { c.eventName = "pull_request"; }] -] as const)("rejects %s before making any API request", async (_name, change) => { - const input = context(); - change(input); - let apiCalls = 0; - await expect(resolvePreviewTarget(input, async () => { apiCalls++; return pull(); })).rejects.toThrow(); - expect(apiCalls).toBe(0); -}); - -test.each([ - ["closed PR", (p: PreviewPullRequest) => { p.state = "closed"; }], - ["wrong repository", (p: PreviewPullRequest) => { p.base.repo.full_name = "other/repo"; }], - ["wrong PR", (p: PreviewPullRequest) => { p.number = 181; }], - ["deleted fork", (p: PreviewPullRequest) => { p.head.repo = null; }], - ["invalid SHA", (p: PreviewPullRequest) => { p.head.sha = "main\npr_number=999"; }] -] as const)("rejects %s returned by the API", async (_name, change) => { - const response = pull(); - change(response); - await expect(resolvePreviewTarget(context(), async () => response)).rejects.toThrow(); -}); - -test("manual PR selection is owner-only and accepts numbers rather than refs or shell syntax", async () => { - const input = context(); - input.eventName = "workflow_dispatch"; - input.event.inputs = { pull_request: "180" }; - expect(await resolvePreviewTarget(input, async () => pull())).toEqual({ sha: headSha, pullRequest: 180 }); - for (const number of ["0", "-1", "180;echo nope", "1\nsha=main", "main", "9007199254740992"]) { - input.event.inputs.pull_request = number; - expect(() => authorizePreview(input)).toThrow(); - } - input.event.inputs.pull_request = "180"; - input.actorId = "1"; - expect(() => authorizePreview(input)).toThrow("Only kingsword09"); -}); - -test("default-branch pushes and owner manual runs retain default-branch previews", async () => { - for (const eventName of ["push", "workflow_dispatch"]) { - const input = context(); - input.eventName = eventName; - expect(await resolvePreviewTarget(input, async () => { throw new Error("No PR lookup expected"); })) - .toEqual({ sha: mainSha }); - } -}); - -test("success reports contain the pinned package URL and failures never invent a package link", () => { - const shortUrl = `https://pkg.pr.new/zcode-app-cli@${headSha.slice(0, 7)}`; - const success = previewComment(headSha, "success", "12345", shortUrl); - expect(success).toContain(`npx --yes ${shortUrl}`); - expect(success).toContain(`[Full repository URL](https://pkg.pr.new/kingsword09/zcode-cli/zcode-app-cli@${headSha})`); - expect(success).toContain("/actions/runs/12345"); - expect(success).toContain("automatically after CI succeeds"); - expect(success).toContain("/pkg-pr-new"); - expect(previewComment(headSha, "failure", "12345")).not.toContain("https://pkg.pr.new"); - expect(() => previewComment(headSha, "cancelled", "12345")).toThrow(); - expect(() => previewComment("`malicious`", "success", "12345")).toThrow(); - expect(() => previewComment(headSha, "success", "12345)evil")).toThrow(); -}); - -test("publication may return compact or full URLs while preserving the emitted SHA spelling", () => { - const fullUrl = `https://pkg.pr.new/kingsword09/zcode-cli/zcode-app-cli@${headSha}`; - for (const url of [`https://pkg.pr.new/zcode-app-cli@${headSha.slice(0, 7)}`, `https://pkg.pr.new/zcode-app-cli@${headSha}`, fullUrl]) { - expect(previewPackageLinks(headSha, url)).toEqual({ url, fullUrl }); - } - const fallback = previewComment(headSha, "success", "12345", fullUrl); - expect(fallback).toContain(`npx --yes ${fullUrl}`); - expect(fallback).not.toContain("[Full repository URL]"); -}); - -test.each([ - `https://pkg.pr.new/zcode-app-cli@${mainSha.slice(0, 7)}`, - `https://pkg.pr.new/other/repo/zcode-app-cli@${headSha}`, - `https://evil.test/zcode-app-cli@${headSha}`, - `https://pkg.pr.new/another-package@${headSha}`, - `https://pkg.pr.new/zcode-app-cli@${headSha}?other=1`, - "https://pkg.pr.new/zcode-app-cli@main", - "https://pkg.pr.new/zcode-app-cli@bbbbbb", - undefined -])("rejects an unverified preview URL: %s", url => { - expect(() => previewPackageLinks(headSha, url)).toThrow(); -}); - -test("the workflow helper runs in native Node without installing PR dependencies", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-preview-comment-")); - try { - const eventPath = join(directory, "event.json"), output = join(directory, "output"); - await writeFile(eventPath, JSON.stringify({ repository: { default_branch: "main" } })); - const child = Bun.spawn([Bun.which("node")!, resolve(import.meta.dir, "../scripts/preview-comment.ts"), "resolve"], { - cwd: directory, env: { ...process.env, GH_TOKEN: undefined, GITHUB_TOKEN: undefined, - GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: output, GITHUB_REPOSITORY: "kingsword09/zcode-cli", - GITHUB_ACTOR_ID: "19650362", GITHUB_TRIGGERING_ACTOR: "kingsword09", GITHUB_EVENT_NAME: "push", - GITHUB_REF: "refs/heads/main", GITHUB_SHA: mainSha }, stdout: "pipe", stderr: "pipe" - }); - const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]); - expect(code, stderr).toBe(0); - expect(await readFile(output, "utf8")).toBe(`sha=${mainSha}\npr_number=\n`); - } finally { await rm(directory, { recursive: true, force: true }); } -}); diff --git a/test/prompt-model.test.ts b/test/prompt-model.test.ts deleted file mode 100644 index cce0d06d..00000000 --- a/test/prompt-model.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { expect, test } from "bun:test"; -import { extractPromptOverrides } from "../src/prompt-model.ts"; -import { patchRuntimePromptModel } from "../scripts/sync-runtime.ts"; - -const model = "account:zai-start-plan/GLM-5.3-Flash"; -const options = { prompt: "string", resume: "string" } as const; - -test("extracts per-prompt model and effort while preserving prompt text and resume arguments", () => { - expect(extractPromptOverrides(["--model", model, "--effort", "high", "-p", "ok", "--resume", "session"], options)) - .toEqual({ args: ["-p", "ok", "--resume", "session"], model, effort: "high" }); - expect(extractPromptOverrides([`--model=${model}`, "--effort=high", "--prompt=ok"], options)) - .toEqual({ args: ["--prompt=ok"], model, effort: "high" }); - expect(extractPromptOverrides(["--model", model, "-p", "ok"], options)) - .toEqual({ args: ["-p", "ok"], model }); - expect(extractPromptOverrides(["--effort", "high", "-p", "ok"], options)) - .toEqual({ args: ["-p", "ok"], effort: "high" }); - expect(extractPromptOverrides(["--prompt", "--model"], options)) - .toEqual({ args: ["--prompt", "--model"] }); - expect(extractPromptOverrides(["--prompt", "--effort"], options)) - .toEqual({ args: ["--prompt", "--effort"] }); - expect(() => extractPromptOverrides(["--model", "glm", "-p", "ok"], options)).toThrow(/provider\/model/); - expect(() => extractPromptOverrides(["--model", model], options)).toThrow(/requires --prompt/); - expect(() => extractPromptOverrides(["--effort", "high"], options)).toThrow(/requires --prompt/); - expect(() => extractPromptOverrides(["--model", model, "--model", model, "-p", "ok"], options)).toThrow(/only once/); - expect(() => extractPromptOverrides(["--effort", "high", "--effort", "low", "-p", "ok"], options)).toThrow(/only once/); - expect(() => extractPromptOverrides(["--effort", "", "-p", "ok"], options)).toThrow(/single effort level/); - expect(() => extractPromptOverrides(["--model", model, "--prompt", "ok", "--effort"], options)).toThrow(/requires a value/); - expect(() => extractPromptOverrides(["--effort", "--model", model, "-p", "ok"], options)).toThrow(/single effort level/); -}); - -const source = [ - 'async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");', - 'return app.submitPrompt("ok")}', - 'async function setModel(e,f,x){return e.cliDefaultSelection&&!f?.transient?e.cliDefaultSelection.set(x):e.runtime.setSessionModelSelection(x),f?.transient||void 0,x}' -].join(""); - -function load(env: Record) { - return new Function("process", `${patchRuntimePromptModel(source)};return {run,setModel}`)({ env }) as { - run: (app: unknown, options: Record) => Promise; - setModel: (context: unknown, options: unknown, selection: unknown) => Promise; - }; -} - -function reasoningModel(providerId = "p", modelId = "m", levels = ["low", "high", "max"]) { - const reasoning = { levels: levels.map(value => ({ value, label: value })) }; - return { ref: { providerId, modelId }, reasoning }; -} - -test("switches the cli-default selection before submitting a prompt and propagates invalid models", async () => { - const events: unknown[] = []; - const app = { - traceId: "fixture", - setModel: async (...args: unknown[]) => { events.push(args); }, - submitPrompt: async () => { events.push("prompt"); return "ok"; } - }; - expect(await load({ ZCODE_CLI_PROMPT_MODEL: model }).run(app, {})).toBe("ok"); - expect(events).toEqual([[model, { transient: true, cliDefault: true }], "prompt"]); - events.length = 0; - await load({}).run(app, {}); - expect(events).toEqual(["prompt"]); - events.length = 0; - await expect(load({ ZCODE_CLI_PROMPT_MODEL: model }).run({ ...app, - setModel: async () => { throw new Error("Unknown model"); } - }, {})).rejects.toThrow("Unknown model"); - expect(events).toEqual([]); - - // A prompt override updates CliDefaultSelection without persisting a session switch. - let cliSelection: unknown; - let runtimeSelection: unknown; - let resumes = 0; - const context = { - prepareResume: async () => { resumes++; }, - cliDefaultSelection: { set: (value: unknown) => { cliSelection = value; } }, - runtime: { setSessionModelSelection: (value: unknown) => { runtimeSelection = value; } } - }; - const selection = { providerId: "p", modelId: "m" }; - const loaded = load({}); - expect(await loaded.setModel(context, { transient: true, cliDefault: true }, selection)).toBe(selection); - expect(cliSelection).toBe(selection); - expect(runtimeSelection).toBeUndefined(); - expect(resumes).toBe(1); - await loaded.setModel(context, { transient: true }, selection); - expect(runtimeSelection).toBe(selection); - await loaded.setModel(context, undefined, { providerId: "q", modelId: "n" }); - expect(cliSelection).toEqual({ providerId: "q", modelId: "n" }); - runtimeSelection = undefined; - await loaded.setModel({ runtime: context.runtime }, { transient: true, cliDefault: true }, selection); - expect(runtimeSelection).toBe(selection); - expect(resumes).toBe(1); -}); - -test("applies model and effort in one transient selection", async () => { - const events: unknown[] = []; - const app = { - traceId: "fixture", - getModelOption: (selection: unknown) => { events.push(["getModelOption", selection]); return reasoningModel("account:zai-start-plan", "GLM-5.3-Flash", ["low", "high", "max"]); }, - setModel: async (...args: unknown[]) => { events.push(["setModel", ...args]); }, - submitPrompt: async () => { events.push("prompt"); return "ok"; } - }; - expect(await load({ ZCODE_CLI_PROMPT_MODEL: model, ZCODE_CLI_PROMPT_EFFORT: "high" }).run(app, {})).toBe("ok"); - expect(events).toEqual([ - ["getModelOption", { providerId: "account:zai-start-plan", modelId: "GLM-5.3-Flash" }], - ["setModel", { - providerId: "account:zai-start-plan", - modelId: "GLM-5.3-Flash", - options: { reasoningLevel: "high" } - }, { transient: true, cliDefault: true }], - "prompt" - ]); -}); - -test("applies effort to the resumed current model without a model override", async () => { - const events: unknown[] = []; - const current = reasoningModel("saved-provider", "saved-model", ["disabled", "low", "high"]); - const app = { - traceId: "fixture", - readExecutionState: async () => { events.push("resume"); return { mode: "build", planEnabled: false }; }, - getCurrentModelOption: () => { events.push("getCurrentModelOption"); return current; }, - getModelOption: (selection: unknown) => { events.push(["getModelOption", selection]); return current; }, - setModel: async (...args: unknown[]) => { events.push(["setModel", ...args]); }, - submitPrompt: async () => { events.push("prompt"); return "ok"; } - }; - expect(await load({ ZCODE_CLI_PROMPT_EFFORT: "HIGH" }).run(app, {})).toBe("ok"); - expect(events).toEqual([ - "resume", - "getCurrentModelOption", - ["getModelOption", { providerId: "saved-provider", modelId: "saved-model" }], - ["setModel", { - providerId: "saved-provider", - modelId: "saved-model", - options: { reasoningLevel: "high" } - }, { transient: true, cliDefault: true }], - "prompt" - ]); -}); - -test("rejects an unsupported effort before sending the prompt", async () => { - const events: unknown[] = []; - const app = { - traceId: "fixture", - getModelOption: () => reasoningModel("p", "m", ["low", "high"]), - setModel: async (...args: unknown[]) => { events.push(["setModel", ...args]); }, - submitPrompt: async () => { events.push("prompt"); return "ok"; } - }; - await expect(load({ ZCODE_CLI_PROMPT_MODEL: model, ZCODE_CLI_PROMPT_EFFORT: "medium" }).run(app, {})) - .rejects.toThrow(/Unsupported reasoning effort: medium\. Available: low, high\./); - expect(events).toEqual([]); -}); - -test("rejects effort for a model without reasoning levels", async () => { - const app = { - traceId: "fixture", - getModelOption: () => ({ ref: { providerId: "p", modelId: "m" }, reasoning: { levels: [] } }), - setModel: async () => { throw new Error("should not be called"); }, - submitPrompt: async () => { throw new Error("should not be called"); } - }; - await expect(load({ ZCODE_CLI_PROMPT_MODEL: model, ZCODE_CLI_PROMPT_EFFORT: "high" }).run(app, {})) - .rejects.toThrow(/does not support reasoning effort/); -}); - -test("rejects effort-only runs without a current model", async () => { - const app = { - traceId: "fixture", - readExecutionState: async () => ({ mode: "build", planEnabled: false }), - getCurrentModelOption: () => undefined, - setModel: async () => { throw new Error("should not be called"); }, - submitPrompt: async () => { throw new Error("should not be called"); } - }; - await expect(load({ ZCODE_CLI_PROMPT_EFFORT: "high" }).run(app, {})) - .rejects.toThrow(/without a current model/); -}); - -test("auto leaves the current effort unchanged", async () => { - const events: unknown[] = []; - const app = { - traceId: "fixture", - setModel: async (...args: unknown[]) => { events.push(["setModel", ...args]); }, - submitPrompt: async () => { events.push("prompt"); return "ok"; } - }; - expect(await load({ ZCODE_CLI_PROMPT_EFFORT: "auto" }).run(app, {})).toBe("ok"); - expect(events).toEqual(["prompt"]); -}); - -test("upgrades a runtime that only has the earlier model override", () => { - const legacy = source.replace( - 'return app.submitPrompt("ok")}', - 'let $zCliDefaultPromptModel=process.env.ZCODE_CLI_PROMPT_MODEL;if($zCliDefaultPromptModel)await app.setModel($zCliDefaultPromptModel,{transient:true,cliDefault:true});return app.submitPrompt("ok")}' - ); - const patched = patchRuntimePromptModel(legacy); - expect(patched).toContain("$zCliDefaultPromptEffort"); - expect(patched).toContain("ZCODE_CLI_PROMPT_EFFORT"); - expect(patched).not.toContain("if($zCliDefaultPromptModel)await app.setModel($zCliDefaultPromptModel"); - expect(patchRuntimePromptModel(patched)).toBe(patched); -}); - -test.each([true, false])("applies a prompt override after lazy resume restores the shared CLI selection (saved valid: %p)", async valid => { - const loaded = new Function("process", `${patchRuntimePromptModel(source)};return {run,setModel}`)({ - env: { ZCODE_CLI_PROMPT_MODEL: model } - }); - const events: unknown[] = []; - const saved = { providerId: "saved", modelId: "model" }; - let selection: unknown; - let resumed = false; - const context = { - cliDefaultSelection: { set: (value: unknown) => { selection = value; } }, - runtime: { setSessionModelSelection: (value: unknown) => { selection = value; } }, - async prepareResume() { - if (resumed) return; - events.push("resume"); - context.cliDefaultSelection.set(valid ? saved : undefined); - resumed = true; - } - }; - const app = { - traceId: "fixture", - setModel: (value: unknown, options: unknown) => loaded.setModel(context, options, value), - async submitPrompt() { - await context.prepareResume(); - events.push(selection); - if (!selection) throw new Error("Saved model is unavailable"); - return selection; - } - }; - expect(await loaded.run(app, {})).toBe(model); - expect(events).toEqual(["resume", model]); -}); diff --git a/test/prompt-preflight.test.ts b/test/prompt-preflight.test.ts index 31ad453a..08f0aa26 100644 --- a/test/prompt-preflight.test.ts +++ b/test/prompt-preflight.test.ts @@ -31,48 +31,6 @@ describe("prompt credential preflight (offline)", () => { expect(await readFile(f.file, "utf8")).toBe(before); }); - test("permits configured keys and auth headers without revealing them", async () => { - const f = await fixture(); - f.settings.access.apiKey = "private-fixture-key"; - await f.save(); - expect(await missingCodingPlanKey(f.options)).toBeUndefined(); - f.settings.access.apiKey = ""; - Object.assign(f.settings.api, { headers: { Authorization: "Bearer private-fixture-token" } }); - await f.save(); - expect(await missingCodingPlanKey(f.options)).toBeUndefined(); - }); - - test("defers environment credentials and model overrides to the runtime", async () => { - const f = await fixture(); - for (const key of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ZCODE_API_KEY", "ZCODE_MODEL_MAIN"]) { - expect(await missingCodingPlanKey({ ...f.options, env: { ...f.env, [key]: "fixture" } })).toBeUndefined(); - } - expect(await missingCodingPlanKey({ ...f.options, env: { ...f.env, ANTHROPIC_API_KEY: " " } })).toBeDefined(); - expect(await missingCodingPlanKey({ ...f.options, env: { - ...f.env, ZCODE_BASE_URL: "https://zcode.z.ai", ZCODE_MODEL_RETRY_MAX_RETRIES: "5", - ZCODE_MODEL_TELEMETRY_ENABLED: "0" - } })).toBeDefined(); - }); - - test("defers a session's different model and account provider", async () => { - const f = await fixture(); - for (const model of ["bigmodel/glm-5.3-flash", "account:bigmodel-start-plan/GLM-5.3-Flash", "default"]) { - expect(await missingCodingPlanKey({ ...f.options, model })).toBeUndefined(); - } - }); - - test("defers project and dotenv overrides in parent directories", async () => { - const f = await fixture(); - const child = join(f.home, "child"); - await mkdir(child); - for (const relative of ["zcode.json", ".zcode/config.json", ".env"]) { - const override = join(f.home, relative); - await writeFile(override, "{}"); - expect(await missingCodingPlanKey({ ...f.options, workingDirectory: child })).toBeUndefined(); - await rm(override); - } - }); - test("leaves malformed configuration to the runtime's own diagnostics", async () => { const f = await fixture(); for (const json of ["null", "[]", "invalid", '{"provider":null}']) { diff --git a/test/queued-input-view.test.ts b/test/queued-input-view.test.ts deleted file mode 100644 index 765493fb..00000000 --- a/test/queued-input-view.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { QueuedInputView } from "../packages/zcode-tui/src/queued-input-view.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -describe("TUI queued follow-up inputs", () => { - test("renders unsent inputs with a discoverable edit shortcut", () => { - const view = new QueuedInputView(createTheme(false)); - view.setState({ - pendingSteers: [], - queuedInputs: ["first follow-up", "second follow-up"] - }); - - const output = view.render(80).join("\n"); - expect(output).toContain("Queued next turn · 2 inputs"); - expect(output).toContain("↳ first follow-up"); - expect(output).toContain("↳ second follow-up"); - expect(output).toContain("Alt+Up / Shift+Left edit last"); - }); - - test("bounds long and numerous inputs at narrow widths", () => { - const view = new QueuedInputView(createTheme(false)); - view.setState({ - pendingSteers: [], - queuedInputs: [ - "first", - "second", - "third", - "fourth follow-up with a deliberately long description" - ] - }); - - const lines = view.render(24); - expect(lines).toHaveLength(6); - expect(lines.join("\n")).toContain("… 1 earlier"); - expect(lines.join("\n")).not.toContain("↳ first"); - expect(lines.every((line) => visibleWidth(line) <= 24)).toBeTrue(); - }); - - test("stays hidden without queued inputs", () => { - const view = new QueuedInputView(createTheme(false)); - expect(view.render(80)).toEqual([]); - }); - - test("keeps undrained steers next to the editor instead of transcript history", () => { - const view = new QueuedInputView(createTheme(false)); - view.setState({ - pendingSteers: ["Use the simpler implementation."], - queuedInputs: ["Add regression tests next."] - }); - - const output = view.render(80).join("\n"); - expect(output).toContain("Steering current turn · 1 waiting"); - expect(output).toContain("↪ Use the simpler implementation."); - expect(output).toContain("waiting for the next model step"); - expect(output).toContain("Queued next turn · 1 input"); - }); -}); diff --git a/test/release-contract.test.ts b/test/release-contract.test.ts new file mode 100644 index 00000000..e1436990 --- /dev/null +++ b/test/release-contract.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import { parseRuntimeLock, selectRuntimeLock } from "../scripts/sync-runtime.ts"; +import { parseReleaseVersion, syncedReleaseVersion } from "../scripts/release-version.ts"; + +const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json(); +const lock = parseRuntimeLock(await Bun.file(new URL("../zcode-runtime.lock.json", import.meta.url)).json()); + +test("the package version and verified remote runtime lock identify the same release", () => { + expect(parseReleaseVersion(manifest.version)?.appVersion).toBe(lock.appVersion); + expect(lock.platform).toBe("linux"); + expect(lock.arch).toBe("x64"); + expect(new URL(lock.url).protocol).toBe("https:"); + expect(manifest.files).toContain("zcode-runtime.lock.json"); + expect(() => parseRuntimeLock({ ...lock, sha512: "not-a-checksum" })).toThrow(); + expect(() => parseRuntimeLock({ ...lock, url: "http://example.test/runtime.deb" })).toThrow(); +}); + +test("a lagging update manifest cannot downgrade the pinned runtime", () => { + expect(selectRuntimeLock({ ...lock, appVersion: "0.0.0" }, lock)).toBe(lock); +}); + +test("a runtime upgrade preserves the CLI build number", () => { + expect(syncedReleaseVersion("9.0.0", "8.0.0-41")).toBe("9.0.0-41"); +}); diff --git a/test/release-package.test.ts b/test/release-package.test.ts deleted file mode 100644 index d43bce8b..00000000 --- a/test/release-package.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; - -import { describe, expect, test } from "bun:test"; -import { parse } from "yaml"; - -import { validatePackageTree } from "../scripts/check-package.ts"; -import { attributionFiles, pluginLicenseFiles, publishedFiles } from "../scripts/package-contents.ts"; -import { markRuntimeModified, runtimeModificationNotice } from "../scripts/runtime-attribution.ts"; -import { runtimePatchPlan } from "../scripts/sync-runtime.ts"; -import { - type PackFile, - type PackResult, - parsePackResult, - validatePackResult -} from "../scripts/pack-release.ts"; - -const requiredPaths = [ - "LICENSE", - "LICENSES/Apache-2.0.txt", - "LICENSES/README.md", - "LICENSES/ZCode-NOTICE.md", - "LICENSES/ZCode-THIRD-PARTY-NOTICES.md", - "README.md", - "bin/zcode.js", - "setting.example.json", - "provider.example.json", - "docs/CONFIGURATION.md", - "docs/CONFIGURATION.zh-CN.md", - "docs/PROVIDER_CONFIG.md", - "docs/PROVIDER_CONFIG.zh-CN.md", - "docs/HOST_INTEGRATION.md", - "docs/THIRD_PARTY_CONTENT.md", - "package.json", - "vendor/extraction.json", - "vendor/node_modules/@zcode/tui/dist/index.js", - "vendor/node_modules/@zcode/tui/package.json", - "vendor/zcode.cjs", - "vendor/cli-config.cjs", - "vendor/provider/zcode-builtin.json", - "vendor/packages/documents-plugin/skills/docx/LICENSE.txt", - "vendor/packages/pdf-plugin/skills/pdf/LICENSE.txt", - "vendor/packages/presentations-plugin/skills/pptx/LICENSE.txt", - "vendor/packages/spreadsheets-plugin/skills/xlsx/LICENSE.txt", - "zcode-runtime.lock.json" -]; - -function packFile(path: string): PackFile { - return { path, mode: path === "bin/zcode.js" ? 0o755 : 0o644, size: 1 }; -} - -function result(files = requiredPaths.map(packFile)): PackResult { - return { - name: "zcode-app-cli", - version: "3.3.5-1", - filename: "zcode-app-cli-3.3.5-1.tgz", - size: 10, - unpackedSize: 20, - integrity: "sha512-test", - shasum: "test", - files - }; -} - -describe("release package", () => { - test("requires upstream notices and original plugin licenses in the tarball", async () => { - const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json(); - expect(manifest.files).toEqual(publishedFiles); - for (const path of [...attributionFiles, ...pluginLicenseFiles]) { - expect(() => validatePackResult(result(requiredPaths.filter(file => file !== path).map(packFile)), { - name: "zcode-app-cli", version: "3.3.5-1" - })).toThrow(`missing: ${path}`); - } - }); - - test("ships the host integration contract referenced by the README", () => { - expect(() => validatePackResult(result(), { - name: "zcode-app-cli", version: "3.3.5-1" - })).not.toThrow(); - expect(() => validatePackResult(result(requiredPaths - .filter(path => path !== "docs/HOST_INTEGRATION.md").map(packFile)), { - name: "zcode-app-cli", version: "3.3.5-1" - })).toThrow("missing: docs/HOST_INTEGRATION.md"); - }); - - test("marks modified runtime bundles without removing the shebang or upstream text", () => { - const body = '"use strict";\n// Original upstream copyright\nreturn 42;\n'; - for (const shebang of ["", "#!/usr/bin/env node\n"]) { - const marked = markRuntimeModified(shebang + body); - expect(marked).toBe(shebang + runtimeModificationNotice + body); - expect(new Function(marked.slice(shebang.length))()).toBe(42); - expect(markRuntimeModified(marked)).toBe(marked); - } - }); - - test("parses npm 11 and npm 12 JSON after named tsdown build logs", () => { - const expected = result(); - const output = `$ tsdown\nℹ [launcher] Build complete\nℹ [tui] Build complete\n${JSON.stringify([expected])}`; - - expect(parsePackResult(output)).toEqual(expected); - expect(parsePackResult(`$ tsdown\n${JSON.stringify({ [expected.name]: expected })}`)).toEqual(expected); - expect(() => parsePackResult("ℹ [launcher] Build complete")).toThrow(/JSON package result/); - expect(() => parsePackResult(JSON.stringify({ first: expected, second: expected }))).toThrow( - /JSON package result/ - ); - }); - - test("defines one locked build, pack, and offline prepack path", async () => { - const packageJson = await Bun.file(new URL("../package.json", import.meta.url)).json(); - - expect(packageJson.scripts["release:prepare"]).toContain("--latest"); - expect(packageJson.scripts["release:build"]).toBe("bun scripts/build-release.ts"); - expect(packageJson.scripts["release:pack"]).toBe("bun scripts/pack-release.ts"); - expect(packageJson.scripts.prepack).toBe("bun scripts/check-package.ts --prepack"); - expect(packageJson.scripts.build).toBe("tsdown"); - expect(packageJson.scripts["build:launcher"]).toContain("--filter launcher"); - expect(packageJson.scripts["build:tui"]).toContain("--filter tui"); - expect(packageJson.scripts.test).toBe("bun run test:unit"); - expect(packageJson.scripts["test:unit"]).toBe("bun test test/*.test.ts"); - expect(packageJson.scripts["test:tui:component"]).toContain("scenario-runtime.test.ts"); - expect(packageJson.scripts["test:tui:e2e"]).toContain("write-and-diff.test.ts"); - expect(packageJson.scripts["test:tui:host"]).toBe("bun test test/tui/scenario-mountx.test.ts"); - expect(packageJson.scripts["test:all"]).toContain("test:unit"); - expect(packageJson.scripts["test:all"]).toContain("test:tui"); - expect(packageJson.scripts["test:all"]).toContain("test:runtime"); - expect(packageJson.scripts["test:all"]).toContain("test:node"); - expect(packageJson.scripts["test:node"]).toBe("node --test test/node/*.test.cjs"); - expect(packageJson.bin.zcode).toBe("bin/zcode.js"); - expect(packageJson.engines).toEqual({ node: ">=22.19.0" }); - expect(packageJson.dependencies.zigpty).toBeUndefined(); - expect(packageJson.dependencies.bun).toBeUndefined(); - expect(packageJson.dependencies["playwright-core"]).toBe("1.59.1"); - expect(packageJson.homepage).toBe("https://github.com/kingsword09/zcode-cli#readme"); - expect(packageJson.bugs.url).toBe("https://github.com/kingsword09/zcode-cli/issues"); - expect(packageJson.repository).toEqual({ - type: "git", - url: "git+https://github.com/kingsword09/zcode-cli.git" - }); - expect(packageJson.keywords).toEqual(expect.arrayContaining(["cli", "node", "terminal", "tui", "zcode"])); - }); - - test("pins one Bun toolchain and tests the validated artifact with real Node versions", async () => { - const packageJson = await Bun.file(new URL("../package.json", import.meta.url)).json(); - expect(packageJson.packageManager).toBe("bun@1.4.1"); - expect(packageJson.devDependencies["bun-types"]).toBe("1.4.1"); - for (const name of ["ci", "prepare-release", "publish"]) { - const workflow = parse(await Bun.file(new URL(`../.github/workflows/${name}.yml`, import.meta.url)).text()); - for (const job of Object.values(workflow.jobs) as { steps?: { uses?: string; with?: Record }[] }[]) { - for (const step of job.steps ?? []) { - if (step.uses?.startsWith("oven-sh/setup-bun@")) expect(step.with?.["bun-version"]).toBe("1.4.1"); - } - } - if (name === "ci") { - const matrix = workflow.jobs["node-runtime"]; - expect(matrix.needs).toBe("validate"); - expect(matrix.strategy.matrix.node).toEqual(["24", "26"]); - expect(matrix.strategy.matrix.include).toContainEqual({ os: "macos-latest", node: "24" }); - expect(matrix.steps.some((step: { run?: string }) => step.run === "bun run test:runtime:compat")).toBe(true); - expect(matrix.steps.some((step: { run?: string }) => step.run === "bun run test:runtime")).toBe(false); - expect(matrix.steps.some((step: { run?: string }) => step.run === "bun run test:node")).toBe(true); - expect(matrix.steps.some((step: { uses?: string }) => step.uses?.startsWith("actions/download-artifact@"))).toBe(true); - } - } - }); - - test("syncs the runtime before running runtime-backed integration tests", async () => { - const source = await Bun.file(new URL("../scripts/build-release.ts", import.meta.url)).text(); - const syncStep = source.indexOf('await run(["run", latest ? "sync" : "sync:locked"]);'); - const testStep = source.indexOf('await run(["run", "test:all"]);'); - - expect(syncStep).toBeGreaterThan(-1); - expect(testStep).toBeGreaterThan(syncStep); - expect(source).toContain('phase: "release_validation"'); - expect(source).toContain("latestRuntimeSynced = latest"); - expect(source).toContain("if (latestRuntimeSynced && !existsSync(reportPath))"); - expect(source).toContain("writeRuntimeCompatibilityFailure(report)"); - }); - - test("accepts reviewed paths and rejects omissions or development files", () => { - const packageJson = { name: "zcode-app-cli", version: "3.3.5-1" }; - - expect(() => validatePackResult(result(), packageJson)).not.toThrow(); - expect(() => validatePackResult(result(requiredPaths.slice(1).map(packFile)), packageJson)).toThrow(/missing/); - expect(() => validatePackResult(result([...requiredPaths.map(packFile), packFile("scripts/private.ts")]), packageJson)) - .toThrow(/unreviewed/); - expect(() => validatePackResult( - result(requiredPaths.map((path) => ({ ...packFile(path), mode: 0o644 }))), - packageJson - )).toThrow(/not executable/); - }); - - test("rejects a missing runtime notice or a compiled TUI that was not injected into vendor", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-package-check-")); - const lock = { - schemaVersion: 1, - appVersion: "3.3.5", - platform: "linux", - arch: "x64", - url: "https://example.com/zcode.deb", - sha512: Buffer.alloc(64, 3).toString("base64") - }; - const packageJson = { - name: "zcode-app-cli", - version: "3.3.5-1", - description: "Unofficial terminal client", - keywords: ["cli", "node", "terminal", "tui", "zcode"], - homepage: "https://github.com/kingsword09/zcode-cli#readme", - bugs: { url: "https://github.com/kingsword09/zcode-cli/issues" }, - license: "MIT", - author: "Kingsword kingsword09 ", - repository: { - type: "git", - url: "git+https://github.com/kingsword09/zcode-cli.git" - }, - bin: { zcode: "bin/zcode.js" }, - files: publishedFiles, - publishConfig: { access: "public", provenance: true }, - dependencies: { - "@earendil-works/pi-tui": "^0.80.6", - "playwright-core": "1.59.1" - } - }; - const tuiPackage = { - name: "@zcode/tui", - version: "0.1.0", - dependencies: { "@earendil-works/pi-tui": "^0.80.6" } - }; - const files: Record = { - ...Object.fromEntries(pluginLicenseFiles.map(path => [path, "original plugin license\n"])), - "docs/CONFIGURATION.md": "# Fixture documentation\n", - "docs/CONFIGURATION.zh-CN.md": "# Fixture documentation\n", - "docs/PROVIDER_CONFIG.md": "# Fixture documentation\n", - "docs/PROVIDER_CONFIG.zh-CN.md": "# Fixture documentation\n", - "docs/HOST_INTEGRATION.md": "# Fixture host integration contract\n", - "docs/THIRD_PARTY_CONTENT.md": "# Fixture documentation\n", - "LICENSES/Apache-2.0.txt": "license\n", - "LICENSES/README.md": "notice provenance\n", - "LICENSES/ZCode-NOTICE.md": "upstream notice\n", - "LICENSES/ZCode-THIRD-PARTY-NOTICES.md": "dependency notices\n", - "LICENSE": "license", - "README.md": "readme", - "bin/zcode.js": "#!/usr/bin/env node\nimport { spawn } from \"node:child_process\";\n", - "bin/zcode.ts": "export {};\n", - "setting.example.json": "{}\n", - "provider.example.json": "{}\n", - "package.json": `${JSON.stringify(packageJson)}\n`, - "src/app-server-client.ts": "export {};\n", - "src/command.ts": "export {};\n", - "src/darwin-oauth-callback.ts": "export {};\n", - "src/launcher.ts": "export {};\n", - "src/model-access.ts": "export {};\n", - "src/plugin-cli.ts": "export {};\n", - "src/plugin-protocol.ts": "export {};\n", - "src/zai-oauth.ts": "export {};\n", - "tsdown.config.ts": "export default [];\n", - "packages/zcode-tui/dist/index.js": "export const value = 1;\n", - "packages/zcode-tui/package.json": `${JSON.stringify(tuiPackage)}\n`, - "vendor/extraction.json": `${JSON.stringify({ - appVersion: lock.appVersion, - cliVersion: "0.15.2", - source: lock.url, - sha512: lock.sha512, - runtimeCapabilities: { - schemaVersion: 1, - cli: { globalOptions: { help: { type: "boolean" } } } - }, - runtimePatches: runtimePatchPlan.map((patch) => ({ - id: patch.id, - requirement: patch.requirement, - status: "already_present" - })) - })}\n`, - "vendor/node_modules/@zcode/tui/dist/index.js": "export const value = 1;\n", - "vendor/node_modules/@zcode/tui/package.json": `${JSON.stringify(tuiPackage)}\n`, - "vendor/cli-config.cjs": "module.exports={};\n", - "vendor/provider/zcode-builtin.json": "{}\n", - "vendor/zcode.cjs": "console.log('runtime');\n", - "zcode-runtime.lock.json": `${JSON.stringify(lock)}\n` - }; - - try { - for (const [path, content] of Object.entries(files)) { - const destination = join(directory, path); - await mkdir(join(destination, ".."), { recursive: true }); - await writeFile(destination, content); - } - await chmod(join(directory, "bin", "zcode.js"), 0o755); - await expect(validatePackageTree(directory)).rejects.toThrow(/modification notice/); - await writeFile(join(directory, "vendor", "zcode.cjs"), markRuntimeModified(files["vendor/zcode.cjs"]!)); - await expect(validatePackageTree(directory)).resolves.toBeUndefined(); - for (const path of pluginLicenseFiles) { - await rm(join(directory, path)); - await expect(validatePackageTree(directory)).rejects.toThrow(`Release file is missing: ${path}`); - await writeFile(join(directory, path), files[path]!); - } - await writeFile(join(directory, "vendor", "node_modules", "@zcode", "tui", "dist", "index.js"), "stale\n"); - await expect(validatePackageTree(directory)).rejects.toThrow(/stale/); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); - - test.each([false, true])("writes release.json only after installation checks pass (valid plugins: %p)", async (validPlugins) => { - const directory = await mkdtemp(join(tmpdir(), "zcode-release-finalize-")); - try { - for (const path of requiredPaths) { - await mkdir(join(directory, path, ".."), { recursive: true }); - await writeFile(join(directory, path), "fixture\n"); - } - await writeFile(join(directory, "package.json"), JSON.stringify({ - name: "zcode-app-cli", version: "3.3.5-1", type: "module", - files: publishedFiles, bin: { zcode: "bin/zcode.js" } - })); - await writeFile(join(directory, "vendor/extraction.json"), JSON.stringify({ cliVersion: "0.15.2" })); - await writeFile(join(directory, "vendor/zcode.cjs"), runtimeModificationNotice); - const plugins = validPlugins - ? ["browser-use", "documents", "pdf", "presentations", "spreadsheets"].map(name => ({ name, enabled: true })) - : []; - await writeFile(join(directory, "bin/zcode.js"), [ - "#!/usr/bin/env node", - `console.log(process.argv.includes("--version") ? "zcode-app-cli 3.3.5-1\\nzcode-runtime 0.15.2" : ${JSON.stringify(JSON.stringify(plugins))});`, - "" - ].join("\n")); - await chmod(join(directory, "bin/zcode.js"), 0o755); - const moduleUrl = new URL("../scripts/pack-release.ts", import.meta.url).href; - const packageDirectory = validPlugins ? directory : relative(process.cwd(), directory); - const child = Bun.spawn([process.execPath, "--eval", - `import { packRelease } from ${JSON.stringify(moduleUrl)}; await packRelease(${JSON.stringify(packageDirectory)});` - ], { - env: { ...process.env, GITHUB_OUTPUT: undefined }, - stdout: "pipe", stderr: "pipe", timeout: 30_000, killSignal: "SIGKILL" - }); - const [code, stdout, stderr] = await Promise.all([ - child.exited, new Response(child.stdout).text(), new Response(child.stderr).text() - ]); - const manifestPath = join(directory, ".release/release.json"); - if (validPlugins) { - expect(code, stderr || stdout).toBe(0); - const manifest = JSON.parse(await readFile(manifestPath, "utf8")); - expect(manifest).toMatchObject({ name: "zcode-app-cli", version: "3.3.5-1" }); - expect(await Bun.file(join(directory, manifest.tarball)).exists()).toBe(true); - } else { - expect(code).not.toBe(0); - expect(stderr).toContain("Installed package cannot initialize its built-in plugin: browser-use"); - expect(await Bun.file(manifestPath).exists()).toBe(false); - } - } finally { - await rm(directory, { recursive: true, force: true }); - } - }, 35_000); -}); diff --git a/test/release-workflows.test.ts b/test/release-workflows.test.ts deleted file mode 100644 index cce455c3..00000000 --- a/test/release-workflows.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; - -import { describe, expect, test } from "bun:test"; -import { parse } from "yaml"; - -const root = resolve(import.meta.dir, ".."); -const actionShas = { - checkout: "df4cb1c069e1874edd31b4311f1884172cec0e10", - downloadArtifact: "d3f86a106a0bac45b974a628896c90dbdf5c8093", - setupBun: "0c5077e51419868618aeaa5fe8019c62421857d6", - setupNode: "249970729cb0ef3589644e2896645e5dc5ba9c38", - uploadArtifact: "ea165f8d65b6e75b540449e92b4886f43607fa02" -} as const; - -interface WorkflowStep { - env?: Record; - id?: string; - if?: string; - name?: string; - run?: string; - uses?: string; - with?: Record; -} - -interface WorkflowJob { - if?: string; - needs?: string | string[]; - concurrency?: { group?: string; "cancel-in-progress"?: boolean }; - outputs?: Record; - permissions?: Record; - "runs-on"?: string; - steps: WorkflowStep[]; - "timeout-minutes"?: number; -} - -interface Workflow { - concurrency?: { - group?: string; - "cancel-in-progress"?: boolean; - }; - jobs: Record; - on: Record; - permissions: Record; -} - -async function readWorkflow(name: string): Promise<{ source: string; workflow: Workflow }> { - const source = await Bun.file(resolve(root, ".github", "workflows", name)).text(); - return { source, workflow: parse(source) as Workflow }; -} - -function findAction(steps: WorkflowStep[], repository: string, sha: string): WorkflowStep | undefined { - return steps.find((step) => step.uses === `${repository}@${sha}`); -} - -async function runInlineVersionComparator(source: string, left: string, right: string): Promise { - const script = /compare_release_versions\(\) \{[\s\S]*?<<'NODE'\n([\s\S]*?)\n\s*NODE\n\s*\}/u - .exec(source)?.[1]; - if (!script) throw new Error("Could not extract the privileged release version comparator."); - - const child = Bun.spawn([process.execPath, "-e", script], { - env: { ...process.env, LEFT_VERSION: left, RIGHT_VERSION: right }, - stdout: "pipe", - stderr: "pipe" - }); - const [code, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text() - ]); - if (code !== 0) throw new Error(stderr.trim() || `Comparator exited with status ${code}.`); - return stdout.trim(); -} - -describe("release workflows", () => { - test("publishes the tested commit tarball without npm publishing credentials", async () => { - const { source, workflow } = await readWorkflow("release-commit.yml"); - const steps = workflow.jobs.preview!.steps; - const checkout = findAction(steps, "actions/checkout", actionShas.checkout); - const build = steps.findIndex(step => step.run === "bun run release:build"); - const pack = steps.findIndex(step => step.id === "pack"); - const publish = steps.findIndex(step => step.name === "Publish commit package"); - expect(workflow.on).toHaveProperty("issue_comment"); - expect(workflow.on.workflow_run).toEqual({ workflows: ["CI"], types: ["completed"] }); - expect(workflow.on).not.toHaveProperty("pull_request"); - expect(workflow.on).toHaveProperty("push"); - expect(workflow.on).toHaveProperty("workflow_dispatch"); - expect(workflow.on).not.toHaveProperty("pull_request_target"); - expect(workflow.permissions).toEqual({ contents: "read" }); - expect(checkout?.with?.["persist-credentials"]).toBe(false); - expect(checkout?.with?.ref).toBe("${{ needs.resolve.outputs.sha }}"); - expect(build).toBeGreaterThan(-1); - expect(pack).toBeGreaterThan(build); - expect(publish).toBeGreaterThan(pack); - expect(steps[pack]?.run).toBe("bun run release:pack"); - expect(steps.find(step => step.name === "Upload tested package")?.with?.["include-hidden-files"]).toBe(true); - expect(steps[publish]?.env?.PREVIEW_TARBALL).toBe("${{ steps.pack.outputs.tarball }}"); - expect(steps[publish]?.run).toContain('bun run pkg-pr-new publish "$PREVIEW_TARBALL"'); - expect(steps[publish]?.run).toContain("--commentWithSha"); - expect(steps[publish]?.run).toContain("--comment=off"); - expect(steps[publish]?.run).not.toContain("--no-compact"); - expect(steps[publish]?.run).toContain("--bin"); - expect(source).not.toContain("NPM_TOKEN"); - expect(source).not.toContain("npm publish"); - expect(source).not.toContain("id-token: write"); - expect(source).not.toContain("bunx"); - }); - - test("authorizes owner comments before running PR code and isolates the comment writer", async () => { - const { source, workflow } = await readWorkflow("release-commit.yml"); - const resolveJob = workflow.jobs.resolve!; - const preview = workflow.jobs.preview!; - const report = workflow.jobs.report!; - expect(workflow.on.issue_comment).toEqual({ types: ["created"] }); - expect(resolveJob.if).toContain("github.actor_id == '19650362'"); - expect(resolveJob.if).toContain("github.triggering_actor == 'kingsword09'"); - expect(resolveJob.if).toContain("github.event.comment.user.id == 19650362"); - expect(resolveJob.if).toContain("github.event.comment.body == '/pkg-pr-new'"); - expect(resolveJob.if).toContain("github.event.issue.pull_request"); - expect(resolveJob.if).toContain("github.ref_name == github.event.repository.default_branch"); - expect(resolveJob.if).toContain("github.event_name == 'workflow_run'"); - expect(resolveJob.if).toContain("github.event.workflow_run.event == 'pull_request'"); - expect(resolveJob.if).toContain("github.event.workflow_run.conclusion == 'success'"); - expect(resolveJob.permissions).toEqual({ contents: "read", "pull-requests": "read" }); - expect(preview.permissions).toEqual({ contents: "read" }); - expect(preview.needs).toBe("resolve"); - expect(preview.if).toContain("github.triggering_actor == 'kingsword09'"); - expect(preview.if).toContain("github.event_name == 'workflow_run'"); - expect(preview.if).toContain("needs.resolve.outputs.sha != ''"); - expect(workflow.concurrency).toBeUndefined(); - expect(preview.concurrency?.group).toContain("needs.resolve.outputs.pr_number"); - expect(preview.concurrency?.["cancel-in-progress"]).toBe(true); - expect(report.needs).toEqual(["resolve", "preview"]); - expect(report.permissions).toEqual({ contents: "read", "pull-requests": "write" }); - expect(report.if).toContain("github.triggering_actor == 'kingsword09'"); - expect(report.if).toContain("github.event_name == 'workflow_run'"); - expect(report.if).toContain("needs.preview.result == 'success'"); - expect(report.if).toContain("needs.preview.result == 'failure'"); - expect(preview.outputs?.url).toBe("${{ steps.published.outputs.url }}"); - expect(report.steps.find(step => step.name === "Post preview result")?.env?.PREVIEW_URL) - .toBe("${{ needs.preview.outputs.url }}"); - for (const job of [resolveJob, report]) { - const checkout = findAction(job.steps, "actions/checkout", actionShas.checkout); - expect(checkout?.with?.ref).toBe("${{ github.workflow_sha }}"); - expect(checkout?.with?.["persist-credentials"]).toBe(false); - expect(job.steps.some(step => step.run?.includes("bun install"))).toBe(false); - } - expect(preview.steps.some(step => step.env?.GH_TOKEN || step.env?.GITHUB_TOKEN)).toBe(false); - expect(source).not.toContain("${{ github.event.comment.body }}"); - }); - - test("runs read-only CI with pinned actions and cancels superseded checks", async () => { - const { source, workflow } = await readWorkflow("ci.yml"); - const job = workflow.jobs.validate!; - const checkout = findAction(job.steps, "actions/checkout", actionShas.checkout); - const setupNode = findAction(job.steps, "actions/setup-node", actionShas.setupNode); - const setupBun = findAction(job.steps, "oven-sh/setup-bun", actionShas.setupBun); - const install = job.steps.find((step) => step.name === "Install dependencies"); - const build = job.steps.find((step) => step.name === "Build and test"); - const pack = job.steps.find((step) => step.name === "Pack and install-test"); - const metadata = job.steps.find((step) => step.name === "Verify repository and npm metadata"); - - expect(workflow.on).toHaveProperty("push"); - expect(workflow.on).toHaveProperty("pull_request"); - expect(workflow.on).toHaveProperty("workflow_dispatch"); - expect(workflow.permissions).toEqual({ contents: "read" }); - expect(workflow.concurrency?.group).toContain("github.event.pull_request.number"); - expect(workflow.concurrency?.group).toContain("github.ref"); - expect(workflow.concurrency?.["cancel-in-progress"]).toBe(true); - expect(job["runs-on"]).toBe("ubuntu-latest"); - expect(job["timeout-minutes"]).toBe(45); - expect(checkout?.with?.["persist-credentials"]).toBe(false); - expect(setupNode?.with?.["node-version"]).toBe("22.19.0"); - expect(setupNode?.with?.["package-manager-cache"]).toBe(false); - expect(setupBun).toBeDefined(); - expect(install?.run).toBe("bun install --frozen-lockfile"); - expect(build?.run).toBe("bun run release:build"); - expect(pack?.run).toBe("bun run release:pack"); - expect(metadata?.run).toContain("npm pkg fix --dry-run --json"); - expect(metadata?.run).toContain("git diff --check"); - expect(metadata?.run).toContain("git diff --exit-code -- package.json zcode-runtime.lock.json"); - expect(source).not.toContain("NPM_TOKEN"); - expect(source).not.toContain("npm publish"); - expect(source).not.toContain("id-token: write"); - }); - - test("prepares release PRs only from the default branch with pinned actions", async () => { - const { source, workflow } = await readWorkflow("prepare-release.yml"); - const job = workflow.jobs.prepare!; - const steps = job.steps; - const checkout = findAction(steps, "actions/checkout", actionShas.checkout); - const setupNode = findAction(steps, "actions/setup-node", actionShas.setupNode); - const setupBun = findAction(steps, "oven-sh/setup-bun", actionShas.setupBun); - const releaseBuild = steps.find((step) => step.name === "Build and validate release candidate"); - const releaseMetadata = steps.find((step) => step.name === "Prepare release metadata"); - const createPullRequest = steps.find((step) => step.name === "Create or update release pull request"); - const keepalive = workflow.jobs.keepalive!; - const keepaliveStep = keepalive.steps.find((step) => step.name === "Keep scheduled workflow enabled"); - - expect(workflow.on).toHaveProperty("schedule"); - expect(workflow.on).toHaveProperty("workflow_dispatch"); - expect(workflow.on.schedule).toEqual([ - { cron: "30 1 * * *", timezone: "Asia/Shanghai" } - ]); - expect(workflow.permissions).toEqual({ contents: "write", issues: "write", "pull-requests": "write" }); - expect(job.if).toContain("github.event_name == 'schedule'"); - expect(job.if).toContain("github.ref_name == github.event.repository.default_branch"); - expect(checkout?.with?.["persist-credentials"]).toBe(false); - expect(setupNode?.with?.["package-manager-cache"]).toBe(false); - expect(setupBun).toBeDefined(); - expect(releaseBuild?.run).toBe("bun run release:prepare"); - const compatibilitySummary = steps.find((step) => step.name === "Summarize runtime compatibility failure"); - const compatibilityUpload = findAction(steps, "actions/upload-artifact", actionShas.uploadArtifact); - const compatibilityIssue = steps.find((step) => step.name === "Create or update runtime compatibility issue"); - const resolvedIssue = steps.find((step) => step.name === "Close resolved runtime compatibility issue"); - expect(compatibilitySummary?.if).toContain("failure()"); - expect(compatibilitySummary?.run).toContain("runtime-compatibility.md"); - expect(compatibilitySummary?.run).toContain('PHASE" == "runtime_discovery"'); - expect(compatibilityUpload?.if).toContain("runtime-compatibility.json"); - expect(compatibilityUpload?.with?.["if-no-files-found"]).toBe("error"); - expect(compatibilityIssue?.if).toContain("steps.compatibility.outputs.actionable == 'true'"); - expect(compatibilityIssue?.run).toContain("gh issue edit"); - expect(compatibilityIssue?.run).toContain("gh issue create"); - expect(resolvedIssue?.if).toContain("success()"); - expect(resolvedIssue?.run).toContain("gh issue close"); - expect(releaseMetadata?.env?.BASE_VERSION).toBe("${{ steps.base.outputs.package_version }}"); - expect(releaseMetadata?.run).toContain( - "compareReleaseVersions(process.env.PACKAGE_VERSION, process.env.BASE_VERSION) > 0" - ); - expect(createPullRequest?.uses).toBe( - "peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1" - ); - expect(createPullRequest?.with?.["add-paths"]).toContain("package.json"); - expect(createPullRequest?.with?.["add-paths"]).toContain("zcode-runtime.lock.json"); - expect(createPullRequest?.with?.branch).toBe("${{ steps.release.outputs.branch }}"); - expect(keepalive.if).toBe("github.event_name == 'schedule'"); - expect(keepalive.permissions).toEqual({ actions: "write" }); - expect(keepalive["timeout-minutes"]).toBe(5); - expect(keepaliveStep?.env?.GH_TOKEN).toBe("${{ github.token }}"); - expect(keepaliveStep?.run).toContain( - "repos/${GITHUB_REPOSITORY}/actions/workflows/prepare-release.yml/enable" - ); - expect(source).not.toContain("NPM_TOKEN"); - expect(source).not.toContain("npm publish"); - }); - - test("validates releases without write credentials before the privileged publish job", async () => { - const { source, workflow } = await readWorkflow("publish.yml"); - const validate = workflow.jobs.validate!; - const publishJob = workflow.jobs.publish!; - const validateCheckout = findAction(validate.steps, "actions/checkout", actionShas.checkout); - const publishCheckout = findAction(publishJob.steps, "actions/checkout", actionShas.checkout); - const setupNode = findAction(publishJob.steps, "actions/setup-node", actionShas.setupNode); - const setupBun = findAction(validate.steps, "oven-sh/setup-bun", actionShas.setupBun); - const upload = findAction(validate.steps, "actions/upload-artifact", actionShas.uploadArtifact); - const download = findAction(publishJob.steps, "actions/download-artifact", actionShas.downloadArtifact); - const releaseBuild = validate.steps.find((step) => step.name === "Build committed release"); - const packageCheck = validate.steps.find((step) => step.name === "Pack and install-test release"); - const driftCheck = validate.steps.find((step) => step.name === "Verify release did not drift"); - const transferCheck = publishJob.steps.find((step) => step.name === "Verify validated tarball"); - const rebuild = publishJob.steps.find((step) => step.name === "Rebuild tarball without project scripts"); - const stateCheck = publishJob.steps.find((step) => step.name === "Inspect release state"); - const publishIndex = publishJob.steps.findIndex( - (step) => step.name === "Publish to npm with Trusted Publishing" - ); - const tagIndex = publishJob.steps.findIndex((step) => step.name === "Create immutable Git tag"); - const releaseIndex = publishJob.steps.findIndex((step) => step.name === "Create GitHub Release"); - const publish = publishJob.steps[publishIndex]; - - expect(workflow.on).toHaveProperty("workflow_dispatch"); - expect(workflow.on).toHaveProperty("pull_request"); - expect(workflow.permissions).toEqual({}); - expect(validate.permissions).toEqual({ contents: "read" }); - expect(publishJob.permissions).toEqual({ contents: "write", "id-token": "write" }); - expect(publishJob.needs).toBe("validate"); - expect(validate.if).toContain("github.ref_name == github.event.repository.default_branch"); - expect(validate.if).toContain("github.event.pull_request.merged == true"); - expect(validate.if).toContain("github.event.pull_request.head.repo.full_name == github.repository"); - expect(validate.if).toContain("release/zcode-cli"); - expect(validate.if).toContain("release/zcode-upstream"); - expect(validateCheckout?.with?.["persist-credentials"]).toBe(false); - expect(publishCheckout?.with?.["persist-credentials"]).toBe(false); - expect(setupNode?.with?.["node-version"]).toBe(24); - expect(setupNode?.with?.["package-manager-cache"]).toBe(false); - expect(setupBun).toBeDefined(); - expect(publishJob.steps.some((step) => step.run?.includes("bun"))).toBe(false); - expect(publishJob.steps.some((step) => step.name === "Install dependencies")).toBe(false); - expect(releaseBuild?.run).toBe("bun run release:build"); - expect(packageCheck?.run).toContain("bun run release:pack"); - expect(packageCheck?.run).toContain("cp .release/release.json"); - expect(upload?.with?.["if-no-files-found"]).toBe("error"); - expect(upload?.with?.path).toBe("release-artifact"); - expect(download?.with?.name).toContain("needs.validate.outputs.artifact_name"); - expect(driftCheck?.run).toContain("git diff --exit-code -- package.json"); - expect(driftCheck?.run).toContain("zcode-runtime.lock.json"); - expect(transferCheck?.run).toContain("release.json"); - expect(rebuild?.run).toContain("tar -xzf"); - expect(rebuild?.run).toContain("npm pack ./.release/publish/package"); - expect(rebuild?.run).toContain("--ignore-scripts"); - expect(rebuild?.run).toContain("cmp --"); - expect(stateCheck?.run).toContain("gitHead"); - expect(stateCheck?.run).toContain("TAG_COMMIT"); - await expect(runInlineVersionComparator(stateCheck!.run!, "3.3.7-1", "3.3.6-99")).resolves.toBe("1"); - await expect(runInlineVersionComparator(stateCheck!.run!, "3.3.6-5", "3.3.6-5")).resolves.toBe("0"); - await expect(runInlineVersionComparator(stateCheck!.run!, "3.3.6-4", "3.3.6-5")).resolves.toBe("-1"); - expect(publish?.run).toBe( - "npm publish ./.release/publish/package --ignore-scripts --access public --tag latest" - ); - expect(publish?.env).toBeUndefined(); - expect(publishIndex).toBeGreaterThan(-1); - expect(tagIndex).toBeGreaterThan(publishIndex); - expect(releaseIndex).toBeGreaterThan(tagIndex); - expect(source).not.toContain("NPM_TOKEN"); - expect(source).not.toContain("npm@latest"); - expect(source).toContain("npm@12.0.1"); - expect(source).toContain("gh api --method POST"); - }); - - test("removes the direct scheduled publishing workflow", () => { - expect(existsSync(resolve(root, ".github", "workflows", "sync-and-publish.yml"))).toBe(false); - }); -}); diff --git a/test/rich-markdown.test.ts b/test/rich-markdown.test.ts deleted file mode 100644 index 4807ade0..00000000 --- a/test/rich-markdown.test.ts +++ /dev/null @@ -1,4543 +0,0 @@ -import { describe, expect, setDefaultTimeout, test } from "bun:test"; -import { Markdown, Text, visibleWidth } from "@earendil-works/pi-tui"; - -import { CodeHighlighter } from "../packages/zcode-tui/src/code-highlighter.ts"; -import { - RichMarkdown, - isPlainMarkdownBlock, - normalizeMermaidTerminalWidth, - renderMermaidPreview, - splitMarkdownSegments, - splitStreamingMarkdownSegments -} from "../packages/zcode-tui/src/rich-markdown.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -// This file exhaustively renders every streaming prefix across widths and themes. -// Keep correctness coverage intact while allowing slower shared CI runners to finish. -setDefaultTimeout(20_000); - -function terminalColumn(line: string, marker: string): number { - const index = line.indexOf(marker); - return index < 0 ? -1 : visibleWidth(line.slice(0, index)); -} - -function withoutBoundaryBlankLines(text: string): string { - const lines = text.split("\n"); - let start = 0; - let end = lines.length; - while (start < end && lines[start]!.trim() === "") start += 1; - while (end > start && lines[end - 1]!.trim() === "") end -= 1; - return lines.slice(start, end).join("\n"); -} - -describe("TUI rich Markdown", () => { - test("uses the plain fast path only when Markdown semantics are impossible", () => { - const plain = [ - "Ordinary prose with punctuation (42), 中文宽度 and emoji ✅.", - "Paths like packages/zcode-tui/src stay plain.", - "A sentence with a mid-line hyphen stays plain." - ]; - const markdown = [ - "# heading", - "- list item", - "+ list item", - "1. ordered item", - "---", - " indented code", - "**bold**", - "`code`", - "[link](target)", - "", - "https://example.com", - "ftp://example.com", - "www.example.com", - "person@example.com", - "AT&T", - "left | right", - "tab\tcontent", - "first line\nsecond line" - ]; - - for (const source of plain) expect(isPlainMarkdownBlock(source)).toBeTrue(); - for (const source of markdown) expect(isPlainMarkdownBlock(source)).toBeFalse(); - }); - - test("plain fast path is byte-identical to Markdown at supported widths and themes", () => { - const sources = [ - "Ordinary prose with punctuation (42), 中文宽度 and emoji ✅.", - "This deliberately long sentence wraps across several terminal rows while remaining plain text for every rendered frame." - ]; - for (const theme of [ - createTheme(false), - createTheme(true, "dark"), - createTheme(true, "light") - ]) { - for (const source of sources) { - const text = new Text(source, 1, 0); - const markdown = new Markdown(source, 1, 0, theme.markdown); - for (const width of [40, 60, 80, 100]) { - expect(text.render(width)).toEqual(markdown.render(width)); - } - } - } - }); - - test("plain streaming prefixes stay byte-identical to one-shot Markdown", () => { - const source = "Ordinary prose grows token by token with 中文宽度, punctuation (42), and emoji ✅."; - for (const color of [false, true]) { - const theme = createTheme(color); - for (let end = 1; end <= source.length; end += 1) { - const prefix = source.slice(0, end); - expect(isPlainMarkdownBlock(prefix)).toBeTrue(); - const streamed = new RichMarkdown("", 1, theme); - for (const character of prefix) streamed.appendText(character); - for (const width of [40, 60, 80, 100]) { - const expected = new Markdown(prefix, 1, 0, theme.markdown).render(width); - expect(streamed.render(width)).toEqual(expected); - } - } - } - }); - - test("incremental plain wrapping matches Text for difficult append boundaries", () => { - const sources = [ - "word boundary spacing keeps only meaningful terminal cells", - "supercalifragilisticexpialidociouscontinuestogrowwithoutspaces", - "中文宽度连续换行测试以及更多字符", - "family emoji 👨‍👩‍👧‍👦 and flags 🇨🇳 remain complete" - ]; - const widths = [40, 17, 60, 11, 80, 100]; - - for (const source of sources) { - const component = new RichMarkdown("", 1, createTheme(false)); - let prefix = ""; - for (const character of source) { - prefix += character; - component.appendText(character); - const plain = prefix.trim(); - for (const width of widths) { - expect(component.render(width)).toEqual(new Text(plain, 1, 0).render(width)); - } - } - - component.setText("replacement text that is not an append"); - for (const width of widths) { - expect(component.render(width)).toEqual( - new Text("replacement text that is not an append", 1, 0).render(width) - ); - } - component.invalidate(); - expect(component.render(40)).toEqual( - new Text("replacement text that is not an append", 1, 0).render(40) - ); - } - }); - - test("semantic inline tails stay byte-identical for every streaming prefix", () => { - const sources = [ - "**bold prefix** ordinary tail grows token by token with punctuation (42).", - "`inline code` ordinary tail keeps wrapping without reparsing the prefix.", - "[link](https://example.com) ordinary tail remains a single paragraph.", - "~~strike~~ 中文尾部和 emoji 👨‍👩‍👧‍👦 remain aligned.", - "Ordinary lead **加粗内容** followed by a stable plain tail.", - "**unclosed delimiter remains literal while a plain tail grows", - "**bold** plain tail later gains *italic syntax* and then becomes plain again" - ]; - for (const theme of [ - createTheme(false), - createTheme(true, "dark"), - createTheme(true, "light") - ]) { - for (const source of sources) { - const component = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - component.appendText(character); - for (const width of [40, 60, 80, 100]) { - expect(component.render(width)).toEqual( - new Markdown(prefix, 1, 0, theme.markdown).render(width) - ); - } - } - } - } - }); - - test("activates the incremental semantic-tail component only for inline paragraphs", () => { - const inline = new RichMarkdown("**bold** stable plain tail", 1, createTheme(false)); - inline.render(80); - const inlineInternal = inline as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(inlineInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingInlineMarkdown"); - - const heading = new RichMarkdown("# **bold heading** stable tail", 1, createTheme(false)); - heading.render(80); - const headingInternal = heading as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(headingInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("flat unordered lists stay byte-identical for every streaming prefix", () => { - const sources = [ - "- first item\n- second item grows token by token\n- third item", - "* **bold item** with more words\n+ `code item` with 中文 and emoji ✅\n- final", - "- [ ] pending task\n- [x] completed task\n- ordinary task", - "- flat item\n nested continuation forces the original Markdown fallback", - "- unordered item\n1. ordered transition also forces fallback" - ]; - for (const theme of [ - createTheme(false), - createTheme(true, "dark"), - createTheme(true, "light") - ]) { - for (const source of sources) { - const component = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - component.appendText(character); - for (const width of [40, 60, 80, 100]) { - expect(component.render(width)).toEqual( - new RichMarkdown(prefix, 1, theme).render(width) - ); - } - } - } - } - }); - - test("activates stable rows only for strict root lists", () => { - const list = new RichMarkdown("- one\n- two\n- three", 1, createTheme(false)); - list.render(80); - const listInternal = list as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - items?: unknown[]; - widthState?: { characters: number }; - }; - }>; - }; - expect(listInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(listInternal.renderedSegments[0]?.component.items).toBeUndefined(); - expect(listInternal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - - const ordered = new RichMarkdown("1. one\n2. two", 1, createTheme(false)); - ordered.render(80); - const orderedInternal = ordered as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(orderedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - - const nested = new RichMarkdown( - "- parent one\n - child one\n * child two\n+ parent two\n - child three", - 1, - createTheme(false) - ); - nested.render(80); - const nestedInternal = nested as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - items?: unknown[]; - widthState?: { characters: number }; - }; - }>; - }; - expect(nestedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(nestedInternal.renderedSegments[0]?.component.items).toBeUndefined(); - expect(nestedInternal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - - const continuation = new RichMarkdown( - "- parent one\n continuation one\n+ parent two\n continuation two", - 1, - createTheme(false) - ); - continuation.render(80); - const continuationInternal = continuation as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(continuationInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - }); - - test("keeps root one-level nested lists byte-identical for every streaming prefix", () => { - const sources = [ - [ - "- parent one", - " - child one", - " * child two with additional wrapping words", - "+ **parent two** with generated content", - " - `child two` with 中文 and emoji ✅" - ].join("\n"), - [ - "* [ ] pending parent task", - " + [x] completed nested task", - "- parent with *italic* and ~~strike~~", - " - [official link](https://example.com) child" - ].join("\n") - ]; - - for (const theme of [ - createTheme(false), - createTheme(true, "dark"), - createTheme(true, "light") - ]) { - for (const source of sources) { - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - } - } - }); - - test("keeps root nested child continuations byte-identical for every streaming prefix", () => { - const source = [ - "- parent one", - " - child one", - ` continuation one ${"with wrapping words ".repeat(6).trim()}`, - " second continuation with *italic* and ~~strike~~", - " + child two with `code`", - " 中文宽度 and emoji ✅ remain aligned", - "+ **parent two** with generated content", - " * [ ] pending child task", - " [official link](https://example.com) stays line-local" - ].join("\n"); - - for (const theme of [ - createTheme(false), - createTheme(true, "dark"), - createTheme(true, "light") - ]) { - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - }); - - test("keeps loose root nested lists byte-identical for every streaming prefix", () => { - const sources = [ - [ - "- parent one with **bold** output", - "", - " - child one with generated words", - "", - " * child two with `code` and additional wrapping words", - "", - "+ parent two with *italic* and ~~strike~~", - "", - " + [ ] pending child task", - "", - " - [official link](https://example.com) child" - ].join("\n"), - [ - "* [x] completed parent task", - "", - " + 中文宽度 and emoji ✅ remain aligned", - "", - `- parent ${"with wrapping words ".repeat(6).trim()}`, - "", - ` * child ${"with generated output ".repeat(6).trim()}` - ].join("\n") - ]; - - for (const theme of [ - createTheme(false), - createTheme(true, "dark"), - createTheme(true, "light") - ]) { - for (const source of sources) { - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - separatorKind?: string; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(internal.renderedSegments[0]?.component.separatorKind).toBe("blank"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - - const full = streamed.render(80); - const windowed = streamed.renderWindow(80, 0, full.length); - expect(windowed.lines).toEqual(full); - expect(windowed.totalLines).toBe(full.length); - } - } - }); - - test("keeps root list continuation chunks byte-identical for every streaming prefix", () => { - const sources = [ - [ - "- parent one", - " continuation one", - " second continuation with additional wrapping words", - "* **parent two** with generated content", - " `code continuation` with 中文 and emoji ✅" - ].join("\n"), - [ - "+ [ ] pending parent task", - " [x] completed continuation task", - "- parent with *italic* and ~~strike~~", - " [official link](https://example.com) continuation" - ].join("\n") - ]; - - for (const theme of [ - createTheme(false), - createTheme(true, "dark"), - createTheme(true, "light") - ]) { - for (const source of sources) { - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - } - } - }); - - test("keeps root ordered continuation chunks byte-identical for every streaming prefix", () => { - const sources = [ - [ - "1. **first item** ordinary tail", - " continuation one with `code`", - ` second continuation ${"with wrapping words ".repeat(6).trim()}`, - "2. *second item* with ~~deleted text~~", - " [official link](https://example.com) continuation", - "3. 中文 item ✅", - " final continuation" - ].join("\n"), - [ - "7) [ ] pending parent task", - " [x] completed continuation task", - "7) repeated source marker", - " continuation is normalized to item eight", - "7) final repeated marker", - " final continuation" - ].join("\n"), - [ - "98. item ninety eight", - " continuation 98", - "98. item ninety nine after normalization", - " continuation 99", - "98. item one hundred after normalization", - " continuation 100" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - }); - - test("keeps root ordered nested-list chunks byte-identical for every streaming prefix", () => { - const sources = [ - [ - "1. **first parent** ordinary tail", - " - child one with `code`", - ` * child two ${"with wrapping words ".repeat(6).trim()}`, - "2. *second parent* with ~~deleted text~~", - " + [ ] pending child task", - "3. 中文 parent ✅", - " - [x] completed child task" - ].join("\n"), - [ - "7) repeated source parent", - " - child seven", - "7) repeated becomes eight", - " * child eight", - "7) repeated becomes nine", - " + child nine" - ].join("\n"), - [ - "9. item nine", - " - child nine", - "9. normalized item ten", - " - child ten", - "9. normalized item eleven", - " - child eleven" - ].join("\n"), - [ - "99. item ninety-nine", - " - child ninety-nine", - "99. normalized item one hundred", - " - child one hundred", - "99. normalized item one hundred one", - " - child one hundred one" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - }); - - test("normalizes root ordered lists exactly like the whole Markdown renderer", () => { - const sources = [ - [ - "1. **first item** ordinary tail", - "1. `second item` with more words", - "1. [ ] pending 中文 task ✅", - "1. [x] completed task" - ].join("\n"), - [ - "7) starts from seven", - "8) **continues at eight**", - "9) final item wraps with additional generated words" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - } - } - }); - - test("keeps loose root ordered lists byte-identical for every streaming prefix", () => { - const sources = [ - [ - "1. **first item** ordinary tail", - "", - `2. \`second item\` ${"with wrapping words ".repeat(8).trim()}`, - "", - "3. [ ] pending 中文 task ✅", - "", - "4. [x] completed task" - ].join("\n"), - [ - "7) starts from seven", - "", - "7) **repeated source marker normalizes to eight**", - "", - "7) final repeated marker becomes nine" - ].join("\n"), - [ - "9. item nine with generated output", - "", - "9. normalized item ten crosses marker width", - "", - "9. normalized item eleven with emoji ✅" - ].join("\n"), - `${[ - "99) item ninety-nine", - "", - "99) normalized item one hundred with 中文宽度", - "", - "99) normalized item one hundred one" - ].join("\n")}\n\n` - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - separatorKind?: string; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(internal.renderedSegments[0]?.component.separatorKind).toBe("blank"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - }); - - test("keeps unsupported loose root ordered lists on the original Markdown path", () => { - for (const source of [ - "1. first\n\n2.", - "1. first\n\n\n2. double blank", - "1. first\n \n2. non-empty blank", - "1. first\n\n2) mixed delimiter", - "1. first\n\n- unordered transition", - "1. first\n\n - nested child", - "1. first\n\n continuation", - "1. first\n\n# heading transition", - "1. first\n\n2. **cross-line starts\ncloses here**", - "1. ambiguous spacing\n\n2. second", - "999999999. first\n\n999999999. overflow", - `1. item ${"x".repeat(100_000)}\n\n2. second` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - expect(component.render(80).length).toBeGreaterThan(0); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("keeps non-flat root ordered lists on the original Markdown path", () => { - for (const source of [ - "1.", - "1. first\n2) mixed delimiter", - "1. first\n- unordered transition", - "999999999. first\n999999999. overflow", - "1. first\n continuation" - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - expect(component.render(80).length).toBeGreaterThan(0); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("keeps unsupported root nested lists on the original Markdown path", () => { - for (const source of [ - "- parent\n -", - "- ambiguous parent spacing\n - child", - "- parent\n - ambiguous child spacing", - "- parent\n - wrong indent", - "- parent\n - deeper child", - "- parent\n continuation before child", - "- parent\n - child\n continuation with wrong indent", - "- parent\n - child\n continuation with deeper indent", - "- parent\n - child\n 1. ordered grandchild", - "- parent\n - child\n # nested heading", - "- parent\n - child\n > nested quote", - "- parent\n - child\n **cross\n line**", - "- parent\n 1. ordered child", - "- parent\n - child\n- flat sibling", - "- parent\n - **cross\n line**", - "- parent\n - # nested heading", - `- parent\n - child ${"x".repeat(100_000)}` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - expect(component.render(80).length).toBeGreaterThan(0); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("keeps unsupported loose root nested lists on the original Markdown path", () => { - for (const source of [ - "- parent\n\n - child with wrong indent", - "- parent\n \n - child after a non-empty blank line", - "- parent\n\n\n - child after multiple blank lines", - "- parent\n\n 1. ordered child", - "1. ordered parent\n\n - unordered child", - "- parent\n\n - child\n - adjacent child", - "- parent\n\n - child\n continuation", - "- parent\n\n - child\n\n - grandchild", - "- parent\n\n - child\n\n- next parent without a child", - "- parent\n\n - # nested heading", - "- parent\n\n -
nested HTML
", - "- **cross\n\n line** child", - "- parent\n\n - **cross\n\n line**", - `- parent\n\n - child ${"x".repeat(100_000)}` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - expect(component.render(80).length).toBeGreaterThan(0); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("keeps unsupported root list continuations on the original Markdown path", () => { - for (const source of [ - "- parent\n continuation with wrong indent", - "- parent\n continuation with deeper indent", - "- parent\n - nested child\n mixed continuation", - "1. ordered parent\n ordered continuation", - "- parent\n continuation\n- flat sibling", - "- parent\n **cross\n line**", - "- parent\n # nested heading", - `- parent\n continuation ${"x".repeat(100_000)}` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - expect(component.render(80).length).toBeGreaterThan(0); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("keeps unsupported root ordered continuations on the original Markdown path", () => { - for (const source of [ - "1. parent\n continuation with wrong indent", - "1. parent\n continuation with deeper indent", - "1. ambiguous parent spacing\n continuation", - "1. parent\n continuation\n2) mixed delimiter\n continuation", - "1. parent\n continuation\n- unordered transition", - "1. parent\n - nested child", - "1. parent\n # nested heading", - "1. parent\n > nested quote", - "1. **cross\n line**", - "1. parent\n\n loose continuation", - "1. parent\n continuation\n2. next parent without continuation", - "999999999. parent\n continuation\n999999999. overflow\n continuation", - `1. parent\n continuation ${"x".repeat(100_000)}` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - expect(component.render(80).length).toBeGreaterThan(0); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("keeps unsupported root ordered nested lists on the original Markdown path", () => { - for (const source of [ - "1. parent\n - child\n2. final parent without child", - " - child before parent", - "1. parent\n - child with too little indent", - "1. parent\n - child with too much indent", - "10. parent\n - child using one-digit indent", - "1. ambiguous parent spacing\n - child", - "1. parent\n - ambiguous child spacing", - "1. parent\n 1. ordered child", - "1. parent\n - child\n - grandchild", - "1. parent\n - child\n continuation", - "1. parent\n # nested heading", - "1. parent\n > nested quote", - "1. parent\n - **cross\n line**", - "1. parent\n\n - loose child", - "1. parent\n - child\n2) mixed delimiter\n - child", - "1. parent\n - child\n- unordered parent", - "999999999. parent\n - child\n999999999. overflow\n - child", - `1. parent\n - child ${"x".repeat(100_000)}` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - expect(component.render(80).length).toBeGreaterThan(0); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("avoids incremental setup for a restored large root nested list", () => { - const chunks = Array.from( - { length: 65 }, - (_, index) => `- parent ${index}\n - nested child ${index}` - ); - const restored = new RichMarkdown(chunks.join("\n"), 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(chunks[0]!, 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n${chunks.slice(1).join("\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - - const replacement = chunks.map((chunk) => chunk.replaceAll("parent", "replacement")); - streamed.setText(replacement.join("\n")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("avoids incremental setup for a restored large nested child continuation", () => { - const chunks = Array.from( - { length: 65 }, - (_, index) => [ - `- parent ${index}`, - ` - nested child ${index}`, - ` continuation ${index}` - ].join("\n") - ); - const restored = new RichMarkdown(chunks.join("\n"), 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(chunks[0]!, 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n${chunks.slice(1).join("\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - - streamed.setText(chunks.map((chunk) => chunk.replace("parent", "replacement")).join("\n")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("avoids incremental setup for a restored large loose root nested list", () => { - const chunks = Array.from( - { length: 65 }, - (_, index) => `- parent ${index}\n\n - nested child ${index}` - ); - const restored = new RichMarkdown(chunks.join("\n\n"), 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(chunks[0]!, 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n\n${chunks.slice(1).join("\n\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ - component: { constructor: { name: string }; separatorKind?: string }; - }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(streamedInternal.renderedSegments[0]?.component.separatorKind).toBe("blank"); - - streamed.setText(chunks.map((chunk) => chunk.replace("parent", "replacement")).join("\n\n")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("avoids incremental setup for a restored large loose root ordered list", () => { - const items = Array.from( - { length: 65 }, - (_, index) => `${index + 1}. **item ${index}** generated output` - ); - const source = items.join("\n\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(items.slice(0, 2).join("\n\n"), 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n\n${items.slice(2).join("\n\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ - component: { constructor: { name: string }; separatorKind?: string }; - }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(streamedInternal.renderedSegments[0]?.component.separatorKind).toBe("blank"); - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("avoids incremental setup for a restored large root continuation list", () => { - const chunks = Array.from( - { length: 65 }, - (_, index) => `- parent ${index}\n continuation ${index}` - ); - const restored = new RichMarkdown(chunks.join("\n"), 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(chunks[0]!, 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n${chunks.slice(1).join("\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - }); - - test("avoids incremental setup for a restored large root ordered continuation list", () => { - const chunks = Array.from( - { length: 65 }, - (_, index) => `${index + 1}. item ${index}\n continuation ${index}` - ); - const restored = new RichMarkdown(chunks.join("\n"), 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(chunks[0]!, 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n${chunks.slice(1).join("\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - - streamed.setText(chunks.map((chunk) => chunk.replace("item", "replacement")).join("\n")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("avoids incremental setup for a restored large root ordered nested list", () => { - const chunks = Array.from( - { length: 65 }, - (_, index) => { - const marker = `${index + 1}.`; - return `${marker} parent ${index}\n${" ".repeat(marker.length + 1)}- child ${index}`; - } - ); - const source = chunks.join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(chunks[0]!, 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n${chunks.slice(1).join("\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - - streamed.setText(source.replaceAll("parent", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("switches a growing plain tail to Markdown as soon as syntax appears", () => { - const component = new RichMarkdown("Plain tail", 1, createTheme(false)); - component.render(80); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments[0]?.component).not.toBeInstanceOf(Markdown); - - component.appendText(" with **bold**"); - expect(component.render(80).join("\n")).toContain("bold"); - expect(internal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("extracts only complete Mermaid fences", () => { - expect(splitMarkdownSegments([ - "Before", - "```mermaid", - "graph LR", - "A --> B", - "```", - "After" - ].join("\n"))).toEqual([ - { kind: "markdown", text: "Before" }, - { kind: "mermaid", source: "graph LR\nA --> B" }, - { kind: "markdown", text: "After" } - ]); - - expect(splitMarkdownSegments("```mermaid\ngraph LR\nA -->")).toEqual([ - { kind: "markdown", text: "```mermaid\ngraph LR\nA -->" } - ]); - - const nestedSource = [ - "````text", - "```mermaid", - "graph LR", - "A --> B", - "```", - "````" - ].join("\n"); - expect(splitMarkdownSegments(nestedSource)).toEqual([ - { kind: "markdown", text: nestedSource } - ]); - - expect(splitMarkdownSegments(" top-level indented code")).toEqual([ - { kind: "markdown", text: " top-level indented code" } - ]); - }); - - test("stabilizes complete Markdown blocks without splitting fenced code", () => { - expect(splitStreamingMarkdownSegments([ - "First paragraph.", - "", - "```ts", - "const first = 1;", - "", - "const second = 2;", - "```", - "", - "Streaming tail" - ].join("\n"))).toEqual([ - { kind: "markdown", text: "First paragraph." }, - { kind: "markdown", text: "```ts\nconst first = 1;\n\nconst second = 2;\n```" }, - { kind: "markdown", text: "Streaming tail" } - ]); - }); - - test("keeps contextual blank boundaries byte-identical to whole Markdown", () => { - const sources = [ - " top-level indented code", - "paragraph\n\n indented code", - "- parent\n - child\n\n loose continuation", - "1. first\n\n1. second", - "- first\n\n- second", - "paragraph\n\n> quoted block", - "first paragraph\n\nsecond paragraph" - ]; - - expect(splitStreamingMarkdownSegments(sources[1]!)).toEqual([ - { kind: "markdown", text: sources[1]! } - ]); - expect(splitStreamingMarkdownSegments(sources[3]!)).toEqual([ - { kind: "markdown", text: sources[3]! } - ]); - expect(splitStreamingMarkdownSegments(sources[6]!)).toEqual([ - { kind: "markdown", text: "first paragraph" }, - { kind: "markdown", text: "second paragraph" } - ]); - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - } - } - }); - - test("keeps full and windowed segment separators width-identical", () => { - const component = new RichMarkdown("first paragraph\n\nsecond paragraph", 1, createTheme(false)); - const full = component.render(80); - const windowed = component.renderWindow(80, 0, full.length); - - expect(windowed.lines).toEqual(full); - expect(windowed.totalLines).toBe(full.length); - expect(full.find((line) => line.trim() === "")).toBe(" ".repeat(80)); - }); - - test("keeps assistant Markdown searchable while streaming", () => { - const component = new RichMarkdown("first", 1, createTheme(false)); - component.setText("first\n\nsecond"); - expect(component.getSearchText()).toBe("first\n\nsecond"); - expect(component.render(80).join("\n")).toContain("second"); - }); - - test("renders chunked streaming input exactly like one-shot input", () => { - const source = [ - "# Heading", - "", - "Paragraph with **bold** and \x1b[31muntrusted color\x1b[0m.", - "", - "| Name | Value |", - "| --- | ---: |", - "| 中文 | 42 |", - "", - "```ts", - "const answer = 42;", - "```", - "", - "- first", - "- second" - ].join("\n"); - const chunks = [ - source.slice(0, 19), - source.slice(19, 39), - source.slice(39, 52), - source.slice(52, 97), - source.slice(97) - ]; - const streamed = new RichMarkdown("", 1, createTheme(false)); - for (const chunk of chunks) streamed.appendText(chunk); - streamed.finishText(); - const complete = new RichMarkdown(source, 1, createTheme(false)); - - expect(streamed.getSearchText()).toBe(complete.getSearchText()); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(complete.render(width)); - } - }); - - test("keeps streaming TypeScript fences byte-identical while the closing fence arrives", () => { - const source = [ - "```typescript", - "const answer: number = 42;", - "const matcher: RegExp = /answer+/g;", - "const label: string = \"中文 ✅\";", - `const longValue = \"${"wrapped".repeat(24)}\";`, - "```" - ].join("\n"); - - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const streamed = new RichMarkdown("", 1, createTheme(colors, scheme)); - const base = createTheme(colors, scheme); - const fullHighlighter = new CodeHighlighter(colors, scheme); - const fallbackTheme = { - ...base, - markdown: { - ...base.markdown, - highlightCode: (code: string, language?: string) => fullHighlighter.highlight( - code, - language === "typescript" || language === "ts" ? "tsx" : language - ) - } - }; - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new RichMarkdown(prefix, 1, fallbackTheme); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - } - }); - - test("keeps blank-separated TypeScript blocks byte-identical while streaming", () => { - const source = [ - "```typescript", - "function first(value: number): number {", - " return value + 1;", - "}", - "", - "interface User {", - " id: string;", - " name: string;", - "}", - "", - "const user = {", - " id: \"1\",", - " name: \"Ada\"", - "};", - "```" - ].join("\n"); - - for (const scheme of ["dark", "light"] as const) { - const streamed = new RichMarkdown("", 1, createTheme(true, scheme)); - const base = createTheme(true, scheme); - const fullHighlighter = new CodeHighlighter(true, scheme); - const fallbackTheme = { - ...base, - markdown: { - ...base.markdown, - highlightCode: (code: string, language?: string) => fullHighlighter.highlight( - code, - language === "typescript" || language === "ts" ? "tsx" : language - ) - } - }; - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new RichMarkdown(prefix, 1, fallbackTheme); - for (const width of [40, 80]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - } - }); - - test("keeps adjacent top-level TypeScript blocks byte-identical while streaming", () => { - const source = [ - "```typescript", - "function first(value: number): number {", - " return value + 1;", - "}", - "interface User {", - " id: string;", - " name: string;", - "}", - "const user = {", - " id: \"1\",", - " name: \"Ada\"", - "};", - "```" - ].join("\n"); - - const streamed = new RichMarkdown("", 1, createTheme(true)); - const base = createTheme(true); - const fullHighlighter = new CodeHighlighter(true); - const fallbackTheme = { - ...base, - markdown: { - ...base.markdown, - highlightCode: (code: string, language?: string) => fullHighlighter.highlight( - code, - language === "typescript" || language === "ts" ? "tsx" : language - ) - } - }; - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new RichMarkdown(prefix, 1, fallbackTheme); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - }); - - test("keeps one still-open TypeScript function byte-identical while streaming", () => { - const source = [ - "```typescript", - "export async function generated(input: number): Promise {", - " const doubled: number = input * 2;", - " // line-local comment", - " const label: string = \"中文 ✅\";", - " const matcher: RegExp = /value+/g;", - " await Promise.resolve(label);", - " return matcher.test(label) ? doubled : input;", - "}", - "```" - ].join("\n"); - - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - const fullHighlighter = new CodeHighlighter(colors, scheme); - const fallbackTheme = { - ...theme, - markdown: { - ...theme.markdown, - highlightCode: (code: string, language?: string) => fullHighlighter.highlight( - code, - language === "typescript" || language === "ts" ? "tsx" : language - ) - } - }; - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new RichMarkdown(prefix, 1, fallbackTheme); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe(colors ? "StreamingFencedCodeMarkdown" : "Markdown"); - } - }); - - test("uses incremental fence presentation only for bounded TypeScript streams", () => { - const safe = new RichMarkdown([ - "~~~ts", - "const first: number = 1;", - "const second: number = 2;", - "~~" - ].join("\n"), 1, createTheme(true)); - safe.render(80); - const safeInternal = safe as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number }; - }; - }>; - }; - expect(safeInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingFencedCodeMarkdown"); - expect(safeInternal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - - const blocks = new RichMarkdown([ - "```typescript", - "function first(): number {", - " return 1;", - "}", - "", - "function second(): number {", - " return 2;", - "}" - ].join("\n"), 1, createTheme(true)); - blocks.render(80); - const blocksInternal = blocks as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(blocksInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingFencedCodeMarkdown"); - - const singleOpenSource = [ - "```typescript", - "function generated(input: number): number {", - " const doubled: number = input * 2;", - " return doubled;" - ].join("\n"); - const coloredSingleOpen = new RichMarkdown(singleOpenSource, 1, createTheme(true)); - coloredSingleOpen.render(80); - const coloredSingleOpenInternal = coloredSingleOpen as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(coloredSingleOpenInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingFencedCodeMarkdown"); - - const plainSingleOpen = new RichMarkdown(singleOpenSource, 1, createTheme(false)); - plainSingleOpen.render(80); - const plainSingleOpenInternal = plainSingleOpen as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(plainSingleOpenInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - for (const source of [ - "```typescript\nconst value = {\n id: 1\n};", - "```typescript\n/* open\nstill comment */", - "```typescript\nconst value = `first\nsecond`;", - "```tsx\nconst view = ;" - ]) { - const fallback = new RichMarkdown(source, 1, createTheme(true)); - fallback.render(80); - const internal = fallback as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("keeps line-local Python fences byte-identical for every streaming prefix", () => { - const sources = [ - [ - "```python", - "value_0: int = 0 # generated output", - "value_1: int = 1 # generated output", - "value_2: int = 2 # generated output", - "value_3: int = 3 # generated output" - ].join("\n"), - [ - "~~~py", - "message: str = \"中文 ✅\"", - "escaped = \"quote: \\\"ok\\\"\"", - "items: list[int] = [1, 2, 3]", - "mapping = {\"answer\": 42}", - "def add(left: int, right: int) -> int:", - " return left + right", - "print(add(1, 2))", - "~~~" - ].join("\n") - ]; - - for (const source of sources) { - for (const scheme of ["dark", "light"] as const) { - const theme = createTheme(true, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - highlightState?: { sourceLines: string[] }; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingFencedCodeMarkdown"); - expect(internal.renderedSegments[0]?.component.highlightState?.sourceLines.length) - .toBeGreaterThan(0); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - }); - - test("keeps no-color and unsafe Python fences on the original Markdown path", () => { - const noColor = new RichMarkdown( - "```python\nvalue: int = 1 # generated", - 1, - createTheme(false) - ); - noColor.render(80); - const noColorInternal = noColor as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(noColorInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - for (const source of [ - "```python\ntext = \"\"\"first\nsecond\"\"\"", - "```python\ntext = fr\"value {answer}\"", - "```python\nvalue = 1 + \\\n 2", - "```python\nvalues = (\n 1,\n)", - "```python\nvalues = [1, 2}", - "```python\n\tvalue = 1", - `\`\`\`python\nvalue = "${"x".repeat(100_000)}"` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - component.render(80); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("avoids incremental setup for a restored large Python fence", () => { - const lines = Array.from( - { length: 65 }, - (_, index) => `value_${index}: int = ${index} # generated output` - ); - const source = `\`\`\`python\n${lines.join("\n")}`; - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown( - `\`\`\`python\n${lines.slice(0, 2).join("\n")}`, - 1, - createTheme(true) - ); - streamed.render(80); - streamed.appendText(`\n${lines.slice(2).join("\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingFencedCodeMarkdown"); - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("keeps simple Python f-string fences byte-identical for every streaming prefix", () => { - const sources = [ - [ - "```python", - "value = f\"generated {index}\" # output", - "message = f\"中文 {value} ✅\"", - "path = f\"{root.name}/{name}.txt\"" - ].join("\n"), - [ - "~~~py", - "formatted = f\"{value:.2f}\"", - "converted = f\"value={value!r}\"", - "literal = f\"{{value}} = {value}\"", - "single = f'item {index}'", - "escaped = f\"quote: \\\"{label}\\\"\"", - "multiple = f\"{left} + {right} = {total}\"", - "plain = f\"no replacement\"", - "~~~" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - highlightState?: { sourceLines: string[] }; - widthState?: { characters: number }; - }; - }>; - }; - if (colors) { - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingFencedCodeMarkdown"); - expect(internal.renderedSegments[0]?.component.highlightState?.sourceLines.length) - .toBeGreaterThan(0); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } else { - expect(internal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - } - } - }); - - test("keeps complex Python f-strings on the original Markdown path", () => { - for (const source of [ - "```python\ntext = fr\"value {answer}\"", - "```python\ntext = rf'value {answer}'", - "```python\ntext = f\"\"\"value {answer}\"\"\"", - "```python\ntext = f\"{value:{width}}\"", - "```python\ntext = f\"{value + 1}\"", - "```python\ntext = f\"{format(value)}\"", - "```python\ntext = f\"{mapping[key]}\"", - "```python\ntext = f\"{value=}\"", - "```python\ntext = f\"{mapping['key']}\"", - "```python\ntext = f\"{value\"", - "```python\ntext = f\"value }\"", - "```python\ntext = f\"{path\\name}\"", - "```python\n\ttext = f\"value {answer}\"" - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - component.render(80); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - function expectJsonFenceByteIdentical(quoted: boolean): void { - const sourcePrefix = quoted ? "> " : ""; - const sources = [ - [ - `${sourcePrefix}\`\`\`json`, - `${sourcePrefix}{`, - `${sourcePrefix} "name": "Ada",`, - `${sourcePrefix} "count": 42,`, - `${sourcePrefix} "active": true,`, - `${sourcePrefix} "nothing": null,`, - `${sourcePrefix} "nested": {"id": 1, "items": [1, 2, null]},`, - `${sourcePrefix} "escaped": "quote: \\" slash: \\\\ unicode: \\u4E2D",`, - `${sourcePrefix} "text": "中文 ✅",`, - `${sourcePrefix} "number": -1.25e+4`, - `${sourcePrefix}}`, - `${sourcePrefix}\`\`\`` - ].join("\n"), - [ - `${sourcePrefix}~~~json`, - `${sourcePrefix}[`, - `${sourcePrefix} {"id": 1, "name": "Ada"},`, - `${sourcePrefix} [1, 2, 3],`, - `${sourcePrefix} false,`, - `${sourcePrefix} "final value"`, - `${sourcePrefix}]` - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - fence?: { lineContexts?: string[] }; - highlightState?: { lineContexts?: string[]; sourceLines: string[] }; - widthState?: { characters: number }; - }; - }>; - }; - const component = internal.renderedSegments[0]?.component; - if (quoted) { - expect(component?.constructor.name).toBe("StreamingQuotedFencedCodeMarkdown"); - const expectedContext = source.split("\n")[1]?.slice(sourcePrefix.length).trim(); - expect(component?.fence?.lineContexts).toContain(expectedContext); - } else if (colors) { - expect(component?.constructor.name).toBe("StreamingFencedCodeMarkdown"); - expect(component?.highlightState?.sourceLines.length).toBeGreaterThan(0); - expect(component?.highlightState?.lineContexts).toBeDefined(); - expect(component?.widthState?.characters).toBeLessThanOrEqual(2_000_000); - } else { - expect(component).toBeInstanceOf(Markdown); - } - } - } - } - - test("keeps strict root JSON fences byte-identical for every streaming prefix", () => { - expectJsonFenceByteIdentical(false); - }); - - test("keeps strict quoted JSON fences byte-identical for every streaming prefix", () => { - expectJsonFenceByteIdentical(true); - }); - - function expectNestedJsonFenceByteIdentical(quoted: boolean): void { - const sourcePrefix = quoted ? "> " : ""; - const sources = [ - [ - `${sourcePrefix}\`\`\`json`, - `${sourcePrefix}{`, - `${sourcePrefix} "profile": {`, - `${sourcePrefix} "name": "中文 ✅",`, - `${sourcePrefix} "count": -1.25e+4,`, - `${sourcePrefix} "active": true,`, - `${sourcePrefix} },`, - `${sourcePrefix} "items": [`, - `${sourcePrefix} 1,`, - `${sourcePrefix} {"id": 2, "value": null},`, - `${sourcePrefix} "escaped \\u4E2D",`, - `${sourcePrefix} ],`, - `${sourcePrefix} "tail": false`, - `${sourcePrefix}}`, - `${sourcePrefix}\`\`\`` - ].join("\n"), - [ - `${sourcePrefix}~~~json`, - `${sourcePrefix}[`, - `${sourcePrefix} {`, - `${sourcePrefix} "id": 1,`, - `${sourcePrefix} },`, - `${sourcePrefix} [`, - `${sourcePrefix} true,`, - `${sourcePrefix} null,`, - `${sourcePrefix} ],`, - `${sourcePrefix}]` - ].join("\n"), - [ - `${sourcePrefix}\`\`\`json`, - `${sourcePrefix}{`, - `${sourcePrefix} "last": {`, - `${sourcePrefix} "id": 1`, - `${sourcePrefix} }`, - `${sourcePrefix}}` - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - fence?: { lineContexts?: string[] }; - rejectedWidths?: Set; - }; - }>; - }; - const component = internal.renderedSegments[0]?.component; - if (quoted) { - expect(component?.constructor.name).toBe("StreamingQuotedFencedCodeMarkdown"); - expect(component?.fence?.lineContexts).toContain("{"); - if (source.includes("[")) expect(component?.fence?.lineContexts).toContain("["); - expect(component?.rejectedWidths?.size).toBe(0); - } else if (colors) { - expect(component?.constructor.name).toBe("StreamingFencedCodeMarkdown"); - expect(component?.fence?.lineContexts).toContain("{"); - if (source.includes("[")) expect(component?.fence?.lineContexts).toContain("["); - expect(component?.rejectedWidths?.size).toBe(0); - } else { - expect(component).toBeInstanceOf(Markdown); - } - } - } - } - - test("keeps bounded root nested JSON fences byte-identical for every prefix", () => { - expectNestedJsonFenceByteIdentical(false); - }); - - test("keeps bounded quoted nested JSON fences byte-identical for every prefix", () => { - expectNestedJsonFenceByteIdentical(true); - }); - - test("keeps unsafe JSON fences on the original Markdown path", () => { - for (const source of [ - "```json\n{\n // comment\n}", - "```json\n{\n /* comment */\n}", - "```json\n{\n 'name': 'Ada'\n}", - "```json\n{\n name: \"Ada\"\n}", - "```json\n{\n \"name\": NaN\n}", - "```json\n{\n \"name\": Infinity\n}", - "```json\n{\n \"name\": \"bad \\x escape\"\n}", - "```json\n{\n \"nested\": {\n },\n}", - "```json\n{\n \"items\": [\n ],\n}", - "```json\n{\n \"nested\": {\n \"deeper\": {\n \"id\": 1\n }\n }\n}", - "```json\n{\n \"nested\": {\n \"id\": 1\n ]\n}", - "```json\n{\n \"name\": \"Ada\"\n]", - "```json\n\t{\"name\": \"Ada\"}", - "```json5\n{\"name\": \"Ada\"}", - "> ```json\n> {\n> // comment\n> }", - "> ```json\n> {\n> \"nested\": {\n> },\n> }", - "> ```json\n> {\n> \"nested\": {\n> \"deeper\": [\n> 1\n> ]\n> }\n> }", - "> ```json5\n> {\"name\": \"Ada\"}", - "> > ```json\n> > {\n> > \"name\": \"Ada\"\n> > }", - ">```json\n> {\n> \"name\": \"Ada\"\n> }", - `\`\`\`json\n{"value": "${"x".repeat(100_000)}"}` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - component.render(80); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("keeps JSON streaming owners through growth and skips restored large setup", () => { - for (const quoted of [false, true]) { - const sourcePrefix = quoted ? "> " : ""; - const lines = Array.from( - { length: 90 }, - (_, index) => `${sourcePrefix} "value_${index}": {"index": ${index}, "active": true},` - ); - const source = [ - `${sourcePrefix}\`\`\`json`, - `${sourcePrefix}{`, - ...lines - ].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown( - [ - `${sourcePrefix}\`\`\`json`, - `${sourcePrefix}{`, - ...lines.slice(0, 2) - ].join("\n"), - 1, - createTheme(true) - ); - streamed.render(80); - streamed.appendText(`\n${lines.slice(2).join("\n")}`); - streamed.render(80); - const expectedName = quoted - ? "StreamingQuotedFencedCodeMarkdown" - : "StreamingFencedCodeMarkdown"; - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name).toBe(expectedName); - - const partial = `${sourcePrefix} "partial": {"value": -1.25e+4}`; - let expectedSource = `${source}\n`; - streamed.appendText("\n"); - for (const character of partial) { - expectedSource += character; - streamed.appendText(character); - expect(streamed.render(80)).toEqual( - new Markdown( - withoutBoundaryBlankLines(expectedSource), - 1, - 0, - createTheme(true).markdown - ).render(80) - ); - expect(streamedInternal.renderedSegments[0]?.component.constructor.name).toBe(expectedName); - } - - streamed.setText(source.replaceAll("active", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("keeps nested JSON owners through growth and rejects unsafe replacement", () => { - for (const quoted of [false, true]) { - const sourcePrefix = quoted ? "> " : ""; - const blocks = Array.from({ length: 30 }, (_, index) => [ - `${sourcePrefix} "value_${index}": {`, - `${sourcePrefix} "index": ${index}, "active": true,`, - `${sourcePrefix} },` - ]).flat(); - const source = [ - `${sourcePrefix}\`\`\`json`, - `${sourcePrefix}{`, - ...blocks - ].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown( - [ - `${sourcePrefix}\`\`\`json`, - `${sourcePrefix}{`, - ...blocks.slice(0, 3) - ].join("\n"), - 1, - createTheme(true) - ); - streamed.render(80); - streamed.appendText(`\n${blocks.slice(3).join("\n")}`); - streamed.render(80); - const expectedName = quoted - ? "StreamingQuotedFencedCodeMarkdown" - : "StreamingFencedCodeMarkdown"; - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name).toBe(expectedName); - - const partial = [ - `${sourcePrefix} "tail": [`, - `${sourcePrefix} "中文 ✅",`, - `${sourcePrefix} ],` - ].join("\n"); - let expectedSource = `${source}\n`; - streamed.appendText("\n"); - for (const character of partial) { - expectedSource += character; - streamed.appendText(character); - expect(streamed.render(80)).toEqual( - new Markdown( - withoutBoundaryBlankLines(expectedSource), - 1, - 0, - createTheme(true).markdown - ).render(80) - ); - expect(streamedInternal.renderedSegments[0]?.component.constructor.name).toBe(expectedName); - } - - streamed.setText([ - `${sourcePrefix}\`\`\`json`, - `${sourcePrefix}{`, - `${sourcePrefix} "empty": {`, - `${sourcePrefix} },`, - `${sourcePrefix}}` - ].join("\n")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - function expectBashFenceByteIdentical(quoted: boolean): void { - const sourcePrefix = quoted ? "> " : ""; - const sources = [ - [ - `${sourcePrefix}\`\`\`bash`, - `${sourcePrefix}echo "generated value" # output`, - `${sourcePrefix}printf "%s\\n" "value"`, - `${sourcePrefix}export NAME="中文 ✅"`, - `${sourcePrefix}cd /tmp && pwd`, - `${sourcePrefix}test -f package.json || echo "missing"`, - `${sourcePrefix}echo 'single quoted value'`, - `${sourcePrefix}echo "escaped \\"quote\\""`, - `${sourcePrefix}echo plain\\ value > output.txt`, - `${sourcePrefix}echo value#literal`, - `${sourcePrefix}\`\`\`` - ].join("\n"), - [ - `${sourcePrefix}~~~sh`, - `${sourcePrefix}printf "%s" "first"; echo "second"`, - `${sourcePrefix}cd ./packages/zcode-tui < input.txt`, - `${sourcePrefix}echo complete &` - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - fence?: { language: string }; - highlightState?: { sourceLines: string[] }; - widthState?: { characters: number }; - }; - }>; - }; - const component = internal.renderedSegments[0]?.component; - if (quoted) { - expect(component?.constructor.name).toBe("StreamingQuotedFencedCodeMarkdown"); - } else if (colors) { - expect(component?.constructor.name).toBe("StreamingFencedCodeMarkdown"); - expect(["bash", "sh"]).toContain(component?.fence?.language.toLowerCase() ?? ""); - expect(component?.highlightState?.sourceLines.length).toBeGreaterThan(0); - expect(component?.widthState?.characters).toBeLessThanOrEqual(2_000_000); - } else { - expect(component).toBeInstanceOf(Markdown); - } - } - } - } - - test("keeps strict root Bash fences byte-identical for every streaming prefix", () => { - expectBashFenceByteIdentical(false); - }); - - test("keeps strict quoted Bash fences byte-identical for every streaming prefix", () => { - expectBashFenceByteIdentical(true); - }); - - test("keeps unsafe Bash fences on the original Markdown path", () => { - for (const source of [ - "```bash\ncat <(consume)", - "```bash\necho value \\\ncontinued", - "```bash\necho \"open quote\ncontinued\"", - "```bash\necho value#literal \"open quote\ncontinued\"", - "```bash\nfunction build() {\n echo value\n}", - "```bash\nif test -f file; then\n echo value\nfi", - "```bash\necho value |\nconsume", - "```bash\necho value && # wait\nconsume", - "```bash\nvalues=(one two)", - "```bash\n[ -f package.json ] && echo value", - "```bash\n\techo value", - "```zsh\necho value", - "```fish\necho value", - "> ```bash\n> echo ${HOME}", - "> > ```bash\n> > echo value", - ">```bash\n> echo value", - `\`\`\`bash\necho "${"x".repeat(100_000)}"` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - component.render(80); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("keeps Bash streaming owners through growth and skips restored large setup", () => { - for (const quoted of [false, true]) { - const sourcePrefix = quoted ? "> " : ""; - const lines = Array.from( - { length: 90 }, - (_, index) => `${sourcePrefix}echo "generated value_${index}" # output` - ); - const source = [`${sourcePrefix}\`\`\`bash`, ...lines].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const theme = createTheme(true); - const streamed = new RichMarkdown( - [`${sourcePrefix}\`\`\`bash`, ...lines.slice(0, 2)].join("\n"), - 1, - theme - ); - streamed.render(80); - streamed.appendText(`\n${lines.slice(2).join("\n")}`); - streamed.render(80); - const expectedName = quoted - ? "StreamingQuotedFencedCodeMarkdown" - : "StreamingFencedCodeMarkdown"; - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name).toBe(expectedName); - - const partial = `${sourcePrefix}echo "partial value"`; - let expectedSource = `${source}\n`; - streamed.appendText("\n"); - for (const character of partial) { - expectedSource += character; - streamed.appendText(character); - expect(streamed.render(80)).toEqual( - new Markdown( - withoutBoundaryBlankLines(expectedSource), - 1, - 0, - theme.markdown - ).render(80) - ); - expect(streamedInternal.renderedSegments[0]?.component.constructor.name).toBe(expectedName); - } - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("keeps line-local JavaScript fences byte-identical for every streaming prefix", () => { - const sources = [ - [ - "```javascript", - "const answer = 42; // generated output", - "let label = \"中文 ✅\";", - "const escaped = \"quote: \\\"ok\\\"\";", - "const items = [1, 2, 3];", - "const mapping = { answer: 42 };" - ].join("\n"), - [ - "~~~js", - "const doubled = (value) => value * 2;", - "const matched = /hello\\s+world/iu.test(label);", - "const ratio = total / Math.max(count, 1);", - "const optional = payload?.value ?? \"fallback\";", - `const longValue = "${"wrapped words ".repeat(12).trim()}";`, - "~~~" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - fence?: { language: string }; - highlightState?: { sourceLines: string[] }; - widthState?: { characters: number }; - }; - }>; - }; - if (colors) { - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingFencedCodeMarkdown"); - const language = internal.renderedSegments[0]?.component.fence?.language.toLowerCase(); - expect(language).toBeDefined(); - expect(["js", "javascript"]).toContain(language!); - expect(internal.renderedSegments[0]?.component.highlightState?.sourceLines.length) - .toBeGreaterThan(0); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } else { - expect(internal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - } - } - }); - - function expectJavaScriptFunctionFenceByteIdentical( - quoted: boolean, - nestedIf = false - ): void { - const sourcePrefix = quoted ? "> " : ""; - const nestedSources = [ - [ - `${sourcePrefix}\`\`\`javascript`, - `${sourcePrefix}export async function generated(input) {`, - `${sourcePrefix} const matcher = /value+/g;`, - `${sourcePrefix} if (matcher.test(input)) {`, - `${sourcePrefix} await Promise.resolve(input);`, - `${sourcePrefix} return "中文 ✅";`, - `${sourcePrefix} }`, - `${sourcePrefix} return input;`, - `${sourcePrefix}}`, - `${sourcePrefix}function next(input) {`, - `${sourcePrefix} if (!input) {`, - `${sourcePrefix} return 0;`, - `${sourcePrefix} }`, - `${sourcePrefix} return input + 1;`, - `${sourcePrefix}}`, - `${sourcePrefix}\`\`\`` - ].join("\n"), - [ - `${sourcePrefix}~~~js`, - `${sourcePrefix}function wrapped(input) {`, - `${sourcePrefix} if (input > 0) {`, - `${sourcePrefix} const message = "${"wrapped words ".repeat(12).trim()}";`, - `${sourcePrefix} return message;`, - `${sourcePrefix} }` - ].join("\n") - ]; - const sources = nestedIf ? nestedSources : [ - [ - `${sourcePrefix}\`\`\`javascript`, - `${sourcePrefix}function generated(input) {`, - `${sourcePrefix} const doubled = input * 2;`, - `${sourcePrefix} // generated comment`, - `${sourcePrefix} const label = "中文 ✅";`, - `${sourcePrefix} const matcher = /value+/g;`, - `${sourcePrefix} return matcher.test(label) ? doubled : input;`, - `${sourcePrefix}}`, - `${sourcePrefix}export async function next(input) {`, - `${sourcePrefix} await Promise.resolve(input);`, - `${sourcePrefix} return input + 1;`, - `${sourcePrefix}}`, - `${sourcePrefix}\`\`\`` - ].join("\n"), - [ - `${sourcePrefix}~~~js`, - `${sourcePrefix}function wrapped(input) {`, - `${sourcePrefix} const message = "${"wrapped words ".repeat(12).trim()}";`, - `${sourcePrefix} return message || input;`, - `${sourcePrefix}}` - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - highlightState?: { sourceLines: string[] }; - widthState?: { body?: { characters: number }; characters?: number }; - }; - }>; - }; - const component = internal.renderedSegments[0]?.component; - if (quoted) { - expect(component?.constructor.name).toBe("StreamingQuotedFencedCodeMarkdown"); - expect(component?.widthState?.body?.characters).toBeLessThanOrEqual(2_000_000); - } else if (colors) { - expect(component?.constructor.name).toBe("StreamingFencedCodeMarkdown"); - expect(component?.highlightState?.sourceLines.length).toBeGreaterThan(0); - expect(component?.widthState?.characters).toBeLessThanOrEqual(2_000_000); - } else { - expect(component).toBeInstanceOf(Markdown); - } - } - } - } - - test("keeps simple root JavaScript function fences byte-identical for every prefix", () => { - expectJavaScriptFunctionFenceByteIdentical(false); - }); - - test("keeps simple quoted JavaScript function fences byte-identical for every prefix", () => { - expectJavaScriptFunctionFenceByteIdentical(true); - }); - - test("keeps nested-if root JavaScript function fences byte-identical for every prefix", () => { - expectJavaScriptFunctionFenceByteIdentical(false, true); - }); - - test("keeps nested-if quoted JavaScript function fences byte-identical for every prefix", () => { - expectJavaScriptFunctionFenceByteIdentical(true, true); - }); - - test("keeps unsafe JavaScript fences on the original Markdown path", () => { - for (const source of [ - "```javascript\nconst value = `first\nsecond`;\nconst tail = 1;", - "```javascript\n/* open\nstill comment */\nconst tail = 1;", - "```javascript\nconst value = {\n id: 1\n};", - "```javascript\nfunction empty() {\n}", - "```javascript\nfunction blank() {\n\n return 1;\n}", - "```javascript\nfunction value(\n input\n) {\n return input;\n}", - "```javascript\nfunction emptyNested(input) {\n if (input) {\n }\n return input;\n}", - "```javascript\nfunction secondNested(input) {\n if (input) {\n return input;\n }\n if (!input) {\n return 0;\n }\n}", - "```javascript\nfunction deeper(input) {\n if (input) {\n if (input.value) {\n return input.value;\n }\n }\n}", - "```javascript\nfunction alternative(input) {\n if (input) {\n return input;\n } else {\n return 0;\n }\n}", - "```javascript\nfunction loop(input) {\n while (input) {\n input -= 1;\n }\n}", - "```javascript\nfunction condition(input) {\n if (\n input\n ) {\n return input;\n }\n}", - "```javascript\nconst value = (input) => {\n return input;\n}", - "```javascript\nclass Value {\n method() { return 1; }\n}", - "```javascript\nconst value = 1 + \\\n 2;", - "```javascript\n\tconst value = 1;\nconst tail = 2;", - "```javascript\nconst view = ;\nconst tail = 1;", - "```jsx\nconst view = ;", - "```mjs\nconst value = 1;\nconsole.log(value);", - "```cjs\nconst value = 1;\nmodule.exports = value;", - "> ```javascript\n> function value(input) {\n> if (input) {\n> return input;\n> } else {\n> return 0;\n> }\n> }", - "> > ```javascript\n> > function value(input) {\n> > return input;\n> > }", - `\`\`\`javascript\nconst first = "${"x".repeat(100_000)}";\nconst second = 2;` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - component.render(80); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("keeps quoted line-local TypeScript fences byte-identical for every prefix", () => { - const sources = [ - [ - "> ```typescript", - "> const answer: number = 42; // generated", - "> const label: string = \"中文 ✅\";", - "> const row = { answer: 42 };", - `> const longValue = "${"wrapped words ".repeat(12).trim()}";`, - "> ```" - ].join("\n"), - [ - "> ~~~ts", - "> type Row = { value: number };", - "> const first: Row = { value: 1 };", - "> const second: Row = { value: 2 }; // open fence" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { body: { characters: number } }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.body.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - }); - - test("keeps depth-one quoted TypeScript functions byte-identical for every prefix", () => { - const sources = [ - [ - "> ```typescript", - "> export async function generated(input: T): Promise {", - "> const label: string = \"中文 ✅\";", - "> const matcher: RegExp = /value+/g;", - "> await Promise.resolve(label);", - "> return matcher.test(label) ? input : input;", - "> }", - "> function next(input: number): number {", - "> return input + 1;", - "> }", - "> ```" - ].join("\n"), - [ - "> ~~~ts", - "> function wrapped(input: number): number {", - "> if (input > 0) {", - `> const message: string = "${"wrapped words ".repeat(12).trim()}";`, - "> return input;", - "> }", - "> return 0;" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let activated = false; - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - const during = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - const componentName = during.renderedSegments[0]?.component.constructor.name; - if (componentName === "StreamingQuotedFencedCodeMarkdown") activated = true; - if (activated) expect(componentName).toBe("StreamingQuotedFencedCodeMarkdown"); - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - fence?: { quoteDepth: number }; - rejectedWidths?: Set; - widthState?: { body: { characters: number } }; - }; - }>; - }; - const component = internal.renderedSegments[0]?.component; - expect(component?.constructor.name).toBe("StreamingQuotedFencedCodeMarkdown"); - expect(component?.fence?.quoteDepth).toBe(1); - expect(component?.rejectedWidths?.size).toBe(0); - expect(component?.widthState?.body.characters).toBeLessThanOrEqual(2_000_000); - } - } - }); - - test("keeps quoted TypeScript function owners through growth and unsafe replacement", () => { - const blocks = Array.from({ length: 30 }, (_, index) => [ - `> function generated_${index}(input: number): number {`, - `> return input + ${index};`, - "> }" - ]).flat(); - const source = ["> ```typescript", ...blocks].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const theme = createTheme(true); - const streamed = new RichMarkdown( - ["> ```typescript", ...blocks.slice(0, 3)].join("\n"), - 1, - theme - ); - streamed.render(80); - streamed.appendText(`\n${blocks.slice(3).join("\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - - const partial = [ - "> function tail(input: number): number {", - "> return input;", - "> }" - ].join("\n"); - let expectedSource = `${source}\n`; - streamed.appendText("\n"); - for (const character of partial) { - expectedSource += character; - streamed.appendText(character); - expect(streamed.render(80)).toEqual( - new Markdown( - withoutBoundaryBlankLines(expectedSource), - 1, - 0, - theme.markdown - ).render(80) - ); - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - } - - streamed.setText([ - "> ```typescript", - "> function unsafe(input: number): number {", - "> if (input > 0) {", - "> return input;", - "> } else {", - "> return 0;", - "> }", - "> }" - ].join("\n")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("keeps depth-two quoted TypeScript fences byte-identical for every prefix", () => { - const sources = [ - [ - "> > ```typescript", - "> > const answer: number = 42; // generated", - "> > const label: string = \"中文 ✅\";", - "> > const row = { answer: 42 };", - `> > const longValue = "${"wrapped words ".repeat(12).trim()}";`, - "> > ```" - ].join("\n"), - [ - "> > ~~~ts", - "> > type Row = { value: number };", - "> > const first: Row = { value: 1 };", - "> > const second: Row = { value: 2 }; // open fence" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - fence?: { quoteDepth: number }; - widthState?: { body: { characters: number } }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - expect(internal.renderedSegments[0]?.component.fence?.quoteDepth).toBe(2); - expect(internal.renderedSegments[0]?.component.widthState?.body.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - }); - - test("keeps quoted line-local Python fences byte-identical for every prefix", () => { - const sources = [ - [ - "> ```python", - "> value: int = 1 # generated output", - "> label: str = \"中文 ✅\"", - "> escaped = \"quote: \\\"ok\\\"\"", - "> items: list[int] = [1, 2, 3]", - "> mapping = {\"answer\": 42}", - "> ```" - ].join("\n"), - [ - "> ~~~py", - "> def add(left: int, right: int) -> int:", - "> return left + right", - "> print(add(1, 2)) # open fence" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - fence?: { language: string; quoteDepth: number }; - widthState?: { body: { characters: number } }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - expect(internal.renderedSegments[0]?.component.fence?.quoteDepth).toBe(1); - const language = internal.renderedSegments[0]?.component.fence?.language.toLowerCase(); - expect(language).toBeDefined(); - expect(["py", "python"]).toContain(language!); - expect(internal.renderedSegments[0]?.component.widthState?.body.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - }); - - test("keeps quoted simple Python f-strings byte-identical for every prefix", () => { - const sources = [ - [ - "> ```python", - "> value = f\"generated {index}\" # output", - "> message = f\"中文 {value} ✅\"", - "> path = f\"{root.name}/{name}.txt\"", - "> ```" - ].join("\n"), - [ - "> ~~~py", - "> formatted = f\"{value:.2f}\"", - "> converted = f\"value={value!r}\"", - "> literal = f\"{{value}} = {value}\"", - "> single = f'item {index}'", - "> escaped = f\"quote: \\\"{label}\\\"\"", - "> multiple = f\"{left} + {right} = {total}\"" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - fence?: { language: string; quoteDepth: number }; - widthState?: { body: { characters: number } }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - expect(internal.renderedSegments[0]?.component.fence?.quoteDepth).toBe(1); - const language = internal.renderedSegments[0]?.component.fence?.language.toLowerCase(); - expect(language).toBeDefined(); - expect(["py", "python"]).toContain(language!); - expect(internal.renderedSegments[0]?.component.widthState?.body.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - }); - - test("keeps quoted line-local JavaScript fences byte-identical for every prefix", () => { - const sources = [ - [ - "> ```javascript", - "> const answer = 42; // generated output", - "> let label = \"中文 ✅\";", - "> const escaped = \"quote: \\\"ok\\\"\";", - "> const items = [1, 2, 3];", - "> const mapping = { answer: 42 };", - "> ```" - ].join("\n"), - [ - "> ~~~js", - "> const doubled = (value) => value * 2;", - "> const matched = /hello\\s+world/iu.test(label);", - "> const ratio = total / Math.max(count, 1);", - "> const optional = payload?.value ?? \"fallback\";", - `> const longValue = "${"wrapped words ".repeat(12).trim()}";` - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - fence?: { language: string; quoteDepth: number }; - widthState?: { body: { characters: number } }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - expect(internal.renderedSegments[0]?.component.fence?.quoteDepth).toBe(1); - const language = internal.renderedSegments[0]?.component.fence?.language.toLowerCase(); - expect(language).toBeDefined(); - expect(["js", "javascript"]).toContain(language!); - expect(internal.renderedSegments[0]?.component.widthState?.body.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - }); - - function expectDeepQuotedFenceByteIdentical( - depth: 3 | 4, - closed: boolean, - themes: ReadonlyArray = [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] - ): void { - const quote = "> ".repeat(depth); - const source = closed - ? [ - `${quote}\`\`\`typescript`, - `${quote}const answer: number = 42; // generated`, - `${quote}const label: string = "中文 ✅";`, - `${quote}const row = { answer: 42 };`, - `${quote}const longValue = "${"wrapped words ".repeat(12).trim()}";`, - `${quote}\`\`\`` - ].join("\n") - : [ - `${quote}~~~ts`, - `${quote}type Row = { value: number };`, - `${quote}const first: Row = { value: 1 };`, - `${quote}const second: Row = { value: 2 }; // open fence` - ].join("\n"); - - for (const [colors, scheme] of themes) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - fence?: { quoteDepth: number }; - widthState?: { body: { characters: number } }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - expect(internal.renderedSegments[0]?.component.fence?.quoteDepth).toBe(depth); - expect(internal.renderedSegments[0]?.component.widthState?.body.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - - test("keeps closed depth-three TypeScript fences byte-identical", () => { - expectDeepQuotedFenceByteIdentical(3, true); - }); - - test("keeps open depth-three TypeScript fences byte-identical", () => { - expectDeepQuotedFenceByteIdentical(3, false); - }); - - test("keeps no-color closed depth-four TypeScript fences byte-identical", () => { - expectDeepQuotedFenceByteIdentical(4, true, [[false, "dark"]]); - }); - - test("keeps dark closed depth-four TypeScript fences byte-identical", () => { - expectDeepQuotedFenceByteIdentical(4, true, [[true, "dark"]]); - }); - - test("keeps light closed depth-four TypeScript fences byte-identical", () => { - expectDeepQuotedFenceByteIdentical(4, true, [[true, "light"]]); - }); - - test("keeps open depth-four TypeScript fences byte-identical", () => { - expectDeepQuotedFenceByteIdentical(4, false); - }); - - test("keeps unsafe quoted fences on the original Markdown path", () => { - for (const source of [ - "> ```typescript\n> const value = `first\n> second`;\n> const tail = 1;", - "> ```typescript\n> /* open\n> still comment */\n> const tail = 1;", - "> ```typescript\n> const value = {\n> id: 1\n> };", - "> ```typescript\n> function empty(): number {\n> }", - "> ```typescript\n> function multiline(\n> input: number\n> ): number {\n> return input;\n> }", - "> ```typescript\n> function alternative(input: number): number {\n> if (input > 0) {\n> return input;\n> } else {\n> return 0;\n> }\n> }", - "> > ```typescript\n> > function value(): number {\n> > return 1;\n> > }", - "> ```tsx\n> const view = ;\n> const tail = 1;", - "> > > > > ```typescript\n> > > > > const first = 1;\n> > > > > const second = 2;", - "> > ```typescript\n> > const first = 1;\n> const second = 2;", - ">```typescript\n> const first = 1;\n> const second = 2;", - "> ```python\n> text = \"\"\"first\n> second\"\"\"", - "> ```python\n> text = fr\"value {answer}\"\n> tail = 1", - "> ```python\n> value = 1 + \\\n> 2", - "> ```python\n> values = (\n> 1,\n> )", - "> ```python\n> \tvalue = 1\n> tail = 2", - "> > ```python\n> > first: int = 1\n> > second: int = 2", - "> ```python\n> text = f\"{value:{width}}\"\n> tail = 1", - "> ```python\n> text = f\"{value + 1}\"\n> tail = 1", - "> ```python\n> text = f\"{mapping[key]}\"\n> tail = 1", - "> ```python\n> text = f\"{value=}\"\n> tail = 1", - "> > ```python\n> > text = f\"value {answer}\"\n> > tail = 1", - "> ```javascript\n> const value = `first\n> second`;\n> const tail = 1;", - "> ```javascript\n> /* open\n> still comment */\n> const tail = 1;", - "> ```javascript\n> const value = {\n> id: 1\n> };", - "> ```javascript\n> function value(input) {\n> if (input) {\n> return input;\n> } else {\n> return 0;\n> }\n> }", - "> ```javascript\n> const value = 1 + \\\n> 2;", - "> ```javascript\n> \tconst value = 1;\n> const tail = 2;", - "> ```javascript\n> const view = ;\n> const tail = 1;", - "> > ```javascript\n> > const first = 1;\n> > const second = 2;", - ">```javascript\n> const first = 1;\n> const second = 2;", - "> ```jsx\n> const view = ;", - `> \`\`\`typescript\n> const first = "${"x".repeat(100_000)}";\n> const second = 2;` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - component.render(80); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("avoids incremental setup for a restored large quoted TypeScript fence", () => { - const lines = Array.from( - { length: 90 }, - (_, index) => `> const value_${index}: number = ${index}; // generated` - ); - const source = [`> \`\`\`typescript`, ...lines].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown( - [`> \`\`\`typescript`, ...lines.slice(0, 2)].join("\n"), - 1, - createTheme(true) - ); - streamed.render(80); - streamed.appendText(`\n${lines.slice(2).join("\n")}`); - const full = streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - expect(streamed.renderWindow(80, 20, 30)).toEqual({ - lines: full.slice(20, 50), - totalLines: full.length - }); - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("avoids incremental setup for restored depth-two TypeScript fences", () => { - const lines = Array.from( - { length: 90 }, - (_, index) => `> > const value_${index}: number = ${index}; // generated` - ); - const source = [`> > \`\`\`typescript`, ...lines].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown( - [`> > \`\`\`typescript`, ...lines.slice(0, 2)].join("\n"), - 1, - createTheme(true) - ); - streamed.render(80); - streamed.appendText(`\n${lines.slice(2).join("\n")}`); - const full = streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ - component: { constructor: { name: string }; fence?: { quoteDepth: number } }; - }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - expect(streamedInternal.renderedSegments[0]?.component.fence?.quoteDepth).toBe(2); - expect(streamed.renderWindow(80, 20, 30)).toEqual({ - lines: full.slice(20, 50), - totalLines: full.length - }); - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("avoids incremental setup for restored depth-three and depth-four fences", () => { - for (const depth of [3, 4] as const) { - const quote = "> ".repeat(depth); - const lines = Array.from( - { length: 90 }, - (_, index) => `${quote}const value_${index}: number = ${index}; // generated` - ); - const source = [`${quote}\`\`\`typescript`, ...lines].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown( - [`${quote}\`\`\`typescript`, ...lines.slice(0, 2)].join("\n"), - 1, - createTheme(true) - ); - streamed.render(80); - streamed.appendText(`\n${lines.slice(2).join("\n")}`); - const full = streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ - component: { constructor: { name: string }; fence?: { quoteDepth: number } }; - }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - expect(streamedInternal.renderedSegments[0]?.component.fence?.quoteDepth).toBe(depth); - expect(streamed.renderWindow(80, 20, 30)).toEqual({ - lines: full.slice(20, 50), - totalLines: full.length - }); - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("avoids incremental setup for a restored large quoted Python fence", () => { - const lines = Array.from( - { length: 90 }, - (_, index) => `> value_${index}: int = ${index} # generated output` - ); - const source = [`> \`\`\`python`, ...lines].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown( - [`> \`\`\`python`, ...lines.slice(0, 2)].join("\n"), - 1, - createTheme(true) - ); - streamed.render(80); - streamed.appendText(`\n${lines.slice(2).join("\n")}`); - const full = streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - fence?: { language: string; quoteDepth: number }; - }; - }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingQuotedFencedCodeMarkdown"); - expect(streamedInternal.renderedSegments[0]?.component.fence?.quoteDepth).toBe(1); - expect(streamed.renderWindow(80, 20, 30)).toEqual({ - lines: full.slice(20, 50), - totalLines: full.length - }); - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("avoids incremental setup for restored large JavaScript fences", () => { - for (const quoted of [false, true]) { - const sourcePrefix = quoted ? "> " : ""; - const lines = Array.from( - { length: 90 }, - (_, index) => `${sourcePrefix}const value_${index} = ${index}; // generated` - ); - const source = [`${sourcePrefix}\`\`\`javascript`, ...lines].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown( - [`${sourcePrefix}\`\`\`javascript`, ...lines.slice(0, 2)].join("\n"), - 1, - createTheme(true) - ); - streamed.render(80); - streamed.appendText(`\n${lines.slice(2).join("\n")}`); - const full = streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe(quoted - ? "StreamingQuotedFencedCodeMarkdown" - : "StreamingFencedCodeMarkdown"); - expect(streamed.renderWindow(80, 20, 30)).toEqual({ - lines: full.slice(20, 50), - totalLines: full.length - }); - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("keeps JavaScript function owners through growth and skips restored setup", () => { - for (const quoted of [false, true]) { - const sourcePrefix = quoted ? "> " : ""; - const lines = Array.from({ length: 90 }, (_, index) => [ - `${sourcePrefix}function generated_${index}(input) {`, - `${sourcePrefix} return input + ${index};`, - `${sourcePrefix}}` - ]).flat(); - const opening = `${sourcePrefix}\`\`\`javascript`; - const source = [opening, ...lines].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const theme = createTheme(true); - const streamed = new RichMarkdown( - [opening, ...lines.slice(0, 3)].join("\n"), - 1, - theme - ); - streamed.render(80); - streamed.appendText(`\n${lines.slice(3).join("\n")}`); - const full = streamed.render(80); - const expectedName = quoted - ? "StreamingQuotedFencedCodeMarkdown" - : "StreamingFencedCodeMarkdown"; - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name).toBe(expectedName); - expect(streamed.renderWindow(80, 20, 30)).toEqual({ - lines: full.slice(20, 50), - totalLines: full.length - }); - - let expectedSource = `${source}\n`; - streamed.appendText("\n"); - const partial = `${sourcePrefix}function partial_${quoted ? "quoted" : "root"}`; - for (const character of partial) { - expectedSource += character; - streamed.appendText(character); - expect(streamed.render(80)).toEqual( - new Markdown( - withoutBoundaryBlankLines(expectedSource), - 1, - 0, - theme.markdown - ).render(80) - ); - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe(expectedName); - } - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("keeps nested-if JavaScript function owners through growth and skips restored setup", () => { - for (const quoted of [false, true]) { - const sourcePrefix = quoted ? "> " : ""; - const lines = Array.from({ length: 70 }, (_, index) => [ - `${sourcePrefix}function generated_${index}(input) {`, - `${sourcePrefix} if (input > ${index}) {`, - `${sourcePrefix} return input + ${index};`, - `${sourcePrefix} }`, - `${sourcePrefix}}` - ]).flat(); - const opening = `${sourcePrefix}\`\`\`javascript`; - const source = [opening, ...lines].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown( - [opening, ...lines.slice(0, 5)].join("\n"), - 1, - createTheme(true) - ); - streamed.render(80); - streamed.appendText(`\n${lines.slice(5).join("\n")}`); - const full = streamed.render(80); - const expectedName = quoted - ? "StreamingQuotedFencedCodeMarkdown" - : "StreamingFencedCodeMarkdown"; - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name).toBe(expectedName); - expect(streamed.renderWindow(80, 20, 30)).toEqual({ - lines: full.slice(20, 50), - totalLines: full.length - }); - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("avoids incremental setup for restored large Python f-string fences", () => { - for (const quoted of [false, true]) { - const sourcePrefix = quoted ? "> " : ""; - const lines = Array.from( - { length: 90 }, - (_, index) => `${sourcePrefix}value_${index} = f"generated {${index}}" # output` - ); - const source = [`${sourcePrefix}\`\`\`python`, ...lines].join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown( - [`${sourcePrefix}\`\`\`python`, ...lines.slice(0, 2)].join("\n"), - 1, - createTheme(true) - ); - streamed.render(80); - streamed.appendText(`\n${lines.slice(2).join("\n")}`); - const full = streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe(quoted - ? "StreamingQuotedFencedCodeMarkdown" - : "StreamingFencedCodeMarkdown"); - expect(streamed.renderWindow(80, 20, 30)).toEqual({ - lines: full.slice(20, 50), - totalLines: full.length - }); - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("keeps strict root tables byte-identical for every streaming prefix", () => { - const source = [ - "| Index | Value | Result |", - "| ---: | :--- | :--- |", - "| 1 | **bold value** | generated output |", - "| 10 | `inline code` | 中文 ✅ |", - "| 100 | *italic value* | ~~deleted result~~ |", - `| 101 | [official link](https://example.com) | ${"wrapped words ".repeat(5).trim()} |` - ].join("\n"); - - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number; presentedRows: string[][] }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableTableMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.presentedRows.length).toBe(4); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - }); - - test("keeps unsupported table shapes on the original Markdown path", () => { - const tooManyColumns = `| ${Array.from({ length: 9 }, (_, index) => `h${index}`).join(" | ")} |\n| ${ - Array.from({ length: 9 }, () => "---").join(" | ") - } |\n| ${Array.from({ length: 9 }, () => "value").join(" | ")} |`; - for (const source of [ - "Index | Value\n--- | ---\n1 | value", - "| Index | Value |\n| --- | --- |\n| 1 | value", - "| Index | Value |\n| --- | --- |\n| 1 | |", - "| Index | Value |\n| --- | --- |\n| 1 | value | extra |", - "| Index | Value |\n| --- | --- |\n| 1 | escaped \\| pipe |", - "| Index | Value |\n| --- | --- |\n| 1 | `code | pipe` |", - "| Index | Value |\n| --- | --- |\n| 1 | # heading |", - "| **semantic header** | Value |\n| --- | --- |\n| 1 | value |", - "| Index | Value |\n| --- | --- |\n\n| 1 | value |", - tooManyColumns, - `| Index | Value |\n| --- | --- |\n| 1 | ${"x".repeat(100_000)} |` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - component.render(80); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("avoids incremental setup for a restored large table", () => { - const opening = "| Index | Value |\n| ---: | :--- |\n"; - const rows = Array.from( - { length: 65 }, - (_, index) => `| ${index} | **value ${index}** |` - ); - const restored = new RichMarkdown(`${opening}${rows.join("\n")}`, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(`${opening}${rows[0]}`, 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n${rows.slice(1).join("\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableTableMarkdown"); - - streamed.setText(`| Replacement | Value |\n| ---: | :--- |\n${rows.join("\n")}`); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("keeps flat plain blockquotes byte-identical for every streaming prefix", () => { - const source = [ - "> first quoted line grows token by token", - `> ${"wrapped words ".repeat(12).trim()}`, - "> 中文宽度 and emoji ✅ remain aligned" - ].join("\n"); - - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingFlatBlockquoteMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - }); - - test("keeps line-local semantic blockquotes byte-identical for every streaming prefix", () => { - const source = [ - "> **bold quote** ordinary tail grows token by token", - "> ordinary plain line remains inside the semantic quote", - `> \`inline ** [literal] > @ code\` ${"wrapped words ".repeat(10).trim()}`, - "> **中文宽度 ✅** with ~~deleted text~~ remains aligned", - "> *italic quote* and _second emphasis_ stay line-local", - "> __underscore strong__ keeps the original Markdown styling", - "> [official link](https://example.com/path) remains clickable" - ].join("\n"); - - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingLineLocalBlockquoteMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - }); - - test("keeps quoted flat lists byte-identical for every streaming prefix", () => { - const source = [ - "> - **bold item** ordinary tail grows token by token", - `> + \`inline code\` ${"wrapped words ".repeat(10).trim()}`, - "> * [ ] pending 中文 task ✅", - "> - [x] completed task" - ].join("\n"); - - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - }); - - test("normalizes quoted ordered items exactly like the whole Markdown renderer", () => { - const sources = [ - [ - "> 1. **first item** ordinary tail", - "> 1. `second item` with more words", - "> 1. [ ] pending 中文 task ✅", - "> 1. [x] completed task" - ].join("\n"), - [ - "> 7) starts from seven", - "> 8) **continues at eight**", - "> 9) final item wraps with additional generated words" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - } - } - }); - - test("keeps loose quoted lists byte-identical for every streaming prefix", () => { - const sources = [ - [ - "> - **bold item** ordinary tail grows token by token", - ">", - `> * \`inline code\` ${"wrapped words ".repeat(10).trim()}`, - ">", - "> + [ ] pending 中文 task ✅", - ">", - "> - [x] completed task", - ">" - ].join("\n"), - [ - "> + plain first item with generated output", - ">", - "> - *italic second item* with more words", - ">", - "> * [official link](https://example.com/path) final item" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - quotedBlankSeparators?: Map; - separatorKind?: string; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(internal.renderedSegments[0]?.component.separatorKind).toBe("quoted-blank"); - expect(internal.renderedSegments[0]?.component.quotedBlankSeparators?.size).toBe(4); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - }); - - test("avoids incremental setup for a restored large loose quoted list", () => { - const items = Array.from( - { length: 65 }, - (_, index) => `> ${["-", "*", "+"][index % 3]} **item ${index}** generated output` - ); - const source = items.join("\n>\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(items.slice(0, 2).join("\n>\n"), 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n>\n${items.slice(2).join("\n>\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ - component: { constructor: { name: string }; separatorKind?: string }; - }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(streamedInternal.renderedSegments[0]?.component.separatorKind).toBe("quoted-blank"); - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - }); - - test("falls back when the quoted blank separator probe wraps", () => { - const source = "> - first\n>\n> * second"; - const theme = createTheme(true); - const component = new RichMarkdown(source, 1, theme); - expect(component.render(4)).toEqual(new Markdown(source, 1, 0, theme.markdown).render(4)); - - const internal = component as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - quotedBlankSeparators?: Map; - rejectedWidths?: Set; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(internal.renderedSegments[0]?.component.rejectedWidths?.has(4)).toBeTrue(); - expect(internal.renderedSegments[0]?.component.quotedBlankSeparators?.has(4)).toBeFalse(); - - expect(component.render(40)).toEqual(new Markdown(source, 1, 0, theme.markdown).render(40)); - expect(internal.renderedSegments[0]?.component.quotedBlankSeparators?.has(40)).toBeTrue(); - }); - - function expectNestedQuotedListDepthByteIdentical(depth: 2 | 3): void { - const quote = "> ".repeat(depth); - const sources = [ - [ - `${quote}- **bold item** ordinary tail grows token by token`, - `${quote}+ \`inline code\` ${"wrapped words ".repeat(10).trim()}`, - `${quote}* [ ] pending 中文 task ✅`, - `${quote}- [x] completed task` - ].join("\n"), - [ - `${quote}7) starts from seven`, - `${quote}7) **source marker is normalized to eight**`, - `${quote}7) final ordered item wraps with additional generated words` - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - } - - test("keeps one-level nested quoted lists byte-identical for every streaming prefix", () => { - expectNestedQuotedListDepthByteIdentical(2); - }); - - test("keeps depth-three quoted lists byte-identical for every streaming prefix", () => { - expectNestedQuotedListDepthByteIdentical(3); - }); - - test("avoids incremental setup for restored large nested quoted lists", () => { - for (const depth of [2, 3] as const) { - const label = `depth-${depth}`; - const lines = Array.from( - { length: 65 }, - (_, index) => `${"> ".repeat(depth)}- **${label} item ${index}** generated output` - ); - const restored = new RichMarkdown(lines.join("\n"), 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(lines[0]!, 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n${lines.slice(1).join("\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - - streamed.setText(lines.map((line) => line.replace(label, "replacement")).join("\n")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("keeps one-level nested plain and semantic blockquotes byte-identical", () => { - const sources = [ - [ - "> > first nested line grows token by token", - `> > ${"wrapped words ".repeat(12).trim()}`, - "> > 中文宽度 and emoji ✅ remain aligned" - ].join("\n"), - [ - "> > **bold quote** ordinary tail grows token by token", - "> > ordinary plain line remains in the nested paragraph", - `> > \`inline ** [literal] > @ code\` ${"wrapped words ".repeat(10).trim()}`, - "> > *italic quote* and ~~deleted text~~ remain line-local", - "> > [official link](https://example.com/path) stays nested" - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingLineLocalBlockquoteMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - }); - - function expectNestedBlockquoteDepthByteIdentical(depth: 3 | 4): void { - const quote = "> ".repeat(depth); - const sources = [ - [ - `${quote}first depth-${depth} line grows token by token`, - `${quote}${"wrapped words ".repeat(12).trim()}`, - `${quote}中文宽度 and emoji ✅ remain aligned` - ].join("\n"), - [ - `${quote}**bold quote** ordinary tail grows token by token`, - `${quote}ordinary plain line remains in the paragraph`, - `${quote}\`inline ** [literal] > @ code\` ${"wrapped words ".repeat(10).trim()}`, - `${quote}*italic quote* and ~~deleted text~~ remain line-local`, - `${quote}[official link](https://example.com/path) stays depth-${depth}` - ].join("\n") - ]; - - for (const source of sources) { - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - quoteDepth?: number; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingLineLocalBlockquoteMarkdown"); - expect(internal.renderedSegments[0]?.component.quoteDepth).toBe(depth); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - } - - test("keeps depth-three line-local blockquotes byte-identical", () => { - expectNestedBlockquoteDepthByteIdentical(3); - }); - - test("keeps depth-four line-local blockquotes byte-identical", () => { - expectNestedBlockquoteDepthByteIdentical(4); - }); - - test("avoids incremental setup for a restored large nested blockquote", () => { - for (const depth of [3, 4] as const) { - const label = `depth-${depth}`; - const lines = Array.from( - { length: 65 }, - (_, index) => `${"> ".repeat(depth)}**${label} ${index}** generated output` - ); - const restored = new RichMarkdown(lines.join("\n"), 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(lines[0]!, 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n${lines.slice(1).join("\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ component: { constructor: { name: string } } }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingLineLocalBlockquoteMarkdown"); - - const replacement = lines.map((line) => line.replace(label, "replacement")); - streamed.setText(replacement.join("\n")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("keeps quoted list continuation chunks byte-identical for every prefix", () => { - const source = [ - "> - **first item** ordinary tail grows token by token", - `> continuation with *italic* ${"wrapped words ".repeat(10).trim()}`, - "> second continuation keeps `inline code` local", - "> + [ ] pending task without continuation", - "> * final 中文 item ✅", - "> final continuation with ~~deleted text~~" - ].join("\n"); - - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingStableListMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - }); - - test("keeps cross-line semantic quote chunks byte-identical for every streaming prefix", () => { - const source = [ - "> **strong starts across", - "> the next line closes** with a plain tail", - "> ordinary plain line between semantic chunks", - "> `code starts with ** literal markers", - "> and closes here` with more words", - "> ~~strike starts 中文", - "> and closes ✅~~ final tail", - "> *italic starts across", - "> and closes here* end" - ].join("\n"); - - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingCrossLineBlockquoteMarkdown"); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - }); - - function expectNestedCrossLineQuoteByteIdentical(depth: 2 | 3): void { - const quote = "> ".repeat(depth); - const source = [ - "**strong starts across", - "the next line closes** with a plain tail", - "ordinary plain line between semantic chunks", - "`code starts with ** literal markers", - "and closes here` with more words", - "~~strike starts 中文", - "and closes ✅~~ final tail", - `*italic starts across ${"wrapped words ".repeat(8).trim()}`, - "and closes here* end", - "__underscore strong starts across", - "and closes here__ with a tail", - "_underscore italic starts across", - "and closes here_ end" - ].map((line) => `${quote}${line}`).join("\n"); - - for (const [colors, scheme] of [ - [false, "dark"], - [true, "dark"], - [true, "light"] - ] as const) { - const theme = createTheme(colors, scheme); - const streamed = new RichMarkdown("", 1, theme); - let prefix = ""; - for (const character of source) { - prefix += character; - streamed.appendText(character); - const expected = new Markdown( - withoutBoundaryBlankLines(prefix), - 1, - 0, - theme.markdown - ); - for (const width of [40, 60, 80, 100]) { - expect(streamed.render(width)).toEqual(expected.render(width)); - } - } - const internal = streamed as unknown as { - renderedSegments: Array<{ - component: { - constructor: { name: string }; - quoteDepth?: number; - widthState?: { characters: number }; - }; - }>; - }; - expect(internal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingCrossLineBlockquoteMarkdown"); - expect(internal.renderedSegments[0]?.component.quoteDepth).toBe(depth); - expect(internal.renderedSegments[0]?.component.widthState?.characters) - .toBeLessThanOrEqual(2_000_000); - } - } - - test("keeps one-level nested cross-line semantic quote chunks byte-identical", () => { - expectNestedCrossLineQuoteByteIdentical(2); - }); - - test("keeps depth-three cross-line semantic quote chunks byte-identical", () => { - expectNestedCrossLineQuoteByteIdentical(3); - }); - - test("avoids incremental setup for restored large nested cross-line quotes", () => { - for (const depth of [2, 3] as const) { - const quote = "> ".repeat(depth); - const chunks = Array.from( - { length: 65 }, - (_, index) => [ - `${quote}**chunk ${index} starts across`, - `${quote}and closes here** with generated output` - ].join("\n") - ); - const source = chunks.join("\n"); - const restored = new RichMarkdown(source, 1, createTheme(true)); - restored.render(80); - const restoredInternal = restored as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(restoredInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - - const streamed = new RichMarkdown(chunks[0]!, 1, createTheme(true)); - streamed.render(80); - streamed.appendText(`\n${chunks.slice(1).join("\n")}`); - streamed.render(80); - const streamedInternal = streamed as unknown as { - renderedSegments: Array<{ - component: { constructor: { name: string }; quoteDepth?: number }; - }>; - }; - expect(streamedInternal.renderedSegments[0]?.component.constructor.name) - .toBe("StreamingCrossLineBlockquoteMarkdown"); - expect(streamedInternal.renderedSegments[0]?.component.quoteDepth).toBe(depth); - - streamed.setText(source.replaceAll("generated", "replacement")); - streamed.render(80); - const replacedInternal = streamed as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(replacedInternal.renderedSegments[0]?.component).toBeInstanceOf(Markdown); - } - }); - - test("keeps non-flat quoted lists on the original Markdown path", () => { - for (const source of [ - "> -", - "> 1.", - "> 1. first\n> 2) mixed delimiter", - "> 1. first\n> - unordered transition", - "> 999999999. first\n> 999999999. overflow", - "> - first\n> - nested", - "> 1. first\n>\n> 2. second", - "> - first\n\n> - ordinary blank", - "> - first\n>\n>\n> - double quoted blank", - "> - first\n>\n> - nested loose item", - "> - first\n>\n> continuation", - "> - first\n>\n> > - depth transition", - "> - first\n>\n> # heading transition", - "> - first\n>\n> - **cross-line starts\n> closes here**", - "> - first\n>\n> - second\n>\n> -", - "> - **cross\n> line**", - "> - first\n> wrong indent", - "> - first\n> code indent", - "> 1. first\n> ordered continuation", - "> continuation before item", - "> > -", - "> > 1.", - "> > 1. first\n> > 2) mixed delimiter", - "> > - first\n> > continuation", - "> > - first\n> > paragraph transition", - "> > > -", - "> > > 1.", - "> > > 1. first\n> > > 2) mixed delimiter", - "> > > - first\n> > > continuation", - "> > > - first\n> > > paragraph transition", - "> > > - **cross\n> > > line**", - "> > > - first\n> > - depth transition", - "> > > > - depth four", - `> > > - item ${"x".repeat(100_000)}`, - `> - item ${"x".repeat(100_000)}\n>\n> - second` - ]) { - const component = new RichMarkdown(source, 1, createTheme(true)); - expect(component.render(80).length).toBeGreaterThan(0); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("keeps unsafe, loose, and deeper semantic quotes on the original path", () => { - for (const source of [ - "> **never closes\n> still open", - "> **nested *inline\n> closes* here**", - "> **escaped \\\n> delimiter**", - "> ** spaced open\n> closes here**", - "> word**intraword open\n> closes here**", - "> **valid starts\n> closes with space **", - "> [link starts\n> here](https://example.com)", - "> > **never closes\n> > still open", - "> > **nested *inline\n> > closes* here**", - "> > **escaped \\\n> > delimiter**", - "> > ** spaced open\n> > closes here**", - "> > word**intraword open\n> > closes here**", - "> > **valid starts\n> > closes with space **", - "> > [link starts\n> > here](https://example.com)", - "> > **valid starts\n> > closes here**\n> > - list transition", - "> > **valid starts\n> > closes here**\n> > # heading transition", - "> > **valid starts\n> > closes here**\n> > ![image](https://example.com/image.png)", - "> > **valid starts\n> > closes here**\n> > ", - "> > > **never closes\n> > > still open", - "> > > **nested *inline\n> > > closes* here**", - "> > > **escaped \\\n> > > delimiter**", - "> > > [link starts\n> > > here](https://example.com)", - "> > > **valid starts\n> > > closes here**\n> > - depth transition", - "> > > **valid starts\n> > > closes here**\n> > > - list transition", - "> > > > > depth-five quote", - "> > > first\n> > depth transition", - "> > > first\n> > > > deeper transition", - "> > > first\n> > > - list transition", - "> > > first\n> > >\n> > > loose paragraph", - "> > > > first\n> > > depth transition", - "> > > > first\n> > > > > deeper transition", - "> > > > first\n> > > > - list transition", - "> > > > first\n> > > >\n> > > > loose paragraph", - "> > > > **open across\n> > > > closes here**", - "> > first\n> >\n> > second", - "> first\n>\n> second", - "> ![image](https://example.com/image.png)", - "> [partial link](https://example.com", - "> ", - "> https://example.com", - `> > **open across\n> > closes here** ${"x".repeat(100_000)}`, - `> > > **open across\n> > > closes here** ${"x".repeat(100_000)}` - ]) { - const theme = createTheme(true); - const component = new RichMarkdown(source, 1, theme); - expect(component.render(80).length).toBeGreaterThan(0); - const internal = component as unknown as { - renderedSegments: Array<{ component: unknown }>; - }; - expect(internal.renderedSegments.every((segment) => segment.component instanceof Markdown)) - .toBeTrue(); - } - }); - - test("keeps completed Markdown segment caches when only the tail grows", () => { - const component = new RichMarkdown("stable paragraph\n\nstream", 1, createTheme(false)); - component.render(80); - const internal = component as unknown as { - renderedSegments: Array<{ component: { cachedLines?: string[] } }>; - }; - const stableLines = internal.renderedSegments[0]?.component.cachedLines; - expect(stableLines).toBeDefined(); - - component.appendText("ing tail"); - component.render(80); - expect(internal.renderedSegments[0]?.component.cachedLines).toBe(stableLines); - }); - - test("uses an explicit readable foreground for strong text on light terminals", () => { - const component = new RichMarkdown( - "### 说明\n\n- **类型** feat\n- **范围** tui\n- **描述** summary", - 1, - createTheme(true, "light") - ); - const rendered = component.render(80).join("\n"); - - expect(rendered).toContain("\x1b[1;38;5;236m类型"); - expect(rendered).toContain("\x1b[1;38;5;236m范围"); - expect(rendered).toContain("\x1b[1;38;5;236m描述"); - expect(rendered).toContain("\x1b[38;5;25m\x1b[1m### "); - }); - - test("pages very long Markdown across internal chunk boundaries", () => { - const component = new RichMarkdown( - Array.from({ length: 500 }, (_, index) => `${index + 1}. task ${index + 1}`).join("\n"), - 1, - createTheme(false) - ); - const page = component.renderWindow(80, 79, 4); - expect(page.totalLines).toBe(500); - expect(page.lines.map((line) => line.trim())).toEqual([ - "80. task 80", - "81. task 81", - "82. task 82", - "83. task 83" - ]); - }); - - test("renders Mermaid flowcharts as terminal diagrams", () => { - const preview = renderMermaidPreview("graph LR\nA[Start] --> B[Done]", 78); - expect(preview.reason).toBeUndefined(); - expect(preview.lines?.join("\n")).toContain("Start"); - expect(preview.lines?.join("\n")).toContain("Done"); - expect(preview.lines?.join("\n")).toContain("►"); - - const component = new RichMarkdown([ - "Before", - "```mermaid", - "graph LR", - "A --> B", - "```", - "After" - ].join("\n"), 1, createTheme(false)); - const output = component.render(80).join("\n"); - expect(output).toContain("◇ Mermaid · flowchart"); - expect(output).toContain("A"); - expect(output).toContain("B"); - expect(output).not.toContain("```mermaid"); - }); - - test("supports the common Mermaid diagram families", () => { - for (const source of [ - "stateDiagram-v2\n[*] --> Idle\nIdle --> Done", - "sequenceDiagram\nAlice->>Bob: Hello\nBob-->>Alice: Hi", - "classDiagram\nAnimal <|-- Dog\nAnimal: +int age", - "erDiagram\nCUSTOMER ||--o{ ORDER : places", - "xychart-beta\nx-axis [a, b]\ny-axis \"Count\" 0 --> 10\nbar [3, 7]" - ]) { - const preview = renderMermaidPreview(source, 150); - expect(preview.reason).toBeUndefined(); - expect(preview.lines?.length).toBeGreaterThan(0); - } - }); - - test("keeps CJK labels aligned with diagram borders and connectors", () => { - const source = [ - "graph TD", - "Input[用户输入] --> Editor[编辑器面板]", - "Editor --> Router[事件路由器]" - ].join("\n"); - const normalized = normalizeMermaidTerminalWidth(source); - expect(normalized).toContain("用\u200b户\u200b输\u200b入\u200b"); - - const preview = renderMermaidPreview(source, 80); - expect(preview.reason).toBeUndefined(); - expect(preview.lines?.join("\n")).not.toContain("\u200b"); - const lines = preview.lines ?? []; - const topBorder = lines.find((line) => line.includes("┌")); - const inputLabel = lines.find((line) => line.includes("用户输入")); - const editorLabel = lines.find((line) => line.includes("编辑器面板")); - expect(topBorder).toBeDefined(); - expect(inputLabel).toBeDefined(); - expect(editorLabel).toBeDefined(); - expect(visibleWidth(inputLabel ?? "")).toBe(visibleWidth(topBorder ?? "")); - expect(visibleWidth(editorLabel ?? "")).toBe(visibleWidth(topBorder ?? "")); - - const junctionRows = lines.flatMap((line, index) => line.includes("┬") ? [index] : []); - expect(junctionRows).toHaveLength(2); - for (const row of junctionRows) { - const column = terminalColumn(lines[row] ?? "", "┬"); - expect(terminalColumn(lines[row + 1] ?? "", "│")).toBe(column); - expect(terminalColumn(lines[row + 2] ?? "", "▼")).toBe(column); - } - }); - - test("preserves Mermaid source when the terminal cannot fit the diagram", () => { - const component = new RichMarkdown([ - "```mermaid", - "graph LR", - "LongNode[This label is deliberately much wider than the terminal] --> B", - "```" - ].join("\n"), 1, createTheme(false)); - const output = component.render(24).join("\n"); - - expect(output).toContain("too wide for terminal"); - expect(output).toContain("```mermaid"); - expect(output).toContain("LongNode"); - }); -}); diff --git a/test/runtime-activity-view.test.ts b/test/runtime-activity-view.test.ts deleted file mode 100644 index 34e23fe2..00000000 --- a/test/runtime-activity-view.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { RuntimeActivityView } from "../packages/zcode-tui/src/runtime-activity-view.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -describe("runtime activity view", () => { - test("stays absent when there is no persistent activity", () => { - const view = new RuntimeActivityView(createTheme(false)); - expect(view.render(80)).toEqual([]); - }); - - test("renders active tools, background tasks and prioritized todos in place", () => { - const view = new RuntimeActivityView(createTheme(false)); - view.update({ - projection: { - activeToolCalls: [{ toolCallId: "tool-1", toolName: "Bash", status: "running" }], - backgroundJobs: [{ - taskId: "bg-1", - taskKind: "local_bash", - status: "running", - description: "Run repository tests", - cancellable: true - }] - }, - todos: [ - { content: "Later cleanup", status: "pending", priority: "low" }, - { content: "Finish projection bridge", status: "in_progress", priority: "high" }, - { content: "Review errors", status: "pending", priority: "high" } - ] - }); - const rendered = view.render(100).join("\n"); - expect(rendered).toContain("Activity · 1 active tool · 1 in background · 3 open tasks · /tasks"); - expect(rendered).toContain("● Bash"); - expect(rendered).toContain("Run repository tests · bg-1"); - expect(rendered.indexOf("Finish projection bridge")).toBeLessThan(rendered.indexOf("Review errors")); - expect(rendered.indexOf("Review errors")).toBeLessThan(rendered.indexOf("Later cleanup")); - }); - - test("links collapsed overflow to a complete width-safe activity view", () => { - const state = { - projection: { - activeToolCalls: Array.from({ length: 7 }, (_, index) => ({ - toolCallId: `tool-${index + 1}`, - toolName: `Tool ${index + 1} 👨‍👩‍👧‍👦 with a detailed activity description`, - status: "running" as const - })), - backgroundJobs: [] - }, - todos: Array.from({ length: 6 }, (_, index) => ({ - content: `Open task ${index + 1} 👨‍👩‍👧‍👦 with complete detail`, - status: "pending" as const, - priority: "medium" as const - })) - }; - const collapsed = new RuntimeActivityView(createTheme(false)); - collapsed.update(state); - const summary = collapsed.render(80).join("\n"); - expect(summary).toContain("2 more activities · /activity"); - expect(summary).toContain("2 more tasks · /activity"); - - const expanded = new RuntimeActivityView(createTheme(false), true); - expanded.update(state); - const lines = expanded.render(40); - expect(lines.join("\n")).toContain("Tool 7"); - expect(lines.join("\n")).toContain("Open task 6"); - expect(lines.every((line) => visibleWidth(line) <= 40)).toBe(true); - }); -}); diff --git a/test/runtime-app-server-patches.test.ts b/test/runtime-app-server-patches.test.ts deleted file mode 100644 index 1b391e21..00000000 --- a/test/runtime-app-server-patches.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { expect, test } from "bun:test"; -import { hasRuntimeAppServerStandaloneAuth as has, patchRuntimeAppServerStandaloneAuth as patch } from "../scripts/runtime-app-server-patches.ts"; -import { applyRuntimePatchPlan, runtimePatchPlan } from "../scripts/sync-runtime.ts"; - -const helper = 'require(require("node:path").join(__dirname,"cli-config.cjs"))'; -function fixture(agent = "agent", starter = "start", reporter = "report", env = "env") { - return `const exports={runZCodeProtocolAgent:()=>${agent},startProcessProviderRegistryRuntime:()=>${starter}}; -async function ${agent}(e={}){ - let ${env}=e.env??process.env; - let store=await deferred({create:label(()=>storage(),"create")}); - let reg=await deferred({create:label(()=>${starter}(${env}),"create")}); - let other=await deferred({create:label(()=>${starter}(otherEnv),"create")}); - log({event:"zcode_protocol.provider_registry.ready",accountRevision:reg.snapshot.sourceRevisions.account}); - const braces="}", regex=/[}]/, template=\`brace } and \${{x:1}.x}\`; - let rt=reg; - const decoy={createZCodeApp:()=>app({...merge(args,other.runtime.registryService,other.personalDefaults)})}; - return new Host({createZCodeApp:label((args={})=>app({...merge(args,rt.runtime.registryService,rt.configuredDefaultModelSelection),env:${env}}),"createZCodeApp")}); -} -label(${reporter},"createCliProviderRefreshReporter"); -function later(){return {create:label(()=>${starter}(laterEnv),"create")}}`; -} - -test.each([ - ["agent", "start", "report", "env"], ["a$1", "$s", "r$2", "$env"] -])("patches only the protocol registry and its app with symbols %s", (agent, starter, reporter, env) => { - const source = fixture(agent, starter, reporter, env); - const result = patch(source); - expect(has(source)).toBe(false); - expect(has(result)).toBe(true); - expect(result).toContain(`${starter}(${env},${helper}.appServerRegistryOptions(${env},${reporter}))`); - expect(result).toContain(`${starter}(otherEnv)`); - expect(result).toContain(`${starter}(laterEnv)`); - expect(result).toContain("rt.configuredDefaultModelSelection),...rt.providerRuntimeHeadersPort?"); - expect(patch(result)).toBe(result); -}); - -test("unrelated injected markers cannot satisfy protocol verification", () => { - const decoy = 'function unrelated(){return {create:r(()=>Other(env,{standalone:{...callbacks(process.stderr)}}),"create"),createZCodeApp:()=>app({...merge(args,other.runtime.registryService,other.configuredDefaultModelSelection),...other.providerRuntimeHeadersPort?{providerRuntimeHeadersPort:other.providerRuntimeHeadersPort}:{}})}}'; - const source = decoy + fixture() + decoy.replace("unrelated", "after"); - expect(has(source)).toBe(false); - expect(has(patch(source))).toBe(true); - expect(patch(source)).toContain(decoy); -}); - -test("a changed registry call cannot redirect the patch to another function", () => { - const earlier = 'async function earlier(e){let env=e.env??process.env;let x=await deferred({create:r(()=>start(env),"create")})}'; - expect(() => patch(earlier + fixture().replace("start(env)", "start(env,{})"))) - .toThrow("registry options changed"); -}); - -test("the app factory must consume the registry named in the ready event", () => { - expect(() => patch(fixture().replace("let rt=reg", "let rt=other"))) - .toThrow("app factory uses another registry"); -}); - -test("missing and ambiguous anchors fail without modifying the source", () => { - for (const source of [fixture() + fixture(), fixture().replace("runZCodeProtocolAgent", "renamed"), - fixture().replace("createCliProviderRefreshReporter", "renamed"), - fixture().replace("zcode_protocol.provider_registry.ready", "renamed"), - fixture().replace("rt.configuredDefaultModelSelection", "rt.otherDefaults")]) { - expect(has(source)).toBe(false); - expect(() => patch(source)).toThrow(); - } -}); - -test("partial patches and overriding auth properties fail verification", () => { - const patched = patch(fixture()); - const partial = patched.replace(`,${helper}.appServerRegistryOptions(env,report)`, ""); - expect(has(partial)).toBe(false); - expect(() => patch(partial)).toThrow("partial auth patch"); - expect(() => patch(fixture().replace("env:env}", "providerRuntimeHeadersPort:otherPort,env:env}"))) - .toThrow("request auth port changed"); -}); - -test("auth is required and records applied or already_present, never a silent skip", () => { - const entry = runtimePatchPlan.find(item => item.id === "app-server-standalone-auth")!; - expect(entry.requirement).toBe("required"); - const first = applyRuntimePatchPlan(fixture(), [entry]); - expect(first.reports[0]?.status).toBe("applied"); - expect(applyRuntimePatchPlan(first.runtime, [entry]).reports[0]?.status).toBe("already_present"); - expect(() => applyRuntimePatchPlan("incompatible runtime", [entry])).toThrow("Required runtime patch"); -}); diff --git a/test/runtime-capabilities.test.ts b/test/runtime-capabilities.test.ts deleted file mode 100644 index da515d17..00000000 --- a/test/runtime-capabilities.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { describe, expect, test } from "bun:test"; - -import { readRuntimeCliOptionTypes } from "../src/launcher.ts"; -import { - capabilitiesFromExtractionMetadata, - parseRuntimeCapabilities -} from "../src/runtime-capabilities.ts"; - -describe("runtime capabilities", () => { - test("validates extraction metadata without trusting malformed option entries", () => { - const capabilities = parseRuntimeCapabilities({ - schemaVersion: 1, - cli: { - globalOptions: { - json: { type: "boolean" }, - "output-format": { type: "string" }, - attach: { type: "string", multiple: true } - } - } - }); - expect(capabilities?.cli.globalOptions["output-format"]).toEqual({ type: "string" }); - expect(capabilities?.cli.globalOptions.attach).toEqual({ type: "string", multiple: true }); - expect(parseRuntimeCapabilities({ - schemaVersion: 1, - cli: { globalOptions: { json: { type: "number" } } } - })).toBeUndefined(); - expect(capabilitiesFromExtractionMetadata({ runtimeCapabilities: capabilities })).toEqual(capabilities); - }); - - test("loads option types from extraction metadata", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-runtime-capabilities-")); - const metadata = join(directory, "extraction.json"); - try { - await writeFile(metadata, JSON.stringify({ - runtimeCapabilities: { - schemaVersion: 1, - cli: { - globalOptions: { - json: { type: "boolean" }, - "output-format": { type: "string" } - } - } - } - })); - expect(readRuntimeCliOptionTypes(metadata)).toEqual({ - json: "boolean", - "output-format": "string" - }); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); -}); diff --git a/test/runtime-config.test.ts b/test/runtime-config.test.ts deleted file mode 100644 index f0489870..00000000 --- a/test/runtime-config.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { afterEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { cliSettingsPath, desktopSettingsPath, legacyCliConfigPath, providerConfigPath } from "../src/config-paths.ts"; -import { ensureCliSettings, readCliSettings } from "../src/model-access.ts"; -import { mergeDesktopSettings } from "../src/runtime-config-bridge.ts"; -import { writeNotificationSettings } from "../packages/zcode-tui/src/notifications.ts"; - -const homes: string[] = []; -afterEach(async () => { await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true }))); }); -async function fixture() { - const home = await mkdtemp(join(tmpdir(), "zcode-settings-")); - homes.push(home); - const env = { HOME: home, USERPROFILE: home }; - await mkdir(dirname(desktopSettingsPath(env)), { recursive: true }); - return { home, env }; -} - -test("reuses native Desktop preferences without copying them into CLI settings", async () => { - const { env } = await fixture(); - const desktop = JSON.stringify({ localePreference: "zh-CN", memoryEnabled: false, terminalFontFamily: "desktop-only" }); - await writeFile(desktopSettingsPath(env), desktop); - await ensureCliSettings(env); - const input = await readCliSettings(env); - expect(input.ui).not.toHaveProperty("locale"); - expect(mergeDesktopSettings(input, cliSettingsPath(env), env)).toMatchObject({ - ui: { locale: "zh-CN" }, memory: { use: false, write: false }, features: { memory: false } - }); - expect(mergeDesktopSettings({ ui: { locale: "en-US" }, memory: { use: true } }, cliSettingsPath(env), env)) - .toMatchObject({ ui: { locale: "en-US" }, memory: { use: true, write: false }, features: { memory: true } }); - expect(mergeDesktopSettings({ memory: { use: true }, features: { memory: false } }, cliSettingsPath(env), env)) - .toMatchObject({ memory: { use: true, write: false }, features: { memory: false } }); - const project = { ui: { theme: "dark" } }; - expect(mergeDesktopSettings(project, "/workspace/zcode.json", env)).toBe(project); - await writeNotificationSettings({ method: "off", condition: "always" }, env); - expect(await readFile(desktopSettingsPath(env), "utf8")).toBe(desktop); - expect((await readCliSettings(env)).ui).not.toHaveProperty("locale"); -}); - -test("maps Desktop's default \"system\" localePreference to the CLI's \"auto\"", async () => { - const { env } = await fixture(); - await writeFile(desktopSettingsPath(env), JSON.stringify({ localePreference: "system" })); - await ensureCliSettings(env); - const input = await readCliSettings(env); - expect(input.ui).not.toHaveProperty("locale"); - expect(mergeDesktopSettings(input, cliSettingsPath(env), env)).toMatchObject({ ui: { locale: "auto" } }); - expect(mergeDesktopSettings({ ui: { locale: "en-US" } }, cliSettingsPath(env), env)) - .toMatchObject({ ui: { locale: "en-US" } }); -}); - -test("migrates CLI settings once and never reads the old file as a fallback", async () => { - const { env } = await fixture(); - await mkdir(dirname(legacyCliConfigPath(env)), { recursive: true }); - const legacy = { provider: { custom: {} }, model: { main: "custom/test" }, modelCatalog: {}, ui: { theme: "dark" } }; - await writeFile(legacyCliConfigPath(env), JSON.stringify(legacy)); - expect(await ensureCliSettings(env)).toMatchObject({ created: true, migrated: true }); - expect(await readCliSettings(env)).toEqual({ ui: { theme: "dark" } }); - await writeFile(legacyCliConfigPath(env), JSON.stringify({ ui: { theme: "light" } })); - expect(await readCliSettings(env)).toEqual({ ui: { theme: "dark" } }); - await writeFile(cliSettingsPath(env), "invalid-new-settings"); - await expect(readCliSettings(env)).rejects.toThrow("Unable to read"); - expect(await readFile(cliSettingsPath(env), "utf8")).toBe("invalid-new-settings"); - await rm(cliSettingsPath(env)); - expect((await readCliSettings(env)).ui).toMatchObject({ theme: "auto" }); -}); - -test("uses Desktop's data directory and honors an explicit provider file", async () => { - const { home, env } = await fixture(); - const data = join(home, "desktop-data"); - await writeFile(desktopSettingsPath(env), JSON.stringify({ dataBaseDir: data })); - expect(providerConfigPath(env)).toBe(join(data, ".zcode/v2/provider_config.json")); - expect(providerConfigPath({ ...env, ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: "/custom/providers.json" })).toBe("/custom/providers.json"); - await writeFile(desktopSettingsPath(env), "invalid-desktop-settings"); - expect(() => providerConfigPath(env)).toThrow("Unable to read Desktop setting.json"); -}); diff --git a/test/runtime-context-cache.test.ts b/test/runtime-context-cache.test.ts deleted file mode 100644 index 2a7775fd..00000000 --- a/test/runtime-context-cache.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { RuntimeContextCache } from "../packages/zcode-tui/src/runtime-context-cache.ts"; -import { runtimeContextChanged, runtimeContextRefreshNeeded } from "../packages/zcode-tui/src/runtime-poll.ts"; -import { normalizeRuntimeProjection } from "../packages/zcode-tui/src/runtime-projection.ts"; - -function messages(input: number, read = 0): unknown[] { - return [{ info: { id: "assistant-1", role: "assistant", tokens: { input, cache: { read, write: 0 } } }, - parts: [{ type: "tool", output: "Large tool output does not belong in the cached summary" }] }]; -} - -describe("runtime context cache", () => { - test("polls reuse a compact summary until usage is invalidated", async () => { - const cache = new RuntimeContextCache(); - let reads = 0; - const load = async () => { reads++; return messages(100, 80); }; - const first = await cache.read("session-1", load); - expect(first?.latestHitRate).toBe(0.8); - for (let index = 0; index < 100; index++) expect(await cache.read("session-1", load)).toBe(first); - expect(reads).toBe(1); - expect(JSON.stringify(first)).not.toContain("Large tool output"); - cache.invalidate(); - await cache.read("session-1", load); - expect(reads).toBe(2); - }); - - test("empty sessions are cached and failed reads can be retried", async () => { - const cache = new RuntimeContextCache(); - let reads = 0; - const empty = async () => { reads++; return []; }; - expect(await cache.read("empty", empty)).toBeUndefined(); - expect(await cache.read("empty", empty)).toBeUndefined(); - expect(reads).toBe(1); - cache.invalidate(); - await expect(cache.read("empty", async () => { throw new Error("Store unavailable"); })).rejects.toThrow("Store unavailable"); - expect((await cache.read("empty", async () => messages(200)))?.inputTokens).toBe(200); - }); - - test.each(["invalidate", "reset", "switch"] as const)("ignores an in-flight read after %s", async (action) => { - const cache = new RuntimeContextCache(); - let resolve!: (value: unknown) => void; - let reads = 0; - const load = () => { reads++; return new Promise((done) => { resolve = done; }); }; - const old = cache.read("old", load); - const duplicate = cache.read("old", load); - await Promise.resolve(); - expect(reads).toBe(1); - if (action === "invalidate") cache.invalidate(); - if (action === "reset") cache.reset(); - const session = action === "switch" ? "new" : "old"; - expect((await cache.read(session, async () => messages(200, 20)))?.latestHitRate).toBe(0.1); - resolve(messages(100, 80)); - expect(await old).toBeUndefined(); - expect(await duplicate).toBeUndefined(); - expect((await cache.read(session, load))?.inputTokens).toBe(200); - }); - - test("invalidates on completed requests and history changes, not presentation events", () => { - for (const type of ["model_complete", "turn_complete", "session_resumed", "session_forked", "session_compacted", "rewind_triggered"]) { - expect(runtimeContextRefreshNeeded({ type })).toBeTrue(); - } - for (const type of ["model_streaming", "part.delta", "tool_call_progress", "background_task_updated"]) { - expect(runtimeContextRefreshNeeded({ type })).toBeFalse(); - } - const projection = (value: Record) => normalizeRuntimeProjection({ sessionId: "s", totalTokenCount: 100, ...value }); - expect(runtimeContextChanged(projection({}), projection({ status: "running" }))).toBeFalse(); - expect(runtimeContextChanged(projection({}), projection({ totalTokenCount: 200 }))).toBeTrue(); - expect(runtimeContextChanged(projection({}), projection({ sessionId: "next" }))).toBeTrue(); - }); -}); diff --git a/test/runtime-context-window-patches.test.ts b/test/runtime-context-window-patches.test.ts deleted file mode 100644 index e28532f0..00000000 --- a/test/runtime-context-window-patches.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - hasRuntimeResolvedContextWindow as has, - patchRuntimeResolvedContextWindow as patch -} from "../scripts/runtime-context-window-patches.ts"; -import { applyRuntimePatchPlan, runtimePatchPlan } from "../scripts/sync-runtime.ts"; - -interface FixtureState { - contextWindow: number; - contextUsed: number; -} - -interface FixtureModel { - providerId: string; - modelId: string; - contextWindow?: number; - current: boolean; -} - -interface FixtureContext { - eventStore: { getEvents(sessionId?: string): Promise }; - eventReducer: { reduce(events: unknown[]): FixtureState }; - modelCatalogPort?: { listModels(): FixtureModel[] }; - sessionId: string; -} - -/** Mirrors the runtime's projection source: the reduced event state for a session. */ -function fixture(projection = "NBo"): string { - return `const label=(fn,name)=>fn; -async function ${projection}(){let events=await this.eventStore.getEvents(this.sessionId);return this.eventReducer.reduce(events)} -label(${projection},"rebuildProjection"); -return {rebuildProjection:${projection}};`; -} - -function load(source: string) { - return new Function(source)() as { rebuildProjection(): Promise }; -} - -const recorded: FixtureState = { contextWindow: 200_000, contextUsed: 1_234 }; -const resolvedWindow = 1_000_000; - -function context(models: FixtureModel[] | undefined): FixtureContext { - return { - sessionId: "sess_fixture", - eventStore: { getEvents: async () => [] }, - eventReducer: { reduce: () => recorded }, - ...models === undefined ? {} : { modelCatalogPort: { listModels: () => models } } - }; -} - -const current = (contextWindow?: number): FixtureModel => ({ - providerId: "probe", modelId: "probe-model", current: true, ...contextWindow === undefined ? {} : { contextWindow } -}); - -test("projects the resolved context window from the model catalog port", async () => { - const patched = patch(fixture()); - expect(has(fixture())).toBe(false); - expect(has(patched)).toBe(true); - expect(patch(patched)).toBe(patched); - const result = await load(patched).rebuildProjection.call(context([current(resolvedWindow)])); - expect(result).toEqual({ contextWindow: resolvedWindow, contextUsed: 1_234 }); - expect(recorded).toEqual({ contextWindow: 200_000, contextUsed: 1_234 }); -}); - -test("keeps the reduced projection when no window is resolved", async () => { - const projection = load(patch(fixture())).rebuildProjection; - const cases = [ - context(undefined), - context([]), - context([current()]), - context([{ ...current(resolvedWindow), current: false }]), - context([current(0)]) - ]; - for (const item of cases) expect(await projection.call(item)).toMatchObject({ contextWindow: 200_000 }); -}); - -test("fails loudly when the projection source changes shape", () => { - expect(() => patch("const runtime = {};")).toThrow("rebuildProjection label missing or ambiguous"); - expect(() => patch(`${fixture()}\nlabel(other,"rebuildProjection");`)).toThrow("rebuildProjection label missing or ambiguous"); - expect(() => patch(fixture().replace("function NBo()", "function IOther()"))) - .toThrow("rebuildProjection function missing or ambiguous"); - expect(() => patch(fixture().replace("function NBo()", "function NBo(value)"))) - .toThrow("rebuildProjection signature changed"); - for (const replaced of [ - "return this.snapshot(events)", - "return this.eventReducer.snapshot(events)", - "return this.eventReducer.reduce()", - "return this.eventReducer.reduce(...events)" - ]) { - expect(() => patch(fixture().replace("return this.eventReducer.reduce(events)", replaced))) - .toThrow("rebuildProjection no longer returns the reduced event state"); - } -}); - -test("applies through the runtime patch plan idempotently", () => { - const entry = runtimePatchPlan.find(item => item.id === "resolved-context-window"); - expect(entry?.requirement).toBe("required"); - const first = applyRuntimePatchPlan(fixture(), [entry!]); - expect(first.reports[0]?.status).toBe("applied"); - expect(applyRuntimePatchPlan(first.runtime, [entry!]).reports[0]?.status).toBe("already_present"); - expect(() => applyRuntimePatchPlan("incompatible runtime", [entry!])) - .toThrow("Required runtime patch resolved-context-window failed"); -}); diff --git a/test/runtime-mcp-availability.test.ts b/test/runtime-mcp-availability.test.ts deleted file mode 100644 index f0062594..00000000 --- a/test/runtime-mcp-availability.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { patchRuntimeOfficialMcpAvailability } from "../scripts/sync-runtime.ts"; - -// This fixture preserves the minified upstream connection branch, with no network transport. -const source = `class Adapter { - records = new Map(); - closed = []; - connected = []; - officialMcpAuth; - createStatus(config,status,extra){return {status,...extra}} - async closeRecord(name){this.closed.push(name)} - async connectServer(t,r,n={}){if(await this.closeRecord(t),r.enabled===!1){let m=this.createStatus(r,"disabled");return this.records.set(t,{config:r,status:m,tools:[]}),m}this.connected.push(t);return {status:"connected"}} -}`; - -function adapter() { - return new (new Function(`${patchRuntimeOfficialMcpAvailability(source)};return Adapter;`)())(); -} - -const official = { - type: "http", auth: { type: "zcode_official", provider: "jwt_token" }, - official: { provider: "fixture" }, url: "https://example.invalid/mcp" -}; - -describe("official MCP runtime availability (offline)", () => { - test("skips unavailable official HTTP services with an actionable status", async () => { - const runtime = adapter(); - const result = await runtime.connectServer("image_search", official); - expect(result).toMatchObject({ status: "disabled", failureKind: "official_auth_unavailable" }); - expect(result.error).toContain("authentication is unavailable"); - expect(runtime.connected).toEqual([]); - expect(runtime.closed).toEqual(["image_search"]); - expect(official).not.toHaveProperty("enabled"); - }); - - test("allows revalidation after the runtime gains trusted-origin support", async () => { - const runtime = adapter(); - await runtime.connectServer("image_search", official); - runtime.officialMcpAuth = { trustedOrigins: {} }; - expect(await runtime.connectServer("image_search", official)).toEqual({ status: "connected" }); - expect(runtime.connected).toEqual(["image_search"]); - }); - - test("preserves explicit disablement and other transports/auth paths", async () => { - const runtime = adapter(); - expect(await runtime.connectServer("disabled", { ...official, enabled: false })).toEqual({ status: "disabled" }); - for (const config of [{ type: "http" }, { ...official, type: "stdio" }, { ...official, official: undefined }]) { - expect(await runtime.connectServer("other", config)).toEqual({ status: "connected" }); - } - }); - - test("is idempotent and rejects missing or ambiguous patch anchors", () => { - const patched = patchRuntimeOfficialMcpAvailability(source); - expect(patchRuntimeOfficialMcpAvailability(patched)).toBe(patched); - expect(() => patchRuntimeOfficialMcpAvailability("changed upstream")).toThrow("incompatible"); - expect(() => patchRuntimeOfficialMcpAvailability(source + source)).toThrow("incompatible"); - }); -}); diff --git a/test/runtime-poll.test.ts b/test/runtime-poll.test.ts deleted file mode 100644 index 9d5bcb62..00000000 --- a/test/runtime-poll.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - ACTIVE_RUNTIME_POLL_INTERVAL_MS, - IDLE_RUNTIME_POLL_INTERVAL_MS, - runtimeActivityActive, - runtimePollInterval, - runtimeRefreshNeeded, - runtimePollStateChanged, - type RuntimePollState -} from "../packages/zcode-tui/src/runtime-poll.ts"; -import { normalizeRuntimeProjection } from "../packages/zcode-tui/src/runtime-projection.ts"; - -function state(totalTokenCount: number): RuntimePollState { - return { - projection: normalizeRuntimeProjection({ - sessionId: "session-1", - status: "running", - totalTokenCount, - activeToolCalls: [], - backgroundJobs: [] - }), - todos: [{ content: "Measure CPU", status: "in_progress", priority: "high" }], - todoGroups: [] - }; -} - -describe("runtime polling", () => { - test("polls active turns more frequently than idle sessions", () => { - expect(runtimePollInterval(true)).toBe(ACTIVE_RUNTIME_POLL_INTERVAL_MS); - expect(runtimePollInterval(false)).toBe(IDLE_RUNTIME_POLL_INTERVAL_MS); - expect(ACTIVE_RUNTIME_POLL_INTERVAL_MS).toBe(1_000); - expect(IDLE_RUNTIME_POLL_INTERVAL_MS).toBe(5_000); - }); - - test("keeps polling while tools or background jobs remain active", () => { - expect(runtimeActivityActive(normalizeRuntimeProjection({ - activeToolCalls: [{ toolCallId: "tool-1", toolName: "Bash", status: "running" }], - backgroundJobs: [] - }))).toBeTrue(); - expect(runtimeActivityActive(normalizeRuntimeProjection({ - activeToolCalls: [], - backgroundJobs: [{ taskId: "task-1", status: "running" }] - }))).toBeTrue(); - expect(runtimeActivityActive(normalizeRuntimeProjection({ - activeToolCalls: [], - backgroundJobs: [{ taskId: "task-1", status: "failed" }] - }))).toBeFalse(); - }); - - test("does not report a change for equivalent normalized snapshots", () => { - expect(runtimePollStateChanged(state(1_000), state(1_000))).toBeFalse(); - expect(runtimePollStateChanged(state(1_000), state(1_001))).toBeTrue(); - }); - - test("does not refresh runtime projection for presentation-only text deltas", () => { - expect(runtimeRefreshNeeded({ kind: "text_delta" })).toBeFalse(); - expect(runtimeRefreshNeeded({ kind: "reasoning_delta" })).toBeFalse(); - expect(runtimeRefreshNeeded({ type: "part.delta", field: "text" })).toBeFalse(); - expect(runtimeRefreshNeeded({ type: "part.delta", field: "reasoning" })).toBeFalse(); - expect(runtimeRefreshNeeded({ type: "part.delta", field: "input" })).toBeFalse(); - expect(runtimeRefreshNeeded({ type: "part.delta", field: "output" })).toBeFalse(); - expect(runtimeRefreshNeeded({ kind: "tool_input_delta" })).toBeFalse(); - expect(runtimeRefreshNeeded({ type: "part.started" })).toBeTrue(); - expect(runtimeRefreshNeeded({ kind: "progress" })).toBeTrue(); - expect(runtimeRefreshNeeded({ kind: "result" })).toBeTrue(); - - const presentationDeltas = Array.from({ length: 10_000 }, (_, index) => index % 2 === 0 - ? { kind: "tool_input_delta" } - : { type: "part.delta", field: index % 4 === 1 ? "input" as const : "output" as const }); - expect(presentationDeltas.filter(runtimeRefreshNeeded)).toHaveLength(0); - }); -}); diff --git a/test/runtime-projection.test.ts b/test/runtime-projection.test.ts deleted file mode 100644 index fce85677..00000000 --- a/test/runtime-projection.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - mergeProjectionContextCache, - normalizeRuntimeProjection, - normalizeTodoGroups, - normalizeTodos -} from "../packages/zcode-tui/src/runtime-projection.ts"; - -describe("runtime projection normalization", () => { - test("normalizes internal runtime projections and background tasks", () => { - const snapshot = normalizeRuntimeProjection({ - id: "session-1", - status: "running", - mode: "build", - turnCount: 3, - totalTokenCount: 8_200, - contextUsed: 45_000, - contextWindow: 200_000, - lastError: { type: "provider", code: "RATE_LIMIT", message: "Retry later", detail: "429" }, - currentTurnId: "turn-3", - activeToolCalls: [{ - toolCallId: "tool-1", - toolName: "Bash", - status: "running", - startedAt: new Date("2026-07-14T01:00:00Z") - }], - backgroundTasks: [{ - taskId: "bg-1", - toolCallId: "tool-1", - toolName: "Bash", - description: "Run tests", - command: "bun test", - status: "running", - cancellable: true, - pid: 42, - stdoutBytes: 512, - stdoutTail: "81 pass" - }] - }); - - expect(snapshot?.sessionId).toBe("session-1"); - expect(snapshot?.contextUsage).toMatchObject({ used: 45_000, size: 200_000 }); - expect(snapshot?.lastError).toEqual({ type: "provider", code: "RATE_LIMIT", message: "Retry later", detail: "429" }); - expect(snapshot?.activeToolCalls[0]).toMatchObject({ toolCallId: "tool-1", toolName: "Bash" }); - expect(snapshot?.backgroundJobs[0]).toMatchObject({ - taskId: "bg-1", - taskKind: "local_bash", - command: "bun test", - status: "running", - stdoutTail: "81 pass" - }); - }); - - test("merges runtime task-registry metadata into background projections", () => { - const snapshot = normalizeRuntimeProjection({ - sessionId: "session-1", - activeToolCalls: [], - backgroundTasks: [{ - taskId: "agent-1", - toolName: "Agent", - status: "failed", - description: "Review task recovery" - }], - backgroundTaskDetails: [{ - taskId: "agent-1", - taskKind: "local_agent", - agentId: "agent-1", - agentType: "reviewer", - childSessionId: "child-1", - parentSessionId: "session-1", - turnId: "turn-1", - prompt: "Audit the task flow", - status: "running", - error: "Provider unavailable" - }] - }); - - expect(snapshot?.backgroundJobs[0]).toMatchObject({ - taskId: "agent-1", - taskKind: "local_agent", - agentId: "agent-1", - agentType: "reviewer", - childSessionId: "child-1", - prompt: "Audit the task flow", - status: "running", - error: "Provider unavailable" - }); - }); - - test("keeps restored background agents whose registry status is stopped", () => { - // Restored tasks re-register with status "stopped" (or completed/failed read - // from task metadata.json); "stopped" is a runtime-native terminal status - // and must survive normalization instead of being dropped from /tasks. - // The readRuntimeProjection bridge pushes registry rows into backgroundTasks - // and repeats them as backgroundTaskDetails, so normalize sees both. - const restoredJob = { - taskId: "agent-2", - taskKind: "local_agent", - agentId: "agent-2", - agentType: "general-purpose", - childSessionId: "child-2", - parentSessionId: "session-1", - status: "stopped", - description: "Restored background agent" - }; - const snapshot = normalizeRuntimeProjection({ - sessionId: "session-1", - activeToolCalls: [], - backgroundTasks: [restoredJob], - backgroundTaskDetails: [restoredJob] - }); - - expect(snapshot?.backgroundJobs[0]).toMatchObject({ - taskId: "agent-2", - taskKind: "local_agent", - agentId: "agent-2", - status: "stopped", - childSessionId: "child-2" - }); - }); - - test("normalizes the restoration log carried by the projection", () => { - const snapshot = normalizeRuntimeProjection({ - sessionId: "session-1", - activeToolCalls: [], - backgroundTasks: [], - restoredBackgroundTasks: [{ - taskId: "agent-3", - taskKind: "local_agent", - agentId: "agent-3", - status: "completed", - description: "Finished while ZCode was closed" - }] - }); - - expect(snapshot?.restoredBackgroundTasks).toHaveLength(1); - expect(snapshot?.restoredBackgroundTasks?.[0]).toMatchObject({ - taskId: "agent-3", - status: "completed" - }); - expect(normalizeRuntimeProjection({ - sessionId: "session-1", - activeToolCalls: [], - backgroundJobs: [] - })?.restoredBackgroundTasks).toEqual([]); - }); - - test("does not let undefined registry fields erase persisted task fields", () => { - const snapshot = normalizeRuntimeProjection({ - sessionId: "session-1", - activeToolCalls: [], - backgroundTasks: [{ - taskId: "agent-3", - taskKind: "local_agent", - status: "failed", - childSessionId: "child-3", - description: "Persisted task" - }], - backgroundTaskDetails: [{ - taskId: "agent-3", - taskKind: "local_agent", - status: "running", - childSessionId: undefined - }] - }); - - expect(snapshot?.backgroundJobs[0]).toMatchObject({ - taskId: "agent-3", - status: "running", - childSessionId: "child-3", - description: "Persisted task" - }); - }); - - test("keeps the runtime killed status visible after an agent stop", () => { - const snapshot = normalizeRuntimeProjection({ - sessionId: "session-1", - activeToolCalls: [], - backgroundTasks: [{ - taskId: "agent-killed", - taskKind: "local_agent", - status: "killed", - childSessionId: "child-killed" - }] - }); - - expect(snapshot?.backgroundJobs[0]).toMatchObject({ - taskId: "agent-killed", - taskKind: "local_agent", - status: "killed", - childSessionId: "child-killed" - }); - }); - - test("preserves protocol context breakdown and cache usage", () => { - const snapshot = normalizeRuntimeProjection({ - projection: { - sessionId: "session-2", - contextUsed: 2_000, - contextWindow: 10_000, - activeToolCalls: [], - backgroundJobs: [] - }, - runtime: { - contextUsage: { - used: 2_100, - size: 10_000, - cost: { amount: 0.12, currency: "USD" }, - cache: { - inputTokens: 2_100, - cacheReadTokens: 800, - cacheWriteTokens: 50, - hitRate: 0.38, - hitRateRequestCount: 3, - totalInputTokens: 6_000, - totalCacheReadTokens: 2_000, - totalCacheWriteTokens: 100 - }, - breakdown: [ - { source: "system_prompt", chars: 2_000 }, - { source: "messages", chars: 6_400 }, - { source: "invalid", chars: 900 } - ] - } - } - }); - - expect(snapshot?.contextUsage).toEqual({ - used: 2_100, - size: 10_000, - cost: { amount: 0.12, currency: "USD" }, - cache: { - inputTokens: 2_100, - cacheReadTokens: 800, - cacheWriteTokens: 50, - latestHitRate: undefined, - hitRate: 0.38, - hitRateRequestCount: 3, - totalInputTokens: 6_000, - totalCacheReadTokens: 2_000, - totalCacheWriteTokens: 100 - }, - breakdown: [ - { source: "system_prompt", chars: 2_000 }, - { source: "messages", chars: 6_400 } - ] - }); - }); - - test("enriches cache usage from persisted step-finish parts without runtime symbols", () => { - const projection = normalizeRuntimeProjection({ - sessionId: "session-legacy", - contextUsed: 2_000, - contextWindow: 10_000, - activeToolCalls: [], - backgroundJobs: [] - }); - const enriched = mergeProjectionContextCache(projection, [{ - info: { - id: "assistant-1", - role: "assistant", - tokens: { input: 0, cache: { read: 0, write: 0 } } - }, - parts: [{ - type: "step-finish", - tokens: { input: 1_000, cache: { read: 900, write: 25 } } - }] - }]); - - expect(enriched?.contextUsage).toMatchObject({ - used: 2_000, - size: 10_000, - cache: { - inputTokens: 1_000, - cacheReadTokens: 900, - cacheWriteTokens: 25, - latestHitRate: 0.9, - hitRate: 0.9, - hitRateRequestCount: 1, - totalInputTokens: 1_000, - totalCacheReadTokens: 900, - totalCacheWriteTokens: 25 - } - }); - }); - - test("normalizes todos and official todo groups", () => { - const todos = normalizeTodos({ - todos: [ - { content: "Implement projection", status: "in_progress", priority: "high" }, - { content: "Run tests", status: "pending", priority: "low" }, - { content: "Ignore invalid", status: "unknown", priority: "low" } - ] - }); - expect(todos).toHaveLength(2); - expect(todos[0]?.priority).toBe("high"); - - expect(normalizeTodoGroups({ - todoGroups: [{ - id: "goal-2", - source: "goal_iteration", - goalIteration: 2, - targetId: "target-1", - todos - }] - })).toEqual([{ - id: "goal-2", - source: "goal_iteration", - goalIteration: 2, - targetId: "target-1", - startedAt: undefined, - updatedAt: undefined, - todos - }]); - }); -}); diff --git a/test/runtime-provider-env-patches.test.ts b/test/runtime-provider-env-patches.test.ts deleted file mode 100644 index 575f5669..00000000 --- a/test/runtime-provider-env-patches.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { hasRuntimeBuiltinProviderEnvRecovery, patchRuntimeBuiltinProviderEnvRecovery, unpatchRuntimeBuiltinProviderEnvRecovery } from "../scripts/runtime-provider-env-patches.ts"; - -const source = [ - 'var active="ZCODE_BUILTIN_PROVIDER_CONFIG_FILE",bundled="ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE",personal="ZCODE_PERSONAL_PROVIDER_CONFIG_FILE";', - 'async function candidate(path){return read(path)}label(candidate,"readReleaseCandidate");', - 'async function discover(options){return resolve(options)}label(discover,"resolveBundledZCodeBuiltinProviderConfig");', - 'async function prepare(options){if(!requires(options.argv))return{};let builtin=options.env[active]?.trim(),file=options.env[personal]?.trim(),root=options.dataBaseDir??options.env.ZCODE_DATA_BASE_DIR?.trim()??home();', - 'if(builtin&&file)return{[active]:builtin,[personal]:file};let catalog=builtin??await discover({dataBaseDir:root,entrypoint:options.entrypoint??process.argv[1],sea:options.sea??sea()}),user=file??join(root,"personal.json"),version=options.appVersion??"0.0.0-dev",platform=options.platform??"test",endpoint=origin(options.env),paths=cache({root,version,platform,endpoint}),instance=new Source({bundledFilePath:catalog,activeFilePath:paths.activeFilePath,watch:!1});', - 'try{await instance.read()}finally{instance.dispose()}return{[active]:paths.activeFilePath,[bundled]:catalog,[personal]:user}}label(prepare,"prepareCliProviderRuntimeEnv");' -].join(""); - -function fixture(releases: Record = { "/vendor/builtin.json": { revision: 1 } }, discovery: string | Error = "/vendor/builtin.json") { - const warnings: string[] = [], reads: string[] = [], sources: unknown[] = []; - const prepare = new Function("read", "resolve", "requires", "home", "join", "sea", "origin", "cache", "Source", "label", "process", - `${patchRuntimeBuiltinProviderEnvRecovery(source)};return prepare`)( - async (path: string) => { - reads.push(path); - const value = releases[path]; - return value instanceof Error ? { error: value } : value ? { release: value } : null; - }, - async () => { if (discovery instanceof Error) throw discovery; return discovery; }, - (argv: string[]) => argv[0] === "app-server", () => "/home", (...parts: string[]) => parts.join("/"), - () => undefined, () => "http://localhost", () => ({ activeFilePath: "/cache/active.json" }), - class { - constructor(options: unknown) { sources.push(options); } - async read() {} - dispose() {} - }, - () => {}, { argv: ["node", "/vendor/zcode.cjs"], stderr: { write: (message: string) => warnings.push(message) } } - ) as (options: { argv: string[]; env: Record }) => Promise>; - return { run: (env: Record = {}, argv = ["app-server"]) => prepare({ argv, env }), warnings, reads, sources }; -} - -describe("built-in provider environment recovery", () => { - test("is idempotent and verifies the complete patch", () => { - const patched = patchRuntimeBuiltinProviderEnvRecovery(source); - expect(hasRuntimeBuiltinProviderEnvRecovery(source)).toBe(false); - expect(hasRuntimeBuiltinProviderEnvRecovery(patched)).toBe(true); - expect(patchRuntimeBuiltinProviderEnvRecovery(patched)).toBe(patched); - }); - - test("restores the unpatched bootstrap without removing unrelated runtime changes", () => { - const baseline = `${source};const unrelatedPatch=true;`; - const patched = patchRuntimeBuiltinProviderEnvRecovery(baseline); - const restored = unpatchRuntimeBuiltinProviderEnvRecovery(patched); - expect(restored).toBe(baseline); - expect(hasRuntimeBuiltinProviderEnvRecovery(restored)).toBe(false); - expect(unpatchRuntimeBuiltinProviderEnvRecovery(restored)).toBe(restored); - expect(patchRuntimeBuiltinProviderEnvRecovery(restored)).toBe(patched); - }); - - test.each([undefined, "", " \t "])("discovers bundled config for an unset or blank override (%j)", async value => { - const host = fixture(); - const env: Record = value === undefined ? {} : { ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: value }; - expect(await host.run(env)).toEqual({ ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/cache/active.json", - ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE: "/vendor/builtin.json", ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: "/home/personal.json" }); - expect(host.warnings).toEqual([]); - }); - - test.each([undefined, new SyntaxError("secret fixture content"), new Error("invalid schema")])("recovers missing or invalid releases with a warning (%j)", async failure => { - const host = fixture({ "/vendor/builtin.json": { revision: 1 }, ...failure ? { "/stale.json": failure } : {} }); - const result = await host.run({ ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/stale.json", ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: "/custom/personal.json" }); - expect(result).toEqual({ ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/cache/active.json", - ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE: "/vendor/builtin.json", ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: "/custom/personal.json" }); - expect(host.sources).toHaveLength(1); - expect(host.warnings).toHaveLength(1); - expect(host.warnings[0]).toContain('ZCODE_BUILTIN_PROVIDER_CONFIG_FILE "/stale.json"'); - expect(host.warnings[0]).toContain('using bundled catalog "/vendor/builtin.json"'); - expect(host.warnings[0]).not.toContain("secret fixture content"); - }); - - test("preserves a valid custom override and the explicit-path fast path", async () => { - const host = fixture({ "/custom.json": { revision: 100 }, "/vendor/builtin.json": { revision: 1 } }); - const env = { ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: " /custom.json ", ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: " /personal.json " }; - expect(await host.run(env)).toEqual({ ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/custom.json", ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: "/personal.json" }); - expect(host.sources).toEqual([]); - expect(host.warnings).toEqual([]); - }); - - test("preserves a valid custom bundled input when the personal path is not set", async () => { - const host = fixture({ "/custom.json": { revision: 100 } }); - expect((await host.run({ ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/custom.json" })).ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE) - .toBe("/custom.json"); - expect(host.sources[0]).toMatchObject({ bundledFilePath: "/custom.json" }); - expect(host.warnings).toEqual([]); - }); - - test("reports unreadable overrides by error code without printing decoder details", async () => { - const host = fixture({ "/vendor/builtin.json": { revision: 1 }, "/unreadable.json": Object.assign(new Error("private data"), { code: "EACCES" }) }); - await host.run({ ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/unreadable.json" }); - expect(host.warnings[0]).toContain("(EACCES)"); - expect(host.warnings[0]).not.toContain("private data"); - }); - - test("does not introduce the explicit-path fast path when only the personal path is set", async () => { - const host = fixture(); - expect((await host.run({ ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: "/personal.json" })).ZCODE_BUILTIN_PROVIDER_CONFIG_FILE).toBe("/cache/active.json"); - expect(host.sources).toHaveLength(1); - }); - - test("prefers a valid explicit bundled fallback", async () => { - const host = fixture({ "/host/bundled.json": { revision: 2 } }, new Error("discovery unavailable")); - expect((await host.run({ ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/stale.json", ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE: "/host/bundled.json" })) - .ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE).toBe("/host/bundled.json"); - }); - - test("ignores an invalid explicit bundled fallback if discovery is healthy", async () => { - const host = fixture(); - expect((await host.run({ ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/stale.json", ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE: "/missing-bundled.json" })) - .ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE).toBe("/vendor/builtin.json"); - }); - - test.each(["/invalid-bundled.json", new Error("not packaged")])("does not replace an override without a validated fallback (%j)", async discovery => { - const host = fixture({}, discovery); - expect(await host.run({ ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/stale.json", ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: "/personal.json" })) - .toEqual({ ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/stale.json", ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: "/personal.json" }); - expect(host.warnings[0]).toContain("no valid bundled fallback"); - }); - - test("leaves commands without provider bootstrap untouched", async () => { - const host = fixture(); - expect(await host.run({ ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/stale.json" }, ["plugins"])).toEqual({}); - expect(host.reads).toEqual([]); - expect(host.warnings).toEqual([]); - }); - - test("rejects missing, ambiguous, changed and partially patched anchors", () => { - expect(() => patchRuntimeBuiltinProviderEnvRecovery(source.replace('label(candidate,"readReleaseCandidate");', ""))).toThrow("incompatible"); - expect(() => patchRuntimeBuiltinProviderEnvRecovery(source + 'label(candidate,"readReleaseCandidate");')).toThrow("incompatible"); - expect(() => patchRuntimeBuiltinProviderEnvRecovery(source.replace("?.trim()", ".trim()"))).toThrow("incompatible"); - const partial = patchRuntimeBuiltinProviderEnvRecovery(source).replace("using bundled catalog", "partial recovery"); - expect(hasRuntimeBuiltinProviderEnvRecovery(partial)).toBe(false); - expect(() => patchRuntimeBuiltinProviderEnvRecovery(partial)).toThrow("incompatible"); - expect(() => unpatchRuntimeBuiltinProviderEnvRecovery(partial)).toThrow("incompatible"); - }); -}); diff --git a/test/runtime-session-lifecycle-patches.test.ts b/test/runtime-session-lifecycle-patches.test.ts deleted file mode 100644 index a5256fd4..00000000 --- a/test/runtime-session-lifecycle-patches.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - hasRuntimeSessionLifecycle, patchRuntimeSessionLifecycle -} from "../scripts/runtime-session-lifecycle-patches.ts"; -import { runtimePatchPlan } from "../scripts/sync-runtime.ts"; -import { assertResumableSession, ensureSessionForTitle, readSessionResumeTarget } from "../src/runtime-session-lifecycle.ts"; - -function fixture(): string { - return `const label=fn=>fn; -async function nativeTitle(input){await this.sessionStore.updateSession({id:this.sessionId,title:input.title,titleSource:"custom"})} -label(nativeTitle,"setCustomSessionTitle"); -async function create(options){let owns=options.sessionStore===void 0,store=options.sessionStore??await open(config,timer),ready=true;return ready} -const facade={setCustomSessionTitle:label(async input=>context.runtime.setCustomSessionTitle({title:input.title,traceContext:input.traceContext??context.traceContext}),"setCustomSessionTitle")}; -const state={},view=value=>value,snapshot=value=>value;let current; -const replace=label(async factory=>{let previous=current,candidate=await factory();return current=candidate,previous&&previous!==candidate&&await close(previous),state.current=view(current,snapshot(state)),current},"replaceApp"); -const make=label(async factory=>{let candidate=await factory();return state.current=view(candidate,snapshot(state)),candidate},"createApp"); -const locale={locale:"en-US"},resumeOptions=context.resumeOptions??{},unused={},prepare=()=>context; -const handler=prepare(unused,unused,unused,resumeOptions,locale.locale,unused); -const shutdown={cleanup:label(async()=>{await handler.close?.()},"cleanup")}; -const run=()=>({started:true}),config={},timer={},open=async()=>context.ownedStore; -async function start(){try{return await run({loadStartupOptions:()=>({})})}finally{await shutdown.cleanup()}} -return {nativeTitle,facade,create,replace,make,start,read:()=>({current,visible:state.current})};`; -} - -function load(source: string, context: object, close: (app: unknown) => Promise = async () => {}) { - return new Function("require", "__dirname", "context", "close", source)( - (name: string) => name === "node:path" ? { join: (...parts: string[]) => parts.join("/") } - : { ensureSessionForTitle, assertResumableSession, readSessionResumeTarget }, - "/fixture", context, close - ); -} - -describe("session lifecycle runtime patch", () => { - test("is required, executable, idempotent, and fails closed on incompatible anchors", () => { - const patched = patchRuntimeSessionLifecycle(fixture()); - expect(hasRuntimeSessionLifecycle(patched)).toBeTrue(); - expect(patchRuntimeSessionLifecycle(patched)).toBe(patched); - expect(() => new Function(patched)).not.toThrow(); - expect(runtimePatchPlan.find(patch => patch.id === "session-lifecycle")?.requirement).toBe("required"); - expect(() => patchRuntimeSessionLifecycle("incompatible")).toThrow("missing or ambiguous"); - expect(() => patchRuntimeSessionLifecycle(fixture() + fixture())).toThrow("ambiguous"); - }); - - test("persists before a title write and skips the write after persistence failure", async () => { - const calls: string[] = []; - let session: object | null = null; - const runtime = { - sessionId: "session", rootTraceContext: {}, - sessionStore: { - getSession: async () => session, - updateSession: async () => { calls.push("title"); } - }, - ensureSessionPersistedForExternalActivity: async () => { calls.push("persist"); session = {}; } - }; - const api = load(patchRuntimeSessionLifecycle(fixture()), { runtime }); - await api.nativeTitle.call(runtime, { title: "chosen" }); - expect(calls).toEqual(["persist", "title"]); - expect(await api.facade.readSessionResumeTarget()).toBe("session"); - runtime.ensureSessionPersistedForExternalActivity = async () => { throw new Error("Write failed"); }; - await expect(api.nativeTitle.call(runtime, { title: "failed" })).rejects.toThrow("Write failed"); - expect(calls).toEqual(["persist", "title"]); - }); - - test("validates a replacement before publishing it or closing the previous app", async () => { - const closed: unknown[] = []; - const api = load(patchRuntimeSessionLifecycle(fixture()), {}, async app => { closed.push(app); }); - const previous = { readExecutionState: async () => ({ mode: "build" }) }; - await api.replace(() => api.make(async () => previous)); - const invalid = { readExecutionState: async () => { throw new Error("Session not found: missing"); } }; - await expect(api.replace(() => api.make(async () => invalid))).rejects.toThrow("Session not found: missing"); - expect(api.read()).toEqual({ current: previous, visible: previous }); - expect(closed).toEqual([invalid]); - }); - - test("checks a resumed target without creating a replacement session", async () => { - const api = load(patchRuntimeSessionLifecycle(fixture()), {}); - const store = { getSession: async () => null }; - await expect(api.create({ resume: true, sessionId: "missing", sessionStore: store })).rejects.toThrow("Session not found: missing"); - expect(await api.create({ sessionStore: store })).toBeTrue(); - }); - - test("closes an owned store when startup resume validation fails", async () => { - let closed = false; - const api = load(patchRuntimeSessionLifecycle(fixture()), { ownedStore: { - getSession: async () => null, close: async () => { closed = true; } - } }); - await expect(api.create({ resume: true, sessionId: "missing" })).rejects.toThrow("Session not found: missing"); - expect(closed).toBeTrue(); - }); - - test("gates only resume startup and cleans up without constructing a broken TUI", async () => { - const calls: string[] = []; - const context = { - resumeOptions: { resumeSessionId: "missing" }, - readExecutionState: async () => { calls.push("validate"); throw new Error("Session not found: missing"); }, - close: async () => { calls.push("close"); } - }; - const api = load(patchRuntimeSessionLifecycle(fixture()), context); - await expect(api.start()).rejects.toThrow("Session not found: missing"); - expect(calls).toEqual(["validate", "close"]); - const fresh = load(patchRuntimeSessionLifecycle(fixture()), { ...context, resumeOptions: {} }); - expect(await fresh.start()).toEqual({ started: true }); - expect(calls).toEqual(["validate", "close", "close"]); - }); -}); diff --git a/test/runtime-session-lifecycle.test.ts b/test/runtime-session-lifecycle.test.ts deleted file mode 100644 index fccfa5e2..00000000 --- a/test/runtime-session-lifecycle.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - assertResumableSession, ensureSessionForTitle, readSessionResumeTarget, - type SessionLifecycleRuntime -} from "../src/runtime-session-lifecycle.ts"; - -describe("runtime session lifecycle", () => { - test("checks real storage without persisting an empty session", async () => { - let persisted = false; - const store = { getSession: async () => persisted ? { time: { created: 1 } } : null }; - const runtime = { - sessionId: "empty", sessionStore: store, - ensureSessionPersistedForExternalActivity: async () => { persisted = true; } - }; - expect(await readSessionResumeTarget({ runtime })).toBeUndefined(); - expect(persisted).toBeFalse(); - await expect(assertResumableSession(store, "empty")).rejects.toThrow("Session not found: empty"); - await ensureSessionForTitle(runtime, { title: "Chosen title" }); - expect(await readSessionResumeTarget({ runtime })).toBe("empty"); - }); - - test("uses the native persistence entry and preserves explicit trace context", async () => { - const calls: unknown[] = []; - const rootTraceContext = { traceId: "root" }; - const runtime = { - sessionId: "session", rootTraceContext, - sessionStore: { getSession: async () => ({}) }, - async ensureSessionPersistedForExternalActivity(title: string, options: unknown) { - expect(this).toBe(runtime); - calls.push({ title, options }); - } - }; - await ensureSessionForTitle(runtime, { title: "First title" }); - await ensureSessionForTitle(runtime, { title: "Second title", traceContext: { traceId: "explicit" } }); - expect(calls).toEqual([ - { title: "First title", options: { traceContext: rootTraceContext } }, - { title: "Second title", options: { traceContext: { traceId: "explicit" } } } - ]); - }); - - test("does not treat deleted, missing, or unreadable sessions as resumable", async () => { - const store = { getSession: async () => ({ time: { deleted: 1 } }) }; - expect(await readSessionResumeTarget({ sessionId: "deleted", sessionStore: store })).toBeUndefined(); - await expect(assertResumableSession(store, "deleted")).rejects.toThrow("Session not found: deleted"); - expect(await readSessionResumeTarget({ sessionId: "id-only" })).toBeUndefined(); - await expect(readSessionResumeTarget({ sessionId: "broken", sessionStore: { - getSession: async () => { throw new Error("Store unreadable"); } - } })).rejects.toThrow("Store unreadable"); - }); - - test("propagates persistence failures and rejects a persistence no-op", async () => { - const runtime: SessionLifecycleRuntime = { - sessionId: "failed", sessionStore: { getSession: async () => null }, - ensureSessionPersistedForExternalActivity: async () => { throw new Error("Write failed"); } - }; - await expect(ensureSessionForTitle(runtime, { title: "title" })).rejects.toThrow("Write failed"); - runtime.ensureSessionPersistedForExternalActivity = async () => {}; - await expect(ensureSessionForTitle(runtime, { title: "title" })).rejects.toThrow("Session not found: failed"); - await expect(ensureSessionForTitle({}, { title: "title" })).rejects.toThrow("persistence is unavailable"); - }); -}); diff --git a/test/runtime-sqlite-patches.test.ts b/test/runtime-sqlite-patches.test.ts deleted file mode 100644 index 5fce7428..00000000 --- a/test/runtime-sqlite-patches.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - hasRuntimeSqliteBusyTimeout, - hasRuntimeSqliteWriteRecovery, - patchRuntimeSqliteBusyTimeout, - patchRuntimeSqliteWriteRecovery -} from "../scripts/runtime-sqlite-patches.ts"; - -function fixture(): string { - return [ - 'const retention=30*86400000;class Store{static async open(t={},n={}){let dbPath="test.sqlite",timeout=5000;const db=t.db;', - 'try{let migration=await migrateAsync(db,dbPath,n??{lockWaitTimeoutMs:timeout});return new Store(db,dbPath,t,migration)}catch(e){try{db.close()}catch{}throw e}}', - 'constructor(db,dbPath,t,migration){this.db=db;this.dbPath=dbPath}', - 'close(){this.db.close()}}', - 'async function prune(db,options={}){let cutoff=options.beforeTime??Date.now()-retention;db.exec("begin immediate");try{', - 'db.prepare("delete from model_usage where started_at < ?").run(cutoff),', - 'db.prepare("delete from turn_usage where started_at < ?").run(cutoff),', - 'db.prepare("delete from tool_usage where started_at < ?").run(cutoff),db.exec("commit")', - '}catch(error){throw db.exec("rollback"),error}}label(prune,"pruneUsage");' - ].join(""); -} - -describe("SQLite recovery bundle boundaries", () => { - test("installs after the single migration open and preserves the native cleanup cutoff and transaction", async () => { - const source = patchRuntimeSqliteBusyTimeout(fixture()); - const patched = patchRuntimeSqliteWriteRecovery(source); - expect(hasRuntimeSqliteBusyTimeout(patched)).toBe(true); - expect(hasRuntimeSqliteWriteRecovery(patched)).toBe(true); - expect(patchRuntimeSqliteWriteRecovery(patched)).toBe(patched); - expect(patchRuntimeSqliteBusyTimeout(patched)).toBe(patched); - const events: unknown[] = []; - const db = { - exec(sql: string) { events.push(sql); }, - prepare(sql: string) { return { run(value: unknown) { events.push([sql, value]); } }; }, - close() { events.push("close"); } - }; - const helper = { - installSqliteWriteRecovery(store: { db: unknown }) { expect(store.db).toBe(db); events.push("install"); }, - pruneSqliteUsage(connection: unknown, options: unknown, cutoff: number, run: () => void) { - expect(connection).toBe(db); - events.push(["cleanup", options, cutoff]); - run(); - } - }; - const run = new Function("require", "__dirname", "migrateAsync", "label", `${patched};return {Store,prune};`)( - (name: string) => name === "node:path" ? { join: (...parts: string[]) => parts.join("/") } : helper, - "/fixture", async () => { events.push("async"); }, () => {} - ); - await run.Store.open({ db }); - expect(events).toEqual(["async", "pragma busy_timeout = 10000", "install"]); - events.length = 0; - await run.prune(db, { beforeTime: 42 }); - expect(events).toEqual([ - ["cleanup", { beforeTime: 42 }, 42], "begin immediate", - ["delete from model_usage where started_at < ?", 42], - ["delete from turn_usage where started_at < ?", 42], - ["delete from tool_usage where started_at < ?", 42], "commit" - ]); - }); - - test("rejects partial patches and unknown cleanup semantics", () => { - const source = patchRuntimeSqliteBusyTimeout(fixture()); - expect(hasRuntimeSqliteWriteRecovery(source)).toBe(false); - expect(() => patchRuntimeSqliteWriteRecovery(fixture())).toThrow("post-migration"); - expect(() => patchRuntimeSqliteWriteRecovery(source + source)).toThrow(); - expect(() => patchRuntimeSqliteWriteRecovery(source.replace('"pruneUsage"', '"renamed"'))).toThrow("symbol"); - expect(() => patchRuntimeSqliteWriteRecovery(source.replace("delete from turn_usage", "delete from different_table"))).toThrow("SQL changed"); - const patched = patchRuntimeSqliteWriteRecovery(source); - const partial = patched.replace(".installSqliteWriteRecovery($zStore)", ".other($zStore)"); - expect(hasRuntimeSqliteWriteRecovery(partial)).toBe(false); - expect(() => patchRuntimeSqliteWriteRecovery(partial)).toThrow("partial"); - }); -}); diff --git a/test/runtime-sqlite-usage.test.ts b/test/runtime-sqlite-usage.test.ts deleted file mode 100644 index 99dd8f7d..00000000 --- a/test/runtime-sqlite-usage.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, test } from "bun:test"; -import { isSqliteBusyError } from "../src/runtime-sqlite-connection.ts"; -import { pruneSqliteUsage } from "../src/runtime-sqlite-usage.ts"; - -test("only the SQLite BUSY family is retryable", () => { - for (const errcode of [5, 261, 517, 773]) expect(isSqliteBusyError({ errcode })).toBe(true); - for (const errcode of [6, 19, 787, 10, "5", undefined, NaN, 5.1]) expect(isSqliteBusyError({ errcode })).toBe(false); - expect(isSqliteBusyError(new Error("database is locked"))).toBe(false); -}); - -test("automatic cleanup skips empty tables, coalesces checks, and preserves explicit requests", () => { - let checks = 0, cleanups = 0; - const db = { isTransaction: false, exec() {}, prepare() { return { get() { checks++; return { expired: 0 }; } }; } }; - const prune = () => { cleanups++; }; - for (let i = 0; i < 100; i++) pruneSqliteUsage(db, {}, 10, prune); - expect(checks).toBe(1); - expect(cleanups).toBe(0); - pruneSqliteUsage(db, { beforeTime: 20 }, 20, prune); - expect(cleanups).toBe(1); -}); - -test("maintenance BUSY is deferred, but transaction and non-BUSY failures remain visible", () => { - const busy = Object.assign(new Error("database is locked"), { errcode: 5 }); - const createDb = () => ({ isTransaction: false, exec() {}, prepare() { return { get() { return { expired: 1 }; } }; } }); - const db = createDb(); - let calls = 0; - const fail = () => { calls++; throw busy; }; - expect(() => pruneSqliteUsage(db, {}, 10, fail)).not.toThrow(); - pruneSqliteUsage(db, {}, 10, fail); - expect(calls).toBe(1); - expect(() => pruneSqliteUsage(db, { beforeTime: 10 }, 10, fail)).toThrow(busy); - const io = Object.assign(new Error("disk I/O error"), { errcode: 10 }); - expect(() => pruneSqliteUsage(createDb(), {}, 10, () => { throw io; })).toThrow(io); - const open = createDb(); - expect(() => pruneSqliteUsage(open, {}, 10, () => { open.isTransaction = true; throw busy; })).toThrow(busy); -}); diff --git a/test/runtime-test-env.test.ts b/test/runtime-test-env.test.ts deleted file mode 100644 index 66cd1719..00000000 --- a/test/runtime-test-env.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, test } from "bun:test"; -import { join, resolve } from "node:path"; -import { runtimeTestEnv } from "../scripts/runtime-test-env.ts"; - -test("isolates runtime configuration while selecting the package under test", () => { - const testHome = resolve("fixture-home"), installedPackage = resolve("installed-package"); - const inherited = { - HOME: "/parent", USERPROFILE: "/parent", PATH: "fixture-path", TERM: "xterm", - ZCODE_NODE: "/parent/node", ZCODE_DATA_BASE_DIR: "/parent/data", - ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: "/parent/providers.json", - ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: "/parent/builtin.json", - ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE: "/parent/bundled.json", - ZCODE_CLI_MIGRATE_CONFIG: "1", ZCODE_TUI_MODE: "fullscreen" - }; - const env = runtimeTestEnv(testHome, installedPackage, inherited); - expect(env).toMatchObject({ - HOME: testHome, USERPROFILE: testHome, ZCODE_DATA_BASE_DIR: testHome, - PATH: inherited.PATH, TERM: inherited.TERM, - ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: join(testHome, ".zcode/v2/provider_config.json"), - ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: join(installedPackage, "vendor/provider/zcode-builtin.json"), - ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE: "" - }); - expect(env.ZCODE_NODE).toBeUndefined(); - expect(env.ZCODE_CLI_MIGRATE_CONFIG).toBeUndefined(); - expect(env.ZCODE_TUI_MODE).toBeUndefined(); - expect(inherited.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE).toBe("/parent/providers.json"); -}); diff --git a/test/runtime-tui-bridge.test.ts b/test/runtime-tui-bridge.test.ts deleted file mode 100644 index 41ea7aa7..00000000 --- a/test/runtime-tui-bridge.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { expect, test } from "bun:test"; -import { fixtureSubagentQueries, subagentRestoreFixture } from "./fixtures/runtime-subagents.ts"; -import { restoreTuiBackgroundTasks } from "../src/runtime-background-restore.ts"; -import { readTuiRuntimeProjection, sendTuiBackgroundTaskMessage, type RuntimeTask, type RuntimeTuiApp } from "../src/runtime-tui-bridge.ts"; - -test("projection merges registry state without mutating persisted entries", async () => { - const original = { taskId: "agent", description: "Saved description", status: "failed", error: "Old failure", completedAt: 10 }; - const app: RuntimeTuiApp = { - readExecutionState: () => ({ mode: "edit", planEnabled: true }), - runtime: { - getProjection: () => ({ backgroundTasks: [original] }), - runtimeTaskRegistry: { all: () => ({ - agent: { taskId: "agent", type: "local_agent", status: "running", isBackgrounded: true }, - foreground: { taskId: "foreground", type: "local_agent", status: "running" } - }) } - } - }; - let restored = false; - const projection = await readTuiRuntimeProjection(app, { $zRestorePersistedBackgroundTasks: async () => { restored = true; } }); - expect(restored).toBeTrue(); - expect(projection).toMatchObject({ mode: "edit", planEnabled: true, backgroundTasks: [{ - taskId: "agent", description: "Saved description", status: "running", error: null, completedAt: null, cancellable: true - }] }); - expect(projection?.backgroundTaskDetails).toHaveLength(1); - expect(original.status).toBe("failed"); -}); - -test("task restart awaits stop and uses refreshed routing and trace data", async () => { - let task: RuntimeTask = { taskId: "agent", type: "local_agent", status: "running" }; - const calls: string[] = []; - let message: Record | undefined; - const app: RuntimeTuiApp = { - readExecutionState: () => ({}), - runtime: { - workingDirectory: "/workspace", - rootTraceContext: { traceId: "root" }, - runtimeTaskRegistry: { get: () => task }, - subagentPort: { - stopTask: async () => { - calls.push("stop"); - task = { ...task, status: "stopped", parentSessionId: "parent", parentToolCallId: "call", agentId: "restored" }; - }, - sendMessage: async (input) => { calls.push("send"); message = input; return { status: "success" }; } - } - } - }; - await sendTuiBackgroundTaskMessage(app, {}, { taskId: "agent", message: "x".repeat(21_000), summary: " Continue\n here ", restart: true }); - expect(calls).toEqual(["stop", "send"]); - expect(message).toMatchObject({ to: "restored", parentToolCallId: "call", sessionId: "parent", summary: "Continue here", trace: { traceId: "root" } }); - expect(String(message?.message)).toHaveLength(20_000); -}); - -test("task messaging retries restoration and rejects unsupported or invalid actions", async () => { - let task: RuntimeTask | undefined; - let restores = 0; - let sends = 0; - const app: RuntimeTuiApp = { - sessionId: "parent", readExecutionState: () => ({}), - runtime: { - runtimeTaskRegistry: { get: () => task }, - subagentPort: { sendMessage: async () => { sends++; return "sent"; } } - } - }; - const bridge = { $zRestorePersistedBackgroundTasks: async () => { - if (++restores === 2) task = { taskId: "agent", type: "local_agent", status: "stopped" }; - } }; - expect(await sendTuiBackgroundTaskMessage(app, bridge, { taskId: "agent", message: "Continue" })).toBe("sent"); - expect(restores).toBe(2); - await expect(sendTuiBackgroundTaskMessage(app, {}, { taskId: "agent", message: " " })).rejects.toThrow("Enter a message"); - task = { taskId: "agent", type: "local_agent", status: "running" }; - await expect(sendTuiBackgroundTaskMessage(app, {}, { taskId: "agent", message: "Continue", restart: true })).rejects.toThrow("restart is unavailable"); - task = { taskId: "agent", type: "local_bash", status: "running" }; - await expect(sendTuiBackgroundTaskMessage(app, {}, { taskId: "agent", message: "Continue" })).rejects.toThrow("not a local agent"); - expect(sends).toBe(1); -}); - -const restore = (app: RuntimeTuiApp) => restoreTuiBackgroundTasks(app, fixtureSubagentQueries); - -test("concurrent task sends wait for an in-flight session restoration", async () => { - const { app } = subagentRestoreFixture(); - let release!: () => void; - const gate = new Promise((resolve) => { release = resolve; }); - const getSession = app.runtime!.sessionStore!.getSession; - let reads = 0, sends = 0; - app.runtime!.sessionStore!.getSession = async (id) => { reads++; await gate; return getSession(id); }; - app.runtime!.subagentPort = { sendMessage: async () => { sends++; return "sent"; } }; - const restoration = restore(app); - const send = sendTuiBackgroundTaskMessage(app, { $zRestorePersistedBackgroundTasks: restore }, { - taskId: "agent", message: "Continue" - }); - await Promise.resolve(); - expect(sends).toBe(0); - release(); - await restoration; - expect(await send).toBe("sent"); - expect(sends).toBe(1); - expect(reads).toBe(2); -}); - -test("restores native subagent projections without reading the legacy transcript", async () => { - const { app, tasks, reminders } = subagentRestoreFixture(); - app.loadSessionTranscript = async () => { throw new Error("Legacy transcript must not be read"); }; - await restore(app); - expect(tasks.get("agent")).toMatchObject({ - childSessionId: "child", status: "completed", parentToolCallId: "call", agentType: "Explore", - parentSessionId: "parent", startedAt: 10, completedAt: 20 - }); - expect(reminders).toHaveLength(1); - await restore(app); - expect(reminders).toHaveLength(1); -}); - -test.each(["getSession", "messages", "events"])("retries a transient %s failure", async (operation) => { - const { app, tasks } = subagentRestoreFixture(); - let reads = 0; - if (operation === "events") { - app.runtime!.getSessionEventStore = () => ({ getEvents: async () => { - if (++reads === 1) throw new Error("database is locked"); - return []; - } }); - } else { - const store = app.runtime!.sessionStore!; - if (operation === "getSession") { - const getSession = store.getSession; - store.getSession = async (id) => { - if (++reads === 1) throw new Error("database is locked"); - return getSession(id); - }; - } else { - const messages = store.messages; - store.messages = async (input) => { - if (++reads === 1) throw new Error("database is locked"); - return messages(input); - }; - } - } - await restore(app); - expect(tasks.size).toBe(0); - expect(app.$zRestoredBackgroundTasksSession).toBeUndefined(); - await restore(app); - expect(tasks.size).toBe(1); -}); - -test.each(["foreign", "wrong-type", "missing"])("rejects %s child sessions before reading their content", async (kind) => { - const { app, sessions, tasks } = subagentRestoreFixture(); - if (kind === "missing") sessions.delete("child"); - else sessions.set("child", { id: "child", parentID: kind === "foreign" ? "other" : "parent", taskType: kind === "wrong-type" ? "main" : "subagent_child" }); - const messages = app.runtime!.sessionStore!.messages; - app.runtime!.sessionStore!.messages = async (input) => { - expect(input.sessionID).toBe("parent"); - return messages(input); - }; - await restore(app); - expect(tasks.size).toBe(0); -}); - -test("does not overwrite a task registered while storage was being read", async () => { - const { app, tasks, reminders } = subagentRestoreFixture(); - const live: RuntimeTask = { taskId: "agent", status: "running", description: "Live task" }; - app.runtime!.getSessionEventStore = () => ({ getEvents: async () => { tasks.set("agent", live); return []; } }); - await restore(app); - expect(tasks.get("agent")).toBe(live); - expect(reminders).toEqual([]); -}); - -test("discards restoration when navigation changes the active session", async () => { - const { app, tasks, reminders } = subagentRestoreFixture(); - const messages = app.runtime!.sessionStore!.messages; - app.runtime!.sessionStore!.messages = async (input) => { app.sessionId = "other"; return messages(input); }; - await restore(app); - expect(tasks.size).toBe(0); - expect(reminders).toEqual([]); - expect(app.$zRestoredBackgroundTasksSession).toBeUndefined(); -}); - -test("does not fall back when session observation is unavailable", async () => { - const { app, tasks } = subagentRestoreFixture(); - delete app.runtime!.sessionStore; - app.loadSessionTranscript = async () => { throw new Error("Legacy transcript must not be read"); }; - await restore(app); - expect(tasks.size).toBe(0); -}); diff --git a/test/runtime-workspace-path-patches.test.ts b/test/runtime-workspace-path-patches.test.ts deleted file mode 100644 index aec7b830..00000000 --- a/test/runtime-workspace-path-patches.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { expect, test } from "bun:test"; -import { - hasRuntimeRecursiveWorkspacePaths as has, - patchRuntimeRecursiveWorkspacePaths as patch -} from "../scripts/runtime-workspace-path-patches.ts"; -import { applyRuntimePatchPlan, runtimePatchPlan } from "../scripts/sync-runtime.ts"; - -const fixture = 'function provider(e){let fs=e.fileSystemPort??defaultFS(),root=(0,path.resolve)(e.workspaceDirectory);' - + 'return async request=>{let entries=(await fs.listDirectory({path:root},{signal:request.abortSignal})).entries;' - + 'return{items:entries.filter(entry=>!excluded.has(entry.name)),truncated:false}}}' - + 'label(provider,"createWorkspacePathSuggestionProvider");'; - -test("wraps the native callback idempotently and keeps its filesystem and exclusions", () => { - const patched = patch(fixture); - expect(has(fixture)).toBe(false); - expect(has(patched)).toBe(true); - expect(patch(patched)).toBe(patched); - expect(patched).toContain("{workspaceDirectory:root,fileSystemPort:fs,excludedDirectories:excluded}"); - expect(patched).toContain("entries.filter(entry=>!excluded.has(entry.name))"); - const renamed = fixture.replaceAll("provider", "$factory").replaceAll("root", "$root").replaceAll("fs", "$fs"); - expect(has(patch(renamed))).toBe(true); -}); - -test("rejects changed, missing and ambiguous native anchors and incomplete wrappers", () => { - for (const source of [ - "const runtime = {};", - `${fixture}label(other,"createWorkspacePathSuggestionProvider");`, - fixture.replace("function provider(e)", "function provider(e, other)"), - fixture.replace("e.fileSystemPort", "e.otherPort"), - fixture.replace(".listDirectory(", ".searchFiles("), - fixture.replace("excluded.has(entry.name)", "false"), - patch(fixture).replace("excludedDirectories:excluded", "excludedDirectories:other"), - patch(fixture).replace("withRecursiveWorkspacePathSuggestions", "wrongWrapper") - ]) { - expect(has(source)).toBe(false); - expect(() => patch(source)).toThrow("incompatible with recursive workspace paths"); - } -}); - -test("registers recursion as a required, verified runtime patch", () => { - const entry = runtimePatchPlan.find(item => item.id === "recursive-workspace-paths"); - expect(entry?.requirement).toBe("required"); - const first = applyRuntimePatchPlan(fixture, [entry!]); - expect(first.reports[0]?.status).toBe("applied"); - expect(applyRuntimePatchPlan(first.runtime, [entry!]).reports[0]?.status).toBe("already_present"); - expect(() => applyRuntimePatchPlan("incompatible runtime", [entry!])) - .toThrow("Required runtime patch recursive-workspace-paths failed"); -}); diff --git a/test/runtime-workspace-paths.test.ts b/test/runtime-workspace-paths.test.ts deleted file mode 100644 index 1362e902..00000000 --- a/test/runtime-workspace-paths.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative } from "node:path"; -import { withRecursiveWorkspacePathSuggestions } from "../src/runtime-workspace-paths.ts"; -import { WorkspaceAutocompleteProvider } from "../packages/zcode-tui/src/workspace-autocomplete.ts"; - -const directories: string[] = []; -afterEach(async () => { - await Promise.all(directories.splice(0).map(path => rm(path, { recursive: true, force: true }))); -}); - -async function fixture(files: string[]) { - const root = await mkdtemp(join(tmpdir(), "zcode-workspace-paths-")); - directories.push(root); - for (const file of files) { - await mkdir(dirname(join(root, file)), { recursive: true }); - await writeFile(join(root, file), ""); - } - const reads: string[] = []; - const fileSystemPort = { - async listDirectory({ path }: { path: string }) { - reads.push(relative(root, path) || "."); - const entries = await readdir(path, { withFileTypes: true }); - return { entries: entries.map(entry => ({ - name: entry.name, path: join(path, entry.name), - kind: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : "symlink" - })) }; - } - }; - const nativeRequests: unknown[] = []; - const options = { workspaceDirectory: root, fileSystemPort, excludedDirectories: new Set([".git", "node_modules"]) }; - const search = withRecursiveWorkspacePathSuggestions(async request => { - nativeRequests.push(request); - return { items: [], truncated: false }; - }, options); - return { root, search, reads, options, nativeRequests }; -} - -describe("recursive runtime workspace paths", () => { - test("finds the issue #240 files across directories and inserts the selected relative path", async () => { - const { root, search } = await fixture([ - "First_folder/one.py", "First_folder/two.py", "Second_folder/one.py", "Second_folder/three.py" - ]); - expect(await search({ token: "@on" })).toEqual({ - items: [ - { kind: "file", path: "First_folder/one.py" }, - { kind: "file", path: "Second_folder/one.py" }, - { kind: "directory", path: "Second_folder/" } - ], truncated: false - }); - const editor = new WorkspaceAutocompleteProvider([], root, search); - const input = "inspect @on"; - const suggestions = await editor.getSuggestions([input], 0, input.length, { signal: new AbortController().signal }); - expect(suggestions?.items[0]).toEqual({ value: "@First_folder/one.py", label: "one.py", description: "First_folder/one.py" }); - expect(editor.applyCompletion([input], 0, input.length, suggestions!.items[0]!, suggestions!.prefix).lines) - .toEqual(["inspect @First_folder/one.py "]); - }); - - test("ranks exact names before prefixes and substrings across the entire tree before limiting", async () => { - const { search } = await fixture(["z-last/stone.py", "z-last/one-extra.py", "a-first/deep/one.py", "one.py"]); - expect(await search({ token: "@ONE.PY", limit: 2 })).toEqual({ - items: [{ kind: "file", path: "one.py" }, { kind: "file", path: "a-first/deep/one.py" }], truncated: true - }); - expect((await search({ token: "@one", limit: 3 })).items.map(item => item.path)) - .toEqual(["one.py", "z-last/one-extra.py", "a-first/deep/one.py"]); - expect((await search({ token: "@missing" })).items).toEqual([]); - expect((await search({ token: "@one", limit: 0 })).items).toHaveLength(1); - expect((await search({ token: "@one", limit: Number.NaN })).items).toHaveLength(4); - }); - - test("keeps empty tokens and explicit paths on the native provider", async () => { - const { search, reads, nativeRequests } = await fixture(["src/one.py"]); - for (const token of ["@", "@src/", "@src/on", "@src\\on", "@../on", "@/tmp/on", "@C:\\tmp\\on", "@.."]) { - await search({ token, limit: 7 }); - } - expect(nativeRequests).toHaveLength(8); - expect(nativeRequests[2]).toEqual({ token: "@src/on", limit: 7 }); - expect(reads).toEqual([]); - }); - - test("searches beyond a full result list and multiple batches of directories", async () => { - const { search, reads } = await fixture([ - ...Array.from({ length: 65 }, (_, index) => `group-${index}/stone.ts`), - "a/deep/one.ts" - ]); - expect(await search({ token: "@one.ts", limit: 1 })).toEqual({ - items: [{ kind: "file", path: "a/deep/one.ts" }], truncated: true - }); - expect(reads).toHaveLength(68); - }); - - test("preserves hidden-directory rules, exclusions and symlink boundaries", async () => { - const { root, search, reads } = await fixture([ - "src/one.py", "src/.one.py", ".hidden/one.py", ".hidden/.one.py", "src/node_modules/one.py", ".git/.one.py" - ]); - await symlink(root, join(root, "cycle"), process.platform === "win32" ? "junction" : "dir"); - expect((await search({ token: "@one" })).items.map(item => item.path)).toEqual(["src/one.py"]); - expect(reads).not.toContain("cycle"); - expect(reads).not.toContain(".hidden"); - expect(reads).not.toContain(join("src", "node_modules")); - expect((await search({ token: "@.one" })).items.map(item => item.path)).toEqual([".hidden/.one.py", "src/.one.py"]); - expect(reads).not.toContain(".git"); - }); - - test("handles deep Unicode paths and quotes spaces through the actual TUI provider", async () => { - const deepPath = `${"nested/".repeat(40)}源码/my file.ts`; - const { root, search } = await fixture([deepPath]); - const editor = new WorkspaceAutocompleteProvider([], root, search); - const input = '@"my fi'; - const suggestions = await editor.getSuggestions([input], 0, input.length, { signal: new AbortController().signal }); - expect(suggestions?.items[0]?.value).toBe(`@"${deepPath}"`); - expect(editor.applyCompletion([input], 0, input.length, suggestions!.items[0]!, suggestions!.prefix).lines) - .toEqual([`@"${deepPath}" `]); - expect((await search({ token: "@源码" })).items[0]?.kind).toBe("directory"); - }); - - test("sees new and removed files on the next query", async () => { - const { root, search } = await fixture(["src/old.ts"]); - expect((await search({ token: "@new" })).items).toEqual([]); - await writeFile(join(root, "src/new.ts"), ""); - expect((await search({ token: "@new" })).items).toEqual([{ kind: "file", path: "src/new.ts" }]); - await rm(join(root, "src/new.ts")); - expect((await search({ token: "@new" })).items).toEqual([]); - }); - - test("skips failed directories and entries outside the workspace", async () => { - const { options, reads } = await fixture(["ok/one.py", "bad/one.py"]); - const listDirectory = options.fileSystemPort.listDirectory; - options.fileSystemPort.listDirectory = async request => { - if (request.path === join(options.workspaceDirectory, "bad")) throw new Error("EACCES"); - const result = await listDirectory(request); - result.entries.push({ name: "one.py", path: join(options.workspaceDirectory, "..", "one.py"), kind: "file" }); - return result; - }; - const search = withRecursiveWorkspacePathSuggestions(async () => { throw new Error("unexpected native query"); }, options); - expect((await search({ token: "@one" })).items).toEqual([{ kind: "file", path: "ok/one.py" }]); - expect(reads).toContain("ok"); - }); - - test("forwards cancellation and discards results without scheduling further directories", async () => { - const { options, reads } = await fixture(["src/deep/one.py"]); - const controller = new AbortController(); - const listDirectory = options.fileSystemPort.listDirectory; - let received: AbortSignal | undefined; - const search = withRecursiveWorkspacePathSuggestions(async () => ({ items: [], truncated: false }), { - ...options, - fileSystemPort: { async listDirectory(request, context) { - received = context?.signal; - const result = await listDirectory(request); - controller.abort(); - return result; - } } - }); - expect(await search({ token: "@one", abortSignal: controller.signal })).toEqual({ items: [], truncated: false }); - expect(received).toBe(controller.signal); - expect(reads).toEqual(["."]); - await search({ token: "@one", abortSignal: controller.signal }); - expect(reads).toEqual(["."]); - }); -}); diff --git a/test/runtime/bigmodel-login.test.ts b/test/runtime/bigmodel-login.test.ts deleted file mode 100644 index 992614ef..00000000 --- a/test/runtime/bigmodel-login.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; - -const root = join(import.meta.dir, "../.."); - -/** Use the real login, encrypted credential store and request-auth port in an isolated Node process. */ -async function probe(body: string): Promise { - const home = await mkdtemp(join(tmpdir(), "zcode-bigmodel-login-")); - try { - const builtinPath = join(home, "builtin.json"); - await writeFile(builtinPath, await readFile(join(root, "vendor/provider/zcode-builtin.json"))); - const script = ` - const fs = require("node:fs"), Module = require("node:module"), path = require("node:path"); - const file = ${JSON.stringify(join(root, "vendor/zcode.cjs"))}; - let source = fs.readFileSync(file, "utf8"); - const names = ["loginBigmodelCodingPlan", "configureCodingPlanApiKey", "createSharedZCodeCredentialStore", - "startProcessProviderRegistryRuntime", "parseClientSigningCredential", "buildLoginSelection", "openUrlInBrowser"]; - const symbols = names.map(name => { - const symbol = new RegExp('[A-Za-z_$][\\\\w$]*\\\\(([A-Za-z_$][\\\\w$]*),"' + name + '"\\\\)', 'u').exec(source); - if (!symbol) throw new Error("Missing native function: " + name); - const init = [...source.slice(0, symbol.index).matchAll(/([A-Za-z_$][\\w$]*)=[A-Za-z_$][\\w$]*\\(\\(\\)=>\\{/gu)].at(-1)?.[1]; - if (!init) throw new Error("Missing native initializer: " + name); - return { symbol: symbol[1], init }; - }); - const main = /async function [A-Za-z_$][\\w$]*\\(\\)\\{let [A-Za-z_$][\\w$]*=process\\.argv\\.slice\\(2\\);/u.exec(source); - if (!main) throw new Error("Missing native entry point"); - source = source.replace(main[0], main[0] + symbols.map(s => s.init + "();").join("") - + "await(async(" + names.join(",") + ")=>{" + ${JSON.stringify(body)} + "})(" + symbols.map(s => s.symbol).join(",") + ");return;"); - const runtime = new Module(file, module); - runtime.filename = file; - runtime.paths = Module._nodeModulePaths(path.dirname(file)); - runtime._compile(source, file); - `; - const child = Bun.spawn([Bun.which("node")!, "--eval", script], { - cwd: home, env: { ...runtimeTestEnv(home), ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: builtinPath }, - stdout: "pipe", stderr: "pipe" - }); - const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); - expect(code, stderr || stdout).toBe(0); - expect(stdout).toContain("BIGMODEL_LOGIN_OK"); - } finally { - await rm(home, { recursive: true, force: true }); - } -} - -test("BigModel OAuth completes native polling and stores OAuth credentials without a derived API key", async () => { - await probe(` - const assert = require("node:assert/strict"); - const fs = require("node:fs/promises"); - const env = {...process.env}; - const choices=buildLoginSelection("en").items; - assert.equal(choices.some(item=>item.id==="bigmodel-coding-plan"),true); - assert.equal(choices.find(item=>item.id==="bigmodel-coding-plan-api-key").input.mask,true); - let requested = false, materialRequests = 0, browserOpened = false; - const response=data=>({status:200,body:new TextEncoder().encode(JSON.stringify({code:0,data}))}); - const result = await loginBigmodelCodingPlan({env, pollToken:"fixture-poll-token", - openBrowser:authorizeUrl=>openUrlInBrowser(authorizeUrl,{platform:"win32",spawnProcess:(executable,args,options)=>{ - assert.equal(executable,"powershell.exe"); - assert.deepEqual(args.slice(0,3),["-NoProfile","-NonInteractive","-EncodedCommand"]); - const script=Buffer.from(args[3],"base64").toString("utf16le"); - const encodedUrl=/FromBase64String\\('([A-Za-z0-9+/=]+)'\\)/u.exec(script)?.[1]; - assert.ok(encodedUrl); - assert.equal(Buffer.from(encodedUrl,"base64").toString("utf8"),authorizeUrl); - assert.equal(options.windowsHide,true); - assert.equal(options.detached,false); - browserOpened=true; - const child=new (require("node:events").EventEmitter)(); - child.unref=()=>{}; - process.nextTick(()=>child.emit("spawn")); - return child; - }}), - onAuthorizeUrl:async({authorize_url,flow_id})=>{ - assert.equal(authorize_url,"https://bigmodel.test/authorize"); - assert.equal(flow_id,"fixture-flow"); - }, - apiKeyResolver:{resolveMaterial:async(input)=>{ - materialRequests++; - assert.equal(input.accessToken,"fixture-token"); - assert.equal(input.family,"bigmodel"); - return {organizationId:"org",projectId:"project",token:"temporary-project-token",apiKeyId:"fixture-id"}; - }}, - httpClient:{request:async(request)=>{ - const url=new URL(request.url); - assert.equal(url.origin,"https://zcode.z.ai"); - assert.equal(request.headers.Authorization,"Bearer fixture-poll-token"); - if(url.pathname==="/api/v1/oauth/cli/init"){ - assert.equal(request.method,"POST"); - assert.deepEqual(JSON.parse(new TextDecoder().decode(request.body)),{provider:"bigmodel"}); - return response({authorize_url:"https://bigmodel.test/authorize",flow_id:"fixture-flow",expires_at:Math.ceil(Date.now()/1000)+60,poll_interval_sec:1}); - } - if(url.pathname==="/api/v1/oauth/cli/poll/fixture-flow"){ - requested=true; - assert.equal(request.method,"GET"); - return response({status:"ready",token:"fixture-jwt",user:{user_id:"fixture-user",name:"Fixture"},bigmodel:{access_token:"fixture-token",refresh_token:"fixture-refresh"}}); - } - throw new Error("Unexpected login request: "+url.pathname); - }} - }); - assert.equal(requested,true); - assert.equal(browserOpened,true); - assert.equal(result.browser.opened,true); - assert.equal(materialRequests,1); - assert.equal(result.providerId,"bigmodel"); - const store=createSharedZCodeCredentialStore({env}); - const raw=await fs.readFile(store.filePath,"utf8"); - assert.equal(raw.includes("enc:v1:"),true); - assert.equal(raw.includes("fixture-id.fixture-secret"),false); - assert.equal(raw.includes("fixture-jwt"),false); - assert.equal(raw.includes("temporary-project-token"),false); - assert.equal(await store.load("zcodejwttoken"),"fixture-jwt"); - assert.equal(await store.load("oauth:bigmodel:refresh_token"),"fixture-refresh"); - const host=await startProcessProviderRegistryRuntime(env,{standalone:{}}); - try { - const providerId="account:bigmodel-individual-coding-plan"; - assert.equal(host.configuredDefaultModelSelection.providerId,providerId); - assert.equal(await store.load("account-provider:"+providerId+":auth-source"),"oauth"); - } finally {host.dispose();} - console.log("BIGMODEL_LOGIN_OK"); - `); -}, 15_000); - -test("a rejected BigModel polling initialization does not save credentials", async () => { - await probe(` - const assert=require("node:assert/strict"); - let saved=false; - await assert.rejects(loginBigmodelCodingPlan({env:process.env,noBrowser:true, - httpClient:{request:async()=>({status:401,body:new TextEncoder().encode(JSON.stringify({msg:"Expired code"}))})}, - credentialStore:{load:async()=>null,saveMany:async()=>{saved=true;}}, - apiKeyResolver:{resolveMaterial:async()=>{throw new Error("Resolved material after a rejected initialization");}} - }),/OAuth HTTP error 401/); - assert.equal(saved,false); - console.log("BIGMODEL_LOGIN_OK"); - `); -}, 15_000); - -test("manual Coding Plan setup decrypts stored credentials for signing and rejects a mismatched encryption secret", async () => { - await probe(` - const assert = require("node:assert/strict"), fs = require("node:fs/promises"); - for (const family of ["bigmodel","zai"]) { - const env={...process.env,ZCODE_CREDENTIAL_SECRET:"fixture-encryption-secret"}; - await configureCodingPlanApiKey({env,providerId:family,apiKey:"fixture-id.fixture-secret"}); - const store=createSharedZCodeCredentialStore({env}); - const raw=await fs.readFile(store.filePath,"utf8"); - assert.equal(raw.includes("fixture-id.fixture-secret"),false); - assert.equal(raw.includes("enc:v1:"),true); - const host=await startProcessProviderRegistryRuntime(env,{standalone:{}}); - try { - const providerId="account:"+family+"-individual-coding-plan"; - const auth=await host.providerRuntimeHeadersPort.refreshBeforeModelRequest({providerId,accountAccess:{mode:"individual-coding-plan",accountType:family}}); - assert.equal(auth.requestAuth.apiKey,"fixture-id.fixture-secret"); - assert.ok(parseClientSigningCredential(auth.requestAuth.apiKey)); - const wrongStore=createSharedZCodeCredentialStore({env:{...env,ZCODE_CREDENTIAL_SECRET:"wrong-secret"}}); - await assert.rejects(wrongStore.load("account-provider:"+providerId+":identity"),/Credential decrypt failed/); - } finally {host.dispose();} - } - console.log("BIGMODEL_LOGIN_OK"); - `); -}, 15_000); diff --git a/test/runtime/builtin-provider-env.test.ts b/test/runtime/builtin-provider-env.test.ts deleted file mode 100644 index 09f050eb..00000000 --- a/test/runtime/builtin-provider-env.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { hasRuntimeBuiltinProviderEnvRecovery, patchRuntimeBuiltinProviderEnvRecovery, unpatchRuntimeBuiltinProviderEnvRecovery } from "../../scripts/runtime-provider-env-patches.ts"; -import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; - -const root = fileURLToPath(new URL("../..", import.meta.url)); -const node = Bun.which("node"); -const endpoint = "http://127.0.0.1:9"; -let directory = "", packageRoot = "", builtinContent = ""; - -beforeAll(async () => { - directory = await mkdtemp(join(tmpdir(), "zcode-builtin-env-")); - packageRoot = join(directory, "package"); - const vendor = join(packageRoot, "vendor"); - await mkdir(join(vendor, "provider"), { recursive: true }); - for (const entry of await readdir(join(root, "vendor"))) { - if (entry !== "zcode.cjs" && entry !== "provider") { - await symlink(join(root, "vendor", entry), join(vendor, entry)); - } - } - const original = await readFile(join(root, "vendor/zcode.cjs"), "utf8"); - const baseline = unpatchRuntimeBuiltinProviderEnvRecovery(original); - const patched = patchRuntimeBuiltinProviderEnvRecovery(original); - expect(hasRuntimeBuiltinProviderEnvRecovery(baseline)).toBe(false); - if (patchRuntimeBuiltinProviderEnvRecovery(baseline) !== patched) { - throw new Error("Unpatched runtime fixture did not preserve the other runtime patches."); - } - builtinContent = await readFile(join(root, "vendor/provider/zcode-builtin.json"), "utf8"); - await writeFile(join(vendor, "zcode.cjs"), patched); - await writeFile(join(vendor, "unpatched.cjs"), baseline); - await writeFile(join(vendor, "provider/zcode-builtin.json"), builtinContent); - await writeFile(join(packageRoot, "package.json"), JSON.stringify({ type: "module", version: "0.0.0-test" })); - const build = await Bun.build({ entrypoints: [join(root, "bin/zcode.ts")], outdir: join(packageRoot, "bin"), target: "node", format: "esm" }); - if (!build.success) throw new Error(`Fixture launcher build failed: ${build.logs.join("\n")}`); -}); - -afterAll(async () => { - if (directory) await rm(directory, { recursive: true, force: true }); -}); - -async function homeEnv(personal = true) { - const home = await mkdtemp(join(directory, "home-")); - const env: NodeJS.ProcessEnv = { ...runtimeTestEnv(home, packageRoot), ZCODE_BASE_URL: endpoint }; - delete env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE; - delete env.ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE; - if (!personal) delete env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE; - return { home, env }; -} - -async function run(home: string, env: NodeJS.ProcessEnv, args = [join(packageRoot, "vendor/zcode.cjs"), "app-server"], rpc = true) { - if (!node) throw new Error("Node.js is required for built-in provider runtime tests."); - return await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { - const child = spawn(node, args, { cwd: home, env, stdio: "pipe", timeout: 8000, killSignal: "SIGKILL" }); - let stdout = "", stderr = ""; - child.once("error", reject); - child.stdin.on("error", () => {}); - child.stdout.on("data", chunk => { - stdout += chunk.toString(); - if (rpc && !child.stdin.writableEnded && stdout.split("\n").some(line => { - try { return JSON.parse(line).id === 1; } catch { return false; } - })) child.stdin.end(); - }); - child.stderr.on("data", chunk => { stderr += chunk.toString(); }); - child.once("close", code => resolve({ code, stdout, stderr })); - if (rpc) child.stdin.write(`${JSON.stringify({ id: 1, method: "session/list", params: {} })}\n`); - else child.stdin.end(); - }); -} - -async function snapshots(home: string) { - const providerRoot = join(home, ".zcode/v2/runtime/provider"); - const entries = await readdir(providerRoot, { recursive: true }).catch(() => []); - return entries.filter(entry => entry.endsWith("zcode-builtin.json")).map(entry => join(providerRoot, entry)); -} - -test.each([false, true])("recovers invalid builtin overrides with personal override=%j", async personal => { - for (const scenario of ["missing", "invalid-json", "invalid-schema"]) { - const { home, env } = await homeEnv(personal); - const override = join(home, "invalid.json"); - env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = override; - if (scenario !== "missing") { - await writeFile(override, scenario === "invalid-json" ? "{invalid-json" : "{}"); - } - const result = await run(home, env); - expect(result.code).toBe(0); - expect(result.stdout).toContain('"id":1'); - expect(result.stderr).toContain(`ZCODE_BUILTIN_PROVIDER_CONFIG_FILE ${JSON.stringify(override)}`); - expect(result.stderr).toContain("using bundled catalog"); - expect(result.stderr).not.toContain("均不可用"); - const active = await snapshots(home); - expect(active).toHaveLength(1); - expect(JSON.parse(await readFile(active[0]!, "utf8")).revision).toBe(JSON.parse(builtinContent).revision); - if (scenario !== "missing") expect(await readFile(override, "utf8")).toBe(scenario === "invalid-json" ? "{invalid-json" : "{}"); - } -}, 30000); - -test.each([undefined, "", " \t "])("uses discovery for unset or blank overrides without losing active initialization (%j)", async value => { - const { home, env } = await homeEnv(); - if (value !== undefined) env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = value; - env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE = " \t "; - const result = await run(home, env); - expect(result.code).toBe(0); - expect(result.stderr).not.toContain("ZCODE_BUILTIN_PROVIDER_CONFIG_FILE"); - expect(await snapshots(home)).toHaveLength(1); -}, 10000); - -test("preserves valid custom releases without overwriting their files", async () => { - const { home, env } = await homeEnv(); - const custom = { ...JSON.parse(builtinContent), revision: JSON.parse(builtinContent).revision + 10 }; - const content = JSON.stringify(custom); - const customPath = join(home, "custom.json"); - await writeFile(customPath, content); - env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = customPath; - const result = await run(home, env); - expect(result.code).toBe(0); - expect(result.stderr).not.toContain("using bundled catalog"); - expect(await readFile(customPath, "utf8")).toBe(content); - expect(await snapshots(home)).toEqual([]); -}, 10000); - -test("retains newer active releases when recovering a stale override", async () => { - const { home, env } = await homeEnv(); - const platform = `${process.platform}-${process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch}`; - const endpointKey = `endpoint-${createHash("sha256").update(endpoint).digest("hex").slice(0, 32)}`; - const activeRoot = join(home, ".zcode/v2/runtime/provider", platform, "0.0.0-dev", endpointKey); - await mkdir(activeRoot, { recursive: true }); - const activePath = join(activeRoot, "zcode-builtin.json"); - const newer = { ...JSON.parse(builtinContent), revision: JSON.parse(builtinContent).revision + 1 }; - await writeFile(activePath, JSON.stringify(newer)); - env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = join(home, "stale.json"); - const result = await run(home, env); - expect(result.code).toBe(0); - expect(JSON.parse(await readFile(activePath, "utf8")).revision).toBe(newer.revision); -}, 10000); - -test("fails with a diagnostic when neither override nor bundled fallback is usable", async () => { - const { home, env } = await homeEnv(); - const isolated = join(home, "runtime"); - await mkdir(isolated); - await writeFile(join(isolated, "zcode.cjs"), await readFile(join(packageRoot, "vendor/zcode.cjs"))); - env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = join(home, "stale.json"); - const result = await run(home, env, [join(isolated, "zcode.cjs"), "app-server"]); - expect(result.code).toBe(1); - expect(result.stderr).toContain("no valid bundled fallback"); -}, 10000); - -test("reproduces an unavailable builtin override failure with the unpatched runtime", async () => { - const { home, env } = await homeEnv(false); - env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = join(home, "stale.json"); - const control = await run(home, env, [join(packageRoot, "vendor/unpatched.cjs"), "app-server"]); - expect(control.code).toBe(1); - expect(control.stderr).toContain("均不可用"); -}, 10000); - -test("recovers the real launcher plugin-validation source path with one warning", async () => { - const { home, env } = await homeEnv(false); - env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = join(home, "stale.json"); - const marketplace = join(home, "marketplace.json"); - await writeFile(marketplace, JSON.stringify({ name: "builtin-env-fixture", plugins: [] })); - const fixed = await run(home, env, [join(packageRoot, "bin/zcode.js"), "plugins", "validate", "--source", marketplace], false); - expect(fixed.code).toBe(0); - expect(fixed.stdout).toContain("Plugin validation completed."); - expect(fixed.stderr.match(/using bundled catalog/gu)).toHaveLength(1); -}, 15000); - -test("warns once per command when plugin-coordinate validation starts multiple app-servers", async () => { - const { home, env } = await homeEnv(false); - const marketplace = join(home, "marketplace"); - const plugin = join(marketplace, "plugin"); - await mkdir(join(plugin, ".zcode-plugin"), { recursive: true }); - await mkdir(join(plugin, "skills", "probe"), { recursive: true }); - await writeFile(join(marketplace, "marketplace.json"), JSON.stringify({ - name: "builtin-env-fixture", pluginRoot: ".", - plugins: [{ name: "probe", source: "./plugin", version: "1.0.0", description: "Validation fixture" }] - })); - await writeFile(join(plugin, ".zcode-plugin", "plugin.json"), JSON.stringify({ - name: "probe", version: "1.0.0", description: "Validation fixture", skills: "skills" - })); - await writeFile(join(plugin, "skills", "probe", "SKILL.md"), - "---\nname: probe\ndescription: Validation fixture\n---\nValidation fixture.\n"); - const launcher = join(packageRoot, "bin/zcode.js"); - const added = await run(home, env, [launcher, "plugins", "marketplace", "add", marketplace, "--yes"], false); - expect(added.code).toBe(0); - expect(added.stderr).not.toContain("using bundled catalog"); - - env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = join(home, "stale.json"); - for (const json of [false, true]) { - const result = await run(home, env, - [launcher, "plugins", "validate", "probe@builtin-env-fixture", ...json ? ["--json"] : []], false); - expect(result.code).toBe(0); - if (json) expect(JSON.parse(result.stdout)).toMatchObject({ diagnostics: [] }); - else expect(result.stdout).toContain("Plugin validation completed."); - expect(result.stderr.match(/using bundled catalog/gu)).toHaveLength(1); - } -}, 30000); diff --git a/test/runtime/config-diagnostics.test.ts b/test/runtime/config-diagnostics.test.ts deleted file mode 100644 index 211624e1..00000000 --- a/test/runtime/config-diagnostics.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; - -const root = join(import.meta.dir, "../.."); - -async function fixture() { - const testHome = await mkdtemp(join(tmpdir(), "zcode-config-doctor-")); - const workspace = join(testHome, "workspace"); - await mkdir(workspace, { recursive: true }); - const settings = join(testHome, ".zcode", "cli", "setting.json"); - const put = async (file: string, value: unknown) => { - await mkdir(dirname(file), { recursive: true }); - await writeFile(file, JSON.stringify(value)); - }; - const run = async (args = ["doctor", "--json"]) => { - const child = Bun.spawn([Bun.which("node")!, join(root, "bin/zcode.js"), "--cwd", workspace, ...args], { - cwd: testHome, env: runtimeTestEnv(testHome), stdin: "ignore", stdout: "pipe", stderr: "pipe" - }); - const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); - return { code, stdout, stderr }; - }; - return { testHome, workspace, settings, put, run, close: () => rm(testHome, { recursive: true, force: true }) }; -} - -test("doctor checks a fresh profile without requiring login or creating settings and migration markers", async () => { - const f = await fixture(); - try { - const result = await f.run(); - expect(result.code, result.stderr).toBe(0); - const report = JSON.parse(result.stdout); - expect(report.cli.version).toBeString(); - expect(report.configuration.user).toMatchObject({ path: f.settings, status: "missing", mcpServerNames: [] }); - expect(report.configuration.mcp.servers).toEqual([]); - expect(await Bun.file(f.settings).exists()).toBe(false); - expect(await Bun.file(join(dirname(f.settings), "migrations", "settings-v1.json")).exists()).toBe(false); - } finally { await f.close(); } -}, 15000); - -test("doctor reads settings after migration, preserves native MCP precedence and never starts a server", async () => { - const f = await fixture(); - try { - const marker = join(f.testHome, "server-started"); - const http = { type: "http", url: "http://127.0.0.1:1/mcp?token=SECRET_VALUE", headers: { Authorization: "SECRET_VALUE" } }; - await f.put(f.settings, { mcp: { servers: { shared: http, local: { - type: "stdio", command: Bun.which("node")!, args: ["-e", `require('node:fs').writeFileSync(${JSON.stringify(marker)},'started')`], env: { TOKEN: "SECRET_VALUE" } - }, disabled: { ...http, enabled: false } } } }); - await f.put(join(dirname(f.settings), "migrations", "settings-v1.json"), { schemaVersion: 1 }); - await f.put(join(dirname(f.settings), "config.json"), { mcp: { servers: { legacy: http } } }); - await f.put(join(f.workspace, ".zcode", "config.json"), { mcp: { servers: { shared: { type: "stdio", command: "unused" }, project: http } } }); - await f.put(join(f.testHome, ".agents", "mcp.json"), { mcpServers: { userFallback: http } }); - await f.put(join(f.workspace, ".agents", "mcp.json"), { mcpServers: { projectFallback: http } }); - const before = await readFile(f.settings, "utf8"); - const result = await f.run(); - expect(result.code, result.stderr).toBe(0); - const report = JSON.parse(result.stdout).configuration; - expect(report.user.status).toBe("loaded"); - expect(report.project.status).toBe("loaded"); - expect(report.mcp.servers).toEqual([ - { name: "disabled", type: "http", enabled: false, source: "user" }, - { name: "local", type: "stdio", enabled: true, source: "user" }, - { name: "project", type: "http", enabled: true, source: "project" }, - { name: "shared", type: "http", enabled: true, source: "user" } - ]); - expect(report.legacy).toMatchObject({ exists: true, usage: "first-run-migration-only" }); - expect(result.stdout + result.stderr).not.toContain("SECRET_VALUE"); - expect(await Bun.file(marker).exists()).toBe(false); - expect(await readFile(f.settings, "utf8")).toBe(before); - const text = await f.run(["doctor"]); - expect(text.code).toBe(0); - expect(text.stdout).toContain("Configuration:"); - expect(text.stdout).toContain('"shared": http, user, enabled'); - expect(text.stdout).toContain("connections were not tested"); - } finally { await f.close(); } -}, 15000); - -test("doctor surfaces rejected settings and individual MCP entries without falling back to other files", async () => { - const f = await fixture(); - try { - const server = { type: "http", url: "http://127.0.0.1:1/mcp" }; - await f.put(join(f.testHome, ".agents", "mcp.json"), { mcpServers: { fallback: server } }); - await f.put(join(dirname(f.settings), "config.json"), { mcp: { servers: { legacy: server } } }); - await f.put(f.settings, { ui: { theme: "system" }, mcp: { servers: { valid: server } } }); - let result = await f.run(); - expect(result.code).toBe(1); - let report = JSON.parse(result.stdout).configuration; - expect(report.user.status).toBe("invalid"); - expect(report.mcp.servers).toEqual([]); - expect(report.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ - code: "config_file_invalid", filePath: f.settings, message: expect.stringContaining("ui.theme") - })])); - await f.put(f.settings, { mcp: { servers: { valid: server, bad: { type: "stdio" } } } }); - result = await f.run(); - expect(result.code).toBe(1); - report = JSON.parse(result.stdout).configuration; - expect(report.user.status).toBe("loaded"); - expect(report.mcp.servers.map((s: { name: string }) => s.name)).toEqual(["valid"]); - expect(report.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ - code: "config_mcp_server_invalid", path: "mcp.servers.bad", message: expect.stringContaining("command") - })])); - await writeFile(f.settings, '{"secret":"SECRET_VALUE", BROKEN'); - result = await f.run(); - expect(result.code).toBe(1); - report = JSON.parse(result.stdout).configuration; - expect(report.user.status).toBe("invalid"); - expect(result.stdout + result.stderr).not.toContain("SECRET_VALUE"); - expect(await readFile(f.settings, "utf8")).toContain("SECRET_VALUE"); - expect(await Bun.file(join(dirname(f.settings), "migrations", "settings-v1.json")).exists()).toBe(false); - await f.put(f.settings, { mcp: { servers: {} } }); - result = await f.run(); - expect(result.code).toBe(0); - expect(JSON.parse(result.stdout).configuration.mcp.servers).toEqual([]); - } finally { await f.close(); } -}, 15000); diff --git a/test/runtime/context-window-projection.test.ts b/test/runtime/context-window-projection.test.ts deleted file mode 100644 index 3df30b9b..00000000 --- a/test/runtime/context-window-projection.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { terminalOutputText as plainText } from "../../scripts/terminal-output.ts"; -import { expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { cliSettingsPath, ensureCliSettings } from "../../src/model-access.ts"; -import { writeProviderFixture } from "../fixtures/provider-config.ts"; -import { runtimeStartupTimeoutMs, runtimeTestEnv } from "../fixtures/runtime-env.ts"; - -const root = join(import.meta.dir, "../.."); -const node = Bun.which("node")!; -const configuredContextWindow = 1_000_000; -const fixtureReply = "CONTEXT_WINDOW_REPLY"; - - -/** A real runtime and session store; only the model HTTP endpoint is mocked. */ -async function fixture() { - const home = await mkdtemp(join(tmpdir(), "zcode-context-window-")); - const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { - if (new URL(request.url).pathname !== "/v1/chat/completions") return new Response(null, { status: 404 }); - const body = await request.json() as { model: string; stream?: boolean }; - if (!body.stream) return Response.json({ id: "fixture", object: "chat.completion", model: body.model, - choices: [{ index: 0, message: { role: "assistant", content: fixtureReply }, finish_reason: "stop" }] }); - const chunks = [{ role: "assistant", content: fixtureReply }, {}].map((delta, index) => - `data: ${JSON.stringify({ id: "fixture", object: "chat.completion.chunk", model: body.model, - choices: [{ index: 0, delta, finish_reason: index ? "stop": null }], - ...index ? { usage: { prompt_tokens: 1_234, completion_tokens: 5, total_tokens: 1_239 } } : {} })}\n\n` - ); - return new Response(`${chunks.join("")}data: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } }); - } }); - const env = runtimeTestEnv(home); - await ensureCliSettings(env); - const settings = JSON.parse(await readFile(cliSettingsPath(env), "utf8")); - settings.plugins.enabled = false; - settings.memory = { use: false, write: false, autoConsolidate: false }; - settings.features = { compact: false, rewind: false, subagent: false, memory: false, skill: false, mcp: false }; - settings.ui.locale = "en-US"; - await writeFile(cliSettingsPath(env), JSON.stringify(settings)); - await writeProviderFixture(env, { - providerId: "probe", modelId: "probe-1m", models: ["probe-1m"], contextWindow: configuredContextWindow, - apiKey: "fixture-key-not-real", baseUrl: `${server.url.origin}/v1` - }); - return { - home, env, - close: async () => { server.stop(true); await rm(home, { recursive: true, force: true }); } - }; -} - -/* #212: compaction and the request budget used the configured window while every - * host-visible projection kept the 200000 default. */ -test.skipIf(process.platform === "win32")( - "headless runs project the configured context window", - async () => { - const f = await fixture(); - try { - const child = Bun.spawn([node, join(root, "bin/zcode.js"), "--prompt", "Reply with the fixture text.", "--json"], { - cwd: f.home, env: f.env, stdin: "ignore", stdout: "pipe", stderr: "pipe" - }); - const [code, stdout, stderr] = await Promise.all([ - child.exited, new Response(child.stdout).text(), new Response(child.stderr).text() - ]); - expect(code, stderr || stdout).toBe(0); - const result = JSON.parse(stdout) as { - response?: string; - projection?: { contextUsed?: number; contextWindow?: number }; - }; - expect(result.response).toBe(fixtureReply); - expect(result.projection?.contextUsed).toBeGreaterThan(0); - expect(result.projection?.contextWindow).toBe(configuredContextWindow); - } finally { await f.close(); } - }, - 30_000 -); - -test.skipIf(process.platform === "win32")( - "the /context panel reports the configured context window", - async () => { - const f = await fixture(); - let output = ""; - const decoder = new TextDecoder(); - const terminal = new Bun.Terminal({ cols: 110, rows: 36, name: "xterm-256color", - data(_terminal, data) { output += decoder.decode(data, { stream: true }); } - }); - const child = Bun.spawn([node, join(root, "bin/zcode.js")], { - cwd: f.home, terminal, env: { ...f.env, TERM: "xterm-256color", CI: "0", ZCODE_TUI_MODE: "regular", ZCODE_NODE: node } - }); - const killTimer = setTimeout(() => child.kill("SIGKILL"), 35_000); - async function waitFor(pattern: RegExp, offset = 0, timeoutMs = 10_000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline && child.exitCode === null) { - if (pattern.test(plainText(output.slice(offset)))) return; - await Bun.sleep(20); - } - throw new Error(`Missing ${pattern}: ${plainText(output).slice(-2000)}`); - } - try { - await waitFor(/◈ probe\/probe-1m/, 0, runtimeStartupTimeoutMs); - const prompt = output.length; - terminal.write("Reply with the fixture text.\r"); - await waitFor(new RegExp(fixtureReply), prompt); - const panel = output.length; - terminal.write("/context\r"); - // 1,000,000 tokens render as "1M"; the unfixed projection rendered "200K". - await waitFor(/\/ 1M tokens/, panel); - expect(plainText(output.slice(panel))).not.toContain("/ 200K tokens"); - } finally { - clearTimeout(killTimer); - if (child.exitCode === null) child.kill("SIGTERM"); - await child.exited; - terminal.close(); - await f.close(); - } - }, - 40_000 -); diff --git a/test/runtime/environment-isolation.test.ts b/test/runtime/environment-isolation.test.ts deleted file mode 100644 index 8579fe37..00000000 --- a/test/runtime/environment-isolation.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect, test } from "bun:test"; -import { copyFile, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { providerFixture } from "../fixtures/provider-config.ts"; -import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; - -const root = join(import.meta.dir, "../.."); - -async function assertParentConfigPreserved(args: string[], timeoutMs: number): Promise { - const home = await mkdtemp(join(tmpdir(), "zcode-test-parent-")); - const personal = join(home, "provider_config.json"), builtin = join(home, "builtin.json"); - const contents = JSON.stringify(providerFixture({ providerId: "parent-provider", apiKey: "parent-fixture-key" })); - try { - await writeFile(personal, contents); - await copyFile(join(import.meta.dir, "../../vendor/provider/zcode-builtin.json"), builtin); - const builtinBefore = await readFile(builtin, "utf8"); - const child = Bun.spawn([process.execPath, ...args], { - cwd: home, - // Reproduce the absolute paths exported by the CLI into its Bash tools. - env: { ...runtimeTestEnv(home), ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: personal, - ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: builtin, ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE: builtin }, - stdin: "ignore", stdout: "pipe", stderr: "pipe" - }); - const timeout = setTimeout(() => child.kill("SIGKILL"), timeoutMs); - try { - const [code, stdout, stderr] = await Promise.all([ - child.exited, new Response(child.stdout).text(), new Response(child.stderr).text() - ]); - expect(code, stderr || stdout).toBe(0); - expect(await readFile(personal, "utf8")).toBe(contents); - expect(await readFile(builtin, "utf8")).toBe(builtinBefore); - expect(await readdir(home)).not.toContain(".zcode"); - } finally { - clearTimeout(timeout); - if (child.exitCode === null) { child.kill("SIGKILL"); await child.exited; } - } - } finally { - await rm(home, { recursive: true, force: true }); - } -} - -test("migration tests preserve the provider files inherited from a running ZCode session", async () => { - await assertParentConfigPreserved(["test", join(import.meta.dir, "config-migration.test.ts")], 20_000); -}, 25_000); - -test("runtime checks preserve inherited provider files and storage", async () => { - await assertParentConfigPreserved([join(root, "scripts/check-runtime.ts")], 20_000); -}, 25_000); - -test.skipIf(process.platform === "win32")("TUI smoke checks keep login changes inside their temporary home", async () => { - await assertParentConfigPreserved([join(root, "scripts/smoke-tui.ts")], 40_000); -}, 45_000); diff --git a/test/runtime/execution-state-tui.test.ts b/test/runtime/execution-state-tui.test.ts deleted file mode 100644 index 26ad3fbe..00000000 --- a/test/runtime/execution-state-tui.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { cliSettingsPath, ensureCliSettings } from "../../src/model-access.ts"; -import { requestAppServer } from "../../src/app-server-client.ts"; -import { writeProviderFixture } from "../fixtures/provider-config.ts"; -import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; -import { TerminalScreen } from "../tui/harness/terminal-screen.ts"; - -test.skipIf(process.platform === "win32").each(["regular", "fullscreen"])("Plan and permissions remain independent in %s TUI and after resume", async (display) => { - const home = await mkdtemp(join(tmpdir(), "zcode-execution-state-")); - const node = Bun.which("node")!; - const root = join(import.meta.dir, "../.."); - const env = { ...runtimeTestEnv(home), - ZCODE_TUI_MODE: display, ZCODE_NODE: node, TERM: "xterm-256color", CI: "0" }; - await ensureCliSettings(env); - const settings = JSON.parse(await readFile(cliSettingsPath(env), "utf8")); - settings.plugins.enabled = false; - settings.ui.locale = "en-US"; - settings.memory = { use: false, write: false }; - await writeFile(cliSettingsPath(env), JSON.stringify(settings)); - const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { - const body = await request.json() as { stream?: boolean }; - if (!body.stream) return Response.json({ choices: [{ message: { role: "assistant", content: "READY" }, finish_reason: "stop" }] }); - return new Response('data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"READY"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', - { headers: { "content-type": "text/event-stream" } }); - } }); - const { path: providerPath } = await writeProviderFixture(env, { apiKey: "fixture-key", modelId: "fixture", baseUrl: `${server.url.origin}/v1` }); - const providerBefore = await readFile(providerPath, "utf8"); - using screen = new TerminalScreen(80, 30); - let output = ""; - const decoder = new TextDecoder(); - const terminal = new Bun.Terminal({ cols: 80, rows: 30, name: "xterm-256color", data(_terminal, data) { - output += decoder.decode(data, { stream: true }); - void screen.write(data); - } }); - const start = (args: string[] = []) => Bun.spawn([node, join(root, "bin/zcode.js"), ...args], { cwd: home, terminal, env }); - let child = start(); - const deadline = setTimeout(() => child.kill("SIGKILL"), 40_000); - const wait = async (predicate: (text: string) => boolean) => { - const until = Date.now() + 7_000; - while (Date.now() < until && child.exitCode === null) { - await screen.settled(); - if (predicate(screen.screenText())) return; - await Bun.sleep(25); - } - throw new Error(`Execution state did not settle:\n${screen.screenText()}\n${output.slice(-2000)}`); - }; - const state = (mode: string, plan: boolean) => (text: string) => { - const lines = text.split("\n"); - // Use the live editor border, not an earlier command/notice in scrollback. - const status = lines.findLastIndex(line => line.includes("◈ custom/fixture") && line.includes(`◉ ${mode}`)); - return status >= 3 && /─/.test(lines[status - 1]!) - && / Plan ──$/.test(lines[status - 3]!.trimEnd()) === plan; - }; - try { - await wait(state("build", false)); - terminal.write("Prepare a session for the execution-state test.\r"); - await wait(text => text.includes("READY") && state("build", false)(text)); - await Bun.sleep(150); - terminal.write("/plan\r"); - await wait(state("build", true)); - expect(screen.screenText()).toContain("Research and plan before making changes"); - terminal.write("/mode\r"); - await wait(text => text.includes("Select mode")); - const picker = screen.screenText().split("Select mode").at(-1)!; - expect(picker).toContain("Build"); - expect(picker).toContain("Edit"); - expect(picker).toContain("Guarded"); - expect(picker).toContain("Yolo"); - expect(picker).not.toMatch(/\bAuto\b/); - terminal.write("\x1b"); - await wait(state("build", true)); - terminal.write("\x1b[Z"); - await wait(state("edit", true)); - terminal.write("\x1b[Z"); - await wait(state("guarded", true)); - terminal.write("\x1b[Z"); - await wait(state("yolo", true)); - terminal.write("\x1b[Z"); - await wait(state("build", true)); - terminal.write("/mode guarded\r"); - await wait(state("guarded", true)); - terminal.write("/plan\r"); - await wait(state("guarded", false)); - terminal.write("/plan\r"); - await wait(state("guarded", true)); - const sessions = await requestAppServer({ method: "session/list", params: {}, transport: { - command: node, args: [join(root, "vendor/zcode.cjs"), "app-server"], cwd: home, env - } }) as { sessions: { sessionId: string }[] }; - const sessionId = sessions.sessions[0]?.sessionId; - expect(sessionId).toBeString(); - terminal.write("/new\r"); - await wait(state("guarded", false)); - terminal.write("/mode yolo\r"); - await wait(state("yolo", false)); - terminal.write(`/resume ${sessionId}\r`); - await wait(state("guarded", true)); - child.kill("SIGTERM"); - await child.exited; - await screen.write("\x1b[2J\x1b[H"); - child = start(["--resume", sessionId!]); - await wait(state("guarded", true)); - terminal.resize(40, 30); - screen.resize(40, 30); - await wait(text => / Plan ──\s*\n/.test(text)); - terminal.write("测试输入"); - await wait(text => text.includes("测试输入") && !text.includes("Research and plan before making changes")); - expect(await readFile(providerPath, "utf8")).toBe(providerBefore); - expect(JSON.parse(await readFile(cliSettingsPath(env), "utf8"))).toEqual(settings); - } finally { - if (child.exitCode === null) child.kill("SIGTERM"); - await child.exited; - clearTimeout(deadline); - terminal.close(); - server.stop(true); - await screen.settled(); - await rm(home, { recursive: true, force: true }); - } -}, 45_000); diff --git a/test/runtime/launcher.test.ts b/test/runtime/launcher.test.ts index 723fac54..295c1cca 100644 --- a/test/runtime/launcher.test.ts +++ b/test/runtime/launcher.test.ts @@ -83,82 +83,6 @@ describe("launcher/runtime integration", () => { } }); - test("keeps TUI runtime diagnostics out of the interactive terminal", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-launcher-stderr-")); - const fakeNode = join(directory, "fake-node"); - const logPath = join(directory, "tui-runtime.log"); - await writeFile(fakeNode, [ - "#!/bin/sh", - "printf '%s\\n' 'AI SDK Warning: cacheControl breakpoint limit' >&2", - "printf '%s\\n' 'ProviderBusinessError: No available channel for model GLM-5.2' >&2", - "printf '\\033[2J' >&2", - "exit \"${FAKE_NODE_EXIT:-0}\"", - "" - ].join("\n")); - await chmod(fakeNode, 0o755); - try { - const tui = await run(["--cwd", directory, "tui"], "", { - ZCODE_NODE: fakeNode, - ZCODE_TUI_RUNTIME_LOG: logPath - }); - expect(tui.code).toBe(0); - expect(tui.stderr).not.toContain("ProviderBusinessError"); - expect(tui.stderr).not.toContain("cacheControl breakpoint limit"); - const tuiLog = await Bun.file(logPath).text(); - expect(tuiLog).toContain("ProviderBusinessError"); - expect(tuiLog).toContain("cacheControl breakpoint limit"); - - const failed = await run(["--cwd", directory, "tui"], "", { - FAKE_NODE_EXIT: "7", - ZCODE_NODE: fakeNode, - ZCODE_TUI_RUNTIME_LOG: logPath - }); - expect(failed.code).toBe(7); - expect(failed.stderr).toContain("ZCode runtime exited with status 7"); - expect(failed.stderr).toContain(`Diagnostics: ${logPath}`); - expect(failed.stderr).not.toContain("ProviderBusinessError"); - - const print = await run(["--print", "hello"], "", { - ZCODE_NODE: fakeNode, - ANTHROPIC_API_KEY: "fixture-only-key" - }); - expect(print.code).toBe(0); - expect(print.stderr).toContain("ProviderBusinessError"); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); - - test("rotates the bounded TUI diagnostic log between invocations", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-launcher-log-rotation-")); - const fakeNode = join(directory, "fake-node"); - const logPath = join(directory, "tui-runtime.log"); - await writeFile(fakeNode, [ - "#!/bin/sh", - "printf '%s\\n' 'fresh diagnostic' >&2", - "exit 0", - "" - ].join("\n")); - await chmod(fakeNode, 0o755); - await writeFile(logPath, Buffer.alloc(2 * 1024 * 1024, "x")); - if (process.platform !== "win32") await chmod(logPath, 0o644); - try { - const result = await run(["tui"], "", { - ZCODE_NODE: fakeNode, - ZCODE_TUI_RUNTIME_LOG: logPath - }); - expect(result.code).toBe(0); - expect(await Bun.file(logPath).text()).toContain("fresh diagnostic"); - expect(Bun.file(`${logPath}.1`).size).toBe(2 * 1024 * 1024); - if (process.platform !== "win32") { - expect((await stat(logPath)).mode & 0o777).toBe(0o600); - expect((await stat(`${logPath}.1`)).mode & 0o777).toBe(0o600); - } - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); - test("shows a missing resume target without exposing unrelated runtime diagnostics", async () => { const directory = await mkdtemp(join(tmpdir(), "zcode-launcher-missing-session-")); const fakeNode = join(directory, "fake-node"); @@ -181,137 +105,6 @@ describe("launcher/runtime integration", () => { } finally { await rm(directory, { recursive: true, force: true }); } }); - test("keeps non-agent runtime subcommands usable", async () => { - const doctor = await run(["doctor", "--json"]); - expect(doctor.code).toBe(0); - expect(JSON.parse(doctor.stdout)).toMatchObject({ cli: { version: readRuntimeVersion() } }); - - const plugins = await run(["plugins", "list", "--json"]); - expect(plugins.code).toBe(0); - expect(JSON.parse(plugins.stdout)).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: "browser-use", enabled: true }) - ])); - - const skills = await run(["skills", "list", "--json"]); - expect(skills.code).toBe(0); - expect(JSON.parse(skills.stdout).skills).toEqual(expect.arrayContaining([ - expect.objectContaining({ qualifiedName: "browser-use:control-browser" }) - ])); - }, 30_000); - - test("lists and inspects workspace custom commands", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-custom-commands-")); - try { - const commandDirectory = join(directory, ".zcode", "commands"); - await mkdir(commandDirectory, { recursive: true }); - await writeFile(join(commandDirectory, "smoke.md"), [ - "---", - "description: Smoke command description.", - "argument-hint: ", - "skills: browser-use:control-browser", - "---", - "", - "Summarize $ARGUMENTS.", - "" - ].join("\n")); - - const listed = await run(["--cwd", directory, "commands", "list", "--json"]); - expect(listed.code).toBe(0); - const catalog = JSON.parse(listed.stdout); - expect(catalog.totalDiscovered).toBe(catalog.commands.length); - expect(catalog.commands.filter((command: { scope: string }) => command.scope === "project")).toHaveLength(1); - expect(catalog).toMatchObject({ - commands: expect.arrayContaining([ - expect.objectContaining({ - argumentHint: "", - description: "Smoke command description.", - name: "smoke", - scope: "project", - skills: ["browser-use:control-browser"], - source: "zcode" - }) - ]), - cwd: directory, - diagnostics: [] - }); - - const inspected = await run(["--cwd", directory, "commands", "inspect", "smoke", "--json"]); - expect(inspected.code).toBe(0); - expect(JSON.parse(inspected.stdout)).toMatchObject({ - command: { - content: "Summarize $ARGUMENTS.", - metadata: expect.objectContaining({ - argumentHint: "", - description: "Smoke command description.", - name: "smoke", - scope: "project", - skills: ["browser-use:control-browser"], - source: "zcode" - }), - truncated: false - }, - cwd: directory, - diagnostics: [] - }); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }, 30_000); - - test("passes app-server through unchanged and exposes Plugin references", async () => { - const workspacePath = root.replace(/\/$/u, ""); - const request = { - id: 1, - method: "plugins/referenceCatalog", - params: { - workspace: { workspacePath, workspaceKey: workspacePath } - } - }; - // EOF means client disconnection in 3.14; close only after the response. - const result = await run(["app-server"], `${JSON.stringify(request)}\n`, {}, request.id); - expect(result.code).toBe(0); - const response = result.stdout.trim().split("\n").map((line) => JSON.parse(line)) - .find((message) => message.id === request.id); - expect(response).toMatchObject({ - id: 1, - result: { - plugins: expect.arrayContaining([ - expect.objectContaining({ - pluginId: "browser-use@zcode-plugins-official", - skillQualifiedNames: expect.arrayContaining(["browser-use:control-browser"]) - }) - ]) - } - }); - }, 30_000); - - test("resolves a real Plugin install dry-run without changing storage", async () => { - const result = await run([ - "plugins", - "install", - "browser-use@zcode-plugins-official", - "--dry-run", - "--json" - ]); - expect(result.code).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ - description: { - components: expect.arrayContaining([ - expect.objectContaining({ - kind: "skill", - items: expect.arrayContaining([ - expect.objectContaining({ name: "control-browser" }) - ]) - }) - ]) - }, - plan: { - dependencyClosure: [], - diagnostics: [] - } - }); - }, 30_000); - test("adds a local marketplace and installs its Plugin end to end", async () => { const marketplace = join(home, "fixture-marketplace"); const plugin = join(marketplace, "plugin"); diff --git a/test/runtime/model-catalog-tui.test.ts b/test/runtime/model-catalog-tui.test.ts deleted file mode 100644 index 6d1dd1a4..00000000 --- a/test/runtime/model-catalog-tui.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { terminalOutputText as plainText } from "../../scripts/terminal-output.ts"; -import { expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { ensureCliSettings, cliSettingsPath } from "../../src/model-access.ts"; -import { writeProviderFixture } from "../fixtures/provider-config.ts"; -import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; -import { requestAppServer } from "../../src/app-server-client.ts"; - - -test.skipIf(process.platform === "win32").each([false, true])("registry TUI starts and reloads personal models (first install: %p)", async (firstInstall) => { - const node = Bun.which("node")!; - const home = await mkdtemp(join(tmpdir(), "zcode-registry-tui-")); - const env = runtimeTestEnv(home); - await ensureCliSettings(env); - const config = JSON.parse(await readFile(cliSettingsPath(env), "utf8")); - const requestedModels: string[] = []; - const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { - if (new URL(request.url).pathname !== "/v1/chat/completions") return new Response("", { status: 404 }); - const body = await request.json() as { model: string; stream?: boolean }; - if (!body.stream) return Response.json({ id: "fixture", object: "chat.completion", model: body.model, - choices: [{ index: 0, message: { role: "assistant", content: "REGISTRY_REPLY" }, finish_reason: "stop" }] }); - requestedModels.push(body.model); - const chunks = [{ role: "assistant", content: "REGISTRY_REPLY" }, {}].map((delta, index) => ( - `data: ${JSON.stringify({ id: "fixture", object: "chat.completion.chunk", model: body.model, - choices: [{ index: 0, delta, finish_reason: index ? "stop" : null }] })}\n\n` - )); - return new Response(`${chunks.join("")}data: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } }); - } }); - config.ui.locale = "en-US"; - config.plugins.enabled = false; - config.memory.use = false; - config.memory.write = false; - await writeFile(cliSettingsPath(env), JSON.stringify(config)); - if (firstInstall) await rm(join(home, ".zcode", "cli"), { recursive: true }); - const personalPath = join(home, "provider_config.json"); - const nativeEnv = { ...env, ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: personalPath }; - if (!firstInstall) await writeProviderFixture(nativeEnv, { - providerId: "zai", modelId: "glm-5.3", models: ["glm-5.3", "glm-5.3-flash"], - apiKey: "fixture-key-not-real", baseUrl: `${server.url.origin}/v1` - }); - let output = ""; - const decoder = new TextDecoder(); - const terminal = new Bun.Terminal({ cols: 110, rows: 36, name: "xterm-256color", - data(_terminal, data) { output += decoder.decode(data, { stream: true }); } - }); - const runtimeEnv = { ...env, TERM: "xterm-256color", CI: "0", - ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: personalPath, - ZCODE_TUI_MODE: "regular", ZCODE_NODE: node }; - const start = (args: string[] = []) => Bun.spawn([node, join(import.meta.dir, "../../bin/zcode.js"), ...args], { - cwd: home, terminal, env: runtimeEnv - }); - let child = start(); - const killTimer = setTimeout(() => child.kill("SIGKILL"), 35_000); - async function waitFor(pattern: RegExp, offset = 0) { - const deadline = Date.now() + 8_000; - while (Date.now() < deadline && child.exitCode === null) { - if (pattern.test(plainText(output.slice(offset)))) return; - await Bun.sleep(20); - } - throw new Error(`Missing ${pattern}: ${plainText(output).slice(-4000)}`); - } - try { - if (firstInstall) { - await waitFor(/Welcome to ZCode CLI/); - const initial = JSON.parse(await readFile(cliSettingsPath(env), "utf8")); - expect(initial.provider).toBeUndefined(); - return; - } - await waitFor(/zai\/glm-5\.3/); - const firstPicker = output.length; - terminal.write("/model\r"); - await waitFor(/Select model/, firstPicker); - expect(plainText(output.slice(firstPicker))).toContain("zai/glm-5.3"); - terminal.write("\x1b"); - await Bun.sleep(50); - const personal = JSON.parse(await readFile(personalPath, "utf8")); - const rules = personal.config.providerConfigRules.providerRules; - const provider = rules.find((entry: { providerId: string }) => entry.providerId === "zai"); - provider.config.personalModelIds.push("catalog-fixture"); - await writeFile(personalPath, JSON.stringify(personal)); - const secondPicker = output.length; - terminal.write("/model\r"); - await waitFor(/zai\/catalog-fixture/, secondPicker); - terminal.write("\x1b"); - await Bun.sleep(50); - const switched = output.length; - terminal.write("/model zai/catalog-fixture\r"); - await waitFor(/Session model now: zai\/catalog-fixture/, switched); - expect(JSON.parse(await readFile(cliSettingsPath(env), "utf8"))).toEqual(config); - expect(JSON.parse(await readFile(personalPath, "utf8")).config.defaultModelSelection) - .toEqual(personal.config.defaultModelSelection); - const sent = output.length; - terminal.write("Reply with the fixture response.\r"); - await waitFor(/REGISTRY_REPLY/, sent); - expect(requestedModels).toContain("catalog-fixture"); - await Bun.sleep(100); - const sessions = await requestAppServer({ method: "session/list", params: {}, transport: { - command: node, args: [join(import.meta.dir, "../../vendor/zcode.cjs"), "app-server"], cwd: home, env: runtimeEnv - } }) as { sessions: Array<{ sessionId: string }> }; - const sessionId = sessions.sessions[0]?.sessionId; - expect(typeof sessionId, JSON.stringify(sessions)).toBe("string"); - let offset = output.length; - terminal.write("/model zai/glm-5.3\r"); - await waitFor(/Session model now: zai\/glm-5\.3/, offset); - offset = output.length; - terminal.write("/model zai/catalog-fixture\r"); - await waitFor(/Session model now: zai\/catalog-fixture/, offset); - offset = output.length; - terminal.write("/new\r"); - await waitFor(/◈ zai\/glm-5\.3/, offset); - offset = output.length; - terminal.write(`/resume ${sessionId}\r`); - await waitFor(/◈ zai\/catalog-fixture/, offset); - offset = output.length; - terminal.write("/effort disabled\r"); - await waitFor(/⚡ disabled/, offset); - child.kill("SIGTERM"); - await child.exited; - offset = output.length; - child = start(["--resume", sessionId!]); - await waitFor(/◈ zai\/catalog-fixture[\s\S]*⚡ disabled/, offset); - offset = output.length; - terminal.write("Reply after resuming.\r"); - await waitFor(/REGISTRY_REPLY/, offset); - expect(requestedModels.at(-1)).toBe("catalog-fixture"); - await Bun.sleep(100); - const settings = output.length; - terminal.write("/settings\r"); - await waitFor(/ZCode settings/, settings); - terminal.write("\r"); - await waitFor(/Default model/, settings); - terminal.write("zai/glm-5.3-flash\r"); - await waitFor(/Default model saved: zai\/glm-5\.3-flash/, settings); - expect(JSON.parse(await readFile(personalPath, "utf8")).config.defaultModelSelection) - .toEqual({ providerId: "zai", modelId: "glm-5.3-flash" }); - expect(JSON.parse(await readFile(cliSettingsPath(env), "utf8"))).toEqual(config); - child.kill("SIGTERM"); - await child.exited; - offset = output.length; - child = start(["--resume", sessionId!]); - await waitFor(/◈ zai\/glm-5\.3-flash/, offset); - offset = output.length; - terminal.write("/new\r"); - await waitFor(/◈ zai\/glm-5\.3-flash/, offset); - } finally { - if (child.exitCode === null) child.kill("SIGTERM"); - await child.exited; - clearTimeout(killTimer); - terminal.close(); - server.stop(true); - await rm(home, { recursive: true, force: true }); - } -}, 40_000); diff --git a/test/runtime/provider-config.test.ts b/test/runtime/provider-config.test.ts deleted file mode 100644 index 490eefeb..00000000 --- a/test/runtime/provider-config.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; - -const root = join(import.meta.dir, "../.."); - -/** Exercise the bundled registry without contacting an account service or changing user files. */ -async function probeRegistry(personal: unknown, body: string): Promise { - const home = await mkdtemp(join(tmpdir(), "zcode-provider-config-")); - try { - const personalPath = join(home, "provider_config.json"), builtinPath = join(home, "builtin.json"); - await writeFile(personalPath, JSON.stringify(personal)); - await writeFile(builtinPath, await readFile(join(root, "vendor/provider/zcode-builtin.json"))); - const script = ` - const fs = require("node:fs"), Module = require("node:module"), path = require("node:path"); - const file = ${JSON.stringify(join(root, "vendor/zcode.cjs"))}; - let source = fs.readFileSync(file, "utf8"); - const symbol = /[A-Za-z_$][\\w$]*\\(([A-Za-z_$][\\w$]*),"startProcessProviderRegistryRuntime"\\)/u.exec(source); - if (!symbol) throw new Error("Missing native registry bootstrap"); - const init = [...source.slice(0, symbol.index).matchAll(/([A-Za-z_$][\\w$]*)=[A-Za-z_$][\\w$]*\\(\\(\\)=>\\{/gu)].at(-1)?.[1]; - const main = /async function [A-Za-z_$][\\w$]*\\(\\)\\{let [A-Za-z_$][\\w$]*=process\\.argv\\.slice\\(2\\);/u.exec(source); - if (!init || !main) throw new Error("Missing native registry test entry"); - const body = ${JSON.stringify(body)}; - source = source.replace(main[0], main[0] + init + "();await(async(startRegistry)=>{" + body + "})(" + symbol[1] + ");return;"); - const runtime = new Module(file, module); - runtime.filename = file; - runtime.paths = Module._nodeModulePaths(path.dirname(file)); - runtime._compile(source, file); - `; - const child = Bun.spawn([Bun.which("node")!, "--eval", script], { - cwd: home, env: { ...runtimeTestEnv(home), - ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: personalPath, ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: builtinPath, - ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE: "" }, - stdout: "pipe", stderr: "pipe" - }); - const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); - expect(code, stderr || stdout).toBe(0); - expect(stdout).toContain("PROVIDER_CONFIG_OK"); - } finally { - await rm(home, { recursive: true, force: true }); - } -} - -test("the complete provider example passes the current native schema", async () => { - const example = await Bun.file(join(root, "provider.example.json")).json(); - await probeRegistry(example, ` - const assert = require("node:assert/strict"); - const host = await startRegistry(process.env, { standalone: {} }); - try { - const snapshot = await host.runtime.personalRepository.read(); - assert.equal(snapshot.providers.has("custom"), true); - assert.equal(snapshot.providers.has("coding-plan-example"), true); - const smart = snapshot.models.getExactRule("custom", "overrides-reference").config.toJSON(); - assert.deepEqual(Object.keys(smart.properties.inputFormat).sort(), ["supportsAudio", "supportsImage", "supportsPdf", "supportsText", "supportsVideo"]); - assert.equal(smart.optionSpecs.maxOutputTokens.max, 32768); - assert.equal(typeof smart.optionSpecs.reasoningLevel.map, "string"); - const manual = snapshot.models.getExactRule("custom", "manual-reference"); - assert.equal(manual.type, "manual-provider-model"); - assert.equal(manual.config.toJSON().properties.supportsMidConversationSystem, false); - assert.equal(snapshot.defaultModelSelection.options.reasoningLevel, "disabled"); - console.log("PROVIDER_CONFIG_OK"); - } finally { host.dispose(); } - `); -}, 15_000); - -test("upstream catalog refresh updates all inherited capabilities and keeps user overrides", async () => { - const example = await Bun.file(join(root, "provider.example.json")).json(); - example.config.providerConfigRules.providerRules[0].config.access.apiKey = "fixture-key"; - example.config.providerConfigRules.providerRules[0].config.personalModelIds.push("user-overridden-model"); - example.config.modelConfigRules.providerModelRules.push({ providerId: "custom", modelId: "user-overridden-model", - config: { properties: { inputFormat: { supportsImage: false } } } }); - example.config.modelConfigRules.manualProviderModelRules[0].config.enabled = true; - await probeRegistry(example, ` - const assert = require("node:assert/strict"), fs = require("node:fs/promises"); - const host = await startRegistry(process.env, { standalone: {} }); - try { - const registry = host.runtime.registryService; - const before = registry.getModel("custom", "your-model-id").config; - assert.equal(before.properties.inputFormat.supportsImage, false); - const personalBefore = await fs.readFile(process.env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE, "utf8"); - const builtin = JSON.parse(await fs.readFile(process.env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE, "utf8")); - builtin.revision += 1; - const properties = { - contextWindow: 1000000, requiresMfjsToolSchema: true, supportsToolCall: true, - supportsJsonSchemaOutput: true, supportsNativeWebSearch: true, supportsMidConversationSystem: true, - inputFormat: { supportsText: true, supportsImage: true, supportsVideo: true, supportsAudio: true, supportsPdf: true }, - outputFormat: { supportsText: true } - }; - const optionSpecs = { - reasoningLevel: { values: ["low", "high", "max"], map: '{"reasoning_effort": reasoningLevel}' }, - maxOutputTokens: { max: 128000, map: '{"max_completion_tokens": maxOutputTokens}' } - }; - builtin.config.modelConfigRules.providerSiteRules.push({ - modelMatch: "your-model-id|user-overridden-model|manual-reference", - apiTypeMatch: "openai-chat-completions", baseUrlMatch: "https://api[.]example[.]com/v1", - config: { properties, optionSpecs } - }); - await fs.writeFile(process.env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE, JSON.stringify(builtin)); - // The real CLI updates its account snapshot to the same catalog revision - // before publishing the new registry view. - await host.accountSource.refresh("test-upstream-model-refresh"); - await registry.refresh("test-upstream-model-refresh"); - const updated = registry.getModel("custom", "your-model-id").config.toJSON(); - assert.deepEqual(updated.properties, properties); - assert.deepEqual(updated.optionSpecs, optionSpecs); - const overridden = registry.getModel("custom", "user-overridden-model").config.toJSON(); - assert.equal(overridden.properties.inputFormat.supportsImage, false); - assert.equal(overridden.properties.inputFormat.supportsVideo, true); - assert.equal(overridden.optionSpecs.maxOutputTokens.max, 128000); - const manual = registry.getModel("custom", "manual-reference").config.toJSON(); - assert.equal(manual.properties.inputFormat.supportsVideo, false); - assert.equal(manual.properties.contextWindow, 200000); - assert.equal(manual.optionSpecs.maxOutputTokens.max, 32768); - assert.equal(await fs.readFile(process.env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE, "utf8"), personalBefore); - console.log("PROVIDER_CONFIG_OK"); - } finally { host.dispose(); } - `); -}, 15_000); diff --git a/test/runtime/session-lifecycle.test.ts b/test/runtime/session-lifecycle.test.ts index 293a75ff..40ed3158 100644 --- a/test/runtime/session-lifecycle.test.ts +++ b/test/runtime/session-lifecycle.test.ts @@ -89,7 +89,7 @@ async function createFixture(display = "regular") { } catch (error) { await dispose(); throw error; } } -test.skipIf(process.platform === "win32").each(["regular", "fullscreen"])( +test.skipIf(process.platform === "win32").each(["regular"])( "untouched %s sessions stay unsaved and omit resume guidance", async display => { await using fixture = await createFixture(display); const tui = fixture.start(); @@ -101,7 +101,7 @@ test.skipIf(process.platform === "win32").each(["regular", "fullscreen"])( }, 30_000 ); -test.skipIf(process.platform === "win32").each(["regular", "fullscreen"])( +test.skipIf(process.platform === "win32").each(["regular"])( "renaming an empty %s session saves its title and supports restart and headless resume", async display => { await using fixture = await createFixture(display); const tui = fixture.start(); diff --git a/test/runtime/session-model-recovery.test.ts b/test/runtime/session-model-recovery.test.ts index 75f55904..5e92af6a 100644 --- a/test/runtime/session-model-recovery.test.ts +++ b/test/runtime/session-model-recovery.test.ts @@ -2,14 +2,10 @@ import { expect, test } from "bun:test"; import { readFile, writeFile } from "node:fs/promises"; import { createSessionModelFixture, type SessionModelCase } from "../fixtures/session-model-recovery.ts"; +import { runtimeStartupTimeoutMs } from "../fixtures/runtime-env.ts"; import { TerminalScreen } from "../tui/harness/terminal-screen.ts"; -const cases = [ - ["legacy-provider", "regular", "startup"], - ["model-casing", "fullscreen", "command"], - ["missing-model", "regular", "command"], - ["missing-reasoning", "fullscreen", "startup"] -] as const; +const cases: Array<[SessionModelCase, "regular" | "fullscreen", "startup" | "command"]> = [["missing-model", "regular", "startup"]]; test.skipIf(process.platform === "win32").each(cases)( "resume repairs %s through the %s picker (%s)", async (kind, display, entry) => { @@ -24,8 +20,8 @@ test.skipIf(process.platform === "win32").each(cases)( }); let child = start(entry === "startup"); const deadline = setTimeout(() => child.kill("SIGKILL"), 35_000); - const wait = async (predicate: (text: string) => boolean) => { - const until = Date.now() + 8_000; + const wait = async (predicate: (text: string) => boolean, timeoutMs = 8_000) => { + const until = Date.now() + timeoutMs; while (Date.now() < until && child.exitCode === null) { await screen.settled(); if (predicate(screen.screenText())) return; @@ -38,7 +34,7 @@ test.skipIf(process.platform === "win32").each(cases)( await wait(text => text.includes("◈ zai/glm-5.3-flash")); terminal.write(`/resume ${fixture.sessionId}\r`); } - await wait(text => text.includes("Select a replacement model")); + await wait(text => text.includes("Select a replacement model"), runtimeStartupTimeoutMs); expect(screen.screenText()).toContain("Saved model"); expect(fixture.requests).toEqual([]); expect(fixture.readSelection()).toEqual(before); @@ -83,7 +79,7 @@ test.skipIf(process.platform === "win32").each(cases)( }, 40_000 ); -test.each(["legacy-provider", "model-casing", "missing-model"] as SessionModelCase[])( +test.each(["missing-model"] as SessionModelCase[])( "headless resume explains the invalid saved model (%s)", async kind => { await using fixture = await createSessionModelFixture(kind); const before = fixture.readSelection(); @@ -103,28 +99,6 @@ test.each(["legacy-provider", "model-casing", "missing-model"] as SessionModelCa }, 25_000 ); -test.each(["valid", "legacy-provider", "model-casing", "missing-model", "missing-reasoning"] as SessionModelCase[])( - "headless cold resume uses the explicit model instead of the saved selection (%s)", async kind => { - await using fixture = await createSessionModelFixture(kind); - const providers = await readFile(fixture.providerPath, "utf8"); - const child = Bun.spawn([...fixture.command, "--resume", fixture.sessionId, "--model", "zai/glm-5.3-flash", "--prompt", "Use the explicit override."], { - cwd: fixture.directory, env: fixture.env, stdin: "ignore", stdout: "pipe", stderr: "pipe" - }); - const timeout = setTimeout(() => child.kill("SIGKILL"), 15_000); - try { - const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); - expect({ code, stderr }).toEqual({ code: 0, stderr: "" }); - expect(stdout).toContain("SESSION_MODEL_REPLY"); - expect(fixture.requests).toEqual(["glm-5.3-flash"]); - expect(await readFile(fixture.providerPath, "utf8")).toBe(providers); - } finally { - if (child.exitCode === null) child.kill("SIGKILL"); - await child.exited; - clearTimeout(timeout); - } - }, 30_000 -); - test("headless cold resume rejects an unavailable explicit model without falling back", async () => { await using fixture = await createSessionModelFixture("valid"); const before = fixture.readSelection(); @@ -144,37 +118,3 @@ test("headless cold resume rejects an unavailable explicit model without falling clearTimeout(timeout); } }, 30_000); - -test.skipIf(process.platform === "win32").each([false, true])("resume preserves valid selections and handles an empty catalog (empty: %p)", async empty => { - await using fixture = await createSessionModelFixture("valid"); - const before = fixture.readSelection(); - if (empty) { - const config = JSON.parse(await readFile(fixture.providerPath, "utf8")); - config.config.providerConfigRules.providerRules[0].enabled = false; - await writeFile(fixture.providerPath, JSON.stringify(config)); - } - using screen = new TerminalScreen(100, 32); - const terminal = new Bun.Terminal({ cols: 100, rows: 32, name: "xterm-256color", - data(_terminal, data) { void screen.write(data); } }); - const child = Bun.spawn([...fixture.command, "--resume", fixture.sessionId], { cwd: fixture.directory, env: fixture.env, terminal }); - const timeout = setTimeout(() => child.kill("SIGKILL"), 15_000); - try { - const until = Date.now() + 8_000; - while (Date.now() < until && child.exitCode === null) { - await screen.settled(); - if (empty ? screen.screenText().includes("No models are available") : screen.screenText().includes("⚡ high")) break; - await Bun.sleep(25); - } - expect(screen.screenText()).toContain(empty ? "No models are available" : "⚡ high"); - expect(screen.screenText()).not.toContain("Select a replacement model"); - if (empty) expect(screen.screenText()).toContain("/login"); - expect(fixture.requests).toEqual([]); - expect(fixture.readSelection()).toEqual(before); - } finally { - if (child.exitCode === null) child.kill("SIGTERM"); - await child.exited; - clearTimeout(timeout); - terminal.close(); - await screen.settled(); - } -}, 25_000); diff --git a/test/runtime/shared-oauth.test.ts b/test/runtime/shared-oauth.test.ts index ed4f2c3d..094ed366 100644 --- a/test/runtime/shared-oauth.test.ts +++ b/test/runtime/shared-oauth.test.ts @@ -3,21 +3,9 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; -import { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "../../scripts/runtime-shared-oauth-patches.ts"; const root = join(import.meta.dir, "../.."); -test("the shared OAuth patch is idempotent and rejects partially patched credentials", async () => { - const source = await readFile(join(root, "vendor/zcode.cjs"), "utf8"); - expect(hasRuntimeSharedOAuth(source)).toBe(true); - expect(patchRuntimeSharedOAuth(source)).toBe(source); - for (const partial of [source.replace('zaiTokenKind:"business"', 'zaiTokenKind:"oauth"'), - source.replace("async saveIfValue(", "async removedIfValue(")]) { - expect(hasRuntimeSharedOAuth(partial)).toBe(false); - expect(() => patchRuntimeSharedOAuth(partial)).toThrow("partial patch"); - } -}, 15_000); - /** Run native login, encryption, account projection and request signing; only HTTP is simulated. */ async function probe(body: string, options: { callback?: boolean } = {}) { const directory = await mkdtemp(join(tmpdir(), "zcode-shared-oauth-")); @@ -139,44 +127,6 @@ test("running CLI requests follow Desktop account changes and reject a late resu `); }, 15_000); -test("a rejected business exchange leaves the existing Desktop login intact", async () => { - await probe(` - await seed(); - const previous=await fs.readFile(store.filePath,"utf8"); - rejectExchange=true; - await assert.rejects(loginZCodeCli({env,noBrowser:true,httpClient}),/project_token_login_failed/); - assert.ok(await fs.readFile(store.filePath,"utf8")===previous,"failed login overwrote the shared Desktop session"); - `, { callback: true }); -}, 15_000); - -test("legacy CLI OAuth credentials migrate to Desktop's format after a confirmed exchange", async () => { - await probe(` - await seed("fixture-user"); - await store.save("oauth:zai:access_token","fixture-oauth-token"); - const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver()); - const first=await headers.refreshBeforeModelRequest(input); - assert.equal(first.requestAuth.apiKey,projectToken); - assert.equal(await store.load("oauth:zai:access_token"),businessToken); - assert.equal(calls.filter(path=>path==="/api/auth/z/login").length,1); - calls.length=0; - const second=await headers.refreshBeforeModelRequest(input); - assert.equal(second.requestAuth.accountScope,first.requestAuth.accountScope); - assert.ok(!calls.includes("/api/auth/z/login")); - `); -}, 15_000); - -test("legacy migration cannot overwrite a newer Desktop login", async () => { - await probe(` - await seed("fixture-user"); - await store.save("oauth:zai:access_token","fixture-oauth-token"); - beforeIssue=async()=>{businessToken="new-desktop-token";await seed("new-desktop-user")}; - const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver()); - await assert.rejects(headers.refreshBeforeModelRequest(input),/project_token_scope_invalidated/); - assert.equal(await store.load("oauth:zai:access_token"),"new-desktop-token"); - assert.equal(JSON.parse(await store.load("oauth:zai:user_info")).user_id,"new-desktop-user"); - `); -}, 15_000); - test("permission failures do not trigger legacy conversion or change the shared token", async () => { await probe(` await seed(); @@ -188,37 +138,3 @@ test("permission failures do not trigger legacy conversion or change the shared assert.equal(await store.load("oauth:zai:access_token"),"fixture-oauth-token"); `); }, 15_000); - -test("cancelling macOS login after material resolution preserves the shared session", async () => { - await probe(` - await seed(); - const previous=await fs.readFile(store.filePath,"utf8"),controller=new AbortController(); - await assert.rejects(loginZCodeCli({env,noBrowser:true,httpClient,abortSignal:controller.signal, - apiKeyResolver:{resolveMaterial:async()=>{controller.abort(new Error("fixture login cancelled"));return{zaiBusinessToken:businessToken}}} - }),/fixture login cancelled/); - assert.ok(await fs.readFile(store.filePath,"utf8")===previous); - `, { callback: true }); -}, 15_000); - -test("a Desktop BigModel profile also supplies the CLI account identity", async () => { - await probe(` - const id="account:bigmodel-individual-coding-plan"; - await store.saveMany({"oauth:active_provider":"bigmodel","oauth:bigmodel:access_token":businessToken, - "oauth:bigmodel:user_info":JSON.stringify({id:"desktop-bigmodel-user",username:"Fixture",displayName:"Fixture"}),zcodejwttoken:"desktop-jwt"}); - const snapshot=await readStandaloneAccountProviderConfigSnapshot(store,env); - assert.equal(snapshot.providers.entries().find(([provider])=>provider===id)[1].access.entitled,true); - assert.equal(snapshot.providers.entries().find(([provider])=>provider===providerId)[1].access.entitled,false); - const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver()); - const auth=await headers.refreshBeforeModelRequest({providerId:id,accountAccess:{mode:"individual-coding-plan",accountType:"bigmodel"}}); - assert.equal(auth.requestAuth.apiKey,projectToken); - assert.ok(!calls.includes("/api/auth/z/login")); - `); -}, 15_000); - -test("request auth uses the resolved provider even when the original catalog path is unavailable", async () => { - await probe(` - await seed(); - const headers=createStandaloneProviderRuntimeHeadersPort(store,{...env,ZCODE_BUILTIN_PROVIDER_CONFIG_FILE:"/missing/fixture-catalog.json"},resolver()); - assert.equal((await headers.refreshBeforeModelRequest(input)).requestAuth.apiKey,projectToken); - `); -}, 15_000); diff --git a/test/runtime/subagent-restoration.test.ts b/test/runtime/subagent-restoration.test.ts deleted file mode 100644 index 7b8c9986..00000000 --- a/test/runtime/subagent-restoration.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, test } from "bun:test"; -import { readFile, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { runtimeSubagentQueryBridge } from "../../scripts/sync-runtime.ts"; -import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; - -test("restores subagents with the extracted runtime's native query and reducer", async () => { - const root = join(import.meta.dir, "../.."); - const source = await readFile(join(root, "vendor/zcode.cjs"), "utf8"); - const queryBridge = runtimeSubagentQueryBridge(source); - const directory = await mkdtemp(join(tmpdir(), "zcode-subagent-restore-")); - try { - const child = Bun.spawn(["node", join(root, "test/fixtures/subagent-restoration.cjs"), queryBridge], { - cwd: directory, env: runtimeTestEnv(directory), stdin: "ignore", stdout: "pipe", stderr: "pipe" - }); - const timeout = setTimeout(() => child.kill("SIGKILL"), 15_000); - try { - const [code, stdout, stderr] = await Promise.all([ - child.exited, new Response(child.stdout).text(), new Response(child.stderr).text() - ]); - expect(code, stderr || stdout).toBe(0); - expect(stdout).toContain("Native subagent restoration passed"); - } finally { - clearTimeout(timeout); - if (child.exitCode === null) { child.kill("SIGKILL"); await child.exited; } - } - } finally { - await rm(directory, { recursive: true, force: true }); - } -}, 20_000); diff --git a/test/runtime/workflow-management-tui.test.ts b/test/runtime/workflow-management-tui.test.ts deleted file mode 100644 index 4f4ac8c4..00000000 --- a/test/runtime/workflow-management-tui.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { expect, test } from "bun:test"; -import { Database } from "bun:sqlite"; -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { cliSettingsPath, ensureCliSettings } from "../../src/model-access.ts"; -import { writeProviderFixture } from "../fixtures/provider-config.ts"; -import { runtimeStartupTimeoutMs, runtimeTestEnv } from "../fixtures/runtime-env.ts"; -import { ScenarioWorkspace } from "../tui/harness/scenario-workspace.ts"; -import { TerminalSession } from "../tui/harness/terminal-session.ts"; - -test.skipIf(process.platform === "win32")("native workflow settings, live retuning and saved restart share the TUI session", async () => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-workflow-management-" }); - const root = join(import.meta.dir, "../.."); - const env = { ...runtimeTestEnv(workspace.home), ZCODE_SESSION_DB_PATH: join(workspace.home, "workflow.sqlite") }; - await ensureCliSettings(env); - const settings = JSON.parse(await readFile(cliSettingsPath(env), "utf8")); - settings.plugins.enabled = false; - settings.ui.locale = "en-US"; - settings.memory = { use: false, write: false, autoConsolidate: false }; - settings.features = { compact: false, rewind: false, subagent: true, memory: false, skill: true, mcp: false }; - await writeFile(cliSettingsPath(env), JSON.stringify(settings)); - const script = 'const reviewer = agent("Reviewer", { model: "custom/worker" });\nconst result = await reviewer.ask<{ ok: boolean }>("check");\nreturn result.ok;'; - let releaseWorker!: () => void; - const workerGate = new Promise(resolve => { releaseWorker = resolve; }); - let markWorkerRequested!: () => void; - const workerRequested = new Promise(resolve => { markWorkerRequested = resolve; }); - const requestedModels: string[] = []; - let toolCount = 0; - const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { - const body = await request.json() as { stream?: boolean; model?: string; messages: { role: string; tool_call_id?: string }[] }; - const worker = body.model?.includes("worker") === true; - requestedModels.push(body.model ?? ""); - if (worker) { markWorkerRequested(); await workerGate; } - if (!body.stream) return Response.json({ choices: [{ message: { role: "assistant", content: worker ? '{"ok":true}' : "READY" }, finish_reason: "stop" }] }); - const last = body.messages.at(-1); - const loadedSkill = last?.tool_call_id?.startsWith("skill_") === true; - const finished = worker || (last?.role === "tool" && !loadedSkill); - const delta = finished ? { role: "assistant", content: worker ? '{"ok":true}' : "WORKFLOW_REQUEST_COMPLETE" } : { - role: "assistant", tool_calls: [{ index: 0, id: `${loadedSkill ? "workflow" : "skill"}_${++toolCount}`, type: "function", - function: loadedSkill - ? { name: "CreateWorkflow", arguments: JSON.stringify({ name: "Native workflow test", script, max_concurrency: 4 }) } - : { name: "Skill", arguments: JSON.stringify({ skill: "dynamic-workflows" }) } }] - }; - return new Response([ - { choices: [{ index: 0, delta, finish_reason: null }] }, - { choices: [{ index: 0, delta: {}, finish_reason: finished ? "stop" : "tool_calls" }] } - ].map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); - } }); - try { - const { path: providerPath } = await writeProviderFixture(env, { apiKey: "fixture-key", modelId: "fixture", models: ["fixture", "alternate", "worker"], baseUrl: `${server.url.origin}/v1` }); - const providerBefore = await readFile(providerPath, "utf8"); - await using session = TerminalSession.start({ command: [Bun.which("node")!, join(root, "bin/zcode.js"), "--workflow-mode", "alwaysOn"], workspace, cols: 110, rows: 40, - env: Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)), timeoutMilliseconds: 60_000 }); - await session.waitForScreen("startup", /custom\/fixture/u, runtimeStartupTimeoutMs); - await session.sendAndWait("Create the isolated workflow.\r", "workflow approval", /Approve CreateWorkflow/u); - await session.sendAndWait("concurrency\r", "concurrency input", /Workflow concurrency/u); - await session.sendAndWait("2\r", "adjusted approval", /Concurrency: 2/u); - await session.sendAndWait("2", "denied workflow", /WORKFLOW_REQUEST_COMPLETE/u); - const database = new Database(env.ZCODE_SESSION_DB_PATH, { readonly: true }); - try { - const runs = () => database.query("select id, caps_max_concurrency, status from dwf_run order by time_created").all() as { id: string; caps_max_concurrency: number; status: string }[]; - expect(runs()).toHaveLength(0); - await session.sendAndWait("Create the isolated workflow again.\r", "second approval", /Approve CreateWorkflow/u); - await session.sendAndWait("concurrency\r", "second concurrency input", /Workflow concurrency/u); - await session.sendAndWait("2\r", "second adjusted approval", /Concurrency: 2/u); - await session.sendAndWait("model\r", "model picker", /Workflow default model/u); - await session.sendAndWait("alternate\r", "selected default", /Subagent default model: custom\/alternate/u); - await session.sendAndWait("1", "workflow created", /WORKFLOW_REQUEST_COMPLETE/u); - expect(runs()).toHaveLength(1); - const runId = runs()[0]!.id; - expect(runs()[0]!.caps_max_concurrency).toBe(2); - await session.sendAndWait("/workflows\r", "run list", /Workflow runs/u); - await session.sendAndWait("Native workflow\r", "run actions", /Workflow actions/u); - await session.waitForHistory("default model details", /custom\/alternate/u); - await session.sendAndWait("concurrency\r", "live concurrency input", /Workflow concurrency/u); - await session.sendAndWait("3\r", "live retune confirmation", /Apply concurrency/u); - await session.sendAndWait("\r", "live retune result", /Workflow concurrency updated/u); - expect(runs()).toHaveLength(1); - expect(runs()[0]).toMatchObject({ id: runId, caps_max_concurrency: 3, status: "running" }); - await session.sendAndWait("/workflows\r", "save run list", /Workflow runs/u); - await session.sendAndWait("Native workflow\r", "save actions", /Workflow actions/u); - await session.sendAndWait("save\r", "save name", /Name: up to 64/u); - await session.sendAndWait("review\r", "save scope", /Save workflow scope/u); - await session.sendAndWait("\r", "save description", /Workflow description/u); - await session.sendAndWait("\r", "saved result", /Workflow saved/u); - expect(await workspace.read(".zcode/workflows/review.dwf.ts")).toContain(script); - await workerRequested; - await session.waitForHistory("explicit agent model", /custom\/worker/u); - expect(requestedModels).toContain("worker"); - expect(runs()[0]).toMatchObject({ id: runId, status: "running" }); - await session.sendAndWait("/workflows saved\r", "saved definitions", /Saved workflows/u); - await session.sendAndWait("review\r", "saved start confirmation", /Run saved workflow/u); - await session.sendAndWait("run\r", "new saved run", /Saved workflow started/u); - expect(runs()).toHaveLength(2); - expect(runs()[1]!.id).not.toBe(runId); - expect(runs()[0]).toMatchObject({ id: runId, status: "running" }); - expect(runs()[1]!.status).toBe("running"); - releaseWorker(); - expect(await readFile(providerPath, "utf8")).toBe(providerBefore); - expect(JSON.parse(await readFile(cliSettingsPath(env), "utf8"))).toEqual(settings); - await session.exit(); - } finally { database.close(); } - } finally { releaseWorker(); server.stop(true); } -}, 70_000); diff --git a/test/selection-command.test.ts b/test/selection-command.test.ts deleted file mode 100644 index cd76ee08..00000000 --- a/test/selection-command.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - parseSelectionCommand, - protectSubmission, - redactSecrets, - selectionSubmission -} from "../packages/zcode-tui/src/selection-command.ts"; - -describe("structured selection commands", () => { - test("preserves upstream masked-input metadata", () => { - const command = parseSelectionCommand({ - command: "/login zai-coding-plan-api-key", - id: "zai-coding-plan-api-key", - primary: "Z.AI Coding Plan API Key", - secondary: "Paste a key manually.", - input: { - cancelStatus: "cancelled", - emptyStatus: "required", - help: "Enter saves the key.", - mask: true, - placeholder: "Paste API key", - primary: "Enter Z.AI API Key", - secondary: "It remains hidden.", - submitStatus: "Saving API key..." - } - }, 0); - - expect(command).toMatchObject({ - command: "/login zai-coding-plan-api-key", - input: { - mask: true, - placeholder: "Paste API key", - submitStatus: "Saving API key..." - } - }); - const submission = selectionSubmission(command!, " top-secret-key "); - expect(submission).toEqual({ - cancelStatus: "cancelled", - displayInput: "/login zai-coding-plan-api-key [redacted]", - input: "/login zai-coding-plan-api-key top-secret-key", - recordHistory: false, - secrets: ["top-secret-key"], - status: "Saving API key..." - }); - expect(selectionSubmission(command!, " ")).toBeNull(); - }); - - test("redacts directly typed API-key commands and downstream errors", () => { - const submission = protectSubmission("/login bigmodel-coding-plan-api-key private-key"); - expect(submission.recordHistory).toBe(false); - expect(submission.displayInput).toBe("/login bigmodel-coding-plan-api-key [redacted]"); - expect(redactSecrets("Could not save private-key", submission.secrets)).toBe( - "Could not save [redacted]" - ); - }); - - test("keeps ordinary selection commands recordable", () => { - const command = parseSelectionCommand({ - command: "/resume session-1", - primary: "Session one" - }, 0); - expect(selectionSubmission(command!)).toMatchObject({ - displayInput: "/resume session-1", - recordHistory: true, - secrets: [] - }); - }); - - test("preserves OAuth pending and cancellation status", () => { - const command = parseSelectionCommand({ - command: "/login zai-coding-plan", - primary: "Z.AI Coding Plan", - pending: { - cancelStatus: "Login cancelled.", - help: "Esc cancels.", - primary: "Waiting for authorization", - secondary: "Complete login in the browser.", - status: "Waiting for browser authorization..." - } - }, 0); - expect(selectionSubmission(command!)).toMatchObject({ - cancelStatus: "Login cancelled.", - pending: { - primary: "Waiting for authorization", - help: "Esc cancels." - }, - status: "Waiting for browser authorization..." - }); - }); -}); diff --git a/test/selectors.test.ts b/test/selectors.test.ts deleted file mode 100644 index 656a406b..00000000 --- a/test/selectors.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - effortPicker, - explicitModelRequest, - isEffortPickerRequest, - isModePickerRequest, - isModelPickerRequest, - modePicker, - modelPicker, - sessionRenameRequest -} from "../packages/zcode-tui/src/selectors.ts"; - -describe("TUI selectors", () => { - test("reads registry model references in the model picker", () => { - const options = [ - { ref: { providerId: "account:zai-individual-coding-plan", modelId: "GLM-5.3" }, label: "GLM-5.3", providerLabel: "Z.AI" }, - { ref: { providerId: "custom", modelId: "org/model" }, label: "Custom" } - ]; - const current = "custom/org/model"; - expect(modelPicker(options, current).items.map((item) => item.value)).toEqual([ - "account:zai-individual-coding-plan/GLM-5.3", current - ]); - expect(modelPicker(options, current).selectedIndex).toBe(1); - }); - - test("builds and selects model choices from upstream model entries", () => { - const picker = modelPicker([ - { alias: "main", id: "zai/glm-5.2", name: "GLM-5.2" }, - { alias: "lite", id: "zai/glm-5-turbo", name: "GLM-5 Turbo" }, - { id: "zai/glm-5.2", name: "duplicate" }, - "custom/model" - ], "zai/glm-5-turbo"); - - expect(picker.selectedIndex).toBe(1); - expect(picker.items).toEqual([ - { - value: "zai/glm-5.2", - label: "zai/glm-5.2", - description: "GLM-5.2 · main", - command: "/model zai/glm-5.2" - }, - { - value: "zai/glm-5-turbo", - label: "zai/glm-5-turbo", - description: "GLM-5 Turbo · lite · current", - command: "/model zai/glm-5-turbo" - }, - { - value: "custom/model", - label: "custom/model", - description: undefined, - command: "/model custom/model" - } - ]); - }); - - test("builds localized effort choices and tracks the current level", () => { - const picker = effortPicker([ - { id: "low", label: "Low" }, - { id: "high", label: "High" } - ], "high"); - - expect(picker.selectedIndex).toBe(1); - expect(picker.items[1]).toEqual({ - value: "high", - label: "High", - description: "high · current", - command: "/effort high" - }); - }); - - test("opens pickers only for list-style slash commands", () => { - expect(isModelPickerRequest("/model")).toBe(true); - expect(isModelPickerRequest("/MODEL list")).toBe(true); - expect(isModelPickerRequest("/model zai/glm-5.2")).toBe(false); - expect(isEffortPickerRequest("/effort")).toBe(true); - expect(isEffortPickerRequest("/variant list")).toBe(true); - expect(isEffortPickerRequest("/effort high")).toBe(false); - }); - - test("extracts explicit model refs, aliases, and nested model IDs", () => { - expect(explicitModelRequest("/model zai/glm-5.2")).toBe("zai/glm-5.2"); - expect(explicitModelRequest("/model custom/model")).toBe("custom/model"); - expect(explicitModelRequest("/model provider/org/model")).toBe("provider/org/model"); - // Bare picker and list forms are handled by showModelPicker. - expect(explicitModelRequest("/model")).toBeUndefined(); - expect(explicitModelRequest("/model list")).toBeUndefined(); - // Runtime-resolved aliases also use the transient session switch path. - expect(explicitModelRequest("/model main")).toBe("main"); - expect(explicitModelRequest("/model lite")).toBeUndefined(); - expect(explicitModelRequest("/model opus")).toBeUndefined(); - // Malformed refs fall through to the runtime's own error handling. - expect(explicitModelRequest("/model not-a-ref")).toBeUndefined(); - expect(explicitModelRequest("/effort high")).toBeUndefined(); - }); - - test("builds mode choices with the current mode preselected", () => { - const picker = modePicker("edit"); - expect(picker.selectedIndex).toBe(1); - expect(picker.items.map(item => item.value)).toEqual(["build", "edit", "guarded", "yolo"]); - expect(picker.items[1]?.description).toBe("Edit automatically · current"); - expect(modePicker("edit", ["build", "edit", "auto", "yolo", "plan"]).items.map(item => item.value)) - .toEqual(["build", "edit", "yolo"]); - const guarded = modePicker("guarded"); - expect(guarded.selectedIndex).toBe(2); - expect(guarded.items[2]?.description).toBe("Autonomous · ask before risky commands · current"); - expect(guarded.items[2]?.command).toBe("/mode guarded"); - - // Unknown current mode falls back to index 0 with no current marker. - const fallback = modePicker(undefined); - expect(fallback.selectedIndex).toBe(0); - expect(fallback.items.every((item) => !item.description?.includes("current"))).toBe(true); - - // Restricted mode list (e.g. runtime-reported availability). - const restricted = modePicker("yolo", ["build", "yolo"]); - expect(restricted.items.map((item) => item.value)).toEqual(["build", "yolo"]); - expect(restricted.selectedIndex).toBe(1); - - expect(isModePickerRequest("/mode")).toBe(true); - expect(isModePickerRequest("/MODE list")).toBe(true); - expect(isModePickerRequest("/mode plan")).toBe(false); - }); - -}); - -describe("sessionRenameRequest", () => { - test("parses explicit rename titles", () => { - expect(sessionRenameRequest("/rename Sidebar drag fix")).toBe("Sidebar drag fix"); - expect(sessionRenameRequest(" /rename spaced out ")).toBe("spaced out"); - }); - - test("returns an empty title for the bare usage form", () => { - expect(sessionRenameRequest("/rename")).toBe(""); - expect(sessionRenameRequest("/rename ")).toBe(""); - expect(sessionRenameRequest("/rename \x1b]0;INJECTED\x07")).toBe(""); - }); - - test("removes terminal controls before persisting a title", () => { - expect(sessionRenameRequest("/rename safe\x07\x1b]0;INJECTED\x07")).toBe("safe"); - expect(sessionRenameRequest("/rename safe\u009c\u009d0;INJECTED\u009c")).toBe("safe"); - expect(sessionRenameRequest("/rename 修复\x1b[31m登录\x1b[0m ✓")).toBe("修复登录 ✓"); - }); - - test("ignores other commands and conversation text", () => { - expect(sessionRenameRequest("/model zai/glm-5.2")).toBeUndefined(); - expect(sessionRenameRequest("what does /rename do")).toBeUndefined(); - }); -}); diff --git a/test/session-model-recovery.test.ts b/test/session-model-recovery.test.ts deleted file mode 100644 index a0ff463e..00000000 --- a/test/session-model-recovery.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { expect, test } from "bun:test"; -import { assertSessionModelReady, readSessionModelState } from "../src/session-model-recovery.ts"; - -const selection = { providerId: "zai", modelId: "glm-5.3", options: { reasoningLevel: "high" } }; -const registry = { - validateSelection(value: typeof selection | Omit) { - if (value.providerId !== "zai") return { ok: false, code: "provider-not-found" }; - if (value.modelId !== "glm-5.3") return { ok: false, code: "model-not-found" }; - if (!("options" in value) || !value.options?.reasoningLevel) return { ok: false, code: "reasoning-level-missing" }; - return { ok: true }; - }, - getModel(provider: string, model: string) { - return provider === "zai" && model === "glm-5.3" ? { config: { optionSpecs: { reasoningLevel: { values: ["disabled", "high"] } } } } : undefined; - } -}; - -test("inspecting a new session without a saved entry does not request recovery", async () => { - expect(await readSessionModelState({ registry, sessionId: "new", sessionStore: { sessionEntries: async () => [] } })).toBeUndefined(); - expect(() => assertSessionModelReady({ registry, sessionId: "new" })).not.toThrow(); -}); - -test("the latest decoded selection is authoritative and inspection never mutates it", async () => { - const data = structuredClone(selection); - const result = await readSessionModelState({ registry, sessionId: "existing", sessionStore: { - sessionEntries: async () => [{ data: { ...selection, providerId: "old" } }, { data }] - } }); - expect(result).toMatchObject({ model: "zai/glm-5.3", thoughtLevel: "high", effortOptions: ["disabled", "high"] }); - expect(result?.issue).toBeUndefined(); - expect(data).toEqual(selection); -}); - -test.each([null, {}, { modelId: "glm-5.3" }])("a present but invalid entry requires a selection (%j)", async data => { - const state = await readSessionModelState({ registry, sessionId: "existing", sessionStore: { sessionEntries: async () => [{ data }] } }); - expect(state?.issue?.code).toBe("selection-missing"); - expect(() => assertSessionModelReady({ registry, sessionId: "existing", restored: { selection: data } })).toThrow("/model"); -}); - -test("a valid user replacement takes precedence over the originally restored broken selection", () => { - expect(() => assertSessionModelReady({ registry, sessionId: "existing", currentSelection: selection, - restored: { selection: { ...selection, providerId: "old" } } })).not.toThrow(); -}); - -test("store failures remain errors rather than being mistaken for missing models", async () => { - await expect(readSessionModelState({ registry, sessionId: "existing", sessionStore: { - sessionEntries: async () => { throw new Error("Database is unavailable"); } - } })).rejects.toThrow("Database is unavailable"); -}); diff --git a/test/session-status.test.ts b/test/session-status.test.ts deleted file mode 100644 index 93cd0099..00000000 --- a/test/session-status.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - contextRemainingPercent, - contextUsedPercent, - mergeMetrics, - mergeProjectionMetrics, - projectionMetrics, - sessionIdFromUsage, - usageMetrics -} from "../packages/zcode-tui/src/session-status.ts"; - -describe("TUI session status", () => { - test("normalizes projection and detailed usage snapshots", () => { - expect(projectionMetrics({ - contextUsed: 32_000, - contextWindow: 128_000, - totalTokenCount: 18_400, - turnCount: 4 - })).toEqual({ - contextUsed: 32_000, - contextWindow: 128_000, - totalTokens: 18_400, - turnCount: 4 - }); - expect(usageMetrics({ - totalTokens: 18_500, - inputTokens: 14_000, - outputTokens: 4_000, - reasoningTokens: 500, - cacheReadTokens: 9_000 - })).toMatchObject({ totalTokens: 18_500, inputTokens: 14_000, cacheReadTokens: 9_000 }); - }); - - test("merges authoritative updates and computes bounded context remaining", () => { - const metrics = mergeMetrics( - { contextUsed: 32_000, contextWindow: 128_000, totalTokens: 18_400 }, - { totalTokens: 18_500, inputTokens: 14_000 } - ); - expect(metrics.totalTokens).toBe(18_500); - expect(metrics.contextUsed).toBe(32_000); - expect(contextRemainingPercent(metrics)).toBe(75); - expect(contextRemainingPercent({ contextUsed: 200, contextWindow: 100 })).toBe(0); - }); - - test("keeps session-store totals authoritative over runtime projections", () => { - const usage = { totalTokens: 18_500, inputTokens: 14_000 }; - const projection = { totalTokens: 417_202, contextUsed: 32_000, contextWindow: 128_000 }; - - expect(mergeProjectionMetrics(usage, projection, true)).toEqual({ - totalTokens: 18_500, - inputTokens: 14_000, - contextUsed: 32_000, - contextWindow: 128_000 - }); - expect(mergeProjectionMetrics({}, projection, false).totalTokens).toBe(417_202); - }); - - test("does not erase metrics with absent fields and keeps used plus remaining at 100%", () => { - expect(mergeMetrics( - { totalTokens: 18_500, contextUsed: 32_000 }, - { contextWindow: 128_000, totalTokens: undefined } - )).toEqual({ totalTokens: 18_500, contextUsed: 32_000, contextWindow: 128_000 }); - - const metrics = { contextUsed: 1, contextWindow: 8 }; - expect(contextUsedPercent(metrics)).toBe(13); - expect(contextRemainingPercent(metrics)).toBe(87); - }); - - test("extracts the official session identifier from usage snapshots", () => { - expect(sessionIdFromUsage({ sessionId: "sess_protocol" })).toBe("sess_protocol"); - expect(sessionIdFromUsage({ sessionID: "sess_store" })).toBe("sess_store"); - expect(sessionIdFromUsage({ totalTokens: 10 })).toBeUndefined(); - }); -}); diff --git a/test/session-title.test.ts b/test/session-title.test.ts deleted file mode 100644 index 9c7c1119..00000000 --- a/test/session-title.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - MAX_SESSION_TITLE_CHARS, - SESSION_TITLE_PREFIX, - SESSION_TITLE_SPINNER_FRAME_DURATION_MS, - emitSessionTerminalTitle, - sessionTitleFromFirstMessage, - sessionTitleSpinnerFrame -} from "../packages/zcode-tui/src/session-title.ts"; - -describe("session title from first message", () => { - test("trims and collapses repeated whitespace", () => { - expect(sessionTitleFromFirstMessage(" fix the login bug ")).toBe("fix the login bug"); - }); - - test("collapses newlines into a single space", () => { - expect(sessionTitleFromFirstMessage("line one\nline two\n\nline three")).toBe( - "line one line two line three" - ); - }); - - test("strips terminal control sequences before titling", () => { - expect(sessionTitleFromFirstMessage("fix \x1b]0;owned\x07 the bug")).toBe("fix the bug"); - }); - - test("truncates long messages with an ellipsis", () => { - const message = "x".repeat(MAX_SESSION_TITLE_CHARS + 20); - const title = sessionTitleFromFirstMessage(message); - expect(title).toBe(`${"x".repeat(MAX_SESSION_TITLE_CHARS - 1)}…`); - expect(Array.from(title!)).toHaveLength(MAX_SESSION_TITLE_CHARS); - }); - - test("truncates by code point without splitting surrogate pairs", () => { - const emoji = "😀".repeat(MAX_SESSION_TITLE_CHARS + 5); - const title = sessionTitleFromFirstMessage(emoji); - expect(title).toBe(`${"😀".repeat(MAX_SESSION_TITLE_CHARS - 1)}…`); - expect(Array.from(title!)).toHaveLength(MAX_SESSION_TITLE_CHARS); - }); - - test("keeps CJK text intact when it fits", () => { - expect(sessionTitleFromFirstMessage("修复登录页面的空指针")).toBe("修复登录页面的空指针"); - }); - - test("returns null for empty or whitespace-only messages", () => { - expect(sessionTitleFromFirstMessage("")).toBeNull(); - expect(sessionTitleFromFirstMessage(" \n\t ")).toBeNull(); - }); -}); - -describe("terminal title emission", () => { - interface FakeStream { - isTTY: boolean; - output: string; - write: (chunk: string) => void; - } - - function fakeStream(isTTY: boolean): FakeStream { - const stream: FakeStream = { - isTTY, - output: "", - write(chunk: string) { - stream.output += chunk; - } - }; - return stream; - } - - test("writes the prefixed OSC 0 sequence to a TTY stream", () => { - const stream = fakeStream(true); - emitSessionTerminalTitle(stream, "fix the login bug"); - expect(stream.output).toBe(`\x1b]0;${SESSION_TITLE_PREFIX}fix the login bug\x07`); - }); - - test("clears the terminal title when given an empty title", () => { - const stream = fakeStream(true); - emitSessionTerminalTitle(stream, ""); - expect(stream.output).toBe("\x1b]0;\x07"); - }); - - test.each([ - "safe\x07\x1b]0;INJECTED\x07", - "safe\u009c\u009d0;INJECTED\u009c", - "safe\x1b[2J" - ])("strips control sequences at the output boundary: %j", (title) => { - const stream = fakeStream(true); - emitSessionTerminalTitle(stream, title); - expect(stream.output).toBe(`\x1b]0;${SESSION_TITLE_PREFIX}safe\x07`); - }); - - test("does not write to a non-TTY stream", () => { - const stream = fakeStream(false); - emitSessionTerminalTitle(stream, "fix the login bug"); - expect(stream.output).toBe(""); - }); - - test("does nothing when no stream is available", () => { - expect(() => emitSessionTerminalTitle(undefined, "fix the login bug")).not.toThrow(); - }); -}); - -describe("terminal title spinner", () => { - test("advances through stable-width frames", () => { - expect(sessionTitleSpinnerFrame(0)).toBe("⠋"); - expect(sessionTitleSpinnerFrame(SESSION_TITLE_SPINNER_FRAME_DURATION_MS)).toBe("⠙"); - expect(sessionTitleSpinnerFrame(10 * SESSION_TITLE_SPINNER_FRAME_DURATION_MS)).toBe("⠋"); - }); - - test("supports a settled frame for reduced motion", () => { - expect(sessionTitleSpinnerFrame(5_000, false)).toBe("⠋"); - }); -}); diff --git a/test/shared-oauth.test.ts b/test/shared-oauth.test.ts index 26bbec46..493685f3 100644 --- a/test/shared-oauth.test.ts +++ b/test/shared-oauth.test.ts @@ -17,25 +17,6 @@ function fixture(initial: Record = {}) { return { values, reads, reader: sharedOAuthCredentialReader(store, catalog) }; } -test("Desktop's active profile supplies the standalone identity without CLI login markers", async () => { - const f = fixture({ "oauth:active_provider": "zai", "oauth:zai:user_info": JSON.stringify({ user_id: "desktop-user" }), - [identity(zai)]: "corrupt" }); - expect(await f.reader.loadMany([identity(zai), identity(bigmodel)])).toEqual({ [identity(zai)]: "desktop-user", [identity(bigmodel)]: null }); - expect(f.values[identity(zai)]).toBe("corrupt"); - f.values["oauth:zai:user_info"] = JSON.stringify({ id: "new-user", username: "User", displayName: "User" }); - expect(await f.reader.load(identity(zai))).toBe("new-user"); - f.values["oauth:zai:user_info"] = JSON.stringify({ id: "normalized-user", rawProfile: { user_id: "raw-user" } }); - expect(await f.reader.load(identity(zai))).toBe("normalized-user"); - delete f.values["oauth:active_provider"]; - expect(await f.reader.load(identity(zai))).toBeNull(); -}); - -test("an explicit manual key remains usable without decrypting unrelated OAuth credentials", async () => { - const f = fixture({ [source(zai)]: "manual", [identity(zai)]: "manual-user", "oauth:active_provider": "corrupt" }); - expect(await f.reader.load(identity(zai))).toBe("manual-user"); - expect(f.reads.flat()).not.toContain("oauth:active_provider"); -}); - test("switching the active platform disconnects stale OAuth markers from the previous platform", async () => { const f = fixture({ "oauth:active_provider": "bigmodel", "oauth:bigmodel:user_info": JSON.stringify({ id: "bigmodel-user" }), [source(zai)]: "oauth", [identity(zai)]: "old-zai-user", "oauth:zai:user_info": "corrupt" }); @@ -51,13 +32,6 @@ test("a missing or malformed profile cannot reuse a stale CLI identity", async ( } }); -test("unrelated credential reads retain the native store contract", async () => { - const f = fixture({ "mcp:test": "value", "account-provider:custom:identity": "custom-user" }); - expect(await f.reader.loadMany(["mcp:test", "missing", "account-provider:custom:identity"])) - .toEqual({ "mcp:test": "value", missing: null, "account-provider:custom:identity": "custom-user" }); - expect(f.reads).toHaveLength(1); -}); - test("login cannot publish an OAuth token or project token as a business credential", () => { expect(zaiBusinessAccessToken({ zaiBusinessToken: " business-token " })).toBe("business-token"); expect(() => zaiBusinessAccessToken({})).toThrow("business access token"); diff --git a/test/shell-security.test.ts b/test/shell-security.test.ts new file mode 100644 index 00000000..52b92ca4 --- /dev/null +++ b/test/shell-security.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const shellRequire = createRequire(require.resolve("just-bash")); +const { sprintf, vsprintf } = shellRequire("sprintf-js") as { + sprintf(format: string, ...values: unknown[]): string; + vsprintf(format: string, values: unknown[]): string; +}; + +test("untrusted shell format precision cannot trigger the known formatter denial of service", () => { + for (const specifier of ["e", "f", "g"]) { + const format = `%.${"9".repeat(310)}${specifier}`; + const bounded = sprintf(`%.100${specifier}`, 1.25); + expect(sprintf(format, 1.25)).toBe(bounded); + expect(vsprintf(format, [1.25])).toBe(bounded); + expect(bounded.length).toBeLessThanOrEqual(106); + } + expect(sprintf("%.2f %d %.3s", 1.25, 42, "hello")).toBe("1.25 42 hel"); +}); diff --git a/test/shortcuts.test.ts b/test/shortcuts.test.ts deleted file mode 100644 index 74a91ea6..00000000 --- a/test/shortcuts.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { PickerSpec } from "../packages/zcode-tui/src/selectors.ts"; -import { - appliesToSetting, - nextMode, - nextPickerCommand, - normalizedMode, - settingTargetForCommand, - transcriptPageDirection -} from "../packages/zcode-tui/src/shortcuts.ts"; - -describe("TUI shortcuts", () => { - test("cycles permission modes including guarded without treating auto or plan as permissions", () => { - expect(normalizedMode("invalid")).toBe("build"); - expect(normalizedMode("guarded")).toBe("guarded"); - expect(nextMode("build")).toBe("edit"); - expect(nextMode("edit")).toBe("guarded"); - expect(nextMode("guarded")).toBe("yolo"); - expect(nextMode("auto")).toBe("build"); - expect(nextMode("yolo")).toBe("build"); - expect(nextMode("plan")).toBe("build"); - }); - - test("isolates mode, model, and effort command results", () => { - expect(settingTargetForCommand("/mode edit")).toBe("mode"); - expect(settingTargetForCommand(" /model local/glm ")).toBe("model"); - expect(settingTargetForCommand("/effort max")).toBe("effort"); - expect(settingTargetForCommand("/variant high")).toBe("effort"); - expect(settingTargetForCommand("/help")).toBeUndefined(); - - expect(appliesToSetting("model", "model")).toBe(true); - expect(appliesToSetting("model", "mode")).toBe(false); - expect(appliesToSetting("effort", "model")).toBe(false); - expect(appliesToSetting(undefined, "mode")).toBe(true); - }); - - test("cycles picker commands and wraps at the final item", () => { - const picker: PickerSpec = { - selectedIndex: 0, - items: [ - { value: "alpha", label: "Alpha", command: "/set alpha" }, - { value: "beta", label: "Beta", command: "/set beta" } - ] - }; - - expect(nextPickerCommand(picker, "alpha")).toBe("/set beta"); - expect(nextPickerCommand(picker, "beta")).toBe("/set alpha"); - expect(nextPickerCommand(picker, "missing")).toBe("/set alpha"); - expect(nextPickerCommand({ ...picker, items: picker.items.slice(0, 1) }, "alpha")).toBeUndefined(); - }); - - test("pages transcript content on key presses without repeating on Kitty releases", () => { - expect(transcriptPageDirection("\x1b[D")).toBe(-1); - expect(transcriptPageDirection("\x1b[C")).toBe(1); - expect(transcriptPageDirection("\x1b[5~")).toBe(-1); - expect(transcriptPageDirection("\x1b[6~")).toBe(1); - expect(transcriptPageDirection("\x1b[1;1:1C")).toBe(1); - expect(transcriptPageDirection("\x1b[1;1:3C")).toBeUndefined(); - }); -}); diff --git a/test/skill-autocomplete.test.ts b/test/skill-autocomplete.test.ts deleted file mode 100644 index 92c38ea1..00000000 --- a/test/skill-autocomplete.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { WorkspaceAutocompleteProvider } from "../packages/zcode-tui/src/workspace-autocomplete.ts"; -import type { SkillSuggestionResult } from "../packages/zcode-tui/src/types.ts"; - -function signal(): AbortSignal { - return new AbortController().signal; -} - -describe("workspace $ skill autocomplete", () => { - test("queries the runtime skill lister and inserts a selected skill", async () => { - let calls = 0; - const provider = new WorkspaceAutocompleteProvider( - [], - process.cwd(), - undefined, - async () => { - calls += 1; - return { - skills: [ - { name: "animate", description: "Add motion to a feature." } - ] - } satisfies SkillSuggestionResult; - } - ); - const input = "polish $anim"; - - const suggestions = await provider.getSuggestions([input], 0, input.length, { - signal: signal() - }); - - expect(calls).toBe(1); - expect(suggestions).toEqual({ - prefix: "$anim", - items: [ - { value: "$animate", label: "animate", description: "Add motion to a feature." } - ] - }); - - const completion = provider.applyCompletion( - [input], - 0, - input.length, - suggestions!.items[0]!, - suggestions!.prefix - ); - expect(completion).toEqual({ - lines: ["polish $animate "], - cursorLine: 0, - cursorCol: "polish $animate ".length - }); - }); - - test("uses qualifiedName when present so plugin skills stay disambiguable", async () => { - const provider = new WorkspaceAutocompleteProvider( - [], - process.cwd(), - undefined, - async () => - ({ - skills: [ - { - name: "control-browser", - qualifiedName: "browser-use:control-browser", - description: "Drive a browser." - } - ] - }) satisfies SkillSuggestionResult - ); - const input = "$control"; - - const suggestions = await provider.getSuggestions([input], 0, input.length, { - signal: signal() - }); - - expect(suggestions?.items[0]).toEqual({ - value: "$browser-use:control-browser", - label: "browser-use:control-browser", - description: "Drive a browser." - }); - }); - - test("filters case-insensitively on name and qualifiedName", async () => { - const provider = new WorkspaceAutocompleteProvider( - [], - process.cwd(), - undefined, - async () => - ({ - skills: [ - { name: "Distill", description: "Simplify." }, - { name: "document-skills:docx", qualifiedName: "document-skills:docx" } - ] - }) satisfies SkillSuggestionResult - ); - - const byName = await provider.getSuggestions(["$dis"], 0, 4, { signal: signal() }); - expect(byName?.items.map((item) => item.value)).toEqual(["$Distill"]); - - const byQualified = await provider.getSuggestions(["$doc"], 0, 4, { signal: signal() }); - expect(byQualified?.items.map((item) => item.value)).toEqual([ - "$document-skills:docx" - ]); - }); - - test("does not query skills when there is no $ prefix and falls back to slash commands", async () => { - let calls = 0; - const provider = new WorkspaceAutocompleteProvider( - [{ name: "help", description: "Show help" }], - process.cwd(), - undefined, - async () => { - calls += 1; - return { skills: [{ name: "animate" }] } satisfies SkillSuggestionResult; - } - ); - - const slash = await provider.getSuggestions(["/he"], 0, 3, { signal: signal() }); - expect(slash).toMatchObject({ - prefix: "/he", - items: [{ value: "help", label: "help", description: "Show help" }] - }); - expect(calls).toBe(0); - - const plain = "just a normal sentence"; - expect( - await provider.getSuggestions([plain], 0, plain.length, { signal: signal() }) - ).toBeNull(); - expect(calls).toBe(0); - }); - - test("ignores $ that is not at a token boundary", async () => { - let calls = 0; - const provider = new WorkspaceAutocompleteProvider( - [], - process.cwd(), - undefined, - async () => { - calls += 1; - return { skills: [{ name: "animate" }] } satisfies SkillSuggestionResult; - } - ); - - // `a$b` keeps `$` mid-token (previous char is not a boundary), so it must - // not trigger the skill lister — consistent with how `@` is handled. - const input = "pay$cost"; - expect( - await provider.getSuggestions([input], 0, input.length, { signal: signal() }) - ).toBeNull(); - expect(calls).toBe(0); - }); - - test("isolates skill-lister failures from the editor", async () => { - const provider = new WorkspaceAutocompleteProvider( - [], - process.cwd(), - undefined, - async () => { - throw new Error("skill discovery unavailable"); - } - ); - - expect( - await provider.getSuggestions(["$an"], 0, 3, { signal: signal() }) - ).toBeNull(); - }); - - test("declares $ as a trigger character", () => { - const provider = new WorkspaceAutocompleteProvider([], process.cwd(), undefined, undefined); - expect(provider.triggerCharacters).toContain("$"); - }); - - test("does not duplicate whitespace after a completed mention", async () => { - const provider = new WorkspaceAutocompleteProvider( - [], - process.cwd(), - undefined, - async () => ({ skills: [{ name: "animate" }] }) - ); - const input = "$anim existing"; - const suggestions = await provider.getSuggestions([input], 0, 5, { signal: signal() }); - const completion = provider.applyCompletion( - [input], - 0, - 5, - suggestions!.items[0]!, - suggestions!.prefix - ); - - expect(completion).toEqual({ - lines: ["$animate existing"], - cursorLine: 0, - cursorCol: "$animate".length - }); - }); - - test("rejects unsafe identifiers and strips terminal controls from descriptions", async () => { - const provider = new WorkspaceAutocompleteProvider( - [], - process.cwd(), - undefined, - async () => ({ - skills: [ - { name: "unsafe\u001b]52;c;dGVzdA==\u0007" }, - { name: "safe", description: "Clear\u001b[2J screen" } - ] - }) - ); - - const suggestions = await provider.getSuggestions(["$"], 0, 1, { signal: signal() }); - expect(suggestions?.items).toEqual([ - { value: "$safe", label: "safe", description: "Clear screen" } - ]); - }); -}); diff --git a/test/skills.test.ts b/test/skills.test.ts deleted file mode 100644 index 3a286611..00000000 --- a/test/skills.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - buildSkillInvocationPrompt, - normalizeSkillEntries, - resolveSkillMentions, - SkillCatalog -} from "../packages/zcode-tui/src/skills.ts"; - -describe("explicit skill invocation", () => { - test("builds the same single-skill contract as the runtime /skill command", () => { - expect(buildSkillInvocationPrompt(["audit"], "Review this change.")).toBe([ - "Use the skill named `audit` for this turn.", - "First call the `Skill` tool with name `audit` before doing the task.", - "After the skill content is loaded, follow its instructions and continue.", - "", - "User request:", - "Review this change." - ].join("\n")); - }); - - test("resolves selected qualified names and multiple mentions in prompt order", async () => { - const catalog = new SkillCatalog(async () => ({ - skills: [ - { name: "audit" }, - { name: "control-browser", qualifiedName: "browser-use:control-browser" } - ] - })); - - const prepared = await catalog.preparePrompt( - "Use $audit and $browser-use:control-browser, then summarize." - ); - expect(prepared?.identifiers).toEqual(["audit", "browser-use:control-browser"]); - expect(prepared?.text).toContain( - "First call the `Skill` tool once for each of these names before doing the task" - ); - expect(prepared?.text).toContain( - "User request:\nUse $audit and $browser-use:control-browser, then summarize." - ); - }); - - test("uses a unique bare alias but skips ambiguous and unknown mentions", () => { - const skills = normalizeSkillEntries({ - skills: [ - { name: "audit" }, - { name: "docx", qualifiedName: "documents:docx" }, - { name: "docx", qualifiedName: "office:docx" } - ] - }); - - expect(resolveSkillMentions("$audit $missing $HOME", skills)).toEqual(["audit"]); - expect(resolveSkillMentions("$docx", skills)).toEqual([]); - expect(resolveSkillMentions("$documents:docx", skills)).toEqual(["documents:docx"]); - }); - - test("shares a short-lived discovery result between autocomplete and submission", async () => { - let calls = 0; - const catalog = new SkillCatalog(async () => { - calls += 1; - return { skills: [{ name: "audit" }] }; - }); - - expect(await catalog.list()).toEqual([{ identifier: "audit", name: "audit" }]); - expect((await catalog.preparePrompt("$audit this"))?.identifiers).toEqual(["audit"]); - expect(calls).toBe(1); - }); - - test("treats synchronous discovery failures as unavailable completion", async () => { - let calls = 0; - const catalog = new SkillCatalog(() => { - calls += 1; - throw new Error("skill discovery unavailable"); - }); - expect(await catalog.list()).toEqual([]); - expect(await catalog.preparePrompt("$audit this")).toBeUndefined(); - expect(calls).toBe(1); - }); - - test("truncates descriptions without splitting emoji graphemes", () => { - const [skill] = normalizeSkillEntries({ - skills: [{ name: "audit", description: `${"a".repeat(138)}😀xy` }] - }); - expect(skill?.description).toBe(`${"a".repeat(138)}😀…`); - }); -}); diff --git a/test/sprintf-precision.test.ts b/test/sprintf-precision.test.ts deleted file mode 100644 index bf6a5bca..00000000 --- a/test/sprintf-precision.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createRequire } from "node:module"; - -import { describe, expect, test } from "bun:test"; - -// Resolve the formatter through the shell dependency, including nested installs. -const require = createRequire(import.meta.url); -const shellRequire = createRequire(require.resolve("just-bash")); -const { sprintf, vsprintf } = shellRequire("sprintf-js") as { - sprintf(format: string, ...values: unknown[]): string; - vsprintf(format: string, values: unknown[]): string; -}; - -describe("patched shell floating-point formatting", () => { - test.each(["e", "f", "g"])("bounds oversized %s precision without a RangeError", (specifier) => { - for (const precision of ["101", "999999", "9".repeat(310)]) { - const format = `%.${precision}${specifier}`; - const expected = sprintf(`%.100${specifier}`, 1.25); - expect(sprintf(format, 1.25)).toBe(expected); - expect(vsprintf(format, [1.25])).toBe(expected); - expect(expected.length).toBeLessThanOrEqual(106); - } - }); - - test("preserves ordinary precision, zero precision and string truncation", () => { - expect(sprintf("%.2f %.0f %.0e %.3g", 1.25, 1.25, 1.25, 1.25)).toBe("1.25 1 1e+0 1.25"); - expect(sprintf("%.0g", 1.25)).toBe("1"); - expect(sprintf("%08.2f %d %.3s", 1.25, 42, "hello")).toBe("00001.25 42 hel"); - expect(sprintf("%.101s", "x".repeat(102))).toBe("x".repeat(101)); - expect(sprintf("%2$.101f", 0, 1.25)).toBe(sprintf("%.100f", 1.25)); - expect(sprintf("%(value).101f", { value: 1.25 })).toBe(sprintf("%.100f", 1.25)); - }); -}); diff --git a/test/status-line.test.ts b/test/status-line.test.ts deleted file mode 100644 index c5d3b909..00000000 --- a/test/status-line.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { StatusLine } from "../packages/zcode-tui/src/status-line.ts"; - -const fields = [ - { text: "◈ alpha/model", priority: 100, required: true }, - { text: "◉ build", priority: 70 }, - { text: "⚡ max", priority: 60 }, - { text: "ctx 75% left", compactText: "ctx 75%", priority: 90 }, - { text: "18.4K tokens", compactText: "18.4K tok", priority: 20 } -]; - -describe("TUI status line", () => { - test("shortens the model to keep the real permission mode visible in a narrow terminal", () => { - const status = new StatusLine(); - status.setFields([ - { text: "◈ provider/a-very-long-model-name-that-will-not-fit", required: true, priority: 100, minWidth: 3 }, - { text: "◉ yolo", required: true, priority: 100 } - ]); - for (const width of [16, 24, 40]) { - const line = status.render(width)[0]!; - expect(line).toContain("◉ yolo"); - expect(visibleWidth(line)).toBeLessThanOrEqual(width); - } - }); - test("shows all session metadata when space is available", () => { - const status = new StatusLine(); - status.setFields(fields, " ─ "); - const [compactLine] = status.render(60); - const [fullLine] = status.render(80); - expect(compactLine).toContain("◈ alpha/model ─ ◉ build ─ ⚡ max ─ ctx 75% ─ 18.4K tok"); - expect(fullLine).toContain("◈ alpha/model ─ ◉ build ─ ⚡ max ─ ctx 75% left ─ 18.4K tokens"); - expect(visibleWidth(compactLine ?? "")).toBeLessThanOrEqual(60); - expect(visibleWidth(fullLine ?? "")).toBeLessThanOrEqual(80); - }); - - test("keeps model and context while dropping lower-priority fields", () => { - const status = new StatusLine(); - status.setFields(fields, " ─ "); - const [line] = status.render(24); - expect(line).toBe(" ◈ alpha/model ─ ctx 75%"); - expect(line).not.toContain("tokens"); - expect(line).not.toContain("build"); - expect(visibleWidth(line ?? "")).toBeLessThanOrEqual(24); - }); - - test("treats cache health as a compact, disposable field", () => { - const status = new StatusLine(); - status.setFields([ - { text: "◈ model", priority: 100, required: true }, - { text: "cache 99% hit", compactText: "cache 99%", priority: 80 }, - { text: "18.4K tokens", compactText: "18.4K tok", priority: 20 } - ], " ─ "); - const [line] = status.render(26); - expect(line).toContain("◈ model"); - expect(line).toContain("cache 99%"); - expect(line).not.toContain("tokens"); - }); -}); diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts deleted file mode 100644 index 7c0524c2..00000000 --- a/test/sync-runtime.test.ts +++ /dev/null @@ -1,1799 +0,0 @@ -import { fixtureSubagentQueries, subagentRestoreFixture } from "./fixtures/runtime-subagents.ts"; -import { restoreTuiBackgroundTasks } from "../src/runtime-background-restore.ts"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -import { describe, expect, test } from "bun:test"; -import { readTuiRuntimeProjection, sendTuiBackgroundTaskMessage } from "../src/runtime-tui-bridge.ts"; -import { assertSessionModelReady } from "../src/session-model-recovery.ts"; - -import { - applyRuntimePatchPlan, - chooseArtifact, - extractRuntimeCapabilities, - formatRuntimeCompatibilityFailure, - hasRuntimeCliHelpContract, - hasRuntimeCliSettingsFile, - hasRuntimeHttpNoContentGuard, - hasRuntimeNetworkRetryGuard, - hasRuntimeSqliteBusyTimeout, - hasRuntimeSqliteWriteRecovery, - hasRuntimeStreamEofFinishGuard, - hasRuntimeTrackedDetachedAgentLifecycle, - installRuntimeProviderConfig, - parseArgs, - parseRuntimePatchReports, - parseRuntimeLock, - patchRuntimeAgentAutoBackground, - patchRuntimeBigmodelSessionReuse, - patchRuntimeCliHelpContract, - patchRuntimeCliCredentials, - patchRuntimeCliSettingsFile, - patchRuntimeDetachedAgentLifecycle, - patchRuntimeGoalFailurePause, - patchRuntimeHttpNoContent, - patchRuntimeLoginModelDefaults, - patchRuntimeNetworkRetryClassification, - patchRuntimeOAuthHttpErrors, - patchRuntimeSessionModelRecovery, - patchRuntimeSharedConfig, - patchRuntimeSqliteBusyTimeout, - patchRuntimeSqliteWriteRecovery, - patchRuntimeStreamEofFinishGuard, - patchRuntimeTerminalToolProjection, - patchRuntimeTuiBridge as patchExtractedRuntimeTuiBridge, - runtimeSubagentQueryBridge, - patchRuntimeTuiExecutionState, - patchRuntimeZaiDesktopOAuth, - resolveArtifactUrl, - resolveLatestRuntimeLock, - selectRuntimeLock, - serviceManifestUrl, - serviceReleasePlatform, - sqliteBusyTimeoutMs, - supportsMultiMessageFileRewind, - writeRuntimeCompatibilityFailure -} from "../scripts/sync-runtime.ts"; - -describe("CLI settings file override", () => { - const home = join(tmpdir(), "settings-fixture-home"); - // Native settings and trust-store path resolution after the shared-config patch. - const trustSource = 'async function trustPath(e={}){let t=(0,P.resolve)(e.homeDir??(0,O.homedir)()),n=(0,P.resolve)(e.userConfigPath??(0,P.join)(t,".zcode","cli","config.json"));return n}'; - const source = 'var F="setting.json",B="~/.zcode/cli";' - + 'function resolvePath(e){return e.startsWith("~/")?(0,P.join)((0,O.homedir)(),e.slice(2)):(0,P.resolve)(e)}' - + 'function load(e,t={}){let n=e?resolvePath(e):(0,P.join)(resolvePath(t.baseDir??B),t.configFileName??F);return n}' - + 'function settingsPath(){return(0,P.join)(resolvePath(B),F)}' - + trustSource; - - function runtime(env: Record) { - const patched = patchRuntimeCliSettingsFile(source); - const P = { join, resolve }; - const require = (id: string) => { - if (id !== "node:path") throw new Error(id); - return P; - }; - return new Function("P", "O", "require", "process", `${patched};return {load,settingsPath,trustPath}`)(P, { homedir: () => home }, require, { env }); - } - - test.each([undefined, "", " \t "])("keeps default paths for an unset or blank override (%j)", async (value) => { - const plain = runtime(value === undefined ? {} : { ZCODE_CLI_SETTINGS_FILE: value }); - expect(plain.load()).toBe(join(home, ".zcode", "cli", "setting.json")); - expect(plain.settingsPath()).toBe(plain.load()); - expect(await plain.trustPath()).toBe(join(home, ".zcode", "cli", "config.json")); - }); - - test("uses ZCODE_CLI_SETTINGS_FILE for settings and hook trust, preserving explicit paths", async () => { - const patched = patchRuntimeCliSettingsFile(source); - expect(patchRuntimeCliSettingsFile(patched)).toBe(patched); - expect(hasRuntimeCliSettingsFile(patched)).toBe(true); - - const hostFile = join(home, "host", "card.json"), explicit = join(home, "explicit.json"); - const project = join(home, "project", ".zcode"); - const host = runtime({ ZCODE_CLI_SETTINGS_FILE: ` ${hostFile} ` }); - expect(host.load()).toBe(hostFile); - expect(host.settingsPath()).toBe(hostFile); - expect(await host.trustPath()).toBe(hostFile); - expect(host.load(explicit)).toBe(explicit); - expect(await host.trustPath({ userConfigPath: explicit })).toBe(explicit); - expect(host.load(undefined, { baseDir: project, configFileName: "config.json" })).toBe(join(project, "config.json")); - expect(host.load(undefined, { baseDir: project })).toBe(join(project, "setting.json")); - expect(host.load(undefined, { configFileName: "custom.json" })).toBe(join(home, ".zcode", "cli", "custom.json")); - }); - - test("upgrades an existing settings override that lacks the hook trust patch", () => { - const patched = patchRuntimeCliSettingsFile(source); - const previous = patched.replace('e.userConfigPath??(process.env.ZCODE_CLI_SETTINGS_FILE?.trim()||(0,P.join)(t,".zcode","cli","config.json"))', - 'e.userConfigPath??(0,P.join)(t,".zcode","cli","config.json")'); - expect(previous).not.toBe(patched); - expect(hasRuntimeCliSettingsFile(previous)).toBe(false); - expect(patchRuntimeCliSettingsFile(previous)).toBe(patched); - }); - - test("refuses missing or ambiguous anchors even when the environment variable is mentioned", () => { - expect(() => patchRuntimeCliSettingsFile("incompatible runtime")).toThrow(/settings path patch/); - expect(() => patchRuntimeCliSettingsFile('"ZCODE_CLI_SETTINGS_FILE"')).toThrow(/settings path patch/); - expect(() => patchRuntimeCliSettingsFile(source.replace(trustSource, ""))).toThrow(/hook trust anchor/); - expect(() => patchRuntimeCliSettingsFile(source + trustSource)).toThrow(/hook trust anchor/); - }); -}); - -describe("CLI credential isolation", () => { - test("keeps explicit paths first and isolates environment-selected credentials", () => { - const source = 'function resolve(e={}){if(e.filePath)return e.filePath;return join(".zcode","v2","credentials.json")}'; - const patched = patchRuntimeCliCredentials(source); - expect(patchRuntimeCliCredentials(patched)).toBe(patched); - const resolve = new Function("join", `${patched};return resolve`)((...parts: string[]) => parts.join("/")); - expect(resolve({ env: {} })).toBe(".zcode/v2/credentials.json"); - expect(resolve({ env: { ZCODE_CLI_CREDENTIALS_FILE: " /cli/credentials.json " } })).toBe("/cli/credentials.json"); - expect(resolve({ filePath: "/explicit", env: { ZCODE_CLI_CREDENTIALS_FILE: "/cli" } })).toBe("/explicit"); - expect(() => patchRuntimeCliCredentials("incompatible runtime")).toThrow(/credential path patch/); - }); -}); -import { - compareReleaseVersions, - nextBuildVersion, - parseReleaseVersion, - syncedReleaseVersion -} from "../scripts/release-version.ts"; - -const subagentQueryAnchors = 'var queryInit=init(()=>{label(collectChildren,"collectSubagentChildSessionIds");label(projectSubagents,"projectSessionSubagents")});var reducerInit=init(()=>{NativeReducer=class{static{label(this,"EventReducer")}}});'; -function patchRuntimeTuiBridge(runtime: string): string { - return patchExtractedRuntimeTuiBridge(runtime.includes('"collectSubagentChildSessionIds"') ? runtime : runtime + subagentQueryAnchors); -} - -function bridgeFunction(...args: string[]): (...values: unknown[]) => unknown { - const body = args.pop()!; - const factory = new Function("require", "__dirname", "queryInit", "reducerInit", "collectChildren", "projectSubagents", "NativeReducer", ...args, body); - return factory.bind(null, (id: string) => id === "node:path" ? { join } - : { readTuiRuntimeProjection, sendTuiBackgroundTaskMessage, restoreTuiBackgroundTasks }, "/fixture", - () => {}, () => {}, fixtureSubagentQueries.collectChildSessionIds, fixtureSubagentQueries.projectSubagents, - class { reduce(events: unknown[]) { return fixtureSubagentQueries.projectChildEvents(events); } }); -} - -describe("runtime synchronization", () => { - test("keeps the project config scope when merging shared Desktop settings", () => { - const source = [ - 'var F="config.json",B="~/.zcode/cli";', - 'var M1=W(()=>{"use strict";r(Imp,"importLegacyCliPersonalProviderConfig")});', - 'var M2=W(()=>{"use strict";Repo=class{static{r(this,"NodePersonalProviderConfigRepository")}}});', - 'async function main(){let e=process.argv.slice(2);return await run(e)}', - 'function load(n,t={}){try{let o=(0,R.readFileSync)(n,"utf-8"),i=JSON.parse(o),a=BJs(i);let c=Lso(i,t.scope);return{config:c.config,path:n}}catch(o){return{config:{},path:n}}}' - ].join(""); - const patched = patchRuntimeSharedConfig(source); - - expect(patched).toContain('F="setting.json"'); - expect(patched).toContain( - 'Lso(require(require("node:path").join(__dirname,"cli-config.cjs")).mergeDesktopSettings(i,n,process.env),t.scope)' - ); - expect(patchRuntimeSharedConfig(patched)).toBe(patched); - - expect(() => patchRuntimeSharedConfig(source.replace(",t.scope", ""))).toThrow(/settings\/migration anchors/); - expect(() => patchRuntimeSharedConfig(source.replace("let c=Lso(i,t.scope);", ""))).toThrow(/settings\/migration anchors/); - }); - - test("requires native subagent query and reducer anchors", () => { - expect(runtimeSubagentQueryBridge(subagentQueryAnchors)).toContain("projectSubagents:projectSubagents"); - for (const name of ["collectSubagentChildSessionIds", "projectSessionSubagents", "EventReducer"]) { - expect(() => runtimeSubagentQueryBridge(subagentQueryAnchors.replace(name, "missing"))).toThrow(`${name} anchor missing`); - } - }); - - test.each(["", "saved&&ok&&!ok.ok&&Boolean(ok.code),"])("adds session recovery at native restore and execution boundaries with diagnostics %j", async (diagnostic) => { - const runtime = 'async function app(options){let cliDefault={set(){}},read=()=>runtime,restore=label(async()=>{let ok=saved&®istry.validateSelection(saved);return ' - + diagnostic + 'read().setSessionModelSelection(ok?.ok?saved:void 0),cliDefault?.set(ok?.ok?saved:void 0),saved},"restorePersistedModelSelection"),prepare=label(async args=>{await options.prepareUserExecutionBoundary(args)},"preparePromptBoundary");return{restore,prepare,getModelOption:label(model=>lookup(context.providerRegistry,model),"getModelOption")}}'; - const patched = patchRuntimeSessionModelRecovery(runtime); - expect(patched).toContain('readSessionModelState:label(async()=>await'); - expect(patched).toContain('read().$zRestoredSessionModel={selection:saved,registry:registry}'); - expect(patched).toContain('cliDefaultSelection?.get?.()'); - expect(patched).toContain('assertSessionModelReady({registry:options.runtime.$zRestoredSessionModel?.registry'); - expect(patchRuntimeSessionModelRecovery(patched)).toBe(patched); - expect(() => patchRuntimeSessionModelRecovery(runtime.replace('"restorePersistedModelSelection"', '"renamed"'))) - .toThrow("restore/facade anchors missing"); - expect(() => patchRuntimeSessionModelRecovery(runtime.replace(",cliDefault?.set(ok?.ok?saved:void 0)", ""))) - .toThrow("restore/facade anchors missing"); - const saved = { providerId: "old", modelId: "model" }; - const registry = { - validateSelection: (selection: { providerId: string }) => selection.providerId === "valid" ? { ok: true } : { ok: false, code: "provider-not-found" }, - getModel: () => undefined - }; - let current: unknown; - const core = { getSessionModelSelection: () => current, setSessionModelSelection: (value: unknown) => { current = value; } }; - const create = new Function("label", "registry", "saved", "runtime", "require", "__dirname", patched + ";return app;")( - (fn: unknown) => fn, registry, saved, core, - (id: string) => id === "node:path" ? { join } : { assertSessionModelReady }, "/fixture" - ); - // The input facade does not receive providerRegistry; the restore closure owns it. - const facade = await create({ runtime: core, sessionId: "test", prepareUserExecutionBoundary: async () => { await facade.restore(); } }); - await expect(facade.prepare({})).rejects.toThrow("the provider is unavailable"); - await expect(facade.prepare({ intent: { modelSelection: { providerId: "valid", modelId: "model" } } })).resolves.toBeUndefined(); - // cli-default admission reads CliDefaultSelection; a replacement chosen there must clear the saved-model block. - const recovered = await create({ - runtime: core, sessionId: "test", - cliDefaultSelection: { get: () => ({ providerId: "valid", modelId: "model" }) }, - prepareUserExecutionBoundary: async () => { await recovered.restore(); } - }); - await expect(recovered.prepare({})).resolves.toBeUndefined(); - }); - - test("projects native planEnabled separately from permission mode", async () => { - const source = 'const a=fn=>fn;const modes=["plan","build","edit","yolo"];a(format,"formatAvailableCommandCenterModes");function format(){return modes.join(", ")}const planning=()=>e.runtime.getPlanEnabled();const app={getMode:a(()=>e.runtime.getMode(),"getMode"),setMode:a(async mode=>{let previous=e.runtime.getMode();await e.runtime.setExecutionState({mode:mode},e.traceContext);return{mode:e.runtime.getMode(),previousMode:previous,traceId:e.traceContext.traceId}},"setMode")};'; - let mode = "edit", planEnabled = false; - const patched = patchRuntimeTuiExecutionState(source); - expect(new Function(`${patched};return format();`)()).toBe("build, edit, yolo"); - const app = new Function("e", `${patched};return app;`)({ runtime: { - getMode: () => mode, getPlanEnabled: () => planEnabled, - setExecutionState: async (state: { mode?: string; planEnabled?: boolean }) => { - planEnabled = state.planEnabled ?? (state.mode ? false : planEnabled); - if (state.mode) mode = state.mode; - } - }, prepareResume: async () => {}, traceContext: { traceId: "fixture" } }); - expect(await app.readExecutionState()).toEqual({ mode: "edit", planEnabled: false }); - expect(await app.setPlanEnabled(true)).toEqual({ mode: "edit", planEnabled: true }); - expect(app.getMode()).toBe("edit"); - expect(await app.setMode("yolo")).toMatchObject({ mode: "yolo", previousMode: "edit", planEnabled: true }); - expect(await app.setPlanEnabled(false)).toEqual({ mode: "yolo", planEnabled: false }); - expect(patchRuntimeTuiExecutionState(patched)).toBe(patched); - expect(() => patchRuntimeTuiExecutionState("unknown runtime")).toThrow("execution state"); - }); - - test("preserves guarded mode in the 3.15.1 facade and CLI parser while separating planning", async () => { - const source = 'const label=fn=>fn;const modes=["plan","build","edit","guarded","yolo"];label(format,"formatAvailableCommandCenterModes");function format(){return modes.join(", ")}const planning=()=>context.runtime.getPlanEnabled();const app={getMode:label(()=>context.runtime.getMode(),"getMode"),setMode:label(async mode=>{let previous=context.runtime.getMode(),session=await context.sessionStore.getSession(context.sessionId);await context.runtime.setExecutionState({mode:mode},context.traceContext);return{mode:context.runtime.getMode(),previousMode:previous,traceId:context.traceContext.traceId}},"setMode")};function normalize(value){if(value===undefined)return;let mode=value.toLowerCase();if(mode==="build"||mode==="plan"||mode==="edit"||mode==="yolo"||mode==="guarded")return mode;throw new Error(`Unsupported --mode value: ${value}. Supported modes: build, edit, plan, yolo, guarded.`)}'; - const patched = patchRuntimeTuiExecutionState(source); - let mode = "build", planEnabled = true; - const fixture = new Function("context", `${patched};return {app,format,normalize};`)({ - runtime: { - getMode: () => mode, - getPlanEnabled: () => planEnabled, - setExecutionState: async (state: { mode?: string; planEnabled?: boolean }) => { - planEnabled = state.planEnabled ?? (state.mode ? false : planEnabled); - if (state.mode) mode = state.mode; - } - }, - sessionStore: { getSession: async () => ({}) }, - prepareResume: async () => {}, - traceContext: { traceId: "guarded-fixture" } - }); - expect(fixture.format()).toBe("build, edit, guarded, yolo"); - expect(fixture.normalize("GUARDED")).toBe("guarded"); - expect(() => fixture.normalize("plan")).toThrow("Use /plan in the TUI to toggle planning."); - expect(() => fixture.normalize("auto")).toThrow("Unsupported --mode value"); - expect(await fixture.app.setMode("guarded")).toMatchObject({ mode: "guarded", previousMode: "build", planEnabled: true }); - expect(await fixture.app.setPlanEnabled(false)).toEqual({ mode: "guarded", planEnabled: false }); - expect(patchRuntimeTuiExecutionState(patched)).toBe(patched); - expect(applyRuntimePatchPlan(source, [ - { id: "tui-execution-state", requirement: "required", apply: patchRuntimeTuiExecutionState } - ]).reports[0]?.status).toBe("applied"); - }); - - test("copies the registry catalog from Desktop resources and rejects missing assets", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-registry-sync-")); - const glm = join(directory, "resources", "glm"), vendor = join(directory, "vendor"); - const config = join(directory, "resources", "config", "provider"); - try { - await mkdir(vendor, { recursive: true }); - await writeFile(join(vendor, "zcode.cjs"), 'label(resolveConfig,"resolveBundledZCodeBuiltinProviderConfig")'); - await expect(installRuntimeProviderConfig(glm, vendor)).rejects.toThrow("missing config/provider"); - await mkdir(config, { recursive: true }); - await writeFile(join(config, "zcode-builtin.json"), '{"revision":28}'); - await installRuntimeProviderConfig(glm, vendor); - expect(await readFile(join(vendor, "provider", "zcode-builtin.json"), "utf8")).toBe('{"revision":28}'); - await writeFile(join(config, "zcode-builtin.json"), '{"revision":29}'); - await installRuntimeProviderConfig(glm, vendor); - expect(await readFile(join(vendor, "provider", "zcode-builtin.json"), "utf8")).toBe('{"revision":29}'); - await writeFile(join(vendor, "zcode.cjs"), "legacy runtime"); - await rm(config, { recursive: true }); - await expect(installRuntimeProviderConfig(glm, vendor)).rejects.toThrow("required provider registry"); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); - - test("keeps registry login defaults driven by the selected provider catalog", async () => { - const source = [ - 'const label=(fn)=>fn;let saved;const store={async saveConfiguredDefault(value){saved=value}};', - 'function catalog(e){return e.providers.flatMap(([id,p])=>{let a=p.access,m=p.builtinModelIds?.find(x=>x.trim())?.trim();return a?.type==="zhipu-account"&&a.mode==="individual-coding-plan"&&a.accountType&&m?[{family:a.accountType,modelId:m,providerId:id}]:[]})}', - 'async function resolveProvider(family,env){let p=catalog(env).filter(x=>x.family===family);if(p.length!==1)throw new Error("ambiguous provider");return p[0]}', - 'async function persist(e){let p=await resolveProvider(e.providerId,e.env),id=p.providerId,model=p.modelId,unused;await store.saveConfiguredDefault({providerId:id,modelId:model});return saved}', - 'function initial(e){return e.configuredDefault?.options?.reasoningLevel?e.configuredDefault:{providerId:"wrong",modelId:"fallback"}}', - 'function complete(registry,selection){return {...selection,options:{reasoningLevel:"high"}}}', - 'function switchModel(e,t){let selected=t;return e.cliDefaultSelection&&(!t?.transient||t?.cliDefault)?e.cliDefaultSelection.set(selected):e.runtime.setSessionModelSelection(selected),t?.transient||void 0,selected}', - 'label(initial,"resolveInitialModelSelection");label(complete,"completeNewModelSelection");', - 'label(catalog,"readStandaloneCodingPlanCatalog");label(resolveProvider,"resolveStandaloneCodingPlanProvider");label(persist,"persistStandaloneCodingPlanConnection");' - ].join(""); - const patched = patchRuntimeLoginModelDefaults(source); - expect(patchRuntimeLoginModelDefaults(patched)).toBe(patched); - const { persist, initial, switchModel } = new Function(`${patched};return {persist,initial,switchModel};`)(); - const provider = { access: { type: "zhipu-account", mode: "individual-coding-plan", accountType: "zai" }, builtinModelIds: ["GLM-5.3", "GLM-5.3-Flash"] }; - const env = { providers: [["account:zai-individual-coding-plan", provider]] }; - expect(await persist({ providerId: "zai", env })).toEqual({ providerId: "account:zai-individual-coding-plan", modelId: "GLM-5.3" }); - provider.builtinModelIds.unshift("GLM-6"); - expect(await persist({ providerId: "zai", env })).toMatchObject({ modelId: "GLM-6" }); - expect(initial({ registry: {}, configuredDefault: { providerId: "custom", modelId: "chosen" } })).toEqual({ - providerId: "custom", modelId: "chosen", options: { reasoningLevel: "high" } - }); - expect(initial({ registry: {}, configuredDefault: { providerId: "custom", modelId: "chosen", options: { reasoningLevel: "low" } } })).toMatchObject({ options: { reasoningLevel: "low" } }); - const context = { runtime: { setSessionModelSelection: () => {} }, providerRegistry: { getView: () => ({}) } }; - expect(switchModel(context, { providerId: "custom", modelId: "chosen" })).toMatchObject({ options: { reasoningLevel: "high" } }); - expect(switchModel(context, { providerId: "custom", modelId: "chosen", options: { reasoningLevel: "low" } })).toMatchObject({ options: { reasoningLevel: "low" } }); - let cliSelection: unknown; - const cliContext = { - runtime: { setSessionModelSelection: () => { throw new Error("session write"); } }, - cliDefaultSelection: { set: (value: unknown) => { cliSelection = value; } }, - providerRegistry: { getView: () => ({}) } - }; - expect(switchModel(cliContext, { providerId: "custom", modelId: "chosen" })).toMatchObject({ options: { reasoningLevel: "high" } }); - expect(cliSelection).toMatchObject({ options: { reasoningLevel: "high" } }); - await expect(persist({ providerId: "bigmodel", env })).rejects.toThrow("ambiguous provider"); - expect(() => patchRuntimeLoginModelDefaults(source.replace("modelId:model}", "modelId:hardcoded}"))).toThrow("anchors missing"); - expect(() => patchRuntimeLoginModelDefaults(source.replace("e.cliDefaultSelection&&", "false&&"))).toThrow(/model switch anchor missing/); - }); - - test("pins the exact remote runtime used by release workflows", async () => { - const packageJson = await Bun.file(new URL("../package.json", import.meta.url)).json(); - const lock = await Bun.file(new URL("../zcode-runtime.lock.json", import.meta.url)).json(); - const release = parseReleaseVersion(String(packageJson.version)); - - expect(lock).toMatchObject({ - schemaVersion: 1, - appVersion: release?.appVersion, - platform: "linux", - arch: "x64" - }); - expect(lock.url).toMatch(/^https:\/\/cdn-zcode\.z\.ai\/.+\.deb$/u); - expect(Buffer.from(String(lock.sha512), "base64")).toHaveLength(64); - expect(packageJson.files).toContain("zcode-runtime.lock.json"); - }); - - test("keeps the CLI build revision while aligning the App version", () => { - expect(parseReleaseVersion("3.3.5-12")).toEqual({ appVersion: "3.3.5", build: 12 }); - expect(parseReleaseVersion("3.3.5+build.12")).toBeUndefined(); - expect(parseReleaseVersion("3.3.5-build.12")).toBeUndefined(); - expect(syncedReleaseVersion("3.3.5", "3.3.5-12")).toBe("3.3.5-12"); - expect(syncedReleaseVersion("3.4.0", "3.3.5-12")).toBe("3.4.0-12"); - expect(syncedReleaseVersion("3.4.0", "3.3.5")).toBe("3.4.0-1"); - expect(nextBuildVersion("3.4.0-12")).toBe("3.4.0-13"); - expect(compareReleaseVersions("3.3.5-13", "3.3.5-12")).toBe(1); - expect(compareReleaseVersions("3.4.0-1", "3.3.5-99")).toBe(1); - expect(compareReleaseVersions("3.3.5-12", "3.4.0-1")).toBe(-1); - expect(compareReleaseVersions("3.3.5-12", "3.3.5-12")).toBe(0); - expect(() => syncedReleaseVersion("3.4", "3.3.5-12")).toThrow(/Unsupported/); - }); - - test("parseArgs uses the CI-safe Linux default", () => { - expect(parseArgs([])).toEqual({ platform: "linux", arch: "x64" }); - expect(parseArgs(["--platform", "win32", "--arch", "arm64"])).toEqual({ - platform: "win32", - arch: "arm64" - }); - expect(parseArgs(["--lock", "zcode-runtime.lock.json"])).toEqual({ - platform: "linux", - arch: "x64", - lock: "zcode-runtime.lock.json" - }); - expect(() => parseArgs(["--app", "/tmp/ZCode.app", "--lock", "runtime.json"])).toThrow(/cannot/); - expect(() => parseArgs(["--version", "3.3.5"])).toThrow(/--app/); - }); - - test("extracts launcher option capabilities from the strict global parser", () => { - const runtime = 'parse=s(e=>parseArgs({options:{help:{short:"h",type:"boolean"},json:{type:"boolean"},"output-format":{type:"string"},prompt:{short:"p",type:"string"},attach:{multiple:!0,type:"string"},surface:{type:"string"},version:{short:"v",type:"boolean"}},strict:!0}),"parseGlobalArgs")'; - expect(extractRuntimeCapabilities(runtime)).toEqual({ - schemaVersion: 1, - cli: { - globalOptions: { - help: { type: "boolean" }, - json: { type: "boolean" }, - "output-format": { type: "string" }, - prompt: { type: "string" }, - attach: { type: "string", multiple: true }, - surface: { type: "string" }, - version: { type: "boolean" } - } - } - }); - expect(() => extractRuntimeCapabilities("incompatible runtime")).toThrow(/global parser anchor/); - }); - - test("hides help options that are absent from the extracted parser contract", () => { - const runtime = [ - "Options:", - " --settings Load settings", - " --max-turns Maximum turns", - " --mode Permission mode", - " --surface Presentation surface", - "", - 'parse=s(e=>parseArgs({options:{help:{type:"boolean"},"max-turns":{type:"string"},mode:{type:"string"},prompt:{type:"string"},surface:{type:"string"},version:{type:"boolean"}},strict:!0}),"parseGlobalArgs")' - ].join("\n"); - - const patched = patchRuntimeCliHelpContract(runtime); - expect(patched).not.toContain("--settings"); - expect(patched).toContain("--max-turns "); - expect(patched).toContain("--surface "); - expect(hasRuntimeCliHelpContract(patched)).toBeTrue(); - expect(patchRuntimeCliHelpContract(patched)).toBe(patched); - }); - - test("validates locked runtime inputs before downloading", () => { - const lock = { - schemaVersion: 1, - appVersion: "3.3.5", - platform: "linux", - arch: "x64", - url: "https://example.com/zcode.deb", - sha512: Buffer.alloc(64, 7).toString("base64") - } as const; - expect(parseRuntimeLock(lock)).toEqual(lock); - expect(() => parseRuntimeLock({ ...lock, url: "http://example.com/zcode.deb" })).toThrow(/HTTPS/); - expect(() => parseRuntimeLock({ ...lock, sha512: `${lock.sha512.slice(0, -2)}!!` })).toThrow(/SHA-512/); - }); - - test("does not downgrade a newer lock when a release manifest lags behind", () => { - const candidate = parseRuntimeLock({ - schemaVersion: 1, - appVersion: "3.6.5", - platform: "linux", - arch: "x64", - url: "https://example.com/3.6.5.deb", - sha512: Buffer.alloc(64, 6).toString("base64") - }); - const current = parseRuntimeLock({ - ...candidate, - appVersion: "3.7.3", - url: "https://example.com/3.7.3.deb", - sha512: Buffer.alloc(64, 7).toString("base64") - }); - - expect(selectRuntimeLock(candidate, current)).toBe(current); - expect(selectRuntimeLock(current, candidate)).toBe(current); - expect(selectRuntimeLock(candidate, { ...current, arch: "arm64" })).toBe(candidate); - }); - - test("preserves the HTTP status when an OAuth error body is not JSON", () => { - const runtime = [ - "class Rx extends Error{}", - "async function Vqr(e,t,r){", - "let o=await e.request(t,r),", - "n=new TextDecoder().decode(o.body),i=oDo(n),s=O7(i);", - "return s}", - "function oDo(e){try{return JSON.parse(e)}catch{", - "throw new Rx(\"OAuth response is not valid JSON\",{httpStatus:void 0})}}" - ].join(""); - const patched = patchRuntimeOAuthHttpErrors(runtime); - - expect(patched).toContain("i=oDo(n,o.status)"); - expect(patched).toContain("OAuth HTTP error ${t} (empty or non-JSON response)"); - const parse = new Function(`${patched};return oDo;`)() as (body: string, status: number) => unknown; - expect(() => parse("", 404)).toThrow("OAuth HTTP error 404 (empty or non-JSON response)"); - expect(() => parse("not-json", 200)).toThrow("OAuth response is not valid JSON"); - expect(patchRuntimeOAuthHttpErrors(patched)).toBe(patched); - expect(patchRuntimeOAuthHttpErrors("upstream runtime without the legacy parser")).toBe( - "upstream runtime without the legacy parser" - ); - expect(() => patchRuntimeOAuthHttpErrors( - 'broken "OAuth response is not valid JSON",{httpStatus:void 0}' - )).toThrow(/parser anchor/); - }); - - test("constructs bodyless Fetch responses for 204, 205, and 304", async () => { - const runtime = [ - 'function request(response,headers){return new Response(streams.Readable.toWeb(response),{headers:headers,status:response.statusCode??502,statusText:response.statusMessage})}', - 'function proxied(response,headers){return new Response(other.Readable.toWeb(response),{headers:headers,status:response.statusCode??502,statusText:response.statusMessage})}' - ].join(""); - const patched = patchRuntimeHttpNoContent(runtime); - expect(hasRuntimeHttpNoContentGuard(runtime)).toBe(false); - expect(hasRuntimeHttpNoContentGuard(patched)).toBe(true); - const streams = { Readable: { toWeb: () => new ReadableStream() } }; - const other = { Readable: { toWeb: () => new ReadableStream() } }; - const functions = new Function( - "streams", - "other", - `${patched};return {proxied,request};` - )(streams, other) as { - proxied: (response: { statusCode: number; statusMessage?: string }, headers: Headers) => Response; - request: (response: { statusCode: number; statusMessage?: string }, headers: Headers) => Response; - }; - - for (const status of [204, 205, 304]) { - const response = functions.request({ statusCode: status }, new Headers()); - expect(response.status).toBe(status); - expect(response.body).toBeNull(); - } - expect(functions.proxied({ statusCode: 200 }, new Headers()).body).not.toBeNull(); - expect(patchRuntimeHttpNoContent(patched)).toBe(patched); - expect(patchRuntimeHttpNoContent("runtime without the HTTP wrapper")).toBe( - "runtime without the HTTP wrapper" - ); - expect(hasRuntimeHttpNoContentGuard("runtime without the HTTP wrapper")).toBe(false); - }); - - test("recognizes the native 3.15.1 bodyless HTTP guard without changing upstream response handling", () => { - const runtime = 'const statuses=new Set([101,103,204,205,304]);function readIncomingResponse(response){let status=response.statusCode??502,headers=new Headers;let empty=statuses.has(status);return empty&&response.resume(),{body:empty?null:streams.Readable.toWeb(response),headers:headers,status:status,statusText:response.statusMessage??""}}function toWebResponse(response){return new Response(response.body,{headers:response.headers,status:response.status,statusText:response.statusText})}label(readIncomingResponse,"readIncomingResponse");label(toWebResponse,"toWebResponse");'; - expect(hasRuntimeHttpNoContentGuard(runtime)).toBe(true); - expect(patchRuntimeHttpNoContent(runtime)).toBe(runtime); - expect(hasRuntimeHttpNoContentGuard(runtime.replace("empty?null:", "empty?undefinedBody:"))).toBe(false); - expect(hasRuntimeHttpNoContentGuard(runtime.replace("[101,103,204,205,304]", "[101,103,204]"))).toBe(false); - const fixture = new Function("streams", "label", `${runtime};return {readIncomingResponse,toWebResponse};`)( - { Readable: { toWeb: () => new ReadableStream() } }, (value: unknown) => value - ); - let resumed = 0; - for (const statusCode of [204, 205, 304]) { - const response = fixture.toWebResponse(fixture.readIncomingResponse({ statusCode, resume: () => { resumed++; } })); - expect(response.status).toBe(statusCode); - expect(response.body).toBeNull(); - } - expect(resumed).toBe(3); - expect(fixture.toWebResponse(fixture.readIncomingResponse({ statusCode: 200 })).body).not.toBeNull(); - }); - - test("sets the SQLite write timeout after the single async migration open", async () => { - const runtime = [ - 'class Store{', - 'static async open(t={},n={}){let o=t.dbPath??"fixture.sqlite",i=t.startupLockTimeoutMs??5e3;', - 'let a=new sqlite.DatabaseSync(o,{timeout:i});', - 'try{let c=await migrateAsync(a,o,n??{lockWaitTimeoutMs:i});return new Store(a,o,t,c)}catch(e){try{a.close()}catch{}throw e}}', - 'constructor(a,o,t,c){this.db=a;this.dbPath=o;this.migrationResult=c}', - 'close(){this.db.close()}}' - ].join(""); - const patched = patchRuntimeSqliteBusyTimeout(runtime); - expect(hasRuntimeSqliteBusyTimeout(runtime)).toBe(false); - expect(hasRuntimeSqliteBusyTimeout(patched)).toBe(true); - expect(hasRuntimeSqliteBusyTimeout(`unrelated "pragma busy_timeout = ${sqliteBusyTimeoutMs}"`)).toBe(false); - const opened: { options: unknown; path: string }[] = []; - const statements: string[] = []; - let migrationFailure = false; - type Database = { exec: (sql: string) => void; close: () => void }; - const sqlite = { - DatabaseSync: function DatabaseSync(this: Database, path: string, options: unknown) { - opened.push({ options, path }); - this.exec = (sql: string) => statements.push(sql); - this.close = () => { statements.push("close"); }; - } - }; - const Store = new Function("sqlite", "migrateAsync", `${patched};return Store;`)( - sqlite, - async (db: Database) => { - db.exec("pragma busy_timeout = 25"); - try { if (migrationFailure) throw new Error("migration failed"); } - finally { db.exec("pragma busy_timeout = 5000"); } - } - ); - await Store.open({ startupLockTimeoutMs: 2500 }); - expect(opened).toEqual([{ options: { timeout: 2500 }, path: "fixture.sqlite" }]); - expect(statements).toEqual(["pragma busy_timeout = 25", "pragma busy_timeout = 5000", `pragma busy_timeout = ${sqliteBusyTimeoutMs}`]); - - // Failed migration paths must close the connection, not hand it to callers. - migrationFailure = true; - statements.length = 0; - await expect(Store.open()).rejects.toThrow("migration failed"); - expect(statements).toEqual(["pragma busy_timeout = 25", "pragma busy_timeout = 5000", "close"]); - expect(patchRuntimeSqliteBusyTimeout(patched)).toBe(patched); - expect(() => patchRuntimeSqliteBusyTimeout("incompatible runtime")).toThrow(/store open anchor/); - expect(() => patchRuntimeSqliteBusyTimeout(runtime.replace("await migrateAsync", "await changed"))).not.toThrow(); - expect(() => patchRuntimeSqliteBusyTimeout(runtime.replace("n??{lockWaitTimeoutMs:i}", "n"))).toThrow(/store open anchor/); - expect(() => patchRuntimeSqliteBusyTimeout(runtime + runtime)).toThrow(/ambiguous/); - }); - - test("installs write recovery and usage admission on the single store open", async () => { - const runtime = [ - 'function label(v){return v}', - 'class Store{', - 'static async open(t={},n={}){let o=t.dbPath??"fixture.sqlite",i=t.startupLockTimeoutMs??5e3;', - 'let a=new sqlite.DatabaseSync(o,{timeout:i});', - 'try{let c=await migrateAsync(a,o,n??{lockWaitTimeoutMs:i});return new Store(a,o,t,c)}catch(e){try{a.close()}catch{}throw e}}', - 'constructor(a,o,t,c){this.db=a;this.dbPath=o;this.migrationResult=c}', - 'close(){this.db.close()}}', - 'async function pruneUsage(e,t={}){let n=t.beforeTime??Date.now()-1000;e.exec("begin immediate");try{e.prepare("delete from model_usage where started_at < ?").run(n),e.prepare("delete from turn_usage where started_at < ?").run(n),e.prepare("delete from tool_usage where started_at < ?").run(n),e.exec("commit")}catch(o){throw e.exec("rollback"),o}}', - 'label(pruneUsage,"pruneUsage");' - ].join(""); - const patched = patchRuntimeSqliteWriteRecovery(patchRuntimeSqliteBusyTimeout(runtime)); - expect(hasRuntimeSqliteWriteRecovery(patched)).toBe(true); - expect(patched).toContain("installSqliteWriteRecovery($zStore)"); - expect(patched).toContain(".pruneSqliteUsage("); - expect(patchRuntimeSqliteWriteRecovery(patched)).toBe(patched); - expect(() => patchRuntimeSqliteWriteRecovery("incompatible runtime")).toThrow(/post-migration timeout/); - const calls: string[] = []; - const helper = { - installSqliteWriteRecovery: (store: unknown) => { - calls.push("install"); - (store as { installed?: boolean }).installed = true; - }, - pruneSqliteUsage: (_db: unknown, _options: unknown, cutoff: number, prune: () => void) => { - calls.push(`prune:${cutoff}`); - prune(); - } - }; - const require = (id: string) => id === "node:path" - ? { join: (...parts: string[]) => parts.join("/") } - : helper; - let pruned = 0; - type Database = { - exec: (sql: string) => void; - prepare: (sql: string) => { run: (value?: unknown) => void }; - close: () => void; - }; - const sqlite = { - DatabaseSync: function DatabaseSync(this: Database, _path: string, _options: unknown) { - this.exec = (sql: string) => { if (sql.startsWith("delete from")) pruned += 1; }; - this.prepare = () => ({ run: () => { pruned += 1; } }); - this.close = () => {}; - } - }; - const fixture = new Function("sqlite", "migrateAsync", "require", "__dirname", `${patched};return {Store,pruneUsage};`)( - sqlite, async () => undefined, require, "/fixture" - ); - const store = await fixture.Store.open({ dbPath: "fixture.sqlite" }); - expect(calls).toContain("install"); - await fixture.pruneUsage(store.db); - expect(calls.some(call => call.startsWith("prune:"))).toBe(true); - expect(pruned).toBe(3); - }); - - test.each(["legacy", "budgeted"])("classifies transport failures without retrying after output (%s)", budget => { - let runtime = [ - "var yt2={ModelRequestFailed:'model_request_failed',ProviderNotConfigured:'provider_not_configured'},", - "it2={Cancelled:'cancelled',NetworkError:'network_error',AuthFailed:'auth_failed',ServerError:'server_error',Unknown:'unknown'},", - "nn2={NetworkError:'network_error',AuthRefresh:'auth_refresh',ServerError:'server_error'},", - "SS=new Set(['provider_overloaded']);", - "function jV(e){return!1}", - "function pu2(e){return e}", - "function s$(e){let t=e,r=new WeakSet;for(;t&&typeof t==='object'&&!r.has(t);){r.add(t);if(typeof t.statusCode==='number')return t.statusCode;t=t.cause}}", - "function p_(e){return}", - "function hdr2(e){return}", - "function ddr2(e,t){return!1}", - "function U_e2(e){return!1}", - "function Ob2(e){return e&&typeof e==='object'?e:void 0}", - "function d1o2(e){let t=e?.trim().toUpperCase();return t==='ECONNRESET'||t==='EPIPE'||t==='CONNECTIONCLOSED'}", - "function fdr2(e){return!1}", - "function Mye2(e){return e.retryable&&e.reason!==it2.Cancelled}", - "function oO(e,t){return e===null||typeof e!==\"object\"?!1:t.has(e)?!0:(t.add(e),!1)}", - "function pP(e){return e!==null&&typeof e===\"object\"?e:{}}", - "function qQ(e,t){let r=e[t];return typeof r===\"string\"&&r.length>0?r:void 0}", - "function w$(e){let t=e?.toUpperCase();return t===\"ECONNRESET\"||t===\"ECONNREFUSED\"||t===\"EAI_AGAIN\"||t===\"ENOTFOUND\"||t===\"ENETUNREACH\"||t===\"EHOSTUNREACH\"||t===\"UND_ERR_SOCKET\"||t===\"UND_ERR_CONNECT_TIMEOUT\"}", - "function Rq(e){return kq(e,new WeakSet)}", - "function kq(e,t){if(oO(e,t))return;let r=pP(e),n=qQ(r,\"code\");if(n)return n;let o=r.cause;if(o&&o!==e)return kq(o,t)}", - "function qq(e,t){if(t){if(w$(t)||SS.has(t))return!0;let r=t.toLowerCase();if(r===\"network_error\"||r===\"network_error_retryable\")return!0}return jV(e)}", - "function Wb(e,t){let r=pu2(e),n=s$(r),o=Rq(r),i=p_(r),a=hdr2(i);", - "if(ddr2(r,t))return{code:yt2.ModelRequestFailed,message:'cancelled',reason:it2.Cancelled,retryReason:nn2.NetworkError,retryable:!1,statusCode:n};", - "if(n===401||n===403)return{code:yt2.ProviderNotConfigured,message:'auth failed',reason:it2.AuthFailed,retryReason:nn2.AuthRefresh,retryable:!1,statusCode:n};", - "if(w$(o))return{code:yt2.ModelRequestFailed,message:\"Network connection failed for the provider request.\",reason:it2.NetworkError,retryReason:nn2.NetworkError,retryable:!0,retryAfterMs:a,statusCode:n};", - "let d=fdr2(r);return{code:yt2.ModelRequestFailed,message:'Model request failed.',reason:d?it2.ServerError:it2.Unknown,retryReason:d?nn2.ServerError:nn2.NetworkError,retryable:d,statusCode:n}}", - "function s9o(e,t){let r=pu2(e);if(U_e2(r)||d1o2(Rq(r)))return!0;if(t!==void 0)return!1;let n=Ob2(r)?.providerCode;return d1o2(typeof n==\"number\"?String(n):n)}", - "function z9o(e){return e.emittedRetryBoundaryEvent||e.attempt>=e.maxAttempts||e.failure.reason===it2.Cancelled?!1:e.preserveProviderStreamBoundaries===!0&&s9o(e.error,e.httpResponseStatus)?!0:Mye2(e.failure)?e.preserveProviderStreamBoundaries!==!0||e.streamFailurePhase!==\"response_body\":!1}", - "function* walk(e){let t=e,r=new WeakSet;for(let n=0;n<=6;n+=1){if(!t||typeof t!=='object'||r.has(t))return;r.add(t);yield t;t=t.cause}}", - "function recoverable(e){for(let t of walk(e)){if(t.retryable===!0)return!0;let r=pP(t.context);if(r.retryable===!0)return!0}return!1}" - ].join(""); - if (budget === "budgeted") runtime = runtime.replace( - "function z9o(e){return e.emittedRetryBoundaryEvent||e.attempt>=e.maxAttempts", - 'function budgetAllows(budget,attempt,max){return budget==="unbounded"||attempt=e.maxAttempts" - : "return e.emittedRetryBoundaryEvent||!budgetAllows(e.retryBudget,e.attempt,e.maxAttempts)"); - expect(patched).not.toContain("e.emittedRetryBoundaryEvent&&!$zTransportChain"); - expect(() => new Function(patched)).not.toThrow(); - expect(patchRuntimeNetworkRetryClassification(patched)).toBe(patched); - expect(() => patchRuntimeNetworkRetryClassification("incompatible runtime")).toThrow(/incompatible/); - - const load = new Function( - `${patched};return {classify:Wb,decide:qq,gate:z9o,recoverable,stale:s9o,transport:e=>$zTransportChain(e,new WeakSet)};` - ) as () => { - classify: (error: unknown) => { - code: string; - reason: string; - retryable: boolean; - }; - decide: (error: unknown, reason?: string) => boolean; - gate: (failure: Record) => boolean; - recoverable: (error: unknown) => boolean; - stale: (error: unknown, status?: number) => boolean; - transport: (error: unknown) => boolean; - }; - const { classify, decide, gate, recoverable, stale, transport } = load(); - - const socket = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" }); - const terminated = Object.assign(new TypeError("terminated"), { cause: socket }); - const wrapped = Object.assign(new Error("Model request failed"), { - code: "model_request_failed", - cause: terminated - }); - const authorized = Object.assign(new Error("Model request failed"), { - code: "model_request_failed", - cause: Object.assign(new Error("Unauthorized"), { statusCode: 401, cause: socket }) - }); - - expect(transport(wrapped)).toBe(true); - expect(decide(wrapped)).toBe(true); - expect(decide({}, "network_error")).toBe(true); - expect(classify(wrapped)).toMatchObject({ - code: "model_request_failed", - reason: "network_error", - retryable: true - }); - expect(transport(authorized)).toBe(true); - expect(classify(authorized).retryable).toBe(false); - expect(transport({ cause: { message: "write EPIPE" } })).toBe(true); - expect(transport({ cause: { code: "ETIMEDOUT", message: "connect failed" } })).toBe(true); - expect(stale(wrapped)).toBe(true); - - const failure = classify(wrapped); - const beforeOutput = { - emittedRetryBoundaryEvent: false, - attempt: 1, - maxAttempts: 6, - failure, - error: wrapped, - preserveProviderStreamBoundaries: false - }; - expect(gate(beforeOutput)).toBe(true); - expect(gate({ ...beforeOutput, emittedRetryBoundaryEvent: true })).toBe(false); - expect(gate({ ...beforeOutput, attempt: 6 })).toBe(false); - if (budget === "budgeted") { - expect(gate({ ...beforeOutput, attempt: 6, retryBudget: "unbounded" })).toBe(true); - expect(gate({ ...beforeOutput, attempt: 6, retryBudget: "unbounded", emittedRetryBoundaryEvent: true })).toBe(false); - } - expect(gate({ ...beforeOutput, failure: { ...failure, reason: "cancelled" } })).toBe(false); - expect(recoverable({ cause: wrapped, context: { retryable: failure.retryable } })).toBe(true); - - const cyclic: { code?: string; cause?: unknown } = { code: "model_request_failed" }; - const cyclicCause: { code?: string; cause?: unknown } = { - code: "model_request_failed", - cause: cyclic - }; - cyclic.cause = cyclicCause; - expect(transport(cyclic)).toBe(false); - expect(decide(cyclic)).toBe(false); - }); - - test.each([false, true])("adds Desktop OAuth with registry credential persistence (native polling: %p)", (nativePolling) => { - const loginFunctions = [ - "async function sDo(e={}){", - "let t=e.env??process.env,r=e.now??Date.now,o=e.sleep??fDo;", - "F1(e.abortSignal);", - "let i=e.credentialStore??cj({env:t}),s=dDo(e,t),u=await s.init({});", - "let c=await mDo({});", - "try{await i.saveZaiLoginCredentials({accessToken:c.zai.access_token,jwtToken:c.token,user:c.user})}", - "catch(f){throw new Ox(\"credential_write_failed\",\"Login succeeded but writing credentials failed.\",{cause:f})}", - `let d=await aGr({accessToken:c.zai.access_token,env:t,httpClient:e.httpClient,family:"zai",resolver:e.apiKeyResolver}),p;`, - `try{p=await qz({accountIdentity:c.user.user_id,apiKey:d,credentialStore:i,env:t,personalProviderConfigPath:e.personalProviderConfigPath,providerId:"zai"})}`, - "catch(f){throw new Ox(\"config_update_failed\",\"Login succeeded but updating ZCode config failed.\",{cause:f})}", - "return{configPath:p.path}}", - "async function uDo(e={}){let t=e.env??process.env;F1(e.abortSignal);", - "let r=e.httpClient??iGr(t),o=e.state??Nqr();return r}" - ].join(""); - const runtime = [ - "class Ox extends Error{}", - "function F1(){}", - "let saved=null,resolved=null,written=null;", - "function cj(){return{filePath:'/credentials.json',async saveZaiLoginCredentials(value){saved=value}}}", - "function iGr(){return{async request(){return{status:200,body:new TextEncoder().encode(JSON.stringify({code:0,data:{token:'jwt-token',zai:{access_token:'oauth-token'},user:{user_id:'user-1'}}}))}}}}", - "async function aGr(value){resolved=value;return'coding-plan-key'}", - "async function qz(value){written=value;return{path:'/config.json',mainModel:'zai/model'}}", - loginFunctions - ].join(""); - const native = runtime - .replace("let t=e.env??process.env,r=", 'let n="zai",t=e.env??process.env,r=') - .replace("accountIdentity:c.user.user_id,apiKey:d,", 'accountIdentity:c.user.user_id,authSource:"oauth",') - .replace('personalProviderConfigPath:e.personalProviderConfigPath,providerId:"zai"', "personalProviderConfigPath:e.personalProviderConfigPath,providerId:n") - + 'function label(fn){return fn}label(sDo,"loginZCodeCli");label(F1,"throwIfAborted");label(aGr,"resolveCodingPlanMaterial");label(iGr,"createDefaultHttpClient");'; - const patched = patchRuntimeZaiDesktopOAuth(nativePolling ? native : runtime); - - expect(patched).toContain('ZCODE_CLI_OAUTH_CALLBACK_STDIN==="1"'); - expect(patched).toContain('url:"https://zcode.z.ai/api/v1/oauth/token"'); - expect(patched).toContain("i.saveZaiLoginCredentials"); - expect(patched).toContain("aGr({accessToken:$zAccessToken"); - expect(patched).toContain(`qz({accountIdentity:$zUser.user_id,${nativePolling ? 'authSource:"oauth"' : "apiKey:$zApiKey"}`); - expect(() => new Function(patched)).not.toThrow(); - expect(patchRuntimeZaiDesktopOAuth(patched)).toBe(patched); - expect(() => patchRuntimeZaiDesktopOAuth("incompatible runtime")).toThrow(/credential anchor/); - - const callback = JSON.stringify({ - callbackUrl: "zcode://zai-auth/callback?code=authorization-code&state=expected-state", - state: "expected-state" - }); - const load = new Function( - "require", - `${patched};return {login:sDo,read:()=>({resolved,saved,written})};` - ) as (require: (id: string) => unknown) => { - login(options: Record): Promise>; - read(): Record; - }; - const fixture = load((id) => { - if (id !== "node:fs") throw new Error(`Unexpected module: ${id}`); - return { readFileSync: () => callback }; - }); - return fixture.login({ - env: { ZCODE_CLI_OAUTH_CALLBACK_STDIN: "1" } - }).then((result) => { - expect(result).toMatchObject({ - configPath: "/config.json", - credentialsPath: "/credentials.json", - model: "zai/model", - providerId: "zai" - }); - expect(fixture.read()).toMatchObject({ - resolved: { accessToken: "oauth-token", family: "zai" }, - saved: { accessToken: "oauth-token", jwtToken: "jwt-token" }, - written: { ...(nativePolling ? { authSource: "oauth" } : { apiKey: "coding-plan-key" }), providerId: "zai" } - }); - expect(fixture.read().written).toMatchObject({ - accountIdentity: "user-1", credentialStore: { filePath: "/credentials.json" }, - env: { ZCODE_CLI_OAUTH_CALLBACK_STDIN: "1" } - }); - if (nativePolling) expect(fixture.read().written).not.toHaveProperty("apiKey"); - }); - }); - - test("reuses a native polling BigModel session without persisting a derived key", async () => { - const runtime = 'function label(fn){return fn}const keys={bigmodelAccessToken:"access"};let resolved=[],written;function store(){return{filePath:"/credentials",load:async()=>"desktop-token"}}function abort(signal){signal?.throwIfAborted()}function http(){return{}}async function material(input){resolved.push(input);return{organizationId:"org",projectId:"project",token:"derived-token"}}function identity(value){return value}async function persist(input){written=input;return{path:"/providers",mainModel:"bigmodel/fixture"}}async function shared(e={}){let env=e.env??process.env,family=e.providerId,credentials=e.credentialStore??store({env:env});if(false)await credentials.saveMany({[keys.bigmodelAccessToken]:"unused"});if(false)await persist({accountIdentity:"unused",authSource:"oauth",credentialStore:credentials,env:env,personalProviderConfigPath:e.personalProviderConfigPath,providerId:family});return{source:"polling"}}async function login(e={}){return{...await shared({...e,providerId:"bigmodel"}),providerId:"bigmodel"}}async function next(){}label(shared,"loginZCodeCli");label(login,"loginBigmodelCodingPlan");label(abort,"throwIfAborted");label(material,"resolveCodingPlanMaterial");label(http,"createDefaultHttpClient");label(identity,"createStandaloneAccountIdentityFromSecret");'; - const patched = patchRuntimeBigmodelSessionReuse(runtime.replace('accountIdentity:"unused"', "accountIdentity:family")); - expect(patchRuntimeBigmodelSessionReuse(patched)).toBe(patched); - const fixture = new Function(`${patched};return {login,read:()=>({resolved,written})};`)(); - expect(await fixture.login({ env: {} })).toEqual({ configPath: "/providers", credentialsPath: "/credentials", model: "bigmodel/fixture", providerId: "bigmodel" }); - expect(fixture.read().written).toMatchObject({ accountIdentity: '["org","project"]', authSource: "oauth", providerId: "bigmodel" }); - expect(fixture.read().written).not.toHaveProperty("apiKey"); - expect(fixture.read().resolved).toHaveLength(1); - expect(await fixture.login({ env: { ZCODE_CLI_BIGMODEL_REUSE_SESSION: "0" } })).toMatchObject({ source: "polling" }); - expect(fixture.read().resolved).toHaveLength(1); - expect(await fixture.login({ env: {}, credentialStore: { load: async () => null } })).toMatchObject({ source: "polling" }); - const controller = new AbortController(); - controller.abort(new Error("cancelled")); - await expect(fixture.login({ env: {}, abortSignal: controller.signal })).rejects.toThrow("cancelled"); - }); - - test("lets BigModel login reuse the Desktop session before starting OAuth", async () => { - // Copied verbatim from the 3.14.4 runtime, followed by its esbuild name label. - const login = 'async function HHa(e={}){let t=e.env??process.env;UK(e.abortSignal);let n=e.httpClient??CHo(t),o=e.state??MZr(),s=e.callbackServer??await YOe({callbackPath:qHa,state:o}),a=OZr({httpClient:n}),l=a.buildAuthorizeUrl({redirectUri:s.callbackUrl,state:o});await e.onAuthorizeUrl?.({authorize_url:l,callback_url:s.callbackUrl,state:o}),UK(e.abortSignal);let u=e.noBrowser?void 0:await(e.openBrowser??Ydn)(l);u&&await e.onBrowserOpen?.(u),UK(e.abortSignal);try{let f=await kHo({callbackServer:s,createTimeoutError:r(()=>new m9("auth_timeout","Authorization timed out. Please retry login."),"createTimeoutError"),signal:e.abortSignal,timeoutMs:e.timeoutMs??IHo}),g=await a.exchangeCode({code:f.code}),_=await EHo({accessToken:g.accessToken,env:t,httpClient:n,family:"bigmodel",resolver:e.apiKeyResolver}),b=e.credentialStore??jM({env:t}),w=rPn(_);await b.saveMany({[R1.activeProvider]:"bigmodel",[R1.bigmodelAccessToken]:g.accessToken,...g.refreshToken?{[R1.bigmodelRefreshToken]:g.refreshToken}:{},[R1.bigmodelUserInfo]:JSON.stringify({id:w})});let T=await aPn({accountIdentity:w,apiKey:_,credentialStore:b,env:t,personalProviderConfigPath:e.personalProviderConfigPath,providerId:"bigmodel"});return{...u?{browser:u}:{},configPath:T.path,model:T.mainModel,providerId:"bigmodel"}}finally{await s.close()}}async function GHa(e){}r(HHa,"loginBigmodelCodingPlan");'; - const harness = [ - "class m9 extends Error{}", - "function r(value){return value}", - "function UK(signal){signal?.throwIfAborted()}", - "function Ydn(){}", - "const qHa='/oauth/callback/bigmodel',IHo=1000;", - "const R1={activeProvider:'oauth:active_provider',bigmodelAccessToken:'oauth:bigmodel:access_token',bigmodelRefreshToken:'oauth:bigmodel:refresh_token',bigmodelUserInfo:'oauth:bigmodel:user_info'};", - "let stored={},resolved=[],authorized=[],written=null;", - "function jM(){return{filePath:'/credentials.json',async load(key){return stored[key]??null},async saveMany(values){Object.assign(stored,values)}}}", - "function CHo(){return{}}", - "function OZr(){return{buildAuthorizeUrl(){return'https://bigmodel.test/login'},async exchangeCode(){return{accessToken:'oauth-token'}}}}", - "async function EHo(value){resolved.push(value.accessToken);if(value.accessToken==='expired-token')throw new Error('expired');return'key-'+value.accessToken}", - "function rPn(apiKey){return'identity-'+apiKey}", - "async function aPn(value){written=value;return{path:'/provider_config.json',mainModel:'account:bigmodel-individual-coding-plan/GLM'}}", - "function MZr(){return'state-1'}", - "async function YOe(){return{callbackUrl:'http://127.0.0.1:4567/oauth/callback/bigmodel',async close(){}}}", - "async function kHo(){return{code:'auth-code'}}", - login - ].join(""); - const patched = patchRuntimeBigmodelSessionReuse(harness); - - expect(patched).toContain('if(t.ZCODE_CLI_BIGMODEL_REUSE_SESSION!=="0")'); - expect(patched).toContain("a.exchangeCode({code:f.code})"); - expect(patchRuntimeBigmodelSessionReuse(patched)).toBe(patched); - expect(() => patchRuntimeBigmodelSessionReuse("incompatible runtime")).toThrow(/login anchor/); - // Independent of how the authorization code is exchanged further down. - const brokered = harness.replace( - "a.exchangeCode({code:f.code})", - "broker.exchangeBigmodelOAuthCode({code:f.code,redirectUri:s.callbackUrl,state:o,httpClient:n})" - ); - expect(patchRuntimeBigmodelSessionReuse(brokered)).toContain("ZCODE_CLI_BIGMODEL_REUSE_SESSION"); - - type Fixture = { - login(options: Record): Promise>; - read(): { authorized: unknown[]; resolved: string[]; stored: Record; written: Record | null }; - seed(values: Record): void; - }; - const run = async (env: Record, token: string) => { - const fixture = new Function( - `${patched};return {login:HHa,read:()=>({authorized,resolved,stored,written}),seed:(values)=>Object.assign(stored,values)};` - )() as Fixture; - fixture.seed({ "oauth:bigmodel:access_token": token }); - const result = await fixture.login({ - env, - noBrowser: true, - onAuthorizeUrl: (value: unknown) => { fixture.read().authorized.push(value); } - }); - return { result, ...fixture.read() }; - }; - - const reused = await run({}, "desktop-token"); - expect(reused.result).toEqual({ - configPath: "/provider_config.json", - model: "account:bigmodel-individual-coding-plan/GLM", - providerId: "bigmodel" - }); - expect(reused.authorized).toEqual([]); - expect(reused.written).toMatchObject({ - accountIdentity: "identity-key-desktop-token", - apiKey: "key-desktop-token", - credentialStore: { filePath: "/credentials.json" }, - providerId: "bigmodel" - }); - - const expired = await run({}, "expired-token"); - expect(expired.resolved).toEqual(["expired-token", "oauth-token"]); - expect(expired.authorized).toHaveLength(1); - expect(expired.stored).toMatchObject({ "oauth:bigmodel:access_token": "oauth-token" }); - expect(expired.written).toMatchObject({ apiKey: "key-oauth-token" }); - - const optedOut = await run({ ZCODE_CLI_BIGMODEL_REUSE_SESSION: "0" }, "desktop-token"); - expect(optedOut.resolved).toEqual(["oauth-token"]); - expect(optedOut.authorized).toHaveLength(1); - }); - - test("applies required patches and records optional compatibility skips", () => { - const result = applyRuntimePatchPlan("runtime", [ - { - id: "optional-diagnostic", - requirement: "optional", - apply: () => { - throw new Error("anchor moved"); - } - }, - { - id: "required-bridge", - requirement: "required", - apply: (runtime) => runtime.includes("|bridge") ? runtime : `${runtime}|bridge`, - verify: (runtime) => runtime.endsWith("|bridge") - } - ]); - - expect(result.runtime).toBe("runtime|bridge"); - expect(result.reports).toEqual([ - { - id: "optional-diagnostic", - requirement: "optional", - status: "skipped", - message: "anchor moved" - }, - { id: "required-bridge", requirement: "required", status: "applied" } - ]); - expect(() => applyRuntimePatchPlan("runtime", [{ - id: "required-bridge", - requirement: "required", - apply: () => { - throw new Error("missing bridge anchor"); - } - }])).toThrow(/Required runtime patch required-bridge failed/); - expect(parseRuntimePatchReports(result.reports)).toEqual(result.reports); - expect(parseRuntimePatchReports([{ ...result.reports[0], status: "unknown" }])).toBeUndefined(); - }); - - test("formats and writes actionable compatibility reports for CI and issue updates", async () => { - const report = { - schemaVersion: 1, - appVersion: "3.9.1", - generatedAt: "2026-08-26T01:30:00.000Z", - phase: "runtime_patch" as const, - error: "Required runtime patch tui-bridge failed: anchor missing", - runtimePatches: [{ - id: "tui-bridge", - requirement: "required" as const, - status: "failed" as const, - message: "anchor | missing" - }] - } as const; - const markdown = formatRuntimeCompatibilityFailure(report); - expect(markdown).toContain("App version: `3.9.1`"); - expect(markdown).toContain("Required runtime patch tui-bridge failed"); - expect(markdown).toContain("anchor \\| missing"); - - const directory = await mkdtemp(join(tmpdir(), "zcode-runtime-report-")); - try { - const paths = await writeRuntimeCompatibilityFailure(report, directory); - expect(await Bun.file(paths.jsonPath).json()).toEqual(report); - expect(await Bun.file(paths.markdownPath).text()).toBe(markdown); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); - - test("maps platforms to the Desktop stable update service", () => { - expect(serviceReleasePlatform("linux", "x64")).toBe("linux-x86_64"); - expect(serviceReleasePlatform("darwin", "arm64")).toBe("darwin-aarch64"); - expect(serviceReleasePlatform("win32", "ia32")).toBe("windows-x86"); - expect(serviceManifestUrl("linux", "x64")).toBe( - "https://zcode.z.ai/api/v1/releases/electron/manifest?platform=linux-x86_64&channel=1" - ); - }); - - test("resolves the latest runtime from the Desktop stable update service", async () => { - const calls: Array<{ init?: RequestInit; url: string }> = []; - const sha512 = Buffer.alloc(64, 7).toString("base64"); - const result = await resolveLatestRuntimeLock( - { platform: "linux", arch: "x64" }, - async (url, init) => { - calls.push({ url, init }); - return JSON.stringify({ - version: "3.7.3", - files: [{ - url: "/zcode/electron/releases/3.7.3/linux-x64/ZCode-3.7.3-linux-x64.deb", - sha512 - }] - }); - } - ); - - expect(result).toEqual({ - source: "service", - url: serviceManifestUrl("linux", "x64"), - lock: { - schemaVersion: 1, - appVersion: "3.7.3", - platform: "linux", - arch: "x64", - url: "https://zcode.z.ai/zcode/electron/releases/3.7.3/linux-x64/ZCode-3.7.3-linux-x64.deb", - sha512 - } - }); - expect(calls).toHaveLength(1); - const headers = new Headers(calls[0]!.init?.headers); - expect(headers.get("Accept")).toContain("application/x-yaml"); - expect(headers.get("X-Platform")).toBe("linux-x86_64"); - expect(headers.get("X-Release-Channel")).toBe("1"); - expect(headers.get("X-Device-Mid")).toBeNull(); - }); - - test("rejects an unusable service manifest without trying an obsolete CDN fallback", async () => { - const calls: string[] = []; - const sha512 = Buffer.alloc(64, 6).toString("base64"); - const resolution = resolveLatestRuntimeLock( - { platform: "linux", arch: "x64" }, - async (url) => { - calls.push(url); - return JSON.stringify({ - version: "3.7.3", - files: [{ url: "ZCode.AppImage", sha512 }] - }); - } - ); - - await expect(resolution).rejects.toThrow("No .deb artifact with sha512"); - await expect(resolution).rejects.toThrow("bun run sync:locked"); - expect(calls).toEqual([serviceManifestUrl("linux", "x64")]); - }); - - test.each(["linux", "darwin", "win32"] as const)( - "preserves %s service failures and explains how to use the existing lock explicitly", - async (platform) => { - const calls: string[] = []; - const cause = new Error("HTTP 503 Service Unavailable"); - const resolution = resolveLatestRuntimeLock({ platform, arch: "x64" }, async (url) => { - calls.push(url); - throw cause; - }); - try { - await resolution; - throw new Error("Expected manifest resolution to fail"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).cause).toBe(cause); - expect((error as Error).message).toContain(`latest ZCode runtime for ${platform}-x64`); - expect((error as Error).message).toContain("bun run sync:locked"); - expect((error as Error).message).toContain("without checking for a newer version"); - } - expect(calls).toEqual([serviceManifestUrl(platform, "x64")]); - } - ); - - test("resolves relative and absolute updater artifact URLs", () => { - const manifest = "https://zcode.z.ai/releases/latest.yml"; - const absolute = "https://cdn-zcode.z.ai/zcode/electron/releases/3.3.6/linux-x64/ZCode.deb"; - - expect(resolveArtifactUrl(manifest, "ZCode.deb")).toBe( - "https://zcode.z.ai/releases/ZCode.deb" - ); - expect(resolveArtifactUrl(manifest, absolute)).toBe(absolute); - }); - - test("chooseArtifact selects an extractable installer", () => { - const manifest = { - files: [ - { url: "ZCode.AppImage", sha512: "one" }, - { url: "ZCode.deb", sha512: "two" } - ] - }; - expect(chooseArtifact(manifest, "linux").url).toBe("ZCode.deb"); - expect(() => chooseArtifact({ files: [] }, "linux")).toThrow(/No \.deb artifact/); - }); - - test("recognizes legacy and native multi-message file rewind support", () => { - expect(supportsMultiMessageFileRewind("Array.isArray(e.targetMessageIds)")).toBe(true); - expect(supportsMultiMessageFileRewind( - "e.targetMessageIds&&e.targetMessageIds.length>0" - )).toBe(true); - expect(supportsMultiMessageFileRewind("e.targetMessageId?[e.targetMessageId]:[]")).toBe(false); - }); - - test("injects transcript and structured state readers into the official TUI adapter", async () => { - const runtime = [ - "function R(e,t){return f(e,{rewindCreatedMessageId:t.revert?.createdMessageID,rewindKeptMessageIds:t.revert?.keptMessageIDs,rewindTargetMessageId:t.revert?.targetMessageID})}", - "async function L(e){if(!e.sessionStore)return[];let t=await e.sessionStore.messages({sessionID:e.sessionId});return p(t)}", - 'function p(e){let t=[];for(let r of e){if(r.info.role==="user"){let l=r.text;t.push({content:l,role:"user"});continue}let n=[],s=[],u=r.text;t.push({content:u,...s.length>0?{parts:s}:{},role:"agent"})}return t}', - "function c(e,t){if(t.targetMessageId)return O(e,[t.targetMessageId]);let r=P(e,t.targetCheckpointId);return r?[r]:[]}", - "E.sendInput=async(A,$)=>{let c=t.runtime.getActiveTurnInfo();if(c)return t.runtime.steerTurn({commandKind:$?.commandKind,inputId:$?.inputId,queryId:$?.queryId,expectedTurnId:$?.expectedTurnId,input:A});return Kvt(await S(),D,O1(t))},", - 'listSkills:k(()=>H(e),"listSkills"),', - "E.recallPreviousInput=async A=>await(await S()).recallPreviousInputHistory?.(A)??null,", - "CVr(E,S,r);", - "return c({recallPreviousInput:g.recallPreviousInput,sendInput:g.sendInput,submitPrompt:g})" - ].join(""); - const runtimeWithApp = runtime.replace( - "E.sendInput", - 'loadSessionTranscript:a(async()=>await dUr({sessionId:e.sessionId,sessionStore:e.sessionStore}),"loadSessionTranscript"),readTodos:E.sendInput' - ); - const patched = patchRuntimeTuiBridge(runtimeWithApp); - - expect(patched).toContain("E.loadSessionTranscript=async()=>await(await S()).loadSessionTranscript?.()??[]"); - expect(patched).toContain("E.loadSessionContextMessages=async()=>await(await S()).loadSessionContextMessages?.()??[]"); - expect(patched).toContain("E.listSkills=async()=>await H(e)"); - expect(patched).toContain("E.readGoal=async()=>await(await S()).readTarget?.()??null"); - expect(patched).toContain("E.readTodos=async()=>await(await S()).readTodos?.()??[]"); - expect(patched).toContain("E.$zRestorePersistedBackgroundTasks=async $zApp=>"); - expect(patched).toContain("E.$zSendInputWithoutBackgroundRestore=E.sendInput"); - expect(patched).toContain('loadSessionContextMessages:a(async()=>await e.sessionStore.messages({sessionID:e.sessionId}),"loadSessionContextMessages")'); - expect(patched).toContain('role:"agent",model:{providerId:r.info.providerID,modelId:r.info.modelID}'); - expect(patched).not.toContain("$zRuntimeProjectionBridge.loadSessionContextMessages?.()"); - expect(patched).not.toContain("e.loadSessionTranscript?.()"); - expect(patched).toContain("E.readSessionUsage=async()=>await(await S()).readSessionUsage?.()??null"); - expect(patched).toContain("E.cancelBackgroundTask=async e=>await(await S()).cancelBackgroundTask?.(e)??null"); - expect(patched).toContain("E.subscribeSessionEvents=e=>{let t=!1,r;S().then(o=>{t||(r=o.runtime?.subscribeEvents?.({onSessionEvent:e}))});return()=>{t=!0,r?.()}}"); - expect(patched).toContain("E.sendBackgroundTaskMessage=async e=>"); - expect(patched).toContain(".readTuiRuntimeProjection("); - expect(patched).toContain(".sendTuiBackgroundTaskMessage("); - expect(patched).toContain("E.previewFileRewind=async e=>{let t=await S();return await t.runtime?.previewWorkspaceFileRewind?.({targetMessageIds:e})??null}"); - expect(patched).toContain("E.applyFileRewind=async e=>{let t=await S();return await t.runtime?.applyWorkspaceFileRewind?.({targetMessageIds:e})??null}"); - expect(patched).toContain("E.setMode=async e=>{return await(await S()).setMode(e)}"); - expect(patched).toContain("E.interruptTurn=async e=>"); - expect(patched).toContain("t.runtime?.stopActiveForegroundExecution?.({preserveQueueAutoDrainOnCancel:"); - expect(patched).toContain('e?.waitForIdle===!0&&t.runtime?.getActiveForegroundExecutionId'); - expect(patched).toContain("t.runtime.getActiveForegroundExecutionId()!==void 0"); - expect(patched).toContain("await t.reserveQueueItem(a,r)"); - expect(patched).toContain( - "expectedTurnId:$?.expectedTurnId,delivery:\"guide\",pendingInputId:$?.pendingInputId,input:A" - ); - expect(patched).toContain("E.promoteQueuedInput=async(e,t,r)=>"); - expect(patched).toContain("r?.pendingInputReservationId??r?.queryId"); - expect(patched).toContain("i=(Array.isArray(t)?t:[t]).filter(Boolean)"); - expect(patched).toContain("o.reserveQueueItem(l,n)"); - expect(patched).toContain("if(await o.markQueueItemPromoting(l,n))"); - expect(patched).toContain("o.markQueueItemPromoting(l,n)"); - expect(patched).toContain('E.sendInput(e,{...r,delivery:"start_turn"})'); - expect(patched).toContain('o.removeQueueItem(l,{reason:"promoted",reservationId:n})'); - expect(patched).toContain("o.releaseQueueItemReservation(l,n)"); - expect(patched).toContain('messageId:r.info.id,role:"user"'); - expect(patched).toContain('messageId:r.info.id,role:"agent"'); - expect(patched).toContain("Array.isArray(t.targetMessageIds)"); - expect(patched).toContain("r=await e.sessionStore.getSession(e.sessionId);return p(r?R(t,r):t)"); - expect(patched).toContain("loadSessionTranscript:g.loadSessionTranscript"); - expect(patched).toContain("readGoal:g.readGoal"); - expect(patched).toContain("readTodos:g.readTodos"); - expect(patched).toContain("readRuntimeProjection:g.readRuntimeProjection"); - expect(patched).toContain("loadSessionContextMessages:g.loadSessionContextMessages"); - expect(patched).toContain("readSessionUsage:g.readSessionUsage"); - expect(patched).toContain("cancelBackgroundTask:g.cancelBackgroundTask"); - expect(patched).toContain("previewFileRewind:g.previewFileRewind"); - expect(patched).toContain("applyFileRewind:g.applyFileRewind"); - expect(patched).toContain("interruptTurn:g.interruptTurn"); - expect(patched).toContain("promoteQueuedInput:g.promoteQueuedInput"); - expect(patched).toContain("listSkills:g.listSkills"); - expect(patched).toContain("setMode:g.setMode"); - expect(patched).toContain("readSessionModel:g.readSessionModel"); - expect(patched).toContain("await $zSessionModelApp.readSessionModelState()"); - expect(patched).toContain('setModel(s.selection,{transient:!0})'); - expect(patched).not.toContain('modelRef:String(e)'); - expect(patched).toContain("subscribeSessionEvents:g.subscribeSessionEvents"); - expect(patched).toContain("sendBackgroundTaskMessage:g.sendBackgroundTaskMessage"); - expect(patched).toContain("sessionStore.queryTaskUsage?.({sessionID:e.sessionId})"); - const projectionStart = patched.indexOf("E.readRuntimeProjection=async()=>"); - const projectionEnd = patched.indexOf(",E.readSessionUsage=", projectionStart); - const projectionAssignment = patched.slice(projectionStart, projectionEnd); - const bridge: { readRuntimeProjection?: () => Promise> } = {}; - const readRuntimeProjection = bridgeFunction( - "E", - "S", - `${projectionAssignment};return E.readRuntimeProjection;` - )( - bridge, - async () => ({ - readExecutionState: () => ({ mode: "edit", planEnabled: true }), - runtime: { - getProjection: () => ({ - activeToolCalls: [], - backgroundTasks: [], - contextUsed: 1_000, - contextWindow: 100_000 - }), - runtimeTaskRegistry: { all: () => ({}) } - } - }) - ) as () => Promise>; - expect(await readRuntimeProjection()).toMatchObject({ - contextUsed: 1_000, - contextWindow: 100_000, - backgroundTaskDetails: [] - }); - - const liveProjectionBridge: { readRuntimeProjection?: () => Promise> } = {}; - const liveReadRuntimeProjection = bridgeFunction( - "E", - "S", - `${projectionAssignment};return E.readRuntimeProjection;` - )( - liveProjectionBridge, - async () => ({ - readExecutionState: () => ({ mode: "edit", planEnabled: true }), - runtime: { - getProjection: () => ({ - activeToolCalls: [], - backgroundTasks: [{ - taskId: "agent-live", - taskKind: "local_agent", - status: "failed", - error: "Provider authentication failed.", - completedAt: Date.now() - 1_000, - blocked: true, - blockedReason: "previous failure" - }] - }), - runtimeTaskRegistry: { - all: () => ({ - "agent-live": { - taskId: "agent-live", - taskType: "local_agent", - type: "local_agent", - agentId: "agent-live", - agentType: "Explore", - childSessionId: "sess-child-live", - parentSessionId: "sess-parent-live", - status: "running", - isBackgrounded: true - } - }) - } - } - }) - ) as () => Promise>; - const liveProjection = await liveReadRuntimeProjection(); - expect(liveProjection.backgroundTasks).toEqual([expect.objectContaining({ - taskId: "agent-live", - status: "running", - error: null, - completedAt: null, - blocked: null, - blockedReason: null, - cancellable: true - })]); - - const restoreStart = patched.indexOf("E.$zRestorePersistedBackgroundTasks=async $zApp=>"); - const restoreEnd = patched.indexOf(",E.recallPreviousInput=", restoreStart); - expect(restoreStart).toBeGreaterThan(0); - expect(restoreEnd).toBeGreaterThan(restoreStart); - const restoreBackgroundTasks = bridgeFunction( - "E", - `${patched.slice(restoreStart, restoreEnd)};return E.$zRestorePersistedBackgroundTasks;` - )({}) as (app: unknown) => Promise; - const restoredFixture = subagentRestoreFixture(); - await restoreBackgroundTasks(restoredFixture.app); - expect(restoredFixture.tasks.get("agent")).toMatchObject({ - status: "completed", parentSessionId: "parent", parentToolCallId: "call", childSessionId: "child" - }); - expect(restoredFixture.reminders).toHaveLength(1); - await restoreBackgroundTasks(restoredFixture.app); - expect(restoredFixture.reminders).toHaveLength(1); - - const restoreBeforeSendStart = patched.indexOf("E.$zSendInputWithoutBackgroundRestore=E.sendInput"); - const restoreBeforeSendEnd = patched.indexOf(",E.recallPreviousInput=", restoreBeforeSendStart); - expect(restoreBeforeSendStart).toBeGreaterThan(0); - expect(restoreBeforeSendEnd).toBeGreaterThan(restoreBeforeSendStart); - const sendOrder: string[] = []; - const sendBridge = { - sendInput: async (input: string) => { - sendOrder.push(`send:${input}`); - return { kind: "started_turn" }; - }, - $zRestorePersistedBackgroundTasks: async (app: unknown) => { - expect(app).toEqual({ id: "app" }); - sendOrder.push("restore"); - } - }; - const wrappedSendInput = new Function( - "E", - "S", - `${patched.slice(restoreBeforeSendStart, restoreBeforeSendEnd)};return E.sendInput;` - )(sendBridge, async () => ({ id: "app" })) as (input: string) => Promise; - await wrappedSendInput("continue"); - expect(sendOrder).toEqual(["restore", "send:continue"]); - - const taskMessageStart = patched.indexOf("E.sendBackgroundTaskMessage=async e=>"); - const taskMessageEnd = patched.indexOf(",E.interruptTurn=", taskMessageStart); - expect(taskMessageStart).toBeGreaterThan(0); - expect(taskMessageEnd).toBeGreaterThan(taskMessageStart); - const taskMessageBridge: { - $zRestorePersistedBackgroundTasks?: (app: unknown) => Promise; - sendBackgroundTaskMessage?: (options: Record) => Promise; - } = {}; - const taskMessageRegistry = new Map>(); - const taskMessagePayloads: Record[] = []; - const taskMessageApp = { - sessionId: "sess_parent_message", - runtime: { - runtimeTaskRegistry: { - register: (task: Record) => taskMessageRegistry.set(String(task.taskId), task), - get: (taskId: string) => taskMessageRegistry.get(taskId) - }, - subagentPort: { - sendMessage: async (payload: Record) => { - taskMessagePayloads.push(payload); - return { status: "success", delivery: "resumed_background", message: "resumed" }; - } - } - }, - - }; - taskMessageBridge.$zRestorePersistedBackgroundTasks = async (app: unknown) => { - const runtime = (app as typeof taskMessageApp).runtime; - runtime.runtimeTaskRegistry.register({ - taskId: "agent-message", - agentId: "agent-message", - agentType: "Explore", - childSessionId: "sess-child-message", - parentSessionId: "sess_parent_message", - parentToolCallId: "call-agent", - status: "failed", - taskType: "local_agent", - type: "local_agent", - isBackgrounded: true - }); - }; - const taskMessage = bridgeFunction( - "E", - "S", - `${patched.slice(taskMessageStart, taskMessageEnd)};return E.sendBackgroundTaskMessage;` - )( - taskMessageBridge, - async () => taskMessageApp - ) as (options: Record) => Promise>; - const taskMessageResult = await taskMessage({ - taskId: "agent-message", - message: "Continue with the saved instructions", - summary: "Continue with the saved instructions" - }); - expect(taskMessageResult).toMatchObject({ status: "success", delivery: "resumed_background" }); - expect(taskMessagePayloads).toHaveLength(1); - expect(taskMessagePayloads[0]).toMatchObject({ - sessionId: "sess_parent_message", - parentToolCallId: "call-agent", - to: "agent-message", - message: "Continue with the saved instructions" - }); - - expect(patchRuntimeTuiBridge(patched)).toBe(patched); - const legacyRestorePatch = patched.replace(".restoreTuiBackgroundTasks(", ".legacyRestore("); - expect(patchRuntimeTuiBridge(legacyRestorePatch)).toContain(".restoreTuiBackgroundTasks("); - const previousRestorePatch = patched.replace(/,\(queryInit\(\),reducerInit\(\),\{collectChildSessionIds:[^}]+\}\)/u, ""); - expect(patchRuntimeTuiBridge(previousRestorePatch)).toContain("projectChildEvents:e=>new NativeReducer().reduce(e)"); - const previousInterruptPatch = patched.replace("e?.waitForIdle===!0", "e?.waitForIdle===!1"); - expect(patchRuntimeTuiBridge(previousInterruptPatch)).toContain("e?.waitForIdle===!0"); - expect(() => patchRuntimeTuiBridge("incompatible runtime")).toThrow(/incompatible/); - - const modernRuntime = runtimeWithApp - .replace( - "function R(e,t){return f(e,{rewindCreatedMessageId:t.revert?.createdMessageID,rewindKeptMessageIds:t.revert?.keptMessageIDs,rewindTargetMessageId:t.revert?.targetMessageID})}", - "function R(e,t){return f(e,{branchCutAfterMessageId:t.revert?.branchCutAfterMessageID,rewindCreatedMessageId:t.revert?.createdMessageID,rewindKeptMessageIds:t.revert?.keptMessageIDs,rewindTargetMessageId:t.revert?.targetMessageID})}" - ) - .replace( - "function c(e,t){if(t.targetMessageId)return O(e,[t.targetMessageId]);let r=P(e,t.targetCheckpointId);return r?[r]:[]}", - "function c(e,t){let r=t.targetMessageIds&&t.targetMessageIds.length>0?t.targetMessageIds:t.targetMessageId?[t.targetMessageId]:[];return O(e,r)}" - ); - const modernPatched = patchRuntimeTuiBridge(modernRuntime); - expect(modernPatched).toContain("r=await e.sessionStore.getSession(e.sessionId);return p(r?R(t,r):t)"); - expect(modernPatched).toContain("targetMessageIds&&t.targetMessageIds.length>0"); - expect(modernPatched).not.toContain("Array.isArray(t.targetMessageIds)"); - - const nativeSteerRuntime = `${runtimeWithApp.replace( - "E.sendInput=async(A,$)=>{let c=t.runtime.getActiveTurnInfo();if(c)return t.runtime.steerTurn({commandKind:$?.commandKind,inputId:$?.inputId,queryId:$?.queryId,expectedTurnId:$?.expectedTurnId,input:A});return Kvt(await S(),D,O1(t))},", - "E.sendInput=async(A,$)=>{let d=$?.delivery??\"auto\";return t.runtime.admitPrompt(A,[],{...$,delivery:d,traceContext:$?.traceContext})}," - + "function admit(A,$){if($?.delivery===\"steer_active_turn\")return this.steerTurn({automationId:$?.automationId,commandKind:$?.commandKind,delivery:void 0,expectedTurnId:$?.expectedTurnId,input:A,inputId:$?.inputId,intent:I($?.intent,\"queue\"),queryId:$?.queryId,toolDisallowlist:$?.toolDisallowlist})}" - )}async function send(H,Z){let Q=await A(),X=await Q.sendInput(H,Z);return X.kind!==\"started_turn\"?X:l1t(X.result,Q,R5(t))}`; - const nativeSteerPatched = patchRuntimeTuiBridge(nativeSteerRuntime); - expect(nativeSteerPatched).toContain('delivery==="steer_active_turn"'); - expect(nativeSteerPatched).toContain('this.steerTurn({automationId:$?.automationId,commandKind:$?.commandKind'); - expect(nativeSteerPatched).toContain('intent:I($?.intent,"queue")'); - expect(nativeSteerPatched).not.toContain('pendingInputId:$?.pendingInputId'); - expect(nativeSteerPatched).toContain('l1t(await(X.result??X.completion),Q,R5(t))'); - expect(nativeSteerPatched).not.toContain('l1t(X.result,Q,R5(t))'); - expect(patchRuntimeTuiBridge(nativeSteerPatched)).toBe(nativeSteerPatched); - const presentationSteerRuntime = nativeSteerRuntime.replace( - "input:A,inputId:$?.inputId", - 'input:A,inputPresentation:$?.inputPresentation??($?.inputSource?void 0:"user_steer"),inputId:$?.inputId' - ); - const presentationSteerPatched = patchRuntimeTuiBridge(presentationSteerRuntime); - expect(presentationSteerPatched).toContain('inputPresentation:$?.inputPresentation??($?.inputSource?void 0:"user_steer")'); - expect(presentationSteerPatched).not.toContain('pendingInputId:$?.pendingInputId'); - expect(patchRuntimeTuiBridge(presentationSteerPatched)).toBe(presentationSteerPatched); - expect(() => patchRuntimeTuiBridge( - nativeSteerRuntime.replace( - "return t.runtime.admitPrompt(A,[],{...$,delivery:d,traceContext:$?.traceContext})", - "return t.runtime.submitPrompt(A,$)" - ) - )).toThrow(/active-turn steer delivery anchor missing/); - }); - - test("delegates session model persistence to the current runtime and refreshes reasoning options", async () => { - const runtime = [ - "function R(e,t){return f(e,{rewindCreatedMessageId:t.revert?.createdMessageID,rewindKeptMessageIds:t.revert?.keptMessageIDs,rewindTargetMessageId:t.revert?.targetMessageID})}", - "async function L(e){if(!e.sessionStore)return[];let t=await e.sessionStore.messages({sessionID:e.sessionId});return p(t)}", - 'function p(e){let t=[];for(let r of e){if(r.info.role==="user"){let l=r.text;t.push({content:l,role:"user"});continue}let n=[],s=[],u=r.text;t.push({content:u,...s.length>0?{parts:s}:{},role:"agent"})}return t}', - "function c(e,t){if(t.targetMessageId)return O(e,[t.targetMessageId]);let r=P(e,t.targetCheckpointId);return r?[r]:[]}", - "E.sendInput=async(A,$)=>{let c=t.runtime.getActiveTurnInfo();if(c)return t.runtime.steerTurn({commandKind:$?.commandKind,inputId:$?.inputId,queryId:$?.queryId,expectedTurnId:$?.expectedTurnId,input:A});return Kvt(await S(),D,O1(t))},", - 'listSkills:k(()=>H(e),"listSkills"),', - "E.recallPreviousInput=async A=>await(await S()).recallPreviousInputHistory?.(A)??null,", - "CVr(E,S,r);", - "return c({recallPreviousInput:g.recallPreviousInput,sendInput:g.sendInput,submitPrompt:g})" - ].join("").replace( - "E.sendInput", - 'loadSessionTranscript:a(async()=>await dUr({sessionId:e.sessionId,sessionStore:e.sessionStore}),"loadSessionTranscript"),readTodos:E.sendInput' - ); - const patched = patchRuntimeTuiBridge(runtime); - const start = patched.indexOf("E.setTransientModel=async"); - const end = patched.indexOf(",E.readSessionModel=", start); - const bridge: Record = {}; - const selections: string[] = []; - const setTransientModel = new Function( - "E", - "S", - `${patched.slice(start, end)};return E.setTransientModel;` - )( - bridge, - async () => ({ - sessionId: "sess_model_switch", - setModel: async (model: string) => { selections.push(model); return { model }; }, - getThoughtLevel: () => "high", - listThoughtLevels: () => ["disabled", "high"] - }) - ) as (model: string) => Promise; - - expect(await setTransientModel("zai/glm-5.3-flash")).toMatchObject({ - model: "zai/glm-5.3-flash", thoughtLevel: "high", effortOptions: ["disabled", "high"] - }); - expect(selections).toEqual(["zai/glm-5.3-flash"]); - }); - - test("upgrades an already-patched runtime that lacks the transient model bridge", () => { - // Simulate a runtime patched by an older patchRuntimeTuiBridge: the - // fixture above fully patched (minus setTransientModel, which did not - // exist yet). The patch must detect the gap and add the bridge + option - // instead of short-circuiting as "already patched". - const runtime = [ - "function R(e,t){return f(e,{rewindCreatedMessageId:t.revert?.createdMessageID,rewindKeptMessageIds:t.revert?.keptMessageIDs,rewindTargetMessageId:t.revert?.targetMessageID})}", - "async function L(e){if(!e.sessionStore)return[];let t=await e.sessionStore.messages({sessionID:e.sessionId});return p(t)}", - 'function p(e){let t=[];for(let r of e){if(r.info.role==="user"){let l=r.text;t.push({content:l,role:"user"});continue}let n=[],s=[],u=r.text;t.push({content:u,...s.length>0?{parts:s}:{},role:"agent"})}return t}', - "function c(e,t){if(t.targetMessageId)return O(e,[t.targetMessageId]);let r=P(e,t.targetCheckpointId);return r?[r]:[]}", - "E.sendInput=async(A,$)=>{let c=t.runtime.getActiveTurnInfo();if(c)return t.runtime.steerTurn({commandKind:$?.commandKind,inputId:$?.inputId,queryId:$?.queryId,expectedTurnId:$?.expectedTurnId,input:A});return Kvt(await S(),D,O1(t))},", - 'listSkills:k(()=>H(e),"listSkills"),', - "E.recallPreviousInput=async A=>await(await S()).recallPreviousInputHistory?.(A)??null,", - "CVr(E,S,r);", - "return c({recallPreviousInput:g.recallPreviousInput,sendInput:g.sendInput,submitPrompt:g})" - ].join("").replace( - "E.sendInput", - 'loadSessionTranscript:a(async()=>await dUr({sessionId:e.sessionId,sessionStore:e.sessionStore}),"loadSessionTranscript"),readTodos:E.sendInput' - ); - // Fully apply the current patch, then strip only the transient-model - // injections to model an older patch generation. - const fullyPatched = patchRuntimeTuiBridge(runtime); - const stripped = fullyPatched - .replace(/[A-Za-z_$]+\.setTransientModel=async e=>\{.*?\},(?=[A-Za-z_$]+\.)/u, "") - .replace(/setTransientModel:[A-Za-z_$][\w$]*\.setTransientModel,/u, ""); - expect(stripped).not.toMatch(/\.setTransientModel=async/u); - expect(stripped).not.toMatch(/setTransientModel:[A-Za-z_$][\w$]*\.setTransientModel/u); - - const patched = patchRuntimeTuiBridge(stripped); - expect(patched).toMatch(/\.setTransientModel=async/u); - expect(patched).toMatch(/setTransientModel:[A-Za-z_$][\w$]*\.setTransientModel/u); - expect(patchRuntimeTuiBridge(patched)).toBe(patched); - }); - - test("upgrades an already-patched runtime that lacks the mode bridge", () => { - const runtime = [ - "function R(e,t){return f(e,{rewindCreatedMessageId:t.revert?.createdMessageID,rewindKeptMessageIds:t.revert?.keptMessageIDs,rewindTargetMessageId:t.revert?.targetMessageID})}", - "async function L(e){if(!e.sessionStore)return[];let t=await e.sessionStore.messages({sessionID:e.sessionId});return p(t)}", - 'function p(e){let t=[];for(let r of e){if(r.info.role==="user"){let l=r.text;t.push({content:l,role:"user"});continue}let n=[],s=[],u=r.text;t.push({content:u,...s.length>0?{parts:s}:{},role:"agent"})}return t}', - "function c(e,t){if(t.targetMessageId)return O(e,[t.targetMessageId]);let r=P(e,t.targetCheckpointId);return r?[r]:[]}", - "E.sendInput=async(A,$)=>{let c=t.runtime.getActiveTurnInfo();if(c)return t.runtime.steerTurn({commandKind:$?.commandKind,inputId:$?.inputId,queryId:$?.queryId,expectedTurnId:$?.expectedTurnId,input:A});return Kvt(await S(),D,O1(t))},", - 'listSkills:k(()=>H(e),"listSkills"),', - "E.recallPreviousInput=async A=>await(await S()).recallPreviousInputHistory?.(A)??null,", - "CVr(E,S,r);", - "return c({recallPreviousInput:g.recallPreviousInput,sendInput:g.sendInput,submitPrompt:g})" - ].join("").replace( - "E.sendInput", - 'loadSessionTranscript:a(async()=>await dUr({sessionId:e.sessionId,sessionStore:e.sessionStore}),"loadSessionTranscript"),readTodos:E.sendInput' - ); - const fullyPatched = patchRuntimeTuiBridge(runtime); - const stripped = fullyPatched - .replace(/[A-Za-z_$]+\.setMode=async e=>\{return await\(await [A-Za-z_$][\w$]*\(\)\)\.setMode\(e\)\},/u, "") - .replace(/setMode:[A-Za-z_$][\w$]*\.setMode,/u, ""); - expect(stripped).not.toMatch(/\.setMode=async/u); - expect(stripped).not.toMatch(/setMode:[A-Za-z_$][\w$]*\.setMode/u); - - const patched = patchRuntimeTuiBridge(stripped); - expect(patched).toMatch(/\.setMode=async/u); - expect(patched).toMatch(/setMode:[A-Za-z_$][\w$]*\.setMode/u); - expect(patchRuntimeTuiBridge(patched)).toBe(patched); - }); - - test("persists and reads custom session titles through runtime and legacy bridges", async () => { - const runtime = [ - "function R(e,t){return f(e,{rewindCreatedMessageId:t.revert?.createdMessageID,rewindKeptMessageIds:t.revert?.keptMessageIDs,rewindTargetMessageId:t.revert?.targetMessageID})}", - "async function L(e){if(!e.sessionStore)return[];let t=await e.sessionStore.messages({sessionID:e.sessionId});return p(t)}", - 'function p(e){let t=[];for(let r of e){if(r.info.role==="user"){let l=r.text;t.push({content:l,role:"user"});continue}let n=[],s=[],u=r.text;t.push({content:u,...s.length>0?{parts:s}:{},role:"agent"})}return t}', - "function c(e,t){if(t.targetMessageId)return O(e,[t.targetMessageId]);let r=P(e,t.targetCheckpointId);return r?[r]:[]}", - 'loadSessionTranscript:a(async()=>await dUr({sessionId:e.sessionId,sessionStore:e.sessionStore}),"loadSessionTranscript"),readTodos:', - "E.sendInput=async(A,$)=>{let c=t.runtime.getActiveTurnInfo();if(c)return t.runtime.steerTurn({commandKind:$?.commandKind,inputId:$?.inputId,queryId:$?.queryId,expectedTurnId:$?.expectedTurnId,input:A});return Kvt(await S(),D,O1(t))},", - 'listSkills:k(()=>H(e),"listSkills"),', - "E.recallPreviousInput=async A=>await(await S()).recallPreviousInputHistory?.(A)??null,", - "CVr(E,S,r);", - "return c({recallPreviousInput:g.recallPreviousInput,sendInput:g.sendInput,submitPrompt:g})" - ].join(""); - const patched = patchRuntimeTuiBridge(runtime); - const setterStart = patched.indexOf("E.setCustomSessionTitle=async"); - const readerStart = patched.indexOf("E.readCustomSessionTitle=async"); - const end = patched.indexOf(",E.", readerStart); - expect(setterStart).toBeGreaterThan(-1); - expect(readerStart).toBeGreaterThan(setterStart); - expect(end).toBeGreaterThan(readerStart); - - const sessions = new Map([ - ["first", { title: "Original prompt", titleSource: "first_input" }], - ["second", { title: "Generated title", titleSource: "generated" }] - ]); - const store = { getSession: async (id: string) => sessions.get(id) }; - const calls: unknown[] = []; - const core = { - sessionStore: store, - rootTraceContext: { traceId: "root" }, - async setCustomSessionTitle(options: { title: string; traceContext?: unknown }) { - expect(this).toBe(core); - calls.push(options); - sessions.set(String(app.sessionId), { title: options.title, titleSource: "custom" }); - } - }; - let app: Record = { sessionId: "first", runtime: core }; - const bridge = new Function("S", `const E={};${patched.slice(setterStart, end)};return E;`)( - async () => app - ) as { - setCustomSessionTitle: (options: { title: string; traceContext?: unknown }) => Promise; - readCustomSessionTitle: () => Promise; - }; - expect(await bridge.readCustomSessionTitle()).toBeUndefined(); - await bridge.setCustomSessionTitle({ title: "Chosen title" }); - expect(await bridge.readCustomSessionTitle()).toBe("Chosen title"); - expect(calls).toEqual([{ title: "Chosen title", traceContext: core.rootTraceContext }]); - - app.sessionId = "second"; - expect(await bridge.readCustomSessionTitle()).toBeUndefined(); - const explicitTrace = { traceId: "explicit" }; - await bridge.setCustomSessionTitle({ title: "Second title", traceContext: explicitTrace }); - expect(calls.at(-1)).toEqual({ title: "Second title", traceContext: explicitTrace }); - expect(await bridge.readCustomSessionTitle()).toBe("Second title"); - app.sessionId = "first"; - expect(await bridge.readCustomSessionTitle()).toBe("Chosen title"); - - app = { - sessionId: "first", - sessionStore: store, - async setCustomSessionTitle(options: { title: string }) { - expect(this).toBe(app); - sessions.set(String(this.sessionId), { title: options.title, titleSource: "custom" }); - } - }; - await bridge.setCustomSessionTitle({ title: "Legacy alias" }); - expect(await bridge.readCustomSessionTitle()).toBe("Legacy alias"); - app = { sessionId: "missing" }; - expect(await bridge.readCustomSessionTitle()).toBeUndefined(); - await expect(bridge.setCustomSessionTitle({ title: "Unavailable" })).rejects.toThrow(/unavailable in this runtime/u); - app = { runtime: { setCustomSessionTitle: async () => { throw new Error("Store unavailable"); } } }; - await expect(bridge.setCustomSessionTitle({ title: "Failed" })).rejects.toThrow("Store unavailable"); - - expect(patched).toContain("setCustomSessionTitle:g.setCustomSessionTitle"); - expect(patched).toContain("readCustomSessionTitle:g.readCustomSessionTitle"); - expect(patched).toContain("readSessionResumeTarget:g.readSessionResumeTarget"); - expect(patched).toContain("E.readSessionResumeTarget=async()=>await(await S()).readSessionResumeTarget?.()"); - expect(patchRuntimeTuiBridge(patched)).toBe(patched); - const missingReader = patched - .replace(`${patched.slice(readerStart, end)},`, "") - .replace("readCustomSessionTitle:g.readCustomSessionTitle,", ""); - const upgraded = patchRuntimeTuiBridge(missingReader); - expect(upgraded).toContain("readCustomSessionTitle:g.readCustomSessionTitle"); - expect(upgraded.match(/\.readCustomSessionTitle=async/gu)).toHaveLength(1); - expect(patchRuntimeTuiBridge(upgraded)).toBe(upgraded); - }); - - test("auto-backgrounds long Agent calls while preserving explicit configuration", () => { - const runtime = "function delay(){return{autoBackgroundMs:this.config.subagents?.autoBackgroundMs,outputRootDir:'tasks'}}"; - const patched = patchRuntimeAgentAutoBackground(runtime); - const delay = new Function(`${patched};return delay;`)() as () => { autoBackgroundMs?: number }; - - expect(delay.call({ config: {} }).autoBackgroundMs).toBe(1_000); - expect(delay.call({ config: { subagents: { autoBackgroundMs: 0 } } }).autoBackgroundMs).toBe(0); - expect(patchRuntimeAgentAutoBackground(patched)).toBe(patched); - expect(() => patchRuntimeAgentAutoBackground("incompatible runtime")).toThrow(/incompatible/); - }); - - test("contains failures from the detached background Agent lifecycle", async () => { - const runtime = [ - "async function run(){throw void 0}", - "async function start(){", - "let d={promise:Promise.resolve(),reject(){}},h={dispose(){}};", - "run({onSessionStartFailed:d.reject},h.dispose);try{await d.promise}catch{}", - "let q={promise:Promise.resolve(),reject(){}},x={dispose(){}};", - "run({onSessionStartFailed:q.reject},x.dispose);try{await q.promise}catch{}", - "await Promise.resolve();await Promise.resolve()", - "}" - ].join(""); - const patched = patchRuntimeDetachedAgentLifecycle(runtime); - const diagnostics: unknown[][] = []; - const start = new Function( - "console", - `${patched};return start;` - )({ error: (...values: unknown[]) => diagnostics.push(values) }) as () => Promise; - - await start(); - expect(diagnostics).toEqual([ - ["Detached background agent lifecycle failed", "unknown rejection"], - ["Detached background agent lifecycle failed", "unknown rejection"] - ]); - expect(patchRuntimeDetachedAgentLifecycle(patched)).toBe(patched); - expect(() => patchRuntimeDetachedAgentLifecycle("incompatible runtime")).toThrow(/incompatible/); - }); - - test("accepts the native detached lifecycle tracker instead of patching it", () => { - const runtime = [ - 'let c=(p,d=!1)=>(set.add(p),p.then(()=>set.delete(p),e=>{set.delete(p),d&&failures.push(e)}),p);', - 'async function start(){let d={promise:Promise.resolve(),reject(){}},h={dispose(){}};', - 'c(run({onSessionStartFailed:d.reject},h.dispose),!0);try{await d.promise}catch{}}' - ].join(""); - - expect(hasRuntimeTrackedDetachedAgentLifecycle(runtime)).toBe(true); - expect(patchRuntimeDetachedAgentLifecycle(runtime)).toBe(runtime); - expect(hasRuntimeTrackedDetachedAgentLifecycle(runtime.replace(",!0)", ")"))).toBe(false); - }); - - test("clears stale active tools when a runtime turn settles", () => { - const runtime = [ - 'function complete(e){return{...e,status:"idle",totalTokenCount:e.totalTokenCount+1}}', - 'function fail(e){return{...e,status:"error",lastError:{message:"failed"}}}' - ].join(""); - const patched = patchRuntimeTerminalToolProjection(runtime); - const load = new Function(`${patched};return {complete,fail};`)() as { - complete: (state: Record) => Record; - fail: (state: Record) => Record; - }; - const state = { - activeToolCalls: [{ toolCallId: "stale", status: "running" }], - currentTurnId: "turn-1", - totalTokenCount: 0 - }; - - expect(load.complete(state)).toMatchObject({ - activeToolCalls: [], - currentTurnId: undefined, - status: "idle" - }); - expect(load.fail(state)).toMatchObject({ - activeToolCalls: [], - currentTurnId: undefined, - status: "error" - }); - expect(patchRuntimeTerminalToolProjection(patched)).toBe(patched); - expect(() => patchRuntimeTerminalToolProjection("incompatible runtime")).toThrow(/incompatible/); - }); - - test("pauses active goals when a continuation turn fails", async () => { - const runtime = [ - "async function execute(e){", - "await this.finishTargetTurnAccounting({endedAtMs:Date.now(),inputID:i,startedTarget:t,status:e.type===CoreErrorType.TurnCancelled?\"paused\":void 0,traceContext:c});", - "}" - ].join(""); - const patched = patchRuntimeGoalFailurePause(runtime); - const statuses: unknown[] = []; - const execute = new Function( - "CoreErrorType", - "i", - "t", - "c", - `${patched};return execute;` - )({ TurnCancelled: "cancelled" }, "input", {}, {}) as ( - this: { finishTargetTurnAccounting: (input: { status?: string }) => Promise }, - error: { type: string } - ) => Promise; - - await execute.call({ - finishTargetTurnAccounting: async (input) => { - statuses.push(input.status); - } - }, { type: "model_request_failed" }); - - expect(patched).toContain('startedTarget:t,status:"paused",traceContext:c'); - expect(statuses).toEqual(["paused"]); - expect(patchRuntimeGoalFailurePause(patched)).toBe(patched); - expect(() => patchRuntimeGoalFailurePause("incompatible runtime")).toThrow(/incompatible/); - }); - - test.each(["legacy", "budgeted"])("reclassifies registry stream EOF as retryable (%s)", budget => { - const runtime = [ - "function detectFallback(e){return}", - "function findProviderError(e){return}", - "function isSuspicious(e){return!1}", - "function logStream(e){return}", - "function publishFailure(e){return}", - "function classifyFailure(e){return}", - "class TerminalStreamError extends Error{}", - "async function*got_no_content;" - ].join(""); - const runner = [ - "async function*streamRunner(e){let retryState=createRetryState({maxAttempts:e.retry.maxAttempts});", - // the runStreamText completion branch: suspicious-empty retry -> success tail - "if(isSuspicious(x)){let le=findProviderError({providerId:String(k.model.providerId),providerKind:k.providerKind,source:x.lastErrorChunk??x.lastFinishChunk});", - "if(le){throw new TerminalStreamError(String(le))}", - "if(e.request.preserveProviderStreamBoundaries!==!0&&XX({finishReason:x.finishReason,reasoningLength:x.reasoningDeltaChars,textLength:x.textDeltaChars,toolCallCount:x.toolCallCount,usage:x.usage})&&tee({abortSignal:e.request.abortSignal,attempt:u,maxAttempts:e.retry.maxAttempts,retryCount:s})){let fe=await fhr(W),ue=Date.now();logStream({attempt:u,diagnostics:x,durationMs:ue-c,emittedError:h,emittedEvent:p,logger:e.logger,outboundHeaders:H.headers,statusContext:k}),s+=1,await g2e({abortSignal:e.request.abortSignal,attempt:u,completedAt:ue,errorPhase:\"stream\",logger:e.logger,requestHeaders:F,requestStatusSink:e.request.statusSink,responseHeaders:fe,retry:e.retry,retryBudgetAttempt:l,startedAt:c,statusContext:k,statusSink:e.statusSink,streamOutputCommitted:!1});continue}}}", - "if(logStream({attempt:u,diagnostics:x,durationMs:Date.now()-c,emittedError:h,emittedEvent:p,logger:e.logger,outboundHeaders:H.headers,statusContext:k}),!h){let de=Date.now(),le=await fhr(W);", - "await Ac({...k,attempt:u,durationMs:de-c,requestHeaderCount:U,requestHeaders:F,responseHeaderCount:Object.keys(le).length,responseHeaders:le,providerRequestId:m2e(le),finishReason:x.finishReason,usage:x.usage,timeToFirstProviderEventMs:Z,timeToFirstContentMs:J,timeToFirstTextMs:Q,streamMaxIdleMs:X||void 0,streamStallCount:V,streamOutputCommitted:z,timestamp:new Date(de).toISOString(),type:\"model_request_completed\"},_A(e)),O=!0}", - "r&&P&&await Yrt({modelIoFullRetentionEnabled:e.modelIoFullRetentionEnabled,attempt:u,debugDir:e.debugDir,isDev:n,normalizedToolCalls:S.snapshotNormalizedToolCalls(),options:P,recordModelIO:r,request:b,requestId:k.requestId,resolved:H,result:W,startedAt:c});return}" - ].join(""); - let source = `${runtime}${runner.replace( - 'providerId:String(k.model.providerId),providerKind:k.providerKind,source:x.lastErrorChunk??x.lastFinishChunk})', - 'providerId:String(k.providerId),providerKind:H.providerKind,source:x.lastErrorChunk??x.lastFinishChunk})??fallback({captcha:H.accountAccess?.mode==="start-plan",providerId:String(k.providerId),providerKind:H.providerKind})' - )}label(streamRunner,"runStreamText");`; - if (budget === "budgeted") source = source.replace( - "let retryState=createRetryState({maxAttempts:e.retry.maxAttempts});", - "let retryBudget=e.request.modelRetryBudget,retryState=createRetryState({maxAttempts:e.retry.maxAttempts});" - ); - - expect(hasRuntimeStreamEofFinishGuard(source)).toBe(false); - const patched = patchRuntimeStreamEofFinishGuard(source); - - expect(patched).toContain("function $zStreamEofFinishGuard(e){"); - expect(patched).toContain("e.rawFinishReason==null"); - expect(patched).toContain('name:"ModelStreamIdleTimeoutError",code:"MODEL_STREAM_IDLE_TIMEOUT"'); - expect(patched).toContain('continue}}}if($zStreamEofFinishGuard(x)&&!h)throw Object.assign'); - expect(patched).not.toContain("$zStreamEofFinishGuard(z)"); - expect(patched).toContain('if(logStream({attempt:u,diagnostics:x'); - expect(hasRuntimeStreamEofFinishGuard(patched)).toBe(true); - expect(hasRuntimeStreamEofFinishGuard( - patched.replace("e.textDeltaChars>0", "e.textDeltaChars>=0") - )).toBe(false); - expect(patchRuntimeStreamEofFinishGuard(patched)).toBe(patched); - expect(() => patchRuntimeStreamEofFinishGuard("incompatible runtime")).toThrow( - /stream EOF guard patch/ - ); - expect(() => patchRuntimeStreamEofFinishGuard(source.replace('label(streamRunner,"runStreamText");', ""))).toThrow(/label missing/); - - // The guard must classify the SDK's synthetic "other" finish (null - // finish_reason) and a missing reason as EOF, while trusting a provider - // supplied raw finish reason. - const guardSource = patched.slice( - patched.indexOf("function $zStreamEofFinishGuard(e){"), - patched.indexOf("async function*streamRunner(e){") - ); - const guard = new Function(`${guardSource};return $zStreamEofFinishGuard;`)() as ( - diagnostics: Record - ) => boolean; - expect(guard({ finishReason: void 0, textDeltaChars: 3, toolCallCount: 0, reasoningDeltaChars: 0 })).toBe(true); - expect(guard({ finishReason: "other", rawFinishReason: void 0, textDeltaChars: 3, toolCallCount: 0, reasoningDeltaChars: 0 })).toBe(true); - expect(guard({ finishReason: "other", rawFinishReason: "other", textDeltaChars: 3, toolCallCount: 0, reasoningDeltaChars: 0 })).toBe(false); - expect(guard({ finishReason: "other", rawFinishReason: "provider_done", textDeltaChars: 3, toolCallCount: 0, reasoningDeltaChars: 0 })).toBe(false); - expect(guard({ finishReason: "stop", rawFinishReason: "stop", textDeltaChars: 3, toolCallCount: 0, reasoningDeltaChars: 0 })).toBe(false); - expect(guard({ finishReason: "other", rawFinishReason: void 0, textDeltaChars: 0, toolCallCount: 0, reasoningDeltaChars: 0 })).toBe(false); - expect(guard({ finishReason: void 0, textDeltaChars: 0, toolCallCount: 0, reasoningDeltaChars: 0 })).toBe(false); - expect(guard({ - finishReason: "other", - rawFinishReason: void 0, - textDeltaChars: 0, - toolCallCount: 0, - reasoningDeltaChars: 0, - chunkCounts: { "tool-input-start": 1 } - })).toBe(true); - }); -}); diff --git a/test/system-event-view.test.ts b/test/system-event-view.test.ts deleted file mode 100644 index 03114925..00000000 --- a/test/system-event-view.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { SystemEventView } from "../packages/zcode-tui/src/system-event-view.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -describe("system event view", () => { - test("keeps retry and error details compact until focused expansion", () => { - const view = new SystemEventView(createTheme(false), { - tone: "warning", - title: "Retrying model request", - summary: "attempt 2/3 in 1s", - detail: "Provider overloaded" - }); - expect(view.render(80).join("\n")).toContain("Ctrl+O to expand"); - expect(view.render(80).join("\n")).not.toContain("Provider overloaded"); - view.setExpanded(true); - expect(view.render(80).join("\n")).toContain("Provider overloaded"); - expect(view.getSearchText()).toContain("Provider overloaded"); - }); -}); diff --git a/test/terminal-text.test.ts b/test/terminal-text.test.ts deleted file mode 100644 index 48231100..00000000 --- a/test/terminal-text.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { CURSOR_MARKER, Input, renderFakeCursor, visibleWidth } from "@earendil-works/pi-tui"; -import { terminalOutputText } from "../scripts/terminal-output.ts"; - -import { - sanitizeTerminalText, - removeLastGrapheme, - stripCaretHighlight, - StreamingTerminalTextSanitizer, - truncateGraphemes, - truncateTerminalText, - wrapTerminalText -} from "../packages/zcode-tui/src/terminal-text.ts"; - -describe("terminal text safety", () => { - test("keeps PTY text after an ST-terminated status query followed by a BEL-terminated title", () => { - const trace = "\x1b]7501;?\x1b\\\x1b[?1;2c" - + "\x1b[32m╭─ ◆ ZCODE\x1b[0m\r\n" - + "\x1b]0;ZCode\x07Ask a task about this workspace\r\n"; - expect(terminalOutputText(trace)).toBe("╭─ ◆ ZCODE\nAsk a task about this workspace\n"); - expect(terminalOutputText("before\x1bPpayload\x1b\\middle\x1b]9;notice\x07after")) - .toBe("beforemiddleafter"); - }); - - test("removes cursor, screen, OSC and string control sequences", () => { - const unsafe = [ - "before", - "\x1b[2J", - "\x1b[1;1H", - "\x1b]0;owned\x07", - "\x1bPpayload\x1b\\", - "after" - ].join(""); - - expect(sanitizeTerminalText(unsafe)).toBe("beforeafter"); - }); - - test("strips external SGR by default and preserves it only when explicitly requested", () => { - expect(sanitizeTerminalText("\x1b[47;8;7m隐藏\x1b[0m\nnext\tcell")).toBe("隐藏\nnext\tcell"); - expect(sanitizeTerminalText("\x1b[31mred\x1b[0m", { preserveSgr: true })) - .toBe("\x1b[31mred\x1b[0m"); - }); - - test("turns carriage-return progress into non-destructive lines", () => { - expect(sanitizeTerminalText("10%\r20%\r\nDone\b!")).toBe("10%\n20%\nDone!"); - }); - - test("matches one-shot sanitizing across every transport chunk boundary", () => { - const values = [ - "before\x1b[31mred\x1b[0mafter", - "title\x1b]0;owned\x07safe", - "left\x1bPpayload\x1b\\right", - "one\x9b2Jtwo\x9dunsafe\x07three", - "10%\r20%\r\nDone\tcell", - "unknown\x1b!escape", - "unfinished\x1b]discarded" - ]; - - for (const value of values) { - const expected = sanitizeTerminalText(value); - for (let split = 0; split <= value.length; split += 1) { - const sanitizer = new StreamingTerminalTextSanitizer(); - const actual = sanitizer.append(value.slice(0, split)) - + sanitizer.append(value.slice(split)) - + sanitizer.finish(); - expect(actual).toBe(expected); - } - - const sanitizer = new StreamingTerminalTextSanitizer(); - const actual = Array.from(value, (character) => sanitizer.append(character)).join("") - + sanitizer.finish(); - expect(actual).toBe(expected); - } - }); - - test("preserves trusted SGR split across chunks without preserving commands", () => { - const sanitizer = new StreamingTerminalTextSanitizer({ preserveSgr: true }); - expect(sanitizer.append("\x1b[3")).toBe(""); - expect(sanitizer.append("1mred\x1b[2")).toBe("\x1b[31mred"); - expect(sanitizer.append("Jsafe") + sanitizer.finish()).toBe("safe"); - }); - - test("wraps ANSI text and truncates complete terminal graphemes", () => { - const wrapped = wrapTerminalText("\x1b[31mone two three four\x1b[0m", 8); - expect(wrapped.length).toBeGreaterThan(1); - expect(wrapped.every((line) => visibleWidth(line) <= 8)).toBe(true); - expect(truncateTerminalText("prefix 👨‍👩‍👧‍👦 suffix", 10)).not.toContain("�"); - expect(truncateTerminalText("prefix 👨‍👩‍👧‍👦 suffix", 10)).not.toMatch(/[\uD800-\uDBFF]$/u); - expect(truncateGraphemes("ab👨‍👩‍👧‍👦cd", 4)).toBe("ab👨‍👩‍👧‍👦…"); - expect(removeLastGrapheme("filter 👨‍👩‍👧‍👦")).toBe("filter "); - }); -}); - -describe("terminal carets", () => { - test("preserves placeholder styles, cursor markers and cell widths", () => { - const input = new Input({ - placeholder: "Find in transcript", - placeholderStyle: (text) => `\x1b[2m${text}\x1b[22m` - }); - for (const focused of [false, true]) { - input.focused = focused; - const rendered = input.render(40)[0]!; - const stripped = stripCaretHighlight(rendered); - expect(stripped).not.toContain("\x1b_pi:fc\x07"); - expect(stripped).not.toContain("\x1b_pi:/fc\x07"); - expect(stripped).toContain(`${focused ? CURSOR_MARKER : ""}\x1b[2mF\x1b[22m`); - expect(visibleWidth(stripped)).toBe(visibleWidth(rendered)); - } - }); - - test("removes pi-tui fake cursors while retaining styled Unicode and hardware cursor positions", () => { - for (const grapheme of [" ", "a", "中", "👩‍💻", "é"]) { - const styled = `\x1b[2m${grapheme}\x1b[22m`; - expect(stripCaretHighlight(renderFakeCursor(styled))).toBe(styled); - expect(stripCaretHighlight(`${CURSOR_MARKER}${renderFakeCursor(styled)}`)) - .toBe(`${CURSOR_MARKER}${styled}`); - // Width truncation can remove the closing APC marker at the right edge. - expect(stripCaretHighlight(`${CURSOR_MARKER}\x1b_pi:fc\x07${styled}`)) - .toBe(`${CURSOR_MARKER}${styled}`); - } - }); - - test("handles editor/input reset forms and Unicode with or without keyboard focus", () => { - for (const reset of [0, 27]) { - for (const grapheme of [" ", "a", "中", "👩‍💻", "é"]) { - const caret = `\x1b[7m${grapheme}\x1b[${reset}m`; - expect(stripCaretHighlight(caret)).toBe(grapheme); - expect(stripCaretHighlight(`${CURSOR_MARKER}${caret}`)).toBe(`${CURSOR_MARKER}${grapheme}`); - } - } - }); -}); diff --git a/test/theme.test.ts b/test/theme.test.ts deleted file mode 100644 index 774a11cb..00000000 --- a/test/theme.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { TerminalColors } from "@earendil-works/pi-tui"; - -import { - colorSchemeFromColorFgBg, - colorSchemeFromRgb, - initialColorScheme, - queryTerminalColorScheme, - themePreference -} from "../packages/zcode-tui/src/color-scheme.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -function xtermRgb(index: number): [number, number, number] { - const base: Array<[number, number, number]> = [ - [0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0], - [0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192], - [128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0], - [0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255] - ]; - if (index < 16) return base[index] ?? [0, 0, 0]; - if (index < 232) { - const levels = [0, 95, 135, 175, 215, 255]; - const value = index - 16; - return [ - levels[Math.floor(value / 36)] ?? 0, - levels[Math.floor(value / 6) % 6] ?? 0, - levels[value % 6] ?? 0 - ]; - } - const gray = 8 + (index - 232) * 10; - return [gray, gray, gray]; -} - -function luminance(color: [number, number, number]): number { - const [red, green, blue] = color.map((channel) => { - const value = channel / 255; - return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - }); - return 0.2126 * (red ?? 0) + 0.7152 * (green ?? 0) + 0.0722 * (blue ?? 0); -} - -function sgrContrast(value: string): number { - const match = /38;5;(\d+);48;5;(\d+)/u.exec(value); - if (!match) return 0; - const foreground = luminance(xtermRgb(Number(match[1]))); - const background = luminance(xtermRgb(Number(match[2]))); - return (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05); -} - -describe("TUI terminal theme", () => { - test("detects light and dark terminal backgrounds", () => { - expect(colorSchemeFromRgb({ r: 0, g: 0, b: 0 })).toBe("dark"); - expect(colorSchemeFromRgb({ r: 255, g: 255, b: 255 })).toBe("light"); - expect(colorSchemeFromRgb({ r: 18, g: 52, b: 86 })).toBe("dark"); - expect(colorSchemeFromRgb({ r: 238, g: 238, b: 238 })).toBe("light"); - - expect(colorSchemeFromColorFgBg("15;0")).toBe("dark"); - expect(colorSchemeFromColorFgBg("0;15")).toBe("light"); - expect(colorSchemeFromColorFgBg("0;8")).toBe("dark"); - expect(colorSchemeFromColorFgBg("0;255")).toBeUndefined(); - expect(colorSchemeFromColorFgBg("invalid")).toBeUndefined(); - }); - - test("honors explicit config themes and safely falls back to auto", () => { - expect(themePreference(" DARK ")).toBe("dark"); - expect(themePreference("light")).toBe("light"); - expect(themePreference("unsupported")).toBe("auto"); - expect(themePreference(undefined)).toBe("auto"); - expect(initialColorScheme("dark", "0;15")).toBe("dark"); - expect(initialColorScheme("light", "15;0")).toBe("light"); - expect(initialColorScheme("auto", "0;15")).toBe("light"); - expect(initialColorScheme("auto", "invalid")).toBe("dark"); - }); - - test.each([ - [{ background: { r: 0, g: 0, b: 0 } }, "light", "dark"], - [{ background: { r: 255, g: 255, b: 255 } }, "dark", "light"], - [{}, "light", "light"], - [{}, undefined, undefined] - ] as const)("uses the palette background or scheme-only terminal reply: %j, %s", async (colors, report, expected) => { - let listener: ((scheme: "dark" | "light") => void) | undefined; - const ui = { - terminal: { write: () => { if (report) listener?.(report); } }, - onTerminalColorSchemeChange: (callback: typeof listener) => { - listener = callback; - return () => { listener = undefined; }; - }, - queryTerminalColors: async (): Promise => colors - }; - expect(await queryTerminalColorScheme(ui, 100)).toBe(expected); - expect(listener).toBeUndefined(); - }); - - test("cleans up the theme listener when the palette query fails", async () => { - let removed = false; - await expect(queryTerminalColorScheme({ - terminal: { write: () => {} }, - onTerminalColorSchemeChange: () => () => { removed = true; }, - queryTerminalColors: async () => { throw new Error("terminal closed"); } - }, 100)).rejects.toThrow("terminal closed"); - expect(removed).toBe(true); - }); - - test("pairs every card background with a readable foreground", () => { - const theme = createTheme(true, "dark"); - const surface = theme.toolErrorBackground; - const rendered = surface(`plain ${theme.bold("bold")} tail`); - - expect(rendered).toStartWith("\x1b[38;5;252;48;5;52m"); - expect(rendered).toContain("\x1b[0m\x1b[38;5;252;48;5;52m tail"); - - theme.setColorScheme("light"); - expect(surface("plain")).toStartWith("\x1b[38;5;236;48;5;224m"); - expect(theme.accent("accent")).toStartWith("\x1b[38;5;25m"); - expect(theme.select.selectedText("selected")).toContain("\x1b[38;5;25m"); - }); - - test("keeps emphasized surfaces above WCAG AA contrast", () => { - const dark = createTheme(true, "dark"); - expect(sgrContrast(dark.diffHunkLine("hunk"))).toBeGreaterThanOrEqual(4.5); - expect(sgrContrast(dark.toolErrorBackground("error"))).toBeGreaterThanOrEqual(4.5); - const light = createTheme(true, "light"); - expect(sgrContrast(light.toolPendingBackground("permission"))).toBeGreaterThanOrEqual(4.5); - expect(sgrContrast(light.toolErrorBackground("error"))).toBeGreaterThanOrEqual(4.5); - }); - - test("gives selected options an explicit foreground in both color schemes", () => { - const theme = createTheme(true, "dark"); - expect(theme.select.selectedText("selected")).toContain("\x1b[38;5;75m"); - theme.setColorScheme("light"); - expect(theme.select.selectedText("selected")).toContain("\x1b[38;5;25m"); - }); - - test("keeps fullscreen scrollbar thumbs aligned with the active theme", () => { - // pi-tui 0.85 paints the scrollbar as a track glyph plus a thumb glyph, so - // both roles are styled with foreground colors rather than a cell background. - const theme = createTheme(true, "dark"); - expect(theme.scrollbarThumb("\u2503")).toStartWith("\x1b[38;5;250m"); - expect(theme.scrollbarTrack("\u2502")).toStartWith("\x1b[38;5;238m"); - - theme.setColorScheme("light"); - expect(theme.scrollbarThumb("\u2503")).toStartWith("\x1b[38;5;240m"); - expect(theme.scrollbarTrack("\u2502")).toStartWith("\x1b[38;5;250m"); - - expect(createTheme(false).scrollbarThumb("\u2503")).toBe("\u2503"); - expect(createTheme(false).scrollbarTrack("\u2502")).toBe("\u2502"); - }); - - test("never relies on the terminal default foreground for strong text", () => { - const theme = createTheme(true, "dark"); - expect(theme.bold("strong")).toStartWith("\x1b[1;38;5;252m"); - theme.setColorScheme("light"); - expect(theme.bold("strong")).toStartWith("\x1b[1;38;5;236m"); - expect(theme.select.selectedText("selected")).toContain("\x1b[38;5;25mselected"); - }); - - test("keeps no-color output unchanged", () => { - const theme = createTheme(false, "light"); - expect(theme.toolErrorBackground("failure")).toBe("failure"); - expect(theme.accent("accent")).toBe("accent"); - expect(theme.searchMatch("match")).toBe("⟦match⟧"); - }); -}); diff --git a/test/thinking-view.test.ts b/test/thinking-view.test.ts deleted file mode 100644 index 067ada74..00000000 --- a/test/thinking-view.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { ThinkingView } from "../packages/zcode-tui/src/thinking-view.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -describe("TUI thinking view", () => { - test("streams reasoning into one card and settles without duplicating text", () => { - const view = new ThinkingView(createTheme(false)); - - expect(view.render(52)).toEqual([]); - view.append("Inspecting "); - view.append("the repository."); - - const active = view.render(52).map((line) => line.trimEnd()).join("\n"); - expect(active).toContain("◇ Thinking · active"); - expect(active).toContain("Inspecting the repository."); - expect(active.match(/Inspecting the repository\./g)).toHaveLength(1); - - view.complete(); - const complete = view.render(52).map((line) => line.trimEnd()).join("\n"); - expect(complete).toContain("◇ Thought"); - expect(complete).not.toContain("· active"); - expect(complete).toContain("Ctrl+O to expand"); - expect(complete).not.toContain("Inspecting the repository."); - - view.setExpanded(true); - const expanded = view.render(52).map((line) => line.trimEnd()).join("\n"); - expect(expanded.match(/Inspecting the repository\./g)).toHaveLength(1); - }); - - test("wraps Markdown and CJK reasoning within narrow terminals", () => { - const view = new ThinkingView(createTheme(false)); - view.append("**检查结果**:需要继续分析工具调用与终端布局,确保所有内容保持可读。 "); - view.append("`reasoning_delta` remains structured."); - - const lines = view.render(30); - expect(lines.join("\n")).toContain("检查结果"); - expect(lines.join("\n")).toContain("reasoning_delta"); - expect(lines.every((line) => visibleWidth(line) <= 30)).toBe(true); - }); - - test("resumes the same card when reasoning continues after a text block", () => { - const view = new ThinkingView(createTheme(false)); - - view.append("Inspecting "); - view.complete(); - view.append("the remaining files."); - - const active = view.render(52).map((line) => line.trimEnd()).join("\n"); - expect(active).toContain("◇ Thinking · active"); - expect(active.match(/Inspecting the remaining files\./g)).toHaveLength(1); - - view.complete(); - expect(view.render(52).join("\n")).toContain("◇ Thought"); - }); - - test("keeps routine thinking content free of full-width backgrounds", () => { - const view = new ThinkingView(createTheme(true, "light")); - view.append("Inspecting the runtime."); - expect(view.render(60).join("\n")).not.toContain("\x1b[48;5;"); - }); - - test("bounds the active stream while retaining the complete trace for search and expansion", () => { - const view = new ThinkingView(createTheme(false)); - for (let index = 0; index < 40; index += 1) { - view.append(`reasoning line ${index}\n`); - } - - const active = view.render(80).join("\n"); - expect(active).not.toContain("reasoning line 0"); - expect(active).toContain("reasoning line 39"); - expect(view.getSearchText()).toContain("reasoning line 0"); - expect(view.getSearchText()).toContain("reasoning line 39"); - - view.complete(); - expect(view.render(80).join("\n")).not.toContain("reasoning line 0"); - - view.setExpanded(true); - const expanded = view.render(80).join("\n"); - expect(expanded).toContain("reasoning line 0"); - expect(expanded).toContain("reasoning line 39"); - }); - - test("does not split a surrogate pair at the live-tail boundary", () => { - const view = new ThinkingView(createTheme(false)); - view.append("prefix ".repeat(3_000)); - view.append("tail 👋"); - - const active = view.render(80).join("\n"); - expect(active).toContain("tail 👋"); - expect(active).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/u); - expect(active).not.toMatch(/(? { - test("keeps small payload shapes and shared references intact", () => { - const shared = { value: "same" }; - const compacted = compactToolPayloads([{ first: shared, second: shared }]); - - const retained = compacted.values[0] as { first: unknown; second: unknown }; - expect(compacted.truncated).toBeFalse(); - expect(retained).toEqual({ first: { value: "same" }, second: { value: "same" } }); - expect(retained.second).toBe(retained.first); - }); - - test("clones Error payloads without losing their display type", () => { - const source = new Error("boom"); - const compacted = compactToolPayloads([source]); - const retained = compacted.values[0]; - - expect(retained).not.toBe(source); - expect(retained).toBeInstanceOf(Error); - expect((retained as Error).name).toBe("Error"); - expect((retained as Error).message).toBe("boom"); - expect((retained as Error).stack).toBeUndefined(); - }); - - test("bounds strings, nodes, depth, arrays, and records", () => { - let deep: unknown = "end"; - for (let level = 0; level < 12; level += 1) deep = { next: deep }; - const payload = { - large: "x".repeat(1_000_000), - array: Array.from({ length: 2_000 }, (_, index) => ({ index, value: `item ${index}` })), - deep - }; - const compacted = compactToolPayloads([payload]); - const actual = toolPayloadSize(compacted.values); - - expect(compacted.truncated).toBeTrue(); - expect(compacted.size.characters).toBeLessThanOrEqual(MAX_RETAINED_TOOL_PAYLOAD_CHARACTERS); - expect(compacted.size.nodes).toBeLessThanOrEqual(MAX_RETAINED_TOOL_PAYLOAD_NODES); - expect(actual.characters).toBeLessThanOrEqual(MAX_RETAINED_TOOL_PAYLOAD_CHARACTERS); - }); - - test("replaces completed image data without retaining base64", () => { - const data = "a".repeat(1_000_000); - const compacted = compactToolPayloads([{ - content: [{ type: "image", mimeType: "image/png", data }] - }]); - const retained = JSON.stringify(compacted.values); - - expect(compacted.truncated).toBeTrue(); - expect(retained).toContain("binary payload omitted: 1000000 characters"); - expect(retained).not.toContain(data.slice(0, 10_000)); - }); - - test("keeps both ends of a clipped text string without broken surrogates", () => { - const value = `start 👨‍👩‍👧‍👦 ${"x".repeat(100)} finish ✅`; - const compacted = compactToolPayloads([value], { - characters: 60, - depth: 8, - entries: 64, - nodes: 64 - }); - const retained = compacted.values[0] as string; - - expect(retained).toContain("retained tool payload truncated"); - expect(retained).not.toMatch(/^[\uDC00-\uDFFF]/u); - expect(retained).not.toMatch(/[\uD800-\uDBFF]$/u); - }); -}); diff --git a/test/tool-renderers.test.ts b/test/tool-renderers.test.ts deleted file mode 100644 index b243e902..00000000 --- a/test/tool-renderers.test.ts +++ /dev/null @@ -1,456 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; -import { - canonicalToolName, - isAgentDispatchTool, - officialToolNames -} from "../packages/zcode-tui/src/tool-renderers.ts"; -import { ToolExecutionView, toolCard } from "../packages/zcode-tui/src/tool-view.ts"; - -function expandedCard(options: ConstructorParameters[1]): string { - const view = new ToolExecutionView(createTheme(false), options); - view.setExpanded(true); - return view.render(100).map((line) => line.trimEnd()).join("\n"); -} - -describe("official tool renderer registry", () => { - test("maps every official tool and only declared aliases", () => { - for (const name of officialToolNames) expect(canonicalToolName(name)).toBe(name); - expect(canonicalToolName("web_search")).toBe("WebSearch"); - expect(canonicalToolName("KillShell")).toBe("TaskStop"); - expect(canonicalToolName("mcp__docs__lookup")).toBe("MCP"); - expect(canonicalToolName("TaskStop")).toBe("TaskStop"); - expect(isAgentDispatchTool("Agent")).toBeTrue(); - expect(isAgentDispatchTool("Subagent")).toBeTrue(); - expect(isAgentDispatchTool("Task")).toBeTrue(); - expect(isAgentDispatchTool("TaskOutput")).toBeFalse(); - expect(canonicalToolName("TaskCreate")).toBeUndefined(); - expect(canonicalToolName("not-a-real-task-tool")).toBeUndefined(); - }); - - test("renders WebFetch metadata and expandable page content", () => { - const options = { - name: "WebFetch", - state: "complete", - input: { url: "https://example.com", prompt: "Summarize" }, - result: { - success: true, - output: { - finalUrl: "https://example.com/final", - status: 200, - statusText: "OK", - bytes: 2_048, - durationMs: 1_500, - cacheHit: false, - redirects: [{ from: "https://example.com", to: "https://example.com/final", status: 301 }], - result: "Fetched page summary", - truncated: false - } - } - }; - const compact = toolCard(options); - expect(compact).toContain("✓ Fetch https://example.com"); - expect(compact).toContain("Received 2 KB · 200 OK · 1s"); - expect(compact).toContain("Ctrl+O to expand"); - expect(compact).not.toContain("Fetched page summary"); - expect(expandedCard(options)).toContain("Fetched page summary"); - }); - - test("renders WebSearch counts, timing, summary and sources", () => { - const options = { - name: "web_search", - state: "complete", - input: { query: "ZCode release" }, - result: { - query: "ZCode release", - results: [ - { title: "ZCode", url: "https://zcode.ai" }, - { title: "Docs", url: "https://docs.zcode.ai", pageAge: "2 days" } - ], - sources: [{ title: "ZCode", url: "https://zcode.ai" }], - summary: "Two relevant results.", - durationMs: 240, - webSearchRequests: 1 - } - }; - expect(toolCard(options)).toContain("2 results · 1 source · 1 search · 240ms"); - const expanded = expandedCard(options); - expect(expanded).toContain("Two relevant results."); - expect(expanded).toContain("Docs · https://docs.zcode.ai · 2 days"); - }); - - test("renders TodoRead and GoalRead without generic JSON", () => { - const todo = toolCard({ - name: "TodoRead", - state: "complete", - result: { todos: [ - { content: "Inspect protocol", status: "completed", priority: "high" }, - { content: "Render tools", status: "in_progress", priority: "high" }, - { content: "Run smoke", status: "pending", priority: "medium" } - ] } - }); - expect(todo).toContain("1 completed · 1 in progress · 1 pending"); - expect(todo).toContain("✓ Inspect protocol"); - expect(todo).not.toContain('"todos"'); - - const goalOptions = { - name: "GoalRead", - state: "complete", - result: { - objective: "Implement the complete TUI", - status: "active", - tokensUsed: 12_000, - tokenBudget: 50_000, - timeUsedSeconds: 90 - } - }; - const goal = toolCard(goalOptions); - expect(goal).toContain("✓ Goal Implement the complete TUI"); - expect(goal).toContain("active · 12,000 tokens used · 50,000 budget · 1m 30s"); - expect(goal).not.toContain('"objective"'); - }); - - test("renders restored session context as bounded expandable content", () => { - const options = { - name: "ReadSessionContext", - state: "complete", - input: { sessionId: "sess_previous", query: "renderer decisions", strategy: "relevant" }, - result: { - status: "success", - sessionId: "sess_previous", - strategy: "relevant", - source: "local", - content: "Use the official part event stream.", - messageCount: 40, - selectedMessageCount: 4, - truncated: false - } - }; - expect(toolCard(options)).toContain("success · local source · 4/40 messages"); - expect(toolCard(options)).not.toContain("Use the official part event stream."); - expect(expandedCard(options)).toContain("Use the official part event stream."); - }); - - test("renders answered questions and suppresses the input payload", () => { - const card = toolCard({ - name: "AskUserQuestion", - state: "complete", - input: { questions: [{ question: "Which runtime?", header: "Runtime", options: [], multiSelect: false }] }, - result: { questions: [], answers: { "Which runtime?": "Bun" } } - }); - expect(card).toContain("✓ Question 1 question"); - expect(card).toContain("Which runtime?"); - expect(card).toContain("Bun"); - expect(card).not.toContain('"questions"'); - }); - - test("renders complete custom answers retained in the modified question input", () => { - const answer = "Use the existing terminal editor so a deliberately long custom response wraps naturally and remains visible through this final marker: ANSWER_END"; - const card = toolCard({ - name: "AskUserQuestion", - state: "complete", - input: { - questions: [{ question: "How should this be handled?", header: "Approach", options: [], multiSelect: false }], - answers: { "How should this be handled?": answer } - }, - result: { success: true } - }); - - expect(card).toContain("How should this be handled?"); - expect(card).toContain(answer); - expect(card).toContain("ANSWER_END"); - expect(card).not.toContain('"answers"'); - }); - - test("renders SendMessage and TaskStop as distinct tools", () => { - const message = toolCard({ - name: "SendMessage", - state: "complete", - input: { to: "agent_123", summary: "Review renderer", message: "Check the new registry." }, - result: { status: "success", messageId: "msg_1", agentId: "agent_123", delivery: "queued" } - }); - expect(message).toContain("✓ Message to agent_123 · Review renderer"); - expect(message).toContain("success · queued · id msg_1"); - - const stopped = toolCard({ - name: "TaskStop", - state: "complete", - input: { task_id: "bg_1" }, - result: { task_id: "bg_1", task_type: "shell", command: "bun test", message: "Successfully stopped" } - }); - expect(stopped).toContain("✓ Stop task bg_1 · shell"); - expect(stopped).toContain("bun test · stopped"); - expect(stopped).not.toContain("Agent"); - }); - - test("maps TaskOutput and renders only the compact runtime display", () => { - expect(canonicalToolName("TaskOutput")).toBe("TaskOutput"); - expect(canonicalToolName("task_output")).toBe("TaskOutput"); - - const transcript = Array.from({ length: 40 }, (_, index) => `tool_use ${index}: Bash → ls -la /src`).join("\n"); - const display = { - kind: "task_output", - retrievalStatus: "success", - taskStatus: "completed", - output: "Subagent final answer: renderer fixed.", - truncated: true - }; - - const live = toolCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_1" }, - result: { - success: true, - content: `success\n\n${transcript}\n`, - display - } - }); - expect(live).toContain("✓ TaskOutput task bg_1"); - expect(live).toContain("retrieved · completed · output truncated"); - expect(live).toContain("Subagent final answer: renderer fixed."); - expect(live).not.toContain("tool_use 0"); - expect(live).not.toContain('"success"'); - - const restored = toolCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_1" }, - result: { - output: `success\nbg_1\n\n${transcript}\n`, - display - } - }); - expect(restored).toContain("Subagent final answer: renderer fixed."); - expect(restored).not.toContain("tool_use 0"); - }); - - test("renders TaskOutput from persisted fallbacks without display metadata", () => { - const serialized = JSON.stringify({ - retrieval_status: "success", - task: { - task_id: "bg_2", - task_type: "local_agent", - status: "failed", - output: "Agent failed: boom", - error: "boom" - } - }); - const serializedCard = toolCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_2" }, - result: { output: serialized } - }); - expect(serializedCard).toContain("✓ TaskOutput task bg_2"); - expect(serializedCard).toContain("failed"); - expect(serializedCard).toContain("Task output is available in /tasks."); - expect(serializedCard).not.toContain("Agent failed: boom"); - expect(serializedCard).not.toContain('"retrieval_status"'); - - const modelContent = "timeout\n\npartial transcript tail\n"; - const timeoutCard = toolCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_3" }, - result: { output: modelContent } - }); - expect(timeoutCard).toContain("timed out waiting"); - expect(timeoutCard).toContain("No output yet"); - expect(timeoutCard).not.toContain("partial transcript tail"); - - const notReady = toolCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_4" }, - result: { - success: true, - content: "not_ready", - display: { kind: "task_output", retrievalStatus: "not_ready" } - } - }); - expect(notReady).toContain("not ready yet"); - expect(notReady).toContain("No output yet"); - }); - - test("renders TaskOutput when the restored part passes the raw output string through", () => { - // upsertProtocolPart builds result = part.output (a bare string) when the - // persisted part carries no resultDisplay metadata at all. There is no - // compact display left to recover, so the renderer consumes the string and - // keeps the raw output in /tasks instead of making it expandable here. - const transcript = Array.from({ length: 30 }, (_, index) => `tool_use ${index}: Bash → ls /src`).join("\n"); - const modelContent = `success\nbg_5\n\n${transcript}\n`; - const card = toolCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_5" }, - result: modelContent - }); - expect(card).toContain("✓ TaskOutput task bg_5"); - expect(card).toContain("retrieved"); - expect(card).toContain("Task output is available in /tasks."); - expect(card).not.toContain("tool_use 0"); - expect(card).not.toContain(""); - expect(card).not.toContain(""); - expect(card).not.toContain(""); - expect(expandedCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_5" }, - result: modelContent - })).not.toContain("tool_use 29"); - - const liveWithoutDisplay = toolCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_5" }, - result: { success: true, content: modelContent } - }); - expect(liveWithoutDisplay).toContain("retrieved"); - expect(liveWithoutDisplay).not.toContain("tool_use 0"); - expect(liveWithoutDisplay).not.toContain('"content"'); - - const serialized = JSON.stringify({ - retrieval_status: "timeout", - task: null - }); - const timeoutCard = toolCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_6" }, - result: serialized - }); - expect(timeoutCard).toContain("timed out waiting"); - expect(timeoutCard).toContain("No output yet"); - expect(timeoutCard).not.toContain('"retrieval_status"'); - }); - - test("preserves serialized TaskOutput failures from restored parts", () => { - const failure = JSON.stringify({ - result: false, - errorCode: 2, - message: "No task found with ID: bg_missing" - }); - for (const result of [failure, { output: failure }]) { - const card = toolCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_missing" }, - result - }); - expect(card).toContain("No task found with ID: bg_missing"); - expect(card).not.toContain("Task output is available in /tasks."); - expect(card).not.toContain('"errorCode"'); - } - }); - - test("keeps terminal control sequences out of every TaskOutput path", () => { - const injection = `success\n\nprefix \x1b]52;c;aGVsbG8=\x1b\\ suffix\n`; - const fallbackCard = toolCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_7" }, - result: injection - }); - expect(fallbackCard).toContain("retrieved"); - expect(fallbackCard).not.toContain("prefix"); - expect(fallbackCard).not.toContain("suffix"); - expect(fallbackCard).not.toContain("\x1b"); - - const displayCard = toolCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_7" }, - result: { - display: { - kind: "task_output", - retrievalStatus: "success", - output: "prefix \x1b]52;c;aGVsbG8=\x1b\\ suffix" - } - } - }); - expect(displayCard).toContain("prefix"); - expect(displayCard).toContain("suffix"); - expect(displayCard).not.toContain("\x1b]52"); - expect(displayCard).not.toContain("\x1b"); - }); - - test("hard-bounds trusted TaskOutput display metadata", () => { - const card = expandedCard({ - name: "TaskOutput", - state: "complete", - input: { task_id: "bg_8" }, - result: { - display: { - kind: "task_output", - retrievalStatus: "success", - output: `${"x".repeat(2_500)}END` - } - } - }); - expect(card).toContain("output truncated"); - expect(card).not.toContain("END"); - expect(card.length).toBeLessThan(2_200); - }); - - test("renders Agent content arrays and background launch metadata", () => { - const completed = { - name: "Agent", - state: "complete", - input: { description: "Inspect renderer", prompt: "Review the TUI" }, - result: { - status: "completed", - agentId: "agent_1", - content: [{ type: "text", text: "Renderer review complete." }], - totalToolUseCount: 3, - totalDurationMs: 5_000, - totalTokens: 1_200 - } - }; - const compact = toolCard(completed); - expect(compact).toContain("completed · 3 tool uses · 1,200 tokens · 5s"); - expect(compact).not.toContain("Renderer review complete."); - const expanded = expandedCard(completed); - expect(expanded).toMatch(/Prompt:\s*Review the TUI/u); - expect(expanded).toMatch(/Response:\s*Renderer review complete\./u); - - const background = toolCard({ - name: "Task", - state: "complete", - input: { description: "Run audit", prompt: "Audit the app", run_in_background: true }, - result: { - status: "async_launched", - agentId: "agent_2", - childSessionId: "sess_child", - backgroundTaskId: "bg_2", - outputFile: "/tmp/agent.out" - } - }); - expect(background).toContain("✓ Task Run audit"); - expect(background).toContain("background task bg_2"); - expect(background).toContain("output /tmp/agent.out"); - }); - - test("renders Skill and dynamic MCP results with bounded detail", () => { - const skillOptions = { - name: "Skill", - state: "complete", - input: { skill: "review" }, - result: { name: "review", content: "# Review instructions", baseDirectory: "/skills/review", truncated: false } - }; - expect(toolCard(skillOptions)).toContain("Loaded · /skills/review"); - expect(toolCard(skillOptions)).not.toContain("# Review instructions"); - expect(expandedCard(skillOptions)).toContain("# Review instructions"); - - const mcp = toolCard({ - name: "mcp__docs__lookup", - state: "complete", - input: { query: "Bun.Terminal" }, - result: { title: "Terminal API", url: "https://bun.sh/docs/api/terminal", count: 2 } - }); - expect(mcp).toContain("✓ MCP · docs/lookup query=Bun.Terminal"); - expect(mcp).toContain("title: Terminal API"); - expect(mcp).not.toContain('"title"'); - }); -}); diff --git a/test/tool-tree-view.test.ts b/test/tool-tree-view.test.ts deleted file mode 100644 index cb7b7a80..00000000 --- a/test/tool-tree-view.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; -import { ToolTreeView } from "../packages/zcode-tui/src/tool-tree-view.ts"; -import { ToolExecutionView } from "../packages/zcode-tui/src/tool-view.ts"; - -function tree(name: string, state = "complete"): ToolTreeView { - const theme = createTheme(false); - return new ToolTreeView(theme, new ToolExecutionView(theme, { name, state })); -} - -describe("TUI tool tree", () => { - test("renders running children and collapses completed descendants", () => { - const parent = tree("Agent", "running"); - parent.addChild(tree("Read")); - parent.addChild(tree("Grep")); - - expect(parent.render(80).join("\n")).toContain("Read"); - parent.tool.update({ name: "Agent", state: "complete" }); - const collapsed = parent.render(80).join("\n"); - expect(collapsed).toContain("2 child tools"); - expect(collapsed).not.toContain("Read"); - - parent.setExpanded(true); - expect(parent.render(80).join("\n")).toContain("Read"); - }); - - test("deduplicates children and supports moving between parents", () => { - const first = tree("Agent"); - const second = tree("Agent"); - const child = tree("Bash"); - first.addChild(child); - first.addChild(child); - expect(first.getChildren()).toHaveLength(1); - - expect(first.removeChild(child)).toBe(true); - second.addChild(child); - second.setExpanded(true); - expect(first.getChildren()).toHaveLength(0); - expect(second.render(80).join("\n")).toContain("Bash"); - }); - - test("keeps collapsed descendant hints inside very narrow terminals", () => { - const parent = tree("Agent"); - parent.addChild(tree("Read")); - for (const width of [8, 20, 36]) { - expect(parent.render(width).every((line) => visibleWidth(line) <= width)).toBe(true); - } - }); -}); diff --git a/test/tool-view.test.ts b/test/tool-view.test.ts deleted file mode 100644 index 7e753a82..00000000 --- a/test/tool-view.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { BoundedToolText } from "../packages/zcode-tui/src/bounded-tool-text.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; -import { fileDiffRetentionSize } from "../packages/zcode-tui/src/file-diff-budget.ts"; -import { - ToolExecutionView, - toolCard, - toolSucceeded -} from "../packages/zcode-tui/src/tool-view.ts"; -import { - MAX_RETAINED_TOOL_PAYLOAD_CHARACTERS, - toolPayloadSize -} from "../packages/zcode-tui/src/tool-payload.ts"; - -describe("TUI tool execution view", () => { - test("renders a scannable tool summary instead of raw input JSON", () => { - const card = toolCard({ - name: "Read", - state: "complete", - input: { file_path: "/tmp/example.ts" }, - result: { output: "source text", success: true } - }); - - expect(card).toContain("✓ Read /tmp/example.ts"); - expect(card).not.toContain("file_path"); - expect(card).toContain("Read 1 line"); - expect(card).toContain("Ctrl+O to expand"); - expect(toolSucceeded({ success: true })).toBe(true); - expect(toolSucceeded({ success: false })).toBe(false); - expect(toolSucceeded({ status: "failed" })).toBe(false); - }); - - test("bounds large output previews", () => { - const card = toolCard({ name: "Bash", state: "complete", result: "x".repeat(2_000) }); - expect(card).not.toContain("more characters"); - expect(card.length).toBeLessThan(2_500); - - const larger = toolCard({ name: "Bash", state: "complete", result: "x".repeat(4_000) }); - expect(larger).toContain("output truncated"); - expect(larger.length).toBeLessThan(2_700); - }); - - test("keeps terminal Error messages compact after payload retention", () => { - const card = toolCard({ name: "Bash", state: "failed", error: new Error("boom") }); - - expect(card).toContain("Error: boom"); - expect(card).not.toContain('"name": "Error"'); - expect(card).not.toContain('"message": "boom"'); - }); - - test("renders user-cancelled tools as a quiet single-line state", () => { - const card = toolCard({ - name: "Bash", - state: "cancelled", - input: { command: "bun test 2>&1 | tail -20" }, - error: { - type: "tool_cancelled", - message: "Bash was cancelled and the child process was asked to stop", - code: "TOOL_CANCELLED", - stack: "Error: internal vendor stack" - }, - progress: { stdoutTail: "partial output before cancellation" } - }); - - expect(card).toBe("■ Bash bun test 2>&1 | tail -20 · cancelled"); - expect(card).not.toContain("failed"); - expect(card).not.toContain("Error:"); - expect(card).not.toContain("TOOL_CANCELLED"); - expect(card).not.toContain("vendor stack"); - expect(card).not.toContain("partial output"); - }); - - test("shows only the message for structured tool failures", () => { - const card = toolCard({ - name: "Bash", - state: "failed", - input: { command: "false" }, - error: { - code: "COMMAND_FAILED", - message: "Command exited with code 1", - stack: "Error: internal vendor stack" - } - }); - - expect(card).toContain("✗ Bash false · failed"); - expect(card).toContain("Error: Command exited with code 1"); - expect(card).not.toContain("COMMAND_FAILED"); - expect(card).not.toContain("vendor stack"); - }); - - test("keeps active payloads intact and compacts them at the terminal state", () => { - const result = { stdout: `HEAD-${"x".repeat(1_000_000)}-TAIL`, success: true }; - const view = new ToolExecutionView(createTheme(false), { - name: "Bash", - state: "running", - input: { command: "generate" }, - result - }); - const internal = view as unknown as { - options: { - error?: unknown; - input?: unknown; - inputText?: string; - progress?: unknown; - result?: unknown; - retainedPayloadTruncated?: boolean; - }; - }; - expect(internal.options.result).toBe(result); - - view.update({ - name: "Bash", - state: "complete", - input: { command: "generate" }, - result - }); - const size = toolPayloadSize([ - internal.options.input, - internal.options.inputText, - internal.options.result, - internal.options.error, - internal.options.progress - ]); - const retained = JSON.stringify(internal.options.result); - expect(internal.options.result).not.toBe(result); - expect(size.characters).toBeLessThanOrEqual(MAX_RETAINED_TOOL_PAYLOAD_CHARACTERS); - expect(retained).toContain("HEAD-"); - expect(retained).toContain("-TAIL"); - expect(internal.options.retainedPayloadTruncated).toBeTrue(); - expect(view.render(80).join("\n")).toContain("completed tool payload retained as a bounded preview"); - }); - - test("materializes active bounded streams only when the dirty view renders", () => { - const output = new BoundedToolText(); - let materializations = 0; - const value = output.value.bind(output); - output.value = () => { - materializations += 1; - return value(); - }; - const view = new ToolExecutionView(createTheme(false), { - name: "Bash", - state: "running", - input: { command: "stream" }, - result: output - }); - - for (let index = 0; index < 10_000; index += 1) { - output.append("0123456789"); - view.update({ - name: "Bash", - state: "running", - input: { command: "stream" }, - result: output - }); - } - - expect(materializations).toBe(0); - const rendered = view.render(80).join("\n"); - expect(materializations).toBe(1); - expect(rendered).toContain("output characters omitted from active tool stream"); - view.render(80); - expect(materializations).toBe(1); - }); - - test("keeps bounded input byte-identical below the limit and marks terminal truncation", () => { - const input = new BoundedToolText('{"command":"printf ok"}'); - expect(toolCard({ name: "Bash", state: "running", inputText: input })) - .toBe(toolCard({ name: "Bash", state: "running", inputText: input.value() })); - - input.append("x".repeat(100_000)); - const completed = new ToolExecutionView(createTheme(false), { - name: "UnknownTool", - state: "complete", - inputText: input, - result: { success: true } - }); - expect(completed.render(80).join("\n")) - .toContain("completed tool payload retained as a bounded preview"); - }); - - test("retains bounded mutation diffs without the original content", () => { - const content = "x".repeat(1_000_000); - const input = { file_path: "src/large.ts", content }; - const view = new ToolExecutionView(createTheme(false), { - name: "Write", - state: "complete", - input, - result: { success: true } - }); - const internal = view as unknown as { - options: { diffs?: []; input?: unknown; retainedPayloadTruncated?: boolean }; - }; - - expect(internal.options.input).not.toBe(input); - expect(toolPayloadSize(internal.options.input).characters) - .toBeLessThanOrEqual(MAX_RETAINED_TOOL_PAYLOAD_CHARACTERS); - expect(fileDiffRetentionSize(internal.options.diffs ?? []).characters).toBeLessThanOrEqual(250_000); - expect(internal.options.retainedPayloadTruncated).toBeTrue(); - }); - - test("replaces oversized completed image data with a searchable omission summary", () => { - const data = "a".repeat(1_000_000); - const view = new ToolExecutionView(createTheme(false), { - name: "Read", - state: "complete", - result: { content: [{ type: "image", mimeType: "image/png", data }] } - }); - const rendered = view.render(80).join("\n"); - - expect(rendered).toContain("completed tool payload retained as a bounded preview"); - expect(view.getSearchText()).toContain("binary payload omitted: 1000000 characters"); - expect(view.getSearchText()).not.toContain(data.slice(0, 10_000)); - }); - - test("coalesces rapid tool progress updates until the next render", () => { - const theme = createTheme(false); - const bold = theme.bold; - let rebuilds = 0; - theme.bold = (text) => { - rebuilds += 1; - return bold(text); - }; - const view = new ToolExecutionView(theme, { - name: "Bash", - state: "running", - input: { command: "long-running-task" } - }); - - for (let index = 0; index < 500; index += 1) { - view.update({ - name: "Bash", - state: "running", - input: { command: "long-running-task" }, - progress: { - stdoutBytes: index, - stdoutTail: `progress ${index}` - } - }); - } - - expect(rebuilds).toBe(0); - expect(view.render(80).join("\n")).toContain("progress 499"); - expect(rebuilds).toBe(1); - }); - - test("styles mutation diffs and keeps running state compact", () => { - const view = new ToolExecutionView(createTheme(false), { - name: "ApplyPatch", - state: "running", - input: { - patch_text: [ - "*** Begin Patch", - "*** Update File: src/app.ts", - "@@ -1,1 +1,1 @@", - "-old", - "+new", - "*** End Patch" - ].join("\n") - } - }); - const output = view.render(80).map((line) => line.trimEnd()).join("\n"); - - expect(output).toContain("● ApplyPatch src/app.ts +1 -1"); - expect(output).toContain("@@ -1,1 +1,1 @@"); - expect(output).toContain("│- old"); - expect(output).toContain("│+ new"); - }); - - test("reserves full-width backgrounds for permission and failure states", () => { - const view = new ToolExecutionView(createTheme(true), { - name: "Bash", - state: "running", - input: { command: "bun test" } - }); - - expect(view.render(72).join("\n")).not.toContain("\x1b[48;5;"); - view.update({ name: "Bash", state: "complete", input: { command: "bun test" } }); - expect(view.render(72).join("\n")).not.toContain("\x1b[48;5;"); - view.update({ name: "Bash", state: "waiting_permission", input: { command: "bun test" } }); - expect(view.render(72).join("\n")).toContain("\x1b[38;5;252;48;5;236m"); - view.update({ name: "Bash", state: "failed", input: { command: "bun test" } }); - expect(view.render(72).join("\n")).toContain("\x1b[38;5;252;48;5;52m"); - }); - - test("strips external background, inverse and hidden SGR from tool output", () => { - const view = new ToolExecutionView(createTheme(true, "light"), { - name: "Bash", - state: "complete", - input: { command: "printf color" }, - result: "\x1b[47;8;7mvisible output\x1b[0m plain output" - }); - const rendered = view.render(72).join("\n"); - - expect(rendered).toContain("visible output plain output"); - expect(rendered).not.toContain("\x1b[47;8;7m"); - expect(rendered).not.toContain("\x1b[48;5;"); - }); - - test("uses an explicit readable foreground for tool names on light terminals", () => { - const view = new ToolExecutionView(createTheme(true, "light"), { - name: "Bash", - state: "complete", - input: { command: "bun test" }, - result: "212 pass" - }); - - expect(view.render(72).join("\n")).toContain("\x1b[1;38;5;236mBash"); - }); - - test("falls back to JSON for unknown structured arrays and reports hidden images", () => { - const structured = toolCard({ - name: "\x1b[47;8mUnknownTool\x1b[0m", - state: "complete", - result: [{ foo: "bar" }, { count: 2 }] - }); - expect(structured).toContain("UnknownTool"); - expect(structured).not.toContain("\x1b["); - expect(structured).toContain('"foo": "bar"'); - expect(structured).toContain('"count": 2'); - - const image = { - type: "image", - source: { data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB", media_type: "image/png" } - }; - const view = new ToolExecutionView(createTheme(false), { - name: "UnknownTool", - state: "complete", - result: Array.from({ length: 6 }, () => image) - }); - expect(view.render(80).join("\n")).toContain("4 of 6 images shown · Ctrl+O to show all"); - expect(view.hasHiddenContent()).toBe(true); - view.setExpanded(true); - expect(view.render(80).join("\n")).toContain("6 images"); - expect(view.hasHiddenContent()).toBe(false); - }); - - test("hides metadata-only success results and surfaces embedded errors", () => { - expect(toolCard({ - name: "Write", - state: "complete", - input: { path: "result.txt", content: "done" }, - result: { success: true, status: "completed" } - })).not.toContain('"success"'); - - expect(toolCard({ - name: "Bash", - state: "failed", - input: { command: "false" }, - result: { status: "failed", error: "Command exited with code 1" } - })).toContain("Error: Command exited with code 1"); - }); -}); diff --git a/test/transcript.test.ts b/test/transcript.test.ts deleted file mode 100644 index 13f72b9e..00000000 --- a/test/transcript.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Text, visibleWidth, type Component } from "@earendil-works/pi-tui"; - -import { - MAX_RETAINED_TRANSCRIPT_BLOCKS, - MAX_RETAINED_TRANSCRIPT_HISTORY_CHARACTERS, - Transcript -} from "../packages/zcode-tui/src/transcript.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -describe("TUI transcript", () => { - test("separates top-level conversation blocks by one line", () => { - const transcript = new Transcript(); - transcript.addBlock(new Text("› user", 0, 0)); - transcript.addBlock(new Text("assistant", 0, 0)); - transcript.addBlock(new Text("› next", 0, 0)); - - expect(transcript.render(80).map((line) => line.trimEnd())).toEqual([ - "› user", - "", - "assistant", - "", - "› next", - "" - ]); - }); - - test("keeps only the intentional bottom margin after clearing", () => { - const transcript = new Transcript(); - transcript.addBlock(new Text("first", 0, 0)); - transcript.clear(); - transcript.addBlock(new Text("restored", 0, 0)); - - expect(transcript.render(80).map((line) => line.trimEnd())).toEqual(["restored", ""]); - }); - - test("does not reserve a bottom margin for an empty transcript", () => { - expect(new Transcript().render(80)).toEqual([]); - }); - - test("upserts protocol blocks in place and removes complete messages", () => { - const transcript = new Transcript(); - transcript.addBlock(new Text("old", 0, 0), { id: "part_1", messageId: "message_1" }); - transcript.addBlock(new Text("second", 0, 0), { id: "part_2", messageId: "message_1" }); - transcript.addBlock(new Text("other", 0, 0), { id: "part_3", messageId: "message_2" }); - transcript.addBlock(new Text("updated", 0, 0), { id: "part_1", messageId: "message_1" }); - - expect(transcript.blockCount).toBe(3); - expect(transcript.render(80).join("\n")).not.toContain("old"); - expect(transcript.render(80).join("\n")).toContain("updated"); - expect(transcript.removeMessage("message_1")).toBe(2); - expect(transcript.blockCount).toBe(1); - expect(transcript.render(80).join("\n")).toContain("other"); - }); - - test("navigates individual blocks and expands only the focused component", () => { - const transcript = new Transcript((text) => `[${text}]`); - transcript.addBlock(new Text("first", 0, 0), { kind: "user", searchText: "first prompt" }); - transcript.addBlock(new Text("second", 0, 0), { kind: "assistant", searchText: "second response" }); - - expect(transcript.selectLatest()).toEqual({ current: 2, total: 2, kind: "assistant" }); - expect(transcript.selectedText()).toBe("second response"); - expect(transcript.render(80).join("\n")).toContain("› second"); - expect(transcript.moveCursor(-1)).toEqual({ current: 1, total: 2, kind: "user" }); - }); - - test("highlights search matches and exposes n/N-compatible cursor state", () => { - const transcript = new Transcript((text) => `<${text}>`); - transcript.addBlock(new Text("Alpha result", 0, 0), { searchText: "Alpha result" }); - transcript.addBlock(new Text("Other", 0, 0), { searchText: "Other" }); - transcript.addBlock(new Text("alpha again", 0, 0), { searchText: "alpha again" }); - - expect(transcript.searchFor("alpha")).toEqual({ query: "alpha", current: 1, total: 2 }); - expect(transcript.render(80).join("\n")).toContain(""); - expect(transcript.nextSearchMatch(1)).toEqual({ query: "alpha", current: 2, total: 2 }); - expect(transcript.selectedText()).toBe("alpha again"); - }); - - test("marks no-color matches without exceeding the terminal width", () => { - const transcript = new Transcript(createTheme(false).searchMatch); - transcript.addBlock(new Text("Alpha result on a narrow line", 0, 0), { - searchText: "Alpha result on a narrow line" - }); - transcript.searchFor("Alpha"); - const lines = transcript.render(18); - expect(lines.join("\n")).toContain("⟦Alpha⟧"); - expect(lines.every((line) => visibleWidth(line) <= 18)).toBe(true); - }); - - test("pages oversized selected blocks without rendering every line", () => { - const transcript = new Transcript(); - transcript.setNavigationViewportRows(4); - transcript.addBlock(new Text(Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0), { - kind: "assistant", - searchText: "long response" - }); - transcript.selectLatest(); - - const first = transcript.render(80).join("\n"); - expect(first).toContain("Page 1/3"); - expect(first).toContain("line 1"); - expect(first).not.toContain("line 10"); - expect(transcript.movePage(1, 80)).toEqual({ current: 2, total: 3 }); - const second = transcript.render(80).join("\n"); - expect(second).toContain("Page 2/3"); - expect(second).toContain("line 5"); - expect(second).not.toContain("line 1\n"); - }); - - test("uses the windowed component path without materializing a full block", () => { - let fullRenders = 0; - const component: Component & { - renderWindow(width: number, start: number, count: number): { lines: string[]; totalLines: number }; - } = { - invalidate() {}, - render() { - fullRenders += 1; - throw new Error("full render should not run while paging"); - }, - renderWindow(_width, start, count) { - return { - lines: Array.from({ length: Math.min(count, Math.max(0, 50_000 - start)) }, (_, index) => `line ${start + index + 1}`), - totalLines: 50_000 - }; - } - }; - const transcript = new Transcript(); - transcript.setNavigationViewportRows(20); - transcript.addBlock(component, { kind: "assistant", searchText: "large output" }); - transcript.selectLatest(); - - expect(transcript.render(80).join("\n")).toContain("Page 1/2500"); - expect(transcript.movePage(1, 80)).toEqual({ current: 2, total: 2_500 }); - expect(transcript.render(80).join("\n")).toContain("line 21"); - expect(fullRenders).toBe(0); - }); - - test("reuses width presentation for stable blocks and refreshes changed content", () => { - let sourceLines = ["界".repeat(20)]; - const component: Component = { - invalidate() {}, - render: () => [...sourceLines] - }; - const transcript = new Transcript(); - transcript.addBlock(component); - const internal = transcript as unknown as { - blocks: Array<{ renderCache?: { lines: string[] } }>; - }; - - const firstLine = transcript.render(10)[0] ?? ""; - expect(firstLine).toContain("界界界"); - expect(visibleWidth(firstLine)).toBeLessThanOrEqual(10); - const firstCache = internal.blocks[0]?.renderCache; - transcript.render(10); - expect(internal.blocks[0]?.renderCache).toBe(firstCache); - - sourceLines = ["changed"]; - expect(transcript.render(10)[0]).toBe("changed"); - expect(internal.blocks[0]?.renderCache).not.toBe(firstCache); - }); - - test("releases component and presentation caches when blocks leave the render window", () => { - const invalidations = Array(301).fill(0); - const transcript = new Transcript(); - for (let index = 0; index < 300; index += 1) { - transcript.addBlock({ - invalidate: () => { invalidations[index] = (invalidations[index] ?? 0) + 1; }, - render: () => [`block ${index}`] - }); - } - transcript.render(80); - const internal = transcript as unknown as { - blocks: Array<{ renderCache?: unknown }>; - }; - expect(internal.blocks[0]?.renderCache).toBeDefined(); - - transcript.addBlock({ - invalidate: () => { invalidations[300] = (invalidations[300] ?? 0) + 1; }, - render: () => ["block 300"] - }); - - expect(internal.blocks[0]?.renderCache).toBeUndefined(); - expect(invalidations[0]).toBe(1); - expect(invalidations[60]).toBe(1); - expect(invalidations[61]).toBe(0); - }); - - test("bounds retained blocks while preserving the current render window", () => { - const transcript = new Transcript(); - for (let index = 0; index < 10_000; index += 1) { - const text = index === 0 - ? "content-oldest-released" - : index === 9_999 - ? "content-latest-retained" - : `content-${index}`; - transcript.addBlock(new Text(text, 0, 0), { kind: "assistant", searchText: text }); - } - - expect(transcript.blockCount).toBe(MAX_RETAINED_TRANSCRIPT_BLOCKS); - expect(transcript.discardedBlockCount).toBe(10_000 - MAX_RETAINED_TRANSCRIPT_BLOCKS); - const rendered = transcript.render(80).join("\n"); - expect(rendered).toContain("older blocks released from in-app history"); - expect(rendered).toContain("content-latest-retained"); - expect(rendered).not.toContain("content-oldest-released"); - expect(transcript.searchFor("content-oldest-released")).toMatchObject({ current: 0, total: 0 }); - expect(transcript.searchFor("content-latest-retained")).toMatchObject({ current: 1, total: 1 }); - }); - - test("bounds searchable characters outside the active render window", () => { - const transcript = new Transcript(); - for (let index = 0; index < 1_000; index += 1) { - const text = `${index}:${"x".repeat(9_995)}`; - transcript.addBlock({ invalidate() {}, render: () => [text] }, { - kind: "assistant", - searchText: text - }); - } - - expect(transcript.retainedHistoryCharacters) - .toBeLessThanOrEqual(MAX_RETAINED_TRANSCRIPT_HISTORY_CHARACTERS); - expect(transcript.blockCount).toBeLessThan(MAX_RETAINED_TRANSCRIPT_BLOCKS); - expect(transcript.render(80).join("\n")).toContain("999:"); - }); -}); diff --git a/test/tui-mode.test.ts b/test/tui-mode.test.ts deleted file mode 100644 index 9d9900b3..00000000 --- a/test/tui-mode.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { readTuiMode, resolveTuiMode, writeTuiMode } from "../packages/zcode-tui/src/tui-mode.ts"; - -describe("TUI mode resolution", () => { - test("uses environment overrides before options and config", () => { - expect(resolveTuiMode({ ZCODE_TUI_MODE: "fullscreen" }, { ui: { tuiMode: "regular" } })) - .toBe("fullscreen"); - expect(resolveTuiMode({}, { ui: { tuiMode: "fullscreen" } })) - .toBe("fullscreen"); - }); - - test("ignores unsupported overrides before falling back", () => { - expect(resolveTuiMode({ ZCODE_TUI_MODE: "unsupported" }, { ui: { tuiMode: "fullscreen" } })) - .toBe("fullscreen"); - expect(resolveTuiMode({}, { ui: { tuiMode: "unsupported" } })) - .toBe("regular"); - }); - - test("reads and writes the persisted mode without replacing other config", async () => { - const home = await mkdtemp(join(tmpdir(), "zcode-tui-mode-test-")); - const configDir = join(home, ".zcode", "cli"); - const env = { HOME: home, USERPROFILE: home }; - try { - await mkdir(configDir, { recursive: true }); - const configPath = join(configDir, "setting.json"); - await Bun.write(configPath, JSON.stringify({ model: { main: "zai/glm-5.2" }, ui: { tuiMode: "fullscreen" } })); - expect(await readTuiMode(env)).toBe("fullscreen"); - await writeTuiMode("regular", env); - expect(await Bun.file(configPath).json()).toEqual({ - model: { main: "zai/glm-5.2" }, - ui: { tuiMode: "regular" } - }); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); -}); diff --git a/test/tui-prompt-preflight.test.ts b/test/tui-prompt-preflight.test.ts deleted file mode 100644 index 844057e9..00000000 --- a/test/tui-prompt-preflight.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { InputQueue, type InputQueueState, type QueuedSubmission } from "../packages/zcode-tui/src/input-queue.ts"; -import { preflightSubmission } from "../packages/zcode-tui/src/prompt-preflight.ts"; - -function fixture(queued = false) { - const states: InputQueueState[] = []; - const warnings: string[] = []; - const inputQueue = new InputQueue({ - onStateChanged: state => states.push(state), - onSteerCommitted() {}, onSteerDiscarded() {} - }); - const submission: QueuedSubmission = { - input: "original prompt", displayInput: "original prompt", secrets: [], - recordHistory: !queued, - ...(queued ? { pendingInputIds: ["pending_1"], pendingInputReservationId: "reservation_1" } : {}) - }; - const state = { draft: "", stopped: false }; - let finish!: (diagnostic: string | undefined) => void; - const validation = new Promise(resolve => { finish = resolve; }); - const options = { - validate: () => validation, - isStopped: () => state.stopped, - submission, queued, inputQueue, - editor: { getText: () => state.draft, setText: (text: string) => { state.draft = text; } }, - warn: (message: string) => { warnings.push(message); } - }; - return { options, state, finish, states, warnings }; -} - -describe("TUI prompt preflight input preservation", () => { - test("restores a popped follow-up at the front with metadata and auto-send paused", async () => { - const f = fixture(true); - const { inputQueue, submission } = f.options; - const second = { ...submission, input: "second", displayInput: "second" }; - inputQueue.queueFollowUp(submission); - inputQueue.queueFollowUp(second); - expect(inputQueue.takeNextFollowUp()).toBe(submission); - const pending = preflightSubmission(f.options); - f.finish("missing key"); - expect(await pending).toBeFalse(); - expect(inputQueue.autoSend).toBeFalse(); - expect(f.states.at(-1)?.queuedInputs).toEqual(["original prompt", "second"]); - expect(inputQueue.takeNextFollowUp()).toBe(submission); - expect(inputQueue.takeNextFollowUp()).toBe(second); - expect(inputQueue.hasFollowUps()).toBeFalse(); - expect(f.state.draft).toBe(""); - }); - - test("retains both a rejected prompt and a newer draft typed during validation", async () => { - const f = fixture(); - const pending = preflightSubmission(f.options); - f.state.draft = "newer draft"; - f.finish("missing key"); - expect(await pending).toBeFalse(); - expect(f.state.draft).toBe("newer draft"); - expect(f.options.inputQueue.takeNextFollowUp()).toBe(f.options.submission); - expect(f.options.inputQueue.autoSend).toBeFalse(); - expect(f.warnings).toEqual(["missing key"]); - }); - - test("restores direct input to an empty editor without duplicating it in the queue", async () => { - const f = fixture(); - const pending = preflightSubmission(f.options); - f.finish("missing key"); - expect(await pending).toBeFalse(); - expect(f.state.draft).toBe("original prompt"); - expect(f.options.inputQueue.hasFollowUps()).toBeFalse(); - expect(f.options.inputQueue.autoSend).toBeFalse(); - }); - - test("allows configured access without changing drafts or queue state", async () => { - const f = fixture(); - const pending = preflightSubmission(f.options); - f.state.draft = "next prompt"; - f.finish(undefined); - expect(await pending).toBeTrue(); - expect(f.state.draft).toBe("next prompt"); - expect(f.options.inputQueue.autoSend).toBeTrue(); - expect(f.states).toEqual([]); - expect(f.warnings).toEqual([]); - }); - - for (const diagnostic of [undefined, "missing key"]) { - test(`does not submit or update a stopped TUI after validation (${diagnostic ?? "allowed"})`, async () => { - const f = fixture(); - const pending = preflightSubmission(f.options); - f.state.stopped = true; - f.finish(diagnostic); - expect(await pending).toBeFalse(); - expect(f.state.draft).toBe(""); - expect(f.states).toEqual([]); - expect(f.warnings).toEqual([]); - }); - } -}); diff --git a/test/tui/allowlisted-shell.test.ts b/test/tui/allowlisted-shell.test.ts deleted file mode 100644 index 29913455..00000000 --- a/test/tui/allowlisted-shell.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { test } from "bun:test"; - -import { runAutomatedTuiScenario } from "./harness/run-scenario.ts"; -import { allowlistedShellScenario } from "./scenarios/allowlisted-shell.ts"; - -test.skipIf(process.platform === "win32")( - "allowlisted shell writes render through the TUI real Git diff", - async () => { - await runAutomatedTuiScenario(allowlistedShellScenario); - }, - 35_000 -); diff --git a/test/tui/dynamic-workflows.test.ts b/test/tui/dynamic-workflows.test.ts deleted file mode 100644 index db5771ad..00000000 --- a/test/tui/dynamic-workflows.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { test } from "bun:test"; -import { join } from "node:path"; -import { writeFile } from "node:fs/promises"; -import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; -import { TerminalSession } from "./harness/terminal-session.ts"; - -test.each([60, 110])("workflow inspection restores progress and resumes via keyboard at %i columns", async (cols) => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-workflows-" }); - await using session = TerminalSession.start({ command: [process.execPath, join(import.meta.dir, "fixtures/dynamic-workflows.ts")], workspace, cols }); - await session.waitForScreen("ready", /scenario\/model/u); - await session.sendAndWait("/workflows\r", "run list", /Review workspace/u); - session.send("\r"); - await session.waitForScreen("actions", /Resume workflow/u); - await session.waitForHistory("restored progress", /1\/2 observed steps settled/u); - await session.waitForHistory("artifact", /report\.md/u); - if (process.env.ZCODE_TEST_SCREEN_DIRECTORY) { - await writeFile(join(process.env.ZCODE_TEST_SCREEN_DIRECTORY, `workflow-${cols}.txt`), session.screenText()); - } - session.send("\x1b[B\r"); - await session.waitForHistory("resume result", /Resumed dynamic workflow run run-1/u); - await session.waitForHistory("live progress", /2\/2 observed steps settled/u); - await session.exit(); -}, 20_000); diff --git a/test/tui/fixtures/dynamic-workflows.ts b/test/tui/fixtures/dynamic-workflows.ts deleted file mode 100644 index 7cf4786f..00000000 --- a/test/tui/fixtures/dynamic-workflows.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { runTui } from "../../../packages/zcode-tui/src/index.ts"; -let sink: ((event: unknown) => void | Promise) | undefined; -let resumed = false; -const run = () => ({ runId: "run-1", label: "Review workspace", status: resumed ? "running" : "stopped", - resumable: !resumed, nodes: [{ phase: "settled" }, { phase: resumed ? "settled" : "waiting" }], - usage: { spentTokens: 321 }, actors: [{ name: "Reviewer", status: "waiting" }], artifacts: [{ title: "report.md" }] }); -await runTui({ - initialModel: "scenario/model", workspaceDirectory: process.cwd(), - getMainSessionId: () => "main-session", - subscribeSessionEvents: (listener) => { sink = listener; return () => { sink = undefined; }; }, - listWorkflowRuns: async () => [run()], - replayWorkflowRuns: async () => [{ runId: "run-1", sequence: resumed ? 2 : 1, eventType: "run-settled", payload: run() }], - reduceWorkflowRuns: (_state, event) => ({ runs: [(event as { payload: unknown }).payload] }), - submitPrompt: async (input) => { - if (input === "/dwf resume run-1") { - resumed = true; - await sink?.({ type: "dynamic_workflow_run_progress", sessionId: "main-session", - payload: { runId: "run-1", sequence: 2, eventType: "run-started", payload: run() } }); - return { response: "Resumed dynamic workflow run run-1." }; - } - return { response: String(input) }; - } -}); diff --git a/test/tui/fixtures/guarded-permission.ts b/test/tui/fixtures/guarded-permission.ts deleted file mode 100644 index bec01422..00000000 --- a/test/tui/fixtures/guarded-permission.ts +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bun - -import { runTui } from "../../../packages/zcode-tui/src/index.ts"; -import type { PromptCallOptions } from "../../../packages/zcode-tui/src/types.ts"; - -let requestCount = 0; - -async function submitPrompt(input: unknown, options: PromptCallOptions): Promise { - if (input !== "request guarded permission") return { response: "Type request guarded permission to start." }; - const attempt = ++requestCount; - const result = await options.requestPermission!({ - toolName: "Bash", - input: { command: "rm -rf build" }, - reason: "GUARDED_SINGLE_USE_PERMISSION", - mode: "guarded", - approvalMode: "user-once", - options: [{ - optionId: "allow_project", - kind: "allow_always", - name: "Always allow in this project", - response: { decision: "allow", permissionUpdates: [{ type: "addRules" }] } - }] - }); - return { response: `Guarded result ${attempt}: ${JSON.stringify(result)}` }; -} - -await runTui({ - workspaceDirectory: process.cwd(), - initialMode: "guarded", - loadSessionTranscript: async () => [{ - messageId: "instructions", - role: "agent", - content: "Type request guarded permission to start." - }], - submitPrompt -}); diff --git a/test/tui/fixtures/model-status.ts b/test/tui/fixtures/model-status.ts deleted file mode 100644 index 628c6736..00000000 --- a/test/tui/fixtures/model-status.ts +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bun - -import { runTui } from "../../../packages/zcode-tui/src/index.ts"; -import type { PromptCallOptions } from "../../../packages/zcode-tui/src/types.ts"; - -async function emit(options: PromptCallOptions, type: string, payload: unknown): Promise { - await options.onEvent?.({ type, payload }); - // Let the TUI render each status before the next one supersedes it. - await Bun.sleep(200); -} - -await runTui({ - initialModel: "scenario/model", - workspaceDirectory: process.cwd(), - loadSessionTranscript: async () => [{ - messageId: "model_status_instruction", - role: "agent", - content: "Type “recover stream” to recover a model request that failed after streaming output. Type “fail hard” to end a turn on an unrecoverable request." - }], - submitPrompt: async (input, options) => { - if (String(input) === "fail hard") { - await emit(options, "model.network_status", { type: "model_request_started", attempt: 1, maxAttempts: 6 }); - await emit(options, "model.network_status", { - type: "model_request_failed", - attempt: 1, - maxAttempts: 6, - message: "Provider rejected the request.", - reason: "invalid_request", - retryable: false - }); - throw new Error("Provider rejected the request."); - } - await emit(options, "model.network_status", { type: "model_request_started", attempt: 1, maxAttempts: 6 }); - for (const [index, attempt] of [1, 2].entries()) { - // The runtime reports a stalled stream, then a non-retryable failure once streamed - // output is committed, and finally recovers that same request at the turn level. - await emit(options, "model.network_status", { - type: "model_stream_stalled", - attempt, - maxAttempts: 6, - idleMs: 60_000, - timeoutMs: 60_000, - message: "Model stream stalled: no event received for 60000ms." - }); - await emit(options, "model.network_status", { - type: "model_request_failed", - attempt, - maxAttempts: 6, - message: `Attempt ${attempt} lost the provider stream.`, - reason: "network_error", - retryable: false, - streamOutputCommitted: true - }); - await emit(options, "streamRecovery.updated", { - attemptId: `attempt_${attempt}`, - failureKind: "sse_disconnected", - message: `Attempt ${attempt} lost the provider stream.`, - retryNumber: index + 1, - maxRetries: 5 - }); - await emit(options, "model.network_status", { - type: "model_request_started", - attempt: attempt + 1, - maxAttempts: 6, - streamRecovery: { retryNumber: index + 1, maxRetries: 5, message: "Model stream recovery retry started" } - }); - } - await emit(options, "model.network_status", { type: "model_request_completed", attempt: 3, maxAttempts: 6 }); - return { response: "Recovered after reconnecting the model stream." }; - } -}); diff --git a/test/tui/fixtures/permission-request-hook.ts b/test/tui/fixtures/permission-request-hook.ts deleted file mode 100644 index 6798485c..00000000 --- a/test/tui/fixtures/permission-request-hook.ts +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bun - -import { runTui } from "../../../packages/zcode-tui/src/index.ts"; -import type { PromptCallOptions } from "../../../packages/zcode-tui/src/types.ts"; - -async function submitPrompt(input: unknown, options: PromptCallOptions): Promise { - if (input === "next prompt") return { response: "Follow-up received." }; - if (input !== "hook allow" && input !== "hook deny") { - return { response: "Type “hook allow” or “hook deny” to start." }; - } - const allowed = input === "hook allow"; - const firstTool = { toolCallId: "first", toolName: "Bash", input: { command: "printf FIRST_TOOL" } }; - const hookTool = { toolCallId: "hook", toolName: "Bash", input: { command: "printf HOOK_TOOL" } }; - for (const tool of [firstTool, hookTool]) { - await options.onEvent?.({ type: "tool_call_scheduled", ...tool }); - } - const hookController = new AbortController(); - const first = options.requestPermission!({ - ...firstTool, - reason: "FIRST_ACTIVE_PERMISSION" - }, { signal: options.abortSignal }); - const queued = options.requestPermission!({ - ...hookTool, - reason: "HOOK_PERMISSION" - }, { signal: hookController.signal }); - - // Let the first dialog open, then settle the queued request as the runtime - // does when a PermissionRequest hook wins the race against the client. - await Promise.resolve(); - hookController.abort(); - if (allowed) await options.onEvent?.({ type: "tool_call_started", ...hookTool }); - await options.onEvent?.({ - type: "tool_call_result", - ...hookTool, - result: allowed - ? { success: true, output: "HOOK_TOOL_SUCCEEDED" } - : { success: false, error: { message: "Denied by permission hook" } } - }); - await options.onEvent?.({ kind: "text_delta", delta: "HOOK_SETTLED_WHILE_QUEUED\n" }); - - await first; - await options.onEvent?.({ type: "tool_call_started", ...firstTool }); - await options.onEvent?.({ type: "tool_call_result", ...firstTool, result: { success: true, output: "FIRST_TOOL_SUCCEEDED" } }); - await queued; - return { response: "Hook race complete." }; -} - -await runTui({ - version: "permission-hook-scenario", - workspaceDirectory: process.cwd(), - initialMode: "build", - initialModel: "scenario/model", - modelOptions: [{ alias: "main", id: "scenario/model", name: "Scenario" }], - loadSessionTranscript: async () => [{ - messageId: "scenario_instruction", - role: "agent", - content: "Type “hook allow” or “hook deny” to start." - }], - submitPrompt, - stdin: process.stdin, - stdout: process.stdout, - stderr: process.stderr -}); diff --git a/test/tui/fixtures/runtime-refresh.ts b/test/tui/fixtures/runtime-refresh.ts deleted file mode 100644 index cd2717ff..00000000 --- a/test/tui/fixtures/runtime-refresh.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { runTui } from "../../../packages/zcode-tui/src/index.ts"; -import { ScenarioRuntimeJournal } from "../runtime/scenario-runtime.ts"; - -const journal = new ScenarioRuntimeJournal(process.env.ZCODE_TUI_SCENARIO_RUNTIME_JOURNAL); -let sessionId = "first-session"; -let contextReads = 0; -let projectionReads = 0; -let completed = false; -let listener: ((event: unknown) => void | Promise) | undefined; - -await runTui({ - initialModel: "scenario/model", - workspaceDirectory: process.cwd(), - subscribeSessionEvents: (sink) => { listener = sink; return () => { listener = undefined; }; }, - readRuntimeProjection: async () => { - projectionReads++; - return { - sessionId, status: "idle", totalTokenCount: completed ? 200 : 100, - contextUsage: { used: 100, size: 1_000 }, activeToolCalls: [], backgroundJobs: [] - }; - }, - loadSessionContextMessages: async () => { - contextReads++; - journal.record("context.read", { sessionId, contextReads }); - return [{ info: { id: "assistant", role: "assistant", tokens: { - input: 100, cache: { read: sessionId === "second-session" ? 20 : completed ? 60 : 80, write: 0 } - } }, parts: [] }]; - }, - submitPrompt: async (input, options) => { - if (String(input) === "/resume second") { - sessionId = "second-session"; - return { response: "Resumed second session", resetSessionProjection: true, restoredMessages: [] }; - } - const before = contextReads; - const projectionsBefore = projectionReads; - const startedAt = performance.now(); - for (let index = 0; index < 40; index++) { - const event = { id: `text-${index}`, sessionId, type: "model_streaming", - payload: { kind: "text_delta", delta: "x", assistantMessageId: "streamed-message" } }; - await options.onEvent?.(event); - await listener?.(event); - await Bun.sleep(20); - } - await Bun.sleep(120); - journal.record("text-deltas.finished", { - before, after: contextReads, projectionReads: projectionReads - projectionsBefore, - elapsedMs: performance.now() - startedAt - }); - for (let index = 0; index < 10; index++) { - await listener?.({ id: `progress-${index}`, sessionId, type: "tool_call_progress", - payload: { toolCallId: "tool", toolName: "Bash", elapsedMs: index * 100 } }); - await Bun.sleep(100); - } - journal.record("tool-progress.finished", { before, after: contextReads }); - completed = true; - await listener?.({ id: "complete", sessionId, type: "model_complete", payload: {} }); - return { response: "Streaming completed" }; - } -}); diff --git a/test/tui/fixtures/session-lifecycle.ts b/test/tui/fixtures/session-lifecycle.ts deleted file mode 100644 index 5c1d9030..00000000 --- a/test/tui/fixtures/session-lifecycle.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { runTui } from "../../../packages/zcode-tui/src/index.ts"; - -const mode = process.env.ZCODE_TEST_RESUME_TARGET; -await runTui({ - initialModel: "fixture/model", - readSessionUsage: async () => ({ sessionId: "id-only", totalTokens: 10 }), - readRuntimeProjection: async () => { throw new Error("Projection unavailable"); }, - ...(mode === "unsupported" ? {} : { - readSessionResumeTarget: async () => { - if (mode === "saved") return "saved-session"; - if (mode === "unreadable") throw new Error("Store unreadable"); - if (mode === "timeout") return new Promise(() => {}); - return undefined; - } - }), - submitPrompt: async () => { throw new Error("Session not found: missing"); }, - stdin: process.stdin, - stdout: process.stdout, - stderr: process.stderr -}); diff --git a/test/tui/http-mock.test.ts b/test/tui/http-mock.test.ts deleted file mode 100644 index 57fb6588..00000000 --- a/test/tui/http-mock.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { test } from "bun:test"; - -import { runAutomatedTuiScenario } from "./harness/run-scenario.ts"; -import { httpMockScenario } from "./scenarios/http-mock.ts"; - -test.skipIf(process.platform === "win32")( - "fixture fetches are intercepted inside the TUI child process", - async () => { - await runAutomatedTuiScenario(httpMockScenario); - }, - 35_000 -); diff --git a/test/tui/input-actions.test.ts b/test/tui/input-actions.test.ts index a2cf4268..d7b52fd8 100644 --- a/test/tui/input-actions.test.ts +++ b/test/tui/input-actions.test.ts @@ -32,25 +32,6 @@ test("queue supports pausing, reordering and editing drafts through the keyboard await session.exit(); }, 20_000); -test.each(["/edit-message", "/retry"])("%s confirms conversation rewind before editing or resending", async (command) => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-message-action-" }); - await using session = TerminalSession.start({ command: [process.execPath, join(import.meta.dir, "fixtures/input-actions.ts")], workspace }); - await session.waitForScreen("ready", /Original answer/u); - await session.sendAndWait(`${command}\r`, "question picker", /Select the question/u); - session.send("\r"); - await session.waitForScreen("explicit history scope", /Later conversation turns will be removed/u); - session.send("\x1b[B\r"); - if (command === "/retry") await session.waitForHistory("question retried", /Sent: Original question/u); - else { - await session.waitForHistory("rewind completed", /Conversation rewound\. The selected input was restored to the editor/u); - await session.waitForScreen("question restored", /Original question/u); - await session.assertScreenExcludes("edit does not submit", /Sent: Original question/u); - session.send("\x03"); - await session.settle(); - } - await session.exit(); -}, 20_000); - test("pending images cannot silently move into another queued or retried question", async () => { await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-input-images-" }); await using session = TerminalSession.start({ command: [process.execPath, join(import.meta.dir, "fixtures/input-actions.ts")], workspace }); @@ -84,74 +65,3 @@ test("resuming queued text preserves an image attached afterwards for the editor await session.assertScreenExcludes("sent image leaves editor", /\[Image #1\]/u); await session.exit(); }, 20_000); - -test("ctrl+c clears the editor text on screen without waiting for the next keypress", async () => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-input-clear-" }); - await using session = TerminalSession.start({ command: [process.execPath, join(import.meta.dir, "fixtures/input-actions.ts")], workspace }); - await session.waitForScreen("ready", /Original answer/u); - session.send("stale draft"); - await session.waitForScreen("draft visible", /stale draft/u); - session.send("\x03"); - await session.settle(); - await session.assertScreenExcludes("cleared draft no longer visible", /stale draft/u); - await session.exit(); -}, 20_000); - -test("ctrl+f replaces editor text with the /search prefill on screen", async () => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-input-search-" }); - await using session = TerminalSession.start({ command: [process.execPath, join(import.meta.dir, "fixtures/input-actions.ts")], workspace }); - await session.waitForScreen("ready", /Original answer/u); - session.send("stale draft"); - await session.waitForScreen("draft visible", /stale draft/u); - session.send("\x06"); - await session.settle(); - await session.assertScreenExcludes("replaced draft no longer visible", /stale draft/u); - await session.waitForScreen("search prefill visible", /\/search/u); - session.send("\x03"); - await session.settle(); - await session.exit(); -}, 20_000); - -test("fragmented terminal palette replies do not enter the draft or trigger input actions", async () => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-color-replies-" }); - await using session = TerminalSession.start({ - command: [process.execPath, join(import.meta.dir, "fixtures/input-actions.ts")], - workspace, - env: { NO_COLOR: "", COLORFGBG: "15;0", EDITOR: "true", VISUAL: "true" } - }); - await session.waitForScreen("ready", /Original answer/u); - session.send("retained draft"); - await session.waitForScreen("draft visible", /retained draft/u); - for (const fragment of [ - "\x1b]10;rgb:", "1111/2222/3333", "\x07", - "\x1b]11;rgb:ffff", "/eeee/dddd", "\x1b\\", - "\x1b]4;2;rgb:", "1234/5678/9abc", "\x07", - "\x1b[?1;2c" - ]) { - session.send(fragment); - await session.settle(); - } - await session.assertScreenExcludes("palette replies stay out of the draft", /rgb:|1111|2222|ffff|9abc/u); - await session.sendAndWait(" tail\r", "unaltered draft submitted", /Sent: retained draft tail/u); - await session.exit(); -}, 20_000); - -test("Ctrl+T cycles reasoning effort without clearing a typed prompt", async () => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-effort-shortcut-" }); - await using session = TerminalSession.start({ - command: [process.execPath, join(import.meta.dir, "fixtures/input-actions.ts")], - workspace - }); - await session.waitForScreen("ready", /Original answer/u); - session.send("Draft question"); - await session.waitForScreen("draft typed", /Draft question/u); - session.send("\x14"); - await session.waitForScreen("effort raised", /⚡ high/u); - await session.waitForScreen("draft preserved after effort raised", /Draft question/u); - session.send("\x14"); - await session.waitForScreen("effort lowered", /⚡ low/u); - await session.waitForScreen("draft preserved after effort lowered", /Draft question/u); - await session.assertScreenExcludes("effort shortcut does not submit the draft", /Sent: Draft question/u); - await session.sendAndWait("\r", "draft submitted", /Sent: Draft question/u); - await session.exit(); -}, 20_000); diff --git a/test/tui/model-resume.test.ts b/test/tui/model-resume.test.ts deleted file mode 100644 index 6792d1e0..00000000 --- a/test/tui/model-resume.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from "bun:test"; - -import { runAutomatedTuiScenario } from "./harness/run-scenario.ts"; -import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; -import { TerminalSession } from "./harness/terminal-session.ts"; -import { modelResumeScenario } from "./scenarios/model-resume.ts"; - -const loginModelFixture = new URL("../fixtures/tui-login-model.ts", import.meta.url).pathname; - -test("TUI restores the last model selected in a resumed session", async () => { - await runAutomatedTuiScenario(modelResumeScenario); -}); - -test("TUI saves a default model after a mixed-case search and uses it in a new session", async () => { - const query = "gLm"; - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-default-model-" }); - const command = [process.execPath, modelResumeScenario.fixture]; - await using session = TerminalSession.start({ command, workspace }); - await session.waitForScreen("initial default model", /◈ scenario\/glm-5\.3(?!-flash)/u); - await session.sendAndWait("/settings\r", "open settings", /ZCode settings/u); - await session.sendAndWait("\r", "open default model picker", /Default model/u); - await session.waitForScreen("unfiltered model", /scenario\/kimi-k3/u); - await session.sendAndWait(query, "filter default models", new RegExp(`Filter: ${query}`, "u")); - await session.waitForScreen("matching provider-qualified model", /scenario\/glm-5\.3-flash/u); - await session.assertScreenExcludes("nonmatching model", /scenario\/kimi-k3/u); - await session.sendAndWait( - "\x1b[B\r", - "save filtered default model", - /Default model saved: scenario\/glm-5\.3-flash/u - ); - expect(JSON.parse(await workspace.read(".default-model-state.json"))).toEqual({ - model: "scenario/glm-5.3-flash" - }); - await session.waitForScreen("settings show saved default", /Saved: scenario\/glm-5\.3-flash/u); - await session.sendAndWait("\x1b", "close settings", /◈ scenario\/glm-5\.3-flash/u); - await session.exit(); - - await using freshSession = TerminalSession.start({ command, workspace }); - await freshSession.waitForScreen("new session uses saved default", /◈ scenario\/glm-5\.3-flash/u); - await freshSession.exit(); -}, 20_000); - -test("TUI moves the session onto the model saved by a runtime login", async () => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-login-model-" }); - await using session = TerminalSession.start({ command: [process.execPath, loginModelFixture], workspace }); - await session.waitForScreen("TUI ready", /Model access is not configured/u); - await session.sendAndWait("/login bigmodel-coding-plan\r", "login response", /Configured BigModel Coding Plan/u); - await session.sendAndWait( - "check this project\r", - "prompt uses the login model", - /Echo from account:bigmodel-individual-coding-plan\/GLM-5\.3: check this project/u - ); - await session.exit(); -}, 20_000); diff --git a/test/tui/model-status.test.ts b/test/tui/model-status.test.ts deleted file mode 100644 index 339ad97e..00000000 --- a/test/tui/model-status.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect, test } from "bun:test"; -import { join } from "node:path"; - -import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; -import { TerminalSession } from "./harness/terminal-session.ts"; - -test.skipIf(process.platform === "win32")( - "a recovered model failure leaves one status row instead of one error row per attempt", - async () => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-model-status-" }); - await using session = TerminalSession.start({ - command: [process.execPath, join(import.meta.dir, "fixtures/model-status.ts")], - workspace - }); - await session.waitForScreen("scenario instructions", /recover a model request that failed/iu); - session.send("recover stream\r"); - // The status row still reports the attempt while it is unresolved. - await session.waitForScreen("stalled model status", /Model stream stalled\s+·\s+idle 60s/u); - await session.waitForHistory( - "recovered turn", - /Recovered after reconnecting the model stream\./u - ); - await session.settle(); - const recovered = session.screenText(); - // Two stalled and failed attempts were recovered, so no status row about them - // may survive the successful request, and none may be appended per attempt. - for (const statusRow of ["Model request failed", "Model stream stalled", "Retrying model request", "Recovering model stream"]) { - expect(recovered).not.toContain(statusRow); - } - - // An unrecoverable failure still has to be visible in the transcript. - session.send("fail hard\r"); - await session.waitForHistory("terminal model failure", /Provider rejected the request\./u); - await session.settle(); - expect(session.screenText().match(/Model request failed/gu) ?? []).toHaveLength(1); - await session.exit(); - }, - 20_000 -); diff --git a/test/tui/permission-request-queue.test.ts b/test/tui/permission-request-queue.test.ts index 7dcd7697..29b7c655 100644 --- a/test/tui/permission-request-queue.test.ts +++ b/test/tui/permission-request-queue.test.ts @@ -13,55 +13,3 @@ test.skipIf(process.platform === "win32")( }, 25_000 ); - -for (const decision of ["allow", "deny"] as const) { - test.skipIf(process.platform === "win32")( - `guarded risk requests allow only a single-use ${decision} decision`, - async () => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-guarded-permission-" }); - await using session = TerminalSession.start({ - command: [process.execPath, join(import.meta.dir, "fixtures", "guarded-permission.ts")], - workspace - }); - await session.waitForScreen("scenario instructions", /Type request guarded permission to start\./u); - for (let attempt = 0; attempt < 2; attempt++) { - await session.sendAndWait("request guarded permission\r", "risk confirmation", /GUARDED_SINGLE_USE_PERMISSION/u); - expect(session.screenText()).toContain("Allow once"); - expect(session.screenText()).toContain("Deny"); - await session.assertScreenExcludes("persistent approval", /Always allow|Deny and tell/u); - await session.sendAndWait(decision === "allow" ? "1" : "2", "single-use result", new RegExp(`Guarded result ${attempt + 1}:.*"decision":"${decision}"`, "u")); - await session.assertScreenExcludes("persistent permission updates", /permissionUpdates|sessionPermissionUpdates/u); - } - await session.exit(); - }, - 25_000 - ); - - test.skipIf(process.platform === "win32")( - `queued permission settled by a hook (${decision}) preserves its tool result`, - async () => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-permission-hook-" }); - await using session = TerminalSession.start({ - command: [process.execPath, join(import.meta.dir, "fixtures", "permission-request-hook.ts")], - workspace - }); - const terminalState = decision === "allow" - ? /✓ Bash printf HOOK_TOOL/u - : /✗ Bash printf HOOK_TOOL · failed/u; - - await session.waitForScreen("scenario instructions", /Type “hook allow” or “hook deny” to start\./u); - await session.sendAndWait(`hook ${decision}\r`, "hook result while queued", /HOOK_SETTLED_WHILE_QUEUED/u); - expect(session.screenText()).toMatch(terminalState); - expect(session.screenText()).toContain("FIRST_ACTIVE_PERMISSION"); - await session.assertScreenExcludes("settled permission dialog", /HOOK_PERMISSION/u); - - await session.sendAndWait("1", "permission queue drained", /Hook race complete\./u); - expect(session.screenText()).toMatch(terminalState); - await session.assertScreenExcludes("stale tool state", /waiting for permission|interrupted/u); - await session.assertScreenExcludes("settled permission dialog", /HOOK_PERMISSION/u); - await session.sendAndWait("next prompt\r", "editor accepts the next prompt", /Follow-up received\./u); - await session.exit(); - }, - 25_000 - ); -} diff --git a/test/tui/run-scenario.test.ts b/test/tui/run-scenario.test.ts deleted file mode 100644 index 05f5d625..00000000 --- a/test/tui/run-scenario.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { expect, test } from "bun:test"; - -import { runManualTuiScenario } from "./harness/run-scenario.ts"; -import type { TuiScenario } from "./scenarios/types.ts"; - -test("manual TUI runner returns the fixture exit code and disposes its workspace", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-manual-runner-")); - const fixture = join(directory, "fixture.ts"); - await writeFile(fixture, [ - 'import { writeFile } from "node:fs/promises";', - 'import { join } from "node:path";', - 'await writeFile(join(import.meta.dir, "observed-cwd.txt"), process.cwd(), "utf8");', - "process.exit(7);", - "" - ].join("\n"), "utf8"); - - const scenario: TuiScenario = { - name: "manual-exit-code", - description: "manual runner test fixture", - fixture, - files: { "baseline.txt": "baseline\n" }, - async run() {} - }; - - try { - await expect(runManualTuiScenario(scenario)).resolves.toBe(7); - const workspaceDirectory = await readFile(join(directory, "observed-cwd.txt"), "utf8"); - await expect(access(workspaceDirectory)).rejects.toThrow(); - } finally { - await rm(directory, { recursive: true, force: true }); - } -}); diff --git a/test/tui/runtime-refresh.test.ts b/test/tui/runtime-refresh.test.ts deleted file mode 100644 index 8565bc5c..00000000 --- a/test/tui/runtime-refresh.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test } from "bun:test"; -import { join } from "node:path"; -import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; -import { TerminalSession } from "./harness/terminal-session.ts"; - -test("TUI streams and polls tool activity without rereading history, then refreshes completed/resumed usage", async () => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-runtime-refresh-" }); - await using session = TerminalSession.start({ - command: [process.execPath, join(import.meta.dir, "fixtures/runtime-refresh.ts")], workspace - }); - await session.waitForScreen("initial cache", /cache 80% hit/u); - session.send("stream\r"); - await session.waitForHistory("stream finished", /Streaming completed/u); - await session.waitForScreen("completed cache", /cache 60% hit/u); - const entries = (await workspace.readRuntimeJournal()).trim().split("\n").map((line) => JSON.parse(line)); - for (const kind of ["text-deltas.finished", "tool-progress.finished"]) { - const entry = entries.find((entry) => entry.kind === kind); - expect(entry, kind).toBeDefined(); - expect(entry.detail.after, kind).toBe(entry.detail.before); - if (kind === "text-deltas.finished") { - // Allow scheduled activity polls on slow CI; the subscription must not - // introduce the previous 80 ms query loop during presentation-only data. - expect(entry.detail.projectionReads).toBeLessThanOrEqual(Math.ceil(entry.detail.elapsedMs / 1_000) + 1); - } - } - await session.sendAndWait("/resume second\r", "resumed session", /Resumed second session/u); - await session.waitForScreen("resumed cache", /cache 20% hit/u); - await session.exit(); -}, 20_000); diff --git a/test/tui/scenario-http.test.ts b/test/tui/scenario-http.test.ts deleted file mode 100644 index 8ad75632..00000000 --- a/test/tui/scenario-http.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { expect, test } from "bun:test"; - -import { ScenarioRuntimeJournal } from "./runtime/scenario-runtime.ts"; -import { createScenarioHttpMock } from "./runtime/scenario-http.ts"; - -test("scenario HTTP mock intercepts real fetch calls and validates typed requests", async () => { - const journal = new ScenarioRuntimeJournal(); - using mock = createScenarioHttpMock({ - journal, - routes: [{ - id: "update-model", - method: "POST", - url: "https://scenario.invalid/api/models/:modelId", - expectedCalls: 1, - async assert(context) { - expect(context.params.modelId).toBe("glm-5"); - expect(await context.json<{ enabled: boolean }>()).toEqual({ enabled: true }); - expect(context.request.headers.get("authorization")).toBe("Bearer fixture-token"); - }, - response: (context) => ({ - type: "json", - body: { - call: context.call, - modelId: context.params.modelId, - updated: true - }, - headers: { "x-scenario": "mocked" }, - status: 202 - }) - }] - }); - mock.start(); - - const response = await fetch("https://scenario.invalid/api/models/glm-5", { - method: "POST", - headers: { - authorization: "Bearer fixture-token", - "content-type": "application/json" - }, - body: JSON.stringify({ enabled: true }) - }); - - expect(response.status).toBe(202); - expect(response.headers.get("x-scenario")).toBe("mocked"); - expect(await response.json()).toEqual({ call: 1, modelId: "glm-5", updated: true }); - expect(mock.calls("update-model")).toBe(1); - mock.assertSatisfied(); - expect(journal.entries().map((entry) => entry.kind)).toEqual([ - "http.start", - "http.request", - "http.response", - "http.assert" - ]); - expect(journal.entries()[1]).toMatchObject({ - detail: { - headers: { authorization: "[redacted]" } - } - }); -}); - -test("scenario HTTP mock rejects unhandled requests and records the failure", async () => { - using mock = createScenarioHttpMock({ routes: [] }); - mock.start(); - - expect((await fetch("https://scenario.invalid/unhandled")).status).toBe(500); - expect(mock.journal.entries().at(-1)).toMatchObject({ - kind: "http.unhandled", - detail: { - method: "GET", - url: "https://scenario.invalid/unhandled" - } - }); - expect(() => mock.assertSatisfied()).toThrow( - "unhandled GET https://scenario.invalid/unhandled" - ); -}); - -test("scenario HTTP mock reports missing, excess, and unknown route calls", async () => { - using mock = createScenarioHttpMock({ - routes: [{ - id: "once", - method: "GET", - url: "https://scenario.invalid/once", - expectedCalls: 1, - response: { type: "empty", status: 204 } - }] - }); - mock.start(); - - expect(() => mock.assertSatisfied()).toThrow('"once" expected 1, received 0'); - expect(() => mock.calls("missing")).toThrow("Unknown scenario HTTP route"); - expect((await fetch("https://scenario.invalid/once")).status).toBe(204); - await expect(fetch("https://scenario.invalid/once")).rejects.toThrow(); - expect(() => mock.assertSatisfied()).toThrow('"once" expected 1, received 2'); -}); - -test("scenario HTTP mock supports latency, cancellation, and network errors", async () => { - using mock = createScenarioHttpMock({ - routes: [{ - id: "delayed", - method: "GET", - url: "https://scenario.invalid/delayed", - response: { - type: "text", - body: "too late", - delayMilliseconds: 5_000 - } - }, { - id: "offline", - method: "GET", - url: "https://scenario.invalid/offline", - response: { type: "networkError" } - }] - }); - mock.start(); - - const controller = new AbortController(); - const delayed = fetch("https://scenario.invalid/delayed", { signal: controller.signal }); - controller.abort(new Error("scenario cancelled")); - await expect(delayed).rejects.toThrow("scenario cancelled"); - await expect(fetch("https://scenario.invalid/offline")).rejects.toThrow(); - mock.assertSatisfied(); - expect(mock.journal.entries()).toEqual(expect.arrayContaining([ - expect.objectContaining({ - kind: "http.abort", - detail: expect.objectContaining({ routeId: "delayed" }) - }), - expect.objectContaining({ - kind: "http.response", - detail: expect.objectContaining({ routeId: "offline", type: "networkError" }) - }) - ])); -}); - -test("scenario HTTP mock rejects ambiguous definitions", () => { - expect(() => createScenarioHttpMock({ - routes: [{ - id: "duplicate", - method: "GET", - url: "https://scenario.invalid/one", - response: { type: "empty" } - }, { - id: "duplicate", - method: "GET", - url: "https://scenario.invalid/two", - response: { type: "empty" } - }] - })).toThrow("route id must be unique"); - - expect(() => createScenarioHttpMock({ - routes: [{ - id: "invalid-count", - method: "GET", - url: "https://scenario.invalid/count", - expectedCalls: -1, - response: { type: "empty" } - }] - })).toThrow("invalid expectedCalls"); -}); - -test("scenario HTTP mock rejects duplicate starts and allows close before start", () => { - using mock = createScenarioHttpMock({ routes: [] }); - - expect(() => mock.close()).not.toThrow(); - mock.start(); - expect(() => mock.start()).toThrow("already started"); - expect(() => mock.close()).not.toThrow(); - expect(() => mock.close()).not.toThrow(); -}); - -test("scenario HTTP mock records response validation failures", async () => { - using mock = createScenarioHttpMock({ - routes: [{ - id: "invalid-delay", - method: "GET", - url: "https://scenario.invalid/invalid-delay", - response: { - type: "text", - body: "unreachable", - delayMilliseconds: Number.NaN - } - }] - }); - mock.start(); - - await expect(fetch("https://scenario.invalid/invalid-delay")).rejects.toThrow("Failed to fetch"); - expect(mock.journal.entries().at(-1)).toMatchObject({ - kind: "http.error", - detail: { error: "Scenario HTTP response has an invalid delay: NaN" } - }); - expect(() => mock.assertSatisfied()).toThrow("invalid delay"); -}); - -test("scenario HTTP mock records route assertion failures", async () => { - using mock = createScenarioHttpMock({ - routes: [{ - id: "assertion", - method: "GET", - url: "https://scenario.invalid/assertion", - assert() { - throw new Error("request did not match fixture"); - }, - response: { type: "empty", status: 204 } - }] - }); - mock.start(); - - await expect(fetch("https://scenario.invalid/assertion")).rejects.toThrow("Failed to fetch"); - expect(mock.journal.entries().at(-1)).toMatchObject({ - kind: "http.error", - detail: { error: "request did not match fixture" } - }); - expect(() => mock.assertSatisfied()).toThrow("request did not match fixture"); -}); - -test("scenario HTTP mock truncates request bodies and redacts sensitive headers", async () => { - const body = "x".repeat(16_385); - using mock = createScenarioHttpMock({ - routes: [{ - id: "large-request", - method: "POST", - url: "https://scenario.invalid/large-request", - expectedCalls: 1, - async assert(context) { - expect((await context.text()).length).toBe(body.length); - }, - response: { - type: "text", - body: "accepted", - delayMilliseconds: 1 - } - }] - }); - mock.start(); - - const response = await fetch("https://scenario.invalid/large-request", { - method: "POST", - headers: { - authorization: "Bearer secret", - cookie: "session=secret", - "x-api-key": "secret" - }, - body - }); - expect(response.status).toBe(200); - expect(await response.text()).toBe("accepted"); - mock.assertSatisfied(); - - const requestEntry = mock.journal.entries().find((entry) => entry.kind === "http.request"); - expect(requestEntry).toMatchObject({ - detail: { - bodyTruncated: true, - headers: { - authorization: "[redacted]", - cookie: "[redacted]", - "x-api-key": "[redacted]" - } - } - }); - const recordedBody = String((requestEntry?.detail as { body: string }).body); - expect(recordedBody).toHaveLength(16_385); - expect(recordedBody.endsWith("…")).toBe(true); -}); diff --git a/test/tui/scenario-mountx.test.ts b/test/tui/scenario-mountx.test.ts deleted file mode 100644 index 0f853abd..00000000 --- a/test/tui/scenario-mountx.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - MountxWorkspaceBackend, - probeScenarioMountx -} from "./harness/mountx-workspace-backend.ts"; -import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; - -test("Mountx reports whether this host has an explicit workspace transport", async () => { - const probe = await probeScenarioMountx(); - - expect(probe.platform).toBe(process.platform); - expect(probe.preference.length).toBeGreaterThan(0); - if (probe.chosen) { - expect(probe[probe.chosen].usable).toBe(true); - } else { - expect(probe.reason).toBeTruthy(); - } -}); - -test.skipIf(process.env.ZCODE_TEST_MOUNTX !== "1")( - "Mountx memory workspace supports real filesystem and Git processes", - async () => { - await using workspace = await ScenarioWorkspace.create({ - backend: new MountxWorkspaceBackend(), - files: { "README.md": "mounted baseline\n" } - }); - - await workspace.write("mounted.txt", "mounted write\n"); - expect(await workspace.read("mounted.txt")).toBe("mounted write\n"); - expect((await workspace.git(["status", "--short"])).stdout).toContain("?? mounted.txt"); - }, - 30_000 -); diff --git a/test/tui/scenario-runtime.test.ts b/test/tui/scenario-runtime.test.ts deleted file mode 100644 index 50c74e3d..00000000 --- a/test/tui/scenario-runtime.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { expect, test } from "bun:test"; - -import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; -import { createScenarioRuntime } from "./runtime/scenario-runtime.ts"; - -test("scenario runtime matches input, writes isolated files, and journals the response", async () => { - await using workspace = await ScenarioWorkspace.create(); - const runtime = createScenarioRuntime({ - journalPath: workspace.runtimeJournalPath, - workspaceDirectory: workspace.directory, - turns: [{ - id: "write", - match: /^write fixture$/u, - steps: [{ - type: "writeFiles", - files: { "nested/result.txt": "fixture result\n" } - }, { - type: "respond", - response: "Write complete.", - metadata: { sessionId: "scenario-session" } - }] - }] - }); - - await expect(runtime.submitPrompt("write fixture", {})).resolves.toEqual({ - response: "Write complete.", - sessionId: "scenario-session" - }); - expect(await workspace.read("nested/result.txt")).toBe("fixture result\n"); - expect(runtime.journal.entries().map((entry) => entry.kind)).toEqual([ - "turn.start", - "step.start", - "files.write", - "step.finish", - "step.start", - "turn.finish" - ]); - expect(await workspace.readRuntimeJournal()).toContain('"kind":"turn.finish"'); -}); - -test("scenario runtime starts parallel permission requests before awaiting either result", async () => { - const first = Promise.withResolvers(); - const second = Promise.withResolvers(); - const calls: unknown[] = []; - const runtime = createScenarioRuntime({ - turns: [{ - id: "permissions", - match: "run", - steps: [{ - type: "permissions", - mode: "parallel", - requests: [{ id: "first" }, { id: "second" }], - saveAs: "decisions" - }, { - type: "respond", - response: (context) => context.value("decisions").join(",") - }] - }] - }); - - const submission = runtime.submitPrompt("run", { - requestPermission: async (request) => { - calls.push(request); - return calls.length === 1 ? await first.promise : await second.promise; - } - }); - await Bun.sleep(0); - expect(calls).toEqual([{ id: "first" }, { id: "second" }]); - second.resolve("allow-second"); - first.resolve("deny-first"); - await expect(submission).resolves.toEqual({ response: "deny-first,allow-second" }); -}); - -test("scenario runtime keeps sequential permission requests ordered", async () => { - const first = Promise.withResolvers(); - const calls: unknown[] = []; - const runtime = createScenarioRuntime({ - turns: [{ - id: "permissions", - match: "run", - steps: [{ - type: "permissions", - requests: [{ id: "first" }, { id: "second" }], - saveAs: "decisions" - }, { - type: "respond", - response: "finished" - }] - }] - }); - - const submission = runtime.submitPrompt("run", { - requestPermission: async (request) => { - calls.push(request); - if (calls.length === 1) return await first.promise; - return "second"; - } - }); - await Bun.sleep(0); - expect(calls).toEqual([{ id: "first" }]); - first.resolve("first"); - await expect(submission).resolves.toEqual({ response: "finished" }); - expect(calls).toEqual([{ id: "first" }, { id: "second" }]); -}); - -test("scenario runtime emits events and cancels abort-aware delays", async () => { - const events: unknown[] = []; - const runtime = createScenarioRuntime({ - turns: [{ - id: "events", - match: "events", - steps: [{ - type: "emit", - event: { type: "fixture.event", payload: { value: 1 } } - }, { - type: "respond", - response: "events complete" - }] - }, { - id: "delay", - match: "delay", - steps: [{ - type: "delay", - milliseconds: 5_000 - }, { - type: "respond", - response: "too late" - }] - }] - }); - - await runtime.submitPrompt("events", { - onEvent: (event) => { - events.push(event); - } - }); - expect(events).toEqual([{ type: "fixture.event", payload: { value: 1 } }]); - - const controller = new AbortController(); - const delayed = runtime.submitPrompt("delay", { abortSignal: controller.signal }); - controller.abort(new Error("fixture stopped")); - await expect(delayed).rejects.toThrow("fixture stopped"); - expect(runtime.journal.entries().at(-1)).toMatchObject({ - kind: "turn.error", - detail: { error: "fixture stopped", turnId: "delay" } - }); -}); - -test("scenario runtime rejects unmatched input, unsafe writes, and invalid definitions", async () => { - const unmatched = createScenarioRuntime({ - turns: [{ - id: "known", - match: "known", - steps: [{ type: "respond", response: "known" }] - }] - }); - await expect(unmatched.submitPrompt("unknown", {})).rejects.toThrow("No scenario turn matches input"); - - const unsafe = createScenarioRuntime({ - workspaceDirectory: process.cwd(), - turns: [{ - id: "unsafe", - match: "unsafe", - steps: [{ - type: "writeFiles", - files: { "../escaped.txt": "not allowed" } - }, { - type: "respond", - response: "unreachable" - }] - }] - }); - await expect(unsafe.submitPrompt("unsafe", {})).rejects.toThrow("escapes the workspace"); - - expect(() => createScenarioRuntime({ - turns: [{ - id: "invalid", - match: "invalid", - steps: [{ type: "delay", milliseconds: 1 }] - }] - })).toThrow('must end with exactly one respond step'); -}); - -test("scenario runtime covers fallback responses and missing callbacks", async () => { - const fallback = createScenarioRuntime({ - unmatchedResponse: (context) => `fallback: ${String(context.input)}`, - turns: [{ - id: "known", - match: "known", - steps: [{ type: "respond", response: "known" }] - }] - }); - await expect(fallback.submitPrompt("unknown", {})).resolves.toEqual({ - response: "fallback: unknown" - }); - - const missingPermissionCallback = createScenarioRuntime({ - turns: [{ - id: "permissions", - match: "permissions", - steps: [{ - type: "permissions", - requests: [{ id: "permission" }], - saveAs: "decisions" - }, { - type: "respond", - response: "unreachable" - }] - }] - }); - await expect(missingPermissionCallback.submitPrompt("permissions", {})) - .rejects.toThrow("permission callback is unavailable"); - - expect(() => createScenarioRuntime({ - turns: [{ - id: "invalid-delay", - match: "invalid-delay", - steps: [{ type: "delay", milliseconds: -1 }, { type: "respond", response: "never" }] - }] - })).toThrow("has an invalid delay"); - - expect(() => createScenarioRuntime({ - turns: [{ - id: "duplicate", - match: "first", - steps: [{ type: "respond", response: "first" }] - }, { - id: "duplicate", - match: "second", - steps: [{ type: "respond", response: "second" }] - }] - })).toThrow("turn id must be unique"); -}); - -test("scenario runtime rejects pre-aborted turns and writes without a workspace", async () => { - const controller = new AbortController(); - controller.abort(new Error("already stopped")); - const runtime = createScenarioRuntime({ - turns: [{ - id: "aborted", - match: "aborted", - steps: [{ type: "respond", response: "unreachable" }] - }] - }); - await expect(runtime.submitPrompt("aborted", { abortSignal: controller.signal })) - .rejects.toThrow("already stopped"); - - const missingWorkspace = createScenarioRuntime({ - turns: [{ - id: "write", - match: "write", - steps: [{ - type: "writeFiles", - files: { "result.txt": "unreachable" } - }, { - type: "respond", - response: "unreachable" - }] - }] - }); - await expect(missingWorkspace.submitPrompt("write", {})) - .rejects.toThrow("without a workspaceDirectory"); -}); diff --git a/test/tui/scenario-shell.test.ts b/test/tui/scenario-shell.test.ts deleted file mode 100644 index 04f54da9..00000000 --- a/test/tui/scenario-shell.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { symlink } from "node:fs/promises"; -import { join } from "node:path"; - -import { expect, test } from "bun:test"; - -import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; -import { createScenarioShell } from "./runtime/scenario-shell.ts"; - -test("scenario shell writes through the isolated workspace and real Git sees the result", async () => { - await using workspace = await ScenarioWorkspace.create({ - files: { "input.txt": "beta\nalpha\n" } - }); - const shell = createScenarioShell({ - journalPath: workspace.runtimeJournalPath, - workspaceDirectory: workspace.directory - }); - - const result = await shell.exec( - "mkdir -p reports && sort input.txt | tee reports/sorted.txt | wc -l" - ); - - expect(result).toMatchObject({ exitCode: 0, stderr: "", stdout: "2\n" }); - expect(await workspace.read("reports/sorted.txt")).toBe("alpha\nbeta\n"); - expect((await workspace.git(["status", "--short"])).stdout).toContain("?? reports/"); - expect(await workspace.readRuntimeJournal()).toContain('"kind":"shell.finish"'); -}); - -test("scenario shell exposes only allowlisted commands without host or network access", async () => { - await using workspace = await ScenarioWorkspace.create(); - const shell = createScenarioShell({ workspaceDirectory: workspace.directory }); - - await expect(shell.exec("git status")).rejects.toThrow("received 127"); - await expect(shell.exec("curl https://example.com")).rejects.toThrow("received 127"); - const outside = await shell.exec("cat /etc/passwd", { expectedExitCode: 1 }); - expect(outside.stderr).toContain("No such file"); -}); - -test("scenario shell supports expected failures, cancellation, and bounded execution", async () => { - await using workspace = await ScenarioWorkspace.create(); - const shell = createScenarioShell({ - executionLimits: { - maxExecutionTimeMs: 50, - maxLoopIterations: 20 - }, - workspaceDirectory: workspace.directory - }); - - await expect(shell.exec("while true; do echo loop >/dev/null; done")).rejects.toThrow(); - - const controller = new AbortController(); - controller.abort(new Error("scenario cancelled")); - await expect(shell.exec("echo unreachable", { - signal: controller.signal - })).rejects.toThrow(); -}); - -test("scenario shell blocks symlink traversal and oversized output", async () => { - await using workspace = await ScenarioWorkspace.create(); - await symlink("/etc/passwd", join(workspace.directory, "host-file")); - - const shell = createScenarioShell({ workspaceDirectory: workspace.directory }); - - const symlinkRead = await shell.exec("cat host-file", { expectedExitCode: 1 }); - expect(symlinkRead.stderr).toContain("No such file"); - - const boundedShell = createScenarioShell({ - executionLimits: { maxOutputSize: 16 }, - workspaceDirectory: workspace.directory - }); - await expect(boundedShell.exec("printf 12345678901234567890")).rejects.toThrow( - "expected exit 0, received 126" - ); -}); - -test("scenario shell rejects an empty command allowlist", async () => { - await using workspace = await ScenarioWorkspace.create(); - - expect(() => createScenarioShell({ - commands: [], - workspaceDirectory: workspace.directory - })).toThrow("requires at least one allowed command"); -}); - -test("scenario shell contains floating-point precision failures and remains usable", async () => { - await using workspace = await ScenarioWorkspace.create(); - const shell = createScenarioShell({ workspaceDirectory: workspace.directory }); - - for (const specifier of ["e", "f", "g"]) { - expect((await shell.exec(`printf '%.100${specifier}' 1.25`)).stdout.length).toBeLessThanOrEqual(107); - // just-bash implements ordinary float formats itself and contains their - // RangeErrors at the command boundary; sprintf-js handles fallback formats. - for (const command of [ - `printf '%.101${specifier}' 1.25`, - `printf '%.*${specifier}' 101 1.25`, - `printf -v formatted '%.*${specifier}' 101 1.25` - ]) { - const result = await shell.exec(command, { expectedExitCode: 1 }); - expect(result.stdout).toBe(""); - expect(result.stderr).toMatch(/^printf: .+argument must be between /u); - } - } - expect(await shell.exec("printf '%.0g %.0f' 1.25 1.25")).toMatchObject({ - exitCode: 0, stdout: "1 1", stderr: "" - }); - expect((await shell.exec("echo ready")).stdout).toBe("ready\n"); -}); - -test("scenario shell rejects excessive awk printf and sprintf precision before formatting", async () => { - await using workspace = await ScenarioWorkspace.create(); - const shell = createScenarioShell({ workspaceDirectory: workspace.directory, commands: ["awk"] }); - for (const expression of ['printf "%.101f", 1.25', 'print sprintf("%.101f", 1.25)']) { - const result = await shell.exec(`awk 'BEGIN { ${expression} }'`, { expectedExitCode: 126 }); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("floating-point precision limit exceeded (100 digits)"); - } - expect((await shell.exec(`awk 'BEGIN { printf "%.2f", 1.25 }'`)).stdout).toBe("1.25"); -}); - -test("scenario shell rejects ambiguous journals and invalid exit expectations", async () => { - await using workspace = await ScenarioWorkspace.create(); - const journalPath = workspace.runtimeJournalPath; - expect(() => createScenarioShell({ - journal: createScenarioShell({ - workspaceDirectory: workspace.directory - }).journal, - journalPath, - workspaceDirectory: workspace.directory - })).toThrow("either journal or journalPath"); - - const shell = createScenarioShell({ workspaceDirectory: workspace.directory }); - await expect(shell.exec("true", { expectedExitCode: [] })).rejects.toThrow( - "must contain valid exit codes" - ); -}); diff --git a/test/tui/scenario-workspace.test.ts b/test/tui/scenario-workspace.test.ts deleted file mode 100644 index 9f473ec9..00000000 --- a/test/tui/scenario-workspace.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { access } from "node:fs/promises"; - -import { expect, test } from "bun:test"; - -import { readWorkspaceDiff } from "../../packages/zcode-tui/src/workspace-diff.ts"; -import { - ScenarioWorkspace, - type ScenarioWorkspaceBackend -} from "./harness/scenario-workspace.ts"; -import { ScenarioJournal } from "./harness/scenario-journal.ts"; - -test.skipIf(process.platform === "win32")("scenario workspace exposes real writes to Git and resets them", async () => { - await using workspace = await ScenarioWorkspace.create({ - files: { - "README.md": "baseline\n", - "src/existing.ts": "export const value = 1;\n" - } - }); - - await workspace.write("src/existing.ts", "export const value = 2;\n"); - await workspace.write("src/created.ts", "export const created = true;\n"); - - const changed = await readWorkspaceDiff(workspace.directory); - expect(changed.error).toBeUndefined(); - expect(changed.files.map((file) => [file.filePath, file.status])).toEqual([ - ["src/created.ts", "untracked"], - ["src/existing.ts", "modified"] - ]); - - await workspace.reset(); - expect(await workspace.read("src/existing.ts")).toBe("export const value = 1;\n"); - const reset = await readWorkspaceDiff(workspace.directory); - expect(reset).toEqual({ files: [], truncated: false }); -}); - -test("scenario workspace rejects paths outside its isolated root", async () => { - await using workspace = await ScenarioWorkspace.create(); - await expect(workspace.write("../escaped.txt", "not allowed\n")).rejects.toThrow("escapes the workspace"); -}); - -test("scenario workspace mounts and disposes an optional backend exactly once", async () => { - let mountedDirectory = ""; - let disposeCalls = 0; - const backend: ScenarioWorkspaceBackend = { - name: "fixture-backend", - async mount(directory) { - mountedDirectory = directory; - }, - async dispose() { - disposeCalls += 1; - } - }; - const workspace = await ScenarioWorkspace.create({ backend }); - - expect(mountedDirectory).toBe(workspace.directory); - expect(workspace.backendName).toBe("fixture-backend"); - await workspace.dispose(); - await workspace.dispose(); - expect(disposeCalls).toBe(1); -}); - -test("scenario workspace removes a partially initialized root when setup fails", async () => { - const journal = new ScenarioJournal(); - - await expect(ScenarioWorkspace.create({ - journal, - files: { "../escaped.txt": "not allowed\n" } - })).rejects.toThrow("escapes the workspace"); - - const createEntry = journal.entries().find((entry) => entry.channel === "workspace.create"); - expect(createEntry).toBeDefined(); - await expect(access(String((createEntry?.detail as { root: string }).root))).rejects.toThrow(); - expect(journal.entries().at(-1)?.channel).toBe("workspace.dispose"); -}); - -test("scenario workspace disposal is idempotent", async () => { - const workspace = await ScenarioWorkspace.create(); - await workspace.dispose(); - await expect(workspace.dispose()).resolves.toBeUndefined(); -}); diff --git a/test/tui/session-lifecycle.test.ts b/test/tui/session-lifecycle.test.ts deleted file mode 100644 index 1260b060..00000000 --- a/test/tui/session-lifecycle.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, test } from "bun:test"; -import { join } from "node:path"; -import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; -import { TerminalSession } from "./harness/terminal-session.ts"; - -test.each(["unsupported", "missing", "unreadable", "timeout", "saved"])( - "TUI exit uses only a verified resume target (%s)", async mode => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-tui-lifecycle-" }); - await using session = TerminalSession.start({ - command: [process.execPath, join(import.meta.dir, "fixtures/session-lifecycle.ts")], workspace, - env: { ZCODE_TEST_RESUME_TARGET: mode } - }); - await session.waitForScreen("ready", /◈ fixture\/model/u); - await session.exit(); - const output = session.historyText(); - expect(output).not.toContain("zcode --resume id-only"); - if (mode === "saved") expect(output).toContain("zcode --resume saved-session"); - else expect(output).not.toContain("To continue this session"); - }, 10_000 -); - -test("TUI settles a failed submission even when runtime projection reads fail", async () => { - await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-tui-lifecycle-failure-" }); - await using session = TerminalSession.start({ - command: [process.execPath, join(import.meta.dir, "fixtures/session-lifecycle.ts")], workspace, - env: { ZCODE_TEST_RESUME_TARGET: "missing" } - }); - await session.waitForScreen("ready", /◈ fixture\/model/u); - await session.sendAndWait("first prompt\r", "first failure", /Session not found: missing/u); - await session.waitForScreen("failed turn settled", /\[ ✓ /u); - const start = session.historyCheckpoint(); - session.send("second prompt\r"); - await session.waitForHistory("second failure", /Session not found: missing/u, start); - await session.waitForScreen("next failed turn settled", /\[ ✓ /u); - await session.exit(); -}, 10_000); diff --git a/test/tui/session-rename.test.ts b/test/tui/session-rename.test.ts deleted file mode 100644 index bc0d0515..00000000 --- a/test/tui/session-rename.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { test } from "bun:test"; -import { runAutomatedTuiScenario } from "./harness/run-scenario.ts"; -import { sessionRenameScenario } from "./scenarios/session-rename.ts"; - -test("TUI safely persists and restores custom session titles", async () => { - await runAutomatedTuiScenario(sessionRenameScenario); -}, 20_000); diff --git a/test/tui/startup.test.ts b/test/tui/startup.test.ts index 0c32bf9a..f5028bc3 100644 --- a/test/tui/startup.test.ts +++ b/test/tui/startup.test.ts @@ -5,7 +5,7 @@ import { TerminalSession } from "./harness/terminal-session.ts"; const command = [process.execPath, join(import.meta.dir, "fixtures/startup.ts")]; -for (const mode of ["lazy", "legacy"]) { +for (const mode of ["lazy"]) { test(`TUI loads ${mode} startup commands before constructing the editor`, async () => { await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-startup-" }); await using session = TerminalSession.start({ command, workspace, env: { ZCODE_TEST_STARTUP_MODE: mode } }); diff --git a/test/tui/terminal-exit.test.ts b/test/tui/terminal-exit.test.ts index c8f8ed85..f4fdae8e 100644 --- a/test/tui/terminal-exit.test.ts +++ b/test/tui/terminal-exit.test.ts @@ -17,11 +17,8 @@ const cases: { }[] = [ { name: "Ctrl+C in regular mode", mode: "regular", input: "\x1b[99;5u", release: ctrlCRelease, status: 0 }, { name: "Ctrl+C in fullscreen mode", mode: "fullscreen", input: "\x1b[99;5u", release: ctrlCRelease, status: 0 }, - { name: "Ctrl+D", mode: "regular", input: "\x1b[100;5u", release: "\x1b[100;5:3u", status: 0 }, - { name: "/exit", mode: "regular", input: "/exit\r", release: "\x1b[13;1:3u", status: 0 }, { name: "startup cancellation", mode: "regular", startup: true, input: "\x1b[99;5u", release: ctrlCRelease, status: 130 }, { name: "repeated SIGTERM", mode: "fullscreen", signal: "SIGTERM", release: ctrlCRelease, status: 143 }, - { name: "repeated SIGTERM during startup", mode: "regular", startup: true, signal: "SIGTERM", release: ctrlCRelease, status: 143 } ]; for (const scenario of cases) { diff --git a/test/tui/terminal-screen.test.ts b/test/tui/terminal-screen.test.ts deleted file mode 100644 index 8679f9e8..00000000 --- a/test/tui/terminal-screen.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { expect, test } from "bun:test"; - -import { TerminalScreen } from "./harness/terminal-screen.ts"; - -test("headless terminal reports the current screen instead of erased output", async () => { - using screen = new TerminalScreen(20, 4); - await screen.write("stale panel"); - expect(screen.screenText()).toContain("stale panel"); - - await screen.write("\x1b[2J\x1b[Hcurrent panel"); - - expect(screen.screenText()).toBe("current panel"); - expect(screen.bufferText()).not.toContain("stale panel"); -}); - -test("headless terminal follows alternate-screen transitions", async () => { - using screen = new TerminalScreen(20, 4); - await screen.write("main screen"); - await screen.write("\x1b[?1049h\x1b[Halternate"); - - expect(screen.snapshot()).toMatchObject({ - buffer: "alternate", - cols: 20, - rows: 4, - text: "alternate" - }); - - await screen.write("\x1b[?1049l"); - - expect(screen.snapshot()).toMatchObject({ - buffer: "normal", - text: "main screen" - }); -}); - -test("headless terminal separates the visible viewport from scrollback", async () => { - using screen = new TerminalScreen(20, 3); - await screen.write("one\r\ntwo\r\nthree\r\nfour"); - - expect(screen.screenText()).toBe("two\nthree\nfour"); - expect(screen.bufferText()).toBe("one\ntwo\nthree\nfour"); -}); - -test("headless terminal preserves visible Unicode cells and resize state", async () => { - using screen = new TerminalScreen(12, 3); - await screen.write("权限 ✓"); - screen.resize(16, 4); - - expect(screen.snapshot()).toEqual({ - buffer: "normal", - cols: 16, - cursor: { x: 6, y: 0 }, - rows: 4, - text: "权限 ✓" - }); -}); diff --git a/test/tui/terminal-session.test.ts b/test/tui/terminal-session.test.ts deleted file mode 100644 index 0d6f36fa..00000000 --- a/test/tui/terminal-session.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { expect, test } from "bun:test"; - -import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; -import { TerminalSession } from "./harness/terminal-session.ts"; - -test.skipIf(process.platform === "win32")( - "terminal sessions reject a non-zero fixture exit", - async () => { - await using workspace = await ScenarioWorkspace.create(); - await using session = TerminalSession.start({ - command: [process.execPath, "-e", "process.exit(7)"], - workspace - }); - - await expect(session.exit()).rejects.toThrow("TUI fixture exited with code 7"); - expect(session.journal.format()).toContain('terminal.exit {"exitCode":7,"timedOut":false}'); - } -); - -test.skipIf(process.platform === "win32")( - "terminal sessions report screen wait timeouts with diagnostics", - async () => { - await using workspace = await ScenarioWorkspace.create(); - await using session = TerminalSession.start({ - command: [process.execPath, "-e", "setTimeout(() => {}, 1000)"], - workspace - }); - - await expect(session.waitForScreen("missing prompt", /never appears/u, 10)) - .rejects.toThrow(/Timed out waiting for missing prompt in terminal screen/iu); - } -); - -test.skipIf(process.platform === "win32")( - "terminal sessions report history wait timeouts with diagnostics", - async () => { - await using workspace = await ScenarioWorkspace.create(); - await using session = TerminalSession.start({ - command: [process.execPath, "-e", "setTimeout(() => {}, 1000)"], - workspace - }); - - await expect(session.waitForHistory("missing output", /never appears/u, 0, 10)) - .rejects.toThrow(/Timed out waiting for missing output in terminal history/iu); - } -); - -test.skipIf(process.platform === "win32")( - "terminal sessions kill fixtures that do not exit in time", - async () => { - await using workspace = await ScenarioWorkspace.create(); - await using session = TerminalSession.start({ - command: [process.execPath, "-e", "setTimeout(() => {}, 10000)"], - workspace - }); - - await expect(session.exit()).rejects.toThrow("did not exit within 2000ms"); - expect(session.journal.format()).toContain('timedOut":true'); - }, - 5_000 -); - -test.skipIf(process.platform === "win32")( - "terminal sessions expose snapshots, checkpoints, and resize events", - async () => { - await using workspace = await ScenarioWorkspace.create(); - await using session = TerminalSession.start({ - cols: 20, - rows: 4, - command: [ - process.execPath, - "-e", - "process.stdout.write('ready'); setTimeout(() => process.exit(0), 100)" - ], - workspace - }); - - await session.waitForHistory("fixture output", /ready/u, 0, 1_000); - const checkpoint = session.historyCheckpoint(); - expect(session.historyText(checkpoint)).toBe(""); - - session.resize(30, 6); - await session.settle(); - expect(session.screenSnapshot()).toMatchObject({ - cols: 30, - rows: 6, - text: "ready" - }); - expect(session.journal.entries().at(-1)).toMatchObject({ - channel: "terminal.resize", - detail: { cols: 30, rows: 6 } - }); - - await session.exit(); - } -); diff --git a/test/turn-diff-store.test.ts b/test/turn-diff-store.test.ts deleted file mode 100644 index 8f73fa54..00000000 --- a/test/turn-diff-store.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - MAX_RETAINED_TURN_DIFFS, - TurnDiffStore -} from "../packages/zcode-tui/src/turn-diff-store.ts"; -import { - fileDiffRetentionSize, - MAX_RETAINED_DIFF_CHARACTERS, - MAX_RETAINED_DIFF_FILES, - MAX_RETAINED_DIFF_LINES -} from "../packages/zcode-tui/src/file-diff-budget.ts"; - -const diff = (filePath: string, additions: number) => ({ - filePath, - additions, - deletions: 1, - structuredPatch: [{ lines: ["-old", "+new"] }] -}); - -describe("turn diff store", () => { - test("replaces repeated lifecycle snapshots for the same tool", () => { - const store = new TurnDiffStore(); - store.beginTurn("Update rendering"); - store.upsertTool("call_1", [diff("first.ts", 1)]); - store.upsertTool("call_1", [diff("first.ts", 2)]); - store.upsertTool("call_2", [diff("second.ts", 3)]); - - expect(store.snapshots()[0]).toMatchObject({ - index: 1, - prompt: "Update rendering", - additions: 5, - deletions: 2 - }); - expect(store.snapshots()[0]?.files).toHaveLength(2); - }); - - test("omits turns without file changes", () => { - const store = new TurnDiffStore(); - store.beginTurn("Read only"); - expect(store.snapshots()).toEqual([]); - }); - - test("releases completed empty turns while preserving chronological indexes", () => { - const store = new TurnDiffStore(); - const internal = store as unknown as { - current?: unknown; - turns: unknown[]; - }; - - for (let turn = 0; turn < 1_000; turn += 1) { - store.beginTurn(`Read only ${turn}`); - store.finishTurn(); - } - expect(internal.turns).toHaveLength(0); - expect(internal.current).toBeUndefined(); - - store.beginTurn("Mutation after read-only turns"); - store.upsertTool("call_mutation", [diff("changed.ts", 2)]); - store.finishTurn(); - expect(store.snapshots()[0]).toMatchObject({ - index: 1_001, - prompt: "Mutation after read-only turns" - }); - - store.clear(); - store.beginTurn("First after clear"); - store.upsertTool("call_after_clear", [diff("reset.ts", 1)]); - expect(store.snapshots()[0]?.index).toBe(1); - }); - - test("retains only the newest completed mutation turns", () => { - const store = new TurnDiffStore(); - for (let turn = 1; turn <= 100; turn += 1) { - store.beginTurn(`Mutation ${turn}`); - store.upsertTool(`call_${turn}`, [diff(`file_${turn}.ts`, turn)]); - store.finishTurn(); - } - - const snapshots = store.snapshots(); - expect(snapshots).toHaveLength(MAX_RETAINED_TURN_DIFFS); - expect(snapshots.map((snapshot) => snapshot.index)).toEqual( - Array.from({ length: MAX_RETAINED_TURN_DIFFS }, (_, index) => 81 + index) - ); - expect(snapshots[0]?.prompt).toBe("Mutation 81"); - expect(snapshots.at(-1)?.prompt).toBe("Mutation 100"); - }); - - test("shares one retained budget across every tool in the active turn", () => { - const store = new TurnDiffStore(); - store.beginTurn("Many large mutations"); - for (let tool = 0; tool < 5; tool += 1) { - store.upsertTool(`call_${tool}`, [{ - filePath: `file_${tool}.ts`, - additions: 1_000, - deletions: 0, - structuredPatch: [{ - lines: Array.from({ length: 1_000 }, (_, line) => `+${tool}:${line}:${"x".repeat(180)}`) - }] - }]); - } - - const snapshot = store.snapshots()[0]!; - const size = fileDiffRetentionSize(snapshot.files); - expect(size.files).toBeLessThanOrEqual(MAX_RETAINED_DIFF_FILES); - expect(size.lines).toBeLessThanOrEqual(MAX_RETAINED_DIFF_LINES); - expect(size.characters).toBeLessThanOrEqual(MAX_RETAINED_DIFF_CHARACTERS); - expect(snapshot.files.some((file) => file.filePath === "file_4.ts")).toBeTrue(); - expect(snapshot.files.some((file) => file.truncated)).toBeTrue(); - }); -}); diff --git a/test/turn-presentation-registry.test.ts b/test/turn-presentation-registry.test.ts deleted file mode 100644 index a73205c6..00000000 --- a/test/turn-presentation-registry.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { Transcript } from "../packages/zcode-tui/src/transcript.ts"; -import { ProtocolPartView } from "../packages/zcode-tui/src/protocol-part-view.ts"; -import { ThinkingView } from "../packages/zcode-tui/src/thinking-view.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; -import { ToolTreeView } from "../packages/zcode-tui/src/tool-tree-view.ts"; -import { ToolExecutionView } from "../packages/zcode-tui/src/tool-view.ts"; -import { TurnPresentationRegistry } from "../packages/zcode-tui/src/turn-presentation-registry.ts"; - -interface ToolState { - tree: ToolTreeView; -} - -function tool(name: string): ToolTreeView { - const theme = createTheme(false); - return new ToolTreeView(theme, new ToolExecutionView(theme, { name, state: "complete" })); -} - -function populate( - registry: TurnPresentationRegistry, - index: number -): void { - const id = `part_${index}`; - const theme = createTheme(false); - registry.thinkingParts.set(id, new ThinkingView(theme)); - registry.protocolPartViews.set(id, new ProtocolPartView(theme, { - type: "file", - text: `file ${index}` - })); - registry.protocolPartKinds.set(id, "tool"); - registry.protocolPartMessages.set(id, `message_${index}`); - registry.protocolPartTools.set(id, `tool_${index}`); - registry.toolViews.set(`tool_${index}`, { tree: tool("Read") }); - registry.pendingToolParents.set(`child_${index}`, `tool_${index}`); - registry.pendingToolProgress.set(`tool_${index}`, { elapsedMs: index }); -} - -describe("turn presentation registry", () => { - test("retains indexes for only the latest turn", () => { - const unbounded = new TurnPresentationRegistry(); - const registry = new TurnPresentationRegistry(); - for (let turn = 1; turn <= 1_000; turn += 1) { - populate(unbounded, turn); - registry.beginTurn(); - populate(registry, turn); - } - - expect(unbounded.sizes().total).toBe(8_000); - expect(registry.sizes()).toEqual({ - thinkingParts: 1, - protocolPartViews: 1, - protocolPartKinds: 1, - protocolPartMessages: 1, - protocolPartTools: 1, - toolViews: 1, - pendingToolParents: 1, - pendingToolProgress: 1, - total: 8 - }); - }); - - test("releases lookup roots without mutating settled transcript trees", () => { - const registry = new TurnPresentationRegistry(); - const parent = tool("Agent"); - const child = tool("Read"); - parent.addChild(child); - parent.setExpanded(true); - const transcript = new Transcript(); - transcript.addBlock(parent, { kind: "tool", searchText: () => parent.getSearchText() }); - transcript.setExpanded(true); - registry.toolViews.set("parent", { tree: parent }); - registry.toolViews.set("child", { tree: child }); - - const before = transcript.render(80); - expect(before.join("\n")).toContain("Read"); - registry.beginTurn(); - - expect(registry.sizes().total).toBe(0); - expect(transcript.render(80)).toEqual(before); - expect(transcript.searchFor("Read")).toMatchObject({ current: 1, total: 1 }); - }); -}); diff --git a/test/turn-status.test.ts b/test/turn-status.test.ts deleted file mode 100644 index 80cb3762..00000000 --- a/test/turn-status.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { - TURN_TIMER_FRAME_DURATION_MS, - formatElapsed, - turnStatusText, - turnTimerAnimationEnabled, - turnTimerFrame -} from "../packages/zcode-tui/src/turn-status.ts"; - -describe("TUI turn status", () => { - test("formats elapsed time compactly", () => { - expect(formatElapsed(0)).toBe("0s"); - expect(formatElapsed(59_999)).toBe("59s"); - expect(formatElapsed(60_000)).toBe("1m 00s"); - expect(formatElapsed(185_000)).toBe("3m 05s"); - expect(formatElapsed(3_661_000)).toBe("1h 01m 01s"); - }); - - test("keeps elapsed time visible with or without turn activity", () => { - expect(turnStatusText(undefined, 0)).toBe("[ 🕛 0s ]"); - expect(turnStatusText("thinking…", 3_000)).toBe("thinking… ── [ 🕛 3s ]"); - }); - - test("marks a settled foreground turn as completed instead of frozen", () => { - expect(turnStatusText(undefined, 364_000, true, false, true)).toBe("[ ✓ 6m 04s ]"); - }); - - test("animates the active timer with complete stable-width clock frames", () => { - const frames = Array.from( - { length: 12 }, - (_, index) => turnTimerFrame(index * TURN_TIMER_FRAME_DURATION_MS, true) - ); - expect(TURN_TIMER_FRAME_DURATION_MS).toBe(1_000); - expect(frames).toEqual(["🕛", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚"]); - expect(frames.map(visibleWidth)).toEqual(Array(12).fill(2)); - expect(turnTimerFrame(12_000, true)).toBe("🕛"); - expect(turnStatusText("thinking…", 4_000, true, true)).toBe("thinking… ── [ 🕓 4s ]"); - }); - - test("provides an explicit reduced-motion and basic-terminal fallback", () => { - expect(turnTimerAnimationEnabled({ TERM: "xterm-256color" })).toBeTrue(); - expect(turnTimerAnimationEnabled({ TERM: "dumb" })).toBeFalse(); - expect(turnTimerAnimationEnabled({ ZCODE_TUI_REDUCED_MOTION: "1" })).toBeFalse(); - expect(turnTimerFrame(500, false)).toBe("🕛"); - }); - - test("hides idle timing without suppressing out-of-turn activity", () => { - expect(turnStatusText(undefined, 0, false)).toBeUndefined(); - expect(turnStatusText("Esc again to rewind conversation", 0, false)).toBe("Esc again to rewind conversation"); - }); -}); diff --git a/test/turn-work-tracker.test.ts b/test/turn-work-tracker.test.ts deleted file mode 100644 index 8c9d5037..00000000 --- a/test/turn-work-tracker.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { StreamEvent } from "../packages/zcode-tui/src/events.ts"; -import type { - RuntimeBackgroundJob, - RuntimeProjectionSnapshot -} from "../packages/zcode-tui/src/runtime-projection.ts"; -import { TurnWorkTracker } from "../packages/zcode-tui/src/turn-work-tracker.ts"; - -function event(value: Partial): StreamEvent { - return { raw: {}, ...value }; -} - -function job( - taskId: string, - status: RuntimeBackgroundJob["status"], - turnId = "turn-current" -): RuntimeBackgroundJob { - return { taskId, taskKind: "local_agent", status, turnId }; -} - -function projection(overrides: Partial = {}): RuntimeProjectionSnapshot { - return { - activeToolCalls: [], - backgroundJobs: [], - ...overrides - }; -} - -describe("turn work tracker", () => { - test("keeps timing until every associated background task settles", () => { - const tracker = new TurnWorkTracker(); - tracker.begin(); - tracker.bindTurn("turn-current"); - tracker.handle(event({ type: "background_task_started", taskId: "task-a", turnId: "turn-current" })); - tracker.handle(event({ type: "background_task_started", taskId: "task-b", turnId: "turn-current" })); - - expect(tracker.finishForeground(true)).toBeTrue(); - expect(tracker.reconcile(projection({ backgroundJobs: [job("task-a", "running"), job("task-b", "running")] }))).toBeTrue(); - expect(tracker.handle(event({ - type: "background_task_completed", - taskId: "task-a", - taskStatus: "failed" - }))).toBeTrue(); - expect(tracker.handle(event({ - type: "background_task_completed", - taskId: "task-b", - taskStatus: "completed" - }))).toBeFalse(); - }); - - test("treats failure terminal statuses as settled and ignores older running jobs", () => { - for (const status of ["failed", "timed_out", "cancelled", "killed", "spawn_error", "lost"] as const) { - const tracker = new TurnWorkTracker(); - tracker.begin(); - tracker.bindTurn("turn-current"); - tracker.handle(event({ type: "subagent_spawned", agentId: "agent-1" })); - tracker.finishForeground(true); - - expect(tracker.reconcile(projection({ - backgroundJobs: [ - job("agent-1", status), - job("older-task", "running", "turn-older") - ] - }))).toBeFalse(); - } - }); - - test("waits for the post-foreground projection before settling an inline-only turn", () => { - const tracker = new TurnWorkTracker(); - tracker.begin(); - tracker.bindTurn("turn-current"); - - expect(tracker.finishForeground(true)).toBeTrue(); - expect(tracker.reconcile(projection())).toBeFalse(); - }); - - test("settles a failed admission without waiting for an unreadable projection", () => { - const tracker = new TurnWorkTracker(); - tracker.begin(); - expect(tracker.finishForeground(false)).toBeFalse(); - expect(tracker.isActive()).toBeFalse(); - }); - - test("resets task ownership when a newer foreground turn begins", () => { - const tracker = new TurnWorkTracker(); - tracker.begin(); - tracker.bindTurn("turn-old"); - tracker.handle(event({ type: "background_task_started", taskId: "old-task", turnId: "turn-old" })); - tracker.finishForeground(true); - - tracker.begin(); - tracker.bindTurn("turn-current"); - expect(tracker.reconcile(projection({ backgroundJobs: [job("old-task", "running", "turn-old")] }))).toBeTrue(); - expect(tracker.finishForeground(true)).toBeTrue(); - expect(tracker.reconcile(projection({ backgroundJobs: [job("old-task", "running", "turn-old")] }))).toBeFalse(); - }); - - test("keeps timing while an active foreground tool remains in projection", () => { - const tracker = new TurnWorkTracker(); - tracker.begin(); - tracker.bindTurn("turn-current"); - expect(tracker.finishForeground(true)).toBeTrue(); - expect(tracker.reconcile(projection({ - activeToolCalls: [{ toolCallId: "webfetch-1", toolName: "WebFetch", status: "running" }] - }))).toBeTrue(); - expect(tracker.reconcile(projection())).toBeFalse(); - }); - - test("keeps timing while the current runtime turn is still present", () => { - const tracker = new TurnWorkTracker(); - tracker.begin(); - tracker.bindTurn("turn-current"); - expect(tracker.finishForeground(true)).toBeTrue(); - expect(tracker.reconcile(projection({ currentTurnId: "turn-current" }))).toBeTrue(); - expect(tracker.reconcile(projection())).toBeFalse(); - }); - - test("does not keep timing for unrelated current turns", () => { - const tracker = new TurnWorkTracker(); - tracker.begin(); - tracker.bindTurn("turn-current"); - expect(tracker.finishForeground(true)).toBeTrue(); - expect(tracker.reconcile(projection({ currentTurnId: "turn-newer" }))).toBeFalse(); - }); - - test("drops all retained work when the user cancels the turn", () => { - const tracker = new TurnWorkTracker(); - tracker.begin(); - tracker.bindTurn("turn-current"); - tracker.handle(event({ type: "background_task_started", taskId: "task-a", turnId: "turn-current" })); - expect(tracker.finishForeground(true)).toBeTrue(); - expect(tracker.reconcile(projection({ - activeToolCalls: [{ toolCallId: "tool-a", toolName: "Bash", status: "running" }], - backgroundJobs: [job("task-a", "running")], - currentTurnId: "turn-current" - }))).toBeTrue(); - - tracker.cancel(); - expect(tracker.isActive()).toBeFalse(); - expect(tracker.ownsTask("task-a")).toBeFalse(); - }); -}); diff --git a/test/update-check.test.ts b/test/update-check.test.ts deleted file mode 100644 index da0c2cf0..00000000 --- a/test/update-check.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; - -import { UpdateAvailableView, releaseNotesUrl, updateCommand } from "../packages/zcode-tui/src/update-available-view.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; -import { - availableUpdateVersion, - readStartupUpdate, - refreshUpdateCache, - UPDATE_CACHE_TTL_MS, - UPDATE_CHECK_URL, - updateCachePath, - updateCheckDisabled -} from "../src/update-check.ts"; - -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((directory) => ( - rm(directory, { recursive: true, force: true }) - ))); -}); - -async function temporaryCachePath(): Promise { - const directory = await mkdtemp(join(tmpdir(), "zcode-update-check-")); - temporaryDirectories.push(directory); - return join(directory, ".zcode", "cli", "version.json"); -} - -async function writeCache( - cachePath: string, - latestVersion: string, - lastCheckedAt: number, - checkedVersion?: string -): Promise { - await mkdir(dirname(cachePath), { recursive: true }); - await writeFile(cachePath, `${JSON.stringify({ - ...(checkedVersion ? { checkedVersion } : {}), - latestVersion, - lastCheckedAt: new Date(lastCheckedAt).toISOString() - })}\n`); -} - -describe("startup update check", () => { - test("uses the cross-platform config directory and respects standard opt-outs", () => { - expect(updateCachePath({ HOME: "/home/alice" }, "linux", "/fallback")).toBe( - "/home/alice/.zcode/cli/version.json" - ); - expect(updateCachePath({ USERPROFILE: "C:\\Users\\Alice" }, "win32", "C:\\fallback")).toBe( - "C:\\Users\\Alice\\.zcode\\cli\\version.json" - ); - expect(updateCheckDisabled({ CI: "true" })).toBe(true); - expect(updateCheckDisabled({ NO_UPDATE_NOTIFIER: "1" })).toBe(true); - expect(updateCheckDisabled({ ZCODE_DISABLE_UPDATE_CHECK: "yes" })).toBe(true); - expect(updateCheckDisabled({ CI: "0", ZCODE_DISABLE_UPDATE_CHECK: "false" })).toBe(false); - }); - - test("shows a cached newer build without refreshing a fresh cache", async () => { - const cachePath = await temporaryCachePath(); - const now = Date.parse("2026-07-14T12:00:00.000Z"); - await writeCache(cachePath, "3.3.5-2", now - 60_000, "3.3.5-1"); - - await expect(readStartupUpdate({ - cachePath, - currentVersion: "3.3.5-1", - env: {}, - now - })).resolves.toEqual({ - availableVersion: "3.3.5-2", - cachePath, - refreshRequired: false - }); - }); - - test("refreshes a fresh cache when the installed package version changes", async () => { - const cachePath = await temporaryCachePath(); - const now = Date.parse("2026-07-14T12:00:00.000Z"); - await writeCache(cachePath, "3.3.5-1", now - 60_000, "3.3.5-1"); - - await expect(readStartupUpdate({ - cachePath, - currentVersion: "3.3.5-2", - env: {}, - now - })).resolves.toEqual({ - availableVersion: undefined, - cachePath, - refreshRequired: true - }); - - expect(availableUpdateVersion("3.3.5-2", "3.3.5-3")).toBe("3.3.5-3"); - expect(availableUpdateVersion("3.3.5-2", "3.3.5-2")).toBeUndefined(); - expect(availableUpdateVersion("invalid", "3.3.5-3")).toBeUndefined(); - }); - - test("refreshes a legacy cache without checked package metadata once", async () => { - const cachePath = await temporaryCachePath(); - const now = Date.parse("2026-07-14T12:00:00.000Z"); - await writeCache(cachePath, "3.3.5-1", now - 60_000); - - await expect(readStartupUpdate({ - cachePath, - currentVersion: "3.3.5-1", - env: {}, - now - })).resolves.toMatchObject({ refreshRequired: true }); - }); - - test("keeps a stale update visible while scheduling a background refresh", async () => { - const cachePath = await temporaryCachePath(); - const now = Date.parse("2026-07-14T12:00:00.000Z"); - await writeCache(cachePath, "3.4.0-1", now - UPDATE_CACHE_TTL_MS - 1, "3.3.5-99"); - - await expect(readStartupUpdate({ - cachePath, - currentVersion: "3.3.5-99", - env: {}, - now - })).resolves.toEqual({ - availableVersion: "3.4.0-1", - cachePath, - refreshRequired: true - }); - await expect(readStartupUpdate({ - cachePath, - currentVersion: "invalid", - env: {}, - now - })).resolves.toBeUndefined(); - }); - - test("refreshes the npm latest version into an atomic cache", async () => { - const cachePath = await temporaryCachePath(); - const now = Date.parse("2026-07-14T12:00:00.000Z"); - let requestedUrl = ""; - - await expect(refreshUpdateCache({ - cachePath, - currentVersion: "3.3.5-1", - fetcher: async (url, init) => { - requestedUrl = url; - expect(new Headers(init.headers).get("user-agent")).toBe("zcode-app-cli/3.3.5-1"); - expect(init.signal).toBeInstanceOf(AbortSignal); - return new Response(JSON.stringify({ version: "3.3.5-2" }), { - headers: { "content-type": "application/json" }, - status: 200 - }); - }, - now - })).resolves.toBe("3.3.5-2"); - - expect(requestedUrl).toBe(UPDATE_CHECK_URL); - expect(JSON.parse(await readFile(cachePath, "utf8"))).toEqual({ - checkedVersion: "3.3.5-1", - latestVersion: "3.3.5-2", - lastCheckedAt: "2026-07-14T12:00:00.000Z" - }); - expect(await readStartupUpdate({ - cachePath, - currentVersion: "3.3.5-1", - env: {}, - now - })).toMatchObject({ availableVersion: "3.3.5-2", refreshRequired: false }); - }); - - test("rejects registry errors without replacing the existing cache", async () => { - const cachePath = await temporaryCachePath(); - const now = Date.parse("2026-07-14T12:00:00.000Z"); - await writeCache(cachePath, "3.3.5-2", now - UPDATE_CACHE_TTL_MS - 1, "3.3.5-1"); - const before = await readFile(cachePath, "utf8"); - - await expect(refreshUpdateCache({ - cachePath, - currentVersion: "3.3.5-1", - fetcher: async () => new Response("unavailable", { status: 503 }), - now - })).rejects.toThrow("HTTP 503"); - expect(await readFile(cachePath, "utf8")).toBe(before); - }); -}); - -describe("update available view", () => { - test("renders the version transition, install command and release notes", () => { - const output = new UpdateAvailableView(createTheme(false), "3.3.5-1", "3.3.5-2") - .render(90) - .join("\n"); - - expect(output).toContain("✨ Update available! 3.3.5-1 → 3.3.5-2"); - expect(output).toContain(updateCommand); - expect(output).toContain(releaseNotesUrl); - }); - - test("keeps the routine update notice free of a full-width background", () => { - const output = new UpdateAvailableView(createTheme(true, "light"), "3.3.5-1", "3.3.5-2") - .render(90) - .join("\n"); - expect(output).not.toContain("\x1b[48;5;"); - }); -}); diff --git a/test/verify-tui-perf.test.ts b/test/verify-tui-perf.test.ts deleted file mode 100644 index 904d7521..00000000 --- a/test/verify-tui-perf.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - evaluateTuiPerfGate, - TUI_PERF_LIMITS -} from "../scripts/verify-tui-perf.ts"; - -function passingStreamProfile(): unknown { - return { - profile: "tui-performance-gate", - median: { - ingestCpuMs: TUI_PERF_LIMITS.coreIngestCpuMs, - ingestWallMs: TUI_PERF_LIMITS.coreIngestWallMs, - renderWallMs: TUI_PERF_LIMITS.coreRenderWallMs - }, - activeToolText: { - median: { - appendCpuMs: TUI_PERF_LIMITS.activeToolAppendCpuMs, - appendWallMs: TUI_PERF_LIMITS.activeToolAppendWallMs, - materializeWallMs: TUI_PERF_LIMITS.activeToolMaterializeWallMs, - outputCharacters: TUI_PERF_LIMITS.activeToolOutputCharacters, - retainedCharacters: TUI_PERF_LIMITS.activeToolRetainedCharacters - } - }, - frames: Array.from({ length: TUI_PERF_LIMITS.minimumFrameProfiles }, (_, index) => ({ - name: `frame-${index}`, - p95Ms: TUI_PERF_LIMITS.commonFrameP95Ms - })) - }; -} - -function passingMemoryProfile(): unknown { - return { - mode: "bounded-transcript", - input: { - blocks: 10_000, - sourceCharacters: 10_000_000 - }, - retained: { - blocks: TUI_PERF_LIMITS.boundedTranscriptRetainedBlocks, - discardedBlocks: 9_520, - historyCharacters: TUI_PERF_LIMITS.boundedTranscriptHistoryCharacters - }, - memory: { - heapDeltaBytes: TUI_PERF_LIMITS.boundedTranscriptHeapDeltaBytes, - rssBytes: TUI_PERF_LIMITS.boundedTranscriptRssBytes - } - }; -} - -describe("TUI performance verifier", () => { - test("accepts every hard limit at its inclusive boundary", () => { - const result = evaluateTuiPerfGate(passingStreamProfile(), passingMemoryProfile()); - - expect(result.failures).toEqual([]); - expect(result.summary.frameCount).toBe(TUI_PERF_LIMITS.minimumFrameProfiles); - }); - - test("reports p95, CPU, RSS, and coverage failures together", () => { - const stream = passingStreamProfile() as { - frames: Array<{ name: string; p95Ms: number }>; - median: { ingestCpuMs: number }; - }; - stream.median.ingestCpuMs += 0.001; - stream.frames[0]!.p95Ms += 0.001; - stream.frames.length = TUI_PERF_LIMITS.minimumFrameProfiles - 1; - const memory = passingMemoryProfile() as { memory: { rssBytes: number } }; - memory.memory.rssBytes += 1; - - const failures = evaluateTuiPerfGate(stream, memory).failures.join("\n"); - - expect(failures).toContain("core ingest CPU ms"); - expect(failures).toContain("frame-0 p95 ms"); - expect(failures).toContain("frame profile count"); - expect(failures).toContain("bounded Transcript RSS bytes"); - }); - - test("rejects missing metrics and duplicate frame names", () => { - const stream = passingStreamProfile() as { - activeToolText: { median: Record }; - frames: Array<{ name: string; p95Ms: number }>; - }; - delete stream.activeToolText.median.appendCpuMs; - stream.frames[1]!.name = stream.frames[0]!.name; - - const failures = evaluateTuiPerfGate(stream, {}).failures.join("\n"); - - expect(failures).toContain("appendCpuMs is missing"); - expect(failures).toContain("is duplicated"); - expect(failures).toContain("mode is missing"); - expect(failures).toContain("rssBytes is missing"); - }); -}); diff --git a/test/welcome-banner.test.ts b/test/welcome-banner.test.ts deleted file mode 100644 index 9a9a4e65..00000000 --- a/test/welcome-banner.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; - -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; -import { - BRAND_MARK, - BRAND_MARK_WIDTH, - Divider, - WelcomeBanner, - WIDE_BANNER_MIN_WIDTH -} from "../packages/zcode-tui/src/welcome-banner.ts"; - -function banner(width: number, color = false): string[] { - return new WelcomeBanner(createTheme(color), { - branch: "main", - distributionVersion: "3.3.5-2", - runtimeVersion: "0.15.2", - workspace: "/home/alice/work/zcode-cli" - }).render(width); -} - -describe("welcome banner", () => { - test("uses a four-line split Z mark inspired by the Desktop icon", () => { - expect(BRAND_MARK).toHaveLength(4); - for (const line of BRAND_MARK) { - expect(visibleWidth(line)).toBe(BRAND_MARK_WIDTH); - expect(line).toMatch(/^[\u0020\u2580-\u259f]+$/u); - } - }); - - test("integrates identity, versions and workspace into the wide header", () => { - for (const width of [48, 80, 120]) { - const lines = banner(width); - const output = lines.join("\n"); - - expect(lines).toHaveLength(4); - expect(output).toContain("SYSTEM INITIATED"); - expect(output).toContain("ZCODE v3.3.5-2"); - expect(output).toMatch(/(?:runtime|rt) v0\.15\.2/u); - expect(output).toContain("zcode-cli"); - expect(output).toContain("branch main"); - expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true); - } - }); - - test("does not change layouts for fixture-looking workspace paths", () => { - for (const workspace of ["/Users/alice/Documents/code/ai/zcode-cli", "/tmp/project"]) { - const output = new WelcomeBanner(createTheme(false), { - branch: "main", - distributionVersion: "3.3.5-2", - runtimeVersion: "0.15.2", - workspace - }).render(80).join("\n"); - - expect(output).toContain("SYSTEM INITIATED"); - expect(output).toContain("runtime v0.15.2"); - expect(output).toContain("branch main"); - } - }); - - test("switches to a compact two-line identity below the wide breakpoint", () => { - const lines = banner(WIDE_BANNER_MIN_WIDTH - 1); - - expect(lines).toHaveLength(2); - expect(lines[0]).toContain("ZCODE v3.3.5-2"); - expect(lines[1]).toContain("zcode-cli · branch main"); - }); - - test("never wraps at tiny or wide terminal widths", () => { - for (const width of [1, 8, 20, 47, 48, 80, 120]) { - const lines = banner(width); - expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true); - } - }); - - test("uses theme tokens and strips untrusted terminal controls", () => { - const view = new WelcomeBanner(createTheme(true, "light"), { - branch: "main\nsecondary\u001b[2J", - distributionVersion: "3.3.5-2\u001b]0;bad\u0007", - runtimeVersion: "0.15.2", - workspace: "/tmp/project\u001b[H" - }); - const output = view.render(80).join("\n"); - - expect(output).toContain("\x1b[38;5;25m"); - expect(output).not.toContain("\x1b[2J"); - expect(output).not.toContain("\x1b]0;bad"); - expect(output).not.toContain("\x1b[H"); - expect(output).toContain("branch main secondary"); - }); -}); - -describe("Divider component", () => { - test("renders a single padded line spanning the terminal width", () => { - const muted = (text: string): string => `\x1b[2m${text}\x1b[0m`; - const divider = new Divider("─", muted); - const lines = divider.render(40); - expect(lines).toHaveLength(1); - expect(visibleWidth(lines[0] ?? "")).toBe(40); - expect(lines[0]).toContain("───◆"); - }); - - test("renders an empty line for non-positive widths", () => { - const divider = new Divider("─", (text) => text); - expect(divider.render(0)).toEqual([""]); - }); -}); diff --git a/test/work-duration-view.test.ts b/test/work-duration-view.test.ts deleted file mode 100644 index 48db6193..00000000 --- a/test/work-duration-view.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { workedDurationLabel, WorkDurationView } from "../packages/zcode-tui/src/work-duration-view.ts"; -import { createTheme } from "../packages/zcode-tui/src/theme.ts"; - -describe("work duration view", () => { - test("uses the Codex threshold and compact duration format", () => { - expect(workedDurationLabel(60_000)).toBeUndefined(); - expect(workedDurationLabel(61_000)).toBe("Worked for 1m 01s"); - expect(workedDurationLabel(3_661_000)).toBe("Worked for 1h 01m 01s"); - }); - - test("renders a width-safe settled-work divider", () => { - const view = new WorkDurationView(125_000, createTheme(false)); - const lines = view.render(32); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain("Worked for 2m 05s"); - expect(lines[0]!.length).toBeLessThanOrEqual(32); - }); -}); diff --git a/test/workflow-management.test.ts b/test/workflow-management.test.ts deleted file mode 100644 index 8c53e549..00000000 --- a/test/workflow-management.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { expect, test } from "bun:test"; -import type { RuntimeAdapter } from "../packages/zcode-tui/src/types.ts"; -import { - promptWorkflowConcurrency, requestWorkflowApproval, retuneWorkflow, saveWorkflow, showSavedWorkflows, - workflowAdjustableSettings, workflowSettingsSummary, type WorkflowDialogs -} from "../packages/zcode-tui/src/workflow-management.ts"; -import { workflowRunDetail } from "../packages/zcode-tui/src/dynamic-workflows.ts"; -import { createTuiWorkflowManagement, startTuiSavedWorkflow } from "../src/runtime-workflow-bridge.ts"; -import { patchRuntimeWorkflowManagement } from "../scripts/runtime-workflow-patches.ts"; - -function dialogs(choices: (string | null)[], prompts: (string | null)[] = []) { - const notices: string[] = []; - const menus: string[][] = []; - const controls: WorkflowDialogs = { - choose: async options => { - menus.push(options.items.map(item => item.value)); - const value = choices.shift(); - if (value === null) return null; - const selected = options.items.find(item => item.value === value); - if (!selected) throw new Error(`Unexpected choice ${value} in ${options.title}`); - return selected; - }, - prompt: async () => { - if (!prompts.length) throw new Error("Unexpected input prompt"); - return prompts.shift()!; - }, - notice: message => { notices.push(message); }, - models: async () => [{ id: "provider/model" }, { id: "disabled/model", disabledReason: "unavailable" }], - isCurrent: () => true - }; - return { controls, notices, menus }; -} - -const request = { toolName: "CreateWorkflow", input: { - adjustable_settings: { subagent_model: true, concurrency_ceiling: 4 }, - model_bindings: { writer: "other/pinned" }, max_concurrency: 3 -} }; - -test("workflow approval applies only explicit adjustments and preserves per-agent model bindings", async () => { - const { controls, menus } = dialogs(["concurrency", "model", "provider/model", "allow"], ["0", "2"]); - expect(await requestWorkflowApproval(request, controls)).toEqual({ decision: "allow", inputAdjustments: { max_concurrency: 2, subagent_model: "provider/model" } }); - expect(menus.flat()).not.toContain("disabled/model"); - expect(workflowSettingsSummary(request.input)).toContain("writer → other/pinned"); - expect(request.input.max_concurrency).toBe(3); -}); - -test("denying or cancelling a workflow discards edited settings without persistent grants", async () => { - const changed = dialogs(["concurrency", "deny"], ["2"]); - expect(await requestWorkflowApproval(request, changed.controls)).toEqual({ decision: "deny", reason: "Workflow approval cancelled" }); - expect(await requestWorkflowApproval(request, dialogs(["allow"]).controls)).toEqual({ decision: "allow" }); - expect(await requestWorkflowApproval(request, dialogs([null]).controls)).toMatchObject({ decision: "deny" }); - expect(changed.menus.flat()).not.toContain("allow_project"); -}); - -test("workflow approval uses null for reset, hides unsupported model controls and respects cancellation", async () => { - expect(await requestWorkflowApproval(request, dialogs(["concurrency", "model", "default", "allow"], ["default"]).controls)) - .toEqual({ decision: "allow", inputAdjustments: { max_concurrency: null, subagent_model: null } }); - const unsupported = dialogs(["allow"]); - await requestWorkflowApproval({ ...request, input: { adjustable_settings: { subagent_model: false } } }, unsupported.controls); - expect(unsupported.menus[0]).not.toContain("model"); - const controller = new AbortController(); - controller.abort(); - expect(await requestWorkflowApproval(request, dialogs([]).controls, undefined, controller.signal)).toMatchObject({ decision: "deny" }); - expect(workflowAdjustableSettings("Bash", request.input)).toBeUndefined(); - expect(workflowAdjustableSettings("CreateWorkflow", {})).toBeUndefined(); -}); - -test("concurrency input rejects fractions, exponent notation and unsafe integers", async () => { - const state = dialogs([], ["1.5", "1e3", "9007199254740992", "3"]); - expect(await promptWorkflowConcurrency(state.controls)).toBe(3); - expect(state.notices).toHaveLength(3); -}); - -test("retuning calls the live adapter only after explicit consent and discards stale-session actions", async () => { - const calls: unknown[] = []; - const adapter = { retuneWorkflowConcurrency: async input => { calls.push(input); return { ok: true }; } } satisfies Partial; - const state = dialogs(["apply"], ["2"]); - await retuneWorkflow("run", adapter as RuntimeAdapter, state.controls); - expect(calls).toEqual([{ runId: "run", maxConcurrency: 2 }]); - await retuneWorkflow("run", adapter as RuntimeAdapter, dialogs(["cancel"], ["4"]).controls); - const stale = dialogs([], []); - stale.controls.isCurrent = () => false; - await retuneWorkflow("run", adapter as RuntimeAdapter, stale.controls); - expect(calls).toHaveLength(1); -}); - -test("saving requires overwrite consent and leaves existing definitions intact on cancel", async () => { - const calls: unknown[] = []; - const adapter = { saveWorkflowRun: async input => { calls.push(input); return input.overwrite ? { ok: true } : { ok: false, reason: "target_exists" }; } } satisfies Partial; - await saveWorkflow({ runId: "run" }, adapter as RuntimeAdapter, dialogs(["project", "cancel"], ["review", "Review code"]).controls); - expect(calls).toHaveLength(1); - await saveWorkflow({ runId: "run" }, adapter as RuntimeAdapter, dialogs(["global", "overwrite"], ["review", "Review code"]).controls); - expect(calls.at(-1)).toEqual({ runId: "run", name: "review", scope: "global", description: "Review code", overwrite: true }); - await saveWorkflow({ runId: "run" }, adapter as RuntimeAdapter, dialogs([], ["../bad"]).controls); - expect(calls).toHaveLength(3); -}); - -test("saved workflow startup preserves scope and JSON argument types and never replays progress", async () => { - const started: unknown[] = []; - const adapter = { - submitPrompt: async () => ({}), - listSavedWorkflows: async () => ({ workflows: [{ name: "review", scope: "global" }] }), - readSavedWorkflow: async () => ({ ok: true, meta: { args: { count: { type: "number", required: true } } } }), - startSavedWorkflow: async input => { started.push(input); return { ok: true, runId: "new-run" }; } - } satisfies Partial; - await showSavedWorkflows(adapter as RuntimeAdapter, dialogs(["0", "run"], ["[]", '{"count":2,"dry":true}']).controls); - expect(started).toEqual([{ name: "review", scope: "global", args: { count: 2, dry: true } }]); - await showSavedWorkflows(adapter as RuntimeAdapter, dialogs(["0", "cancel"], ["{}"]).controls); - expect(started).toHaveLength(1); -}); - -test("workflow details show native concurrency and explicit or inherited actor models", () => { - const detail = workflowRunDetail({ runId: "run", status: "running", concurrency: { cap: 3, limit: 2, ceiling: 4 }, subagentModel: "provider/default", - actors: [{ name: "writer", model: "other/pinned" }, { name: "reviewer" }] }); - expect(detail).toContain("3 shared · 2 run limit"); - expect(detail).toContain("writer · other/pinned"); - expect(detail).toContain("reviewer · provider/default"); -}); - -test("native workflow management preserves ownership and never replaces terminal runs", async () => { - const retunes: unknown[] = [], saves: unknown[] = []; - let task = { parentSessionId: "session", runStatus: "running" }; - const management = createTuiWorkflowManagement({ sessionId: "session", cwd: "/workspace", sessionStore: "store", - port: { getTask: async () => task, retuneConcurrency: input => { retunes.push(input); return { ok: true }; } }, - native: { list: async params => ({ workflows: [params.scope] }), read: async params => params, - save: async (context, params) => { saves.push({ context, params }); return { ok: true }; } } }); - expect(await management.retuneWorkflowConcurrency({ runId: "run", maxConcurrency: 2 })).toEqual({ ok: true }); - task.runStatus = "completed"; - expect(await management.retuneWorkflowConcurrency({ runId: "run", maxConcurrency: 3 })).toMatchObject({ reason: "not_live" }); - task.parentSessionId = "other"; - expect(await management.saveWorkflowRun({ runId: "run", name: "review", scope: "project", description: "Review" })).toMatchObject({ reason: "not_found" }); - expect(retunes).toHaveLength(1); - expect(saves).toHaveLength(0); - expect(await management.listSavedWorkflows()).toEqual({ workflows: ["project", "global"], invalid: [] }); - await expect(management.retuneWorkflowConcurrency({ runId: "run", maxConcurrency: 1.5 })).rejects.toThrow("positive integer"); - await expect(management.readSavedWorkflow({ name: "review", scope: "invalid" as "project" })).rejects.toThrow("scope"); -}); - -test("workflow patch fails closed when native management anchors are absent", () => { - expect(() => patchRuntimeWorkflowManagement("unsupported")).toThrow("incompatible"); - const patched = "createTuiWorkflowManagement({"; - expect(patchRuntimeWorkflowManagement(patched)).toBe(patched); -}); - -test("saved workflow startup respects the native workflow mode gate", async () => { - const calls: unknown[] = []; - const app = { runtime: { config: { dynamicWorkflowEnabled: false } }, startSavedWorkflow: async (input: unknown) => { calls.push(input); return { ok: true }; } }; - await expect(startTuiSavedWorkflow(app, { name: "review", scope: "project" })).resolves.toMatchObject({ reason: "workflow_disabled" }); - expect(calls).toHaveLength(0); - const enabled = { runtime: { config: { dynamicWorkflowEnabled: true } }, startSavedWorkflow: async (input: unknown) => { calls.push(input); return { ok: true }; } }; - await expect(startTuiSavedWorkflow(enabled, { name: "review", scope: "project" })).resolves.toEqual({ ok: true }); - expect(calls).toHaveLength(1); -}); diff --git a/test/workspace-autocomplete.test.ts b/test/workspace-autocomplete.test.ts deleted file mode 100644 index 82c647ad..00000000 --- a/test/workspace-autocomplete.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { WorkspaceAutocompleteProvider } from "../packages/zcode-tui/src/workspace-autocomplete.ts"; -import type { - WorkspacePathSuggestionRequest, - WorkspacePathSuggestionResult -} from "../packages/zcode-tui/src/types.ts"; - -function signal(): AbortSignal { - return new AbortController().signal; -} - -describe("workspace @ autocomplete", () => { - test("queries the official runtime callback and inserts a selected file", async () => { - const requests: WorkspacePathSuggestionRequest[] = []; - const provider = new WorkspaceAutocompleteProvider([], process.cwd(), async (request) => { - requests.push(request); - return { - items: [{ kind: "file", path: "src/index.ts" }], - truncated: false - }; - }); - const input = "inspect @ind"; - - const suggestions = await provider.getSuggestions([input], 0, input.length, { signal: signal() }); - - expect(requests).toHaveLength(1); - expect(requests[0]).toMatchObject({ token: "@ind", limit: 50 }); - expect(requests[0]?.abortSignal).toBeInstanceOf(AbortSignal); - expect(suggestions).toEqual({ - prefix: "@ind", - items: [{ value: "@src/index.ts", label: "index.ts", description: "src/index.ts" }] - }); - - const completion = provider.applyCompletion( - [input], - 0, - input.length, - suggestions!.items[0]!, - suggestions!.prefix - ); - expect(completion).toEqual({ - lines: ["inspect @src/index.ts "], - cursorLine: 0, - cursorCol: "inspect @src/index.ts ".length - }); - }); - - test("quotes paths with spaces and preserves an explicitly quoted token", async () => { - const tokens: string[] = []; - const provider = new WorkspaceAutocompleteProvider([], process.cwd(), async (request) => { - tokens.push(request.token); - return { - items: [{ kind: "file", path: "docs/my file.md" }], - truncated: false - }; - }); - const input = 'read @"docs/my f'; - - const suggestions = await provider.getSuggestions([input], 0, input.length, { signal: signal() }); - expect(tokens).toEqual(["@docs/my f"]); - expect(suggestions?.items[0]).toEqual({ - value: '@"docs/my file.md"', - label: "my file.md", - description: "docs/my file.md" - }); - - const completion = provider.applyCompletion( - [input], - 0, - input.length, - suggestions!.items[0]!, - suggestions!.prefix - ); - expect(completion.lines).toEqual(['read @"docs/my file.md" ']); - expect(completion.cursorCol).toBe(completion.lines[0]!.length); - }); - - test("keeps directory completion open for the next path segment", async () => { - const tokens: string[] = []; - const provider = new WorkspaceAutocompleteProvider([], process.cwd(), async (request) => { - tokens.push(request.token); - return { - items: request.token.endsWith("/in") - ? [{ kind: "file", path: "docs/my folder/index.md" }] - : [{ kind: "directory", path: "docs/my folder/" }], - truncated: false - }; - }); - const input = 'open @"docs/my'; - const directorySuggestions = await provider.getSuggestions([input], 0, input.length, { - signal: signal() - }); - const completion = provider.applyCompletion( - [input], - 0, - input.length, - directorySuggestions!.items[0]!, - directorySuggestions!.prefix - ); - - expect(completion.lines).toEqual(['open @"docs/my folder/"']); - expect(completion.cursorCol).toBe(completion.lines[0]!.length - 1); - - const continued = `${completion.lines[0]!.slice(0, completion.cursorCol)}in${completion.lines[0]!.slice(completion.cursorCol)}`; - const fileSuggestions = await provider.getSuggestions( - [continued], - 0, - completion.cursorCol + 2, - { signal: signal() } - ); - expect(tokens).toEqual(["@docs/my", "@docs/my folder/in"]); - expect(fileSuggestions?.items[0]?.value).toBe('@"docs/my folder/index.md"'); - }); - - test("does not treat email addresses as file tokens and preserves slash completion", async () => { - let workspaceQueries = 0; - const provider = new WorkspaceAutocompleteProvider( - [{ name: "help", description: "Show help" }], - process.cwd(), - async () => { - workspaceQueries += 1; - return { items: [], truncated: false }; - } - ); - const email = "mail me@example.com"; - - expect(await provider.getSuggestions([email], 0, email.length, { signal: signal() })).toBeNull(); - expect(workspaceQueries).toBe(0); - - const slash = await provider.getSuggestions(["/he"], 0, 3, { signal: signal() }); - expect(slash).toMatchObject({ - prefix: "/he", - items: [{ value: "help", label: "help", description: "Show help" }] - }); - }); - - test("normalizes Windows separators and rejects paths outside the workspace", async () => { - const provider = new WorkspaceAutocompleteProvider([], process.cwd(), async () => ({ - items: [ - { kind: "file", path: "../secret.txt" }, - { kind: "file", path: "/etc/passwd" }, - { kind: "file", path: "C:\\secrets.txt" }, - { kind: "file", path: "bad\nname.txt" }, - { kind: "file", path: 'bad"name.txt' }, - { kind: "file", path: "src\\index.ts" }, - { kind: "file", path: "src/index.ts" } - ], - truncated: false - })); - - const suggestions = await provider.getSuggestions(["@src"], 0, 4, { signal: signal() }); - expect(suggestions?.items).toEqual([ - { value: "@src/index.ts", label: "index.ts", description: "src/index.ts" } - ]); - }); - - test("forwards cancellation and isolates callback failures from the editor", async () => { - const controller = new AbortController(); - let requestSignal: AbortSignal | undefined; - let resolveRequest: ((result: WorkspacePathSuggestionResult) => void) | undefined; - const provider = new WorkspaceAutocompleteProvider([], process.cwd(), (request) => { - requestSignal = request.abortSignal; - return new Promise((resolve) => { - resolveRequest = resolve; - }); - }); - - const pending = provider.getSuggestions(["@src"], 0, 4, { signal: controller.signal }); - controller.abort(); - resolveRequest?.({ - items: [{ kind: "file", path: "src/index.ts" }], - truncated: false - }); - expect(await pending).toBeNull(); - expect(requestSignal).toBe(controller.signal); - - const failing = new WorkspaceAutocompleteProvider([], process.cwd(), async () => { - throw new Error("search unavailable"); - }); - expect(await failing.getSuggestions(["@src"], 0, 4, { signal: signal() })).toBeNull(); - }); - - test("supports an empty token and Unicode workspace paths", async () => { - const tokens: string[] = []; - const provider = new WorkspaceAutocompleteProvider([], process.cwd(), async (request) => { - tokens.push(request.token); - return { - items: [{ kind: "file", path: "源码/入口.ts" }], - truncated: false - }; - }); - - const root = await provider.getSuggestions(["@"], 0, 1, { signal: signal() }); - const unicodeInput = "检查 @入口"; - const unicode = await provider.getSuggestions([unicodeInput], 0, unicodeInput.length, { - signal: signal() - }); - - expect(tokens).toEqual(["@", "@入口"]); - expect(root?.items[0]?.value).toBe("@源码/入口.ts"); - expect(unicode?.prefix).toBe("@入口"); - }); - - test("merges enabled Plugins into @ completion and inserts their native reference", async () => { - const provider = new WorkspaceAutocompleteProvider( - [], - process.cwd(), - async () => ({ - items: [{ kind: "file", path: "browser-notes.md" }], - truncated: false - }), - undefined, - async () => ({ - authority: "workspace", - plugins: [{ - conflictingPluginIds: [], - enabled: true, - marketplace: "zcode-plugins-official", - mcpServerNames: [], - name: "browser-use", - pluginId: "browser-use@zcode-plugins-official", - skillQualifiedNames: ["browser-use:control-browser"], - subagentNames: [] - }] - }) - ); - const input = "Use @bro"; - const suggestions = await provider.getSuggestions([input], 0, input.length, { signal: signal() }); - - expect(suggestions).toEqual({ - prefix: "@bro", - items: [ - { - value: "[@browser-use](plugin://browser-use@zcode-plugins-official)", - label: "@browser-use", - description: "Plugin | zcode-plugins-official | 1 skill" - }, - { - value: "@browser-notes.md", - label: "browser-notes.md", - description: "browser-notes.md" - } - ] - }); - - const completion = provider.applyCompletion( - [input], - 0, - input.length, - suggestions!.items[0]!, - suggestions!.prefix - ); - expect(completion.lines).toEqual([ - "Use [@browser-use](plugin://browser-use@zcode-plugins-official) " - ]); - expect(completion.cursorCol).toBe(completion.lines[0]!.length); - }); -}); diff --git a/test/workspace-diff.test.ts b/test/workspace-diff.test.ts deleted file mode 100644 index dbdd2d29..00000000 --- a/test/workspace-diff.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { spawnSync } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { - enrichWorkspaceFiles, - parseWorkspaceDiff, - readWorkspaceDiff -} from "../packages/zcode-tui/src/workspace-diff.ts"; - -const temporaryDirectories: string[] = []; -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); -}); - -describe("workspace diff reader", () => { - test("combines Git patches with tracked and untracked status", () => { - const patch = [ - "diff --git a/src/value.ts b/src/value.ts", - "index 1111111..2222222 100644", - "--- a/src/value.ts", - "+++ b/src/value.ts", - "@@ -1 +1 @@", - "-export const value = 1;", - "+export const value = 2;", - "" - ].join("\n"); - const snapshot = parseWorkspaceDiff(patch, " M src/value.ts\0?? notes.txt\0"); - - expect(snapshot.files).toHaveLength(2); - expect(snapshot.files[1]).toMatchObject({ - filePath: "src/value.ts", - additions: 1, - deletions: 1, - status: "modified" - }); - expect(snapshot.files[0]).toMatchObject({ - filePath: "notes.txt", - status: "untracked", - isUntracked: true - }); - }); - - test("keeps rename metadata and global truncation", () => { - const snapshot = parseWorkspaceDiff("", "R new.ts\0old.ts\0", true); - expect(snapshot.files[0]).toMatchObject({ - filePath: "new.ts", - oldFilePath: "old.ts", - status: "renamed", - truncated: true - }); - }); - - test("marks only the corresponding binary patch as binary", () => { - const snapshot = parseWorkspaceDiff([ - "diff --git a/image.png b/image.png", - "index 1111111..2222222 100644", - "Binary files a/image.png and b/image.png differ", - "" - ].join("\n"), " M image.png\0"); - expect(snapshot.files[0]).toMatchObject({ filePath: "image.png", isBinary: true }); - }); - - test("previews small text files and classifies untracked binary files", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-diff-")); - temporaryDirectories.push(directory); - await writeFile(join(directory, "notes.txt"), "first\nsecond\n"); - await writeFile(join(directory, "image.bin"), new Uint8Array([1, 0, 2])); - const snapshot = parseWorkspaceDiff("", "?? notes.txt\0?? image.bin\0"); - await enrichWorkspaceFiles(snapshot, directory); - - expect(snapshot.files.find((file) => file.filePath === "notes.txt")).toMatchObject({ - additions: 2, - isBinary: false, - structuredPatch: [{ lines: ["+first", "+second"] }] - }); - expect(snapshot.files.find((file) => file.filePath === "image.bin")?.isBinary).toBe(true); - }); - - test("reads a real Git worktree through Node child processes", async () => { - const directory = await mkdtemp(join(tmpdir(), "zcode-diff-git-")); - temporaryDirectories.push(directory); - const runGit = (...args: string[]) => spawnSync("git", args, { cwd: directory, encoding: "utf8" }); - - expect(runGit("init", "--quiet").status).toBe(0); - await writeFile(join(directory, "tracked.txt"), "before\n"); - expect(runGit("add", "tracked.txt").status).toBe(0); - expect(runGit( - "-c", "user.name=ZCode Test", - "-c", "user.email=zcode@example.invalid", - "commit", "--quiet", "-m", "initial" - ).status).toBe(0); - await writeFile(join(directory, "tracked.txt"), "after\n"); - await writeFile(join(directory, "untracked.txt"), "new\n"); - - const snapshot = await readWorkspaceDiff(directory); - expect(snapshot.error).toBeUndefined(); - expect(snapshot.files.map((file) => file.filePath)).toEqual(["tracked.txt", "untracked.txt"]); - }); -}); diff --git a/test/zai-oauth.test.ts b/test/zai-oauth.test.ts deleted file mode 100644 index 08138bf9..00000000 --- a/test/zai-oauth.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - buildZaiAuthorizeUrl, - parseZaiOAuthCallback, - runZaiOAuthLogin, - usesNativeZaiOAuth -} from "../src/zai-oauth.ts"; - -describe("Z.AI Desktop OAuth bridge", () => { - test("routes Linux and Windows to the runtime's HTTPS callback flow", () => { - expect(usesNativeZaiOAuth("linux")).toBe(true); - expect(usesNativeZaiOAuth("win32")).toBe(true); - expect(usesNativeZaiOAuth("darwin")).toBe(false); - }); - test("builds the registered Desktop authorization request", () => { - const url = new URL(buildZaiAuthorizeUrl("expected-state")); - expect(`${url.origin}${url.pathname}`).toBe("https://chat.z.ai/api/oauth/authorize"); - expect(url.searchParams.get("client_id")).toBe("client_P8X5CMWmlaRO9gyO-KSqtg"); - expect(url.searchParams.get("redirect_uri")).toBe("zcode://zai-auth/callback"); - expect(url.searchParams.get("response_type")).toBe("code"); - expect(url.searchParams.get("state")).toBe("expected-state"); - }); - - test("accepts only the registered callback with the expected state", () => { - const callback = "zcode://zai-auth/callback?code=authorization-code&state=expected-state"; - expect(parseZaiOAuthCallback(callback, "expected-state")).toEqual({ - callbackUrl: callback, - code: "authorization-code", - state: "expected-state" - }); - expect(() => parseZaiOAuthCallback( - "zcode://zai-auth/callback?code=authorization-code&state=wrong", - "expected-state" - )).toThrow(/state did not match/); - expect(() => parseZaiOAuthCallback( - "http://127.0.0.1/callback?code=authorization-code&state=expected-state", - "expected-state" - )).toThrow(/unexpected OAuth callback target/); - expect(() => parseZaiOAuthCallback( - "zcode://zai-auth/callback?error=access_denied&state=expected-state", - "expected-state" - )).toThrow(/access_denied/); - expect(() => parseZaiOAuthCallback( - "zcode://zai-auth/callback?error=forged-error&state=wrong", - "expected-state" - )).toThrow(/state did not match/); - }); - - test("restores the protocol receiver before handing the callback to the official runtime", async () => { - const events: string[] = []; - let output = ""; - const callbackUrl = "zcode://zai-auth/callback?code=private-code&state=expected-state"; - const code = await runZaiOAuthLogin({ - completeLogin: async (payload, runtimeArgs) => { - events.push("complete"); - expect(events).toEqual(["open", "wait", "dispose", "complete"]); - expect(payload).toEqual({ callbackUrl, state: "expected-state" }); - expect(runtimeArgs).toEqual(["login"]); - return 0; - }, - createReceiver: async ({ scheme }) => { - expect(scheme).toBe("zcode"); - return { - async dispose() { - events.push("dispose"); - }, - async waitForCallback() { - events.push("wait"); - return callbackUrl; - } - }; - }, - invocation: { json: false, noBrowser: false, runtimeArgs: ["login"] }, - openBrowser: async () => { - events.push("open"); - return { opened: true }; - }, - output: { write(value) { output += value; } }, - platform: "darwin", - state: "expected-state" - }); - - expect(code).toBe(0); - expect(output).toContain("Opening browser for Z.AI authorization"); - expect(output).toContain("Authorization received"); - expect(output).not.toContain("private-code"); - expect(events.filter((event) => event === "dispose")).toHaveLength(1); - }); - - test("supports manual browser opening without invoking the opener", async () => { - const callbackUrl = "zcode://zai-auth/callback?code=code&state=expected-state"; - let output = ""; - const code = await runZaiOAuthLogin({ - completeLogin: async () => 0, - createReceiver: async () => ({ - async dispose() {}, - async waitForCallback() { return callbackUrl; } - }), - invocation: { - json: false, - noBrowser: true, - runtimeArgs: ["login", "--no-browser"] - }, - openBrowser: async () => { - throw new Error("browser opener should not run"); - }, - output: { write(value) { output += value; } }, - platform: "darwin", - state: "expected-state" - }); - - expect(code).toBe(0); - expect(output).toContain("Open this URL to sign in"); - }); - - test("fails clearly on platforms that cannot receive the registered callback", async () => { - expect(runZaiOAuthLogin({ - completeLogin: async () => 0, - invocation: { json: false, noBrowser: false, runtimeArgs: ["login"] }, - platform: "linux" - })).rejects.toThrow(/requires macOS/); - }); -});