From 4ec948341f2711a14be8ccef355ff900bcc8e779 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 20:23:27 +0000 Subject: [PATCH 1/9] fix(ci): checkout before local composite; harden installer + release assets GitHub resolves local composite actions from the workspace before any step runs, so nesting checkout inside setup-bun-monorepo made every CI/release/ build-binaries job fail instantly since that action landed. Callers now checkout first; the composite only sets up Bun/caches/install. Also: native smoke matrix for host binaries, fail_on_unmatched_files + asset-count assert on release upload, clearer empty-release installer errors, and fix install.ps1 returning early after mpv so yt-dlp is offered. Co-authored-by: Manash Pratim Bhuyan --- .changeset/ci-checkout-installer-hardening.md | 7 ++ .docs/repo-infrastructure.md | 11 +++ .github/actions/setup-bun-monorepo/action.yml | 8 +- .github/workflows/build-binaries.yml | 80 +++++++++++++++++++ .github/workflows/ci.yml | 35 ++++++++ .github/workflows/release.yml | 25 ++++++ RELEASING.md | 12 ++- docs/users/install-and-update.mdx | 14 ++++ install.ps1 | 22 ++--- install.sh | 12 ++- 10 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 .changeset/ci-checkout-installer-hardening.md diff --git a/.changeset/ci-checkout-installer-hardening.md b/.changeset/ci-checkout-installer-hardening.md new file mode 100644 index 00000000..2c684bf4 --- /dev/null +++ b/.changeset/ci-checkout-installer-hardening.md @@ -0,0 +1,7 @@ +--- +"@kitsunekode/kunai": patch +--- + +Fix Windows installer optional deps so yt-dlp is still offered after mpv, and improve binary-download errors when a GitHub Release has no assets. + +CI: require `actions/checkout` before the local `setup-bun-monorepo` composite (nested checkout never ran), assert release uploads are non-empty, and smoke host-native binaries on Linux/macOS/Windows after the weekly 8-target build. diff --git a/.docs/repo-infrastructure.md b/.docs/repo-infrastructure.md index a5df6979..2ab3dd50 100644 --- a/.docs/repo-infrastructure.md +++ b/.docs/repo-infrastructure.md @@ -68,6 +68,11 @@ Pull requests and pushes to `main` use the composite setup action Bun store cache, per-job `.turbo` cache prefixes, `TURBO_SCM_BASE` on PRs, and `TURBO_TOKEN` / `TURBO_TEAM` for remote Turbo cache. +**Hard rule:** every job that uses `./.github/actions/setup-bun-monorepo` must run +`actions/checkout` first. GitHub resolves local composite actions from the +workspace before any step runs, so nesting checkout inside the composite fails +with `Can't find 'action.yml' … Did you forget to run actions/checkout`. + **Parallel jobs** (`.github/workflows/ci.yml`): | Job | PR | Main | @@ -78,8 +83,14 @@ Bun store cache, per-job `.turbo` cache prefixes, `TURBO_SCM_BASE` on PRs, and | `test` | `turbo run test --affected` | full | | `build-cli` | `bun run build` + `bun run pkg:check` when CLI paths change | same on main | | `build-binaries` | 2 Linux targets via Turbo when CLI/installer paths change | same | +| `installer-*` | shellcheck/PSScriptAnalyzer + Docker glibc/musl smoke when installer paths change | same | | `checks-docs` | docs gate when docs paths change | same | +**Full 8-target workflow** (`.github/workflows/build-binaries.yml`, weekly / +manual / path-triggered): cross-compiles all release assets on `ubuntu-latest`, +then a `native-smoke` matrix boots the host-matching binary on +`ubuntu-latest` / `macos-latest` / `windows-latest`. + Install cache key: `${{ runner.os }}-bun-store-${{ hashFiles('bun.lock') }}` covering `~/.bun/install/cache` only (Bun reconstructs `node_modules` from the store). diff --git a/.github/actions/setup-bun-monorepo/action.yml b/.github/actions/setup-bun-monorepo/action.yml index f1f17ed3..8981a049 100644 --- a/.github/actions/setup-bun-monorepo/action.yml +++ b/.github/actions/setup-bun-monorepo/action.yml @@ -1,5 +1,5 @@ name: Setup Bun monorepo -description: Checkout, Bun, dependency caches, and install for Kunai workspace jobs. +description: Bun, dependency caches, and install for Kunai workspace jobs. Callers must run actions/checkout first — local composite actions are not on disk until the repo is checked out. inputs: turbo-cache-prefix: @@ -13,12 +13,6 @@ inputs: runs: using: composite steps: - - name: Checkout - uses: actions/checkout@v5 - with: - fetch-depth: 0 - filter: blob:none - - name: Setup Bun uses: oven-sh/setup-bun@v2 with: diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 40839247..b78563c9 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -2,6 +2,8 @@ name: Build all release binaries # Full 8-target cross-compile is too slow for every PR (~5+ min). Run on a # schedule, manually, or when release binaries workflow inputs change. +# Cross-compile happens on Linux; a follow-up matrix smokes the host-native +# darwin/windows/linux artifacts so we catch "builds but won't run" failures. on: workflow_dispatch: schedule: @@ -12,7 +14,9 @@ on: - "apps/cli/scripts/build-binaries.ts" - "apps/cli/scripts/build-shared.ts" - "apps/cli/scripts/verify-release-binaries.sh" + - "apps/cli/src/services/update/platform-assets.ts" - ".github/workflows/build-binaries.yml" + - ".github/actions/setup-bun-monorepo/action.yml" concurrency: group: build-binaries-${{ github.workflow }}-${{ github.ref }} @@ -32,6 +36,12 @@ jobs: timeout-minutes: 35 steps: + # Local composite actions require checkout before `uses: ./…`. + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + filter: blob:none + - uses: ./.github/actions/setup-bun-monorepo with: turbo-cache-prefix: binaries-all @@ -57,3 +67,73 @@ jobs: apps/cli/dist/bin/kunai-windows-x64.exe apps/cli/dist/bin/kunai-windows-arm64.exe apps/cli/dist/bin/SHA256SUMS + + # Native smoke: run the host-matching binary on each OS. Cross-compile alone + # only proves the Linux runner can emit the files — not that they boot. + native-smoke: + name: Native smoke (${{ matrix.os }}) + needs: all-targets + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + asset: kunai-linux-x64 + - os: macos-latest + asset: kunai-darwin-arm64 + - os: windows-latest + asset: kunai-windows-x64.exe + + steps: + - uses: actions/checkout@v5 + + - name: Download release binaries + uses: actions/download-artifact@v4 + with: + name: kunai-release-binaries + path: apps/cli/dist/bin + + - name: Smoke host binary (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + BIN="apps/cli/dist/bin/${{ matrix.asset }}" + chmod +x "$BIN" + cd apps/cli/dist/bin + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c SHA256SUMS | grep -F "${{ matrix.asset }}: OK" + else + want="$(awk -v a="${{ matrix.asset }}" '$2==a {print $1}' SHA256SUMS)" + got="$(shasum -a 256 "${{ matrix.asset }}" | awk '{print $1}')" + test -n "$want" && test "$want" = "$got" + echo "${{ matrix.asset }}: OK" + fi + version_out="$("./${{ matrix.asset }}" --version)" + echo "$version_out" + grep -q '^kunai ' <<<"$version_out" + "./${{ matrix.asset }}" --help >/dev/null + echo "✓ ${{ matrix.asset }} boots on ${{ matrix.os }}" + + - name: Smoke host binary (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $bin = "apps/cli/dist/bin/${{ matrix.asset }}" + if (-not (Test-Path $bin)) { throw "missing $bin" } + $sums = Get-Content apps/cli/dist/bin/SHA256SUMS + $want = ($sums | Where-Object { $_ -match "\s$([regex]::Escape('${{ matrix.asset }}'))$" }) -replace '\s.*', '' + $got = (Get-FileHash -Path $bin -Algorithm SHA256).Hash.ToLower() + if ([string]::IsNullOrEmpty($want) -or $want.ToLower() -ne $got) { + throw "Checksum mismatch for ${{ matrix.asset }} (expected '$want', got '$got')" + } + $versionOut = & $bin --version + Write-Host $versionOut + if ($versionOut -notmatch '^kunai ') { + throw "--version must print kunai semver, got: $versionOut" + } + & $bin --help | Out-Null + Write-Host "✓ ${{ matrix.asset }} boots on ${{ matrix.os }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b55ec91..a8b8d38e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,11 @@ jobs: timeout-minutes: 15 steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + filter: blob:none + - uses: ./.github/actions/setup-bun-monorepo with: turbo-cache-prefix: fmt @@ -87,6 +92,11 @@ jobs: timeout-minutes: 15 steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + filter: blob:none + - uses: ./.github/actions/setup-bun-monorepo with: turbo-cache-prefix: lint @@ -105,6 +115,11 @@ jobs: timeout-minutes: 15 steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + filter: blob:none + - uses: ./.github/actions/setup-bun-monorepo with: turbo-cache-prefix: typecheck @@ -123,6 +138,11 @@ jobs: timeout-minutes: 20 steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + filter: blob:none + - uses: ./.github/actions/setup-bun-monorepo with: turbo-cache-prefix: test @@ -168,6 +188,11 @@ jobs: timeout-minutes: 20 steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + filter: blob:none + - uses: ./.github/actions/setup-bun-monorepo with: turbo-cache-prefix: build @@ -186,6 +211,11 @@ jobs: timeout-minutes: 20 steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + filter: blob:none + - uses: ./.github/actions/setup-bun-monorepo with: turbo-cache-prefix: docs @@ -207,6 +237,11 @@ jobs: timeout-minutes: 15 steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + filter: blob:none + - uses: ./.github/actions/setup-bun-monorepo with: turbo-cache-prefix: binaries diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2698f9a1..c6cf5b2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,11 @@ jobs: published: ${{ steps.changesets.outputs.published }} steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + filter: blob:none + - uses: ./.github/actions/setup-bun-monorepo with: turbo-cache-prefix: release @@ -100,6 +105,11 @@ jobs: contents: write steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + filter: blob:none + - uses: ./.github/actions/setup-bun-monorepo with: turbo-cache-prefix: release-binaries @@ -121,11 +131,13 @@ jobs: # download contract (`releases/latest/download/` and # `releases/download/v/`) resolves to these binaries. - name: Upload binaries to GitHub Release + id: upload uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.ver.outputs.tag }} make_latest: true body_path: .release/kunai-${{ steps.ver.outputs.tag }}.md + fail_on_unmatched_files: true files: | apps/cli/dist/bin/kunai-linux-x64 apps/cli/dist/bin/kunai-linux-arm64 @@ -138,3 +150,16 @@ jobs: apps/cli/dist/bin/SHA256SUMS env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Guard against empty "latest" releases that break install.sh / install.ps1. + - name: Assert release has binary assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + tag="${{ steps.ver.outputs.tag }}" + count="$(gh release view "$tag" --json assets --jq '.assets | length')" + echo "Release $tag asset count: $count" + test "$count" -ge 9 + gh release view "$tag" --json assets --jq '.assets[].name' | grep -qx 'SHA256SUMS' + gh release view "$tag" --json assets --jq '.assets[].name' | grep -qx 'kunai-linux-x64' diff --git a/RELEASING.md b/RELEASING.md index 4d279b96..45936755 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -63,10 +63,11 @@ Short one-line summary of the release. - **Release Guard** (`.github/workflows/release-guard.yml`): PR-time check that `package.json`, both changelogs, pending changesets, and installer/release paths agree. - **Release** (`.github/workflows/release.yml`): runs on pushes to `main` that touch release paths. Two jobs: 1. **Publish** — `bun run ci` + `bun run build` + `bun run pkg:check` + `bun run guard` + `changesets/action` (`bun run version:packages` then `bun run release` for npm). - 2. **Binaries** (only after a real publish) — `bun run build:binaries` (all 8 targets), `verify-release-binaries.sh`, merges per-asset SHA256 checksums into `.release/kunai-v.json`, then `softprops/action-gh-release` uploads assets + `SHA256SUMS` to tag `v` with body from `.release/kunai-v.md`. + 2. **Binaries** (only after a real publish) — `bun run build:binaries` (all 8 targets), `verify-release-binaries.sh`, merges per-asset SHA256 checksums into `.release/kunai-v.json`, then `softprops/action-gh-release` uploads assets + `SHA256SUMS` to tag `v` with body from `.release/kunai-v.md`. The job asserts the published release has ≥9 assets (`fail_on_unmatched_files` + `gh release view` count) so `install.sh` / `install.ps1` never see an empty `latest`. - `version:packages` runs `changeset version`, mirrors changelog, and regenerates `.release/kunai-v*.md` via `bun run release:notes`. - Publish uses OIDC (`id-token: write`) with npm provenance enabled. - **CI** (`.github/workflows/ci.yml`): parallel Turbo jobs (`fmt`, `lint`, `typecheck`, `test`, `build-cli`, `build-binaries`); on installer-related diffs, runs Docker `install.sh → upgrade → uninstall` smoke after building linux glibc + musl binaries (images cached; binaries not rebuilt in Docker job). +- **Build all release binaries** (`.github/workflows/build-binaries.yml`): weekly/manual 8-target cross-compile plus native smoke on Linux/macOS/Windows runners. **npm vs GitHub Release artifacts:** npm publishes only `dist/kunai.js` and `dist/assets/**` (allowlisted in `apps/cli/package.json` `files`). Standalone binaries live under `dist/bin/` on disk and ship exclusively via the **binaries** job to GitHub Releases — `bun run pkg:check` fails if `dist/bin/` appears in the npm tarball. @@ -74,6 +75,15 @@ Short one-line summary of the release. Prefer tag `vX.Y.Z` with binary assets attached by the **binaries** job (not a separate empty GitHub release from Changesets alone). Release notes body comes from `.release/kunai-vX.Y.Z.md`. Avoid duplicate `@kitsunekode/kunai@X.Y.Z` releases with empty bodies. +### Backfilling an empty release + +If `releases/latest` has a tag but **zero assets** (installers 404), do **not** tell users to curl the default binary path. Either: + +1. Re-run the Release workflow after CI is green so the **binaries** job uploads assets, or +2. Manually: `bun run build:binaries` → `bash apps/cli/scripts/verify-release-binaries.sh` → `gh release upload vX.Y.Z apps/cli/dist/bin/kunai-* apps/cli/dist/bin/SHA256SUMS --clobber`. + +Until assets exist, point users at `npm i -g @kitsunekode/kunai` / `bun i -g @kitsunekode/kunai`. + ## Local release utilities - `bun run changeset` → create release intent files diff --git a/docs/users/install-and-update.mdx b/docs/users/install-and-update.mdx index 25358208..ae10690b 100644 --- a/docs/users/install-and-update.mdx +++ b/docs/users/install-and-update.mdx @@ -84,6 +84,20 @@ kunai +## If the binary download 404s + +`install.sh` / `install.ps1` pull from GitHub Releases (`…/releases/latest/download/…`). +If that release was published **without** binary assets (empty release), the download fails. +Use a package channel until assets are backfilled: + +```sh +bun install -g @kitsunekode/kunai +# or +npm install -g @kitsunekode/kunai +``` + +Or pin a release that has assets: `bash install.sh --version X.Y.Z` (when that tag’s assets exist). + ## Unsigned binaries (beta) Release binaries are **not code-signed or notarized** today. That is intentional for beta: diff --git a/install.ps1 b/install.ps1 index 85a6d196..3405522b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -106,16 +106,17 @@ function Install-OptionalDeps { $reply = Read-Host 'Install mpv (required for playback)? [Y/n]' if ($reply -match '^[Nn]') { $installMpv = $false } } - if (-not $installMpv) { return } - if (Test-Cmd 'winget') { - Invoke-Step 'winget install --id mpv.net -e' { winget install --id mpv.net -e --accept-package-agreements --accept-source-agreements } - return - } - if (Test-Cmd 'scoop') { - Invoke-Step 'scoop install mpv' { scoop install mpv } - return + if ($installMpv) { + if (Test-Cmd 'winget') { + Invoke-Step 'winget install --id mpv.net -e' { winget install --id mpv.net -e --accept-package-agreements --accept-source-agreements } + } + elseif (Test-Cmd 'scoop') { + Invoke-Step 'scoop install mpv' { scoop install mpv } + } + else { + Write-Warn 'No winget/scoop found. Install mpv manually: https://mpv.io/installation/' + } } - Write-Warn 'No winget/scoop found. Install mpv manually: https://mpv.io/installation/' $installYtDlp = $true if (-not $Yes -and -not $DryRun -and [Console]::IsInputRedirected -eq $false) { @@ -153,8 +154,9 @@ function Install-Binary { } catch { Write-Warn "Download failed for $asset." + Write-Warn 'The latest GitHub Release may be missing binary assets (empty release).' Write-Warn 'Try: -Method npm | -Method bun | -Method source' - Write-Warn 'Or pin a version: -Version X.Y.Z' + Write-Warn 'Or pin a known-good version: -Version X.Y.Z' throw } $want = ($sums -split "`n" | Where-Object { $_ -match "\s$([regex]::Escape($asset))$" }) -replace '\s.*', '' diff --git a/install.sh b/install.sh index 0a608371..d2d8faf3 100755 --- a/install.sh +++ b/install.sh @@ -107,8 +107,9 @@ resolve_published_version() { download_failed_hint() { local asset="$1" err "Download failed for $asset." + err "The latest GitHub Release may be missing binary assets (empty release)." err "Try: --method npm | --method bun | --method source" - err "Or pin a version: --version X.Y.Z" + err "Or pin a known-good version: --version X.Y.Z" err "Override mirror: KUNAI_DL_BASE=https://github.com/KitsuneKode/kunai/releases" } @@ -213,8 +214,13 @@ install_binary() { if [[ "$DRY" != 1 ]]; then want="$(awk -v a="$asset" '$2==a {print $1}' "$tmp/SHA256SUMS")" got="$(sha256_of "$tmp/$asset")" - if [[ -z "$want" || "$want" != "$got" ]]; then - err "Checksum mismatch for $asset (expected ${want:-}, got $got)." + if [[ -z "$want" ]]; then + err "SHA256SUMS has no entry for $asset — this release likely has incomplete binary assets." + download_failed_hint "$asset" + exit 1 + fi + if [[ "$want" != "$got" ]]; then + err "Checksum mismatch for $asset (expected $want, got $got)." exit 1 fi mkdir -p "$(dirname "$version_path")" From 0abf476b4f908830955f8b4ec170f0ce0ea9b7a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 20:28:12 +0000 Subject: [PATCH 2/9] fix(docs): accept summary-only release notes artifacts in docs gate v0.2.6.json has an empty sections array (changelog body has no ### headings). The docs test required sections and failed once CI actually ran again after the checkout fix. Co-authored-by: Manash Pratim Bhuyan --- apps/docs/test/release-notes.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/docs/test/release-notes.test.ts b/apps/docs/test/release-notes.test.ts index 4a388147..b12114cb 100644 --- a/apps/docs/test/release-notes.test.ts +++ b/apps/docs/test/release-notes.test.ts @@ -10,6 +10,10 @@ describe("release notes artifacts", () => { expect(releases.length).toBeGreaterThan(0); expect(latest?.packageName).toBe("@kitsunekode/kunai"); expect(latest?.install.bunx).toContain("@kitsunekode/kunai@"); - expect(latest?.sections.length).toBeGreaterThan(0); + // Artifacts may be summary-only when the changelog has no ### headings + // (see scripts/generate-release-notes.ts). Prefer sections when present. + const hasBody = + (latest?.sections.length ?? 0) > 0 || (latest?.summary.trim().length ?? 0) > 0; + expect(hasBody).toBe(true); }); }); From 911388ce8a6d5637541cf630f051c4f620cede56 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 20:35:13 +0000 Subject: [PATCH 3/9] style(docs): oxfmt release-notes test --- apps/docs/test/release-notes.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/docs/test/release-notes.test.ts b/apps/docs/test/release-notes.test.ts index b12114cb..c9deb5b0 100644 --- a/apps/docs/test/release-notes.test.ts +++ b/apps/docs/test/release-notes.test.ts @@ -12,8 +12,7 @@ describe("release notes artifacts", () => { expect(latest?.install.bunx).toContain("@kitsunekode/kunai@"); // Artifacts may be summary-only when the changelog has no ### headings // (see scripts/generate-release-notes.ts). Prefer sections when present. - const hasBody = - (latest?.sections.length ?? 0) > 0 || (latest?.summary.trim().length ?? 0) > 0; + const hasBody = (latest?.sections.length ?? 0) > 0 || (latest?.summary.trim().length ?? 0) > 0; expect(hasBody).toBe(true); }); }); From f060fea775309b7ab07ac352047a8a556b4d2758 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 21:06:51 +0000 Subject: [PATCH 4/9] fix(ci): stabilize shared CLI suite failures after checkout fix These failures only surfaced once CI started executing the full suite again (#8). Fix the durable diagnostics retention time bomb, make install.ps1 dry-run succeed without LOCALAPPDATA/network, stop mock.module leaking from palette tests into history/overlay tests, and gate live Miruro network checks. --- .changeset/ci-shared-test-stabilization.md | 5 + .../anime-discovery-resolve-handoff.test.ts | 140 +++++++++--------- .../integration/install-scripts-pwsh.test.ts | 15 +- .../dispatch-palette-command.test.ts | 64 ++++---- .../app-shell/root-overlay-bridge.test.ts | 5 +- .../unit/app-shell/workflows-history.test.ts | 6 +- .../diagnostics/diagnostics-service.test.ts | 5 +- install.ps1 | 17 ++- 8 files changed, 145 insertions(+), 112 deletions(-) create mode 100644 .changeset/ci-shared-test-stabilization.md diff --git a/.changeset/ci-shared-test-stabilization.md b/.changeset/ci-shared-test-stabilization.md new file mode 100644 index 00000000..1d5ddb6b --- /dev/null +++ b/.changeset/ci-shared-test-stabilization.md @@ -0,0 +1,5 @@ +--- +"@kitsunekode/kunai": patch +--- + +Stabilize shared CLI CI failures that only surfaced after checkout/installer CI started running again: durable diagnostics retention timestamp, install.ps1 dry-run without LOCALAPPDATA, and mock.module leak across app-shell unit tests. Gate live Miruro network checks behind KUNAI_LIVE_PROVIDERS. diff --git a/apps/cli/test/integration/anime-discovery-resolve-handoff.test.ts b/apps/cli/test/integration/anime-discovery-resolve-handoff.test.ts index 8ab15408..4329c3f2 100644 --- a/apps/cli/test/integration/anime-discovery-resolve-handoff.test.ts +++ b/apps/cli/test/integration/anime-discovery-resolve-handoff.test.ts @@ -7,6 +7,10 @@ import { ProviderResolveFailureError } from "@kunai/core"; import { handoffAniListSearchPick } from "./helpers/anime-search-handoff"; import { createIsolatedContainer } from "./helpers/isolated-container"; +/** Live Miruro/network checks — opt in so default CI stays deterministic. */ +const describeLiveProviders = + process.env.KUNAI_LIVE_PROVIDERS === "1" ? describe : describe.skip; + const FARMING_LIFE_S2: SearchResult = { id: "197824", type: "series", @@ -124,72 +128,74 @@ describe("anime discovery → resolve handoff (CLI-shaped)", () => { expect(failure?.result?.streams.length).toBe(0); }); - test( - "miruro listEpisodes after AniList handoff uses pipe titles without AniList/Jikan enrichment", - async () => { - const { container, dispose } = await createIsolatedContainer("miruro-episodes"); - disposers.push(dispose); - - const { title } = await handoffAniListSearchPick(container, { - discovery: FARMING_LIFE_S2, - providerId: "miruro", - }); - - const miruro = container.providerRegistry.get("miruro"); - expect(miruro?.listEpisodes).toBeDefined(); - - let externalMetadataRequests = 0; - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (input, init) => { - const url = String(input); - if (url.includes("graphql.anilist.co") || url.includes("api.jikan.moe")) { - externalMetadataRequests += 1; + describeLiveProviders("miruro live provider (opt-in)", () => { + test( + "miruro listEpisodes after AniList handoff uses pipe titles without AniList/Jikan enrichment", + async () => { + const { container, dispose } = await createIsolatedContainer("miruro-episodes"); + disposers.push(dispose); + + const { title } = await handoffAniListSearchPick(container, { + discovery: FARMING_LIFE_S2, + providerId: "miruro", + }); + + const miruro = container.providerRegistry.get("miruro"); + expect(miruro?.listEpisodes).toBeDefined(); + + let externalMetadataRequests = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.includes("graphql.anilist.co") || url.includes("api.jikan.moe")) { + externalMetadataRequests += 1; + } + return originalFetch(input, init); + }) as typeof fetch; + + try { + const episodes = await miruro!.listEpisodes!({ title }, new AbortController().signal); + expect(externalMetadataRequests).toBe(0); + expect(episodes && episodes.length > 0).toBe(true); + expect( + episodes?.some((episode) => episode.name && !/^Episode \d+$/.test(episode.name)), + ).toBe(true); + } finally { + globalThis.fetch = originalFetch; } - return originalFetch(input, init); - }) as typeof fetch; - - try { - const episodes = await miruro!.listEpisodes!({ title }, new AbortController().signal); - expect(externalMetadataRequests).toBe(0); - expect(episodes && episodes.length > 0).toBe(true); - expect( - episodes?.some((episode) => episode.name && !/^Episode \d+$/.test(episode.name)), - ).toBe(true); - } finally { - globalThis.fetch = originalFetch; - } - }, - { timeout: 60_000 }, - ); - - test( - "miruro resolves a playable stream after AniList search handoff (Farming Life S2)", - async () => { - const { container, dispose } = await createIsolatedContainer("miruro-resolve"); - disposers.push(dispose); - - const { request, resolveInput, title } = await handoffAniListSearchPick(container, { - discovery: FARMING_LIFE_S2, - providerId: "miruro", - episode: 1, - }); - - expect(title.id).toBe("197824"); - - const startedAt = Date.now(); - const result = await container.engine.resolve(resolveInput, "miruro"); - const resolveDurationMs = Date.now() - startedAt; - - const stream = providerResolveResultToStreamInfo({ - result, - title: request.title.name, - subtitlePreference: request.subtitlePreference, - }); - - expect(stream?.url).toBeTruthy(); - expect(result.failures).toHaveLength(0); - expect(resolveDurationMs).toBeLessThan(60_000); - }, - { timeout: 90_000 }, - ); + }, + { timeout: 60_000 }, + ); + + test( + "miruro resolves a playable stream after AniList search handoff (Farming Life S2)", + async () => { + const { container, dispose } = await createIsolatedContainer("miruro-resolve"); + disposers.push(dispose); + + const { request, resolveInput, title } = await handoffAniListSearchPick(container, { + discovery: FARMING_LIFE_S2, + providerId: "miruro", + episode: 1, + }); + + expect(title.id).toBe("197824"); + + const startedAt = Date.now(); + const result = await container.engine.resolve(resolveInput, "miruro"); + const resolveDurationMs = Date.now() - startedAt; + + const stream = providerResolveResultToStreamInfo({ + result, + title: request.title.name, + subtitlePreference: request.subtitlePreference, + }); + + expect(stream?.url).toBeTruthy(); + expect(result.failures).toHaveLength(0); + expect(resolveDurationMs).toBeLessThan(60_000); + }, + { timeout: 90_000 }, + ); + }); }); diff --git a/apps/cli/test/integration/install-scripts-pwsh.test.ts b/apps/cli/test/integration/install-scripts-pwsh.test.ts index 09004eff..aecb9e8c 100644 --- a/apps/cli/test/integration/install-scripts-pwsh.test.ts +++ b/apps/cli/test/integration/install-scripts-pwsh.test.ts @@ -30,21 +30,32 @@ function runInstallPs1( describePwsh("install.ps1 dry-run", () => { test("prints the binary install plan without downloading", () => { - const result = runInstallPs1(["-DryRun", "-Yes"]); + // Clear Windows-only env vars so Linux CI pwsh exercises the fallback paths. + const result = runInstallPs1(["-DryRun", "-Yes"], { + ...process.env, + LOCALAPPDATA: "", + APPDATA: "", + }); expect(result.status).toBe(0); expect(result.stdout).toContain("Kunai installer"); expect(result.stdout).toContain("Downloading kunai-windows-"); expect(result.stdout).toContain("versions"); expect(result.stdout).toContain("[dry-run]"); + expect(result.stdout).toContain("vdry-run"); expect(result.stderr).toBe(""); }); test("honors pinned -Version in dry-run output", () => { - const result = runInstallPs1(["-DryRun", "-Yes", "-Version", "9.8.7"]); + const result = runInstallPs1(["-DryRun", "-Yes", "-Version", "9.8.7"], { + ...process.env, + LOCALAPPDATA: "", + APPDATA: "", + }); expect(result.status).toBe(0); expect(result.stdout).toContain("v9.8.7"); + expect(result.stdout).toContain("[dry-run]"); }); test("rejects lifecycle switches — use kunai upgrade / kunai uninstall instead", () => { diff --git a/apps/cli/test/unit/app-shell/dispatch-palette-command.test.ts b/apps/cli/test/unit/app-shell/dispatch-palette-command.test.ts index f02a4abb..69bd128c 100644 --- a/apps/cli/test/unit/app-shell/dispatch-palette-command.test.ts +++ b/apps/cli/test/unit/app-shell/dispatch-palette-command.test.ts @@ -1,33 +1,24 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; -const openSetupWizardFromShell = mock(async () => {}); -const handleShellAction = mock(async () => "handled" as const); -const openRootOwnedOverlay = mock(async () => {}); +import { dispatchPaletteCommand } from "@/app-shell/dispatch-palette-command"; +import * as rootOverlayBridge from "@/app-shell/root-overlay-bridge"; +import * as rootQueueBridge from "@/app-shell/root-queue-bridge"; +import * as workflows from "@/app-shell/workflows"; +import * as setupWorkflows from "@/app-shell/workflows/setup-workflows"; -mock.module("@/app-shell/workflows/setup-workflows", () => ({ - openSetupWizardFromShell, -})); - -mock.module("@/app-shell/root-overlay-bridge", () => ({ - openRootOwnedOverlay, - openNotificationsOverlay: async () => {}, -})); - -mock.module("@/app-shell/root-queue-bridge", () => ({ - waitForRootQueueSelection: async () => null, -})); - -mock.module("@/app-shell/workflows", () => ({ - handleShellAction, - resolveQuitWithDownloadQueue: async () => "handled" as const, -})); - -const { dispatchPaletteCommand } = await import("@/app-shell/dispatch-palette-command"); +beforeEach(() => { + spyOn(setupWorkflows, "openSetupWizardFromShell").mockImplementation(async () => {}); + spyOn(rootOverlayBridge, "openRootOwnedOverlay").mockImplementation(async () => {}); + spyOn(rootOverlayBridge, "openNotificationsOverlay").mockImplementation(async () => ({ + playback: null, + })); + spyOn(rootQueueBridge, "waitForRootQueueSelection").mockImplementation(async () => null); + spyOn(workflows, "handleShellAction").mockImplementation(async () => "handled" as const); + spyOn(workflows, "resolveQuitWithDownloadQueue").mockImplementation(async () => "handled" as const); +}); afterEach(() => { - openSetupWizardFromShell.mockClear(); - handleShellAction.mockClear(); - openRootOwnedOverlay.mockClear(); + mock.restore(); }); describe("dispatchPaletteCommand", () => { @@ -39,19 +30,19 @@ describe("dispatchPaletteCommand", () => { expect(browseResult).toBe("handled"); expect(playbackResult).toBe("handled"); - expect(openSetupWizardFromShell).toHaveBeenCalledTimes(2); - expect(openSetupWizardFromShell).toHaveBeenCalledWith(container, { + expect(setupWorkflows.openSetupWizardFromShell).toHaveBeenCalledTimes(2); + expect(setupWorkflows.openSetupWizardFromShell).toHaveBeenCalledWith(container, { force: true, closeOverlays: true, }); - expect(handleShellAction).not.toHaveBeenCalled(); + expect(workflows.handleShellAction).not.toHaveBeenCalled(); }); test("provider command returns provider picker intent from the shared dispatcher", async () => { const result = await dispatchPaletteCommand("playback", "provider", {} as never); expect(result).toBe("provider"); - expect(handleShellAction).not.toHaveBeenCalled(); + expect(workflows.handleShellAction).not.toHaveBeenCalled(); }); test("routes saved-media palette actions to distinct workflows", async () => { @@ -60,8 +51,10 @@ describe("dispatchPaletteCommand", () => { await expect(dispatchPaletteCommand("browse", "up-next", container as never)).resolves.toBe( "handled", ); - expect(openRootOwnedOverlay).toHaveBeenCalledWith(container, { type: "queue" }); - expect(handleShellAction).not.toHaveBeenCalled(); + expect(rootOverlayBridge.openRootOwnedOverlay).toHaveBeenCalledWith(container, { + type: "queue", + }); + expect(workflows.handleShellAction).not.toHaveBeenCalled(); await expect(dispatchPaletteCommand("browse", "playlists", container as never)).resolves.toBe( "handled", @@ -69,7 +62,10 @@ describe("dispatchPaletteCommand", () => { await expect(dispatchPaletteCommand("browse", "playlist", container as never)).resolves.toBe( "handled", ); - expect(handleShellAction).toHaveBeenCalledTimes(2); - expect(handleShellAction).toHaveBeenCalledWith({ action: "playlists", container }); + expect(workflows.handleShellAction).toHaveBeenCalledTimes(2); + expect(workflows.handleShellAction).toHaveBeenCalledWith({ + action: "playlists", + container, + }); }); }); diff --git a/apps/cli/test/unit/app-shell/root-overlay-bridge.test.ts b/apps/cli/test/unit/app-shell/root-overlay-bridge.test.ts index dd541781..aca2f727 100644 --- a/apps/cli/test/unit/app-shell/root-overlay-bridge.test.ts +++ b/apps/cli/test/unit/app-shell/root-overlay-bridge.test.ts @@ -83,7 +83,10 @@ describe("root-overlay-bridge notification intents", () => { const container = createOverlayContainer(); const resultPromise = openNotificationsOverlay(container); - await Promise.resolve(); + // Wait until the overlay is actually open before closing — a single + // microtask is not enough if OPEN_OVERLAY is deferred. + await Bun.sleep(0); + expect(container.stateManager.getState().activeModals.at(-1)?.type).toBe("notifications"); container.stateManager.dispatch({ type: "CLOSE_TOP_OVERLAY" }); const result = await resultPromise; diff --git a/apps/cli/test/unit/app-shell/workflows-history.test.ts b/apps/cli/test/unit/app-shell/workflows-history.test.ts index 8ce751c1..d233d9bf 100644 --- a/apps/cli/test/unit/app-shell/workflows-history.test.ts +++ b/apps/cli/test/unit/app-shell/workflows-history.test.ts @@ -9,7 +9,9 @@ describe("history workflow action", () => { const { container, dispatches, closeTopOverlay } = createContainerFixture(); const result = handleShellAction({ action: "continue", container }); - await Promise.resolve(); + // openRootOwnedOverlay dispatches synchronously; yield so the promise settles + // after OPEN_OVERLAY before we assert and close. + await Bun.sleep(0); expect(dispatches).toEqual(["open:history"]); @@ -23,7 +25,7 @@ describe("history workflow action", () => { const { container, dispatches, closeTopOverlay } = createContainerFixture(); const result = handleShellAction({ action: "history", container }); - await Promise.resolve(); + await Bun.sleep(0); expect(dispatches).toEqual(["open:history"]); diff --git a/apps/cli/test/unit/services/diagnostics/diagnostics-service.test.ts b/apps/cli/test/unit/services/diagnostics/diagnostics-service.test.ts index 07a33188..7e9138b3 100644 --- a/apps/cli/test/unit/services/diagnostics/diagnostics-service.test.ts +++ b/apps/cli/test/unit/services/diagnostics/diagnostics-service.test.ts @@ -248,11 +248,14 @@ describe("DiagnosticsServiceImpl", () => { try { runMigrations(db, "cache"); const repository = new DiagnosticEventsRepository(db); + // Use a recent wall-clock timestamp so the 14-day durable prune does not + // delete the row before the restart read (hardcoded June dates went stale). + const recordedAt = new Date(); const firstService = new DiagnosticsServiceImpl({ store: new DiagnosticsStoreImpl(), logger: createLogger(), durableSink: new AsyncDurableDiagnosticsSink({ repository }), - now: () => new Date("2026-06-24T12:00:00.000Z"), + now: () => recordedAt, }); firstService.record({ diff --git a/install.ps1 b/install.ps1 index 3405522b..2c71d69f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -20,12 +20,17 @@ param( $ErrorActionPreference = 'Stop' +# Windows uses LOCALAPPDATA/APPDATA. On Linux CI (pwsh dry-run), fall back so +# Join-Path never receives a null Path and dry-run stays network-free. +$LocalAppData = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } elseif ($env:HOME) { Join-Path $env:HOME '.local/share' } else { [System.IO.Path]::GetTempPath().TrimEnd('\', '/') } +$RoamingAppData = if ($env:APPDATA) { $env:APPDATA } elseif ($env:HOME) { Join-Path $env:HOME '.config' } else { $LocalAppData } + $DlBase = if ($env:KUNAI_DL_BASE) { $env:KUNAI_DL_BASE } else { 'https://github.com/KitsuneKode/kunai/releases' } $ReleasesApi = if ($env:KUNAI_RELEASES_API) { $env:KUNAI_RELEASES_API } else { 'https://api.github.com/repos/KitsuneKode/kunai/releases/latest' } $Package = '@kitsunekode/kunai' -$BinDir = Join-Path $env:LOCALAPPDATA 'kunai\bin' -$DataDir = Join-Path $env:LOCALAPPDATA 'kunai' -$ConfigDir = Join-Path $env:APPDATA 'kunai' +$BinDir = Join-Path $LocalAppData 'kunai\bin' +$DataDir = Join-Path $LocalAppData 'kunai' +$ConfigDir = Join-Path $RoamingAppData 'kunai' $BinPath = Join-Path $BinDir 'kunai.exe' $VersionsDir = Join-Path $DataDir 'versions' @@ -142,8 +147,10 @@ function Install-Binary { $base = if ($Version -eq 'latest') { "$DlBase/latest/download" } else { "$DlBase/download/v$Version" } $versionPath = Join-Path (Join-Path $VersionsDir $resolved) 'kunai.exe' - New-Item -ItemType Directory -Force -Path $BinDir | Out-Null - New-Item -ItemType Directory -Force -Path (Split-Path $versionPath) | Out-Null + if (-not $DryRun) { + New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + New-Item -ItemType Directory -Force -Path (Split-Path $versionPath) | Out-Null + } $tmp = Join-Path $BinDir '.kunai-new.exe' Write-Info "Downloading $asset (v$resolved) ..." From 63f6ca075c0f23d7507d706059ce771565e0fd60 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 21:11:27 +0000 Subject: [PATCH 5/9] fix(test): return SetupWizardResult from setup wizard spy Typecheck failed because openSetupWizardFromShell was mocked as Promise instead of Promise, which also broke Format (turbo depends on typecheck for fmt:check). --- .../test/unit/app-shell/dispatch-palette-command.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/cli/test/unit/app-shell/dispatch-palette-command.test.ts b/apps/cli/test/unit/app-shell/dispatch-palette-command.test.ts index 69bd128c..db1461ec 100644 --- a/apps/cli/test/unit/app-shell/dispatch-palette-command.test.ts +++ b/apps/cli/test/unit/app-shell/dispatch-palette-command.test.ts @@ -7,14 +7,16 @@ import * as workflows from "@/app-shell/workflows"; import * as setupWorkflows from "@/app-shell/workflows/setup-workflows"; beforeEach(() => { - spyOn(setupWorkflows, "openSetupWizardFromShell").mockImplementation(async () => {}); + spyOn(setupWorkflows, "openSetupWizardFromShell").mockImplementation(async () => "completed"); spyOn(rootOverlayBridge, "openRootOwnedOverlay").mockImplementation(async () => {}); spyOn(rootOverlayBridge, "openNotificationsOverlay").mockImplementation(async () => ({ playback: null, })); spyOn(rootQueueBridge, "waitForRootQueueSelection").mockImplementation(async () => null); spyOn(workflows, "handleShellAction").mockImplementation(async () => "handled" as const); - spyOn(workflows, "resolveQuitWithDownloadQueue").mockImplementation(async () => "handled" as const); + spyOn(workflows, "resolveQuitWithDownloadQueue").mockImplementation( + async () => "handled" as const, + ); }); afterEach(() => { From 15d5ce115caa6ea61b47c9b495f2a547c362bee9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 20:59:25 +0000 Subject: [PATCH 6/9] fix(reliability): deterministic Videasy preferred fallback + Esc ownership Make the deprecated preferred-source unit test mock network instead of live fetch, and harden Videasy so deprecated preferred ids never bias cycle order or probe Sanji/1movies. Document that resolveEscTransition stays unused at runtime because root-overlay-shell + resolveOverlayBackStack already own Esc. --- apps/cli/src/app-shell/root-shell-state.ts | 10 +++ .../unit/app-shell/overlay-back-stack.test.ts | 21 +++++++ packages/providers/src/videasy/direct.ts | 62 +++++++++++++++---- .../test/videasy-preferred-fallback.test.ts | 55 +++++++++++----- 4 files changed, 118 insertions(+), 30 deletions(-) diff --git a/apps/cli/src/app-shell/root-shell-state.ts b/apps/cli/src/app-shell/root-shell-state.ts index 1569e5bd..93e6d575 100644 --- a/apps/cli/src/app-shell/root-shell-state.ts +++ b/apps/cli/src/app-shell/root-shell-state.ts @@ -134,6 +134,16 @@ export function resolveHelpScope(state: SessionState): KeyScope { } } +/** + * Pure Esc → transition helper for root-owned overlays. + * + * Runtime Esc ownership lives in `root-overlay-shell.tsx` via + * `resolveOverlayBackStack` (`overlay-back-stack.ts`): filters, nested panes, + * confirmations, and surface-owned Esc clear first; only then does the shell + * dispatch `CLOSE_TOP_OVERLAY`. Wiring this helper into that path would create + * a second Esc owner and risk double-close. Keep this as the pure + * "overlay open → CLOSE_TOP_OVERLAY" contract used by SessionState tests. + */ export function resolveEscTransition(state: SessionState): StateTransition | null { if (state.activeModals.length > 0) { return { type: "CLOSE_TOP_OVERLAY" }; diff --git a/apps/cli/test/unit/app-shell/overlay-back-stack.test.ts b/apps/cli/test/unit/app-shell/overlay-back-stack.test.ts index 8c510f4b..c71a9a16 100644 --- a/apps/cli/test/unit/app-shell/overlay-back-stack.test.ts +++ b/apps/cli/test/unit/app-shell/overlay-back-stack.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { resolveOverlayBackStack } from "@/app-shell/overlay-back-stack"; +import { resolveEscTransition } from "@/app-shell/root-shell-state"; +import { createInitialState, reduceState } from "@/domain/session/SessionState"; describe("resolveOverlayBackStack", () => { test("clears filters before backing out of panes or confirmations", () => { @@ -44,4 +46,23 @@ describe("resolveOverlayBackStack", () => { test("closes root overlays when no local state remains", () => { expect(resolveOverlayBackStack({})).toBe("close-overlay"); }); + + test("root-overlay Esc close-overlay matches resolveEscTransition CLOSE_TOP_OVERLAY", () => { + // Production Esc for root-owned overlays is owned by root-overlay-shell + + // resolveOverlayBackStack. When the back-stack says close-overlay, the shell + // dispatches CLOSE_TOP_OVERLAY — the same transition resolveEscTransition + // would return. Do not wire both into the same key handler. + let state = createInitialState("vidking", "allanime", { + anime: { audio: "original", subtitle: "en" }, + series: { audio: "original", subtitle: "none" }, + movie: { audio: "original", subtitle: "en" }, + }); + state = reduceState(state, { + type: "OPEN_OVERLAY", + overlay: { type: "help" }, + }); + + expect(resolveOverlayBackStack({})).toBe("close-overlay"); + expect(resolveEscTransition(state)).toEqual({ type: "CLOSE_TOP_OVERLAY" }); + }); }); diff --git a/packages/providers/src/videasy/direct.ts b/packages/providers/src/videasy/direct.ts index 7cb4735c..2d115d8c 100644 --- a/packages/providers/src/videasy/direct.ts +++ b/packages/providers/src/videasy/direct.ts @@ -56,6 +56,7 @@ import { flavorSourceId, normalizeLegacyVideasySourceId, getPhaseAVidkingFlavorIds, + listDeprecatedVidkingEndpoints, listEligibleVidkingFlavorIds, listVidkingFlavors, isVidkingSourceDeprecated, @@ -281,11 +282,23 @@ export async function resolveVideasyDirect( }); const exhaustiveRefresh = input.intent === "refresh"; - const preferredSourceId = - input.preferredSourceId && - !isVidkingSourceDeprecated(normalizeLegacyVideasySourceId(input.preferredSourceId)) - ? normalizeLegacyVideasySourceId(input.preferredSourceId) - : undefined; + const rawPreferredSourceId = input.preferredSourceId + ? normalizeLegacyVideasySourceId(input.preferredSourceId) + : undefined; + const preferredSourceDeprecated = + rawPreferredSourceId !== undefined && isVidkingSourceDeprecated(rawPreferredSourceId); + // Deprecated preferred ids (e.g. Sanji / 1movies) must not bias cycle order or + // stream selection — treat them as unset and fall through to Phase A mirrors. + const preferredSourceId = preferredSourceDeprecated ? undefined : rawPreferredSourceId; + if (preferredSourceDeprecated && rawPreferredSourceId) { + emitTraceEvent(events, context, { + type: "source:skipped", + providerId: VIDEOSY_PROVIDER_ID, + sourceId: rawPreferredSourceId, + message: "Preferred Videasy source is deprecated; falling back to Phase A mirrors", + attributes: { preferredSourceId: rawPreferredSourceId, reason: "deprecated-endpoint" }, + }); + } const preferredFlavorIds = !exhaustiveRefresh && !resolvedOptions?.serverEndpoint && preferredSourceId ? listVidkingFlavors() @@ -303,10 +316,22 @@ export async function resolveVideasyDirect( preferredFlavorIds.length === 0 && !resolvedOptions?.serverEndpoint && !resolvedOptions?.customReferer; + const deprecatedEndpoints = new Set(listDeprecatedVidkingEndpoints()); let activeServers: VidkingServerEndpoint[] = ( phaseAOnly ? [...VIDKING_PHASE_A_SERVERS] : [...VIDKING_SERVERS] - ).filter((s) => createVideasyEndpointHealth(context).shouldTry(s)); + ) + .filter((s) => !deprecatedEndpoints.has(s)) + .filter((s) => createVideasyEndpointHealth(context).shouldTry(s)); const exhaustiveFlavorIds = + exhaustiveRefresh && !resolvedOptions?.serverEndpoint + ? listVidkingFlavors() + .filter((flavor) => flavor.deprecated !== true) + .filter((flavor) => input.mediaKind !== "series" || flavor.moviesOnly !== true) + .map((flavor) => flavor.id) + : []; + // Keep deprecated flavors in inventory for UI (failed/unsupported), but never + // cycle-probe them during refresh. + const inventoryExhaustiveFlavorIds = exhaustiveRefresh && !resolvedOptions?.serverEndpoint ? listVidkingFlavors() .filter((flavor) => input.mediaKind !== "series" || flavor.moviesOnly !== true) @@ -331,8 +356,8 @@ export async function resolveVideasyDirect( input.mediaKind === "movie" || input.mediaKind === "series" ? input.mediaKind : undefined; const inventoryFlavorIds = resolvedOptions?.flavorId ? [resolvedOptions.flavorId] - : exhaustiveFlavorIds.length > 0 - ? exhaustiveFlavorIds + : inventoryExhaustiveFlavorIds.length > 0 + ? inventoryExhaustiveFlavorIds : preferredFlavorIds.length > 0 ? preferredFlavorIds : resolvedOptions?.serverEndpoint @@ -413,12 +438,12 @@ export async function resolveVideasyDirect( ? [resolvedOptions.serverEndpoint] : exhaustiveFlavorIds.length > 0 ? [] - : [...VIDKING_SERVERS] + : [...VIDKING_SERVERS].filter((server) => !deprecatedEndpoints.has(server)) : [], flavorIds: exhaustiveFlavorIds.length > 0 ? exhaustiveFlavorIds : preferredFlavorIds, embedReferer: resolvedOptions.customReferer ?? embedReferer ?? undefined, engineOptions: resolvedOptions, - preferredSourceId: input.preferredSourceId, + preferredSourceId, }); const resolveVidkingCycleCandidate = async (candidate: ProviderCycleCandidate) => { @@ -554,7 +579,10 @@ export async function resolveVideasyDirect( ); const embedCandidates = buildVidkingCycleCandidates({ directServers: [], - embedServers: embedServers.length > 0 ? embedServers : [...VIDKING_SERVERS], + embedServers: + embedServers.length > 0 + ? embedServers + : [...VIDKING_SERVERS].filter((server) => !deprecatedEndpoints.has(server)), flavorIds: [], embedReferer, engineOptions: resolvedOptions, @@ -923,7 +951,11 @@ export function createVidkingResultFromPayload({ startupPriority: input.startupPriority, qualityPreference: input.qualityPreference, preferredStreamId: input.preferredStreamId, - preferredSourceId: input.preferredSourceId, + preferredSourceId: + input.preferredSourceId && + !isVidkingSourceDeprecated(normalizeLegacyVideasySourceId(input.preferredSourceId)) + ? normalizeLegacyVideasySourceId(input.preferredSourceId) + : undefined, favoriteSourceNames: input.favoriteSourceNames, }); const selectedStream = selection.selected; @@ -1184,7 +1216,11 @@ async function probeSelectedVidkingPayloadStream({ startupPriority: input.startupPriority, qualityPreference: input.qualityPreference, preferredStreamId: input.preferredStreamId, - preferredSourceId: input.preferredSourceId, + preferredSourceId: + input.preferredSourceId && + !isVidkingSourceDeprecated(normalizeLegacyVideasySourceId(input.preferredSourceId)) + ? normalizeLegacyVideasySourceId(input.preferredSourceId) + : undefined, favoriteSourceNames: input.favoriteSourceNames, }); const selected = selection.selected; diff --git a/packages/providers/test/videasy-preferred-fallback.test.ts b/packages/providers/test/videasy-preferred-fallback.test.ts index e21127a3..696fe93b 100644 --- a/packages/providers/test/videasy-preferred-fallback.test.ts +++ b/packages/providers/test/videasy-preferred-fallback.test.ts @@ -2,16 +2,13 @@ import { describe, expect, test } from "bun:test"; import type { ProviderRuntimeContext } from "@kunai/types"; -import { videasyProviderModule } from "../src/videasy/direct"; +import { resolveVidkingDirect } from "../src/videasy/direct"; import { isVidkingSourceDeprecated } from "../src/videasy/flavors"; -const context = { - now: () => new Date().toISOString(), - signal: AbortSignal.timeout(120_000), - retryPolicy: { maxAttempts: 2, backoff: "none" as const }, - fetch: { runtime: "direct-http" as const, fetch }, - emit: () => {}, -} satisfies ProviderRuntimeContext; +/** db.videasy enrich fetch is separate from api.videasy resolve requests. */ +function videasyResolveUrls(urls: readonly string[]): string[] { + return urls.filter((url) => !url.includes("db.videasy.to")); +} describe("videasy preferred source fallback", () => { test("deprecated Sanji source id is recognized", () => { @@ -19,10 +16,16 @@ describe("videasy preferred source fallback", () => { expect(isVidkingSourceDeprecated("source:videasy:mb-flix")).toBe(false); }); - test("deprecated preferred source falls back to Phase A mirrors", async () => { - const result = await videasyProviderModule.resolve( + test("deprecated preferred source skips Sanji and probes Phase A mirrors", async () => { + const requestedUrls: string[] = []; + const result = await resolveVidkingDirect( { - title: { id: "248244", title: "Undercover High School", tmdbId: "248244", kind: "series" }, + title: { + id: "248244", + title: "Undercover High School", + tmdbId: "248244", + kind: "series", + }, mediaKind: "series", episode: { season: 1, episode: 4 }, allowedRuntimes: ["direct-http"], @@ -30,12 +33,30 @@ describe("videasy preferred source fallback", () => { preferredSourceId: "source:videasy:1movies", intent: "play", }, - context, + { + now: () => "2026-07-09T00:00:00.000Z", + retryPolicy: { maxAttempts: 1, backoff: "none" }, + fetch: { + runtime: "direct-http", + fetch: async (input) => { + requestedUrls.push(String(input)); + // Deterministic failure fixture: no live network. Per-server 404s + // keep cycling through Phase A; session_missing would stop fanout. + return new Response("", { status: 404 }); + }, + }, + emit: () => {}, + } satisfies ProviderRuntimeContext, ); - expect(result.status).toBe("resolved"); - expect( - result.sources?.some((source) => source.status === "selected" && source.label === "Luffy"), - ).toBe(true); - }, 120_000); + expect(result?.status).toBe("exhausted"); + + const resolveUrls = videasyResolveUrls(requestedUrls); + expect(resolveUrls.length).toBeGreaterThanOrEqual(1); + expect(resolveUrls.every((url) => !url.includes("/1movies/"))).toBe(true); + expect(resolveUrls.some((url) => url.includes("/mb-flix/"))).toBe(true); + expect(resolveUrls.some((url) => url.includes("/cdn/") || url.includes("/downloader2/"))).toBe( + true, + ); + }); }); From 6e0cf689d572aba4be724501766e7be65f7da080 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 21:13:35 +0000 Subject: [PATCH 7/9] fix(test): stop TMDB mock leakage and date-sensitive stats failures Replace mock.module in watch-genre-stats with a restored spy so later tmdb-proxy/title-detail/recommendation tests see the real module, and use relative timestamps in stats-service so windowed averages stay valid. --- .../unit/domain/lists/stats-service.test.ts | 33 ++++++++++++------- .../domain/lists/watch-genre-stats.test.ts | 27 +++++++-------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/apps/cli/test/unit/domain/lists/stats-service.test.ts b/apps/cli/test/unit/domain/lists/stats-service.test.ts index 125688de..b798865f 100644 --- a/apps/cli/test/unit/domain/lists/stats-service.test.ts +++ b/apps/cli/test/unit/domain/lists/stats-service.test.ts @@ -11,6 +11,8 @@ function makeStatsService(): { service: StatsService; history: HistoryRepository test("getStats uses watched_seconds and completed episodes for honesty", () => { const { service, history } = makeStatsService(); + const day2 = new Date(Date.now() - 2 * 86_400_000).toISOString(); + const day1 = new Date(Date.now() - 1 * 86_400_000).toISOString(); history.upsertProgress({ title: { id: "show-a", kind: "series", title: "Show A" }, @@ -19,7 +21,7 @@ test("getStats uses watched_seconds and completed episodes for honesty", () => { durationSeconds: 1_200, completed: true, watchedSeconds: 900, - updatedAt: "2026-06-20T12:00:00.000Z", + updatedAt: day2, }); history.upsertProgress({ title: { id: "show-a", kind: "series", title: "Show A" }, @@ -28,7 +30,7 @@ test("getStats uses watched_seconds and completed episodes for honesty", () => { durationSeconds: 1_200, completed: false, watchedSeconds: 400, - updatedAt: "2026-06-21T12:00:00.000Z", + updatedAt: day1, }); const stats = service.getStats(30); @@ -79,6 +81,7 @@ test("getStats honors windowDays for heatmap and weekly buckets", () => { test("anime kind filter uses corrected provider markers", () => { const { service, history } = makeStatsService(); + const recent = new Date(Date.now() - 2 * 86_400_000).toISOString(); history.upsertProgress({ title: { id: "aa:1", kind: "series", title: "Via AllAnime" }, @@ -88,7 +91,7 @@ test("anime kind filter uses corrected provider markers", () => { completed: true, watchedSeconds: 1_000, providerId: "allanime", - updatedAt: "2026-06-20T12:00:00.000Z", + updatedAt: recent, }); history.upsertProgress({ title: { id: "tmdb:2", kind: "series", title: "Regular Drama" }, @@ -98,7 +101,7 @@ test("anime kind filter uses corrected provider markers", () => { completed: true, watchedSeconds: 500, providerId: "videasy", - updatedAt: "2026-06-20T13:00:00.000Z", + updatedAt: recent, }); const animeStats = service.getStats(30, "anime"); @@ -135,6 +138,8 @@ test("computeStreak breaks on gaps and counts longest run", () => { test("seriesCompleted counts titles with all stored episodes completed", () => { const { service, history } = makeStatsService(); + const day2 = new Date(Date.now() - 2 * 86_400_000).toISOString(); + const day1 = new Date(Date.now() - 1 * 86_400_000).toISOString(); history.upsertProgress({ title: { id: "done", kind: "series", title: "Done Show" }, @@ -143,7 +148,7 @@ test("seriesCompleted counts titles with all stored episodes completed", () => { durationSeconds: 1_000, completed: true, watchedSeconds: 1_000, - updatedAt: "2026-06-20T12:00:00.000Z", + updatedAt: day2, }); history.upsertProgress({ title: { id: "done", kind: "series", title: "Done Show" }, @@ -152,7 +157,7 @@ test("seriesCompleted counts titles with all stored episodes completed", () => { durationSeconds: 1_000, completed: true, watchedSeconds: 1_000, - updatedAt: "2026-06-21T12:00:00.000Z", + updatedAt: day1, }); history.upsertProgress({ title: { id: "partial", kind: "series", title: "Partial Show" }, @@ -161,7 +166,7 @@ test("seriesCompleted counts titles with all stored episodes completed", () => { durationSeconds: 1_000, completed: false, watchedSeconds: 500, - updatedAt: "2026-06-21T13:00:00.000Z", + updatedAt: day1, }); const stats = service.getStats(30); @@ -170,6 +175,7 @@ test("seriesCompleted counts titles with all stored episodes completed", () => { test("exportStatsJson and exportStatsCsv include extended metrics", () => { const { service, history } = makeStatsService(); + const recent = new Date(Date.now() - 2 * 86_400_000).toISOString(); history.upsertProgress({ title: { id: "show", kind: "series", title: "Export Me" }, @@ -179,7 +185,7 @@ test("exportStatsJson and exportStatsCsv include extended metrics", () => { completed: true, watchedSeconds: 1_000, providerId: "allanime", - updatedAt: "2026-06-20T21:00:00.000Z", + updatedAt: recent, }); const json = service.exportStatsJson(30); @@ -195,6 +201,10 @@ test("exportStatsJson and exportStatsCsv include extended metrics", () => { test("avgEpisodesPerDay divides completed episodes by window length", () => { const { service, history } = makeStatsService(); + // Relative dates stay inside the 10-day window regardless of when CI runs. + const day2 = new Date(Date.now() - 2 * 86_400_000).toISOString(); + const day1 = new Date(Date.now() - 1 * 86_400_000).toISOString(); + history.upsertProgress({ title: { id: "show", kind: "series", title: "Show" }, episode: { season: 1, episode: 1 }, @@ -202,7 +212,7 @@ test("avgEpisodesPerDay divides completed episodes by window length", () => { durationSeconds: 1_000, completed: true, watchedSeconds: 1_000, - updatedAt: "2026-06-20T12:00:00.000Z", + updatedAt: day2, }); history.upsertProgress({ title: { id: "show", kind: "series", title: "Show" }, @@ -211,7 +221,7 @@ test("avgEpisodesPerDay divides completed episodes by window length", () => { durationSeconds: 1_000, completed: true, watchedSeconds: 1_000, - updatedAt: "2026-06-21T12:00:00.000Z", + updatedAt: day1, }); const stats = service.getStats(10); @@ -220,6 +230,7 @@ test("avgEpisodesPerDay divides completed episodes by window length", () => { test("getStats includes video kind in type breakdown", () => { const { service, history } = makeStatsService(); + const recent = new Date(Date.now() - 2 * 86_400_000).toISOString(); history.upsertProgress({ title: { id: "youtube:abc", kind: "video", title: "Clip" }, @@ -228,7 +239,7 @@ test("getStats includes video kind in type breakdown", () => { durationSeconds: 600, completed: true, watchedSeconds: 600, - updatedAt: "2026-06-20T12:00:00.000Z", + updatedAt: recent, }); const stats = service.getStats(30, "video"); diff --git a/apps/cli/test/unit/domain/lists/watch-genre-stats.test.ts b/apps/cli/test/unit/domain/lists/watch-genre-stats.test.ts index 6b737f7d..eee72403 100644 --- a/apps/cli/test/unit/domain/lists/watch-genre-stats.test.ts +++ b/apps/cli/test/unit/domain/lists/watch-genre-stats.test.ts @@ -1,19 +1,13 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { buildWatchGenreBreakdown, resolveTmdbIdentityFromStoredIds, resolveWatchTitleTmdbIdentity, } from "@/domain/lists/WatchGenreStats"; +import * as tmdbProxy from "@/services/catalog/tmdb-proxy"; import type { WatchStatsTitleSecondsRow } from "@kunai/storage"; -const fetchTmdbJsonCached = mock(async (_path: string): Promise => ({ genres: [] })); - -mock.module("@/services/catalog/tmdb-proxy", () => ({ - fetchTmdbJsonCached, - clearTmdbSessionCache: () => {}, -})); - function row( patch: Partial & Pick, ): WatchStatsTitleSecondsRow { @@ -26,9 +20,10 @@ function row( }; } +let fetchSpy: ReturnType>; + beforeEach(() => { - fetchTmdbJsonCached.mockReset(); - fetchTmdbJsonCached.mockImplementation(async (path: string) => { + fetchSpy = spyOn(tmdbProxy, "fetchTmdbJsonCached").mockImplementation(async (path: string) => { if (path.startsWith("/search/")) { return { results: [{ id: 42 }] }; } @@ -39,6 +34,10 @@ beforeEach(() => { }); }); +afterEach(() => { + mock.restore(); +}); + describe("resolveTmdbIdentityFromStoredIds", () => { test("reads tmdbId from external ids", () => { expect( @@ -69,15 +68,13 @@ describe("resolveWatchTitleTmdbIdentity", () => { row({ titleId: "anilist:1", title: "Barakamon" }), ); expect(resolved).toEqual({ id: "42", mediaType: "tv" }); - expect(fetchTmdbJsonCached).toHaveBeenCalledWith( - "/search/tv?query=Barakamon&include_adult=false&page=1", - ); + expect(fetchSpy).toHaveBeenCalledWith("/search/tv?query=Barakamon&include_adult=false&page=1"); }); }); describe("buildWatchGenreBreakdown", () => { test("allocates watched seconds equally across genres", async () => { - fetchTmdbJsonCached.mockImplementation(async (path: string) => { + fetchSpy.mockImplementation(async (path: string) => { if (path.startsWith("/search/")) return { results: [{ id: 7 }] }; if (path === "/tv/7") { return { @@ -101,7 +98,7 @@ describe("buildWatchGenreBreakdown", () => { }); test("returns empty genres when TMDB resolution fails", async () => { - fetchTmdbJsonCached.mockImplementation(async () => ({ results: [] })); + fetchSpy.mockImplementation(async () => ({ results: [] })); const breakdown = await buildWatchGenreBreakdown([ row({ titleId: "no-match", title: "Unknown Title" }), From 5ac30dee6c32cc75237359d647e12a9b99549c34 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 21:17:25 +0000 Subject: [PATCH 8/9] style(test): oxfmt anime discovery handoff live-provider gate --- .../test/integration/anime-discovery-resolve-handoff.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/cli/test/integration/anime-discovery-resolve-handoff.test.ts b/apps/cli/test/integration/anime-discovery-resolve-handoff.test.ts index 4329c3f2..8bafc150 100644 --- a/apps/cli/test/integration/anime-discovery-resolve-handoff.test.ts +++ b/apps/cli/test/integration/anime-discovery-resolve-handoff.test.ts @@ -8,8 +8,7 @@ import { handoffAniListSearchPick } from "./helpers/anime-search-handoff"; import { createIsolatedContainer } from "./helpers/isolated-container"; /** Live Miruro/network checks — opt in so default CI stays deterministic. */ -const describeLiveProviders = - process.env.KUNAI_LIVE_PROVIDERS === "1" ? describe : describe.skip; +const describeLiveProviders = process.env.KUNAI_LIVE_PROVIDERS === "1" ? describe : describe.skip; const FARMING_LIFE_S2: SearchResult = { id: "197824", From d0643ae8e9fdfcf3a96a96de8fd72e5f875fffc7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 21:25:50 +0000 Subject: [PATCH 9/9] fix(shell): stop double-dispatching post-play footer keybinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShellFrame resolved footer letters via useShellInput and then forwarded the same key to onUnhandledInput. Post-play wires both paths, so o/r/n opened tracks / replayed / continued twice — first press looked dead. Skip footer-owned keys in the unhandled path unless the surface opts into letterKeysHandledExternally (LoadingShell). --- apps/cli/src/app-shell/shell-frame.tsx | 11 +++ .../post-play-keybind-once.useinput.test.tsx | 86 +++++++++++++++++++ .../shell-frame-input-bridge.test.tsx | 19 ++++ 3 files changed, 116 insertions(+) create mode 100644 apps/cli/test/unit/app-shell/post-play-keybind-once.useinput.test.tsx diff --git a/apps/cli/src/app-shell/shell-frame.tsx b/apps/cli/src/app-shell/shell-frame.tsx index 9af77734..6e8e9fba 100644 --- a/apps/cli/src/app-shell/shell-frame.tsx +++ b/apps/cli/src/app-shell/shell-frame.tsx @@ -79,6 +79,17 @@ export function ShellFrame({ onResolve("help"); return; } + // Footer-owned letters are already resolved by useShellInput. Forwarding + // them again to onUnhandledInput double-dispatches post-play/playback + // actions (open tracks twice, replay twice, …) so the first press looks + // like a no-op. Surfaces that own letters themselves opt in via + // letterKeysHandledExternally and still receive the key here. + if (!letterKeysHandledExternally) { + const matchKey = input.toLowerCase(); + if (footerActions.some((action) => action.key === matchKey && !action.disabled)) { + return; + } + } onUnhandledInput?.(input, key); }); diff --git a/apps/cli/test/unit/app-shell/post-play-keybind-once.useinput.test.tsx b/apps/cli/test/unit/app-shell/post-play-keybind-once.useinput.test.tsx new file mode 100644 index 00000000..5a5c2979 --- /dev/null +++ b/apps/cli/test/unit/app-shell/post-play-keybind-once.useinput.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; + +import type { ResolvedAppCommand } from "@/app-shell/commands"; +import { ShellFrame } from "@/app-shell/shell-frame"; +import type { FooterAction, ShellAction } from "@/app-shell/types"; +import { Text } from "ink"; +import React from "react"; + +import { render } from "../../harness/render-capture"; + +/** + * Post-play / playback ShellFrame ownership: footer accelerators must fire once. + * Before the fix, ShellFrame resolved the footer letter AND forwarded it to + * onUnhandledInput, so `o`/`r`/`n` opened tracks / replayed / continued twice — + * the classic "keybind does not trigger in one go" report. + */ + +const COMMANDS: readonly ResolvedAppCommand[] = [ + { id: "source", label: "Source", aliases: [], description: "Pick source", enabled: true }, +]; + +const POST_PLAY_FOOTER: readonly FooterAction[] = [ + { key: "n", label: "continue", action: "next", primary: true }, + { key: "o", label: "source", action: "source" }, + { key: "r", label: "replay", action: "replay" }, + { key: "m", label: "menu", action: "menu" }, + { key: "/", label: "commands", action: "command-mode" }, +]; + +describe("post-play ShellFrame keybinds fire once", () => { + test("footer accelerators resolve exactly once and are not re-delivered as unhandled", () => { + const resolved: ShellAction[] = []; + const unhandled: string[] = []; + const handle = render( + unhandled.push(input)} + onResolve={(action) => resolved.push(action)} + > + post-play + , + { columns: 100 }, + ); + + handle.stdin.enqueue("o"); + handle.stdin.enqueue("r"); + handle.stdin.enqueue("n"); + + expect(resolved).toEqual(["source", "replay", "next"]); + expect(unhandled).toEqual([]); + handle.unmount(); + }); + + test("action-list navigation keys still reach onUnhandledInput", () => { + const unhandled: string[] = []; + const handle = render( + unhandled.push(input)} + onResolve={() => {}} + > + post-play + , + { columns: 100 }, + ); + + handle.stdin.enqueue("j"); + handle.stdin.enqueue("k"); + handle.stdin.enqueue("1"); + + expect(unhandled).toEqual(["j", "k", "1"]); + handle.unmount(); + }); +}); diff --git a/apps/cli/test/unit/app-shell/shell-frame-input-bridge.test.tsx b/apps/cli/test/unit/app-shell/shell-frame-input-bridge.test.tsx index 5cc484ff..7ccc92b4 100644 --- a/apps/cli/test/unit/app-shell/shell-frame-input-bridge.test.tsx +++ b/apps/cli/test/unit/app-shell/shell-frame-input-bridge.test.tsx @@ -90,6 +90,25 @@ describe("ShellFrame input ownership (bridge)", () => { handle.unmount(); }); + test("unlocked: footer-owned letters are not double-delivered to onUnhandledInput", () => { + // Post-play wires both footer resolution and onUnhandledInput. Without this + // gate, pressing `g` would resolve AND re-enter the unhandled path — the + // classic "first press does nothing / needs two presses" failure mode. + const resolved: ShellAction[] = []; + const unhandled: string[] = []; + const handle = render( + resolved.push(action)} + onUnhandledInput={(input) => unhandled.push(input)} + />, + { columns: 100 }, + ); + handle.stdin.enqueue("g"); + expect(resolved).toEqual(["help"]); + expect(unhandled).not.toContain("g"); + handle.unmount(); + }); + test("letterKeysHandledExternally: footer letter is delivered to onUnhandledInput, not resolved", () => { const resolved: ShellAction[] = []; const unhandled: string[] = [];