diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index e223b4728de..50ddc0a218a 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -252,7 +252,6 @@ 2 test-no-real-time-wait:src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts 1 test-no-real-time-wait:src/main/git/refs/maintenance/local-repo-ref-maintenance.test.ts 4 test-no-real-time-wait:src/main/git/refs/maintenance/repo-ref-maintenance-real-git.test.ts -1 test-no-real-time-wait:src/main/git/source-control/status-submodule.test.ts 1 test-no-real-time-wait:src/main/git/worktrees/creation/worktree-configured-paths-concurrency.test.ts 1 test-no-real-time-wait:src/main/git/worktrees/removal/remove-worktree-branch-cleanup.test.ts 1 test-no-real-time-wait:src/main/git/worktrees/removal/worktree-prune-guard.test.ts @@ -677,17 +676,14 @@ 1 test-no-wall-clock:src/main/git/source-control/blob-absence-real-git.test.ts 2 test-no-wall-clock:src/main/git/worktrees/catalog/worktree-created-description-real-git.test.ts 1 test-no-wall-clock:src/main/git/wsl-windows-checkout-git-probe.test.ts -2 test-no-wall-clock:src/main/github/client-pr-fallback-number.test.ts -1 test-no-wall-clock:src/main/github/client-work-items.test.ts -6 test-no-wall-clock:src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts 9 test-no-wall-clock:src/main/github/pr-refresh-coordinator-active-visible-priority.test.ts -15 test-no-wall-clock:src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts +8 test-no-wall-clock:src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts 5 test-no-wall-clock:src/main/github/pr-refresh-coordinator-completion-order.test.ts 2 test-no-wall-clock:src/main/github/pr-refresh-coordinator-error-cadence.test.ts 3 test-no-wall-clock:src/main/github/pr-refresh-coordinator-foreground-pacing.test.ts 3 test-no-wall-clock:src/main/github/pr-refresh-coordinator-refresh-events.test.ts -11 test-no-wall-clock:src/main/github/pr-refresh-coordinator-visibility-policy.test.ts -18 test-no-wall-clock:src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts +4 test-no-wall-clock:src/main/github/pr-refresh-coordinator-visibility-policy.test.ts +3 test-no-wall-clock:src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts 6 test-no-wall-clock:src/main/github/pr-refresh-queue-growth-bound.test.ts 2 test-no-wall-clock:src/main/grok/grok-hook-stdin-no-eof.test.ts 1 test-no-wall-clock:src/main/ipc/ai-vault/ai-vault.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 052a410374b..b92866e39b3 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5211,7 +5211,7 @@ "providers": ["remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": [], - "coverageNotes": "Audit1571 removes mock forwarding, capability/schema inventories, private predicates, supplied error/result replays, and filesystem-mocked persistence checks without equivalent replacement claims. Retained changed tests also exercise real local permission-file reread, registry proxy rollback/credential retirement, site display recovery, and pending sender/credential/document admission with injected Electron/UI producers. Retained network tests exercise owned stream credit/cleanup, generation and retirement fences, real loopback proxy/SOCKS bytes and file-backed binding state. SSH/WSL/paired runtime, native sessions, authority producers and storage clears remain injected; simulated relaunch uses filesystem mocks. The unchanged profile-retirement suite was statically reviewed and not rerun. No actual SSH/WSL runtime, native Chromium jar deletion, durable process restart, remote DNS or external site proof. Receipts selecting changed tests are retired and unaffected receipts remain unchanged. Historical scope before this audit: Audit1568 removes API inventories, supplied mock-value/forwarding assertions, implementation-shape tables and negative/setup cases that do not reach their intended boundary, without equivalent replacement claims. Retained changed definitions exercise owned manager lifetime/queue/refcount/clamp and real synthetic process/files/staging boundaries. Seventeen unchanged native Electron definitions across this combined audit are statically reviewed and not rerun. Changed native suites exercise local popup wire/cookie/session and clean/native process-identity behavior; known popup-admission flaws remain unfixed. Unit native/event/session/registry inputs remain injected. No actual OS focus, registry/host execution, CRX verification or durable restart proof. Receipts selecting changed tests are retired and unaffected receipts remain unchanged. Historical scope before this audit: Audit1564 retires copied cookie key recipes, mock bridge/preview forwarding and flag tables, source-spelling/script inventories and setup-only alarms without equivalent replacement coverage. Retained changed cases exercise actual scoped SQLite merge/rollback/schema refusal, owned cancellation and grant authority/canonical containment, ranged response consistency, replacement guest ownership and executed guest-script promise/DOM cleanup behavior. Session, host response, geometry/observer and transport producers remain injected where documented. One unchanged native extension definition is not rerun locally; no actual remote filesystem, native layout/focus or authentication claim. Receipts selecting modified tests are retired; unaffected exact-selector receipts remain unchanged. Historical scope before this audit: Audit1412 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): Audit1386 changes referenced sources; no new execution result validates reduced selectors. Retained unchanged receipts apply only to unchanged commands. Historical coverage: Audit1308 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): A macOS desktop host and a separate paired Electron client exercise the Explorer preview action end to end: the document renders inside the client-local orca-preview guest, the client gains exactly one browser workspace and it is the document one, the host's own page registry and tab snapshot gain nothing, the split does not take focus from the source editor, an explicit click activates the preview, and closing it with its X leaves the editor and terminal intact. Both host oracles are read in a run where an ordinary URL browser tab from the same client does arrive in them, because a host that cannot project browser pages at all would otherwise answer the absence question the same way. A second journey quits that client with a document tab open and relaunches it on the same profile, which is the restore the user's original report turned on: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit. The same journey pins the guest URL to the orca-preview scheme, so a regression back to file:// or to a host browser page is visible. The rewritten journey caught a live gap the old one could not see: since previews became browser pages they had no chrome to hand focus to their guest, so main refused every reported link click as coming from an unfocused guest and the one route out of a preview was dead. Main's own log answered it — registered true, grant live, focused false — after the embedder reported the anchor really taking six real presses. The pane now hands focus over when it is the reader's surface, and the journey routes. The preview's own components moved out of the editor folder into browser-pane/workspace-doc/ with this change, since a preview is a browser pane and no longer an editor one; their i18n keys keep the historical auto.components.editor.HtmlDocPreview prefix rather than dropping thirty translated strings across four locales for a tidier key path. Seven gate commands named those files by their old paths and were re-issued at the new ones, and the nine dated runs behind them were retired rather than left naming files that are no longer there, on the same principle this gate already applied twice: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. Each retired record's finding is carried into the replacement run recorded below it, including the red half of the entry/subresource pair, which was re-measured by re-applying its mutant at the new path rather than being dropped. The fence over the page-management channels was decided channel by channel before it was built, and that decision record is: of the 23 channels that can name a page, 6 are allowed for a workspace-document page — the tool channels the reader drives, which reach it through the one authority that reads both halves of the registry — 9 refuse (download accept and cancel, devtools, certificate proceed, client page metadata publish, viewport override, WebAuthn account response, registration probe, registration repair), 3 cannot name a page at all because they take a profile or a worktree instead, and the rest are the registration and activation doors, refused on membership. What shipped is not the per-channel guard that classification implies: eleven call sites read the browsing map directly, so a guard per channel is exactly the caller-flag seam that has to be remembered at every future call site. The registry was split in two instead, which makes the classification structural — a document guest is absent from the browsing map, so every refusing channel refuses by absence rather than by a guard, and a channel added later inherits the refusal without being classified at all. Unit contracts cover grant scoping and path containment, per-owner read routing, refusal of a truncated or over-cap read, the old-server binary degradation, scheme admission on will-attach-webview, the guest navigation policy, the plan layer's local/SSH/paired/out-of-worktree routing, and the distinct sentence the reader sees for each failure reason. The paired browser-creation surfaces this gate still owns — capability rejection cleanup, canonical page identity, reconciliation rollback, close convergence — keep their existing headed and headless journeys. Linux and Windows live journeys remain gaps. A refused subresource is a notice on a rendered document rather than a failure panel, since only the entry document's failure leaves the reader with nothing; the entry document is served as text by every owner and cannot be refused for its format at all. Grants die with the renderer document that minted them, at window creation and on every reload, because the renderer holds the only record of which preview owns which grant. The mint and revoke channels answer only the trusted renderer, and a preview tab is never republished to mobile clients, which have no grant to read it with. External URLs leave the preview only via a trusted user click on an anchor while the preview is focused; script-driven navigation and popups reach nothing. The guest's navigation sinks are deny-only and route no URL at all, and the guest carries a preview-only preload, pinned by will-attach-webview and denied to every other attach path, whose capture-phase click handler reports an anchor's URL to main; main answers only a report from a preview guest with a live bound grant that is the focused contents, and only for http(s). Two earlier reader-facing records from 2026-08-26, which measured a shape where a subresource failure was filtered out and the panel still carried an unsupported-asset sentence, were retired with that shape rather than left contradicting the 10/10 runs of the command they share. Two more were retired on the same principle when the external-link design changed: a record showing that removing a recent-input gate let the unattended document open two browser tabs, and a record showing that dropping the webview's allowpopups attribute left a target=_blank press with nothing to route. Both measured machinery that no longer exists — the input gate could not tell a script's navigation from the click it followed, which is the case now recorded below, and the preload intercepts the press before Chromium considers a popup at all, so allowpopups was removed. WebRTC is fenced by the guest's IP-handling policy alone: this Chromium answers a webrtc content-security-policy directive as unrecognized and gathers candidates anyway, so the directive is deliberately absent from the served policy and a unit assertion keeps it out. A standalone Electron probe on the same version gathered 7 candidates including a STUN reflexive one without the policy and 0 with it. Two command paths this gate used to carry no longer exist, and their structured evidence was retired with them rather than left naming files that are no longer there: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. The first is src/renderer/src/lib/file-preview-capability-cleanup.test.ts, deleted in STA-5557 when HTML previews stopped creating paired browser tabs: on 2026-08-12 its five-assertion STA-4063 oracle ran red 5/5 on scan e4c278eb52 and on main bc2e30000b, partially green 2/5 on pre-fix PR #14100, and green 5/5 on candidate 14b5877a75; the surviving half of that oracle is web-runtime-browser-capability-cleanup.test.ts, still commanded above. The second is tests/e2e/paired-remote-html-browser-focus.spec.ts, renamed in STA-5557 to paired-remote-html-preview-local-render.spec.ts because the journey no longer proves anything about browser focus: its 2026-08-12 runs established the owning-runtime routing and no-focus-steal behavior of the then-current browser-tab preview (red on main 346e59c879 and a90a18d43d, green on candidates d05217c40d and b7de9c0961, red again with only the side-preview focus or routing seam disabled), and its 2026-08-26 runs are recorded below under the current path. An adversarial pass after the journey landed found four coverage gaps rather than shipping defects, and all four are pinned here. The page url had a third door: the navigation commit wrote whatever url was reported, unfenced, and while nothing reaches it for a document page today it sat one report away from giving a document page a URL that persistence, the publish boundary and the address bar all read. The group projection was the load-bearing half of the mobile publish boundary that no test had: removing it alone left the whole suite green, because no test in it had tab groups, while the phone payload gained a group whose order and active tab named a document tab it was never sent. The focus handoff was covered only in the narrowing direction, so widening it — dropping the page-activity check, or either half of the reader-surface check — survived both suites. And the handoff itself was offered once and never again, so another app taking the front left the preview's one route out shut until the pane remounted, which is a desktop gap rather than a test-only one. The standalone capability-cleanup and browser-placement ledger suites were removed, including their mixed-version rejection, cleanup transfer, capacity and partition alarms. web-runtime-session.test.ts no longer supplies browser creation, rollback or recovery assertions. The grouped workspace-document exclusion remains; the separate no-group publication example was removed. Existing live/browser-store evidence does not establish equivalent replacement alarms. Earlier descriptions of the removed fixtures and their run results are historical. The client-owned-placement and layout-groups suites are removed. The broad mirror fixture no longer supplies preview placement/reservation/move assertions. Browser rows retain pre-response replay and real local-workspace reuse; focus intent retains response ordering and exact-leaf sibling ordering. Supplied browser-layout and placement/focus projection alarms are lost; retained evidence is narrower and not equivalent. The test-value audit retires renderer grant-release mock ledgers, direct creation/title/session-field copies, notification/onboarding calls and several placement/per-page projections. Canonical-ID rejection, document reopen/restore and preview URL fences remain; they do not prove close-to-registry revocation or replace every lost alarm. Historical execution evidence is unchanged. The test-value audit removed the direct legacy editor-preview hydration example from tabs-hydration.test.ts. Dropping legacy preview chrome while preserving the same-document ordinary editor no longer has that alarm. The surviving corrupt-id deduplication case does not cover that migration, and retained preview journeys are not equivalent replacement coverage. Historical run counts and migration mutation findings describe the earlier suite. Audit1308: src/renderer/src/components/editor/EditorPanelShell.header.test.tsx removed. Header visibility and mocked editor-surface inventory alarms disappear. No equivalent replacement coverage is claimed. Audit1312 removes low-value imported specs from aggregate test drivers; old whole-driver execution receipts are archived. Retained registrations are unchanged. No equivalent replacement coverage is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1386: src/renderer/src/lib/doc-preview-grants.test.ts reduced. Repeated-mount mint count, ordinary release/remint and failed-mint cache retry standalone alarms removed; no actual grant server/access test. No equivalent replacement coverage is claimed. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. The existing primary-selector receipt and its claims remain scoped to their unchanged sources; the broad optional command has no new execution result. Audit1412: src/main/browser/guest-lifecycle/manager/browser-manager-guest-policy-profile.test.ts removed. Loses workspace-document in-grant navigation/popup refusal, cleanup/re-attach, cross-registry guest authorization and browsing-map separation alarms. No equivalent browser policy coverage is claimed. No equivalent replacement coverage is claimed. Audit1421 removes browser-address-bar-suggestions.test.ts. Document suggestion title/path matching, recency merging and path-prefix ranking alarms are lost. No equivalent replacement coverage is claimed. Historical receipts remain scoped to unchanged selectors; the reduced command has no new execution result.", + "coverageNotes": "Audit1630 removes low-value Git, GitHub/GitLab and IPC projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1571 removes mock forwarding, capability/schema inventories, private predicates, supplied error/result replays, and filesystem-mocked persistence checks without equivalent replacement claims. Retained changed tests also exercise real local permission-file reread, registry proxy rollback/credential retirement, site display recovery, and pending sender/credential/document admission with injected Electron/UI producers. Retained network tests exercise owned stream credit/cleanup, generation and retirement fences, real loopback proxy/SOCKS bytes and file-backed binding state. SSH/WSL/paired runtime, native sessions, authority producers and storage clears remain injected; simulated relaunch uses filesystem mocks. The unchanged profile-retirement suite was statically reviewed and not rerun. No actual SSH/WSL runtime, native Chromium jar deletion, durable process restart, remote DNS or external site proof. Receipts selecting changed tests are retired and unaffected receipts remain unchanged. Historical scope before this audit: Audit1568 removes API inventories, supplied mock-value/forwarding assertions, implementation-shape tables and negative/setup cases that do not reach their intended boundary, without equivalent replacement claims. Retained changed definitions exercise owned manager lifetime/queue/refcount/clamp and real synthetic process/files/staging boundaries. Seventeen unchanged native Electron definitions across this combined audit are statically reviewed and not rerun. Changed native suites exercise local popup wire/cookie/session and clean/native process-identity behavior; known popup-admission flaws remain unfixed. Unit native/event/session/registry inputs remain injected. No actual OS focus, registry/host execution, CRX verification or durable restart proof. Receipts selecting changed tests are retired and unaffected receipts remain unchanged. Historical scope before this audit: Audit1564 retires copied cookie key recipes, mock bridge/preview forwarding and flag tables, source-spelling/script inventories and setup-only alarms without equivalent replacement coverage. Retained changed cases exercise actual scoped SQLite merge/rollback/schema refusal, owned cancellation and grant authority/canonical containment, ranged response consistency, replacement guest ownership and executed guest-script promise/DOM cleanup behavior. Session, host response, geometry/observer and transport producers remain injected where documented. One unchanged native extension definition is not rerun locally; no actual remote filesystem, native layout/focus or authentication claim. Receipts selecting modified tests are retired; unaffected exact-selector receipts remain unchanged. Historical scope before this audit: Audit1412 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): Audit1386 changes referenced sources; no new execution result validates reduced selectors. Retained unchanged receipts apply only to unchanged commands. Historical coverage: Audit1308 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): A macOS desktop host and a separate paired Electron client exercise the Explorer preview action end to end: the document renders inside the client-local orca-preview guest, the client gains exactly one browser workspace and it is the document one, the host's own page registry and tab snapshot gain nothing, the split does not take focus from the source editor, an explicit click activates the preview, and closing it with its X leaves the editor and terminal intact. Both host oracles are read in a run where an ordinary URL browser tab from the same client does arrive in them, because a host that cannot project browser pages at all would otherwise answer the absence question the same way. A second journey quits that client with a document tab open and relaunches it on the same profile, which is the restore the user's original report turned on: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit. The same journey pins the guest URL to the orca-preview scheme, so a regression back to file:// or to a host browser page is visible. The rewritten journey caught a live gap the old one could not see: since previews became browser pages they had no chrome to hand focus to their guest, so main refused every reported link click as coming from an unfocused guest and the one route out of a preview was dead. Main's own log answered it — registered true, grant live, focused false — after the embedder reported the anchor really taking six real presses. The pane now hands focus over when it is the reader's surface, and the journey routes. The preview's own components moved out of the editor folder into browser-pane/workspace-doc/ with this change, since a preview is a browser pane and no longer an editor one; their i18n keys keep the historical auto.components.editor.HtmlDocPreview prefix rather than dropping thirty translated strings across four locales for a tidier key path. Seven gate commands named those files by their old paths and were re-issued at the new ones, and the nine dated runs behind them were retired rather than left naming files that are no longer there, on the same principle this gate already applied twice: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. Each retired record's finding is carried into the replacement run recorded below it, including the red half of the entry/subresource pair, which was re-measured by re-applying its mutant at the new path rather than being dropped. The fence over the page-management channels was decided channel by channel before it was built, and that decision record is: of the 23 channels that can name a page, 6 are allowed for a workspace-document page — the tool channels the reader drives, which reach it through the one authority that reads both halves of the registry — 9 refuse (download accept and cancel, devtools, certificate proceed, client page metadata publish, viewport override, WebAuthn account response, registration probe, registration repair), 3 cannot name a page at all because they take a profile or a worktree instead, and the rest are the registration and activation doors, refused on membership. What shipped is not the per-channel guard that classification implies: eleven call sites read the browsing map directly, so a guard per channel is exactly the caller-flag seam that has to be remembered at every future call site. The registry was split in two instead, which makes the classification structural — a document guest is absent from the browsing map, so every refusing channel refuses by absence rather than by a guard, and a channel added later inherits the refusal without being classified at all. Unit contracts cover grant scoping and path containment, per-owner read routing, refusal of a truncated or over-cap read, the old-server binary degradation, scheme admission on will-attach-webview, the guest navigation policy, the plan layer's local/SSH/paired/out-of-worktree routing, and the distinct sentence the reader sees for each failure reason. The paired browser-creation surfaces this gate still owns — capability rejection cleanup, canonical page identity, reconciliation rollback, close convergence — keep their existing headed and headless journeys. Linux and Windows live journeys remain gaps. A refused subresource is a notice on a rendered document rather than a failure panel, since only the entry document's failure leaves the reader with nothing; the entry document is served as text by every owner and cannot be refused for its format at all. Grants die with the renderer document that minted them, at window creation and on every reload, because the renderer holds the only record of which preview owns which grant. The mint and revoke channels answer only the trusted renderer, and a preview tab is never republished to mobile clients, which have no grant to read it with. External URLs leave the preview only via a trusted user click on an anchor while the preview is focused; script-driven navigation and popups reach nothing. The guest's navigation sinks are deny-only and route no URL at all, and the guest carries a preview-only preload, pinned by will-attach-webview and denied to every other attach path, whose capture-phase click handler reports an anchor's URL to main; main answers only a report from a preview guest with a live bound grant that is the focused contents, and only for http(s). Two earlier reader-facing records from 2026-08-26, which measured a shape where a subresource failure was filtered out and the panel still carried an unsupported-asset sentence, were retired with that shape rather than left contradicting the 10/10 runs of the command they share. Two more were retired on the same principle when the external-link design changed: a record showing that removing a recent-input gate let the unattended document open two browser tabs, and a record showing that dropping the webview's allowpopups attribute left a target=_blank press with nothing to route. Both measured machinery that no longer exists — the input gate could not tell a script's navigation from the click it followed, which is the case now recorded below, and the preload intercepts the press before Chromium considers a popup at all, so allowpopups was removed. WebRTC is fenced by the guest's IP-handling policy alone: this Chromium answers a webrtc content-security-policy directive as unrecognized and gathers candidates anyway, so the directive is deliberately absent from the served policy and a unit assertion keeps it out. A standalone Electron probe on the same version gathered 7 candidates including a STUN reflexive one without the policy and 0 with it. Two command paths this gate used to carry no longer exist, and their structured evidence was retired with them rather than left naming files that are no longer there: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. The first is src/renderer/src/lib/file-preview-capability-cleanup.test.ts, deleted in STA-5557 when HTML previews stopped creating paired browser tabs: on 2026-08-12 its five-assertion STA-4063 oracle ran red 5/5 on scan e4c278eb52 and on main bc2e30000b, partially green 2/5 on pre-fix PR #14100, and green 5/5 on candidate 14b5877a75; the surviving half of that oracle is web-runtime-browser-capability-cleanup.test.ts, still commanded above. The second is tests/e2e/paired-remote-html-browser-focus.spec.ts, renamed in STA-5557 to paired-remote-html-preview-local-render.spec.ts because the journey no longer proves anything about browser focus: its 2026-08-12 runs established the owning-runtime routing and no-focus-steal behavior of the then-current browser-tab preview (red on main 346e59c879 and a90a18d43d, green on candidates d05217c40d and b7de9c0961, red again with only the side-preview focus or routing seam disabled), and its 2026-08-26 runs are recorded below under the current path. An adversarial pass after the journey landed found four coverage gaps rather than shipping defects, and all four are pinned here. The page url had a third door: the navigation commit wrote whatever url was reported, unfenced, and while nothing reaches it for a document page today it sat one report away from giving a document page a URL that persistence, the publish boundary and the address bar all read. The group projection was the load-bearing half of the mobile publish boundary that no test had: removing it alone left the whole suite green, because no test in it had tab groups, while the phone payload gained a group whose order and active tab named a document tab it was never sent. The focus handoff was covered only in the narrowing direction, so widening it — dropping the page-activity check, or either half of the reader-surface check — survived both suites. And the handoff itself was offered once and never again, so another app taking the front left the preview's one route out shut until the pane remounted, which is a desktop gap rather than a test-only one. The standalone capability-cleanup and browser-placement ledger suites were removed, including their mixed-version rejection, cleanup transfer, capacity and partition alarms. web-runtime-session.test.ts no longer supplies browser creation, rollback or recovery assertions. The grouped workspace-document exclusion remains; the separate no-group publication example was removed. Existing live/browser-store evidence does not establish equivalent replacement alarms. Earlier descriptions of the removed fixtures and their run results are historical. The client-owned-placement and layout-groups suites are removed. The broad mirror fixture no longer supplies preview placement/reservation/move assertions. Browser rows retain pre-response replay and real local-workspace reuse; focus intent retains response ordering and exact-leaf sibling ordering. Supplied browser-layout and placement/focus projection alarms are lost; retained evidence is narrower and not equivalent. The test-value audit retires renderer grant-release mock ledgers, direct creation/title/session-field copies, notification/onboarding calls and several placement/per-page projections. Canonical-ID rejection, document reopen/restore and preview URL fences remain; they do not prove close-to-registry revocation or replace every lost alarm. Historical execution evidence is unchanged. The test-value audit removed the direct legacy editor-preview hydration example from tabs-hydration.test.ts. Dropping legacy preview chrome while preserving the same-document ordinary editor no longer has that alarm. The surviving corrupt-id deduplication case does not cover that migration, and retained preview journeys are not equivalent replacement coverage. Historical run counts and migration mutation findings describe the earlier suite. Audit1308: src/renderer/src/components/editor/EditorPanelShell.header.test.tsx removed. Header visibility and mocked editor-surface inventory alarms disappear. No equivalent replacement coverage is claimed. Audit1312 removes low-value imported specs from aggregate test drivers; old whole-driver execution receipts are archived. Retained registrations are unchanged. No equivalent replacement coverage is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1386: src/renderer/src/lib/doc-preview-grants.test.ts reduced. Repeated-mount mint count, ordinary release/remint and failed-mint cache retry standalone alarms removed; no actual grant server/access test. No equivalent replacement coverage is claimed. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. The existing primary-selector receipt and its claims remain scoped to their unchanged sources; the broad optional command has no new execution result. Audit1412: src/main/browser/guest-lifecycle/manager/browser-manager-guest-policy-profile.test.ts removed. Loses workspace-document in-grant navigation/popup refusal, cleanup/re-attach, cross-registry guest authorization and browsing-map separation alarms. No equivalent browser policy coverage is claimed. No equivalent replacement coverage is claimed. Audit1421 removes browser-address-bar-suggestions.test.ts. Document suggestion title/path matching, recency merging and path-prefix ranking alarms are lost. No equivalent replacement coverage is claimed. Historical receipts remain scoped to unchanged selectors; the reduced command has no new execution result.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-5557/remote-html-preview-render-workspace-files-locally-over-the-file", "https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server", @@ -5776,6 +5776,7 @@ "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake." ], "knownGaps": [ + "Audit1630 removes low-value Git, GitHub/GitLab and IPC projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1571 removes mock forwarding, capability/schema inventories, private predicates, supplied error/result replays, and filesystem-mocked persistence checks without equivalent replacement claims. Retained changed tests also exercise real local permission-file reread, registry proxy rollback/credential retirement, site display recovery, and pending sender/credential/document admission with injected Electron/UI producers. Retained network tests exercise owned stream credit/cleanup, generation and retirement fences, real loopback proxy/SOCKS bytes and file-backed binding state. SSH/WSL/paired runtime, native sessions, authority producers and storage clears remain injected; simulated relaunch uses filesystem mocks. The unchanged profile-retirement suite was statically reviewed and not rerun. No actual SSH/WSL runtime, native Chromium jar deletion, durable process restart, remote DNS or external site proof. Receipts selecting changed tests are retired and unaffected receipts remain unchanged.", "Audit1568 removes API inventories, supplied mock-value/forwarding assertions, implementation-shape tables and negative/setup cases that do not reach their intended boundary, without equivalent replacement claims. Retained changed definitions exercise owned manager lifetime/queue/refcount/clamp and real synthetic process/files/staging boundaries. Seventeen unchanged native Electron definitions across this combined audit are statically reviewed and not rerun. Changed native suites exercise local popup wire/cookie/session and clean/native process-identity behavior; known popup-admission flaws remain unfixed. Unit native/event/session/registry inputs remain injected. No actual OS focus, registry/host execution, CRX verification or durable restart proof. Receipts selecting changed tests are retired and unaffected receipts remain unchanged.", "Audit1564 retires copied cookie key recipes, mock bridge/preview forwarding and flag tables, source-spelling/script inventories and setup-only alarms without equivalent replacement coverage. Retained changed cases exercise actual scoped SQLite merge/rollback/schema refusal, owned cancellation and grant authority/canonical containment, ranged response consistency, replacement guest ownership and executed guest-script promise/DOM cleanup behavior. Session, host response, geometry/observer and transport producers remain injected where documented. One unchanged native extension definition is not rerun locally; no actual remote filesystem, native layout/focus or authentication claim. Receipts selecting modified tests are retired; unaffected exact-selector receipts remain unchanged.", @@ -7697,7 +7698,7 @@ "providers": ["local", "daemon", "ssh", "runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "ssh", "runtime"], - "coverageNotes": "Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A deterministic final-pane interleaving now proves durable tab retirement commits before externally owned PTY teardown can publish graph loss, and preserves an unrelated live canary through duplicate exit retirement. Deterministic tests also cover final-pane CLI close durability and same-incarnation handle continuity; persisted-empty restart suppression remains historical Electron evidence, alongside replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. The retained paired journey covers the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId. The repeated target-after-unrelated-terminal journey was retired; its separate ordering alarm is no longer provided. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here. The synthetic default-tabs unit suite was retired: its explicit-empty/default-tab assertions no longer supply unit evidence. This retirement supplies no new persisted-restart, runtime-routing, cross-platform, or mobile evidence; existing historical runs and counts remain historical. The test-value audit removes the isolated re-retirement-token/epoch, activity-cutoff, idle-reattach and sibling-restore examples. The cited exact retirement and chained-detach cases remain unchanged, alongside closed-tab revival fencing and alias churn bounds. Their retained evidence is not equivalent coverage for every removed permutation; historical execution evidence is unchanged. The test-value audit removed shared-owner snapshot/cold-restore reference examples, a direct absent-tab PTY-index update, caller-supplied host-owned teardown boolean/no-dispatch, and a single local-provider rejection example. It also removed hand-built detach-retention comparator examples for different runs, handles, provider identities, and handle-less rows without independent identity ingress. Their specific snapshot, missing-tab, host-decision, rejection-count, and comparator alarms are lost. Current exact authority/ordering and chained-detach cases remain retained; they do not establish equivalent coverage for the removed examples. Historical commands, counts, results, and red-green narratives remain historical. The test-value audit retires terminals-explicit-empty-hydration.test.ts in full: its direct git/folder empty-tombstone assertions and literal-flag auto-create helper check are lost. The store test never mounted the initial-terminal effect or performed a persisted restart. The audit also removes terminal-tab-retirement.test.ts pure ownership/alias/routing plan inventories and sleeping-record map assertions; its former 100-tab scan-count case was subsequently retired too; no assertion remains in that suite. These retained and historical cases do not establish equivalent tombstone, default-create, provider teardown, cross-platform, or mobile coverage. Historical commands, results, counts, and red-green narratives remain historical. The subsequent test-value audit deletes the remaining terminal-tab-retirement.test.ts suite. Its 100-tab case asserted only returned plan count and one outer scan per ownership store; it never inspected per-tab plans or provider teardown. Those direct cardinality/work-count alarms are intentionally lost. Historical executed commands, results, counts and red-green narratives remain historical; no equivalent replacement or new runtime, platform or mobile evidence is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1392: src/main/agent-hooks/server-pane-authority.test.ts reduced. Retires one duplicate-transfer no-op listener count and latest pty/timestamp callback fixture. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", + "coverageNotes": "Audit1630 removes low-value Git, GitHub/GitLab and IPC projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A deterministic final-pane interleaving now proves durable tab retirement commits before externally owned PTY teardown can publish graph loss, and preserves an unrelated live canary through duplicate exit retirement. Deterministic tests also cover final-pane CLI close durability and same-incarnation handle continuity; persisted-empty restart suppression remains historical Electron evidence, alongside replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. The retained paired journey covers the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId. The repeated target-after-unrelated-terminal journey was retired; its separate ordering alarm is no longer provided. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here. The synthetic default-tabs unit suite was retired: its explicit-empty/default-tab assertions no longer supply unit evidence. This retirement supplies no new persisted-restart, runtime-routing, cross-platform, or mobile evidence; existing historical runs and counts remain historical. The test-value audit removes the isolated re-retirement-token/epoch, activity-cutoff, idle-reattach and sibling-restore examples. The cited exact retirement and chained-detach cases remain unchanged, alongside closed-tab revival fencing and alias churn bounds. Their retained evidence is not equivalent coverage for every removed permutation; historical execution evidence is unchanged. The test-value audit removed shared-owner snapshot/cold-restore reference examples, a direct absent-tab PTY-index update, caller-supplied host-owned teardown boolean/no-dispatch, and a single local-provider rejection example. It also removed hand-built detach-retention comparator examples for different runs, handles, provider identities, and handle-less rows without independent identity ingress. Their specific snapshot, missing-tab, host-decision, rejection-count, and comparator alarms are lost. Current exact authority/ordering and chained-detach cases remain retained; they do not establish equivalent coverage for the removed examples. Historical commands, counts, results, and red-green narratives remain historical. The test-value audit retires terminals-explicit-empty-hydration.test.ts in full: its direct git/folder empty-tombstone assertions and literal-flag auto-create helper check are lost. The store test never mounted the initial-terminal effect or performed a persisted restart. The audit also removes terminal-tab-retirement.test.ts pure ownership/alias/routing plan inventories and sleeping-record map assertions; its former 100-tab scan-count case was subsequently retired too; no assertion remains in that suite. These retained and historical cases do not establish equivalent tombstone, default-create, provider teardown, cross-platform, or mobile coverage. Historical commands, results, counts, and red-green narratives remain historical. The subsequent test-value audit deletes the remaining terminal-tab-retirement.test.ts suite. Its 100-tab case asserted only returned plan count and one outer scan per ownership store; it never inspected per-tab plans or provider teardown. Those direct cardinality/work-count alarms are intentionally lost. Historical executed commands, results, counts and red-green narratives remain historical; no equivalent replacement or new runtime, platform or mobile evidence is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1392: src/main/agent-hooks/server-pane-authority.test.ts reduced. Retires one duplicate-transfer no-op listener count and latest pty/timestamp callback fixture. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-4327", "https://github.com/stablyai/orca/pull/14590", @@ -7912,6 +7913,7 @@ "Add restart/no-resurrection and repeated park-close soak coverage." ], "knownGaps": [ + "Audit1630 removes low-value Git, GitHub/GitLab and IPC projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "The live Electron proof currently covers macOS local PTYs only.", "Disconnected SSH relay death still requires reconnect-aware provider ownership.", @@ -10656,7 +10658,7 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos", "linux"], "coveredProviders": ["local", "ssh", "remote-runtime"], - "coverageNotes": "Audit1602 removes mocked transport, deletion and ownership inventories, provider metadata projections, private callback and counter checks, and duplicate reader examples without equivalent replacement coverage. Retained tests exercise SQLite/FTS migrations, transcript/cache/sidecar changes, recovery and cursor boundaries, local process/WAL/lock/framing and generated scripts. Remote and WSL failures and some delivery races remain injected; in-process reopen does not prove crash recovery, and counters do not prove latency or memory bounds. Historical scope before this audit: Audit1301 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, installs no interval, and re-reads only once per explicit daemon-management kill/restart. AI Vault tab-title sync uses bounded exact identities on the transcript-owning local, SSH, or runtime host and has no access to broad listing; unsupported older hosts retain persisted titles without fallback scans. WSL path translation still needs a Windows integration oracle. Audit1301: src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx removed. Visibility epochs, event identity fencing, retry and no-polling hook-layer alarms disappear. src/renderer/src/components/status-bar/resource-session-inventory.test.ts reduced. Detached-array/count/noop contracts, authoritative refresh variants, batching cadence, duplicate acknowledgements, error/pause combinations and other identity matrices lose their authored alarms. No equivalent replacement coverage is claimed. Audit1302: src/main/ai-vault/parse-cache/session-parse-cache-persistence.test.ts: Keep real filesystem/SQLite persistence, reopen/restart hits, changed-file invalidation, legacy/corrupt-cache recovery, concurrent scope replacement and optional-cache write failure isolation. Remove configuration/sidecar copy inventories, write-call-only timing/row counts, a wall-clock polling case with an internally hung parser, synthetic UNC aging/stat inventory and a no-op mock pretending to be failed database deletion. Lose these private configuration/scheduling/sidecar/UNC/fault wiring alarms; no equivalent replacement. Reduced selectors require fresh validation; prior full-suite receipts do not validate removed or changed coverage. No equivalent replacement or physical-provider claim.", + "coverageNotes": "Audit1630 removes low-value Git, GitHub/GitLab and IPC projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1602 removes mocked transport, deletion and ownership inventories, provider metadata projections, private callback and counter checks, and duplicate reader examples without equivalent replacement coverage. Retained tests exercise SQLite/FTS migrations, transcript/cache/sidecar changes, recovery and cursor boundaries, local process/WAL/lock/framing and generated scripts. Remote and WSL failures and some delivery races remain injected; in-process reopen does not prove crash recovery, and counters do not prove latency or memory bounds. Historical scope before this audit: Audit1301 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, installs no interval, and re-reads only once per explicit daemon-management kill/restart. AI Vault tab-title sync uses bounded exact identities on the transcript-owning local, SSH, or runtime host and has no access to broad listing; unsupported older hosts retain persisted titles without fallback scans. WSL path translation still needs a Windows integration oracle. Audit1301: src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx removed. Visibility epochs, event identity fencing, retry and no-polling hook-layer alarms disappear. src/renderer/src/components/status-bar/resource-session-inventory.test.ts reduced. Detached-array/count/noop contracts, authoritative refresh variants, batching cadence, duplicate acknowledgements, error/pause combinations and other identity matrices lose their authored alarms. No equivalent replacement coverage is claimed. Audit1302: src/main/ai-vault/parse-cache/session-parse-cache-persistence.test.ts: Keep real filesystem/SQLite persistence, reopen/restart hits, changed-file invalidation, legacy/corrupt-cache recovery, concurrent scope replacement and optional-cache write failure isolation. Remove configuration/sidecar copy inventories, write-call-only timing/row counts, a wall-clock polling case with an internally hung parser, synthetic UNC aging/stat inventory and a no-op mock pretending to be failed database deletion. Lose these private configuration/scheduling/sidecar/UNC/fault wiring alarms; no equivalent replacement. Reduced selectors require fresh validation; prior full-suite receipts do not validate removed or changed coverage. No equivalent replacement or physical-provider claim.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/7002", "https://github.com/stablyai/orca/pull/6858", @@ -10696,13 +10698,6 @@ { "file": "src/main/ai-vault/parse-cache/session-parse-cache-persistence.test.ts", "assertions": ["unchanged transcripts remain reusable across process restarts"] - }, - { - "file": "src/main/ipc/ai-vault/ai-vault.test.ts", - "assertions": [ - "exact title identities route to local, SSH, and runtime host authority", - "unsupported remote hosts return no refresh and never fall back to broad scans" - ] } ], "evidenceRuns": [ @@ -10739,6 +10734,7 @@ "Run with enough preserved sessions/providers to make a broad listing observable." ], "knownGaps": [ + "Audit1630 removes low-value Git, GitHub/GitLab and IPC projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1602 removes mocked transport, deletion and ownership inventories, provider metadata projections, private callback and counter checks, and duplicate reader examples without equivalent replacement coverage. Retained tests exercise SQLite/FTS migrations, transcript/cache/sidecar changes, recovery and cursor boundaries, local process/WAL/lock/framing and generated scripts. Remote and WSL failures and some delivery races remain injected; in-process reopen does not prove crash recovery, and counters do not prove latency or memory bounds.", "Current commands cover Resource Manager readiness/lifecycle inventory counts; the broader targeted-liveness slice still lives on the pending fix-terminal-reliability stack.", "Current coverage includes the closed Resource Manager's no-interval and known-reattach budgets, but not every hot interaction listed in the invariant.", diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index a8e013b4c95..f4a42b7d258 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -2087,7 +2087,6 @@ "src/main/git/runner-wsl-linked-gitdir-windows.test.ts": 381, "src/main/git/runner-wsl-login-shell-capture.test.ts": 222, "src/main/git/runner-wsl-network-ssh-policy.test.ts": 260, - "src/main/git/runner.test.ts": 284, "src/main/git/saved-clone-target.test.ts": 890, "src/main/git/source-control/blob-absence-real-git.test.ts": 629, "src/main/git/source-control/complete-merge-real-git.test.ts": 958, @@ -2095,7 +2094,6 @@ "src/main/git/source-control/diff-too-large-real-git.test.ts": 446, "src/main/git/source-control/drive-checkout-suspect-paths.test.ts": 49, "src/main/git/source-control/drive-checkout-suspect-real-git.test.ts": 406, - "src/main/git/source-control/drive-checkout-suspect-status.test.ts": 343, "src/main/git/source-control/drive-checkout-suspect-writes-real-git.test.ts": 691, "src/main/git/source-control/stage-worktree-changes.test.ts": 1737, "src/main/git/source-control/staged-rename-diff-real-git.test.ts": 362, @@ -2107,7 +2105,6 @@ "src/main/git/source-control/status-branch-line-total-exec-contract.test.ts": 1033, "src/main/git/source-control/status-branch-line-total-real-git.test.ts": 785, "src/main/git/source-control/status-branch-line-total-untracked-base.test.ts": 700, - "src/main/git/source-control/status-conflict-operations.test.ts": 250, "src/main/git/source-control/status-cquoted-paths.test.ts": 348, "src/main/git/source-control/status-diff.test.ts": 327, "src/main/git/source-control/status-discard-and-bulk-staging.test.ts": 289, @@ -2121,9 +2118,7 @@ "src/main/git/source-control/status-shared-symlinks.test.ts": 746, "src/main/git/source-control/status-submodule.test.ts": 306, "src/main/git/source-control/status-symlink-probe-budget.test.ts": 238, - "src/main/git/source-control/status-upstream-probe-churn.test.ts": 261, "src/main/git/source-control/status-upstream-ref.test.ts": 52, - "src/main/git/source-control/status.test.ts": 316, "src/main/git/source-control/upstream-deferred-fork-remote-real.test.ts": 319, "src/main/git/source-control/upstream.test.ts": 406, "src/main/git/worktrees/creation/worktree-add-local-base-refresh.test.ts": 362, @@ -2166,7 +2161,6 @@ "src/main/git/worktrees/catalog/worktree-scan-cache-annotation-reuse.test.ts": 361, "src/main/git/worktrees/catalog/worktree-separate-git-dir.test.ts": 689, "src/main/git/worktrees/content-paths/worktree-shared-directories.test.ts": 1045, - "src/main/git/worktrees/sparse-checkout/worktree-sparse-checkout.test.ts": 851, "src/main/git/worktrees/content-paths/worktree-symlink-detection.test.ts": 51, "src/main/git/command-runner/wsl-linked-worktree-git-route-invalidation.test.ts": 48, "src/main/git/wsl-windows-checkout-git-config.test.ts": 64, @@ -2181,18 +2175,12 @@ "src/main/github/client-issue-origin-preference.test.ts": 610, "src/main/github/client-issue-source.test.ts": 663, "src/main/github/client-merge-queue-auto-merge.test.ts": 848, - "src/main/github/client-merged-pr-visibility.test.ts": 644, - "src/main/github/client-pr-branch-discovery.test.ts": 605, "src/main/github/client-pr-check-details.test.ts": 440, - "src/main/github/client-pr-checks.test.ts": 656, - "src/main/github/client-pr-comment-reactions.test.ts": 575, - "src/main/github/client-pr-conflict-summary.test.ts": 608, "src/main/github/client-pr-conversation-requests.test.ts": 533, "src/main/github/client-pr-fallback-number.test.ts": 577, "src/main/github/client-pr-linked-lookup.test.ts": 574, "src/main/github/client-pr-local-runtime.test.ts": 577, "src/main/github/client-pr-lookup-coalescing.test.ts": 1407, - "src/main/github/client-pr-push-target.test.ts": 592, "src/main/github/client-pr-rest-revalidation.test.ts": 535, "src/main/github/client-rate-limit-block.test.ts": 585, "src/main/github/client-stack-merge-guard.test.ts": 644, @@ -2201,20 +2189,17 @@ "src/main/github/client-tracked-upstream-fork-owner.test.ts": 546, "src/main/github/client-tracked-upstream-snapshot.test.ts": 913, "src/main/github/client-work-item-check-summary.test.ts": 536, - "src/main/github/client-work-items-query-paging.test.ts": 543, "src/main/github/client-work-items.test.ts": 551, "src/main/github/client/check/check-details-admission-lifetime.test.ts": 707, "src/main/github/client/check/check-details-admission-snapshot.test.ts": 601, "src/main/github/client/check/check-details-capture-diagnostics.test.ts": 1171, "src/main/github/client/check/check-job-log-download.test.ts": 133, "src/main/github/client/check/check-job-log-execution.test.ts": 202, - "src/main/github/client/check/check-job-log-host-measurement.test.ts": 810, "src/main/github/client/check/check-job-log-shared-pipeline.test.ts": 519, "src/main/github/client/check/check-job-log-subscribers.test.ts": 603, "src/main/github/client/check/check-job-log-tails.test.ts": 616, "src/main/github/comment-reactions.test.ts": 41, "src/main/github/conflict-summary.test.ts": 509, - "src/main/github/default-branch-stale-pr.test.ts": 604, "src/main/github/gh-capability-state.test.ts": 48, "src/main/github/gh-utils-concurrency.test.ts": 530, "src/main/github/gh-utils.test.ts": 570, @@ -2223,7 +2208,6 @@ "src/main/github/github-enterprise-repository.test.ts": 479, "src/main/github/github-pr-stack.test.ts": 495, "src/main/github/github-project-account.test.ts": 547, - "src/main/github/github-remote-identity-parsing.test.ts": 70, "src/main/github/github-repository-identity.fork-owner-repo.test.ts": 478, "src/main/github/github-repository-identity.signed-cache.test.ts": 479, "src/main/github/github-repository-identity.ssh-host-alias.test.ts": 523, @@ -2231,7 +2215,6 @@ "src/main/github/issue-work-item-details-fresh.test.ts": 490, "src/main/github/issues.test.ts": 569, "src/main/github/pr-head-tracking-ref.test.ts": 48, - "src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts": 932, "src/main/github/pr-refresh-coordinator-active-visible-priority.test.ts": 643, "src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts": 1020, "src/main/github/pr-refresh-coordinator-completion-order.test.ts": 914, @@ -2262,7 +2245,6 @@ "src/main/github/repository-merge-metadata-reuse.test.ts": 546, "src/main/github/stacked-pr-creation.test.ts": 490, "src/main/github/work-item-details-enterprise-host.test.ts": 574, - "src/main/github/work-item-details-file-viewed.test.ts": 599, "src/main/github/work-item-details-pr-files.test.ts": 591, "src/main/github/work-item-details-query-reuse.test.ts": 592, "src/main/github/work-item-details.test.ts": 613, @@ -2283,7 +2265,6 @@ "src/main/gitlab/gitlab-partial-auth-recovery.test.ts": 591, "src/main/gitlab/gl-utils.test.ts": 493, "src/main/gitlab/issues.test.ts": 591, - "src/main/gitlab/mappers.test.ts": 71, "src/main/gitlab/merge-request-creation.test.ts": 464, "src/main/gitlab/merge-request-ready.test.ts": 98, "src/main/gitlab/mr-head-tracking-ref.test.ts": 64, diff --git a/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs b/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs index cc9e0316324..57a87df264d 100644 --- a/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs +++ b/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs @@ -125,7 +125,6 @@ const NODE_RUNTIME_INCLUDE = [ 'src/main/git/command-runner/worker-exec-file.test.ts', 'src/main/git/worktrees/removal/worktree-removal-real-git.test.ts', 'src/main/git/worktrees/catalog/worktree-list-paths.test.ts', - 'src/main/git/worktrees/sparse-checkout/worktree-sparse-checkout.test.ts', 'src/main/runtime/workspaces/removal/runtime-failed-local-worktree-removal.test.ts', 'src/main/runtime/workspaces/removal/runtime-interrupted-local-worktree-removal.test.ts', 'src/main/workspaces/removal/local-worktree-removal-recovery-live.test.ts', diff --git a/src/main/git/runner-network-ssh-policy.test.ts b/src/main/git/runner-network-ssh-policy.test.ts index 05feae57942..5a53e5a659f 100644 --- a/src/main/git/runner-network-ssh-policy.test.ts +++ b/src/main/git/runner-network-ssh-policy.test.ts @@ -28,41 +28,6 @@ beforeEach(() => { afterEach(() => _resetGitAdmissionForTests()) describe('network Git SSH policy', () => { - it('probes core.sshCommand for opted-in network git calls', async () => { - const child = createMockChildProcess(1234) - const calls: { args: string[]; env: NodeJS.ProcessEnv }[] = [] - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - calls.push({ args, env: opts.env }) - cb( - null, - args[0] === 'config' - ? 'core.sshcommand\nssh -F ~/.ssh/github-work -i ~/.ssh/work_key\0' - : '', - '' - ) - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(calls[0]?.args).toEqual([ - 'config', - '--null', - '--get-regexp', - '^(core\\.sshcommand|ssh\\.variant)$' - ]) - expect(calls[0]?.env.GIT_TERMINAL_PROMPT).toBe('0') - expect(calls[0]?.env.GIT_SSH_COMMAND).toBeUndefined() - expect(calls[1]?.args).toEqual(['fetch', 'origin']) - expect(calls[1]?.env.GIT_SSH_COMMAND).toBe( - 'ssh -o BatchMode=yes -F ~/.ssh/github-work -i ~/.ssh/work_key' - ) - }) - it('admits the core.sshCommand probe before spawning it', async () => { const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) _resetGitAdmissionForTests(scheduler) @@ -93,176 +58,6 @@ describe('network Git SSH policy', () => { ]) }) - it('replaces configured BatchMode for opted-in mergeable OpenSSH commands', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb(null, 'core.sshcommand\nssh -o BatchMode=no -i ~/.ssh/personal\0', '') - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_SSH_COMMAND).toBe( - 'ssh -o BatchMode=yes -o BatchMode=no -i ~/.ssh/personal' - ) - }) - - it('merges quoted ssh.exe command shapes for opted-in network calls', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb(null, 'core.sshcommand\n"C:/Program Files/Git/usr/bin/ssh.exe" -F ~/.ssh/config\0', '') - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_SSH_COMMAND).toBe( - '"C:/Program Files/Git/usr/bin/ssh.exe" -o BatchMode=yes -F ~/.ssh/config' - ) - }) - - it('merges unquoted Windows ssh.exe paths for opted-in network calls', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb( - null, - `core.sshcommand\n${String.raw`C:\Git\usr\bin\ssh.exe -i C:\Users\me\.ssh\work_key`}\0`, - '' - ) - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_SSH_COMMAND).toBe( - String.raw`'C:\Git\usr\bin\ssh.exe' -o BatchMode=yes -i 'C:\Users\me\.ssh\work_key'` - ) - }) - - it('passes through unmergeable core.sshCommand wrappers without generic fallback', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb(null, 'core.sshcommand\n/usr/local/bin/work-ssh-wrapper --account work\0', '') - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') - expect(capturedEnv?.GIT_ASKPASS).toBe('') - expect(capturedEnv?.SSH_ASKPASS).toBe('') - expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined() - }) - - it('passes through shell-expanding OpenSSH configs without changing expansion semantics', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb(null, 'core.sshcommand\nssh -i "$HOME/.ssh/work_key"\0', '') - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') - expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined() - }) - - it('falls back to generic batch-mode SSH when opted-in config is unset', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb(Object.assign(new Error('missing'), { code: 1 }), '', '') - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes') - }) - - it.each([ - { GIT_SSH_COMMAND: 'custom-ssh -o IdentityAgent=none' }, - { GIT_SSH: 'custom-ssh-wrapper' } - ])('preserves explicit SSH environment and skips the config probe (%j)', async (env) => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, _args, opts, cb) => { - capturedEnv = opts.env - cb(null, '', '') - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env, - useConfiguredSshCommandForNetwork: true - }) - - expect(execFileMock).toHaveBeenCalledTimes(1) - expect(capturedEnv?.GIT_SSH_COMMAND).toBe(env.GIT_SSH_COMMAND) - expect(capturedEnv?.GIT_SSH).toBe(env.GIT_SSH) - expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') - }) - it.each(['ETIMEDOUT', 'ENOBUFS', 'ABORT_ERR'])( 'does not select a generic SSH command after a %s config failure', async (code) => { diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index 581cc3d08c0..35cdcca65c6 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -16,15 +16,16 @@ vi.mock('../observability/instrumentation', () => ({ withGitSpan: (_attributes: unknown, run: () => unknown) => run() })) vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) - -import { getBranchConflictKind } from './repo-branch-conflict' -import { pendingWslDirectGitReadEnvironment } from './command-runner/git-command-resolution' -import { gitExecFileAsync, gitExecFileAsyncBuffer, gitSpawn, gitStreamStdout } from './runner' +import { gitExecFileAsync, gitExecFileAsyncBuffer, gitStreamStdout } from './runner' import { GitAdmissionScheduler } from '../../shared/git/admission/git-admission-scheduler' import { _resetGitAdmissionForTests } from './command-runner/git-subprocess-admission' import type { GitAdmissionEvent } from '../../shared/git/admission/git-admission-state' import { - getWslGitReadEnvironment, + recordHostAutocrlf, + resetWslWindowsCheckoutGitConfigForTests, + wslWindowsCheckoutKey +} from './wsl-windows-checkout-git-config' +import { resetWslGitReadEnvironmentForTests, seedWslGitReadEnvironmentForTests, WSL_GIT_READ_ENVIRONMENT_WAIT_MS @@ -64,14 +65,6 @@ async function withPlatform(platform: NodeJS.Platform, run: () => Promise) } } -function succeedExecFile(stdout = 'ok'): void { - execFileMock.mockImplementation((_command, _args, _options, callback) => { - const child = createMockChild() - queueMicrotask(() => callback?.(null, stdout, '')) - return child - }) -} - describe('WSL direct Git reads', () => { beforeEach(() => { execFileMock.mockReset() @@ -79,54 +72,14 @@ describe('WSL direct Git reads', () => { spawnMock.mockReset() resetWslGitReadEnvironmentForTests() resetWslLinkedWorktreeGitRoutingForTests() + resetWslWindowsCheckoutGitConfigForTests() + recordHostAutocrlf(wslWindowsCheckoutKey(String.raw`C:\repo`, DISTRO), 'other', Date.now()) }) afterEach(() => { resetWslGitReadEnvironmentForTests() resetWslLinkedWorktreeGitRoutingForTests() - }) - - it('coalesces the login-shell environment probe per distro', async () => { - let completeProbe: ((error: Error | null, stdout: string, stderr: string) => void) | undefined - execFileMock.mockImplementation((_command, _args, _options, callback) => { - completeProbe = callback - return createMockChild() - }) - - const first = getWslGitReadEnvironment(DISTRO) - const second = getWslGitReadEnvironment(DISTRO) - expect(execFileMock).toHaveBeenCalledTimes(1) - completeProbe?.( - null, - fencedProbeStdout( - execFileMock.mock.calls[0]?.[1]?.[5], - '/home/user/bin:/usr/bin\0/home/user/bin/git\0/home/user' - ), - '' - ) - - await expect(Promise.all([first, second])).resolves.toEqual([ - { gitPath: '/home/user/bin/git', home: '/home/user', path: '/home/user/bin:/usr/bin' }, - { gitPath: '/home/user/bin/git', home: '/home/user', path: '/home/user/bin:/usr/bin' } - ]) - expect(execFileMock.mock.calls[0]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc']) - expect(execFileMock.mock.calls[0]?.[1]?.[5]).toContain('^GIT_') - }) - - it('keeps reads with custom Git environment policy on the login shell', async () => { - await withPlatform('win32', async () => { - seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) - succeedExecFile() - - await gitExecFileAsync(['status', '--short'], { - cwd: String.raw`C:\repo`, - env: { GIT_CONFIG_GLOBAL: '/home/user/custom.gitconfig' }, - preferWslDirectGit: true, - wslDistro: DISTRO - }) - - expect(execFileMock.mock.calls[0]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc']) - }) + resetWslWindowsCheckoutGitConfigForTests() }) it('fences parsed output from a barrier Git login-shell fallback', async () => { @@ -233,26 +186,6 @@ describe('WSL direct Git reads', () => { }) }) - it('stops waiting for a cold probe as soon as the read aborts', async () => { - await withPlatform('win32', async () => { - vi.useFakeTimers() - try { - execFileMock.mockImplementation(() => createMockChild()) - const controller = new AbortController() - - const pending = pendingWslDirectGitReadEnvironment(['show', ':src/file.ts'], { - cwd: String.raw`\\wsl.localhost\Ubuntu\repo`, - signal: controller.signal - }) - controller.abort() - - await expect(pending).resolves.toBeNull() - } finally { - vi.useRealTimers() - } - }) - }) - it('waits for direct Git to close before retrying through the login shell', async () => { await withPlatform('win32', async () => { const admissionEvents: GitAdmissionEvent[] = [] @@ -303,146 +236,6 @@ describe('WSL direct Git reads', () => { }) }) - it('falls back and disables the fast path after another direct Git failure', async () => { - await withPlatform('win32', async () => { - seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - const child = createMockChild() - queueMicrotask(() => { - callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'helper failed') - child.emit('close', 128, null) - }) - return child - }) - execFileMock.mockImplementation((_command, _args, _options, callback) => { - const child = createMockChild() - queueMicrotask(() => { - callback?.(null, 'ok', '') - child.emit('close', 0, null) - }) - return child - }) - const options = { - cwd: String.raw`C:\repo`, - preferWslDirectGit: true as const, - wslDistro: DISTRO - } - - await gitExecFileAsync(['status', '--short'], options) - await gitExecFileAsync(['status', '--short'], options) - - expect(execFileMock.mock.calls[0]?.[1]).toContain('--exec') - expect(execFileMock.mock.calls[1]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc']) - expect(execFileMock.mock.calls[2]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc']) - }) - }) - - it('checks a missing branch conflict without retrying through a login shell', async () => { - await withPlatform('win32', async () => { - seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) - execFileMock.mockImplementation((_command, args: string[], _options, callback) => { - const child = createMockChild() - queueMicrotask(() => { - const missingRef = args.join(' ').includes('rev-parse') - const quiet = args.includes('--quiet') - const code = missingRef ? (quiet ? 1 : 128) : 0 - callback?.( - code ? Object.assign(new Error('missing ref'), { code }) : null, - '', - missingRef && !quiet ? 'fatal: Needed a single revision' : '' - ) - child.emit('close', code, null) - }) - return child - }) - - await expect( - getBranchConflictKind(String.raw`\\wsl.localhost\Ubuntu\repo`, 'new-feature') - ).resolves.toBeNull() - expect(execFileMock).toHaveBeenCalledTimes(3) - expect(execFileMock.mock.calls[0]?.[1]).toContain('--quiet') - }) - }) - - it('keeps the fast path when direct and login Git both report an expected failure', async () => { - await withPlatform('win32', async () => { - seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) - execFileMock - .mockImplementationOnce((_command, _args, _options, callback) => { - const child = createMockChild() - queueMicrotask(() => { - callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'missing ref') - child.emit('close', 128, null) - }) - return child - }) - .mockImplementationOnce((_command, _args, _options, callback) => { - const child = createMockChild() - queueMicrotask(() => { - callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'missing ref') - child.emit('close', 128, null) - }) - return child - }) - .mockImplementationOnce((_command, _args, _options, callback) => { - const child = createMockChild() - queueMicrotask(() => { - callback?.(null, 'ok', '') - child.emit('close', 0, null) - }) - return child - }) - const options = { - cwd: String.raw`C:\repo`, - preferWslDirectGit: true as const, - wslDistro: DISTRO - } - - await expect(gitExecFileAsync(['rev-parse', '--verify', 'missing'], options)).rejects.toThrow( - 'exit 128' - ) - await gitExecFileAsync(['status', '--short'], options) - - expect(execFileMock.mock.calls[0]?.[1]).toContain('--exec') - expect(execFileMock.mock.calls[1]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc']) - expect(execFileMock.mock.calls[2]?.[1]).toContain('--exec') - }) - }) - - it('retries a direct stream only when no stdout was delivered', async () => { - await withPlatform('win32', async () => { - seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) - spawnMock - .mockImplementationOnce(() => { - const child = createMockChild() - queueMicrotask(() => { - child.stderr.emit('data', Buffer.from('/usr/bin/env: No such file or directory')) - child.emit('close', 127) - }) - return child - }) - .mockImplementationOnce(() => { - const child = createMockChild() - queueMicrotask(() => { - child.stdout.emit('data', Buffer.from('# branch.head main\n')) - child.emit('close', 0) - }) - return child - }) - - await gitStreamStdout(['status', '--porcelain=v2'], { - cwd: String.raw`C:\repo`, - onStdout: () => {}, - preferWslDirectGit: true, - wslDistro: DISTRO - }) - - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock.mock.calls[0]?.[1]).toContain('--exec') - expect(spawnMock.mock.calls[1]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc']) - }) - }) - it('does not retry a failed direct stream after delivering output', async () => { await withPlatform('win32', async () => { seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) @@ -468,64 +261,6 @@ describe('WSL direct Git reads', () => { }) }) - it('keeps network reads on the login shell even if opted in', async () => { - await withPlatform('win32', async () => { - seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) - succeedExecFile() - - await gitExecFileAsync(['fetch', '--dry-run'], { - cwd: String.raw`C:\repo`, - preferWslDirectGit: true, - useConfiguredSshCommandForNetwork: true, - wslDistro: DISTRO - }) - - expect( - execFileMock.mock.calls.every((call) => call[1]?.slice(3, 5).join(' ') === 'sh -lc') - ).toBe(true) - }) - }) - - it('keeps gitSpawn cache-only while async linked-worktree discovery is pending', async () => { - await withPlatform('win32', async () => { - let releaseStat: (() => void) | undefined - const delayedStat = new Promise((resolve) => { - releaseStat = resolve - }) - const fileSystem: WslLinkedWorktreeRoutingFileSystem = { - stat: vi.fn(async () => { - await delayedStat - return { isDirectory: () => false, isFile: () => true } - }), - readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') - } - const pending = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { - platform: 'win32', - fileSystem - }) - spawnMock.mockReturnValue(createMockChild()) - - gitSpawn(['ls-files'], { - cwd: String.raw`C:\repo`, - wslDistro: DISTRO, - stdio: ['ignore', 'pipe', 'pipe'] - }) - - expect(fileSystem.stat).toHaveBeenCalledTimes(1) - expect(spawnMock.mock.calls[0]?.[0]).toBe('wsl.exe') - releaseStat?.() - await expect(pending).resolves.toBe(true) - - gitSpawn(['ls-files'], { - cwd: String.raw`C:\repo`, - wslDistro: DISTRO, - stdio: ['ignore', 'pipe', 'pipe'] - }) - expect(fileSystem.stat).toHaveBeenCalledTimes(1) - expect(spawnMock.mock.calls[1]?.[0]).toBe('git') - }) - }) - it.each([gitExecFileAsync, gitExecFileAsyncBuffer])( 'aborts an async Git read while linked-worktree discovery remains pending (%s)', async (readGit) => { diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 1a4b3a525ff..c347a2f43a7 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -30,13 +30,7 @@ vi.mock('../wsl/discovery/distro-catalog', async (importOriginal) => ({ ...(await importOriginal()), getDefaultWslDistro: getDefaultWslDistroMock })) - -import { - ghExecFileAsync, - ghExecFileWithScopeAsync, - glabExecFileAsync, - setDefaultWslDistroOverride -} from './runner' +import { ghExecFileAsync, glabExecFileAsync, setDefaultWslDistroOverride } from './runner' import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' const PRIMARY_RATE_LIMIT_STDERR = @@ -74,41 +68,6 @@ describe('ghExecFileAsync WSL fallback', () => { }) }) - it('falls back to host gh for explicit-repo WSL calls when gh is missing in the distro', async () => { - spawnMock.mockImplementation( - fakeSpawnDispatch((program) => - program === 'wsl.exe' ? { stderr: WSL_GH_MISSING, code: 1 } : { stdout: '[]' } - ) - ) - - await expect( - ghExecFileAsync(['issue', 'list', '--repo', 'stablyhq/noqa', '--json', 'number,title'], { - cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa` - }) - ).resolves.toEqual({ stdout: '[]', stderr: '' }) - - expect(spawnMock).toHaveBeenNthCalledWith( - 1, - 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--exec', - 'bash', - '-c', - "cd '/home/jinwoo/stably/noqa' && 'gh' 'issue' 'list' '--repo' 'stablyhq/noqa' '--json' 'number,title'" - ], - // Use a stable Win32 cwd instead of inheriting Orca's deletable UNC cwd. - expect.objectContaining({ cwd: expect.any(String) }) - ) - expect(spawnMock).toHaveBeenNthCalledWith( - 2, - 'gh', - ['issue', 'list', '--repo', 'stablyhq/noqa', '--json', 'number,title'], - expect.objectContaining({ cwd: undefined }) - ) - }) - it('does not fall back for repo-context gh calls without explicit repo context', async () => { spawnMock.mockImplementation(fakeSpawnReturning({ stderr: WSL_GH_MISSING, code: 1 })) @@ -121,73 +80,6 @@ describe('ghExecFileAsync WSL fallback', () => { expect(spawnMock).toHaveBeenCalledTimes(1) }) - it('falls back for short-form explicit repo flags used by gh', async () => { - spawnMock.mockImplementation( - fakeSpawnDispatch((program) => - program === 'wsl.exe' ? { stderr: WSL_GH_MISSING, code: 1 } : { stdout: '[]' } - ) - ) - - await expect( - ghExecFileAsync(['issue', 'list', '-R', 'stablyhq/noqa'], { - cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa` - }) - ).resolves.toEqual({ stdout: '[]', stderr: '' }) - - expect(spawnMock).toHaveBeenNthCalledWith( - 2, - 'gh', - ['issue', 'list', '-R', 'stablyhq/noqa'], - expect.objectContaining({ cwd: undefined }) - ) - }) - - it('falls back for compact short-form repo flags used by gh', async () => { - spawnMock.mockImplementation( - fakeSpawnDispatch((program) => - program === 'wsl.exe' ? { stderr: WSL_GH_MISSING, code: 1 } : { stdout: '[]' } - ) - ) - - await expect( - ghExecFileAsync(['issue', 'list', '-Rstablyhq/noqa'], { - cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa` - }) - ).resolves.toEqual({ stdout: '[]', stderr: '' }) - - expect(spawnMock).toHaveBeenNthCalledWith( - 2, - 'gh', - ['issue', 'list', '-Rstablyhq/noqa'], - expect.objectContaining({ cwd: undefined }) - ) - }) - - it('falls back for repo view with an explicit positional repository', async () => { - spawnMock.mockImplementation( - fakeSpawnDispatch((program) => - program === 'wsl.exe' ? { stderr: WSL_GH_MISSING, code: 1 } : { stdout: '{"isFork":false}' } - ) - ) - - await expect( - ghExecFileAsync( - ['repo', 'view', 'github.acme-corp.com/stablyhq/noqa', '--json', 'isFork,parent'], - { - cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa`, - host: 'github.acme-corp.com' - } - ) - ).resolves.toEqual({ stdout: '{"isFork":false}', stderr: '' }) - - expect(spawnMock).toHaveBeenNthCalledWith( - 2, - 'gh', - ['repo', 'view', 'github.acme-corp.com/stablyhq/noqa', '--json', 'isFork,parent'], - expect.objectContaining({ cwd: undefined }) - ) - }) - it('does not fall back for gh api calls that depend on repo-context placeholders', async () => { spawnMock.mockImplementation(fakeSpawnReturning({ stderr: WSL_GH_MISSING, code: 1 })) @@ -200,45 +92,6 @@ describe('ghExecFileAsync WSL fallback', () => { expect(spawnMock).toHaveBeenCalledTimes(1) }) - it('retries idempotent gh GraphQL query transient failures', async () => { - spawnMock - .mockImplementationOnce(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 })) - .mockImplementationOnce(fakeSpawnReturning({ stdout: '{"data":{}}' })) - - await expect( - ghExecFileAsync(['api', 'graphql', '-f', 'query=query { viewer { login } }']) - ).resolves.toEqual({ stdout: '{"data":{}}', stderr: '' }) - - expect(spawnMock).toHaveBeenCalledTimes(2) - }) - - it('retries a host-pinned idempotent gh GraphQL query after host injection', async () => { - spawnMock - .mockImplementationOnce(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 })) - .mockImplementationOnce(fakeSpawnReturning({ stdout: '{"data":{}}' })) - - await expect( - ghExecFileAsync(['api', 'graphql', '-f', 'query=query { viewer { login } }'], { - host: 'github.acme-corp.com' - }) - ).resolves.toEqual({ stdout: '{"data":{}}', stderr: '' }) - - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock).toHaveBeenNthCalledWith( - 1, - 'gh', - [ - 'api', - '--hostname', - 'github.acme-corp.com', - 'graphql', - '-f', - 'query=query { viewer { login } }' - ], - expect.any(Object) - ) - }) - it('does not retry non-idempotent gh API transient failures', async () => { spawnMock.mockImplementation(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 })) @@ -274,59 +127,6 @@ describe('ghExecFileAsync WSL fallback', () => { expect(spawnMock).toHaveBeenCalledTimes(1) }) - it('retries cwd-less gh calls through the default WSL distro when host gh is missing', async () => { - getDefaultWslDistroMock.mockReturnValue('Ubuntu') - spawnMock - .mockImplementationOnce(fakeSpawnReturning(spawnEnoent('gh'))) - .mockImplementationOnce(fakeSpawnReturning({ stdout: '{"resources":{}}' })) - - await expect(ghExecFileAsync(['api', 'rate_limit'])).resolves.toEqual({ - stdout: '{"resources":{}}', - stderr: '' - }) - - expect(spawnMock).toHaveBeenNthCalledWith( - 2, - 'wsl.exe', - ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'gh' 'api' 'rate_limit'"], - // Global calls still need a stable Win32 spawn cwd. - expect.objectContaining({ cwd: expect.any(String) }) - ) - }) - - it('returns the WSL scope after a missing native gh falls back', async () => { - getDefaultWslDistroMock.mockReturnValue('Ubuntu') - spawnMock - .mockImplementationOnce(fakeSpawnReturning(spawnEnoent('gh'))) - .mockImplementationOnce(fakeSpawnReturning({ stdout: 'gho_wsl' })) - - await expect( - ghExecFileWithScopeAsync(['auth', 'token', '--hostname', 'github.com']) - ).resolves.toEqual({ - stdout: 'gho_wsl', - stderr: '', - rateLimitScope: 'wsl:ubuntu:github.com' - }) - expect(spawnMock).toHaveBeenCalledTimes(2) - }) - - it('returns the native scope after a missing WSL gh falls back', async () => { - spawnMock - .mockImplementationOnce(fakeSpawnReturning({ stderr: WSL_GH_MISSING, code: 127 })) - .mockImplementationOnce(fakeSpawnReturning({ stdout: 'gho_native' })) - - await expect( - ghExecFileWithScopeAsync(['auth', 'token', '--hostname', 'github.com'], { - wslDistro: 'Ubuntu' - }) - ).resolves.toEqual({ - stdout: 'gho_native', - stderr: '', - rateLimitScope: 'native:github.com' - }) - expect(spawnMock).toHaveBeenCalledTimes(2) - }) - it('checks a blocked WSL scope before repeating a native-to-WSL fallback', async () => { getDefaultWslDistroMock.mockReturnValue('Ubuntu') spawnMock.mockImplementation( @@ -379,38 +179,6 @@ describe('ghExecFileAsync WSL fallback', () => { expect(spawnMock).toHaveBeenCalledTimes(1) }) - it('does not retry high-level glab update transient failures', async () => { - spawnMock.mockImplementation(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 })) - - await expect( - glabExecFileAsync(['issue', 'update', '5', '-R', 'stablyai/orca'], { - cwd: String.raw`C:\repo` - }) - ).rejects.toThrow('HTTP 502 Bad Gateway') - - expect(spawnMock).toHaveBeenCalledTimes(1) - }) - - it('retries cwd-less glab calls through the default WSL distro when host glab is missing', async () => { - getDefaultWslDistroMock.mockReturnValue('Ubuntu') - spawnMock - .mockImplementationOnce(fakeSpawnReturning(spawnEnoent('glab'))) - .mockImplementationOnce(fakeSpawnReturning({ stdout: '[]' })) - - await expect(glabExecFileAsync(['api', 'projects'])).resolves.toEqual({ - stdout: '[]', - stderr: '' - }) - - expect(spawnMock).toHaveBeenNthCalledWith( - 2, - 'wsl.exe', - ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'api' 'projects'"], - // Global calls still need a stable Win32 spawn cwd. - expect.objectContaining({ cwd: expect.any(String) }) - ) - }) - it('times out the default-WSL glab fallback and waits for full tree cleanup', async () => { vi.useFakeTimers() getDefaultWslDistroMock.mockReturnValue('Ubuntu') @@ -494,72 +262,4 @@ describe('ghExecFileAsync WSL fallback', () => { expect.objectContaining({ cwd: undefined }) ) }) - - it('still retries idempotent glab transient failures', async () => { - spawnMock - .mockImplementationOnce(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 })) - .mockImplementationOnce(fakeSpawnReturning({ stdout: '[]' })) - - await expect( - glabExecFileAsync(['api', 'projects/stablyai%2Forca/issues'], { - cwd: String.raw`C:\repo` - }) - ).resolves.toEqual({ stdout: '[]', stderr: '' }) - - expect(spawnMock).toHaveBeenCalledTimes(2) - }) - - it('resolves fallback to the overridden distro if configured, and falls back to default WSL distro otherwise', async () => { - setDefaultWslDistroOverride('Debian') - getDefaultWslDistroMock.mockReturnValue('Ubuntu') - - spawnMock - .mockImplementationOnce(fakeSpawnReturning(spawnEnoent('gh'))) - .mockImplementationOnce( - fakeSpawnDispatch((program, args) => - program === 'wsl.exe' && args.includes('Debian') - ? { stdout: 'Logged in to github.com as override' } - : { stderr: 'Wrong distro fallback', code: 1 } - ) - ) - - await expect(ghExecFileAsync(['auth', 'status'])).resolves.toEqual({ - stdout: 'Logged in to github.com as override', - stderr: '' - }) - - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock).toHaveBeenNthCalledWith( - 2, - 'wsl.exe', - ['-d', 'Debian', '--exec', 'bash', '-c', "'gh' 'auth' 'status'"], - expect.any(Object) - ) - - spawnMock.mockClear() - setDefaultWslDistroOverride(null) - - spawnMock - .mockImplementationOnce(fakeSpawnReturning(spawnEnoent('gh'))) - .mockImplementationOnce( - fakeSpawnDispatch((program, args) => - program === 'wsl.exe' && args.includes('Ubuntu') - ? { stdout: 'Logged in to github.com as default' } - : { stderr: 'Wrong distro fallback', code: 1 } - ) - ) - - await expect(ghExecFileAsync(['auth', 'status'])).resolves.toEqual({ - stdout: 'Logged in to github.com as default', - stderr: '' - }) - - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock).toHaveBeenNthCalledWith( - 2, - 'wsl.exe', - ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'gh' 'auth' 'status'"], - expect.any(Object) - ) - }) }) diff --git a/src/main/git/runner-wsl-network-ssh-policy.test.ts b/src/main/git/runner-wsl-network-ssh-policy.test.ts index d0eb762330a..167fa4fc3de 100644 --- a/src/main/git/runner-wsl-network-ssh-policy.test.ts +++ b/src/main/git/runner-wsl-network-ssh-policy.test.ts @@ -91,27 +91,6 @@ afterEach(() => { }) describe('WSL network Git SSH policy startup', () => { - it('allows a three-second shell startup and preserves global config arguments', async () => { - const calls = installShell() - const args = ['-c', `core.sshCommand=${SSH_COMMAND}`, ...FETCH_ARGS] - const pending = gitExecFileAsync(args, { cwd: WSL_CWD, wslDistro: 'Ubuntu', env: {} }) - - await vi.advanceTimersByTimeAsync(6500) - await expect(pending).resolves.toEqual({ stdout: 'fetch-ok', stderr: '' }) - expect(calls).toHaveLength(2) - const probeScript = calls[0].script - expect(probeScript).toContain(`core.sshCommand=${SSH_COMMAND}`) - expect(probeScript.indexOf("'-c'")).toBeGreaterThan(-1) - expect(probeScript.indexOf("'-c'")).toBeLessThan(probeScript.indexOf('core.sshCommand=')) - expect(probeScript.indexOf('core.sshCommand=')).toBeLessThan(probeScript.indexOf("'config'")) - expect(calls[0].script).toContain('__ORCA_WSL_CAPTURE_BEGIN_') - expect(calls[0].env.GIT_SSH_COMMAND).toBeUndefined() - expect(networkCalls(calls)[0].env.GIT_SSH_COMMAND).toBe( - SSH_COMMAND.replace('ssh', 'ssh -o BatchMode=yes') - ) - expect(networkCalls(calls)[0].env.WSLENV?.split(':')).toContain('GIT_SSH_COMMAND') - }) - it('honors an explicit shorter timeout before the network command starts', async () => { const calls = installShell() const pending = fetch({ timeout: 1000 }).catch((error) => error) @@ -133,27 +112,6 @@ describe('WSL network Git SSH policy startup', () => { expect(networkCalls(calls)).toHaveLength(0) }) - it.each([undefined, 0])('keeps the native probe deadline with timeout %s', async (timeout) => { - const calls = installShell() - const pending = gitExecFileAsync(FETCH_ARGS, { cwd: 'C:\\repo', env: {}, timeout }).catch( - (error) => error - ) - - await vi.advanceTimersByTimeAsync(2700) - expect(await pending).toMatchObject({ message: 'git timed out.' }) - expect(calls).toHaveLength(1) - expect(calls[0].child.kill).toHaveBeenCalledOnce() - }) - - it('uses generic batch mode only when config reports a missing value', async () => { - const calls = installShell({ configError: Object.assign(new Error('missing'), { code: 1 }) }) - const pending = fetch() - - await vi.advanceTimersByTimeAsync(6500) - await expect(pending).resolves.toMatchObject({ stdout: 'fetch-ok' }) - expect(networkCalls(calls)[0].env.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes') - }) - it.each([128, 'ETIMEDOUT', 'ENOBUFS'])( 'keeps configuration failure %s closed after a slow startup', async (code) => { @@ -168,16 +126,6 @@ describe('WSL network Git SSH policy startup', () => { } ) - it.each(['simple', 'putty'])('preserves configured %s variant behavior', async (variant) => { - const calls = installShell({ variant }) - const pending = fetch() - - await vi.advanceTimersByTimeAsync(6500) - await expect(pending).resolves.toMatchObject({ stdout: 'fetch-ok' }) - expect(networkCalls(calls)).toHaveLength(1) - expect(networkCalls(calls)[0].env.GIT_SSH_COMMAND).toBeUndefined() - }) - it('aborts the slow probe and releases admission for the next attempt', async () => { const calls = installShell() const controller = new AbortController() @@ -204,15 +152,4 @@ describe('WSL network Git SSH policy startup', () => { await expect(fetch({ signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' }) expect(calls).toHaveLength(0) }) - - it('preserves explicitly forwarded SSH environment and skips the probe', async () => { - const calls = installShell() - const pending = fetch({ env: { GIT_SSH_COMMAND: SSH_COMMAND, WSLENV: 'GIT_SSH_COMMAND' } }) - - await vi.advanceTimersByTimeAsync(3500) - await expect(pending).resolves.toMatchObject({ stdout: 'fetch-ok' }) - expect(calls).toHaveLength(1) - expect(networkCalls(calls)[0].env.GIT_SSH_COMMAND).toBe(SSH_COMMAND) - expect(networkCalls(calls)[0].env.WSLENV?.split(':')).toContain('GIT_SSH_COMMAND') - }) }) diff --git a/src/main/git/runner.test.ts b/src/main/git/runner.test.ts deleted file mode 100644 index c4e1cf7547c..00000000000 --- a/src/main/git/runner.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { mergeGitConfigEnvProtocol } from '../../shared/git/commands/git-credential-prompt-env' -import { - extractExecError, - isTransientGhError, - nonInteractiveGitEnv, - parseRetryAfterMs, - promptGuardGitEnv, - promptGuardShellEnv, - redirectPortedHostnameToEnv, - untranslatedGitOutputEnv -} from './runner' - -describe('redirectPortedHostnameToEnv', () => { - it('moves a ported --hostname into GITLAB_HOST and strips the flag', () => { - const { args, options } = redirectPortedHostnameToEnv( - ['api', '--hostname', 'gitlab.example.com:8443', 'projects/foo%2Fbar/issues'], - { cwd: '/repo' } - ) - expect(args).toEqual(['api', 'projects/foo%2Fbar/issues']) - expect(options.env?.GITLAB_HOST).toBe('gitlab.example.com:8443') - expect(options.cwd).toBe('/repo') - }) - - it('leaves a port-less --hostname untouched', () => { - const input = ['api', '--hostname', 'gitlab.com', 'user'] - const { args, options } = redirectPortedHostnameToEnv(input, {}) - expect(args).toEqual(input) - expect(options.env).toBeUndefined() - }) - - it('is a no-op when no --hostname is present', () => { - const input = ['auth', 'status'] - const { args, options } = redirectPortedHostnameToEnv(input, { env: { A: '1' } }) - expect(args).toEqual(input) - expect(options.env).toEqual({ A: '1' }) - }) - - it('preserves existing env entries alongside GITLAB_HOST', () => { - const { options } = redirectPortedHostnameToEnv( - ['auth', 'status', '--hostname', 'gl.example.org:3001'], - { env: { PATH: '/usr/bin' } } - ) - expect(options.env?.PATH).toBe('/usr/bin') - expect(options.env?.GITLAB_HOST).toBe('gl.example.org:3001') - }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('parseRetryAfterMs', () => { - it('returns null when no Retry-After is present', () => { - expect(parseRetryAfterMs('HTTP 429 Too Many Requests')).toBeNull() - }) - - it('parses integer seconds', () => { - expect(parseRetryAfterMs('HTTP 429\nRetry-After: 30\n')).toBe(30_000) - }) - - it('handles case-insensitive header name and surrounding whitespace', () => { - expect(parseRetryAfterMs(' retry-after: 12 \n')).toBe(12_000) - }) - - it('parses large stderr output without full-string retry-after matching', () => { - const matchSpy = vi.spyOn(String.prototype, 'match') - const stderr = `${'noise\n'.repeat(10_000)}Retry-After: 12\n` - - expect(parseRetryAfterMs(stderr)).toBe(12_000) - const usedRetryAfterMatch = matchSpy.mock.calls.some( - ([pattern]) => - pattern instanceof RegExp && - pattern.source.startsWith('retry-after:') && - pattern.source.includes('[^\\r\\n]') - ) - expect(usedRetryAfterMatch).toBe(false) - }) - - it('returns null for malformed values', () => { - expect(parseRetryAfterMs('Retry-After: not-a-date')).toBeNull() - }) -}) - -describe('isTransientGhError', () => { - it('retries 5xx errors', () => { - expect(isTransientGhError('HTTP 502 Bad Gateway')).toBe(true) - expect(isTransientGhError('http 503')).toBe(true) - }) - - it('retries network resets', () => { - expect(isTransientGhError('connect ECONNRESET 10.0.0.1:443')).toBe(true) - expect(isTransientGhError('socket hang up')).toBe(true) - }) - - it('retries 429 without Retry-After', () => { - expect(isTransientGhError('HTTP 429 Too Many Requests')).toBe(true) - }) - - it('does NOT retry 429 with Retry-After', () => { - expect(isTransientGhError('HTTP 429 Too Many Requests\nRetry-After: 60\n')).toBe(false) - }) - - it("does NOT retry 4xx that aren't 429", () => { - expect(isTransientGhError('HTTP 401 Unauthorized')).toBe(false) - expect(isTransientGhError('HTTP 404 Not Found')).toBe(false) - expect(isTransientGhError('HTTP 422 Unprocessable Entity')).toBe(false) - }) -}) - -describe('extractExecError', () => { - it('reads stderr and stdout from explicit fields', () => { - const err = Object.assign(new Error('Command failed'), { - stderr: 'real stderr content', - stdout: '{"data": null}' - }) - expect(extractExecError(err)).toEqual({ - stderr: 'real stderr content', - stdout: '{"data": null}' - }) - }) - - it('decodes Buffer stderr/stdout', () => { - const err = Object.assign(new Error('Command failed'), { - stderr: Buffer.from('buf-stderr', 'utf-8'), - stdout: Buffer.from('buf-stdout', 'utf-8') - }) - expect(extractExecError(err)).toEqual({ - stderr: 'buf-stderr', - stdout: 'buf-stdout' - }) - }) - - it('falls back to err.message when stderr/stdout are absent', () => { - const err = new Error('Some message') - expect(extractExecError(err)).toEqual({ - stderr: 'Some message', - stdout: '' - }) - }) - - it('handles non-Error rejections', () => { - expect(extractExecError('plain string error')).toEqual({ - stderr: 'plain string error', - stdout: '' - }) - }) -}) - -describe('mergeGitConfigEnvProtocol', () => { - it('replaces inherited indexed config atomically when an override has a smaller count', () => { - const env = mergeGitConfigEnvProtocol( - { - GIT_CONFIG_COUNT: '2', - GIT_CONFIG_KEY_0: 'base.zero', - GIT_CONFIG_VALUE_0: 'zero', - GIT_CONFIG_KEY_1: 'base.one', - GIT_CONFIG_VALUE_1: 'one' - }, - { - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'override.zero', - GIT_CONFIG_VALUE_0: 'override' - } - ) - - expect(env.GIT_CONFIG_COUNT).toBe('1') - expect(env.GIT_CONFIG_KEY_0).toBe('override.zero') - expect(env.GIT_CONFIG_KEY_1).toBeUndefined() - expect(env.GIT_CONFIG_VALUE_1).toBeUndefined() - }) -}) - -describe('promptGuardGitEnv credential-interactivity disable (STA-1292)', () => { - it('disables the GCM GUI prompt without nuking the credential helper', () => { - const env = promptGuardGitEnv({ PATH: '/usr/bin' }) - expect(env.GCM_INTERACTIVE).toBe('never') - // Scalar guards only: env scrubs can split an indexed config protocol (#26594). - expect(env.GIT_CONFIG_COUNT).toBeUndefined() - expect(env.GIT_TERMINAL_PROMPT).toBe('0') - }) -}) - -describe('nonInteractiveGitEnv credential-interactivity disable (STA-1292)', () => { - it('carries the credential-interactivity disable through from promptGuardGitEnv', () => { - const env = nonInteractiveGitEnv({ PATH: '/usr/bin' }) - expect(env.GCM_INTERACTIVE).toBe('never') - expect(env.GIT_CONFIG_COUNT).toBeUndefined() - expect(env.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes') - }) -}) - -describe('guard-env WSLENV forwarding (#7652)', () => { - it('registers the guard vars in WSLENV on Windows so WSL-routed git imports them', () => { - const env = promptGuardGitEnv( - { - PATH: '/usr/bin', - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'http.proxy', - GIT_CONFIG_VALUE_0: 'http://proxy.invalid' - }, - 'win32' - ) - const keys = (env.WSLENV ?? '').split(':') - expect(keys).toContain('GIT_TERMINAL_PROMPT') - expect(keys).toContain('GCM_INTERACTIVE') - // A caller's indexed config is forwarded as a complete set. - expect(keys).toContain('GIT_CONFIG_COUNT') - expect(keys).toContain('GIT_CONFIG_KEY_0') - expect(keys).toContain('GIT_CONFIG_VALUE_0') - // Windows askpass paths are meaningless inside a distro. - expect(keys).not.toContain('GIT_ASKPASS') - expect(keys).not.toContain('SSH_ASKPASS') - }) - - it('preserves a caller-set WSLENV instead of clobbering it', () => { - const env = promptGuardGitEnv({ PATH: '/usr/bin', WSLENV: 'MY_VAR/p' }, 'win32') - const keys = (env.WSLENV ?? '').split(':') - expect(keys[0]).toBe('MY_VAR/p') - expect(keys).toContain('GIT_TERMINAL_PROMPT') - }) - - it('does not touch WSLENV on non-Windows hosts', () => { - const env = promptGuardGitEnv({ PATH: '/usr/bin' }, 'darwin') - expect(env.WSLENV).toBeUndefined() - }) - - it('forwards GIT_SSH_COMMAND only when nonInteractiveGitEnv set the default itself', () => { - const defaulted = nonInteractiveGitEnv({ PATH: '/usr/bin' }, 'win32') - expect((defaulted.WSLENV ?? '').split(':')).toContain('GIT_SSH_COMMAND') - - // A caller's Windows-specific ssh command must not leak into the distro. - const callerSet = nonInteractiveGitEnv( - { PATH: '/usr/bin', GIT_SSH_COMMAND: 'C:\\ssh\\ssh.exe' }, - 'win32' - ) - expect((callerSet.WSLENV ?? '').split(':')).not.toContain('GIT_SSH_COMMAND') - }) -}) - -describe('promptGuardShellEnv keeps the shell locale (#7652 x #7808)', () => { - it('guards without pinning the locale — a terminal env is the whole shell, not just git', () => { - const env = promptGuardShellEnv({ PATH: '/usr/bin', LC_ALL: 'ja_JP.UTF-8' }, 'win32') - expect(env.GIT_TERMINAL_PROMPT).toBe('0') - expect(env.GCM_INTERACTIVE).toBe('never') - expect((env.WSLENV ?? '').split(':')).toContain('GIT_TERMINAL_PROMPT') - expect(env.LC_ALL).toBe('ja_JP.UTF-8') - expect(env.LANG).toBeUndefined() - expect(env.LANGUAGE).toBeUndefined() - }) -}) - -describe('git env forces untranslated diagnostics (issue #7808)', () => { - it('overrides an inherited non-English locale so stderr parsers keep working', () => { - const env = promptGuardGitEnv({ PATH: '/usr/bin', LC_ALL: 'de_DE.UTF-8' }) - expect(env.LC_ALL).toBe('en_US.UTF-8') - expect(env.LANG).toBe('en_US.UTF-8') - }) - - it('pins LANGUAGE, which outranks LC_ALL in gettext lookups', () => { - const env = untranslatedGitOutputEnv({ PATH: '/usr/bin', LANGUAGE: 'de:en' }) - expect(env.LANGUAGE).toBe('en') - expect(env.LC_ALL).toBe('en_US.UTF-8') - }) - - it('applies to nonInteractiveGitEnv as well', () => { - const env = nonInteractiveGitEnv({ PATH: '/usr/bin', LANG: 'fr_FR.UTF-8' }) - expect(env.LC_ALL).toBe('en_US.UTF-8') - expect(env.LANGUAGE).toBe('en') - }) -}) - -describe('redirectPortedHostnameToEnv WSLENV forwarding', () => { - it('names GITLAB_HOST in WSLENV so it can cross into a distro', async () => { - const { redirectPortedHostnameToEnv } = await import('./runner') - const { options } = redirectPortedHostnameToEnv( - ['api', '--hostname', 'gitlab.example.com:8443'], - { env: { PATH: '/usr/bin' } } - ) - expect(options.env?.GITLAB_HOST).toBe('gitlab.example.com:8443') - expect((options.env?.WSLENV ?? '').split(':')).toContain('GITLAB_HOST') - }) -}) diff --git a/src/main/git/source-control/drive-checkout-suspect-status.test.ts b/src/main/git/source-control/drive-checkout-suspect-status.test.ts deleted file mode 100644 index 155389d1dda..00000000000 --- a/src/main/git/source-control/drive-checkout-suspect-status.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as CheckoutGitConfig from '../wsl-windows-checkout-git-config' -import type * as GitRunner from '../command-runner/git-stream-stdout' -import type * as WatcherObservers from '../../ipc/filesystem/watchers/local-watcher-event-observers' -import type { GitStatusEntry } from '../../../shared/git/status/git-status-types' -import { notifyLocalWatcherEvents } from '../../ipc/filesystem/watchers/local-watcher-event-observers' -import type { WatchedRoot } from '../../ipc/filesystem/watchers/filesystem-watcher-wsl' -import { createDebouncedBatch } from '../../ipc/filesystem/watchers/filesystem-watcher-batch-control' - -type StreamMock = ( - args: string[], - options: { onStdout: (chunk: string) => void } -) => Promise<{ stoppedEarly: boolean }> - -const mocks = vi.hoisted(() => { - const state: { - appliesTo: Set - coveringRoot: WatchedRoot | null - gitStreamStdout: ReturnType> - } = { appliesTo: new Set(), coveringRoot: null, gitStreamStdout: vi.fn() } - return state -}) - -vi.mock('../wsl-windows-checkout-git-config', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - withWslWindowsCheckoutGitConfig: (args: string[], cwd: string | undefined) => - cwd && mocks.appliesTo.has(cwd) ? ['-c', 'core.checkStat=minimal', ...args] : args - } -}) - -vi.mock('../../ipc/filesystem/watchers/local-watcher-event-observers', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, findCoveringLocalWatchRoot: () => mocks.coveringRoot } -}) - -vi.mock('../command-runner/git-stream-stdout', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, gitStreamStdout: mocks.gitStreamStdout } -}) - -const { - driveCheckoutSuspectTrackerForTests, - mergeSuspectStatus, - reconcileDriveCheckoutSuspects, - resetDriveCheckoutSuspectsForTests -} = await import('./drive-checkout-suspect-status') - -function watchRoot(rootPath: string): WatchedRoot { - return { - subscription: { unsubscribe: async () => {} }, - listeners: new Map(), - batch: createDebouncedBatch(), - rootPath - } -} - -const unstaged = (filePath: string, extra: Partial = {}): GitStatusEntry => ({ - path: filePath, - status: 'modified', - area: 'unstaged', - ...extra -}) - -describe('mergeSuspectStatus', () => { - it('overrides unstaged rows only for checked paths and keeps staged and untracked rows', () => { - const minimal: GitStatusEntry[] = [ - { path: 'a.ts', status: 'modified', area: 'staged' }, - unstaged('b.ts'), - unstaged('keep.ts'), - { path: 'new.ts', status: 'untracked', area: 'untracked' } - ] - const merged = mergeSuspectStatus(minimal, new Set(['a.ts', 'b.ts', 'c.ts']), [ - unstaged('a.ts'), - unstaged('c.ts'), - unstaged('keep.ts') - ]) - expect(merged).toEqual([ - { path: 'a.ts', status: 'modified', area: 'staged' }, - unstaged('a.ts'), - unstaged('keep.ts'), - unstaged('c.ts'), - { path: 'new.ts', status: 'untracked', area: 'untracked' } - ]) - }) - - it('carries a staged rename source onto the rechecked unstaged row', () => { - const minimal: GitStatusEntry[] = [ - { path: 'new.ts', status: 'renamed', area: 'staged', oldPath: 'old.ts' } - ] - expect(mergeSuspectStatus(minimal, new Set(['new.ts']), [unstaged('new.ts')])).toEqual([ - minimal[0], - unstaged('new.ts', { oldPath: 'old.ts' }) - ]) - }) -}) - -describe('reconcileDriveCheckoutSuspects', () => { - let repo: string - let root: WatchedRoot - - beforeEach(async () => { - repo = await mkdtemp(path.join(tmpdir(), 'orca-suspects-')) - root = watchRoot(repo) - mocks.appliesTo = new Set([repo]) - mocks.coveringRoot = root - mocks.gitStreamStdout.mockReset() - }) - - afterEach(async () => { - resetDriveCheckoutSuspectsForTests() - await rm(repo, { recursive: true, force: true }) - }) - - async function touchFiles(names: string[]): Promise { - for (const name of names) { - await mkdir(path.dirname(path.join(repo, name)), { recursive: true }) - await writeFile(path.join(repo, name), 'x') - } - } - - function changed(names: string[]): void { - notifyLocalWatcherEvents( - root, - names.map((name) => ({ type: 'update', path: path.join(repo, name) })) - ) - } - - function answer(dirty: Set): void { - mocks.gitStreamStdout.mockImplementation(async (args, options) => { - for (const arg of args) { - const target = arg.startsWith(':(literal)') ? arg.slice(':(literal)'.length) : null - if (target && dirty.has(target)) { - options.onStdout(`1 .M N... 100644 100644 100644 aaa aaa ${target}\n`) - } - } - return { stoppedEarly: false } - }) - } - - it('rechecks only suspects minimal status reported clean, keeping the dirty ones', async () => { - await touchFiles(['clean.ts', 'hidden edit.ts', 'already.ts']) - await reconcileDriveCheckoutSuspects(repo, [], {}) - changed(['clean.ts', 'hidden edit.ts', 'already.ts', '.git/index']) - answer(new Set(['hidden edit.ts'])) - - const entries = await reconcileDriveCheckoutSuspects(repo, [unstaged('already.ts')], {}) - - expect(entries).toEqual([unstaged('already.ts'), unstaged('hidden edit.ts')]) - const [args] = mocks.gitStreamStdout.mock.calls[0] - expect(args).toContain('core.checkStat=default') - expect(args.filter((arg) => arg.startsWith(':(literal)'))).toEqual([ - ':(literal)clean.ts', - ':(literal)hidden edit.ts' - ]) - const snapshot = driveCheckoutSuspectTrackerForTests(repo)?.beginPoll(root) - expect([...(snapshot?.keys() ?? [])]).toEqual(['hidden edit.ts']) - }) - - it('skips directories and vanished paths without spawning git', async () => { - await mkdir(path.join(repo, 'dir')) - await reconcileDriveCheckoutSuspects(repo, [], {}) - changed(['dir', 'gone.ts']) - expect(await reconcileDriveCheckoutSuspects(repo, [], {})).toEqual([]) - expect(mocks.gitStreamStdout).not.toHaveBeenCalled() - expect(driveCheckoutSuspectTrackerForTests(repo)?.size).toBe(0) - }) - - it('batches a large suspect set across several pathspec-limited commands', async () => { - const names = Array.from({ length: 250 }, (_, index) => `src/f${index}.ts`) - await touchFiles(names) - await reconcileDriveCheckoutSuspects(repo, [], {}) - changed(names) - answer(new Set(['src/f7.ts', 'src/f249.ts'])) - - const entries = await reconcileDriveCheckoutSuspects(repo, [], {}) - - expect(mocks.gitStreamStdout.mock.calls.length).toBeGreaterThan(1) - const pathspecs = mocks.gitStreamStdout.mock.calls.flatMap(([args]) => - args.filter((arg) => arg.startsWith(':(literal)')) - ) - expect(pathspecs).toHaveLength(250) - expect(entries.map((entry) => entry.path).sort()).toEqual(['src/f249.ts', 'src/f7.ts']) - }) - - it('keeps suspects when the recheck fails', async () => { - await touchFiles(['a.ts']) - await reconcileDriveCheckoutSuspects(repo, [], {}) - changed(['a.ts']) - mocks.gitStreamStdout.mockRejectedValue(new Error('spawn failed')) - expect(await reconcileDriveCheckoutSuspects(repo, [], {})).toEqual([]) - expect(driveCheckoutSuspectTrackerForTests(repo)?.size).toBe(1) - }) - - it('stays minimal-only when no watcher covers the checkout', async () => { - await touchFiles(['a.ts']) - await reconcileDriveCheckoutSuspects(repo, [], {}) - changed(['a.ts']) - mocks.coveringRoot = null - expect(await reconcileDriveCheckoutSuspects(repo, [], {})).toEqual([]) - expect(mocks.gitStreamStdout).not.toHaveBeenCalled() - expect(driveCheckoutSuspectTrackerForTests(repo)?.lastDegradeReason).toBe('no-watcher') - }) -}) diff --git a/src/main/git/source-control/drive-checkout-suspect-writes-real-git.test.ts b/src/main/git/source-control/drive-checkout-suspect-writes-real-git.test.ts index 83305b1784b..91f42d66f23 100644 --- a/src/main/git/source-control/drive-checkout-suspect-writes-real-git.test.ts +++ b/src/main/git/source-control/drive-checkout-suspect-writes-real-git.test.ts @@ -153,17 +153,6 @@ describe('drive checkout suspect writes with real Git', () => { expect(mocks.execArgs).toContainEqual(['add', '--pathspec-from-file=-', '--pathspec-file-nul']) }) - it('keeps writes outside the suspect set on minimal stat alone', async () => { - await rewriteSuspect('c d.txt', 'bbbb\n') - await writeFile(path.join(repo, 'a.txt'), 'longer content\n') - - await stageFile(repo, 'a.txt') - await bulkStageFiles(repo, ['a.txt', 'sub']) - - expect(await stagedPaths()).toEqual(['a.txt']) - expect(defaultStatWrites()).toEqual([]) - }) - it('stages a suspect through a scoped stage-all', async () => { await rewriteSuspect('c d.txt', 'bbbb\n') @@ -193,17 +182,4 @@ describe('drive checkout suspect writes with real Git', () => { ['-c', 'core.checkStat=default', 'restore', '--worktree', '--', ':(literal)sub/e.txt'] ]) }) - - it('leaves an untracked suspect to the main write', async () => { - expect(await statusPaths()).toEqual([]) - await writeOld('new.txt', 'nnnn\n') - notifyLocalWatcherEvents(root, [{ type: 'create', path: path.join(repo, 'new.txt') }]) - expect(await statusPaths()).toEqual(['untracked:new.txt']) - mocks.execArgs = [] - - await stageFile(repo, 'new.txt') - - expect(await stagedPaths()).toEqual(['new.txt']) - expect(defaultStatWrites()).toEqual([]) - }) }) diff --git a/src/main/git/source-control/stage-worktree-changes.test.ts b/src/main/git/source-control/stage-worktree-changes.test.ts index 7848b51070b..812439a0913 100644 --- a/src/main/git/source-control/stage-worktree-changes.test.ts +++ b/src/main/git/source-control/stage-worktree-changes.test.ts @@ -127,19 +127,6 @@ describe('stageWorktreeChanges', () => { expect(names(repo, ['diff', '--name-only'])).toEqual(['outside.txt']) }) - it('leaves unresolved conflicts unstaged in a nested folder too', async () => { - const repo = await createConflictRepo() - await mkdir(path.join(repo, 'sub')) - await writeFile(path.join(repo, 'sub', 'new.txt'), 'new\n') - - await stageWorktreeChanges(path.join(repo, 'sub'), 'all') - - expect(names(repo, ['diff', '--name-only', '--diff-filter=U'])).toEqual(['conflict.txt']) - expect(names(repo, ['diff', '--cached', '--name-only', '--diff-filter=A'])).toEqual([ - 'sub/new.txt' - ]) - }) - describe('Orca shared symlinks', () => { async function createSharedLinkWorktree(): Promise<{ repo: string; owner: string }> { const repo = await createRepo() @@ -165,42 +152,8 @@ describe('stageWorktreeChanges', () => { expect(names(repo, ['ls-files', '--others', '--exclude-standard'])).toEqual(['node_modules']) }) - it('never stages the shared link while a conflict is unresolved', async () => { - const repo = await createConflictRepo() - const owner = await mkdtemp(path.join(tmpdir(), 'orca-stage-owner-')) - tempRoots.push(owner) - await symlink(owner, path.join(repo, 'node_modules')) - - await stageWorktreeChanges(repo, 'all', { sharedLinkPaths: ['node_modules'] }) - - expect(names(repo, ['diff', '--cached', '--name-only', '--diff-filter=A'])).toEqual([ - 'untracked.txt' - ]) - }) - // Shared Paths preserves typed spellings; Git reports canonical relative names. describe.each(['./node_modules', 'node_modules/'])('saved as %s', (sharedPath) => { - it('never stages the shared link', async () => { - const { repo } = await createSharedLinkWorktree() - - await stageWorktreeChanges(repo, 'all', { sharedLinkPaths: [sharedPath] }) - - expect(names(repo, ['diff', '--cached', '--name-only'])).toEqual(['new.txt']) - }) - - it('never stages the shared link while a conflict is unresolved', async () => { - const repo = await createConflictRepo() - const owner = await mkdtemp(path.join(tmpdir(), 'orca-stage-owner-')) - tempRoots.push(owner) - await symlink(owner, path.join(repo, 'node_modules')) - - await stageWorktreeChanges(repo, 'all', { sharedLinkPaths: [sharedPath] }) - - expect(names(repo, ['diff', '--cached', '--name-only', '--diff-filter=A'])).toEqual([ - 'untracked.txt' - ]) - }) - it('never stages the shared link from an over-cap listing with a conflict', async () => { const repo = await createConflictRepo() const owner = await mkdtemp(path.join(tmpdir(), 'orca-stage-owner-')) diff --git a/src/main/git/source-control/status-branch-compare.test.ts b/src/main/git/source-control/status-branch-compare.test.ts index a190d1e0bae..d4a2486c006 100644 --- a/src/main/git/source-control/status-branch-compare.test.ts +++ b/src/main/git/source-control/status-branch-compare.test.ts @@ -81,7 +81,6 @@ vi.mock('../../../shared/node-bounded-file-reader', async (importOriginal) => ) import { getBranchCompare } from './branch-compare' -import { getCommitCompare } from './commit-compare' describe('getBranchCompare', () => { beforeEach(() => { @@ -158,39 +157,6 @@ describe('getBranchCompare', () => { }) } - it.each([40, 64])( - 'skips change and count reads for identical %i-character commit tips', - async (length) => { - const oid = 'a'.repeat(length) - mockBranchCompareGit({ - branch: 'feature\n', - probe: { 'refs/remotes/origin/main^{commit}': `${oid}\n` }, - headOid: `${oid}\n`, - baseOid: `${oid}\n`, - mergeBase: `${oid}\n` - }) - - await expect(getBranchCompare('/repo', 'origin/main')).resolves.toEqual({ - summary: { - baseRef: 'origin/main', - baseOid: oid, - compareRef: 'feature', - headOid: oid, - mergeBase: oid, - changedFiles: 0, - commitsAhead: 0, - commitsBehind: 0, - status: 'ready' - }, - entries: [] - }) - expect(gitExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'merge-base')).toBe(true) - expect( - gitExecFileAsyncMock.mock.calls.some(([args]) => ['diff', 'rev-list'].includes(args[0])) - ).toBe(false) - } - ) - it('keeps cancellation effective when equal-tip validation settles', async () => { const oid = 'a'.repeat(40) const controller = new AbortController() @@ -214,329 +180,5 @@ describe('getBranchCompare', () => { ).toBe(false) }) - it('returns a pinned branch compare snapshot and parsed branch entries', async () => { - mockBranchCompareGit({ - branch: 'main\n', - probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' }, - headOid: 'head-oid\n', - baseOid: 'base-oid\n', - mergeBase: 'merge-base-oid\n', - nameStatus: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n', - numstat: - '10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n', - revList: '4\t7\n' - }) - - const result = await getBranchCompare('/repo', 'origin/main') - - expect(result.summary).toEqual({ - baseRef: 'origin/main', - baseOid: 'base-oid', - compareRef: 'main', - headOid: 'head-oid', - mergeBase: 'merge-base-oid', - changedFiles: 3, - commitsAhead: 7, - commitsBehind: 4, - status: 'ready' - }) - expect(result.entries).toEqual([ - { path: 'file-a.ts', status: 'modified', added: 10, removed: 2 }, - { path: 'new-name.ts', oldPath: 'old-name.ts', status: 'renamed', added: 1, removed: 1 }, - { path: 'new-copy.ts', oldPath: 'old-copy.ts', status: 'copied', added: 3, removed: 0 } - ]) - }) - - it('counts both divergence directions from one symmetric rev-list', async () => { - mockBranchCompareGit({ - branch: 'main\n', - probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' }, - headOid: 'head-oid\n', - baseOid: 'base-oid\n', - mergeBase: 'merge-base-oid\n', - nameStatus: '', - numstat: '', - revList: '12\t33\n' - }) - - const result = await getBranchCompare('/repo', 'origin/main') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['rev-list', '--left-right', '--count', 'base-oid...head-oid'], - expect.objectContaining({ cwd: '/repo' }) - ) - expect(result.summary.commitsAhead).toBe(33) - expect(result.summary.commitsBehind).toBe(12) - }) - - it('returns invalid-base when the compare ref does not resolve', async () => { - mockBranchCompareGit({ - branch: 'main\n', - probe: { - 'refs/remotes/origin/missing^{commit}': new Error('missing remote base'), - 'refs/heads/origin/missing^{commit}': new Error('missing local base') - }, - headOid: 'head-oid\n', - baseOid: new Error('missing base') - }) - - const result = await getBranchCompare('/repo', 'origin/missing') - - expect(result.summary.status).toBe('invalid-base') - expect(result.summary.errorMessage).toContain('origin/missing') - expect(result.entries).toEqual([]) - }) - - it('returns unborn-head when HEAD cannot be resolved', async () => { - mockBranchCompareGit({ - branch: 'main\n', - probe: { 'refs/remotes/origin/main^{commit}': new Error('missing base') }, - headOid: new Error('unborn'), - baseOid: new Error('missing base') - }) - - const result = await getBranchCompare('/repo', 'origin/main') - - expect(result.summary.status).toBe('unborn-head') - expect(result.summary.errorMessage).toContain('committed HEAD') - expect(result.entries).toEqual([]) - }) - - it('treats an unborn branch with a resolvable base as having no committed branch changes', async () => { - mockBranchCompareGit({ - branch: 'feature\n', - probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' }, - headOid: new Error('unborn'), - baseOid: 'base-oid\n' - }) - - const result = await getBranchCompare('/repo', 'origin/main') - - expect(result.summary).toEqual({ - baseRef: 'origin/main', - baseOid: 'base-oid', - compareRef: 'feature', - headOid: null, - mergeBase: null, - changedFiles: 0, - commitsAhead: 0, - commitsBehind: 0, - status: 'ready' - }) - expect(result.entries).toEqual([]) - }) - - it('returns no-merge-base when histories do not intersect', async () => { - mockBranchCompareGit({ - branch: 'main\n', - probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' }, - headOid: 'head-oid\n', - baseOid: 'base-oid\n', - mergeBase: new Error('no merge base') - }) - - const result = await getBranchCompare('/repo', 'origin/main') - - expect(result.summary.status).toBe('no-merge-base') - expect(result.summary.errorMessage).toContain('merge base') - expect(result.entries).toEqual([]) - }) - - it('loads UTF-8 paths and line counts with one diff', async () => { - mockBranchCompareGit({ - branch: 'main\n', - probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' }, - headOid: 'head-oid\n', - baseOid: 'base-oid\n', - mergeBase: 'merge-base-oid\n', - nameStatus: 'M\tdocs/日本語/sample.md\n', - numstat: '2\t1\tdocs/日本語/sample.md\n', - revList: '0\t1\n' - }) - - const result = await getBranchCompare('/repo', 'origin/main') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['diff', '--raw', '--numstat', '-z', '-M', '-C', 'merge-base-oid', 'head-oid', '--'], - expect.objectContaining({ cwd: '/repo' }) - ) - expect(result.entries).toEqual([ - { path: 'docs/日本語/sample.md', status: 'modified', added: 2, removed: 1 } - ]) - }) - - it('treats an empty probe result as an unresolved base ref', async () => { - mockBranchCompareGit({ - branch: 'main\n', - probe: { - 'refs/remotes/origin/main^{commit}': '\n', - 'refs/heads/origin/main^{commit}': '\n' - }, - headOid: 'head-oid\n', - baseOid: new Error('missing base') - }) - - const result = await getBranchCompare('/repo', 'origin/main') - - expect(result.summary.status).toBe('invalid-base') - }) - // Keep distinct probe results to catch accidental reuse of the losing ref's OID. - it('reuses only the oid of the ref the probe actually resolved', async () => { - mockBranchCompareGit({ - branch: 'feature\n', - probe: { - 'refs/remotes/origin/main^{commit}': new Error('no remote-tracking ref'), - 'refs/heads/origin/main^{commit}': 'local-branch-oid\n' - }, - headOid: 'head-oid\n', - mergeBase: 'merge-base-oid\n', - nameStatus: '', - numstat: '', - revList: '0\t0\n' - }) - - const result = await getBranchCompare('/repo', 'origin/main') - - expect(result.summary).toMatchObject({ - baseOid: 'local-branch-oid', - status: 'ready' - }) - }) - - it('resolves an already-qualified base ref without a probe', async () => { - mockBranchCompareGit({ - branch: 'main\n', - headOid: 'head-oid\n', - baseOid: 'qualified-base-oid\n', - mergeBase: 'merge-base-oid\n', - nameStatus: '', - numstat: '', - revList: '0\t0\n' - }) - - const result = await getBranchCompare('/repo', 'refs/remotes/origin/main') - - expect(result.summary).toMatchObject({ - baseOid: 'qualified-base-oid', - status: 'ready' - }) - }) - - it('resolves remote-tracking refs separately after probing their commit target', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'branch') { - return Promise.resolve({ stdout: 'feature\n' }) - } - if ( - args[0] === 'rev-parse' && - args.includes('--quiet') && - args.includes('refs/remotes/origin/main^{commit}') - ) { - return Promise.resolve({ stdout: 'peeled-base-oid\n' }) - } - if (args[0] === 'rev-parse' && args.includes('HEAD')) { - return Promise.resolve({ stdout: 'head-oid\n' }) - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) { - return Promise.resolve({ stdout: 'raw-base-oid\n' }) - } - if (args[0] === 'merge-base') { - return Promise.resolve({ stdout: 'merge-base-oid\n' }) - } - if (args.includes('--raw')) { - return Promise.resolve({ stdout: '' }) - } - if (args[0] === 'rev-list') { - return Promise.resolve({ stdout: '0\n' }) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const result = await getBranchCompare('/repo', 'origin/main') - - expect(result.summary).toMatchObject({ - baseRef: 'origin/main', - baseOid: 'raw-base-oid', - status: 'ready' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], - { cwd: '/repo' } - ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['rev-parse', '--verify', '--end-of-options', 'refs/remotes/origin/main'], - { cwd: '/repo' } - ) - }) - - it('attaches counts for branch compare paths containing rename markers', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'branch') { - return Promise.resolve({ stdout: 'main\n' }) - } - if (args[0] === 'rev-parse' && args.includes('HEAD')) { - return Promise.resolve({ stdout: 'head-oid\n' }) - } - if (args[0] === 'rev-parse') { - return Promise.resolve({ stdout: 'base-oid\n' }) - } - if (args[0] === 'merge-base') { - return Promise.resolve({ stdout: 'merge-base-oid\n' }) - } - if (args.includes('--raw')) { - return Promise.resolve({ - stdout: ':100644 100644 a b M\0docs/a => b.txt\0' + '1\t0\tdocs/a => b.txt\0' - }) - } - if (args[0] === 'rev-list') { - return Promise.resolve({ stdout: '1\n' }) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const result = await getBranchCompare('/repo', 'origin/main') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['diff', '--raw', '--numstat', '-z', '-M', '-C', 'merge-base-oid', 'head-oid', '--'], - expect.objectContaining({ cwd: '/repo' }) - ) - expect(result.entries).toEqual([ - { path: 'docs/a => b.txt', status: 'modified', added: 1, removed: 0 } - ]) - }) -}) - -describe('getCommitCompare', () => { - beforeEach(() => { - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncBufferMock.mockReset() - }) - - it('attaches counts for commit compare paths containing rename markers', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'rev-parse') { - return Promise.resolve({ stdout: 'commit-oid\n' }) - } - if (args[0] === 'rev-list') { - return Promise.resolve({ stdout: 'commit-oid parent-oid\n' }) - } - if (args.includes('--raw')) { - return Promise.resolve({ - stdout: ':100644 100644 a b M\0docs/a => b.txt\0' + '1\t0\tdocs/a => b.txt\0' - }) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const result = await getCommitCompare('/repo', 'commit-oid') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['diff', '--raw', '--numstat', '-z', '-M', '-C', 'parent-oid', 'commit-oid', '--'], - expect.objectContaining({ cwd: '/repo' }) - ) - expect(result.entries).toEqual([ - { path: 'docs/a => b.txt', status: 'modified', added: 1, removed: 0 } - ]) - }) }) diff --git a/src/main/git/source-control/status-branch-line-total-exec-contract.test.ts b/src/main/git/source-control/status-branch-line-total-exec-contract.test.ts index 34b4b0ef00a..c1e9f179989 100644 --- a/src/main/git/source-control/status-branch-line-total-exec-contract.test.ts +++ b/src/main/git/source-control/status-branch-line-total-exec-contract.test.ts @@ -5,10 +5,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - buildGitBranchLineTotalDiffArgs, - invalidateGitBranchLineTotalInFlight -} from '../../../shared/git/status/git-branch-line-total' +import { invalidateGitBranchLineTotalInFlight } from '../../../shared/git/status/git-branch-line-total' import type * as BranchLineTotal from '../../../shared/git/status/git-branch-line-total' @@ -105,10 +102,6 @@ function rangedDiffCalls(): string[][] { return gitExecCalls.filter((args) => args.includes('--numstat') && args.at(-1) === '--') } -function numstatCalls(): string[][] { - return gitExecCalls.filter((args) => args.includes('--numstat')) -} - /** Resolves once `count` status passes have entered the branch-total coalescer. */ function waitForCoalescerJoins(count: number): Promise { if (coalescerJoins.count >= count) { @@ -172,17 +165,6 @@ describe('branch line total completeness', () => { expect(rangedDiffCalls()).toHaveLength(1) }) - it('omits the total for a directory that is not a git worktree', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-folder-')) - tempRoots.push(root) - - const result = await getStatus(root, { branchLineTotalMergeBase: BOGUS_MERGE_BASE }) - - expect(result.branchLineTotal).toBeUndefined() - expect(result.branchLineTotalUnavailable).toBeUndefined() - expect(rangedDiffCalls()).toEqual([]) - }) - it('rejects with an AbortError when the pass is cancelled mid-diff', async () => { const { repo, mergeBase } = await createFixtureRepo() const controller = new AbortController() @@ -200,30 +182,6 @@ describe('branch line total completeness', () => { }) describe('branch line total merge-base gate', () => { - it('runs no ranged diff and returns no total without a merge base', async () => { - const { repo } = await createFixtureRepo() - - const result = await getStatus(repo) - - expect(result.branchLineTotal).toBeUndefined() - expect(rangedDiffCalls()).toEqual([]) - }) - - it.each(['--upload-pack=x', 'HEAD', '', 'origin/main', '-M', 'A1B2C3D'])( - 'never spawns a ranged diff for a merge base of %j', - async (branchLineTotalMergeBase) => { - const { repo } = await createFixtureRepo() - - const result = await getStatus(repo, { branchLineTotalMergeBase }) - - expect(result.branchLineTotal).toBeUndefined() - expect(rangedDiffCalls()).toEqual([]) - expect(gitExecCalls).not.toContainEqual( - buildGitBranchLineTotalDiffArgs(branchLineTotalMergeBase) - ) - } - ) - it('never leaks a flag-shaped merge base into any git argv', async () => { const { repo } = await createFixtureRepo() @@ -236,40 +194,6 @@ describe('branch line total merge-base gate', () => { }) describe('branch line total exec budget', () => { - it('costs exactly one extra exec versus the same pass with the chip hidden', async () => { - const { repo, mergeBase } = await createFixtureRepo() - - await getStatus(repo) - const withoutChip = gitExecCalls.map((args) => args.join(' ')) - - gitExecCalls.length = 0 - resetGitReadCaches() - await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - const withChip = gitExecCalls.map((args) => args.join(' ')) - - expect(withChip.filter((args) => !args.endsWith(`${mergeBase} --`))).toEqual(withoutChip) - expect(withChip).toHaveLength(withoutChip.length + 1) - }) - - it('shares one ranged diff between two concurrent identical status passes', async () => { - const { repo, mergeBase } = await createFixtureRepo() - - const [first, second] = await Promise.all([ - getStatus(repo, { branchLineTotalMergeBase: mergeBase }), - getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - ]) - - expect(first.branchLineTotal).toEqual({ - added: 1, - removed: 0, - mergeBase, - test: NO_LINES, - generated: NO_LINES - }) - expect(second.branchLineTotal).toEqual(first.branchLineTotal) - expect(rangedDiffCalls()).toHaveLength(1) - }) - it('shares one ranged diff across status passes the status lease cannot merge', async () => { const { repo, mergeBase } = await createFixtureRepo() // Different limits require separate status leases; hold the diff until both join its shared lease. @@ -296,34 +220,6 @@ describe('branch line total exec budget', () => { expect(rangedDiffCalls()).toHaveLength(1) }) - it('reuses the cached total when the pass reuses cached line stats', async () => { - const { repo, mergeBase } = await createFixtureRepo() - - const first = await getStatus(repo, { - branchLineTotalMergeBase: mergeBase, - reuseLineStats: true - }) - expect(rangedDiffCalls()).toHaveLength(1) - expect(numstatCalls()).toHaveLength(2) - - gitExecCalls.length = 0 - invalidateGitBranchLineTotalInFlight() - const second = await getStatus(repo, { - branchLineTotalMergeBase: mergeBase, - reuseLineStats: true - }) - - expect(second.branchLineTotal).toEqual(first.branchLineTotal) - expect(second.branchLineTotal).toEqual({ - added: 1, - removed: 0, - mergeBase, - test: NO_LINES, - generated: NO_LINES - }) - expect(numstatCalls()).toEqual([]) - }) - it('recomputes rather than reusing a total measured against another merge base', async () => { const { repo, mergeBase } = await createFixtureRepo() git(repo, ['add', '-A']) diff --git a/src/main/git/source-control/status-branch-line-total-real-git.test.ts b/src/main/git/source-control/status-branch-line-total-real-git.test.ts index f68b32f40c1..464a5be1fb1 100644 --- a/src/main/git/source-control/status-branch-line-total-real-git.test.ts +++ b/src/main/git/source-control/status-branch-line-total-real-git.test.ts @@ -93,25 +93,6 @@ describe('branch line total against a real repository', () => { ]) }) - it('nets a line added in a branch commit and deleted in the working tree to zero', async () => { - const repo = await createFixtureRepo() - await write(repo, 'f.txt', 'a\n') - const mergeBase = commitAll(repo, 'base') - await write(repo, 'f.txt', 'a\nb\n') - commitAll(repo, 'add b') - await write(repo, 'f.txt', 'a\n') - - const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - - expect(result.branchLineTotal).toEqual({ - added: 0, - removed: 0, - mergeBase, - test: NO_LINES, - generated: NO_LINES - }) - }) - it('keeps the total across a commit and updates it on the next status call after an edit', async () => { const repo = await createFixtureRepo() await write(repo, 'f.txt', 'a\n') @@ -148,84 +129,6 @@ describe('branch line total against a real repository', () => { }) }) - it('counts a rename across the commit boundary once, using the post-rename path', async () => { - const repo = await createFixtureRepo() - await write(repo, 'old.txt', 'a\nb\nc\nd\ne\n') - const mergeBase = commitAll(repo, 'base') - git(repo, ['mv', 'old.txt', 'new.txt']) - commitAll(repo, 'rename') - await write(repo, 'new.txt', 'a\nb\nc\nd\ne\nf\n') - - const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - - expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) - expect(result.branchLineTotal).toEqual({ - added: 1, - removed: 0, - mergeBase, - test: NO_LINES, - generated: NO_LINES - }) - }) - - it('excludes a binary-only change, matching numstat reporting it as "-"', async () => { - const repo = await createFixtureRepo() - await write(repo, 'blob.bin', Buffer.from([0, 1, 2, 3, 10, 65, 66])) - const mergeBase = commitAll(repo, 'base') - await write(repo, 'blob.bin', Buffer.from([0, 9, 10, 11, 12, 67, 68, 69, 70])) - - const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - - expect(git(repo, ['diff', '--numstat', mergeBase, '--'])).toMatch(/^-\t-\t/) - expect(result.branchLineTotal).toEqual({ - added: 0, - removed: 0, - mergeBase, - test: NO_LINES, - generated: NO_LINES - }) - }) - - it('splits test-path and generated files out of the published total', async () => { - const repo = await createFixtureRepo() - await write(repo, 'src/a.ts', 'a\n') - const mergeBase = commitAll(repo, 'base') - await write(repo, 'src/a.ts', 'a\nb\n') - await write(repo, 'src/a.test.ts', 't1\nt2\nt3\n') - await write(repo, 'pnpm-lock.yaml', 'l1\nl2\nl3\nl4\nl5\n') - - const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - - expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) - expect(result.branchLineTotal).toEqual({ - added: 9, - removed: 0, - mergeBase, - test: { added: 3, removed: 0 }, - generated: { added: 5, removed: 0 } - }) - }) - - it('includes untracked additions alongside tracked range changes', async () => { - const repo = await createFixtureRepo() - await write(repo, 'f.txt', 'a\nb\n') - const mergeBase = commitAll(repo, 'base') - await write(repo, 'f.txt', 'a\nb\nc\n') - commitAll(repo, 'append c') - await write(repo, 'untracked.txt', 'u1\nu2\nu3\nu4\n') - - const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - - expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) - expect(result.branchLineTotal).toEqual({ - added: 5, - removed: 0, - mergeBase, - test: NO_LINES, - generated: NO_LINES - }) - }) - it('omits the total when a deleted base file is recreated as untracked', async () => { const repo = await createFixtureRepo() await write(repo, 'gone.txt', 'a\nb\nc\n') diff --git a/src/main/git/source-control/status-conflict-operations.test.ts b/src/main/git/source-control/status-conflict-operations.test.ts deleted file mode 100644 index 7da1c5821e5..00000000000 --- a/src/main/git/source-control/status-conflict-operations.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import type * as RunnerModule from '../runner' -import type * as GitExecFileModule from '../command-runner/git-exec-file' -import type * as GitStreamStdoutModule from '../command-runner/git-stream-stdout' -import type * as GitProcessEnvModule from '../command-runner/git-process-env' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as BoundedFileReader from '../../../shared/node-bounded-file-reader' -import { - createBoundedFileReaderModuleMock, - createFsPromisesModuleMock, - createGitRunnerModuleMock -} from './status-test-harness' - -const { - gitExecFileAsyncMock, - gitExecFileAsyncBufferMock, - gitStreamOptionsMock, - lstatMock, - realpathMock, - readFileMock, - statMock, - rmMock, - accessMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileAsyncBufferMock: vi.fn(), - gitStreamOptionsMock: vi.fn(), - lstatMock: vi.fn(), - realpathMock: vi.fn(), - readFileMock: vi.fn(), - statMock: vi.fn(), - rmMock: vi.fn(), - accessMock: vi.fn() -})) - -vi.mock('../runner', () => - createGitRunnerModuleMock({ - gitExecFileAsyncMock, - gitExecFileAsyncBufferMock, - gitStreamOptionsMock - }) -) - -vi.mock('../command-runner/git-exec-file', async (importOriginal) => { - const runnerMocks = await vi.importMock('../runner') - return { - ...(await importOriginal()), - gitExecFileAsync: runnerMocks.gitExecFileAsync, - gitExecFileAsyncBuffer: runnerMocks.gitExecFileAsyncBuffer - } -}) - -vi.mock('../command-runner/git-stream-stdout', async (importOriginal) => { - const runnerMocks = await vi.importMock('../runner') - return { - ...(await importOriginal()), - gitStreamStdout: runnerMocks.gitStreamStdout - } -}) - -vi.mock('../command-runner/git-process-env', async (importOriginal) => { - const runnerMocks = await vi.importMock('../runner') - return { - ...(await importOriginal()), - gitOptionalLocksDisabledEnv: runnerMocks.gitOptionalLocksDisabledEnv - } -}) - -vi.mock('fs/promises', () => - createFsPromisesModuleMock({ - lstatMock, - realpathMock, - readFileMock, - statMock, - rmMock, - accessMock - }) -) - -vi.mock('../../../shared/node-bounded-file-reader', async (importOriginal) => - createBoundedFileReaderModuleMock(await importOriginal(), { - readFileMock, - statMock - }) -) - -import { abortMerge, abortRebase, detectConflictOperation } from './git-conflict-operation' - -describe('abortMerge', () => { - beforeEach(() => { - gitExecFileAsyncMock.mockReset() - }) - - it('runs git merge --abort in the worktree', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) - - await abortMerge('/repo') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['merge', '--abort'], { cwd: '/repo' }) - }) -}) - -describe('abortRebase', () => { - beforeEach(() => { - gitExecFileAsyncMock.mockReset() - }) - - it('runs git rebase --abort in the worktree', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) - - await abortRebase('/repo') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rebase', '--abort'], { cwd: '/repo' }) - }) -}) -describe('detectConflictOperation', () => { - beforeEach(() => { - readFileMock.mockReset() - accessMock.mockReset() - }) - - it('ignores a stale REBASE_HEAD when no rebase directory exists', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - accessMock.mockImplementation(async (target: string) => { - // A stale REBASE_HEAD alone must not report a live rebase. - if (target.endsWith('REBASE_HEAD')) { - return undefined - } - throw Object.assign(new Error(`ENOENT: ${target}`), { code: 'ENOENT' }) - }) - - const result = await detectConflictOperation('/repo') - - expect(result).toBe('unknown') - }) - - it.each([ - ['MERGE_HEAD', 'merge'], - ['rebase-merge', 'rebase'], - ['rebase-apply', 'rebase'], - ['CHERRY_PICK_HEAD', 'cherry-pick'] - ])('reports %s as %s', async (marker, expected) => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - accessMock.mockImplementation(async (target: string) => { - if (target.endsWith(marker)) { - return undefined - } - throw Object.assign(new Error(`ENOENT: ${target}`), { code: 'ENOENT' }) - }) - - await expect(detectConflictOperation('/repo')).resolves.toBe(expected) - }) - - it('probes every marker concurrently', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - let concurrent = 0 - let peakConcurrent = 0 - accessMock.mockImplementation(async () => { - concurrent += 1 - peakConcurrent = Math.max(peakConcurrent, concurrent) - await Promise.resolve() - concurrent -= 1 - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) - - await detectConflictOperation('/repo') - - expect(accessMock).toHaveBeenCalledTimes(4) - expect(peakConcurrent).toBe(4) - }) - - it('reads as unknown when the git dir cannot be reached at all', async () => { - readFileMock.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' })) - accessMock.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' })) - - await expect(detectConflictOperation('/repo')).resolves.toBe('unknown') - }) - - // Compare normalized prefixes so Win32 pointer resolution is tested on every host. - it('probes the drive spelling of a drvfs gitdir pointer on Windows', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - readFileMock.mockResolvedValue('gitdir: /mnt/c/Users/me/repo/.git/worktrees/feature\n') - accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) - - try { - await expect(detectConflictOperation(String.raw`C:\Users\me\repo\feature`)).resolves.toBe( - 'unknown' - ) - for (const [target] of accessMock.mock.calls) { - expect(target).toContain(String.raw`C:\Users\me\repo\.git\worktrees\feature`) - } - expect(accessMock).toHaveBeenCalledTimes(4) - } finally { - platformSpy.mockRestore() - } - }) - - it('reads the gitfile at the host spelling of a guest-spelled worktree', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - readFileMock.mockResolvedValue('gitdir: /mnt/c/Users/me/repo/.git/worktrees/feature\n') - accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) - - try { - await expect(detectConflictOperation('/mnt/c/Users/me/repo/feature')).resolves.toBe('unknown') - expect(readFileMock).toHaveBeenCalledTimes(1) - expect(readFileMock.mock.calls[0][0]).toContain(String.raw`C:\Users\me\repo\feature`) - } finally { - platformSpy.mockRestore() - } - }) - - it('probes a directory .git under the distro share when the caller names one', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - readFileMock.mockRejectedValue(Object.assign(new Error('EISDIR'), { code: 'EISDIR' })) - accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) - - try { - await expect( - detectConflictOperation('/home/me/repo/feature', { wslDistro: 'Ubuntu' }) - ).resolves.toBe('unknown') - expect(readFileMock.mock.calls[0][0]).toContain( - String.raw`\\wsl.localhost\Ubuntu\home\me\repo\feature` - ) - for (const [target] of accessMock.mock.calls) { - expect(target).toContain(String.raw`\\wsl.localhost\Ubuntu\home\me\repo\feature`) - } - expect(accessMock).toHaveBeenCalledTimes(4) - } finally { - platformSpy.mockRestore() - } - }) - - // Relative Git 2.48+ pointers need a host-spelled base to avoid Win32 drive-relative resolution. - it('resolves a relative gitdir pointer against the host spelling of the worktree', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - readFileMock.mockResolvedValue('gitdir: ../.git/worktrees/feature\n') - accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) - - try { - await expect(detectConflictOperation('/mnt/c/Users/me/repo/feature')).resolves.toBe('unknown') - for (const [target] of accessMock.mock.calls) { - expect(target).toContain(String.raw`C:\Users\me\repo\.git\worktrees\feature`) - } - expect(accessMock).toHaveBeenCalledTimes(4) - } finally { - platformSpy.mockRestore() - } - }) - - it('probes the distro UNC share when the caller names one', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n') - accessMock.mockImplementation(async (target: string) => - target.includes(String.raw`\\wsl.localhost\Ubuntu\home\me\repo\.git\worktrees\feature`) && - target.endsWith('MERGE_HEAD') - ? undefined - : Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) - ) - - try { - await expect( - detectConflictOperation(String.raw`C:\Users\me\repo\feature`, { wslDistro: 'Ubuntu' }) - ).resolves.toBe('merge') - } finally { - platformSpy.mockRestore() - } - }) -}) diff --git a/src/main/git/source-control/status-diff.test.ts b/src/main/git/source-control/status-diff.test.ts index d65a4b213d9..65b025fc2f0 100644 --- a/src/main/git/source-control/status-diff.test.ts +++ b/src/main/git/source-control/status-diff.test.ts @@ -5,10 +5,7 @@ import type * as GitProcessEnvModule from '../command-runner/git-process-env' import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as BoundedFileReader from '../../../shared/node-bounded-file-reader' import path from 'node:path' -import { - MAX_RENDERED_DIFF_COMBINED_CHARACTERS, - MAX_RENDERED_DIFF_LINES_PER_SIDE -} from '../../../shared/large-diff-render-limit' +import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../../shared/large-diff-render-limit' import { createBoundedFileReaderModuleMock, createFsPromisesModuleMock, @@ -84,35 +81,7 @@ vi.mock('../../../shared/node-bounded-file-reader', async (importOriginal) => statMock }) ) - -import { getBranchDiff } from './branch-diff' -import { getCommitDiff } from './commit-diff' import { getDiff } from './file-diff' -import { getStagedCommitContext } from './staged-commit-context' -import { stageFile } from './staging' - -function deferredBuffer(content: string): { - promise: Promise<{ stdout: Buffer }> - resolve: () => void -} { - let resolve!: (value: { stdout: Buffer }) => void - const promise = new Promise<{ stdout: Buffer }>((innerResolve) => { - resolve = innerResolve - }) - return { - promise, - resolve: () => resolve({ stdout: Buffer.from(content) }) - } -} - -async function waitForMockCalls(mock: ReturnType, calls: number): Promise { - for (let i = 0; i < 20; i++) { - if (mock.mock.calls.length >= calls) { - return - } - await new Promise((resolve) => setTimeout(resolve, 0)) - } -} describe('getDiff', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() @@ -127,83 +96,6 @@ describe('getDiff', () => { }) }) - it('uses the index as the left side for unstaged diffs when present', async () => { - gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') }) - readFileMock.mockResolvedValue(Buffer.from('working-tree-content')) - - const result = await getDiff('/repo', 'src/file.ts', false) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith( - ['cat-file', 'blob', '--', ':src/file.ts'], - { - cwd: '/repo', - maxBuffer: 10 * 1024 * 1024, - preferWslDirectGit: true - } - ) - expect(readFileMock).toHaveBeenCalledWith(path.join('/repo', 'src/file.ts')) - expect(result).toEqual({ - kind: 'text', - originalContent: 'index-content\n', - modifiedContent: 'working-tree-content', - originalIsBinary: false, - modifiedIsBinary: false - }) - }) - - it('normalizes Windows separators before reading git blobs', async () => { - gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') }) - readFileMock.mockResolvedValue(Buffer.from('working-tree-content')) - - await getDiff('/repo', 'src\\file.ts', false) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith( - ['cat-file', 'blob', '--', ':src/file.ts'], - { - cwd: '/repo', - maxBuffer: 10 * 1024 * 1024, - preferWslDirectGit: true - } - ) - }) - - it('falls back to HEAD for unstaged diffs when the file is not in the index', async () => { - gitExecFileAsyncBufferMock - .mockRejectedValueOnce( - Object.assign(new Error("fatal: path 'src/file.ts' exists on disk, but not in the index"), { - code: 128, - stderr: "fatal: path 'src/file.ts' exists on disk, but not in the index" - }) - ) - .mockResolvedValueOnce({ stdout: Buffer.from('head-content\n') }) - readFileMock.mockResolvedValue(Buffer.from('working-tree-content')) - - const result = await getDiff('/repo', 'src/file.ts', false) - - expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith( - 2, - ['cat-file', 'blob', '--', 'HEAD:src/file.ts'], - { - cwd: '/repo', - maxBuffer: 10 * 1024 * 1024, - preferWslDirectGit: true - } - ) - expect(result.originalContent).toBe('head-content\n') - expect(result.modifiedContent).toBe('working-tree-content') - }) - - it('marks binary content in the diff payload', async () => { - gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from([0x00, 0x61, 0x62]) }) - readFileMock.mockResolvedValue(Buffer.from('working-tree-content')) - - const result = await getDiff('/repo', 'src/file.bin', false) - - expect(result.kind).toBe('binary') - expect(result.originalIsBinary).toBe(true) - expect(result.modifiedIsBinary).toBe(false) - }) - it('does not read oversized working-tree files into memory', async () => { const workingTreePath = path.join('/repo', 'dist/large.log') gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') }) @@ -249,86 +141,6 @@ describe('getDiff', () => { ) }) - it('omits over-limit text bodies when line-count exceeds the cap', async () => { - const oversizedByLines = 'x\n'.repeat(MAX_RENDERED_DIFF_LINES_PER_SIDE) - gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') }) - statMock.mockResolvedValueOnce({ - isFile: () => true, - size: oversizedByLines.length - }) - readFileMock.mockResolvedValue(Buffer.from(oversizedByLines)) - - const result = await getDiff('/repo', 'dist/large-lines.log', false) - - expect(result.kind).toBe('text') - if (result.kind !== 'text') { - throw new Error('expected text diff result') - } - expect(result.originalContent).toBe('') - expect(result.modifiedContent).toBe('') - expect(result.largeDiffRenderLimit?.limited).toBe(true) - if (result.largeDiffRenderLimit?.limited !== true) { - throw new Error('expected large diff render limit') - } - expect(result.largeDiffRenderLimit.reason).toBe('line-count') - expect(result.largeDiffRenderLimit.lineCounts?.modified).toBeGreaterThan( - MAX_RENDERED_DIFF_LINES_PER_SIDE - ) - }) - - it('marks git blobs that overflow maxBuffer as binary instead of pretending they are missing', async () => { - gitExecFileAsyncBufferMock.mockRejectedValueOnce( - Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' }) - ) - readFileMock.mockResolvedValue(Buffer.from('working-tree-content')) - - const result = await getDiff('/repo', 'src/file.txt', false) - - expect(result.kind).toBe('binary') - expect(result.originalIsBinary).toBe(true) - expect(result.originalContent).toBe('') - }) - - it('includes preview metadata for pdf diffs', async () => { - const pdfBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00]) - gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pdfBuffer }) - readFileMock.mockResolvedValue(pdfBuffer) - - const result = await getDiff('/repo', 'docs/spec.pdf', false) - - expect(result).toEqual({ - kind: 'binary', - originalContent: pdfBuffer.toString('base64'), - modifiedContent: pdfBuffer.toString('base64'), - originalIsBinary: true, - modifiedIsBinary: true, - isImage: true, - mimeType: 'application/pdf' - }) - }) - - it('flags a deleted image so previewers can fall back to the original bytes', async () => { - const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]) - const workingTreePath = path.join('/repo', 'assets/deleted.png') - gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer }) - statMock.mockImplementation(async (target: string) => { - if (target === workingTreePath) { - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - } - return { isFile: () => true, size: 12 } - }) - - const result = await getDiff('/repo', 'assets/deleted.png', false) - - expect(result.kind).toBe('binary') - if (result.kind !== 'binary') { - throw new Error('expected binary diff result') - } - expect(result.modifiedDeleted).toBe(true) - expect(result.originalContent).toBe(pngBuffer.toString('base64')) - expect(result.modifiedContent).toBe('') - }) - it('does not treat an unreadable working-tree image as a deletion', async () => { const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]) const workingTreePath = path.join('/repo', 'assets/unreadable.png') @@ -349,294 +161,4 @@ describe('getDiff', () => { } expect(result.modifiedDeleted).toBeUndefined() }) - - it('coalesces concurrent identical staged diff reads while in flight', async () => { - const leftBlob = deferredBuffer('head-content\n') - const rightBlob = deferredBuffer('index-content\n') - const pendingBuffers = [leftBlob, rightBlob] - gitExecFileAsyncBufferMock.mockImplementation(async () => pendingBuffers.shift()!.promise) - - const reads = Array.from({ length: 8 }, () => getDiff('/repo', 'src/file.ts', true)) - - // Eight coalesced reads should spawn only once for each independent side. - await waitForMockCalls(gitExecFileAsyncBufferMock, 2) - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2) - - leftBlob.resolve() - rightBlob.resolve() - - const results = await Promise.all(reads) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2) - expect(results.every((result) => result.kind === 'text')).toBe(true) - - gitExecFileAsyncBufferMock - .mockResolvedValueOnce({ stdout: Buffer.from('fresh-head\n') }) - .mockResolvedValueOnce({ stdout: Buffer.from('fresh-index\n') }) - - await getDiff('/repo', 'src/file.ts', true) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(4) - }) - - it('clears pending diff reads when a mutation runs', async () => { - const firstBlob = deferredBuffer('head-content\n') - const secondBlob = deferredBuffer('fresh-head-content\n') - const pendingBuffers = [firstBlob, secondBlob] - gitExecFileAsyncBufferMock.mockImplementation(async () => pendingBuffers.shift()!.promise) - readFileMock.mockResolvedValue(Buffer.from('working-tree\n')) - gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) - - const first = getDiff('/repo', 'src/file.ts', false) - await waitForMockCalls(gitExecFileAsyncBufferMock, 1) - - await stageFile('/repo', 'src/file.ts') - - const second = getDiff('/repo', 'src/file.ts', false) - await waitForMockCalls(gitExecFileAsyncBufferMock, 2) - - firstBlob.resolve() - secondBlob.resolve() - await Promise.all([first, second]) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['add', '--', ':(literal)src/file.ts'], - expect.objectContaining({ cwd: '/repo' }) - ) - }) - - it('coalesces concurrent identical branch and commit diff reads while in flight', async () => { - const branchLeftBlob = deferredBuffer('branch-left\n') - const branchRightBlob = deferredBuffer('branch-right\n') - const pendingBranchBuffers = [branchLeftBlob, branchRightBlob] - gitExecFileAsyncBufferMock.mockImplementation(async () => pendingBranchBuffers.shift()!.promise) - - const branchReads = Array.from({ length: 8 }, () => - getBranchDiff('/repo', { - mergeBase: 'b'.repeat(40), - headOid: 'c'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }) - ) - - await waitForMockCalls(gitExecFileAsyncBufferMock, 1) - branchLeftBlob.resolve() - await waitForMockCalls(gitExecFileAsyncBufferMock, 2) - branchRightBlob.resolve() - - await Promise.all(branchReads) - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2) - - gitExecFileAsyncBufferMock.mockReset() - const commitLeftBlob = deferredBuffer('commit-left\n') - const commitRightBlob = deferredBuffer('commit-right\n') - const pendingCommitBuffers = [commitLeftBlob, commitRightBlob] - gitExecFileAsyncBufferMock.mockImplementation(async () => pendingCommitBuffers.shift()!.promise) - - const commitReads = Array.from({ length: 8 }, () => - getCommitDiff('/repo', { - parentOid: 'd'.repeat(40), - commitOid: 'e'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }) - ) - - await waitForMockCalls(gitExecFileAsyncBufferMock, 1) - commitLeftBlob.resolve() - await waitForMockCalls(gitExecFileAsyncBufferMock, 2) - commitRightBlob.resolve() - - await Promise.all(commitReads) - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2) - }) - - it('coalesces logically identical branch and commit diff args regardless of property order', async () => { - gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('blob\n') }) - - await Promise.all([ - getBranchDiff('/repo', { - mergeBase: 'b'.repeat(40), - headOid: 'c'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }), - getBranchDiff('/repo', { - oldPath: 'src/old-file.ts', - filePath: 'src/file.ts', - headOid: 'c'.repeat(40), - mergeBase: 'b'.repeat(40) - }) - ]) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2) - - gitExecFileAsyncBufferMock.mockClear() - - await Promise.all([ - getCommitDiff('/repo', { - parentOid: 'd'.repeat(40), - commitOid: 'e'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }), - getCommitDiff('/repo', { - oldPath: 'src/old-file.ts', - filePath: 'src/file.ts', - commitOid: 'e'.repeat(40), - parentOid: 'd'.repeat(40) - }) - ]) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2) - }) - - it('keeps distinct diff inputs on separate in-flight reads', async () => { - gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('blob\n') }) - readFileMock.mockResolvedValue(Buffer.from('working-tree\n')) - - await Promise.all([ - getDiff('/repo', 'src/file.ts', false, false), - getDiff('/repo', 'src/file.ts', false, true), - getDiff('/repo', 'src/file.ts', true, false), - getDiff('/repo', 'src/file.ts', true, false, { wslDistro: 'ubuntu' }), - getDiff('/repo', 'src/file.ts', true, false, { wslDistro: 'debian' }) - ]) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(8) - - gitExecFileAsyncBufferMock.mockReset() - gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('blob\n') }) - - await Promise.all([ - getBranchDiff('/repo', { - mergeBase: 'b'.repeat(40), - headOid: 'c'.repeat(40), - filePath: 'src/file.ts' - }), - getBranchDiff('/repo', { - mergeBase: 'b'.repeat(40), - headOid: 'd'.repeat(40), - filePath: 'src/file.ts' - }), - getBranchDiff('/repo', { - mergeBase: 'b'.repeat(40), - headOid: 'c'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-a.ts' - }), - getBranchDiff('/repo', { - mergeBase: 'b'.repeat(40), - headOid: 'c'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-b.ts' - }), - getCommitDiff('/repo', { - parentOid: 'e'.repeat(40), - commitOid: 'f'.repeat(40), - filePath: 'src/file.ts' - }), - getCommitDiff('/repo', { - parentOid: 'a'.repeat(40), - commitOid: 'f'.repeat(40), - filePath: 'src/file.ts' - }), - getCommitDiff('/repo', { - parentOid: 'e'.repeat(40), - commitOid: 'f'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-a.ts' - }), - getCommitDiff('/repo', { - parentOid: 'e'.repeat(40), - commitOid: 'f'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-b.ts' - }) - ]) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(16) - }) - - it('coalesces parentless root commit diff reads without reading a left-side blob', async () => { - const rightBlob = deferredBuffer('root-content\n') - gitExecFileAsyncBufferMock.mockImplementation(async () => rightBlob.promise) - - const reads = Array.from({ length: 8 }, () => - getCommitDiff('/repo', { - parentOid: null, - commitOid: 'e'.repeat(40), - filePath: 'src/file.ts' - }) - ) - - await waitForMockCalls(gitExecFileAsyncBufferMock, 1) - rightBlob.resolve() - await Promise.all(reads) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(1) - }) -}) - -describe('getStagedCommitContext', () => { - beforeEach(() => { - gitExecFileAsyncMock.mockReset() - }) - - it('uses explicit large buffers before prompt truncation', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'feature/ai\n' }) - .mockResolvedValueOnce({ stdout: 'M\tREADME.md\n' }) - .mockResolvedValueOnce({ stdout: 'diff --git a/README.md b/README.md\n+hello\n' }) - - const result = await getStagedCommitContext('/repo') - - expect(result).toEqual({ - branch: 'feature/ai', - stagedSummary: 'M\tREADME.md', - stagedPatch: 'diff --git a/README.md b/README.md\n+hello\n' - }) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['diff', '--cached', '--name-status'], { - cwd: '/repo', - maxBuffer: 10 * 1024 * 1024 - }) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, - ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], - { - cwd: '/repo', - maxBuffer: 10 * 1024 * 1024 - } - ) - }) - - it('falls back to the file summary when the staged patch overflows the buffer', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'feature/ai\n' }) - .mockResolvedValueOnce({ stdout: 'A\thuge.jsonl\n' }) - .mockRejectedValueOnce( - Object.assign(new Error('stdout maxBuffer length exceeded'), { - code: 'ENOBUFS' - }) - ) - - const result = await getStagedCommitContext('/repo') - - expect(result).toEqual({ - branch: 'feature/ai', - stagedSummary: 'A\thuge.jsonl', - stagedPatch: '' - }) - }) - - it('rethrows staged patch failures that are not buffer overflows', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'feature/ai\n' }) - .mockResolvedValueOnce({ stdout: 'M\tREADME.md\n' }) - .mockRejectedValueOnce(new Error('fatal: bad revision')) - - await expect(getStagedCommitContext('/repo')).rejects.toThrow('fatal: bad revision') - }) }) diff --git a/src/main/git/source-control/status-read-coalescing.test.ts b/src/main/git/source-control/status-read-coalescing.test.ts index 3b556b0e0f1..9ffa37bf184 100644 --- a/src/main/git/source-control/status-read-coalescing.test.ts +++ b/src/main/git/source-control/status-read-coalescing.test.ts @@ -82,7 +82,6 @@ vi.mock('../../../shared/node-bounded-file-reader', async (importOriginal) => import { clearEffectiveUpstreamStatusCacheForTests } from './effective-upstream-status-cache' import { getStatus } from './status-read' -import { stageFile } from './staging' describe('getStatus', () => { beforeEach(() => { @@ -97,58 +96,6 @@ describe('getStatus', () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) }) - it('preserves status admission through the direct WSL stream without changing mutation options', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) - - await getStatus('/repo', { wslDistro: 'Ubuntu', admissionTier: 'interactive' }) - await stageFile('/repo', 'src/file.ts', { wslDistro: 'Ubuntu' }) - - expect(gitStreamOptionsMock).toHaveBeenCalledWith( - expect.objectContaining({ - admissionTier: 'interactive', - preferWslDirectGit: true, - wslDistro: 'Ubuntu' - }) - ) - const addOptions = gitExecFileAsyncMock.mock.calls.find(([args]) => - (args as string[]).includes('add') - )?.[1] as { preferWslDirectGit?: boolean } | undefined - expect(addOptions).toBeDefined() - expect(addOptions?.preferWslDirectGit).toBeUndefined() - }) - - it('coalesces identical in-flight status reads without caching after settle', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) - let statusCommandCalls = 0 - const releaseStatusReads: (() => void)[] = [] - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - statusCommandCalls += 1 - return new Promise<{ stdout: string }>((resolve) => { - releaseStatusReads.push(() => resolve({ stdout: '' })) - }) - } - if (args.includes('--numstat')) { - return Promise.resolve({ stdout: '' }) - } - return Promise.resolve({ stdout: '' }) - }) - - const sharedRead = Promise.all([getStatus('/repo'), getStatus('/repo'), getStatus('/repo')]) - await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) - releaseStatusReads.splice(0).forEach((release) => release()) - await sharedRead - expect(statusCommandCalls).toBe(1) - - const settledRead = getStatus('/repo') - await vi.waitFor(() => expect(statusCommandCalls).toBe(2)) - releaseStatusReads.splice(0).forEach((release) => release()) - await settledRead - expect(statusCommandCalls).toBe(2) - }) - it('shares one physical status read across distinct caller signals', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) @@ -217,129 +164,4 @@ describe('getStatus', () => { await expect(getStatus('/repo')).resolves.toMatchObject({ entries: [] }) expect(statusCommandCalls).toBe(2) }) - - it('rejects a pre-aborted caller without starting or joining status work', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) - let releaseStatus!: () => void - let statusCommandCalls = 0 - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - statusCommandCalls += 1 - return new Promise<{ stdout: string }>((resolve) => { - releaseStatus = () => resolve({ stdout: '' }) - }) - } - return Promise.resolve({ stdout: '' }) - }) - const active = getStatus('/repo') - await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) - const controller = new AbortController() - const abortError = new Error('already cancelled') - controller.abort(abortError) - - await expect(getStatus('/repo', { signal: controller.signal })).rejects.toBe(abortError) - expect(statusCommandCalls).toBe(1) - - releaseStatus() - await active - }) - - it('isolates status reads by worktree, host, and output-affecting options', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) - let statusCommandCalls = 0 - const releases: (() => void)[] = [] - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - statusCommandCalls += 1 - return new Promise<{ stdout: string }>((resolve) => { - releases.push(() => resolve({ stdout: '' })) - }) - } - return Promise.resolve({ stdout: '' }) - }) - const reads = [ - getStatus('/repo'), - getStatus('/other-repo'), - getStatus('/repo', { wslDistro: 'Ubuntu' }), - getStatus('/repo', { includeIgnored: true }), - getStatus('/repo', { includeLineStats: false }), - getStatus('/repo', { reuseLineStats: true }), - getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }), - getStatus('/repo', { limit: 1 }), - getStatus('/repo', { sharedLinkPaths: ['node_modules'] }), - getStatus('/repo', { admissionTier: 'interactive' }) - ] - - await vi.waitFor(() => expect(statusCommandCalls).toBe(10)) - releases.splice(0).forEach((release) => release()) - await Promise.all(reads) - expect(statusCommandCalls).toBe(10) - }) - - it('clears in-flight status reads when a mutation runs', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) - let statusCommandCalls = 0 - const releaseStatusReads: (() => void)[] = [] - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - statusCommandCalls += 1 - return new Promise<{ stdout: string }>((resolve) => { - releaseStatusReads.push(() => resolve({ stdout: '' })) - }) - } - return Promise.resolve({ stdout: '' }) - }) - - const first = getStatus('/repo') - await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) - - // Mutations retire the lease so post-mutation reads cannot join a stale status pass. - await stageFile('/repo', 'src/file.ts') - - const second = getStatus('/repo') - await vi.waitFor(() => expect(statusCommandCalls).toBe(2)) - - releaseStatusReads.splice(0).forEach((release) => release()) - await Promise.all([first, second]) - expect(statusCommandCalls).toBe(2) - }) - - it('fences reads started before, during, and after a concurrent mutation', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) - let statusCommandCalls = 0 - let releaseMutation!: () => void - const releaseStatusReads: (() => void)[] = [] - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - statusCommandCalls += 1 - return new Promise<{ stdout: string }>((resolve) => { - releaseStatusReads.push(() => resolve({ stdout: '' })) - }) - } - if (args.includes('add')) { - return new Promise<{ stdout: string }>((resolve) => { - releaseMutation = () => resolve({ stdout: '' }) - }) - } - return Promise.resolve({ stdout: '' }) - }) - - const beforeMutation = getStatus('/repo', { signal: new AbortController().signal }) - await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) - const mutation = stageFile('/repo', 'src/file.ts') - const duringMutation = getStatus('/repo', { signal: new AbortController().signal }) - await vi.waitFor(() => expect(statusCommandCalls).toBe(2)) - releaseMutation() - await mutation - const afterMutation = getStatus('/repo', { signal: new AbortController().signal }) - await vi.waitFor(() => expect(statusCommandCalls).toBe(3)) - - releaseStatusReads.splice(0).forEach((release) => release()) - await Promise.all([beforeMutation, duringMutation, afterMutation]) - expect(statusCommandCalls).toBe(3) - }) }) diff --git a/src/main/git/source-control/status-submodule.test.ts b/src/main/git/source-control/status-submodule.test.ts index 41f31280966..92e960ff7eb 100644 --- a/src/main/git/source-control/status-submodule.test.ts +++ b/src/main/git/source-control/status-submodule.test.ts @@ -80,21 +80,13 @@ vi.mock('../../../shared/node-bounded-file-reader', async (importOriginal) => statMock }) ) - -import { clearEffectiveUpstreamStatusCacheForTests } from './effective-upstream-status-cache' -import { - clearSubmodulePathsCacheForTests, - listSubmodulePaths, - resolveSubmoduleWorktreePath -} from './submodule-paths' +import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from './submodule-paths' import { getDiff } from './file-diff' -import { getSubmoduleStatus } from './submodule-status' describe('submodule diff routing', () => { const OLD_OID = 'a'.repeat(40) const NEW_OID = 'b'.repeat(40) const PARENT = path.resolve('/repo-sm') - const SUBMODULE = path.join(PARENT, 'flutter_mine') beforeEach(() => { clearSubmodulePathsCacheForTests() @@ -125,119 +117,6 @@ describe('submodule diff routing', () => { }) }) - it('synthesizes a Subproject commit pointer diff for the gitlink root', async () => { - const result = await getDiff(PARENT, 'flutter_mine', false) - - expect(result.kind).toBe('text') - expect(result.originalContent).toBe(`Subproject commit ${OLD_OID}\n`) - expect(result.modifiedContent).toBe(`Subproject commit ${NEW_OID}\n`) - }) - - it('diffs inner files across the two commits when the gitlink moved', async () => { - gitExecFileAsyncBufferMock.mockImplementation((args: string[]) => { - const spec = String(args.at(-1)) - if (spec.startsWith(`${OLD_OID}:`)) { - return Promise.resolve({ stdout: Buffer.from('v1\n') }) - } - if (spec.startsWith(`${NEW_OID}:`)) { - return Promise.resolve({ stdout: Buffer.from('v2\n') }) - } - return Promise.resolve({ stdout: Buffer.from('') }) - }) - - const result = await getDiff(PARENT, 'flutter_mine/lib/main.dart', false) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith( - ['cat-file', 'blob', '--', `${OLD_OID}:lib/main.dart`], - { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true } - ) - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith( - ['cat-file', 'blob', '--', `${NEW_OID}:lib/main.dart`], - { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true } - ) - expect(result.kind).toBe('text') - expect(result.originalContent).toBe('v1\n') - expect(result.modifiedContent).toBe('v2\n') - }) - - it('diffs staged inner files from parent HEAD to parent index', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => { - if (args[0] === 'config' && args.includes('.gitmodules')) { - return Promise.resolve({ - stdout: options?.cwd === PARENT ? 'submodule.flutter_mine.path\nflutter_mine\0' : '' - }) - } - if (args[0] === 'ls-files') { - return Promise.resolve({ stdout: `160000 ${NEW_OID} 0\tflutter_mine\n` }) - } - if (args[0] === 'ls-tree') { - return Promise.resolve({ stdout: `160000 commit ${OLD_OID}\tflutter_mine\n` }) - } - if (args[0] === 'rev-parse') { - return Promise.resolve({ stdout: `${NEW_OID}\n` }) - } - return Promise.resolve({ stdout: '' }) - }) - gitExecFileAsyncBufferMock.mockImplementation((args: string[]) => { - const spec = String(args.at(-1)) - if (spec.startsWith(`${OLD_OID}:`)) { - return Promise.resolve({ stdout: Buffer.from('v1\n') }) - } - if (spec.startsWith(`${NEW_OID}:`)) { - return Promise.resolve({ stdout: Buffer.from('v2\n') }) - } - return Promise.resolve({ stdout: Buffer.from('') }) - }) - - const result = await getDiff(PARENT, 'flutter_mine/lib/main.dart', true) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith( - ['cat-file', 'blob', '--', `${OLD_OID}:lib/main.dart`], - { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true } - ) - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith( - ['cat-file', 'blob', '--', `${NEW_OID}:lib/main.dart`], - { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true } - ) - expect(result.kind).toBe('text') - expect(result.originalContent).toBe('v1\n') - expect(result.modifiedContent).toBe('v2\n') - }) - - it('reads inner files from the working tree when the commit is unchanged', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => { - if (args[0] === 'config' && args.includes('.gitmodules')) { - return Promise.resolve({ - stdout: options?.cwd === PARENT ? 'submodule.flutter_mine.path\nflutter_mine\0' : '' - }) - } - if (args[0] === 'ls-files') { - return Promise.resolve({ stdout: `160000 ${OLD_OID} 0\tflutter_mine\n` }) - } - if (args[0] === 'rev-parse') { - return Promise.resolve({ stdout: `${OLD_OID}\n` }) - } - return Promise.resolve({ stdout: '' }) - }) - gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('old\n') }) - readFileMock.mockResolvedValue(Buffer.from('new')) - - const result = await getDiff(PARENT, 'flutter_mine/lib/main.dart', false) - - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith( - ['cat-file', 'blob', '--', ':lib/main.dart'], - { - cwd: SUBMODULE, - maxBuffer: 10 * 1024 * 1024, - preferWslDirectGit: true - } - ) - expect(readFileMock).toHaveBeenCalledWith(path.join(SUBMODULE, 'lib/main.dart')) - expect(result.kind).toBe('text') - expect(result.originalContent).toBe('old\n') - expect(result.modifiedContent).toBe('new') - }) - it('rejects inner submodule diffs whose .gitmodules path escapes the worktree', async () => { gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => { if (args[0] === 'config' && args.includes('.gitmodules')) { @@ -265,21 +144,6 @@ describe('submodule diff routing', () => { }) }) -describe('resolveSubmoduleWorktreePath', () => { - it('resolves a relative submodule path inside the worktree', () => { - expect(resolveSubmoduleWorktreePath('/repo', 'flutter_mine')).toBe( - path.resolve('/repo', 'flutter_mine') - ) - }) - - it('rejects absolute and escaping submodule paths', () => { - expect(() => resolveSubmoduleWorktreePath('/repo', path.resolve('/etc'))).toThrow( - 'Access denied' - ) - expect(() => resolveSubmoduleWorktreePath('/repo', '../outside')).toThrow('Access denied') - }) -}) - describe('listSubmodulePaths', () => { beforeEach(() => { clearSubmodulePathsCacheForTests() @@ -305,155 +169,4 @@ describe('listSubmodulePaths', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - - it('reads submodule names and paths that contain spaces', async () => { - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'submodule.my lib.path\nmy lib\0' }) - - await expect(listSubmodulePaths('/repo')).resolves.toEqual(['my lib']) - }) -}) - -describe('getSubmoduleStatus', () => { - beforeEach(() => { - clearEffectiveUpstreamStatusCacheForTests() - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncBufferMock.mockReset() - gitStreamOptionsMock.mockReset() - lstatMock.mockReset() - readFileMock.mockReset() - existsSyncMock.mockReset() - gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) - }) - - it('runs status inside the submodule worktree and returns inner entries', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/flutter_mine/.git\n') - existsSyncMock.mockReturnValue(false) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - '1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a lib/main.dart\n' - }) - - const result = await getSubmoduleStatus('/repo', 'flutter_mine') - - expect(result.entries).toContainEqual({ - path: 'lib/main.dart', - status: 'modified', - area: 'unstaged' - }) - }) - - it('includes commit-range entries when the submodule pointer moved', async () => { - const OLD_OID = 'a'.repeat(40) - const NEW_OID = 'b'.repeat(40) - readFileMock.mockResolvedValue('gitdir: /repo/flutter_mine/.git\n') - existsSyncMock.mockReturnValue(false) - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('--raw')) { - return Promise.resolve({ stdout: ':100644 100644 a b M\0lib/main.dart\0' }) - } - if (args[0] === 'ls-files') { - return Promise.resolve({ stdout: `160000 ${OLD_OID} 0\tflutter_mine\n` }) - } - if (args[0] === 'rev-parse') { - return Promise.resolve({ stdout: `${NEW_OID}\n` }) - } - return Promise.resolve({ stdout: '' }) - }) - - const result = await getSubmoduleStatus('/repo', 'flutter_mine') - - expect(result.entries).toContainEqual( - expect.objectContaining({ path: 'lib/main.dart', status: 'modified', area: 'unstaged' }) - ) - }) - - it('includes staged commit-range entries from parent HEAD to parent index', async () => { - const OLD_OID = 'a'.repeat(40) - const NEW_OID = 'b'.repeat(40) - readFileMock.mockResolvedValue('gitdir: /repo/flutter_mine/.git\n') - existsSyncMock.mockReturnValue(false) - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('--raw')) { - return Promise.resolve({ stdout: ':100644 100644 a b M\0lib/main.dart\0' }) - } - if (args[0] === 'ls-files') { - return Promise.resolve({ stdout: `160000 ${NEW_OID} 0\tflutter_mine\n` }) - } - if (args[0] === 'ls-tree') { - return Promise.resolve({ stdout: `160000 commit ${OLD_OID}\tflutter_mine\n` }) - } - if (args[0] === 'rev-parse') { - return Promise.resolve({ stdout: `${NEW_OID}\n` }) - } - return Promise.resolve({ stdout: '' }) - }) - - const result = await getSubmoduleStatus('/repo', 'flutter_mine', { staged: true }) - - expect(result.entries).toContainEqual( - expect.objectContaining({ path: 'lib/main.dart', status: 'modified', area: 'unstaged' }) - ) - expect(gitExecFileAsyncMock.mock.calls.some(([args]) => args.includes('status'))).toBe(false) - }) - - it('caps staged commit-range entries before returning them to the renderer', async () => { - const OLD_OID = 'a'.repeat(40) - const NEW_OID = 'b'.repeat(40) - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('--raw')) { - return Promise.resolve({ - stdout: ':100644 100644 a b M\0lib/a.dart\0:100644 100644 a b M\0lib/b.dart\0' - }) - } - if (args[0] === 'ls-files') { - return Promise.resolve({ stdout: `160000 ${NEW_OID} 0\tflutter_mine\n` }) - } - if (args[0] === 'ls-tree') { - return Promise.resolve({ stdout: `160000 commit ${OLD_OID}\tflutter_mine\n` }) - } - return Promise.resolve({ stdout: '' }) - }) - - const result = await getSubmoduleStatus('/repo', 'flutter_mine', { - staged: true, - limit: 1 - }) - - expect(result.entries).toHaveLength(1) - expect(result.didHitLimit).toBe(true) - expect(result.statusLength).toBe(2) - }) - - it('overlaps the inner status, index and HEAD reads instead of serializing them', async () => { - const OLD_OID = 'a'.repeat(40) - const NEW_OID = 'b'.repeat(40) - readFileMock.mockResolvedValue('gitdir: /repo/flutter_mine/.git\n') - existsSyncMock.mockReturnValue(false) - const events: string[] = [] - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const name = args.includes('status') ? 'status' : args[0] - events.push(`start:${name}`) - if (name === 'status') { - // Hold status open so the assertion detects serialization of the independent OID reads. - await new Promise((resolve) => setTimeout(resolve, 5)) - } - events.push(`end:${name}`) - if (name === 'ls-files') { - return { stdout: `160000 ${OLD_OID} 0\tflutter_mine\n` } - } - if (name === 'rev-parse') { - return { stdout: `${NEW_OID}\n` } - } - return { stdout: '' } - }) - - await getSubmoduleStatus('/repo', 'flutter_mine') - - expect(events.indexOf('start:ls-files')).toBeLessThan(events.indexOf('end:status')) - expect(events.indexOf('start:rev-parse')).toBeLessThan(events.indexOf('end:status')) - }) }) diff --git a/src/main/git/source-control/status-upstream-probe-churn.test.ts b/src/main/git/source-control/status-upstream-probe-churn.test.ts deleted file mode 100644 index 655054bb748..00000000000 --- a/src/main/git/source-control/status-upstream-probe-churn.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { existsSyncMock, gitExecFileAsyncMock, readFileMock } = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - gitExecFileAsyncMock: vi.fn(), - readFileMock: vi.fn() -})) - -vi.mock('../command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal>()), - gitExecFileAsync: gitExecFileAsyncMock -})) - -vi.mock('../command-runner/git-stream-stdout', async (importOriginal) => ({ - ...(await importOriginal>()), - gitStreamStdout: async ( - args: string[], - options: { onStdout: (chunk: string) => boolean | void } - ) => { - const { stdout } = await gitExecFileAsyncMock(args) - const stoppedEarly = options.onStdout(stdout ?? '') === true - return { stoppedEarly } - } -})) - -vi.mock('../command-runner/git-process-env', async (importOriginal) => ({ - ...(await importOriginal>()), - gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({ - ...env, - GIT_OPTIONAL_LOCKS: '0' - }) -})) - -vi.mock('fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('fs', () => ({ - existsSync: existsSyncMock -})) - -import { clearEffectiveUpstreamStatusCacheForTests } from './effective-upstream-status-cache' -import { getStatus } from './status-read' - -function getGitArgs(call: unknown[]): string[] { - return call[0] as string[] -} - -function isConfigListSnapshotCommand(args: string[]): boolean { - return args[0] === 'config' && args[1] === '--list' && args[2] === '-z' -} - -function emptyGitConfigSnapshot(): { stdout: string } { - return { stdout: 'core.repositoryformatversion\n0\0' } -} - -function featureFixPushTargetSnapshot(): { stdout: string } { - const records = [ - 'branch.feature/fix.pushremote\nfork', - 'branch.feature/fix.remote\nfork', - 'branch.feature/fix.merge\nrefs/heads/feature/fix' - ] - return { stdout: `${records.join('\0')}\0` } -} - -describe('getStatus missing-upstream polling churn', () => { - beforeEach(() => { - clearEffectiveUpstreamStatusCacheForTests() - existsSyncMock.mockReset() - gitExecFileAsyncMock.mockReset() - readFileMock.mockReset() - existsSyncMock.mockReturnValue(false) - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/Initi-Project\n') - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('does not repeat failed effective-upstream probes for a branch with no upstream', async () => { - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args.includes('status')) { - return { - stdout: '# branch.oid abcdef1234567890\n# branch.head Initi-Project\n' - } - } - if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { - return { stdout: 'refs/heads/Initi-Project\n' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error("fatal: no upstream configured for branch 'Initi-Project'") - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/Initi-Project')) { - throw new Error('missing remote branch') - } - throw new Error(`unexpected git command: ${args.join(' ')}`) - }) - - await getStatus('/repo') - await getStatus('/repo') - await getStatus('/repo') - - const upstreamProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => { - const args = getGitArgs(call) - return args[0] === 'rev-parse' && args.includes('HEAD@{u}') - }) - const sameNameOriginProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => { - const args = getGitArgs(call) - return args[0] === 'rev-parse' && args.includes('refs/remotes/origin/Initi-Project') - }) - - expect(upstreamProbeCalls).toHaveLength(1) - expect(sameNameOriginProbeCalls).toHaveLength(1) - }) - - it('keeps failed effective-upstream probes cached beyond thirty seconds', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args.includes('status')) { - return { - stdout: '# branch.oid abcdef1234567890\n# branch.head Initi-Project\n' - } - } - if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { - return { stdout: 'refs/heads/Initi-Project\n' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error("fatal: no upstream configured for branch 'Initi-Project'") - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/Initi-Project')) { - throw new Error('missing remote branch') - } - throw new Error(`unexpected git command: ${args.join(' ')}`) - }) - - await getStatus('/repo') - vi.setSystemTime(31_000) - await getStatus('/repo') - - const upstreamProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => { - const args = getGitArgs(call) - return args[0] === 'rev-parse' && args.includes('HEAD@{u}') - }) - - expect(upstreamProbeCalls).toHaveLength(1) - }) - - it('coalesces concurrent effective-upstream probes for a branch with no upstream', async () => { - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args.includes('status')) { - return { - stdout: '# branch.oid abcdef1234567890\n# branch.head Initi-Project\n' - } - } - if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { - return { stdout: 'refs/heads/Initi-Project\n' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - await Promise.resolve() - throw new Error("fatal: no upstream configured for branch 'Initi-Project'") - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/Initi-Project')) { - await Promise.resolve() - throw new Error('missing remote branch') - } - throw new Error(`unexpected git command: ${args.join(' ')}`) - }) - - await Promise.all([getStatus('/repo'), getStatus('/repo'), getStatus('/repo')]) - - const upstreamProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => { - const args = getGitArgs(call) - return args[0] === 'rev-parse' && args.includes('HEAD@{u}') - }) - const sameNameOriginProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => { - const args = getGitArgs(call) - return args[0] === 'rev-parse' && args.includes('refs/remotes/origin/Initi-Project') - }) - - expect(upstreamProbeCalls).toHaveLength(1) - expect(sameNameOriginProbeCalls).toHaveLength(1) - }) - - // Model a pushed branch with no configured upstream, resolved through same-name origin. - function mockResolvedSameNameOrigin(revList: { failOnCall?: number } = {}): void { - let revListCalls = 0 - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args.includes('status')) { - return { - stdout: '# branch.oid abcdef1234567890\n# branch.head bench/feature\n' - } - } - if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { - return { stdout: 'refs/heads/bench/feature\n' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error("fatal: no upstream configured for branch 'bench/feature'") - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/bench/feature')) { - return { stdout: 'abcdef1234567890\n' } - } - if (args[0] === 'rev-list') { - revListCalls++ - if (revList.failOnCall === revListCalls) { - throw new Error('fatal: bad revision') - } - return { stdout: '1\t0\n' } - } - throw new Error(`unexpected git command: ${args.join(' ')}`) - }) - } - - function countCalls(predicate: (args: string[]) => boolean): number { - return gitExecFileAsyncMock.mock.calls.filter((call) => predicate(getGitArgs(call))).length - } - - it('revalidates a resolved same-name upstream with a single rev-list per poll', async () => { - mockResolvedSameNameOrigin() - - await getStatus('/repo') - await getStatus('/repo') - await getStatus('/repo') - - expect(countCalls((args) => args[0] === 'symbolic-ref')).toBe(1) - expect(countCalls((args) => args[0] === 'rev-parse' && args.includes('HEAD@{u}'))).toBe(1) - expect(countCalls(isConfigListSnapshotCommand)).toBe(1) - expect( - countCalls( - (args) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/bench/feature') - ) - ).toBe(1) - expect(countCalls((args) => args[0] === 'rev-list')).toBe(3) - }) - - it('re-resolves the upstream after the resolved-name cache TTL lapses', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - mockResolvedSameNameOrigin() - - await getStatus('/repo') - vi.setSystemTime(61_000) - await getStatus('/repo') - - expect(countCalls((args) => args[0] === 'symbolic-ref')).toBe(2) - expect(countCalls((args) => args[0] === 'rev-list')).toBe(2) - }) - - it('falls back to a full re-resolve when the cached upstream rev-list fails', async () => { - mockResolvedSameNameOrigin({ failOnCall: 2 }) - - await getStatus('/repo') - const status = await getStatus('/repo') - - expect(countCalls((args) => args[0] === 'symbolic-ref')).toBe(2) - // rev-list: initial + failed revalidate + fallback's own rev-list. - expect(countCalls((args) => args[0] === 'rev-list')).toBe(3) - expect(status.upstreamStatus?.hasUpstream).toBe(true) - }) - - it('does not cache a positive configured push target signal', async () => { - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args.includes('status')) { - return { - stdout: '# branch.oid abcdef1234567890\n# branch.head feature/fix\n' - } - } - if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { - return { stdout: 'refs/heads/feature/fix\n' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error("fatal: no upstream configured for branch 'feature/fix'") - } - if (isConfigListSnapshotCommand(args)) { - return featureFixPushTargetSnapshot() - } - if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/'))) { - throw new Error('missing remote branch') - } - throw new Error(`unexpected git command: ${args.join(' ')}`) - }) - - await getStatus('/repo') - await getStatus('/repo') - - const upstreamProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => { - const args = getGitArgs(call) - return args[0] === 'rev-parse' && args.includes('HEAD@{u}') - }) - - expect(upstreamProbeCalls).toHaveLength(2) - }) - - it('rechecks failed effective-upstream probes after the branch identity changes', async () => { - let nextBranch = 'Second-Project' - let currentStatusBranch = nextBranch - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args.includes('status')) { - currentStatusBranch = nextBranch - nextBranch = 'Other-Project' - return { - stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentStatusBranch}\n` - } - } - if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { - return { stdout: `refs/heads/${currentStatusBranch}\n` } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error('fatal: no upstream configured') - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { - throw new Error('missing remote branch') - } - throw new Error(`unexpected git command: ${args.join(' ')}`) - }) - - await getStatus('/repo') - await getStatus('/repo') - - const sameNameOriginProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => { - const args = getGitArgs(call) - return args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/')) - }) - - expect(sameNameOriginProbeCalls.map((call) => getGitArgs(call).at(-1))).toEqual([ - 'refs/remotes/origin/Second-Project', - 'refs/remotes/origin/Other-Project' - ]) - }) - - it('coalesces no-upstream config reads into one snapshot subprocess', async () => { - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args.includes('status')) { - return { - stdout: '# branch.oid abcdef1234567890\n# branch.head Initi-Project\n' - } - } - if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { - return { stdout: 'refs/heads/Initi-Project\n' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error("fatal: no upstream configured for branch 'Initi-Project'") - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/Initi-Project')) { - throw new Error('missing remote branch') - } - throw new Error(`unexpected git command: ${args.join(' ')}`) - }) - - const status = await getStatus('/repo') - - const configListCalls = gitExecFileAsyncMock.mock.calls.filter((call) => - isConfigListSnapshotCommand(getGitArgs(call)) - ) - const configGetCalls = gitExecFileAsyncMock.mock.calls.filter((call) => { - const args = getGitArgs(call) - return args[0] === 'config' && args[1] === '--get' - }) - - expect(configListCalls).toHaveLength(1) - expect(configGetCalls).toHaveLength(0) - if (!status.upstreamStatus) { - throw new Error('expected upstream status') - } - expect(status.upstreamStatus.hasUpstream).toBe(false) - }) -}) diff --git a/src/main/git/source-control/status.test.ts b/src/main/git/source-control/status.test.ts deleted file mode 100644 index a929d126cbe..00000000000 --- a/src/main/git/source-control/status.test.ts +++ /dev/null @@ -1,763 +0,0 @@ -import type * as RunnerModule from '../runner' -import type * as GitExecFileModule from '../command-runner/git-exec-file' -import type * as GitStreamStdoutModule from '../command-runner/git-stream-stdout' -import type * as GitProcessEnvModule from '../command-runner/git-process-env' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as BoundedFileReader from '../../../shared/node-bounded-file-reader' -import { - createBoundedFileReaderModuleMock, - createFsPromisesModuleMock, - createGitRunnerModuleMock -} from './status-test-harness' - -const { - gitExecFileAsyncMock, - gitExecFileAsyncBufferMock, - gitStreamOptionsMock, - lstatMock, - realpathMock, - readFileMock, - statMock, - rmMock, - accessMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileAsyncBufferMock: vi.fn(), - gitStreamOptionsMock: vi.fn(), - lstatMock: vi.fn(), - realpathMock: vi.fn(), - readFileMock: vi.fn(), - statMock: vi.fn(), - rmMock: vi.fn(), - accessMock: vi.fn() -})) - -vi.mock('../runner', () => - createGitRunnerModuleMock({ - gitExecFileAsyncMock, - gitExecFileAsyncBufferMock, - gitStreamOptionsMock - }) -) - -vi.mock('../command-runner/git-exec-file', async (importOriginal) => { - const runnerMocks = await vi.importMock('../runner') - return { - ...(await importOriginal()), - gitExecFileAsync: runnerMocks.gitExecFileAsync, - gitExecFileAsyncBuffer: runnerMocks.gitExecFileAsyncBuffer - } -}) - -vi.mock('../command-runner/git-stream-stdout', async (importOriginal) => { - const runnerMocks = await vi.importMock('../runner') - return { - ...(await importOriginal()), - gitStreamStdout: runnerMocks.gitStreamStdout - } -}) - -vi.mock('../command-runner/git-process-env', async (importOriginal) => { - const runnerMocks = await vi.importMock('../runner') - return { - ...(await importOriginal()), - gitOptionalLocksDisabledEnv: runnerMocks.gitOptionalLocksDisabledEnv - } -}) - -vi.mock('fs/promises', () => - createFsPromisesModuleMock({ - lstatMock, - realpathMock, - readFileMock, - statMock, - rmMock, - accessMock - }) -) - -vi.mock('../../../shared/node-bounded-file-reader', async (importOriginal) => - createBoundedFileReaderModuleMock(await importOriginal(), { - readFileMock, - statMock - }) -) - -import { clearEffectiveUpstreamStatusCacheForTests } from './effective-upstream-status-cache' -import { getStatus } from './status-read' -import { stageFile } from './staging' - -describe('getStatus', () => { - beforeEach(() => { - clearEffectiveUpstreamStatusCacheForTests() - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncBufferMock.mockReset() - gitStreamOptionsMock.mockReset() - lstatMock.mockReset() - readFileMock.mockReset() - accessMock.mockReset() - accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) - statMock.mockReset() - statMock.mockResolvedValue({ isFile: () => true, size: 12 }) - // Supply empty numstat results after tests' one-shot status responses. - gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) - }) - - /** `access` targets outside the git dir — i.e. working-tree probes, not conflict-marker reads. */ - function conflictFileProbes(): string[] { - return accessMock.mock.calls - .map(([target]) => String(target).replaceAll('\\', '/')) - .filter((target) => !target.includes('/.git/')) - } - - it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - accessMock.mockImplementation(async (target: string) => { - if (target.endsWith('MERGE_HEAD')) { - return undefined - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - 'u UU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/app.ts\n' - }) - - const result = await getStatus('/repo') - - expect(result.conflictOperation).toBe('merge') - expect(result.entries).toEqual([ - { - path: 'src/app.ts', - area: 'unstaged', - status: 'modified', - conflictKind: 'both_modified', - conflictStatus: 'unresolved' - } - ]) - }) - - // The 7th field of a `u` record is the working-tree mode; `000000` is how Git reports an absent path. - it('maps deleted conflicts to deleted from the porcelain working-tree mode', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - 'u UD N... 100644 100644 000000 000000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/deleted.ts\n' - }) - - const result = await getStatus('/repo') - - expect(result.entries[0]).toEqual({ - path: 'src/deleted.ts', - area: 'unstaged', - status: 'deleted', - conflictKind: 'deleted_by_them', - conflictStatus: 'unresolved' - }) - expect(conflictFileProbes()).toEqual([]) - }) - - it('never re-probes the working tree for a conflict row, whatever the filesystem would say', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - // Every probe fails ENOENT (beforeEach) or EIO — neither may reach the row's status. - accessMock.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' })) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - 'u AU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/new.ts\n' - }) - - const result = await getStatus('/repo') - - expect(result.entries[0]?.status).toBe('modified') - expect(result.entries[0]?.conflictKind).toBe('added_by_us') - expect(conflictFileProbes()).toEqual([]) - }) - - // Normalize separators to test probe targets independently of the current host. - it('resolves a WSL conflict row without crossing the 9p share', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - 'u DU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/new.ts\n' - }) - - try { - const result = await getStatus('/home/me/repo/feature', { wslDistro: 'Ubuntu' }) - - const probed = accessMock.mock.calls.map(([target]) => String(target).replaceAll('\\', '/')) - expect(probed).not.toContain('//wsl.localhost/Ubuntu/home/me/repo/feature/src/new.ts') - expect(conflictFileProbes()).toEqual([]) - expect(result.entries[0]?.status).toBe('modified') - expect(result.entries[0]?.conflictKind).toBe('deleted_by_us') - expect( - probed.filter((target) => - target.startsWith('//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/feature/') - ) - ).toHaveLength(4) - } finally { - platformSpy.mockRestore() - } - }) - - it('probes shared symlinks through the distro spelling on Windows', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n') - lstatMock.mockResolvedValue({ isSymbolicLink: () => true }) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '? node_modules\n' }) - - try { - const result = await getStatus('/home/me/repo/feature', { - wslDistro: 'Ubuntu', - sharedLinkPaths: ['node_modules'] - }) - - expect(String(lstatMock.mock.calls[0]?.[0]).replaceAll('\\', '/')).toContain( - '//wsl.localhost/Ubuntu/home/me/repo/feature/node_modules' - ) - expect(result.entries).toEqual([]) - } finally { - platformSpy.mockRestore() - } - }) - - it('passes core.quotePath=false and round-trips UTF-8 paths', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - '1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a docs/日本語/sample.md\n' - }) - - const result = await getStatus('/repo') - - // Without quotePath=false, octal-escaped names would break display and subsequent blob reads. - expect(gitExecFileAsyncMock).toHaveBeenCalledWith([ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all' - ]) - expect(result.entries).toEqual([ - { path: 'docs/日本語/sample.md', status: 'modified', area: 'unstaged' } - ]) - }) - - it('preserves porcelain v2 submodule dirtiness flags on status rows', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - '1 AM S..U 000000 160000 160000 0000000000000000000000000000000000000000 7844cb64e631f17a9ca5b548f3500ef7cecd2f17 nested-repo\n' - }) - - const result = await getStatus('/repo') - - expect(result.entries).toEqual([ - { - path: 'nested-repo', - status: 'added', - area: 'staged', - submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } - }, - { - path: 'nested-repo', - status: 'modified', - area: 'unstaged', - submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } - } - ]) - }) - - it('omits ignored files by default and parses them when requested', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: '! dist/\n! generated/file.js\n' - }) - - const result = await getStatus('/repo', { includeIgnored: true }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith([ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all', - '--ignored=matching' - ]) - expect(result.ignoredPaths).toEqual(['dist/', 'generated/file.js']) - }) - - it('parses branch identity from porcelain v2 branch headers', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a src/app.ts\n' - }) - - const result = await getStatus('/repo') - - expect(result).toMatchObject({ - head: 'abcdef1234567890', - branch: 'refs/heads/feature/prompts' - }) - }) - - it('folds upstream ahead/behind from porcelain v2 into the status result', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n# branch.upstream origin/feature/prompts\n# branch.ab +2 -3\n' - }) - - const result = await getStatus('/repo') - - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(result.upstreamStatus).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature/prompts', - ahead: 2, - behind: 3 - }) - }) - - it('reports no upstream from porcelain v2 status when no same-name origin branch exists', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === '-c' && args.includes('status')) { - return Promise.resolve({ - stdout: '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n' - }) - } - if (args[0] === 'symbolic-ref') { - return Promise.resolve({ stdout: 'refs/heads/feature/prompts\n' }) - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - return Promise.reject(new Error('fatal: no upstream configured')) - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature/prompts')) { - return Promise.reject(new Error('missing remote branch')) - } - if (args[0] === 'config') { - return Promise.reject(new Error(`missing ${args[2] ?? 'config'}`)) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const result = await getStatus('/repo') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/feature/prompts'], - { cwd: '/repo', preferWslDirectGit: true } - ) - expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) - }) - - it('uses same-name origin branch status for legacy base-tracking worktrees', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: - '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n# branch.upstream origin/main\n# branch.ab +1 -0\n' - }) - .mockResolvedValueOnce({ stdout: 'refs/heads/feature/prompts\n' }) - .mockResolvedValueOnce({ stdout: 'origin/main\n' }) - .mockResolvedValueOnce({ stdout: 'abc123\n' }) - .mockResolvedValueOnce({ stdout: '3\t1\n' }) - - const result = await getStatus('/repo') - - expect(result.upstreamStatus).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature/prompts', - ahead: 3, - behind: 1 - }) - }) - - it('omits --ignored and ignoredPaths when includeIgnored is not requested', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) - - const result = await getStatus('/repo') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith([ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all' - ]) - expect('ignoredPaths' in result).toBe(false) - }) - - it('parses ! porcelain v2 records into ignoredPaths when includeIgnored is true', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: '! dist/\n! .env\n! coverage/\n' - }) - - const result = await getStatus('/repo', { includeIgnored: true }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith([ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all', - '--ignored=matching' - ]) - expect(result.ignoredPaths).toEqual(['dist/', '.env', 'coverage/']) - expect(result.entries).toEqual([]) - }) - - it('attaches per-area line counts from staged and unstaged numstat', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - return Promise.resolve({ - stdout: - '1 M. N... 100644 100644 100644 aaaa aaaa src/staged.ts\n' + - '1 .M N... 100644 100644 100644 bbbb bbbb src/unstaged.ts\n' - }) - } - if (args.includes('--numstat')) { - return Promise.resolve({ - stdout: args.includes('--cached') ? '10\t0\tsrc/staged.ts\n' : '3\t4\tsrc/unstaged.ts\n' - }) - } - return Promise.resolve({ stdout: '' }) - }) - - const result = await getStatus('/repo') - - expect(result.entries).toEqual([ - { path: 'src/staged.ts', status: 'modified', area: 'staged', added: 10, removed: 0 }, - { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 } - ]) - }) - - it('omits line stats without overwriting the reusable line-stats cache', async () => { - readFileMock.mockResolvedValue('gitdir: /stats-repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - return Promise.resolve({ - stdout: - '# branch.oid head-stats\n' + - '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' - }) - } - if (args.includes('--numstat')) { - return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) - } - return Promise.resolve({ stdout: '' }) - }) - - const withStats = await getStatus('/stats-repo') - const withoutStats = await getStatus('/stats-repo', { includeLineStats: false }) - const reused = await getStatus('/stats-repo', { reuseLineStats: true }) - - expect(withoutStats.entries).toEqual( - withStats.entries.map(({ added: _added, removed: _removed, ...entry }) => entry) - ) - expect(reused.entries).toEqual(withStats.entries) - expect( - gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) - ).toHaveLength(1) - }) - - it('reuses unchanged line stats only when the safety hint is present', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - return Promise.resolve({ - stdout: - '# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' - }) - } - if (args.includes('--numstat')) { - return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) - } - return Promise.resolve({ stdout: '' }) - }) - - await getStatus('/repo') - const reused = await getStatus('/repo', { reuseLineStats: true }) - await getStatus('/repo') - - expect(reused.entries).toEqual([ - { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 } - ]) - expect( - gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) - ).toHaveLength(2) - }) - - it('recomputes after a scan whose numstat failed instead of pinning missing counts', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - let failNumstat = true - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - return Promise.resolve({ - stdout: - '# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' - }) - } - if (args.includes('--numstat')) { - if (failNumstat) { - failNumstat = false - return Promise.reject(new Error('transient index.lock')) - } - return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) - } - return Promise.resolve({ stdout: '' }) - }) - - const failed = await getStatus('/repo') - const reused = await getStatus('/repo', { reuseLineStats: true }) - - expect(failed.entries[0]?.added).toBeUndefined() - expect(reused.entries).toEqual([ - { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 } - ]) - }) - - it('invalidates safety reuse for a new head and for known mutations', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - let head = 'head-1' - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - return Promise.resolve({ - stdout: - `# branch.oid ${head}\n` + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' - }) - } - if (args.includes('--numstat')) { - return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) - } - return Promise.resolve({ stdout: '' }) - }) - - await getStatus('/repo') - head = 'head-2' - await getStatus('/repo', { reuseLineStats: true }) - await stageFile('/repo', 'src/unstaged.ts') - await getStatus('/repo', { reuseLineStats: true }) - - expect( - gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) - ).toHaveLength(3) - }) - - it('isolates line-stat reuse between WSL distributions', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - return Promise.resolve({ - stdout: '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' - }) - } - if (args.includes('--numstat')) { - return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) - } - return Promise.resolve({ stdout: '' }) - }) - - await getStatus('/repo', { wslDistro: 'ubuntu' }) - await getStatus('/repo', { wslDistro: 'debian', reuseLineStats: true }) - await getStatus('/repo', { wslDistro: 'ubuntu', reuseLineStats: true }) - - expect( - gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) - ).toHaveLength(2) - }) - - it('attaches numstat counts for literal paths containing rename markers', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - return Promise.resolve({ - stdout: '1 .M N... 100644 100644 100644 aaaa aaaa docs/a => b.txt\n' - }) - } - if (args.includes('--numstat')) { - return Promise.resolve({ stdout: '1\t0\tdocs/a => b.txt\0' }) - } - return Promise.resolve({ stdout: '' }) - }) - - const result = await getStatus('/repo') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - [ - '-c', - 'core.quotePath=false', - '-c', - 'diff.autoRefreshIndex=false', - 'diff', - '-z', - '--numstat', - '--no-renames' - ], - expect.objectContaining({ - cwd: '/repo', - env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) - }) - ) - expect(result.entries).toEqual([ - { path: 'docs/a => b.txt', status: 'modified', area: 'unstaged', added: 1, removed: 0 } - ]) - }) - - it('attaches staged rename counts to the new path', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - return Promise.resolve({ - stdout: '2 R. N... 100644 100644 100644 aaaa bbbb R100 src/new name.ts\tsrc/old name.ts\n' - }) - } - if (args.includes('--numstat')) { - return Promise.resolve({ stdout: '2\t1\tsrc/old name.ts => src/new name.ts\n' }) - } - return Promise.resolve({ stdout: '' }) - }) - - const result = await getStatus('/repo') - - expect(result.entries).toEqual([ - { - path: 'src/new name.ts', - oldPath: 'src/old name.ts', - status: 'renamed', - area: 'staged', - added: 2, - removed: 1 - } - ]) - }) - - it('counts untracked file contents as additions', async () => { - lstatMock.mockResolvedValue({ - size: 14, - mtimeMs: 1, - ctimeMs: 1, - isFile: () => true, - isSymbolicLink: () => false - }) - readFileMock.mockImplementation((target: string) => - String(target).endsWith('.git') - ? Promise.resolve('gitdir: /repo/.git/worktrees/feature\n') - : Promise.resolve(Buffer.from('one\ntwo\nthree\n')) - ) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '? src/brand-new.ts\n' }) - - const result = await getStatus('/repo') - - expect(result.entries).toEqual([ - { path: 'src/brand-new.ts', status: 'untracked', area: 'untracked', added: 3 } - ]) - }) - - it('leaves binary working-tree changes without counts', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - return Promise.resolve({ - stdout: '1 .M N... 100644 100644 100644 cccc cccc assets/logo.png\n' - }) - } - if (args.includes('--numstat')) { - return Promise.resolve({ stdout: '-\t-\tassets/logo.png\n' }) - } - return Promise.resolve({ stdout: '' }) - }) - - const result = await getStatus('/repo') - - expect(result.entries).toEqual([ - { path: 'assets/logo.png', status: 'modified', area: 'unstaged' } - ]) - }) - - it('skips numstat entirely for a clean working tree', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) - - await getStatus('/repo') - - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - - it('truncates and flags didHitLimit when entries exceed the limit', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - const stdout = `${Array.from({ length: 25 }, (_, i) => `? file${i}.txt`).join('\n')}\n` - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout }) - - const result = await getStatus('/repo', { limit: 10 }) - - expect(result.didHitLimit).toBe(true) - expect(result.statusLength).toBeGreaterThan(10) - expect(result.entries.length).toBe(10) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - - it('caps unmerged conflicts and keeps the visible conflict rows', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - const lines = [ - 'u UU S... 160000 160000 160000 160000 aa bb cc vendor/submodule', - ...Array.from( - { length: 3 }, - (_, i) => `u UU N... 100644 100644 100644 100644 aa bb cc conflict-${i}.ts` - ) - ].join('\n') - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: `${lines}\n` }) - - const result = await getStatus('/repo', { limit: 2 }) - - expect(result.didHitLimit).toBe(true) - expect(result.statusLength).toBe(3) - expect(result.entries).toHaveLength(2) - expect(result.entries.map((entry) => entry.path)).toEqual(['conflict-0.ts', 'conflict-1.ts']) - expect(result.entries.every((entry) => entry.conflictStatus === 'unresolved')).toBe(true) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - - it('keeps an early conflict ahead of later ordinary rows at the cap', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - const lines = [ - '? before.ts', - 'u UU N... 100644 100644 100644 100644 aa bb cc conflict.ts', - '? after.ts' - ].join('\n') - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: `${lines}\n` }) - - const result = await getStatus('/repo', { limit: 2 }) - - expect(result.didHitLimit).toBe(true) - expect(result.entries.map((entry) => entry.path)).toEqual(['before.ts', 'conflict.ts']) - expect(result.entries[1]).toMatchObject({ - conflictKind: 'both_modified', - conflictStatus: 'unresolved' - }) - }) - - it('does not flag didHitLimit for a normal repo under the limit', async () => { - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '? a.txt\n? b.txt\n' }) - - const result = await getStatus('/repo', { limit: 10 }) - - expect(result.didHitLimit).toBeUndefined() - expect(result.entries.length).toBe(2) - }) -}) diff --git a/src/main/git/source-control/upstream.test.ts b/src/main/git/source-control/upstream.test.ts index 778182c9d2b..25dce884ec9 100644 --- a/src/main/git/source-control/upstream.test.ts +++ b/src/main/git/source-control/upstream.test.ts @@ -10,13 +10,6 @@ vi.mock('../command-runner/git-exec-file', async (importOriginal) => ({ })) import { getUpstreamStatus, invalidateGitUpstreamStatusReads } from './upstream' -import { runWithGitReadCacheInvalidation } from './git-read-cache-invalidation' - -const missingTrackingRefError = new Error( - "fatal: ambiguous argument 'HEAD@{u}': unknown revision or path not in the working tree.\n" + - "Use '--' to separate paths from revisions, like this:\n" + - "'git [...] -- [...]'" -) describe('getUpstreamStatus', () => { beforeEach(() => { @@ -24,654 +17,6 @@ describe('getUpstreamStatus', () => { invalidateGitUpstreamStatusReads() }) - it('shares one physical read across ten identical native callers', async () => { - let resolveSymbolicRef = (): void => {} - const symbolicRefGate = new Promise((resolve) => { - resolveSymbolicRef = resolve - }) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'symbolic-ref') { - await symbolicRefGate - return { stdout: 'refs/heads/main\n' } - } - if (args[0] === 'rev-parse') { - return { stdout: 'origin/main\n' } - } - if (args[0] === 'rev-list') { - return { stdout: '0\t0\n' } - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const reads = Array.from({ length: 10 }, () => getUpstreamStatus('/repo')) - resolveSymbolicRef() - const results = await Promise.all(reads) - - expect( - gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'symbolic-ref') - ).toHaveLength(1) - expect(new Set(results).size).toBe(1) - await getUpstreamStatus('/repo') - expect( - gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'symbolic-ref') - ).toHaveLength(2) - }) - - it('isolates physical reads by worktree, native or WSL host, and every target field', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'symbolic-ref') { - return Promise.resolve({ stdout: 'refs/heads/main\n' }) - } - if (args[0] === 'check-ref-format') { - return Promise.resolve({ stdout: '' }) - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - return Promise.resolve({ stdout: 'origin/main\n' }) - } - if (args[0] === 'rev-parse' && args.includes('--verify')) { - return Promise.resolve({ stdout: 'abc123\n' }) - } - if (args[0] === 'rev-list') { - return Promise.resolve({ stdout: '0\t0\n' }) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - const baseTarget = { remoteName: 'fork', branchName: 'feature' } - - await Promise.all([ - getUpstreamStatus('/repo-a'), - getUpstreamStatus('/repo-b'), - getUpstreamStatus('/repo-a', undefined, { wslDistro: 'Ubuntu' }), - getUpstreamStatus('/repo-a', undefined, { wslDistro: 'Debian' }), - getUpstreamStatus('/repo-a', baseTarget), - getUpstreamStatus('/repo-a', { ...baseTarget, remoteName: 'origin' }), - getUpstreamStatus('/repo-a', { ...baseTarget, branchName: 'other' }), - getUpstreamStatus('/repo-a', { - ...baseTarget, - remoteUrl: 'https://github.com/example/fork.git' - }), - getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: false }), - getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: true }) - ]) - - expect( - gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'symbolic-ref') - ).toHaveLength(4) - expect( - gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'check-ref-format') - ).toHaveLength(6) - }) - - it('runs fresh physical work after a normalized rejection', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) - .mockResolvedValueOnce({ stdout: 'origin/main\n' }) - .mockRejectedValueOnce(new Error('fatal: authentication failed')) - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) - .mockResolvedValueOnce({ stdout: 'origin/main\n' }) - .mockResolvedValueOnce({ stdout: '0\t0\n' }) - - await expect(getUpstreamStatus('/repo')).rejects.toThrow('fatal: authentication failed') - await expect(getUpstreamStatus('/repo')).resolves.toMatchObject({ - hasUpstream: true, - upstreamName: 'origin/main' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(6) - }) - - it('uses the common pre/post mutation fence for physical upstream reads', async () => { - const pendingReads: { - promise: Promise<{ stdout: string }> - resolve: (value: { stdout: string }) => void - }[] = [] - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'symbolic-ref') { - let resolve!: (value: { stdout: string }) => void - const promise = new Promise<{ stdout: string }>((innerResolve) => { - resolve = innerResolve - }) - pendingReads.push({ promise, resolve }) - return promise - } - if (args[0] === 'rev-parse') { - return Promise.resolve({ stdout: 'origin/main\n' }) - } - if (args[0] === 'rev-list') { - return Promise.resolve({ stdout: '0\t0\n' }) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - let finishMutation!: () => void - const mutationGate = new Promise((resolve) => { - finishMutation = resolve - }) - - const before = getUpstreamStatus('/repo') - await vi.waitFor(() => expect(pendingReads).toHaveLength(1)) - const mutation = runWithGitReadCacheInvalidation(() => mutationGate) - const during = getUpstreamStatus('/repo') - await vi.waitFor(() => expect(pendingReads).toHaveLength(2)) - finishMutation() - await mutation - const after = getUpstreamStatus('/repo') - await vi.waitFor(() => expect(pendingReads).toHaveLength(3)) - - pendingReads.forEach(({ resolve }) => resolve({ stdout: 'refs/heads/main\n' })) - await Promise.all([before, during, after]) - expect(pendingReads).toHaveLength(3) - }) - - it('returns upstream and ahead/behind counts when tracking is configured', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) - .mockResolvedValueOnce({ stdout: 'origin/main\n' }) - .mockResolvedValueOnce({ stdout: '2\t3\n' }) - .mockResolvedValueOnce({ stdout: '+ abc123 remote work\n' }) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: true, - upstreamName: 'origin/main', - ahead: 2, - behind: 3, - behindCommitsArePatchEquivalent: false - }) - }) - - it('marks diverged upstream commits as patch-equivalent after a rebase', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'refs/heads/feature\n' }) - .mockResolvedValueOnce({ stdout: 'origin/feature\n' }) - .mockResolvedValueOnce({ stdout: '14\t3\n' }) - .mockResolvedValueOnce({ - stdout: - '= ac503deae Stabilize pull request creation flow\n' + - '= 7dc0fc1a6 Clean up fork PR remotes after worktree deletion\n' - }) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature', - ahead: 14, - behind: 3, - behindCommitsArePatchEquivalent: true - }) - }) - - it('keeps configured local-branch upstreams', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'refs/heads/feature\n' }) - .mockResolvedValueOnce({ stdout: 'main\n' }) - .mockResolvedValueOnce({ stdout: '1\t0\n' }) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: true, - upstreamName: 'main', - ahead: 1, - behind: 0 - }) - }) - - it('returns hasUpstream=false when upstream output is empty', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'refs/heads/feature\n' }) - .mockResolvedValueOnce({ stdout: '\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockRejectedValueOnce(new Error('missing remote branch')) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: false, - ahead: 0, - behind: 0 - }) - }) - - it('returns hasUpstream=false when upstream is missing', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'refs/heads/feature\n' }) - .mockRejectedValueOnce(new Error('fatal: no upstream configured')) - .mockResolvedValueOnce({ stdout: '' }) - .mockRejectedValueOnce(new Error('missing remote branch')) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: false, - ahead: 0, - behind: 0 - }) - }) - - it('returns hasUpstream=false when the configured tracking ref is missing', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'refs/heads/feature\n' }) - .mockRejectedValueOnce(missingTrackingRefError) - .mockResolvedValueOnce({ stdout: '' }) - .mockRejectedValueOnce(new Error('missing remote branch')) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: false, - ahead: 0, - behind: 0 - }) - }) - - it('uses the same-name origin branch when a legacy worktree tracks origin/main', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'refs/heads/feature\n' }) - .mockResolvedValueOnce({ stdout: 'origin/main\n' }) - .mockResolvedValueOnce({ stdout: 'abc123\n' }) - .mockResolvedValueOnce({ stdout: '3\t1\n' }) - .mockResolvedValueOnce({ stdout: '+ def456 remote work\n' }) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature', - ahead: 3, - behind: 1, - behindCommitsArePatchEquivalent: false - }) - }) - - it('uses a named remote that matches a URL-valued branch remote', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'symbolic-ref') { - return Promise.resolve({ stdout: 'refs/heads/imp/chinese-translation\n' }) - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - return Promise.reject(new Error('fatal: no upstream configured')) - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) { - return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' }) - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) { - return Promise.resolve({ stdout: 'refs/heads/imp/chinese-translation\n' }) - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.base')) { - return Promise.reject(new Error('missing branch base')) - } - if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'origin') { - return Promise.resolve({ stdout: 'https://github.com/stablyai/orca.git\n' }) - } - if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') { - return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' }) - } - if (args[0] === 'remote' && args[1] === '-v') { - return Promise.resolve({ - stdout: [ - 'origin\thttps://github.com/stablyai/orca.git (fetch)', - 'origin\thttps://github.com/stablyai/orca.git (push)', - 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)', - 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)' - ].join('\n') - }) - } - if (args[0] === 'remote') { - return Promise.resolve({ stdout: 'origin\npr-pynickle-orca\n' }) - } - if ( - args[0] === 'rev-parse' && - args.includes('refs/remotes/pr-pynickle-orca/imp/chinese-translation') - ) { - return Promise.resolve({ stdout: 'fork-head\n' }) - } - if (args[0] === 'rev-list') { - return Promise.resolve({ stdout: '2\t0\n' }) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: true, - upstreamName: 'pr-pynickle-orca/imp/chinese-translation', - ahead: 2, - behind: 0 - }) - }) - - it('uses a fork head branch even when its name matches the base branch', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'symbolic-ref') { - return Promise.resolve({ stdout: 'refs/heads/review/pr-1\n' }) - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - return Promise.reject(new Error('fatal: no upstream configured')) - } - if (args[0] === 'config' && args.includes('branch.review/pr-1.remote')) { - return Promise.resolve({ stdout: 'fork\n' }) - } - if (args[0] === 'config' && args.includes('branch.review/pr-1.merge')) { - return Promise.resolve({ stdout: 'refs/heads/main\n' }) - } - if (args[0] === 'config' && args.includes('branch.review/pr-1.base')) { - return Promise.resolve({ stdout: 'refs/remotes/origin/main\n' }) - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/fork/main')) { - return Promise.resolve({ stdout: 'fork-head\n' }) - } - if (args[0] === 'rev-list') { - return Promise.resolve({ stdout: '3\t0\n' }) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: true, - upstreamName: 'fork/main', - ahead: 3, - behind: 0 - }) - }) - - it('marks a URL-valued branch push target when no matching remote is configured', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'symbolic-ref') { - return Promise.resolve({ stdout: 'refs/heads/imp/chinese-translation\n' }) - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - return Promise.reject(new Error('fatal: no upstream configured')) - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.pushRemote')) { - return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' }) - } - if (args[0] === 'config' && args.includes('remote.pushDefault')) { - return Promise.reject(new Error('missing pushDefault')) - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) { - return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' }) - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) { - return Promise.resolve({ stdout: 'refs/heads/imp/chinese-translation\n' }) - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.base')) { - return Promise.reject(new Error('missing branch base')) - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return Promise.resolve({ stdout: 'https://github.com/stablyai/orca.git\n' }) - } - if (args[0] === 'remote') { - return Promise.resolve({ stdout: 'origin\n' }) - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/imp/chinese-translation')) { - return Promise.reject(new Error('missing origin tracking ref')) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: false, - ahead: 0, - behind: 0, - hasConfiguredPushTarget: true - }) - }) - - it('marks a fork head push target when the same-named base branch is on another remote', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'symbolic-ref') { - return Promise.resolve({ stdout: 'refs/heads/review/pr-1\n' }) - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - return Promise.reject(new Error('fatal: no upstream configured')) - } - if (args[0] === 'config' && args.includes('branch.review/pr-1.pushRemote')) { - return Promise.resolve({ stdout: 'fork\n' }) - } - if (args[0] === 'config' && args.includes('remote.pushDefault')) { - return Promise.reject(new Error('missing pushDefault')) - } - if (args[0] === 'config' && args.includes('branch.review/pr-1.remote')) { - return Promise.resolve({ stdout: 'fork\n' }) - } - if (args[0] === 'config' && args.includes('branch.review/pr-1.merge')) { - return Promise.resolve({ stdout: 'refs/heads/main\n' }) - } - if (args[0] === 'config' && args.includes('branch.review/pr-1.base')) { - return Promise.resolve({ stdout: 'refs/remotes/origin/main\n' }) - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/fork/main')) { - return Promise.reject(new Error('missing fork tracking ref')) - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/review/pr-1')) { - return Promise.reject(new Error('missing origin review branch')) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: false, - ahead: 0, - behind: 0, - hasConfiguredPushTarget: true - }) - }) - - it('does not mark origin base-branch config as a push target', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'symbolic-ref') { - return Promise.resolve({ stdout: 'refs/heads/feature\n' }) - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - return Promise.reject(new Error('fatal: no upstream configured')) - } - if (args[0] === 'config' && args.includes('branch.feature.pushRemote')) { - return Promise.reject(new Error('missing pushRemote')) - } - if (args[0] === 'config' && args.includes('remote.pushDefault')) { - return Promise.reject(new Error('missing pushDefault')) - } - if (args[0] === 'config' && args.includes('branch.feature.remote')) { - return Promise.resolve({ stdout: 'origin\n' }) - } - if (args[0] === 'config' && args.includes('branch.feature.merge')) { - return Promise.resolve({ stdout: 'refs/heads/main\n' }) - } - if (args[0] === 'config' && args.includes('branch.feature.base')) { - return Promise.resolve({ stdout: 'refs/remotes/origin/main\n' }) - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) { - return Promise.reject(new Error('missing origin/main tracking ref')) - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { - return Promise.reject(new Error('missing origin/feature tracking ref')) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: false, - ahead: 0, - behind: 0 - }) - }) - - it('does not mark remote.pushDefault plus origin base branch as a push target', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'symbolic-ref') { - return Promise.resolve({ stdout: 'refs/heads/feature/fix\n' }) - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - return Promise.reject(new Error('fatal: no upstream configured')) - } - if (args[0] === 'config' && args.includes('branch.feature/fix.pushRemote')) { - return Promise.reject(new Error('missing pushRemote')) - } - if (args[0] === 'config' && args.includes('remote.pushDefault')) { - return Promise.resolve({ stdout: 'fork\n' }) - } - if (args[0] === 'config' && args.includes('branch.feature/fix.remote')) { - return Promise.resolve({ stdout: 'origin\n' }) - } - if (args[0] === 'config' && args.includes('branch.feature/fix.merge')) { - return Promise.resolve({ stdout: 'refs/heads/main\n' }) - } - if (args[0] === 'config' && args.includes('branch.feature/fix.base')) { - return Promise.resolve({ stdout: 'refs/remotes/origin/main\n' }) - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature/fix')) { - return Promise.reject(new Error('missing origin feature branch')) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: false, - ahead: 0, - behind: 0 - }) - }) - - it('keeps a configured upstream whose remote name contains a slash', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'symbolic-ref') { - return Promise.resolve({ stdout: 'refs/heads/feature\n' }) - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - return Promise.resolve({ stdout: 'origin/team/feature\n' }) - } - if (args[0] === 'remote') { - return Promise.resolve({ stdout: 'origin\norigin/team\n' }) - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { - return Promise.resolve({ stdout: 'origin-feature-oid\n' }) - } - if (args[0] === 'rev-list' && args.includes('HEAD...origin/team/feature')) { - return Promise.resolve({ stdout: '2\t0\n' }) - } - if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { - return Promise.resolve({ stdout: '9\t9\n' }) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - const result = await getUpstreamStatus('/repo') - - expect(result).toEqual({ - hasUpstream: true, - upstreamName: 'origin/team/feature', - ahead: 2, - behind: 0 - }) - }) - - it('uses an explicit publish target instead of the configured upstream', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: '1\t2\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: '+ def456 remote work\n', stderr: '' }) - - const result = await getUpstreamStatus('/repo', { - remoteName: 'fork', - branchName: 'feature/fix' - }) - - expect(result).toEqual({ - hasUpstream: true, - upstreamName: 'fork/feature/fix', - ahead: 1, - behind: 2, - behindCommitsArePatchEquivalent: false - }) - expect(gitExecFileAsyncMock.mock.calls).toEqual([ - [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }], - [['rev-parse', '--verify', '--quiet', 'refs/remotes/fork/feature/fix'], { cwd: '/repo' }], - [ - ['rev-list', '--left-right', '--count', 'HEAD...refs/remotes/fork/feature/fix'], - { cwd: '/repo' } - ], - [ - [ - 'log', - '--no-show-signature', - '--no-color', - '--format=%m', - '--cherry-mark', - '--right-only', - 'HEAD...refs/remotes/fork/feature/fix', - '--' - ], - { cwd: '/repo' } - ] - ]) - }) - - it('routes explicit publish-target probes through the selected WSL distro', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: '0\t0\n', stderr: '' }) - - await expect( - getUpstreamStatus( - '/repo', - { - remoteName: 'fork', - branchName: 'feature/fix' - }, - { wslDistro: 'Ubuntu' } - ) - ).resolves.toEqual({ - hasUpstream: true, - upstreamName: 'fork/feature/fix', - ahead: 0, - behind: 0 - }) - expect(gitExecFileAsyncMock.mock.calls).toEqual([ - [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo', wslDistro: 'Ubuntu' }], - [ - ['rev-parse', '--verify', '--quiet', 'refs/remotes/fork/feature/fix'], - { cwd: '/repo', wslDistro: 'Ubuntu' } - ], - [ - ['rev-list', '--left-right', '--count', 'HEAD...refs/remotes/fork/feature/fix'], - { cwd: '/repo', wslDistro: 'Ubuntu' } - ] - ]) - }) - - it('reports no upstream when an explicit publish target has not been fetched yet', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '', stderr: '' }) - .mockRejectedValueOnce(Object.assign(new Error('git exited with 1.'), { stderr: '' })) - - await expect( - getUpstreamStatus('/repo', { - remoteName: 'fork', - branchName: 'feature/fix' - }) - ).resolves.toEqual({ - hasUpstream: false, - upstreamName: 'fork/feature/fix', - ahead: 0, - behind: 0, - hasConfiguredPushTarget: true - }) - }) - it('does not hide git failures while checking an explicit publish target', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }).mockRejectedValueOnce( Object.assign(new Error('fatal: not a git repository'), { diff --git a/src/main/git/worktrees/catalog/worktree-list-porcelain.test.ts b/src/main/git/worktrees/catalog/worktree-list-porcelain.test.ts index 7fd65819628..2a7c390d8f8 100644 --- a/src/main/git/worktrees/catalog/worktree-list-porcelain.test.ts +++ b/src/main/git/worktrees/catalog/worktree-list-porcelain.test.ts @@ -48,19 +48,15 @@ vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises') return { ...actual, stat: statMock, readFile: readFileMock } }) - import { - createGitCallReader, createGitCommandMocker, resetWorktreeGitMocks, resetWorktreeRemovalState } from '../removal/remove-worktree-test-harness' import { listWorktrees } from './worktree-scan-cache' -import { WORKTREE_LIST_TIMEOUT_MS } from '../worktree-operation-options' const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) -const getGitCalls = createGitCallReader(gitExecFileAsyncMock) beforeEach(() => { resetWorktreeRemovalState() @@ -78,179 +74,6 @@ describe('listWorktrees', () => { }) }) - it('translates parsed path fields from line-block porcelain output', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - 'worktree /home/me/repo\nHEAD abc123\nbranch refs/heads/main\n\n' + - 'worktree /home/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\nsparse\n\n' - }) - translateWslOutputPathsMock.mockImplementation((output: string) => - output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') - ) - - await expect(listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).resolves.toEqual([ - { - path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', - head: 'abc123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - }, - { - path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature', - head: 'def456', - branch: 'refs/heads/feature/test', - isBare: false, - isSparse: true, - isMainWorktree: false - } - ]) - // The parsed sparse token skips its filesystem probe; only the main worktree needs one. - expect(getGitCalls()).toEqual(['git worktree list --porcelain -z']) - expect(statMock).toHaveBeenCalledTimes(1) - expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2) - }) - - it('passes the selected WSL distro when translating Windows-path worktree output', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - 'worktree /mnt/c/Users/me/repo\nHEAD abc123\nbranch refs/heads/main\nsparse\n\n' + - 'worktree /mnt/c/Users/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\nsparse\n\n' - }) - translateWslOutputPathsMock.mockImplementation((output: string) => - output - .replace('/mnt/c/Users/me/repo-feature', 'C:\\Users\\me\\repo-feature') - .replace('/mnt/c/Users/me/repo', 'C:\\Users\\me\\repo') - ) - - await expect(listWorktrees('C:\\Users\\me\\repo', { wslDistro: 'Ubuntu' })).resolves.toEqual([ - { - path: 'C:\\Users\\me\\repo', - head: 'abc123', - branch: 'refs/heads/main', - isBare: false, - isSparse: true, - isMainWorktree: true - }, - { - path: 'C:\\Users\\me\\repo-feature', - head: 'def456', - branch: 'refs/heads/feature/test', - isBare: false, - isSparse: true, - isMainWorktree: false - } - ]) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], { - cwd: 'C:\\Users\\me\\repo', - wslDistro: 'Ubuntu', - timeout: WORKTREE_LIST_TIMEOUT_MS - }) - expect(translateWslOutputPathsMock).toHaveBeenCalledWith( - expect.any(String), - 'C:\\Users\\me\\repo', - { wslDistro: 'Ubuntu' } - ) - }) - - it('returns no worktrees when the repo path is gone', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - gitExecFileAsyncMock.mockRejectedValueOnce( - Object.assign(new Error('spawn git ENOENT'), { - code: 'ENOENT' - }) - ) - statMock.mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) - - await expect(listWorktrees('/workspace/deleted-repo')).resolves.toEqual([]) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], { - cwd: '/workspace/deleted-repo', - timeout: WORKTREE_LIST_TIMEOUT_MS - }) - expect(statMock).toHaveBeenCalledWith('/workspace/deleted-repo') - expect(warnSpy).toHaveBeenCalledWith( - '[git/worktree] repo path missing; skipping worktree list: /workspace/deleted-repo' - ) - warnSpy.mockRestore() - }) - - it('returns no worktrees when the path exists but is not a git repo', async () => { - const warnSpy = vi.spyOn(console, 'warn') - gitExecFileAsyncMock.mockRejectedValueOnce( - Object.assign(new Error('Command failed: git worktree list --porcelain'), { - code: 128, - stdout: '', - stderr: 'fatal: not a git repository (or any of the parent directories): .git\n' - }) - ) - - await expect(listWorktrees('/private/tmp/orca-issue-1582-test/my-repo')).resolves.toEqual([]) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], { - cwd: '/private/tmp/orca-issue-1582-test/my-repo', - timeout: WORKTREE_LIST_TIMEOUT_MS - }) - expect(warnSpy).not.toHaveBeenCalled() - warnSpy.mockRestore() - }) - - it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.join(' ') === 'worktree list --porcelain -z') { - return { - stdout: - 'worktree /home/me/repo\nHEAD abc123\nbranch refs/heads/main\n\n' + - 'worktree /home/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n\n', - stderr: '' - } - } - throw new Error(`Unexpected git call: ${args.join(' ')}`) - }) - translateWslOutputPathsMock.mockImplementation((output: string) => - output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') - ) - const featureWorktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature' - resolveGitDirMock.mockImplementation(async (worktreePath: string) => - worktreePath === featureWorktreePath - ? `${featureWorktreePath}\\.git-worktrees\\feature` - : `${worktreePath}/.git` - ) - statMock.mockImplementation(async (filePath: string) => { - if (filePath.includes('repo-feature') && filePath.includes('sparse-checkout')) { - return { isFile: () => true, size: 32 } - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) - - const worktrees = await listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo') - - expect(worktrees).toEqual([ - { - path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', - head: 'abc123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - }, - { - path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature', - head: 'def456', - branch: 'refs/heads/feature/test', - isBare: false, - isSparse: true, - isMainWorktree: false - } - ]) - // The UNC repo path carries the distro without an explicit option. - const gitDirCall = resolveGitDirMock.mock.calls.find( - ([probed]) => probed === featureWorktreePath - ) - expect(gitDirCall).toBeDefined() - expect(gitDirCall?.[1]?.wslDistro).toBeUndefined() - expect(getGitCalls()).toEqual(['git worktree list --porcelain -z']) - }) - it('bounds concurrent sparse-checkout filesystem probes', async () => { const worktreeCount = 20 const sparseWorktreePath = '/repo-worktree-17' @@ -310,49 +133,6 @@ describe('listWorktrees', () => { }) }) - it('falls back to line-block porcelain output when Git rejects -z', async () => { - mockGitCommands({ - 'git worktree list --porcelain -z': { - error: Object.assign(new Error("unknown switch `z'"), { - stderr: "error: unknown switch `z'" - }) - }, - 'git worktree list --porcelain': { - stdout: - 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\n' + - 'worktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n' - } - }) - // Keep linked paths present to isolate parser selection from stale-registration checks. - statMock.mockImplementation(async (targetPath: string) => { - if (String(targetPath).endsWith('sparse-checkout')) { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - } - return {} - }) - - await expect(listWorktrees('/repo')).resolves.toEqual([ - { - path: '/repo', - head: 'abc123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - }, - { - path: '/repo-feature', - head: 'def456', - branch: 'refs/heads/feature/test', - isBare: false, - isMainWorktree: false - } - ]) - expect(getGitCalls()).toEqual([ - 'git worktree list --porcelain -z', - 'git worktree list --porcelain' - ]) - }) - it('annotates missing linked worktrees as prunable via the line-block fallback', async () => { // Older Git omits prunable fields, so fallback must verify linked paths on disk. mockGitCommands({ diff --git a/src/main/git/worktrees/catalog/worktree-separate-git-dir.test.ts b/src/main/git/worktrees/catalog/worktree-separate-git-dir.test.ts index 60a8f50ecdd..83c9297cf1c 100644 --- a/src/main/git/worktrees/catalog/worktree-separate-git-dir.test.ts +++ b/src/main/git/worktrees/catalog/worktree-separate-git-dir.test.ts @@ -74,14 +74,6 @@ async function createNormalRepo(): Promise { return createCommittedRepo(root, 'repo') } -async function createBareRepo(): Promise { - const root = await mkdtemp(path.join(tmpdir(), 'orca-bare-worktree-')) - tempRoots.push(root) - const repoPath = path.join(root, 'repo.git') - execFileSync('git', ['init', '--bare', '--quiet', repoPath]) - return realpath(repoPath) -} - afterEach(async () => { revParseTopLevelCalls.count = 0 await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) @@ -104,29 +96,6 @@ describe('git worktree separate git dir paths', () => { } ) - it.skipIf(process.platform === 'win32')( - 'fires exactly one rev-parse for a separate-git-dir repo', - async () => { - const { worktreePath } = await createSeparateGitDirRepo() - - await listWorktrees(worktreePath) - - expect(revParseTopLevelCalls.count).toBe(1) - } - ) - - it.skipIf(process.platform === 'win32')('leaves a normal repo main path unchanged', async () => { - const repoPath = await createNormalRepo() - - const worktrees = await listWorktrees(repoPath) - const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree) - - expect(mainWorktree).toMatchObject({ - path: repoPath, - isMainWorktree: true - }) - }) - it.skipIf(process.platform === 'win32')( 'leaves an ordinary repo reached via a symlinked path unchanged', async () => { @@ -201,17 +170,4 @@ describe('git worktree separate git dir paths', () => { ]) } ) - - it.skipIf(process.platform === 'win32')('does not throw for a bare repo', async () => { - const repoPath = await createBareRepo() - - const worktrees = await listWorktrees(repoPath) - const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree) - - expect(mainWorktree).toMatchObject({ - path: repoPath, - isBare: true, - isMainWorktree: true - }) - }) }) diff --git a/src/main/git/worktrees/content-paths/worktree-shared-directories.test.ts b/src/main/git/worktrees/content-paths/worktree-shared-directories.test.ts index eb0e4e3e67d..b4f16ae4fc6 100644 --- a/src/main/git/worktrees/content-paths/worktree-shared-directories.test.ts +++ b/src/main/git/worktrees/content-paths/worktree-shared-directories.test.ts @@ -6,15 +6,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { clearConfiguredWorktreeSharedDirectoriesCacheForTests, getConfiguredWorktreeSharedDirectories, - getConfiguredWorktreeSharedDirectoriesCacheSizeForTests, - MAX_CONFIGURED_SHARED_DIRECTORIES_CACHE_ENTRIES, - getWorktreeSharedLinkPaths, resolveWorktreeSharedDirectories } from './worktree-shared-directories' import { createWorktreeSharedPaths } from '../../../ipc/worktree-symlinks' import { findExistingWorktreeSymlinkPaths } from './worktree-symlink-detection' import { assertWorktreeCleanForRemoval } from '../removal/worktree-removal-preflight' -import { getStatus } from '../../source-control/status-read' // An empty config file works with Windows Git (os.devNull does not) and isolates the production runner too. let gitConfigRoot: string @@ -78,27 +74,6 @@ describe('resolveWorktreeSharedDirectories', () => { rmSync(repo, { recursive: true, force: true }) }) - it('returns gitignored directories listed under worktree.sharedDirectories', async () => { - mkdirSync(join(repo, 'node_modules')) - mkdirSync(join(repo, '.cache')) - writeOrcaYaml('worktree:\n sharedDirectories:\n - node_modules\n - .cache\n') - - expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['.cache', 'node_modules']) - }) - - it('returns [] when orca.yaml is absent', async () => { - mkdirSync(join(repo, 'node_modules')) - - expect(await resolveWorktreeSharedDirectories(repo)).toEqual([]) - }) - - it('returns [] when orca.yaml has no worktree key', async () => { - mkdirSync(join(repo, 'node_modules')) - writeOrcaYaml('scripts:\n setup: pnpm install\n') - - expect(await resolveWorktreeSharedDirectories(repo)).toEqual([]) - }) - it('skips a directory that is not gitignored', async () => { mkdirSync(join(repo, 'shared-but-tracked')) writeOrcaYaml('worktree:\n sharedDirectories:\n - shared-but-tracked\n') @@ -115,12 +90,6 @@ describe('resolveWorktreeSharedDirectories', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('must be directories')) }) - it('skips entries that are absent from the primary checkout', async () => { - writeOrcaYaml('worktree:\n sharedDirectories:\n - node_modules\n') - - expect(await resolveWorktreeSharedDirectories(repo)).toEqual([]) - }) - it('drops unsafe entries before touching the filesystem', async () => { mkdirSync(join(repo, 'node_modules')) writeOrcaYaml( @@ -138,98 +107,6 @@ describe('resolveWorktreeSharedDirectories', () => { expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['node_modules']) }) - - it('normalizes trailing slashes, ./ prefixes and duplicates', async () => { - mkdirSync(join(repo, 'node_modules')) - writeOrcaYaml( - 'worktree:\n sharedDirectories:\n - node_modules/\n - ./node_modules\n - node_modules\n' - ) - - expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['node_modules']) - }) - - it('returns [] for a malformed sharedDirectories value instead of throwing', async () => { - mkdirSync(join(repo, 'node_modules')) - writeOrcaYaml('worktree:\n sharedDirectories: node_modules\n') - - expect(await resolveWorktreeSharedDirectories(repo)).toEqual([]) - }) - - it('resolves nested directories anchored at the repo root', async () => { - mkdirSync(join(repo, 'apps', 'web', '.cache'), { recursive: true }) - writeFileSync(join(repo, '.gitignore'), 'node_modules/\n.cache\napps/web/.cache\n') - writeOrcaYaml('worktree:\n sharedDirectories:\n - apps/web/.cache\n') - - expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['apps/web/.cache']) - }) -}) - -describe('getConfiguredWorktreeSharedDirectories', () => { - it('bounds cache growth when repository paths churn', () => { - for (let index = 0; index < MAX_CONFIGURED_SHARED_DIRECTORIES_CACHE_ENTRIES + 4; index += 1) { - getConfiguredWorktreeSharedDirectories(`/repo-${index}`) - } - - expect(getConfiguredWorktreeSharedDirectoriesCacheSizeForTests()).toBe( - MAX_CONFIGURED_SHARED_DIRECTORIES_CACHE_ENTRIES - ) - }) - let repo: string - - beforeEach(() => { - clearConfiguredWorktreeSharedDirectoriesCacheForTests() - repo = mkdtempSync(join(tmpdir(), 'orca-shared-dirs-config-')) - }) - - afterEach(() => { - rmSync(repo, { recursive: true, force: true }) - }) - - it('returns the configured names without existence or gitignore filtering', () => { - // Removal needs configured names even after the primary directory disappears. - writeFileSync( - join(repo, 'orca.yaml'), - 'worktree:\n sharedDirectories:\n - node_modules\n - .cache\n' - ) - - expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['node_modules', '.cache']) - }) - - it('combines live per-user paths with cached repo configuration', () => { - writeFileSync(join(repo, 'orca.yaml'), 'worktree:\n sharedDirectories:\n - node_modules\n') - - expect(getWorktreeSharedLinkPaths({ path: repo, symlinkPaths: ['.cache'] })).toEqual([ - '.cache', - 'node_modules' - ]) - }) - - it('returns [] when orca.yaml is absent or has no worktree key', () => { - expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual([]) - - writeFileSync(join(repo, 'orca.yaml'), 'scripts:\n setup: pnpm install\n') - clearConfiguredWorktreeSharedDirectoriesCacheForTests() - expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual([]) - }) - - it('caches repeated status polls but refreshes changed configuration', () => { - vi.useFakeTimers() - try { - writeFileSync( - join(repo, 'orca.yaml'), - 'worktree:\n sharedDirectories:\n - node_modules\n' - ) - expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['node_modules']) - - writeFileSync(join(repo, 'orca.yaml'), 'worktree:\n sharedDirectories:\n - .cache\n') - - expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['node_modules']) - vi.advanceTimersByTime(30_001) - expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['.cache']) - } finally { - vi.useRealTimers() - } - }) }) // Directory-only ignore rules hide the primary directory, but not its worktree symlink. @@ -281,19 +158,6 @@ describe('shared directories and worktree removal', () => { }) // Pass actual resolver output to status so path spelling cannot drift across the seam. - it('leaves status clean when the resolved directory is fed back as a shared link', async () => { - await createWorktreeSharedPaths( - primary, - worktree, - await resolveWorktreeSharedDirectories(primary) - ) - - const status = await getStatus(worktree, { - sharedLinkPaths: getWorktreeSharedLinkPaths({ path: primary }) - }) - - expect(status.entries).toEqual([]) - }) it('reports only genuine blockers in the removal error, with no NUL bytes', async () => { await createWorktreeSharedPaths( @@ -343,22 +207,4 @@ describe('shared directories and worktree removal', () => { assertWorktreeCleanForRemoval(worktree, false, { ignoredUntrackedPaths: ignoredLinkedPaths }) ).resolves.toBeUndefined() }) - - it('still refuses removal for real untracked changes next to a shared directory', async () => { - await createWorktreeSharedPaths( - primary, - worktree, - await resolveWorktreeSharedDirectories(primary) - ) - writeFileSync(join(worktree, 'scratch.txt'), 'unsaved work') - - await expect( - assertWorktreeCleanForRemoval(worktree, false, { - ignoredUntrackedPaths: await findExistingWorktreeSymlinkPaths( - worktree, - getConfiguredWorktreeSharedDirectories(primary) - ) - }) - ).rejects.toThrow('uncommitted or untracked') - }) }) diff --git a/src/main/git/worktrees/creation/worktree-add-local-base-refresh.test.ts b/src/main/git/worktrees/creation/worktree-add-local-base-refresh.test.ts index 58dc98ed135..a7aa2da16bc 100644 --- a/src/main/git/worktrees/creation/worktree-add-local-base-refresh.test.ts +++ b/src/main/git/worktrees/creation/worktree-add-local-base-refresh.test.ts @@ -35,7 +35,6 @@ vi.mock('../../command-runner/checkout-workers-git-args', () => ({ })) import { addWorktree } from './worktree-add' -import { WORKTREE_ADD_TIMEOUT_MS } from '../worktree-operation-options' import { registerWorktreeSuiteHooks } from '../testing/worktree-test-harness' registerWorktreeSuiteHooks() @@ -66,63 +65,6 @@ describe('addWorktree', () => { translateWslOutputPathsMock.mockClear() }) - it('fast-forwards with merge --ff-only when localBranch is checked out in primary worktree', async () => { - const worktreeListOutput = - 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-other\nHEAD def456\nbranch refs/heads/feature\n' - queueBehindInspection() - .mockResolvedValueOnce({ stdout: worktreeListOutput }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) - - const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) - - expect(result.localBaseRefRefresh).toEqual({ - status: 'updated', - baseRef: 'origin/main', - localBranch: 'main', - ownerWorktreePath: '/repo' - }) - expect(refreshGitMock.mock.calls).toEqual([ - [['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }], - [['rev-parse', '--verify', 'refs/heads/main^{commit}'], { cwd: '/repo' }], - [['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }], - [['rev-list', '--left-right', '--count', 'old-main...remote-main'], { cwd: '/repo' }], - [['worktree', 'list', '--porcelain'], { cwd: '/repo' }], - [['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }], - [['symbolic-ref', '-q', 'HEAD'], { cwd: '/repo' }], - [ownerFastForwardArgs('remote-main'), { cwd: '/repo' }], - [['rev-parse', '--verify', 'refs/heads/main^{commit}'], { cwd: '/repo' }] - ]) - expect(checkoutGitMock.mock.calls).toEqual([ - [ - [ - 'worktree', - 'add', - '--no-track', - '-b', - 'feature/test', - '/repo-feature', - 'refs/remotes/origin/main' - ], - { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS } - ], - [ - [ - 'config', - '--local', - '--replace-all', - 'branch.feature/test.base', - 'refs/remotes/origin/main' - ], - { cwd: '/repo-feature' } - ], - [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }], - [['config', '--local', 'push.autoSetupRemote', 'true'], { cwd: '/repo-feature' }] - ]) - }) - it('runs worktree add while the local base refresh is still fast-forwarding', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' let finishMerge!: () => void @@ -159,32 +101,6 @@ describe('addWorktree', () => { expect(checkoutGitMock.mock.calls[0]?.[0]).toContain('add') }) - it('fast-forwards in the sibling worktree when localBranch is checked out there', async () => { - const worktreeListOutput = - 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\nworktree /repo-main-wt\nHEAD def456\nbranch refs/heads/main\n' - queueBehindInspection() - .mockResolvedValueOnce({ stdout: worktreeListOutput }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) - - await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) - - expect(refreshGitMock.mock.calls.slice(5)).toEqual([ - [ - ['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'], - expect.objectContaining({ cwd: '/repo-main-wt' }) - ], - [['symbolic-ref', '-q', 'HEAD'], expect.objectContaining({ cwd: '/repo-main-wt' })], - [ownerFastForwardArgs('remote-main'), expect.objectContaining({ cwd: '/repo-main-wt' })], - [ - ['rev-parse', '--verify', 'refs/heads/main^{commit}'], - expect.objectContaining({ cwd: '/repo' }) - ] - ]) - }) - it('fast-forwards local base via update-ref when localBranch is not checked out', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n' queueBehindInspection() @@ -372,77 +288,6 @@ describe('addWorktree', () => { }) // Creating the base's own local branch leaves nothing to refresh; probing would race the add. - it('does not warn when worktree add itself creates the local base branch', async () => { - refreshGitMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) - .mockRejectedValueOnce( - new Error( - "fatal: ambiguous argument 'refs/heads/feature-x...refs/remotes/origin/feature-x': unknown revision or path not in the working tree." - ) - ) - .mockResolvedValueOnce({ stdout: '' }) - - const result = await addWorktree( - '/repo', - '/repo-feature-x', - 'feature-x', - 'origin/feature-x', - true - ) - - expect(result.localBaseRefRefresh).toBeUndefined() - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ - 'worktree', - 'add', - '--no-track', - '-b', - 'feature-x', - '/repo-feature-x', - 'refs/remotes/origin/feature-x' - ]) - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ - 'branch', - '--set-upstream-to', - 'refs/remotes/origin/feature-x', - 'feature-x' - ]) - expect(refreshGitMock.mock.calls.map((call) => call[0][0])).toEqual(['rev-parse']) - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('update-ref') - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('reset') - }) - - it('does not warn when the local base branch does not exist in a fetch-only clone', async () => { - refreshGitMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) - .mockRejectedValueOnce(new Error('unknown revision refs/heads/main')) - .mockRejectedValueOnce(Object.assign(new Error('missing ref'), { code: 1 })) - - const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) - - expect(result.localBaseRefRefresh).toBeUndefined() - expect(refreshGitMock.mock.calls.at(-1)?.[0]).toEqual([ - 'show-ref', - '--verify', - '--quiet', - '--', - 'refs/heads/main' - ]) - }) - - it('keeps the warning when the local base ref probe itself fails', async () => { - refreshGitMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) - .mockRejectedValueOnce(new Error('rev-parse failed')) // local oid probe - .mockRejectedValueOnce(new Error('fatal: not a git repository')) - - const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) - - expect(result.localBaseRefRefresh).toEqual({ - status: 'skipped_not_fast_forward', - baseRef: 'origin/main', - localBranch: 'main' - }) - }) it('still suggests nothing but keeps the warning when the local base ref exists and diverged', async () => { queueBehindInspection({ counts: { stdout: '2\t3\n' } }) @@ -482,31 +327,6 @@ describe('addWorktree', () => { }) expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args[0])).not.toContain('reset') }) - - it('uses the remote name from the base ref instead of hardcoding origin', async () => { - const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' - queueBehindInspection({ remoteOid: 'remote-upstream-main' }) - .mockResolvedValueOnce({ stdout: worktreeListOutput }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) - - await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true) - - expect(refreshGitMock.mock.calls[2]?.[0]).toEqual([ - 'rev-parse', - '--verify', - 'refs/remotes/upstream/main^{commit}' - ]) - expect(refreshGitMock.mock.calls[3]?.[0]).toEqual([ - 'rev-list', - '--left-right', - '--count', - 'old-main...remote-upstream-main' - ]) - expect(refreshGitMock.mock.calls[7]?.[0]).toEqual(ownerFastForwardArgs('remote-upstream-main')) - }) }) function ownerFastForwardArgs(remoteOid: string): string[] { diff --git a/src/main/git/worktrees/creation/worktree-base-refresh-contention.test.ts b/src/main/git/worktrees/creation/worktree-base-refresh-contention.test.ts index 324afbfa7ce..80bc0a630d4 100644 --- a/src/main/git/worktrees/creation/worktree-base-refresh-contention.test.ts +++ b/src/main/git/worktrees/creation/worktree-base-refresh-contention.test.ts @@ -97,26 +97,6 @@ describe('refreshLocalBaseRefForWorktreeCreate lock contention', () => { }) afterEach(() => vi.useRealTimers()) - it('retries a fast-forward that lost the index.lock race and reports updated', async () => { - const mutate = vi - .fn() - .mockRejectedValueOnce(INDEX_LOCK_ERROR) - .mockResolvedValueOnce({ stdout: '' }) - installGitFake({ owner: true, mutate }) - - const pending = refresh() - await vi.runAllTimersAsync() - - await expect(pending).resolves.toEqual({ - baseRef: 'origin/main', - localBranch: 'main', - status: 'updated', - ownerWorktreePath: '/repo' - }) - expect(mutationCalls().map(commandOf)).toEqual(['merge', 'merge']) - expect(mutationCalls().map((args) => args.at(-1))).toEqual(['remote-main', 'remote-main']) - }) - it('re-confirms the owner has the branch checked out before each retry', async () => { let symbolicRefReads = 0 installGitFake({ @@ -132,30 +112,6 @@ describe('refreshLocalBaseRefForWorktreeCreate lock contention', () => { expect(mutationCalls()).toHaveLength(1) }) - it('reports updated without a warning when the lock never clears but local already reached the target', async () => { - installGitFake({ - owner: true, - mutate: () => Promise.reject(INDEX_LOCK_ERROR), - localContainsTarget: () => true - }) - - const pending = refresh() - await vi.runAllTimersAsync() - - await expect(pending).resolves.toMatchObject({ status: 'updated' }) - expect(mutationCalls()).toHaveLength(4) - }) - - it('reports skipped_error when the lock never clears and local is still behind', async () => { - installGitFake({ owner: true, mutate: () => Promise.reject(INDEX_LOCK_ERROR) }) - - const pending = refresh() - await vi.runAllTimersAsync() - - await expect(pending).resolves.toMatchObject({ status: 'skipped_error' }) - expect(mutationCalls()).toHaveLength(4) - }) - it('does not retry a failure that is not lock contention', async () => { const casMismatch = Object.assign(new Error('Command failed: git update-ref'), { stderr: "fatal: cannot lock ref 'refs/heads/main': is at other-oid but expected old-main" @@ -191,36 +147,6 @@ describe('refreshLocalBaseRefForWorktreeCreate lock contention', () => { }) expect(mutationCalls()).toHaveLength(1) }) - - it('skips the owner check and the move when local is already current', async () => { - installGitFake({ owner: true, mutate: () => Promise.resolve({ stdout: '' }) }) - const base = gitExecFileAsyncMock.getMockImplementation()! - gitExecFileAsyncMock.mockImplementation(async (args: string[], opts: { cwd: string }) => - args[0] === 'rev-parse' ? { stdout: 'remote-main\n' } : base(args, opts) - ) - - await expect(refresh()).resolves.toBeUndefined() - expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args[0])).toEqual([ - 'rev-parse', - 'rev-parse' - ]) - }) - - it("runs the shared refresh without one create's abort signal or timeout", async () => { - installGitFake({ owner: true, mutate: () => Promise.resolve({ stdout: '' }) }) - - await refreshLocalBaseRefForWorktreeCreate( - '/repo', - 'origin/main', - 'refs/remotes/origin/main', - undefined, - { wslDistro: 'Ubuntu', signal: new AbortController().signal, timeout: 8000 } - ) - - for (const [, options] of gitExecFileAsyncMock.mock.calls) { - expect(options).toEqual({ cwd: expect.any(String), wslDistro: 'Ubuntu' }) - } - }) }) describe('refreshLocalBaseRefForWorktreeCreate runs one refresh at a time per branch', () => { diff --git a/src/main/git/worktrees/creation/worktree-create-preparation-real-git.test.ts b/src/main/git/worktrees/creation/worktree-create-preparation-real-git.test.ts index 9fb55c51203..0d915631436 100644 --- a/src/main/git/worktrees/creation/worktree-create-preparation-real-git.test.ts +++ b/src/main/git/worktrees/creation/worktree-create-preparation-real-git.test.ts @@ -7,7 +7,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import * as gitRunner from '../../command-runner/git-exec-file' import { createWorktreePreparationLockReason, - isWorktreeCreatePreparation, WORKTREE_CREATE_PREPARATION_DIRECTORY } from '../../../../shared/worktree/create-preparation' import { listWorktrees } from '../catalog/worktree-scan-cache' @@ -621,63 +620,4 @@ describe('prepared worktree creation with real Git', () => { 'refs/heads/main' ) }) - - it('hides the preparation, retargets an advanced base, and attaches the final branch', async () => { - const { repoPath, root } = await createRepo() - const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) - const preparedPath = join(preparationRoot, `${process.pid}-test`) - const finalPath = join(root, 'final-worktree') - await mkdir(preparationRoot, { recursive: true }) - - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('real-git-test') - ) - - const visibleBeforeSubmit = await listWorktrees(repoPath) - const allBeforeSubmit = await listWorktrees(repoPath, { includeCreatePreparations: true }) - expect(visibleBeforeSubmit).toHaveLength(1) - expect(allBeforeSubmit).toHaveLength(2) - expect(allBeforeSubmit.find(isWorktreeCreatePreparation)).toMatchObject({ - locked: true, - lockReason: expect.stringContaining('orca-create-preparation:v1:') - }) - - await writeFile(join(repoPath, 'version.txt'), 'two\n') - git(repoPath, ['add', 'version.txt']) - git(repoPath, ['commit', '--quiet', '-m', 'advance base']) - const latestHead = git(repoPath, ['rev-parse', 'HEAD']) - - await finalizePreparedWorktree( - repoPath, - preparedPath, - finalPath, - 'feature/prepared', - 'main', - false, - {}, - createWorktreePreparationLockReason('real-git-test') - ) - - expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(latestHead) - expect(git(finalPath, ['branch', '--show-current'])).toBe('feature/prepared') - expect((await readFile(join(finalPath, 'version.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe( - 'two\n' - ) - expect(git(finalPath, ['config', '--get', 'branch.feature/prepared.base'])).toBe( - 'refs/heads/main' - ) - expect(git(finalPath, ['config', '--get', 'push.autoSetupRemote'])).toBe('true') - const listedWorktrees = await listWorktrees(repoPath) - const resolvedFinalPath = await realpath(finalPath) - expect( - listedWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, resolvedFinalPath)) - ).toBe(true) - expect( - listedWorktrees.find((worktree) => areWorktreePathsEqual(worktree.path, resolvedFinalPath)) - ?.locked - ).not.toBe(true) - }) }) diff --git a/src/main/git/worktrees/creation/worktree-preparation-add.test.ts b/src/main/git/worktrees/creation/worktree-preparation-add.test.ts index c9c06f2f02b..66a5424bd6d 100644 --- a/src/main/git/worktrees/creation/worktree-preparation-add.test.ts +++ b/src/main/git/worktrees/creation/worktree-preparation-add.test.ts @@ -1,7 +1,6 @@ import { beforeEach, expect, it, vi } from 'vitest' import type * as FilePromises from 'node:fs/promises' import type * as PreparationLock from './worktree-preparation-lock' -import { isUnsupportedWorktreeAddLockReasonError } from '../../../../shared/git/worktrees/git-worktree-command-capabilities' const mocks = vi.hoisted(() => ({ git: vi.fn(), @@ -32,10 +31,7 @@ import { } from '../../command-runner/git-capability-state' import { WorktreePreparationLockOwnershipError } from './worktree-preparation-lock' import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from '../worktree-operation-options' -import { - resetWslLinkedWorktreeGitRoutingForTests, - seedWslLinkedWorktreeGitRoutingForTests -} from '../../command-runner/wsl-linked-worktree-git-routing' +import { resetWslLinkedWorktreeGitRoutingForTests } from '../../command-runner/wsl-linked-worktree-git-routing' const reason = 'orca-create-preparation:v1:123:atomic' const unsupported = Object.assign(new Error("error: unknown option 'reason'"), { code: 129 }) @@ -52,42 +48,6 @@ beforeEach(() => { mocks.discard.mockReset().mockResolvedValue(undefined) }) -it('asks Git to create the exact marker atomically and only verifies its ownership', async () => { - const controller = new AbortController() - const options = { signal: controller.signal, timeout: 6000, admissionTier: 'background' as const } - await expect(prepare('/prepared', options)).resolves.toBe('/atomic-lock') - expect(mocks.git).toHaveBeenCalledExactlyOnceWith( - [ - 'worktree', - 'add', - '--detach', - '--no-checkout', - '--lock', - '--reason', - reason, - '/prepared', - 'refs/heads/main' - ], - { cwd: '/repo', ...options } - ) - expect(mocks.verify).toHaveBeenCalledExactlyOnceWith('/prepared', reason, options) - expect(mocks.lock).not.toHaveBeenCalled() -}) - -it('falls back once on the old-Git reason rejection and caches the absence', async () => { - mocks.git.mockRejectedValueOnce(unsupported) - await expect(prepare()).resolves.toBe('/fallback-lock') - await expect(prepare('/second')).resolves.toBe('/fallback-lock') - expect(mocks.git.mock.calls.map(([args]) => args.includes('--reason'))).toEqual([ - true, - false, - false - ]) - expect(mocks.lock.mock.calls.map(([path]) => path)).toEqual(['/prepared', '/second']) - expect(mocks.verify).not.toHaveBeenCalled() - expect(mocks.discard).not.toHaveBeenCalled() -}) - it.each([ ['first', 'cancellation'], ['cached', 'cancellation'], @@ -126,15 +86,6 @@ it.each([ expect(options.signal).toBe(controller.signal) }) -it('preserves the original lock failure when bounded fallback cleanup also fails', async () => { - getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') - const failure = new Error('lock path unavailable') - mocks.lock.mockRejectedValueOnce(failure) - mocks.discard.mockRejectedValueOnce(new Error('cleanup unavailable')) - await expect(prepare()).rejects.toBe(failure) - expect(mocks.discard).toHaveBeenCalledOnce() -}) - it.each(['first', 'cached'])( 'preserves a pre-existing target after %s fallback locking fails', async (fallback) => { @@ -233,52 +184,6 @@ it('runs each concurrent supported add rather than sharing the first checkout re expect(mocks.git).toHaveBeenCalledTimes(2) }) -it('isolates native rejection from individual WSL distros', async () => { - mocks.git.mockRejectedValueOnce(unsupported) - await prepare() - await prepare('/ubuntu', { wslDistro: 'Ubuntu' }) - await prepare('/debian', { wslDistro: 'Debian' }) - await prepare('/native-again') - expect(mocks.git.mock.calls.map(([args]) => args.includes('--reason'))).toEqual([ - true, - false, - true, - true, - false - ]) -}) - -it('uses native cached rejection when WSL routing executes the host Git binary', async () => { - getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') - const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - try { - const repoPath = String.raw`C:\repo\linked` - seedWslLinkedWorktreeGitRoutingForTests(repoPath) - await addLockedWorktreePreparation(repoPath, String.raw`C:\prepared`, 'main', reason, { - wslDistro: 'Ubuntu' - }) - expect(mocks.git).toHaveBeenCalledTimes(1) - expect(mocks.git.mock.calls[0][0]).not.toContain('--reason') - expect( - getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' }).shouldTry('worktree-add-lock-reason') - ).toBe(true) - } finally { - platform.mockRestore() - } -}) - -it('checks a guest directory through its execution distro rather than the Windows root', async () => { - const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - try { - await prepare('/home/prepared', { wslDistro: 'Ubuntu' }) - expect(mocks.lstat).toHaveBeenCalledExactlyOnceWith( - String.raw`\\wsl.localhost\Ubuntu\home\prepared` - ) - } finally { - platform.mockRestore() - } -}) - it('fails closed when the pre-existing target cannot be inspected', async () => { const failure = Object.assign(new Error('path access denied'), { code: 'EACCES' }) mocks.lstat.mockRejectedValueOnce(failure) @@ -287,17 +192,6 @@ it('fails closed when the pre-existing target cannot be inspected', async () => expect(mocks.discard).not.toHaveBeenCalled() }) -it('keeps a general add failure visible without poisoning the capability or claiming a marker', async () => { - const failure = new Error('permission denied') - mocks.git.mockRejectedValueOnce(failure) - await expect(prepare()).rejects.toBe(failure) - expect(mocks.lock).not.toHaveBeenCalled() - expect(getLocalGitCapabilityCache().shouldTry('worktree-add-lock-reason')).toBe(true) - expect(mocks.discard).toHaveBeenCalledExactlyOnceWith('/repo', '/prepared', {}, reason) - await prepare('/next') - expect(mocks.git.mock.calls.every(([args]) => args.includes('--reason'))).toBe(true) -}) - it('never removes a pre-existing checkout after add fails', async () => { mocks.lstat.mockResolvedValueOnce({}) mocks.git.mockRejectedValueOnce(new Error('path already exists')) @@ -325,14 +219,3 @@ it('attempts ownership-checked cleanup if cancellation interrupts the newly regi expect(mocks.discard).toHaveBeenCalledExactlyOnceWith('/repo', '/prepared', options, reason) expect(mocks.lock).not.toHaveBeenCalled() }) - -it.each([ - [new Error("unknown option 'reason'"), true], - [{ stderr: "error: unrecognized option '--reason'" }, true], - [{ stdout: 'invalid switch --reason' }, true], - [{ code: 129, stderr: "unknown option 'detach'" }, false], - [{ code: 129 }, false], - [new Error('permission denied while writing lock reason'), false] -])('recognizes only an unsupported lock-reason option: %j', (error, expected) => { - expect(isUnsupportedWorktreeAddLockReasonError(error)).toBe(expected) -}) diff --git a/src/main/git/worktrees/creation/worktree-preparation-lock-real-git.test.ts b/src/main/git/worktrees/creation/worktree-preparation-lock-real-git.test.ts index 6256c71f752..709a3936da2 100644 --- a/src/main/git/worktrees/creation/worktree-preparation-lock-real-git.test.ts +++ b/src/main/git/worktrees/creation/worktree-preparation-lock-real-git.test.ts @@ -52,29 +52,6 @@ async function fixture( return { root, repo, prepared: join(root, 'prepared'), final: join(root, 'final') } } -it('creates and consumes its marker without worktree lock or unlock inventory scans', async () => { - const { repo, prepared, final } = await fixture() - const reason = createWorktreePreparationLockReason('targeted') - const spy = vi.spyOn(runner, 'gitExecFileAsync') - await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) - const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) - expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`) - spy.mockClear() - await finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason) - const lockQueries = spy.mock.calls.filter( - ([args]) => args.includes('--git-path') || args.includes('--git-common-dir') - ) - expect(lockQueries).toHaveLength(1) - expect(lockQueries[0]?.[0]).toEqual(['rev-parse', '--git-path', 'locked', '--git-common-dir']) - expect(await git(final, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature') - expect(await git(final, ['status', '--porcelain'])).toBe('') - expect(await readFile(join(final, 'tracked.txt'), 'utf8')).toBe('original\n') - await expect(readFile(lock, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - expect(spy.mock.calls.some(([args]) => args.includes('lock') || args.includes('unlock'))).toBe( - false - ) -}) - it.runIf(process.platform !== 'win32')( 'unlocks an owned preparation whose repository path contains a newline', async () => { diff --git a/src/main/git/worktrees/creation/worktree-preparation-lock.test.ts b/src/main/git/worktrees/creation/worktree-preparation-lock.test.ts index 998fd7bc593..d355c3ffd4a 100644 --- a/src/main/git/worktrees/creation/worktree-preparation-lock.test.ts +++ b/src/main/git/worktrees/creation/worktree-preparation-lock.test.ts @@ -46,24 +46,6 @@ beforeEach(() => { }) describe('targeted preparation lock ownership', () => { - it('creates Git’s reason marker exclusively without enumerating worktrees', async () => { - const controller = new AbortController() - const options = { - wslDistro: 'Ubuntu', - signal: controller.signal, - timeout: 8000, - admissionTier: 'interactive' as const - } - await lockWorktreePreparation('/prepared', lockReason, options) - expect(mocks.git).toHaveBeenCalledExactlyOnceWith( - ['rev-parse', '--git-path', 'locked', '--git-common-dir'], - { cwd: '/prepared', ...options } - ) - expect(mocks.writeFile).toHaveBeenCalledExactlyOnceWith(lockPath, `${lockReason}\n`, { - flag: 'wx' - }) - }) - it('preserves an existing lock if exclusive creation fails', async () => { const error = Object.assign(new Error('lock exists'), { code: 'EEXIST' }) mocks.writeFile.mockRejectedValueOnce(error) @@ -73,19 +55,6 @@ describe('targeted preparation lock ownership', () => { expect(mocks.unlink).not.toHaveBeenCalled() }) - it('unlinks only the exact reason minted for this checkout', async () => { - await unlockWorktreePreparation('/final', lockReason, {}) - expect(mocks.readFile).toHaveBeenCalledExactlyOnceWith(lockPath, 'utf8') - expect(mocks.unlink).toHaveBeenCalledExactlyOnceWith(lockPath) - }) - - it('reuses a verified administrative path and reads the owner again before unlinking', async () => { - await unlockWorktreePreparationAtPath(lockPath, lockReason) - expect(mocks.git).not.toHaveBeenCalled() - expect(mocks.readFile).toHaveBeenCalledExactlyOnceWith(lockPath, 'utf8') - expect(mocks.unlink).toHaveBeenCalledExactlyOnceWith(lockPath) - }) - it.each([ 'user lock\n', 'orca-create-preparation:v1:123:another-session\n', @@ -139,33 +108,6 @@ describe('targeted preparation lock ownership', () => { }) describe('combined preparation lock query', () => { - it('reads embedded-newline pointers independently without changing their paths', async () => { - const controller = new AbortController() - const options = { signal: controller.signal, wslDistro: 'Ubuntu', timeout: 6000 } - const newlineCommon = join(tmpdir(), 'repo\nnewline', '.git') - const newlineLock = join(newlineCommon, 'worktrees', 'prepared', 'locked') - mocks.git.mockImplementation(async (args: string[]) => ({ - stdout: - args.includes('--git-path') && args.includes('--git-common-dir') - ? `${newlineLock}\n${newlineCommon}\n` - : `${args.includes('--git-path') ? newlineLock : newlineCommon}\n` - })) - - await expect(lockWorktreePreparation('/prepared', lockReason, options)).resolves.toBe( - toHostFilesystemPath(newlineLock) - ) - expect(mocks.git.mock.calls).toEqual([ - [['rev-parse', '--git-path', 'locked', '--git-common-dir'], { cwd: '/prepared', ...options }], - [['rev-parse', '--git-path', 'locked'], { cwd: '/prepared', ...options }], - [['rev-parse', '--git-common-dir'], { cwd: '/prepared', ...options }] - ]) - expect(mocks.writeFile).toHaveBeenCalledExactlyOnceWith( - toHostFilesystemPath(newlineLock), - `${lockReason}\n`, - { flag: 'wx' } - ) - }) - it.each(['create', 'unlock'] as const)( 'refuses a foreign administrative path before %s touches its marker', async (operation) => { @@ -180,34 +122,6 @@ describe('combined preparation lock query', () => { } ) - it.each(['create', 'unlock'] as const)( - 'keeps a failed combined Git read visible before %s touches a marker', - async (operation) => { - const failure = new Error('Git cannot read the preparation') - mocks.git.mockRejectedValueOnce(failure) - const run = operation === 'create' ? lockWorktreePreparation : unlockWorktreePreparation - await expect(run('/prepared', lockReason, {})).rejects.toBe(failure) - expect(mocks.git).toHaveBeenCalledOnce() - expect(mocks.readFile).not.toHaveBeenCalled() - expect(mocks.writeFile).not.toHaveBeenCalled() - expect(mocks.unlink).not.toHaveBeenCalled() - } - ) - - it('keeps a failed independent pointer read visible after an ambiguous response', async () => { - const failure = new Error('common directory is unavailable') - mocks.git.mockResolvedValueOnce({ stdout: 'ambiguous\n' }).mockImplementation(async (args) => { - if (args.includes('--git-common-dir')) { - throw failure - } - return { stdout: `${gitLockPath}\n` } - }) - await expect(lockWorktreePreparation('/prepared', lockReason, {})).rejects.toBe(failure) - expect(mocks.git).toHaveBeenCalledTimes(3) - expect(mocks.writeFile).not.toHaveBeenCalled() - expect(mocks.unlink).not.toHaveBeenCalled() - }) - it('forwards cancellation to both independent reads and never creates a marker', async () => { const controller = new AbortController() const cancellation = new Error('fallback canceled') @@ -232,56 +146,6 @@ describe('combined preparation lock query', () => { }) describe('Git preparation lock path resolution', () => { - it.each([ - { - path: '/prepared', - lock: '/repo/.git/worktrees/prepared/locked\n', - common: '/repo/.git\n', - expected: '/repo/.git/worktrees/prepared/locked', - options: { platform: 'linux' as const } - }, - { - path: '/workspace/prepared', - lock: '../../repo/.git/worktrees/prepared/locked\n', - common: '../../repo/.git\n', - expected: '/repo/.git/worktrees/prepared/locked', - options: { platform: 'linux' as const } - }, - { - path: String.raw`C:\workspace\prepared`, - lock: 'C:/repo/.git/worktrees/prepared/locked\n', - common: 'C:/repo/.git\n', - expected: 'C:/repo/.git/worktrees/prepared/locked', - options: { platform: 'win32' as const, wslDistro: 'Ubuntu' } - }, - { - path: String.raw`\\wsl.localhost\Ubuntu\home\workspace\prepared`, - lock: '/home/repo/.git/worktrees/prepared/locked\n', - common: '/home/repo/.git\n', - expected: String.raw`\\wsl.localhost\Ubuntu\home\repo\.git\worktrees\prepared\locked`, - options: { platform: 'win32' as const, wslDistro: 'Ubuntu' } - }, - { - path: String.raw`C:\workspace\prepared`, - lock: '/mnt/c/repo/.git/worktrees/prepared/locked\n', - common: '/mnt/c/repo/.git\n', - expected: String.raw`C:\repo\.git\worktrees\prepared\locked`, - options: { platform: 'win32' as const, wslDistro: 'Ubuntu' } - }, - { - path: '/workspace/new\nline/prepared', - lock: '/repo/new\nline/.git/worktrees/prepared/locked\n', - common: '/repo/new\nline/.git\n', - expected: '/repo/new\nline/.git/worktrees/prepared/locked', - options: { platform: 'linux' as const } - } - ])( - 'resolves $path in the filesystem namespace that owns Git', - ({ path, lock, common, expected, options }) => { - expect(resolveWorktreePreparationLockPath(path, lock, common, options)).toBe(expected) - } - ) - it.each([ { lock: '/repo/.git/locked\n', common: '/repo/.git\n' }, { lock: '/other/.git/worktrees/prepared/locked\n', common: '/repo/.git\n' }, diff --git a/src/main/git/worktrees/removal/remove-worktree-branch-cleanup.test.ts b/src/main/git/worktrees/removal/remove-worktree-branch-cleanup.test.ts index b8d2765fa31..def779b6e45 100644 --- a/src/main/git/worktrees/removal/remove-worktree-branch-cleanup.test.ts +++ b/src/main/git/worktrees/removal/remove-worktree-branch-cleanup.test.ts @@ -49,11 +49,9 @@ vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises') return { ...actual, stat: statMock, readFile: readFileMock } }) - import { createGitCallReader, createGitCommandMocker, - expectGitCallOrder, resetWorktreeGitMocks, resetWorktreeRemovalState } from './remove-worktree-test-harness' @@ -88,271 +86,6 @@ describe('removeWorktree', () => { }) }) - it('keeps removal successful when branch cleanup fails', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - }, - 'git branch -d -- feature/test': { - error: new Error('branch delete failed'), - stderr: 'branch delete failed' - } - }) - - await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({ - preservedBranch: { branchName: 'feature/test', head: 'def456' } - }) - - expect(warnSpy).toHaveBeenCalledWith( - '[git] Preserved local branch "feature/test" after removing worktree (not fully merged)', - expect.any(Error) - ) - - warnSpy.mockRestore() - }) - - it('deletes a squash-merged branch when merging it into the base is a no-op', async () => { - mockGitCommands({ - 'git worktree list --porcelain -z': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain -z#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - }, - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - }, - 'git branch -d -- feature/test': { - error: new Error('branch delete failed'), - stderr: 'error: the branch feature/test is not fully merged' - }, - 'git config --get branch.feature/test.base': { - stdout: 'refs/remotes/origin/main\n' - }, - 'git rev-parse --verify --quiet refs/remotes/origin/main^{commit}': { - stdout: 'base123\n' - }, - 'git rev-parse --verify --quiet HEAD^{commit}': { - stdout: 'base123\n' - }, - 'git merge-base base123 refs/heads/feature/test': { stdout: 'base000\n' }, - 'git merge-tree --write-tree base123 refs/heads/feature/test': { - stdout: 'tree123\n' - }, - 'git rev-parse --verify --quiet base123^{tree}': { - stdout: 'tree123\n' - } - }) - - await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({}) - - const calls = getGitCalls() - expect(calls).toContain('git branch -d -- feature/test') - expect(calls).toContain('git merge-tree --write-tree base123 refs/heads/feature/test') - expect(calls).toContain('git update-ref -d refs/heads/feature/test def456') - expect(calls).toContain('git config --remove-section branch.feature/test') - expect(calls).not.toContain('git remote') - }) - - it('deletes a squash-merged branch with branch-only merge commits via expected head', async () => { - mockGitCommands({ - 'git worktree list --porcelain -z': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain -z#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - }, - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - }, - 'git branch -d -- feature/test': { - error: new Error('branch delete failed'), - stderr: 'error: the branch feature/test is not fully merged' - }, - 'git config --get branch.feature/test.base': { - stdout: 'refs/remotes/origin/main\n' - }, - 'git rev-parse --verify --quiet HEAD^{commit}': { - stdout: 'target123\n' - }, - 'git merge-tree --write-tree target123 refs/heads/feature/test': { - stdout: 'merged-tree\n' - }, - 'git rev-parse --verify --quiet target123^{tree}': { - stdout: 'target-tree\n' - }, - 'git rev-list --right-only --merges --count target123...refs/heads/feature/test': { - stdout: '1\n' - }, - 'git merge-base target123 refs/heads/feature/test': { - stdout: 'base123\n' - }, - 'git diff base123 refs/heads/feature/test': { - stdout: 'branch net diff\n' - }, - 'git patch-id --stable#1': { - stdout: 'patch123 0000000000000000000000000000000000000000\n' - }, - 'git rev-list --ancestry-path --max-count=201 base123..target123': { - stdout: 'squash123\n' - }, - 'git show --format= squash123': { - stdout: 'squash diff\n' - }, - 'git patch-id --stable#2': { - stdout: 'patch123 squash123\n' - }, - 'git merge-tree --write-tree squash123 refs/heads/feature/test': { - stdout: 'squash-tree\n' - }, - 'git rev-parse --verify --quiet squash123^{tree}': { - stdout: 'squash-tree\n' - } - }) - - await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({}) - - const calls = getGitCalls() - expect(calls).toContain('git update-ref -d refs/heads/feature/test def456') - expect(calls).toContain('git config --remove-section branch.feature/test') - expect(gitExecFileAsyncMock.mock.calls).toContainEqual([ - ['patch-id', '--stable'], - expect.objectContaining({ cwd: '/repo', stdin: 'branch net diff\n' }) - ]) - expect(gitExecFileAsyncMock.mock.calls).toContainEqual([ - ['patch-id', '--stable'], - expect.objectContaining({ cwd: '/repo', stdin: 'squash diff\n' }) - ]) - }) - - it('refreshes the saved remote base before deleting a safe-delete-rejected branch', async () => { - mockGitCommands({ - 'git worktree list --porcelain -z': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain -z#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - }, - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - }, - 'git branch -d -- feature/test': { - error: new Error('branch delete failed'), - stderr: 'error: the branch feature/test is not fully merged' - }, - 'git config --get branch.feature/test.base': { - stdout: 'refs/remotes/origin/main\n' - }, - 'git remote': { - stdout: 'origin\n' - }, - 'git config --get-all remote.origin.fetch': { - stdout: '+refs/heads/*:refs/remotes/origin/*\n' - }, - 'git -c maintenance.auto=false -c maintenance.commit-graph.auto=0 -c gc.auto=0 fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main': - { - stdout: '' - }, - 'git rev-parse --verify --quiet refs/remotes/origin/main^{commit}': { - stdout: 'base123\n' - }, - 'git merge-base base123 refs/heads/feature/test': { stdout: 'base000\n' }, - 'git merge-tree --write-tree base123 refs/heads/feature/test': { - stdout: 'tree123\n' - }, - 'git rev-parse --verify --quiet base123^{tree}': { - stdout: 'tree123\n' - } - }) - - await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({}) - - const calls = getGitCalls() - const mergeTreeCall = 'git merge-tree --write-tree base123 refs/heads/feature/test' - const mergeTreeIndexes = calls.flatMap((call, index) => (call === mergeTreeCall ? [index] : [])) - const fetchIndex = calls.indexOf( - 'git -c maintenance.auto=false -c maintenance.commit-graph.auto=0 -c gc.auto=0 fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main' - ) - const updateRefIndex = calls.indexOf('git update-ref -d refs/heads/feature/test def456') - expect(calls).toContain( - 'git -c maintenance.auto=false -c maintenance.commit-graph.auto=0 -c gc.auto=0 fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main' - ) - expect(calls).toContain('git update-ref -d refs/heads/feature/test def456') - expect(mergeTreeIndexes).toHaveLength(1) - expect(fetchIndex).toBeLessThan(mergeTreeIndexes[0]) - expect(mergeTreeIndexes[0]).toBeLessThan(updateRefIndex) - expectGitCallOrder( - calls, - 'git -c maintenance.auto=false -c maintenance.commit-graph.auto=0 -c gc.auto=0 fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main', - 'git update-ref -d refs/heads/feature/test def456' - ) - }) - it('preserves an already-merged branch when cleanup races after worktree removal', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) mockGitCommands({ @@ -416,17 +149,6 @@ branch refs/heads/main warnSpy.mockRestore() }) - it('force-deletes a preserved branch only at its saved head', async () => { - mockGitCommands({}) - - await forceDeleteLocalBranch('/repo', 'feature/test', 'def456') - - const calls = getGitCalls() - expect(calls).toContain('git worktree list --porcelain') - expect(calls).toContain('git update-ref -d refs/heads/feature/test def456') - expect(calls).toContain('git config --remove-section branch.feature/test') - }) - it('refuses to force-delete a preserved branch that is checked out again', async () => { mockGitCommands({ 'git worktree list --porcelain': { diff --git a/src/main/git/worktrees/removal/remove-worktree.test.ts b/src/main/git/worktrees/removal/remove-worktree.test.ts index 9c3de8b0565..77d7da1c19d 100644 --- a/src/main/git/worktrees/removal/remove-worktree.test.ts +++ b/src/main/git/worktrees/removal/remove-worktree.test.ts @@ -48,20 +48,15 @@ vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises') return { ...actual, stat: statMock, readFile: readFileMock } }) - import { createGitCallReader, createGitCommandMocker, - expectGitCallOrder, resetWorktreeGitMocks, resetWorktreeRemovalState } from './remove-worktree-test-harness' import { removeWorktree } from './worktree-removal' -import { - __getSparseCheckoutStateCacheSizeForTests, - detectSparseCheckoutCached -} from '../sparse-checkout/worktree-sparse-checkout-cache' +import { __getSparseCheckoutStateCacheSizeForTests } from '../sparse-checkout/worktree-sparse-checkout-cache' const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) @@ -90,36 +85,6 @@ describe('removeWorktree', () => { }) }) - it('removes the worktree and deletes its local branch', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - } - }) - - await removeWorktree('/repo', '/repo-feature') - - const calls = getGitCalls() - expect(calls).toEqual( - expect.arrayContaining(['git worktree remove /repo-feature', 'git branch -d -- feature/test']) - ) - expect(calls).not.toContain('git worktree prune') - expectGitCallOrder(calls, 'git worktree remove /repo-feature', 'git branch -d -- feature/test') - }) - it('preserves the branch when requested for a pre-existing local branch checkout', async () => { mockGitCommands({ 'git worktree list --porcelain': { @@ -195,130 +160,6 @@ branch refs/heads/feature/test expect(calls).not.toContain('git branch -D -- feature/test') }) - it('deletes the branch after prune removes stale sibling worktree entries', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test - -worktree /repo-stale -HEAD 0000000 -branch refs/heads/feature/test -prunable gitdir file points to non-existent location -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - }, - 'git branch -d -- feature/test': { - error: new Error("cannot delete branch 'feature/test' used by worktree at '/repo-stale'") - }, - 'git branch -d -- feature/test#2': { - stdout: '' - } - }) - - await removeWorktree('/repo', '/repo-feature') - - const calls = getGitCalls() - expect(calls).toEqual([ - 'git worktree list --porcelain -z', - 'git worktree remove /repo-feature', - 'git branch -d -- feature/test', - 'git worktree prune', - 'git branch -d -- feature/test' - ]) - }) - - it('lets Git delete the checkout inline with no deadline', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - } - }) - - await removeWorktree('/repo', '/repo-feature') - - // Large deletions must finish without a deadline; their own queue bounds admission. - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'remove', '/repo-feature'], { - cwd: '/repo', - admissionExempt: true, - ...(process.platform === 'darwin' ? { spawnOnWorkerThread: true } : {}) - }) - expect(getGitCalls()).not.toContain('git worktree prune') - expect(getGitCalls()).toContain('git branch -d -- feature/test') - }) - - it('lets a quit stop only the checkout delete, not the branch cleanup that holds ref locks', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - } - }) - const stop = new AbortController() - - await removeWorktree('/repo', '/repo-feature', false, { - checkoutDeleteSignal: stop.signal - }) - - const optionsOf = (command: string): { signal?: AbortSignal } | undefined => - gitExecFileAsyncMock.mock.calls.find((call) => call[0].join(' ') === command)?.[1] - expect(optionsOf('worktree remove /repo-feature')?.signal).toBe(stop.signal) - expect(optionsOf('branch -d -- feature/test')).toBeDefined() - expect(optionsOf('branch -d -- feature/test')?.signal).toBeUndefined() - }) - - it('never lets Git run an inherited yes/no prompt program during the delete', async () => { - vi.stubEnv('GIT_ASK_YESNO', '/usr/local/bin/prompt') - try { - mockGitCommands({}) - - await removeWorktree('/repo', '/repo-feature', false, { - knownRemovedWorktree: { branch: '', head: '', locked: false } - }) - - const [, removeOptions] = gitExecFileAsyncMock.mock.calls[0] - expect(removeOptions.env).toBeDefined() - expect(removeOptions.env).not.toHaveProperty('GIT_ASK_YESNO') - } finally { - vi.unstubAllEnvs() - } - }) - it('deletes what Git left of the checkout after a successful remove', async () => { const scratch = await mkdtemp(join(tmpdir(), 'orca-remove-residue-')) const worktreePath = join(scratch, 'feature') @@ -356,93 +197,6 @@ branch refs/heads/main } }) - it('leaves a dirty checkout to Git to refuse', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git status --porcelain --untracked-files=all': { stdout: ' M src/app.ts\n' }, - 'git worktree remove /repo-feature': { - error: new Error('fatal: contains modified or untracked files, use --force to delete it') - } - }) - - await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( - 'contains modified or untracked files' - ) - expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') - }) - - it('deletes WSL-hosted checkouts in place inside the distro', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - } - }) - - await removeWorktree('/repo', '/repo-feature', false, { wslDistro: 'Ubuntu' }) - - expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') - expect(getGitCalls()).toContain('git worktree remove /repo-feature') - }) - - it('removes a WSL checkout configured for a native Windows repo with Git alone', async () => { - const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\feature' - platformSpy.mockReturnValue('win32') - mockGitCommands({}) - - await removeWorktree('C:\\repo', worktreePath, false, { - knownRemovedWorktree: { branch: '', head: '', locked: false } - }) - - expect(getGitCalls()).toEqual([`git -c core.longpaths=true worktree remove ${worktreePath}`]) - }) - - it('passes one --force before the worktree path for dirty-file removal', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - } - }) - - await removeWorktree('/repo', '/repo-feature', true) - - expect(getGitCalls()).toContain('git worktree remove --force /repo-feature') - }) - it('preserves Git refusal even when parent status cannot reveal unpublished submodule commits', async () => { mockGitCommands({ 'git worktree list --porcelain': { @@ -475,101 +229,6 @@ branch refs/heads/main expect(getGitCalls()).not.toContain('git branch -d -- feature/test') }) - it('preserves Git refusal for a dirty submodule worktree', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree remove /repo-feature': { - error: new Error('git worktree remove failed'), - stderr: 'fatal: working trees containing submodules cannot be moved or removed' - }, - 'git status --porcelain --untracked-files=all': { stdout: ' M sub\n' } - }) - - await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( - 'git worktree remove failed' - ) - expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') - }) - - it('does not force-retry when the caller already forced removal', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree remove --force /repo-feature': { - error: new Error('git worktree remove failed'), - stderr: 'fatal: working trees containing submodules cannot be moved or removed' - } - }) - - await expect(removeWorktree('/repo', '/repo-feature', true)).rejects.toThrow() - expect( - getGitCalls().filter((call) => call === 'git worktree remove --force /repo-feature') - ).toHaveLength(1) - }) - - it('does not force-retry unrelated non-force remove failures', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree remove /repo-feature': { - error: new Error('git worktree remove failed'), - stderr: 'fatal: contains modified or untracked files, use --force to delete it' - } - }) - - await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( - 'git worktree remove failed' - ) - expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') - expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') - }) - - it('rejects a locked worktree with stable app-owned copy before invoking remove', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -locked active agent session -` - } - }) - - await expect(removeWorktree('/repo', '/repo-feature', true)).rejects.toThrow( - 'Worktree is locked by Git. Lock reason: active agent session.' - ) - expect(getGitCalls()).not.toContain('git worktree remove /repo-feature') - }) - it('does not treat dirty-file force as permission to override a lock', async () => { mockGitCommands({ 'git worktree list --porcelain': { @@ -590,74 +249,4 @@ locked active agent session ) expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') }) - - it('matches Windows worktree paths before deleting the branch', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree C:/repo -HEAD abc123 -branch refs/heads/main - -worktree C:/Workspaces/Delete-Branch-Ui-Test -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree C:/repo -HEAD abc123 -branch refs/heads/main -` - } - }) - - await removeWorktree('C:\\repo', 'c:\\workspaces\\delete-branch-ui-test') - - const calls = getGitCalls() - expect(calls).toEqual( - expect.arrayContaining([ - 'git worktree remove c:\\workspaces\\delete-branch-ui-test', - 'git branch -d -- feature/test' - ]) - ) - expect(calls).not.toContain('git worktree prune') - }) -}) - -describe('removeWorktree sparse-checkout cache invalidation', () => { - beforeEach(() => { - resetWorktreeGitMocks({ - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - statMock, - readFileMock, - resolveGitDirMock - }) - }) - - it('drops the removed worktree path so a re-created worktree at the same path is re-detected', async () => { - await detectSparseCheckoutCached('/repo', '/repo-feature') - expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(1) - - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - } - }) - - await removeWorktree('/repo', '/repo-feature', false, { deleteBranch: false }) - - // Other rows may be re-cached during removal; only the removed path must require a fresh stat. - const statCallsBefore = statMock.mock.calls.length - expect(await detectSparseCheckoutCached('/repo', '/repo-feature')).toBe(false) - expect(statMock.mock.calls.length).toBeGreaterThan(statCallsBefore) - }) }) diff --git a/src/main/git/worktrees/removal/worktree-removal-real-git.test.ts b/src/main/git/worktrees/removal/worktree-removal-real-git.test.ts index 069eec1cb99..4c68968f6a4 100644 --- a/src/main/git/worktrees/removal/worktree-removal-real-git.test.ts +++ b/src/main/git/worktrees/removal/worktree-removal-real-git.test.ts @@ -190,22 +190,6 @@ describe('worktree removal against the real Git binary', () => { } ) - it('leaves sibling worktrees registered', async () => { - const siblingPath = join(workspaceRoot, 'repo', 'sibling') - await git(['worktree', 'add', '-q', siblingPath, '-b', 'sibling'], repoPath) - - await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) - - expect(await isRegistered(siblingPath)).toBe(true) - expect(existsSync(siblingPath)).toBe(true) - }) - - it('deletes the branch exactly as the in-place removal did', async () => { - await removeWorktree(repoPath, worktreePath, false) - - expect(await git(['branch', '--list', 'feature'], repoPath)).toBe('') - }) - it('preserves a branch whose only unmerged commit is empty', async () => { await git(['commit', '--allow-empty', '-qm', 'unique empty commit'], worktreePath) const head = (await git(['rev-parse', 'HEAD'], worktreePath)).trim() diff --git a/src/main/git/worktrees/sparse-checkout/worktree-sparse-checkout.test.ts b/src/main/git/worktrees/sparse-checkout/worktree-sparse-checkout.test.ts deleted file mode 100644 index a720e4877a3..00000000000 --- a/src/main/git/worktrees/sparse-checkout/worktree-sparse-checkout.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { installRealWorkerThreadChildProcesses } from '../../../worker-thread-child-process/worker-thread-child-process-real.test-fixtures' -import { execFileSync } from 'node:child_process' -import { mkdtemp, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { listWorktrees } from '../catalog/worktree-scan-cache' -import { parseCoreSparseCheckoutFlag } from './worktree-sparse-state' -import { removeWorktree } from '../removal/worktree-removal' - -const tempRoots: string[] = [] - -function git(cwd: string, args: string[]): string { - return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) -} - -async function createRepoWithTwoDirs(): Promise { - const root = await mkdtemp(path.join(tmpdir(), 'orca-sparse-checkout-')) - tempRoots.push(root) - const repoPath = path.join(root, 'repo') - - execFileSync('git', ['init', '--quiet', repoPath]) - git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) - git(repoPath, ['config', 'user.email', 'test@example.com']) - git(repoPath, ['config', 'user.name', 'Test User']) - await mkdir(path.join(repoPath, 'keep'), { recursive: true }) - await writeFile(path.join(repoPath, 'keep', 'file.txt'), 'keep\n') - await mkdir(path.join(repoPath, 'drop'), { recursive: true }) - await writeFile(path.join(repoPath, 'drop', 'file.txt'), 'drop\n') - git(repoPath, ['add', '-A']) - git(repoPath, ['commit', '--quiet', '-m', 'initial']) - - return realpath(repoPath) -} - -function mainWorktree(worktrees: Awaited>) { - const found = worktrees.find((worktree) => worktree.isMainWorktree) - if (!found) { - throw new Error('expected a main worktree in the listing') - } - return found -} - -afterEach(async () => { - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('sparse-checkout detection', () => { - it.skipIf(process.platform === 'win32')( - 'reports isSparse while sparse checkout is enabled', - async () => { - const repoPath = await createRepoWithTwoDirs() - - git(repoPath, ['sparse-checkout', 'set', 'keep']) - - expect(mainWorktree(await listWorktrees(repoPath)).isSparse).toBe(true) - } - ) - - it.skipIf(process.platform === 'win32')( - 'does not report isSparse after disable leaves the pattern file behind', - async () => { - const repoPath = await createRepoWithTwoDirs() - - git(repoPath, ['sparse-checkout', 'set', 'keep']) - git(repoPath, ['sparse-checkout', 'disable']) - - // Disabling sparse checkout deliberately retains its pattern file for later re-enabling. - const patternFile = path.join(repoPath, '.git', 'info', 'sparse-checkout') - await expect(stat(patternFile)).resolves.toMatchObject({}) - - expect(mainWorktree(await listWorktrees(repoPath)).isSparse).toBeFalsy() - } - ) - - it.skipIf(process.platform === 'win32')( - 'ignores config.worktree while extensions.worktreeConfig is off', - async () => { - const repoPath = await createRepoWithTwoDirs() - - git(repoPath, ['sparse-checkout', 'set', 'keep']) - git(repoPath, ['config', 'extensions.worktreeConfig', 'false']) - git(repoPath, ['config', 'core.sparseCheckout', 'false']) - await writeFile( - path.join(repoPath, '.git', 'config.worktree'), - '[core]\n\tsparseCheckout = true\n' - ) - - expect(git(repoPath, ['config', '--get', 'core.sparseCheckout']).trim()).toBe('false') - expect(mainWorktree(await listWorktrees(repoPath)).isSparse).toBeFalsy() - } - ) - - it.skipIf(process.platform === 'win32')( - 'honors config.worktree while extensions.worktreeConfig is on', - async () => { - const repoPath = await createRepoWithTwoDirs() - - git(repoPath, ['sparse-checkout', 'set', 'keep']) - git(repoPath, ['config', 'extensions.worktreeConfig', 'true']) - git(repoPath, ['config', 'core.sparseCheckout', 'false']) - await writeFile( - path.join(repoPath, '.git', 'config.worktree'), - '[core]\n\tsparseCheckout = true\n' - ) - - expect(git(repoPath, ['config', '--get', 'core.sparseCheckout']).trim()).toBe('true') - expect(mainWorktree(await listWorktrees(repoPath)).isSparse).toBe(true) - } - ) -}) - -describe('sparse-checkout cache invalidation across the worktree lifecycle', () => { - it.skipIf(process.platform === 'win32')( - 'does not leak a stale sparse badge onto a worktree created at a removed worktree`s path', - async () => { - const repoPath = await createRepoWithTwoDirs() - const linkedPath = path.join(path.dirname(repoPath), 'linked') - - git(repoPath, ['worktree', 'add', '-b', 'sparse-branch', linkedPath]) - git(linkedPath, ['sparse-checkout', 'set', 'keep']) - - const beforeRemoval = await listWorktrees(repoPath) - const sparseRow = beforeRemoval.find((worktree) => worktree.branch.endsWith('sparse-branch')) - expect(sparseRow?.isSparse).toBe(true) - - // Exercise Orca's invalidation hook rather than removing through raw Git. - await removeWorktree(repoPath, linkedPath, true) - git(repoPath, ['worktree', 'add', '-b', 'full-branch', linkedPath]) - - const afterRecreate = await listWorktrees(repoPath) - const fullRow = afterRecreate.find((worktree) => worktree.branch.endsWith('full-branch')) - expect(fullRow?.isSparse).toBeFalsy() - } - ) -}) - -describe('parseCoreSparseCheckoutFlag', () => { - it('reads an enabled flag from the [core] section', () => { - expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = true\n')).toBe(true) - }) - - it('reads a disabled flag written by `sparse-checkout disable`', () => { - expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = false\n')).toBe(false) - }) - - it('returns undefined when the flag is absent', () => { - expect(parseCoreSparseCheckoutFlag('[core]\n\tbare = false\n')).toBeUndefined() - expect(parseCoreSparseCheckoutFlag('')).toBeUndefined() - }) - - it('honors the last assignment when the key repeats', () => { - expect( - parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = true\n\tsparseCheckout = false\n') - ).toBe(false) - }) - - it('is case-insensitive for the section and key names', () => { - expect(parseCoreSparseCheckoutFlag('[CORE]\n\tSPARSECHECKOUT = TRUE\n')).toBe(true) - }) - - it('treats a valueless boolean as true', () => { - expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout\n')).toBe(true) - }) - - it('ignores a [core "subsection"] header', () => { - expect(parseCoreSparseCheckoutFlag('[core "sub"]\n\tsparseCheckout = true\n')).toBeUndefined() - }) - - it('ignores a matching key outside the [core] section', () => { - expect(parseCoreSparseCheckoutFlag('[other]\n\tsparseCheckout = true\n')).toBeUndefined() - }) - - it('ignores an inline comment after the value', () => { - expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = true # on\n')).toBe(true) - }) - - // Git accepts an assignment immediately after its section header. - it('reads an assignment on the same line as the section header', () => { - expect(parseCoreSparseCheckoutFlag('[core] sparseCheckout = true\n')).toBe(true) - expect(parseCoreSparseCheckoutFlag('[core] sparseCheckout = false\n')).toBe(false) - expect(parseCoreSparseCheckoutFlag('[core]sparseCheckout=true\n')).toBe(true) - expect(parseCoreSparseCheckoutFlag('[core] sparseCheckout\n')).toBe(true) - }) - - it('keeps the section open for later lines after a same-line assignment', () => { - expect( - parseCoreSparseCheckoutFlag('[core] sparseCheckout = false\n\tsparseCheckout = true\n') - ).toBe(true) - }) - - it('lets the last header on a line decide the section', () => { - expect(parseCoreSparseCheckoutFlag('[core "sub"] [core] sparseCheckout = true\n')).toBe(true) - expect(parseCoreSparseCheckoutFlag('[core] [other] sparseCheckout = true\n')).toBeUndefined() - }) - - it('ignores a same-line assignment under a [core "subsection"] header', () => { - expect(parseCoreSparseCheckoutFlag('[core "sub"] sparseCheckout = true\n')).toBeUndefined() - }) - - it('leaves [core] when a subsection header carries its own same-line assignment', () => { - // A subsection must end [core] even when its header carries an assignment. - expect( - parseCoreSparseCheckoutFlag( - '[core]\n[core "sub"]worktreeConfig = x\n\tsparseCheckout = true\n' - ) - ).toBeUndefined() - }) - - it('honors the last assignment across mixed same-line and indented forms', () => { - expect( - parseCoreSparseCheckoutFlag( - '[core] sparseCheckout = true\n[core]\n\tsparseCheckout = false\n' - ) - ).toBe(false) - expect( - parseCoreSparseCheckoutFlag( - '[core]\n\tsparseCheckout = false\n[core] sparseCheckout = true\n' - ) - ).toBe(true) - }) - - it('handles comments and whitespace around a same-line header', () => { - expect(parseCoreSparseCheckoutFlag('[core]# c\n\tsparseCheckout = true\n')).toBe(true) - expect(parseCoreSparseCheckoutFlag('\t[core] sparseCheckout = true ; c\n')).toBe(true) - }) - - it('does not treat trailing junk as a second assignment', () => { - // Git treats the full tail as one value; invalid boolean coercion must remain non-sparse. - expect(parseCoreSparseCheckoutFlag('[core] sparseCheckout = true bogus = false\n')).toBe(false) - }) -}) - -installRealWorkerThreadChildProcesses() diff --git a/src/main/git/wsl-windows-checkout-git-probe.test.ts b/src/main/git/wsl-windows-checkout-git-probe.test.ts index 662ced838ae..47ec2eac1ff 100644 --- a/src/main/git/wsl-windows-checkout-git-probe.test.ts +++ b/src/main/git/wsl-windows-checkout-git-probe.test.ts @@ -8,11 +8,9 @@ import { } from './wsl-windows-checkout-git-probe' import { resetWslWindowsCheckoutGitConfigForTests, - withWslWindowsCheckoutGitConfig, - WSL_WINDOWS_CHECKOUT_TTL_MS + withWslWindowsCheckoutGitConfig } from './wsl-windows-checkout-git-config' import { - HOST_CONFIG_LOCAL_AUTOCRLF_FALSE, HOST_CONFIG_SYSTEM_AUTOCRLF, PLAIN_WINDOWS_CLONE_SAMPLE, WSL_CLONE_WITH_CRLF_README_SAMPLE @@ -69,19 +67,6 @@ function harness( } describe('pendingWslWindowsCheckoutGitConfig', () => { - it('ignores commands that are not WSL git on a Windows drive', () => { - const { deps } = harness() - expect(pendingWslWindowsCheckoutGitConfig(READ, { cwd: CWD }, deps)).toBeNull() - expect( - pendingWslWindowsCheckoutGitConfig( - READ, - { cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\repo`, wslDistro: 'Ubuntu' }, - deps - ) - ).toBeNull() - expect(deps.runHostGit).not.toHaveBeenCalled() - }) - it('flags the first read from the host answer alone, without waiting for the distro', async () => { const { deps, run, flagged } = harness({ runWsl: vi.fn(() => new Promise(() => {})) @@ -92,53 +77,6 @@ describe('pendingWslWindowsCheckoutGitConfig', () => { await vi.waitFor(() => expect(deps.runWsl).toHaveBeenCalledTimes(1)) }) - it('never samples when host Git would not write CRLF', async () => { - const { deps, run, flagged } = harness({ - runHostGit: vi.fn(async () => host({ stdout: HOST_CONFIG_LOCAL_AUTOCRLF_FALSE })) - }) - await run(WRITE) - expect(flagged(READ)).toBe(false) - expect(deps.runWsl).not.toHaveBeenCalled() - }) - - it('treats an unset autocrlf (exit 1, quiet) as settled', async () => { - const { deps, run } = harness({ runHostGit: vi.fn(async () => host({ code: 1, stdout: '' })) }) - await run(READ) - await run(READ) - expect(deps.runHostGit).toHaveBeenCalledTimes(1) - }) - - it('waits for a fresh sample before a write and flags it on a Windows checkout', async () => { - const { run, flagged } = harness() - await run(WRITE) - expect(flagged(WRITE)).toBe(true) - }) - - it('does not flag writes on a WSL checkout with one CRLF file', async () => { - const { run, flagged } = harness({ - runWsl: vi.fn(async () => wsl({ stdout: WSL_CLONE_WITH_CRLF_README_SAMPLE })) - }) - await run(WRITE) - expect(flagged(WRITE)).toBe(false) - expect(flagged(READ)).toBe(false) - }) - - it('treats clipped sample output as inconclusive', async () => { - const { run, flagged } = harness({ runWsl: vi.fn(async () => wsl({ outputTruncated: true })) }) - await run(WRITE) - expect(flagged(WRITE)).toBe(false) - }) - - it('re-samples before a write once the answer is stale', async () => { - const { clock, deps, run, flagged } = harness() - await run(WRITE) - clock.now += WSL_WINDOWS_CHECKOUT_TTL_MS - deps.runWsl.mockResolvedValueOnce(wsl({ stdout: WSL_CLONE_WITH_CRLF_README_SAMPLE })) - await run(WRITE) - expect(deps.runWsl).toHaveBeenCalledTimes(2) - expect(flagged(WRITE)).toBe(false) - }) - it('re-samples before a write when the checkout was re-cloned at the same path', async () => { const { deps, run, flagged } = harness() await run(WRITE) @@ -169,79 +107,6 @@ describe('pendingWslWindowsCheckoutGitConfig', () => { ]) }) - it('keeps a sample taken of the checkout that replaced the one the write first saw', async () => { - const { deps, run, flagged } = harness({ checkoutIdentity: async () => 'clone-2' }) - deps.checkoutIdentity.mockResolvedValueOnce('clone-1') - await run(WRITE) - expect(flagged(WRITE)).toBe(true) - }) - - it('refreshes a stale host answer before a write rather than in the background', async () => { - const { clock, deps, run, flagged } = harness() - await run(WRITE) - clock.now += WSL_WINDOWS_CHECKOUT_TTL_MS - deps.runHostGit.mockResolvedValue(host({ stdout: HOST_CONFIG_LOCAL_AUTOCRLF_FALSE })) - await run(WRITE) - expect(deps.runHostGit).toHaveBeenCalledTimes(2) - expect(flagged(WRITE)).toBe(false) - expect(flagged(READ)).toBe(false) - }) - - it('never probes for a command that creates another checkout', async () => { - const { deps, run } = harness() - await run(['worktree', 'add', '-b', 'feature', String.raw`C:\Users\dev\feature`]) - expect(deps.runHostGit).not.toHaveBeenCalled() - expect(deps.runWsl).not.toHaveBeenCalled() - }) - - it('caches only a genuine "not a git repository" and backs off other fatal errors', async () => { - const notRepo = harness({ - runHostGit: vi.fn(async () => - host({ code: 128, stdout: '', stderr: 'fatal: not a git repository (or any parent)' }) - ) - }) - await notRepo.run(READ) - await notRepo.run(READ) - expect(notRepo.deps.runHostGit).toHaveBeenCalledTimes(1) - resetWslWindowsCheckoutGitConfigForTests() - - const dubious = harness({ - runHostGit: vi.fn(async () => - host({ code: 128, stdout: '', stderr: 'fatal: detected dubious ownership in repository' }) - ) - }) - await dubious.run(READ) - expect(dubious.flagged(READ)).toBe(false) - await dubious.run(READ) - expect(dubious.deps.runHostGit).toHaveBeenCalledTimes(1) - dubious.clock.now += 30_000 - dubious.deps.runHostGit.mockResolvedValueOnce(host()) - await dubious.run(READ) - expect(dubious.flagged(READ)).toBe(true) - }) - - it('backs off a sample that fails for any reason other than "not a git repository"', async () => { - const { clock, deps, run, flagged } = harness({ - runWsl: vi.fn(async () => wsl({ code: 3, stdout: '', stderr: 'fatal: unable to read index' })) - }) - await run(WRITE) - await run(WRITE) - expect(deps.runWsl).toHaveBeenCalledTimes(1) - expect(flagged(WRITE)).toBe(false) - clock.now += 30_000 - await run(WRITE) - expect(deps.runWsl).toHaveBeenCalledTimes(2) - }) - - it('admits every probe through the git scheduler and shares concurrent probes', async () => { - const { deps, run } = harness() - await Promise.all([run(WRITE), run(WRITE), run(READ)]) - expect(deps.runHostGit).toHaveBeenCalledTimes(1) - expect(deps.runWsl).toHaveBeenCalledTimes(1) - expect(deps.admit).toHaveBeenCalledTimes(2) - expect(deps.admit).toHaveBeenCalledWith(expect.objectContaining({ wslDistro: 'Ubuntu' })) - }) - it('stops waiting when the command aborts', async () => { const { run } = harness({ runHostGit: vi.fn(() => new Promise(() => {})) }) const controller = new AbortController() @@ -249,16 +114,4 @@ describe('pendingWslWindowsCheckoutGitConfig', () => { controller.abort() await expect(waiting).resolves.toBeUndefined() }) - - it('passes the sample cap and the index-entry scan cap through argv', async () => { - const { deps, run } = harness() - await run(WRITE) - expect(deps.runWsl).toHaveBeenCalledWith( - expect.objectContaining({ - distro: 'Ubuntu', - args: ['/mnt/c/Users/dev/repo', '200', '2000'], - loginPath: 'preferred' - }) - ) - }) }) diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index bd6a861b790..6bf761036c7 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -85,60 +85,6 @@ describe('createGitHubPullRequest', () => { acquireMock.mockResolvedValue(undefined) }) - it('creates a GitHub pull request with normalized refs and a body file', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - url: 'https://github.com/acme/widgets/pull/42' - }) - }) - - await expect( - createGitHubPullRequest( - '/repo-root', - { - provider: 'github', - base: 'origin/main', - head: 'refs/heads/feature/create-pr', - title: ' Create PR UI ', - body: 'Body text', - draft: true - }, - 'local' - ) - ).resolves.toEqual({ - ok: true, - number: 42, - url: 'https://github.com/acme/widgets/pull/42' - }) - - const [args, options] = ghExecFileAsyncMock.mock.calls[0] - expect(args).toEqual( - expect.arrayContaining([ - 'pr', - 'create', - '--repo', - 'acme/widgets', - '--base', - 'main', - '--head', - 'feature/create-pr', - '--title', - 'Create PR UI', - '--draft' - ]) - ) - expect(args[args.indexOf('--body-file') + 1]).toMatch(/body\.md$/) - expect(options).toMatchObject({ - cwd: '/repo-root', - timeout: 60_000, - idempotent: false - }) - expect(acquireMock).toHaveBeenCalledOnce() - expect(releaseMock).toHaveBeenCalledOnce() - }) - it('targets the origin fork (not the upstream parent) on a fork checkout (#7331)', async () => { // Creation targets the fork holding the unqualified head branch; reads can prefer upstream. getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => @@ -175,195 +121,6 @@ describe('createGitHubPullRequest', () => { expect(args[args.indexOf('--head') + 1]).toBe('my-branch') }) - it('routes --repo to the Enterprise server via options.host for a GHES remote (#8312)', async () => { - getOwnerRepoMock.mockResolvedValueOnce(null) - getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce({ - owner: 'team', - repo: 'orca', - host: 'github.acme-corp.com' - }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'https://github.acme-corp.com/team/orca/pull/7\n' - }) - - await expect( - createGitHubPullRequest( - '/repo-root', - { - provider: 'github', - base: 'main', - head: 'feature/create-pr', - title: 'GHES PR' - }, - 'local' - ) - ).resolves.toEqual({ - ok: true, - number: 7, - url: 'https://github.acme-corp.com/team/orca/pull/7' - }) - - const [args, options] = ghExecFileAsyncMock.mock.calls[0] - // The real runner qualifies argv from options.host after this mocked boundary. - expect(args[args.indexOf('--repo') + 1]).toBe('team/orca') - expect(options).toMatchObject({ host: 'github.acme-corp.com' }) - }) - - it('routes the GHES existing-PR fallback lookup through options.host (#8312)', async () => { - getOwnerRepoMock.mockResolvedValue(null) - getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ - owner: 'team', - repo: 'orca', - host: 'github.acme-corp.com' - }) - ghExecFileAsyncMock - .mockRejectedValueOnce( - Object.assign(new Error('exists'), { - stderr: 'a pull request for branch "feature/create-pr" already exists', - stdout: '' - }) - ) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' } - ]) - }) - - await expect( - createGitHubPullRequest( - '/repo-root', - { - provider: 'github', - base: 'main', - head: 'feature/create-pr', - title: 'GHES PR' - }, - 'local' - ) - ).resolves.toMatchObject({ - ok: false, - code: 'already_exists', - existingReview: { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' } - }) - - const [listArgs, listOptions] = ghExecFileAsyncMock.mock.calls[1] - expect(listArgs).toEqual(expect.arrayContaining(['pr', 'list'])) - expect(listArgs[listArgs.indexOf('--repo') + 1]).toBe('team/orca') - expect(listOptions).toMatchObject({ host: 'github.acme-corp.com' }) - }) - - it('runs local WSL project pull request creation through the selected distro', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 43, - url: 'https://github.com/acme/widgets/pull/43' - }) - }) - - await expect( - createGitHubPullRequest( - '/repo-root', - { - provider: 'github', - base: 'main', - head: 'feature/wsl-create-pr', - title: 'WSL Create PR' - }, - 'local', - { localGitExecOptions: { wslDistro: 'Ubuntu' } } - ) - ).resolves.toEqual({ - ok: true, - number: 43, - url: 'https://github.com/acme/widgets/pull/43' - }) - - const [, options] = ghExecFileAsyncMock.mock.calls[0] - expect(options).toMatchObject({ - cwd: '/repo-root', - wslDistro: 'Ubuntu', - timeout: 60_000, - idempotent: false - }) - }) - - it('creates SSH-backed pull requests without using the remote path as a local cwd', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 45, - url: 'https://github.com/acme/widgets/pull/45' - }) - }) - - await expect( - createGitHubPullRequest( - '/remote/repo-root', - { - provider: 'github', - base: 'main', - head: 'feature/ssh-create-pr', - title: 'SSH Create PR' - }, - 'ssh:ssh-1' - ) - ).resolves.toEqual({ - ok: true, - number: 45, - url: 'https://github.com/acme/widgets/pull/45' - }) - - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( - '/remote/repo-root', - 'origin', - 'ssh-1', - {} - ) - const [args, options] = ghExecFileAsyncMock.mock.calls[0] - expect(args).toEqual( - expect.arrayContaining([ - 'pr', - 'create', - '--repo', - 'acme/widgets', - '--base', - 'main', - '--head', - 'feature/ssh-create-pr' - ]) - ) - expect(options).toMatchObject({ - timeout: 60_000, - idempotent: false - }) - expect(options).not.toHaveProperty('cwd') - }) - - it('falls back to parsing the PR URL for older gh output', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'https://github.com/acme/widgets/pull/43\n' - }) - - await expect( - createGitHubPullRequest( - '/repo-root', - { - provider: 'github', - base: 'main', - head: 'feature/url-output', - title: 'URL output' - }, - 'local' - ) - ).resolves.toEqual({ - ok: true, - number: 43, - url: 'https://github.com/acme/widgets/pull/43' - }) - }) - it('returns the existing PR when gh reports an already-open pull request', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock @@ -420,29 +177,4 @@ describe('createGitHubPullRequest', () => { { cwd: '/repo-root', host: 'github.com' } ]) }) - - it('validates base, head, and title before invoking gh', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - - await expect( - createGitHubPullRequest( - '/repo-root', - { - provider: 'github', - base: 'refs/heads/feature', - head: 'feature', - title: 'Feature' - }, - 'local' - ) - ).resolves.toEqual({ - ok: false, - code: 'validation', - error: 'Create PR failed: choose a different base branch before creating a pull request.', - reason: 'same_base_branch' - }) - - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(acquireMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 2e303664888..9e157f3a097 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -90,53 +90,10 @@ vi.mock('./github-api-repository', async (importOriginal) => { ) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions) } }) - -import { countWorkItems } from './client/list/count-work-items' import { getWorkItem } from './client/fetch/get-work-item' import { listWorkItems } from './client/list/list-work-items' import { _resetOwnerRepoCache } from './gh-utils' -const PR_LIST_FIELDS = - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' - -function issueSearchArgs( - ownerRepo: string, - options: { noCache?: boolean; query?: string } = {} -): string[] { - const query = options.query ?? 'is:issue is:open' - return [ - 'api', - ...(options.noCache ? [] : ['--cache', '120s']), - `search/issues?q=${encodeURIComponent(`repo:${ownerRepo} ${query}`)}&sort=created&order=desc&per_page=10&page=1`, - '--jq', - '.items' - ] -} - -function prListArgs(ownerRepo: string, query = 'is:open'): string[] { - return [ - 'pr', - 'list', - '--limit', - '10', - '--state', - 'all', - '--json', - PR_LIST_FIELDS, - '--repo', - ownerRepo, - '--search', - `${query} sort:created-desc` - ] -} - -function decodedIssueSearchPath(callIndex: number): string { - const args = ghExecFileAsyncMock.mock.calls[callIndex]?.[0] as string[] | undefined - const apiPath = args?.find((arg) => arg.startsWith('search/issues?')) - expect(apiPath).toBeDefined() - return decodeURIComponent(apiPath ?? '') -} - // Share origin fixtures while letting candidate tests opt into upstream. function mockUpstreamCandidate(upstream: { owner: string; repo: string } | null): void { getOwnerRepoForRemoteMock.mockImplementation( @@ -177,114 +134,6 @@ describe('GitHub issue source split', () => { _resetOwnerRepoCache() }) - it('uses upstream for issues and origin for PRs in mixed recent results', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 923, - title: 'Use upstream issues', - state: 'open', - html_url: 'https://github.com/stablyai/orca/issues/923', - labels: [], - updated_at: '2026-04-01T00:00:00Z', - user: { login: 'octocat' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Fork PR', - state: 'open', - html_url: 'https://github.com/fork/orca/pull/42', - labels: [], - updated_at: '2026-03-31T00:00:00Z', - user: { login: 'octocat' }, - draft: false, - head: { ref: 'feature' }, - base: { ref: 'main' } - } - ]) - }) - - await listWorkItems('/repo-root', 10) - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), { - cwd: '/repo-root' - }) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), { - cwd: '/repo-root' - }) - }) - - it('omits gh api cache args for no-cache recent work-item requests', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - await listWorkItems('/repo-root', 10, undefined, undefined, undefined, undefined, true) - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - issueSearchArgs('stablyai/orca', { noCache: true }), - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), { - cwd: '/repo-root' - }) - }) - - it('lists SSH repo work items with explicit owner/repo and no local cwd', async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - await listWorkItems('/home/jinwoo/orca', 10, undefined, undefined, 'auto', 'openclaw-2') - - expect(resolveIssueSourceMock).toHaveBeenCalledWith( - '/home/jinwoo/orca', - 'auto', - 'openclaw-2', - {} - ) - expect(getOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2', {}) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {}) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {}) - }) - - it('uses upstream for issue-only queries and origin for PR-only queries', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'is:issue') - - expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue') - - ghExecFileAsyncMock.mockClear() - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'is:pr') - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--repo', 'fork/orca']), - { cwd: '/repo-root' } - ) - }) - it.each(['is:issue', 'is:pr'])( 'propagates GitHub outages for scoped %s queries', async (query) => { @@ -314,198 +163,6 @@ describe('GitHub issue source split', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - it("uses upstream for recent PRs when preference='upstream'", async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('stablyai/orca'), { - cwd: '/repo-root' - }) - }) - - it("uses upstream for queried PRs when preference='upstream'", async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'is:pr is:open', undefined, 'upstream') - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--repo', 'stablyai/orca']), - { cwd: '/repo-root' } - ) - }) - - it("uses upstream for PR counts when preference='upstream'", async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '9\n' }) - - const count = await countWorkItems('/repo-root', 'is:pr is:open', 'upstream') - - expect(count).toBe(9) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent('repo:stablyai/orca is:pull-request is:open')}&per_page=1`, - '--jq', - '.total_count' - ], - { cwd: '/repo-root' } - ) - }) - - it("falls back to origin for PRs when preference='upstream' and upstream is missing", async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'fork', repo: 'orca' }, - fellBack: true - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - const result = await listWorkItems('/repo-root', 10, 'is:pr', undefined, 'upstream') - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--repo', 'fork/orca']), - { cwd: '/repo-root' } - ) - expect(result.sources).toEqual({ - issues: { owner: 'fork', repo: 'orca' }, - prs: { owner: 'fork', repo: 'orca' }, - originCandidate: { owner: 'fork', repo: 'orca' }, - upstreamCandidate: null - }) - expect(result.issueSourceFellBack).toBe(true) - }) - - it('counts default work items across upstream issues and origin PRs', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '7\n' }) - .mockResolvedValueOnce({ stdout: '5\n' }) - - const count = await countWorkItems('/repo-root') - - expect(count).toBe(12) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent('repo:stablyai/orca is:issue is:open')}&per_page=1`, - '--jq', - '.total_count' - ], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent('repo:fork/orca is:pull-request is:open')}&per_page=1`, - '--jq', - '.total_count' - ], - { cwd: '/repo-root' } - ) - }) - - it('typed PR lookup does not fetch an upstream issue with the same number', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: 'Origin PR', - state: 'open', - html_url: 'https://github.com/fork/orca/pull/42', - labels: [], - updated_at: '2026-04-02T00:00:00Z', - user: { login: 'octocat' }, - draft: false, - head: { ref: 'feature' }, - base: { ref: 'main' } - }) - }) - - const item = await getWorkItem('/repo-root', 42, 'pr') - - expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'pr', - 'view', - '42', - '--repo', - 'fork/orca', - '--json', - expect.stringContaining('reviewDecision') - ], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(item?.type).toBe('pr') - }) - - it('probes the upstream repository for a typed fork PR before origin', async () => { - const upstream = { owner: 'stablyai', repo: 'orca', host: 'github.com' } - const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [upstream, origin], - headRepo: origin - }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: 'Upstream PR', - state: 'open', - url: 'https://github.com/stablyai/orca/pull/42', - labels: [], - updatedAt: '2026-04-02T00:00:00Z', - author: { login: 'octocat' }, - isDraft: false - }) - }) - - const item = await getWorkItem('/repo-root', 42, 'pr') - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'pr', - 'view', - '42', - '--repo', - 'stablyai/orca', - '--json', - expect.stringContaining('reviewDecision') - ], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(item?.prRepo).toEqual(upstream) - }) - it('pins typed PR metadata to explicit origin when upstream has the same number', async () => { const upstream = { owner: 'stablyai', repo: 'orca', host: 'github.com' } const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } @@ -626,24 +283,6 @@ describe('GitHub issue source split', () => { expect(item?.type).toBe('pr') }) - it('surfaces a 403 from upstream issues through the listWorkItems envelope', async () => { - // Preserve source metadata with classified failures so the retry banner can name its repository. - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('HTTP 403: Resource not accessible by integration')) - .mockResolvedValueOnce({ stdout: '[]' }) - - const result = await listWorkItems('/repo-root', 10) - - expect(result.items).toEqual([]) - expect(result.sources).toMatchObject({ - issues: { owner: 'stablyai', repo: 'orca' }, - prs: { owner: 'fork', repo: 'orca' } - }) - expect(result.errors?.issues?.type).toBe('permission_denied') - }) - it('returns partial results when upstream issues fail but origin PRs succeed', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) @@ -682,134 +321,4 @@ describe('GitHub issue source split', () => { expect(getOwnerRepoMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - - describe('per-repo issue-source preference', () => { - it("preference='auto' + upstream exists → queries upstream", async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') - - expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'auto', undefined, {}) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), { - cwd: '/repo-root' - }) - expect(result.issueSourceFellBack).toBeUndefined() - }) - - it("preference='auto' + upstream exists → PRs query upstream too", async () => { - // Fork contribution PRs live upstream; automatic selection must query that namespace. - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - expect.arrayContaining(['--repo', 'stablyai/orca']), - { cwd: '/repo-root' } - ) - expect(result.sources).toEqual({ - issues: { owner: 'stablyai', repo: 'orca' }, - prs: { owner: 'stablyai', repo: 'orca' }, - originCandidate: { owner: 'fork', repo: 'orca' }, - upstreamCandidate: { owner: 'stablyai', repo: 'orca' } - }) - }) - - it('collapses the default count to one query when auto resolves both sides to upstream', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '11\n' }) - - const count = await countWorkItems('/repo-root') - - expect(count).toBe(11) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent('repo:stablyai/orca is:open')}&per_page=1`, - '--jq', - '.total_count' - ], - { cwd: '/repo-root' } - ) - }) - - it("preference='upstream' + no upstream → falls back to origin with fellBack=true", async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'solo', repo: 'orca' }, - fellBack: true - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'solo', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - - expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open') - expect(result.issueSourceFellBack).toBe(true) - }) - - it('surfaces upstreamCandidate in sources regardless of effective preference', async () => { - // Preserve raw upstream metadata so the selector can still switch back after choosing origin. - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'fork', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') - - expect(result.sources).toEqual({ - issues: { owner: 'fork', repo: 'orca' }, - prs: { owner: 'fork', repo: 'orca' }, - originCandidate: { owner: 'fork', repo: 'orca' }, - upstreamCandidate: { owner: 'stablyai', repo: 'orca' } - }) - }) - - it('keeps raw origin metadata when effective PR source is upstream', async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - - expect(result.sources).toEqual({ - issues: { owner: 'stablyai', repo: 'orca' }, - prs: { owner: 'stablyai', repo: 'orca' }, - originCandidate: { owner: 'fork', repo: 'orca' }, - upstreamCandidate: { owner: 'stablyai', repo: 'orca' } - }) - }) - }) }) diff --git a/src/main/github/client-merge-queue-auto-merge.test.ts b/src/main/github/client-merge-queue-auto-merge.test.ts index b1040764abb..490a0eb8213 100644 --- a/src/main/github/client-merge-queue-auto-merge.test.ts +++ b/src/main/github/client-merge-queue-auto-merge.test.ts @@ -39,13 +39,10 @@ vi.mock('./github-api-repository', async (importOriginal) => await importOriginal() ) ) - -import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { getWorkItemByOwnerRepo } from './client/fetch/get-work-item' import { mergePR } from './client/merge/merge-pr' import { resolveReviewThread } from './client/update/resolve-review-thread' import { setPRAutoMerge } from './client/merge/pr-auto-merge' -import { updatePRTitle } from './client/update/pr-details' import { _getMergeQueueCacheSizeForTests } from './client/detect/repository-merge-metadata-cache' import { installIndependentCheckFixtures, @@ -53,7 +50,6 @@ import { } from './client-test-harness' const { - getOwnerRepoMock, resolvePRRepositoryCandidatesMock, gitExecFileAsyncMock, rateLimitGuardMock, @@ -73,158 +69,6 @@ describe('GitHub GraphQL rate-limit guard', () => { afterEach(() => vi.restoreAllMocks()) - it('uses explicit PR repo for merge and title mutations', async () => { - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'PR', - state: 'open', - head: { ref: 'feature', sha: 'head-oid' }, - base: { ref: 'main', sha: 'base-oid' }, - stack: null - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/7', - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - }) - }) - .mockResolvedValue({ stdout: '', stderr: '' }) - - await expect( - mergePR('/repo-root', 7, 'squash', undefined, { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toEqual({ ok: true }) - await expect( - updatePRTitle('/repo-root', 7, ' New title @literal ', undefined, { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toBe(true) - - expect(getOwnerRepoMock).not.toHaveBeenCalled() - expect(metadataRequests).toHaveBeenNthCalledWith( - 1, - ['api', 'repos/stablyai/orca/pulls/7', '--include'], - { - cwd: '/repo-root', - host: 'github.com' - } - ) - expect(metadataRequests).toHaveBeenNthCalledWith( - 2, - [ - 'pr', - 'view', - '7', - '--repo', - 'stablyai/orca', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(metadataRequests).toHaveBeenNthCalledWith( - 3, - ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], - expect.objectContaining({ - cwd: '/repo-root', - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), - host: 'github.com' - }) - ) - expect(metadataRequests).toHaveBeenNthCalledWith( - 4, - [ - 'api', - '-X', - 'PATCH', - 'repos/stablyai/orca/pulls/7', - '--raw-field', - 'title= New title @literal ' - ], - { cwd: '/repo-root', host: 'github.com' } - ) - }) - - it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => { - metadataRequests - .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) - }) - .mockResolvedValue({ stdout: '', stderr: '' }) - - await expect( - setPRAutoMerge('/remote/repo-root', 7, true, 'squash', 'ssh-1', { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toEqual({ ok: true }) - await expect( - setPRAutoMerge('/remote/repo-root', 7, false, 'squash', 'ssh-1', { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toEqual({ ok: true }) - - expect(metadataRequests).toHaveBeenNthCalledWith( - 1, - ['api', 'repos/stablyai/orca/pulls/7', '--include'], - { - host: 'github.com' - } - ) - expect(metadataRequests).toHaveBeenNthCalledWith( - 2, - ['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'], - { host: 'github.com' } - ) - expect(metadataRequests).toHaveBeenNthCalledWith( - 3, - expect.arrayContaining([ - 'api', - 'graphql', - '-f', - 'pullRequestId=PR_kwDO123', - '-f', - 'mergeMethod=SQUASH', - '-f', - 'expectedHeadOid=head-oid' - ]), - expect.objectContaining({ - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), - host: 'github.com' - }) - ) - expect(metadataRequests).toHaveBeenNthCalledWith( - 4, - ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], - expect.objectContaining({ - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), - host: 'github.com' - }) - ) - expect(metadataRequests.mock.calls[0]?.[1]).not.toHaveProperty('cwd') - }) - it('enables auto-merge without invoking the direct merge command', async () => { metadataRequests .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) @@ -284,26 +128,6 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(metadataRequests).toHaveBeenCalledTimes(1) }) - it('translates the GitHub clean-status rejection into an actionable message', async () => { - metadataRequests - .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) - }) - .mockRejectedValueOnce(new Error('GraphQL: Pull request is in clean status')) - - await expect( - setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toMatchObject({ - ok: false, - error: 'This pull request can already be merged. Use Merge instead of auto-merge.' - }) - }) - it('preserves a provider rejection when eligibility changes before enabling auto-merge', async () => { const providerError = 'gh: Pull request Pull request is in unstable status (enablePullRequestAutoMerge)' @@ -329,142 +153,6 @@ describe('GitHub GraphQL rate-limit guard', () => { ).toBe(false) }) - it('names a failed disable-auto-merge operation', async () => { - metadataRequests.mockRejectedValueOnce(new Error('gh: pull request is already closed')) - await expect( - setPRAutoMerge('/repo-root', 7, false, 'squash', undefined, { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toMatchObject({ - ok: false, - error: 'Failed to disable auto-merge for pull request: gh: pull request is already closed', - errorCode: 'github.autoMergeDisableFailed', - errorDetail: 'gh: pull request is already closed' - }) - }) - - it('preserves REST merge eligibility and an existing auto-merge request in PR details', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - metadataRequests - .mockRejectedValueOnce(new Error('GraphQL unavailable')) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'PR', - state: 'open', - html_url: 'https://github.com/stablyai/orca/pull/7', - mergeable: true, - mergeable_state: 'blocked', - auto_merge: { enabled_by: { login: 'octocat' }, merge_method: 'squash' }, - head: { ref: 'feature', sha: 'head-oid' }, - base: { ref: 'main' } - }) - }) - .mockResolvedValueOnce({ stdout: JSON.stringify({ reviewRequests: [], latestReviews: [] }) }) - - await expect( - getWorkItemByOwnerRepo( - '/repo-root', - { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }, - 7, - 'pr' - ) - ).resolves.toMatchObject({ - state: 'open', - mergeable: 'MERGEABLE', - mergeStateStatus: 'BLOCKED', - autoMergeEnabled: true - }) - }) - - it('uses the queue-aware gh merge path when the base branch has a merge queue', async () => { - metadataRequests - .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) - }) - .mockResolvedValue({ stdout: '', stderr: '' }) - - await expect( - setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toEqual({ ok: true }) - - expect(metadataRequests).toHaveBeenNthCalledWith( - 3, - expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']), - { cwd: '/repo-root', host: 'github.com' } - ) - expect(metadataRequests).toHaveBeenNthCalledWith( - 4, - ['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'], - expect.objectContaining({ - cwd: '/repo-root', - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), - host: 'github.com' - }) - ) - expect( - metadataRequests.mock.calls.some((call) => - (call[0] as string[]).some((arg) => arg.includes('enablePullRequestAutoMerge')) - ) - ).toBe(false) - }) - - it('enqueues instead of gh pr merge --auto when a required merge queue disallows auto-merge', async () => { - metadataRequests - .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { repository: { mergeQueue: { id: 'MQ_kw' }, autoMergeAllowed: false } } - }) - }) - .mockResolvedValue({ stdout: '', stderr: '' }) - - await expect( - setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toEqual({ ok: true }) - - expect(metadataRequests).toHaveBeenCalledTimes(4) - expect(metadataRequests).toHaveBeenNthCalledWith( - 4, - [ - 'api', - 'graphql', - '-f', - expect.stringContaining('enqueuePullRequest('), - '-f', - 'pullRequestId=PR_kwDO123', - '-f', - 'expectedHeadOid=head-oid' - ], - expect.objectContaining({ - cwd: '/repo-root', - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), - host: 'github.com' - }) - ) - }) - it('blocks direct merge when GitHub reports required approval', async () => { metadataRequests .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) @@ -552,174 +240,6 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(metadataRequests.mock.calls[2]?.[0]).not.toContain('-F') }) - it('caches unknown merge queue probes after GraphQL failures', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - const prView = { - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/7', - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - autoMergeRequest: null, - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - } - metadataRequests - .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) - .mockRejectedValueOnce(new Error('network is down')) - .mockResolvedValueOnce({ stdout: '{}' }) - .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) - .mockResolvedValueOnce({ stdout: '{}' }) - - await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ - mergeQueueRequired: null - }) - await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ - mergeQueueRequired: null - }) - - expect(metadataRequests.mock.calls.filter((call) => call[0].includes('graphql'))).toHaveLength( - 1 - ) - }) - - it('reuses repository metadata between PR details and sidebar refreshes', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - const prView = { - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/7', - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - autoMergeRequest: null, - baseRefName: 'main', - headRefOid: 'head-oid' - } - metadataRequests.mockImplementation(async (args: string[]) => { - if (args.includes('graphql')) { - return { - stdout: JSON.stringify({ - data: { repository: { autoMergeAllowed: true, mergeQueue: { id: 'queue-id' } } } - }) - } - } - return { stdout: JSON.stringify(args[0] === 'pr' ? prView : { stack: null }) } - }) - - await expect( - getWorkItemByOwnerRepo('/repo-root', { owner: 'stablyai', repo: 'orca' }, 7, 'pr') - ).resolves.toMatchObject({ mergeQueueRequired: true, autoMergeAllowed: true }) - await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ - mergeQueueRequired: true, - autoMergeAllowed: true - }) - - expect(metadataRequests.mock.calls.filter(([args]) => args.includes('graphql'))).toHaveLength(1) - }) - - it('bounds merge metadata cache entries across many base branches', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - let prViewCount = 0 - metadataRequests.mockImplementation(async (args) => { - if (args.includes('graphql')) { - return { stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } }) } - } - prViewCount += 1 - return { - stdout: JSON.stringify({ - number: prViewCount, - title: 'PR', - state: 'OPEN', - url: `https://github.com/stablyai/orca/pull/${prViewCount}`, - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - autoMergeRequest: null, - baseRefName: `base-${prViewCount}`, - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - }) - } - }) - - for (let i = 0; i < 260; i++) { - await getPRForBranch('/repo-root', `feature/${i}`, i + 1) - } - - expect(_getMergeQueueCacheSizeForTests()).toBe(256) - }) - - it('isolates merge metadata for the same slug on different GitHub hosts', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [ - { owner: 'acme', repo: 'widgets', host: 'github.com' }, - { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } - ], - headRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } - }) - const prView = { - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/7', - statusCheckRollup: [], - updatedAt: '2026-07-16T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefOid: 'head-oid' - } - metadataRequests.mockImplementation(async (args, options) => { - if (args.includes('graphql')) { - const enterprise = options?.host === 'github.acme-corp.com' - return { - stdout: JSON.stringify({ - data: { - repository: { - mergeQueue: null, - autoMergeAllowed: !enterprise - } - } - }) - } - } - return { stdout: JSON.stringify(prView) } - }) - - const githubDotCom = await getWorkItemByOwnerRepo( - '/repo-root', - { owner: 'acme', repo: 'widgets', host: 'github.com' }, - 7, - 'pr' - ) - const enterprise = await getWorkItemByOwnerRepo( - '/repo-root', - { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, - 7, - 'pr' - ) - - expect(githubDotCom?.autoMergeAllowed).toBe(true) - expect(enterprise?.autoMergeAllowed).toBe(false) - const graphqlCalls = metadataRequests.mock.calls.filter(([args]) => args.includes('graphql')) - expect(graphqlCalls).toHaveLength(2) - expect(graphqlCalls[1]?.[1]).toEqual(expect.objectContaining({ host: 'github.acme-corp.com' })) - }) - it('rejects explicit work-item lookups outside configured repository candidates', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'acme', repo: 'widgets', host: 'github.com' }], @@ -783,46 +303,6 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(metadataRequests).toHaveBeenCalledTimes(2) }) - it('does not run merge conflict preflight for SSH-backed repos', async () => { - metadataRequests - .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/7', - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'CONFLICTING', - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - }) - }) - .mockResolvedValueOnce({ stdout: '', stderr: '' }) - - await expect( - mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toEqual({ ok: true }) - - expect(metadataRequests).toHaveBeenCalledTimes(3) - expect(metadataRequests).toHaveBeenNthCalledWith( - 3, - ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], - expect.objectContaining({ - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) - }) - ) - expect(metadataRequests.mock.calls[0]?.[1]).not.toHaveProperty('cwd') - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('blocks review-thread resolve mutations before spawning gh when GraphQL is low', async () => { rateLimitGuardMock.mockReturnValue({ blocked: true, diff --git a/src/main/github/client-merged-pr-visibility.test.ts b/src/main/github/client-merged-pr-visibility.test.ts deleted file mode 100644 index c06c76684fb..00000000000 --- a/src/main/github/client-merged-pr-visibility.test.ts +++ /dev/null @@ -1,542 +0,0 @@ -import type * as GitExecFileModule from '../git/command-runner/git-exec-file' -import type * as GhExecFileModule from '../git/command-runner/gh-exec-file' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as GithubApiRepositoryModule from './github-api-repository' -import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' - -const { clientMocks, moduleMocks } = await vi.hoisted(async () => { - const moduleMocks = await import('./client-test-mocks') - return { clientMocks: moduleMocks.createGitHubClientMocks(), moduleMocks } -}) - -vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) -vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) - -vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal()), - gitExecFileAsync: clientMocks.gitExecFileAsyncMock -})) - -vi.mock('../git/command-runner/gh-exec-file', async (importOriginal) => ({ - ...(await importOriginal()), - ghExecFileAsync: clientMocks.ghExecFileAsyncMock -})) -vi.mock('./local-git-config-signature', () => - moduleMocks.localGitConfigSignatureModuleMock(clientMocks) -) -vi.mock('./github-enterprise-repository', async (importOriginal) => - moduleMocks.githubEnterpriseRepositoryModuleMock( - await importOriginal() - ) -) -vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(clientMocks)) -vi.mock('./github-api-repository', async (importOriginal) => - moduleMocks.githubApiRepositoryModuleMock( - clientMocks, - await importOriginal() - ) -) - -import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' -import { getPRForBranch } from './client/lookup/get-pr-for-branch' -import { installIndependentCheckFixtures, resetPRForBranchMocks } from './client-test-harness' - -const { - getOwnerRepoMock, - resolvePRRepositoryCandidatesMock, - gitExecFileAsyncMock, - rateLimitGuardMock -} = clientMocks - -const metadataRequests = vi.fn() -const unexpectedChecks: string[][] = [] -afterEach(() => expect(unexpectedChecks).toEqual([])) - -describe('getPRForBranch', () => { - beforeEach(() => { - resetPRForBranchMocks(clientMocks) - installIndependentCheckFixtures(clientMocks, metadataRequests, unexpectedChecks) - }) - - it('ignores merged PRs discovered only by branch lookup when the branch moved on', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 5511, - title: 'Merged branch PR', - state: 'closed', - merged_at: '2026-06-16T17:15:33Z', - html_url: 'https://github.com/stablyai/orca/pull/5511', - updated_at: '2026-06-16T17:15:33Z', - draft: false, - mergeable_state: 'clean', - head: { ref: 'add-guide-for-mobile-emulator-use', sha: 'head-oid' }, - base: { ref: 'main', sha: 'base-oid' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 5511, - title: 'Merged branch PR', - state: 'MERGED', - url: 'https://github.com/stablyai/orca/pull/5511', - statusCheckRollup: [], - updatedAt: '2026-06-16T17:15:33Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'add-guide-for-mobile-emulator-use', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - }) - }) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'new-local-head-oid\n', - stderr: '' - }) - - const pr = await getPRForBranch('/repo-root', 'add-guide-for-mobile-emulator-use') - - expect(pr).toBeNull() - }) - - it('shows a merged branch PR when it still matches the current HEAD', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 5875, - title: 'Merged current branch PR', - state: 'closed', - merged_at: '2026-06-20T04:53:05Z', - html_url: 'https://github.com/acme/widgets/pull/5875', - updated_at: '2026-06-20T04:53:05Z', - draft: false, - mergeable_state: 'clean', - head: { ref: 'fix-tab-strip-layout-test', sha: 'current-head-oid' }, - base: { ref: 'main', sha: 'base-oid' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 5875, - title: 'Merged current branch PR', - state: 'MERGED', - url: 'https://github.com/acme/widgets/pull/5875', - statusCheckRollup: [], - updatedAt: '2026-06-20T04:53:05Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'fix-tab-strip-layout-test', - baseRefOid: 'base-oid', - headRefOid: 'current-head-oid' - }) - }) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'current-head-oid\n', - stderr: '' - }) - - const pr = await getPRForBranch('/repo-root', 'fix-tab-strip-layout-test') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rev-parse', 'HEAD'], { - cwd: '/repo-root' - }) - expect(pr).toMatchObject({ - number: 5875, - title: 'Merged current branch PR', - state: 'merged', - headSha: 'current-head-oid' - }) - }) - - const mockMergedBranchPRLookupBehindHead = (prNumber = 6011): void => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: prNumber, - title: 'Merged PR with unpulled final head', - state: 'closed', - merged_at: '2026-07-03T21:27:36Z', - html_url: `https://github.com/acme/widgets/pull/${prNumber}`, - updated_at: '2026-07-03T21:27:36Z', - draft: false, - mergeable_state: 'clean', - head: { ref: 'fix-hibernation-wake', sha: 'aaaa1111aaaa1111' }, - base: { ref: 'main', sha: 'base-oid' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: prNumber, - title: 'Merged PR with unpulled final head', - state: 'MERGED', - url: `https://github.com/acme/widgets/pull/${prNumber}`, - statusCheckRollup: [], - updatedAt: '2026-07-03T21:27:36Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'fix-hibernation-wake', - baseRefOid: 'base-oid', - headRefOid: 'aaaa1111aaaa1111' - }) - }) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'bbbb2222bbbb2222\n', - stderr: '' - }) - } - - it('shows a merged branch PR when the worktree head is one of its own commits', async () => { - mockMergedBranchPRLookupBehindHead() - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([{ number: 6011 }, { number: 42 }]) - }) - - const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake') - - expect(metadataRequests).toHaveBeenNthCalledWith( - 3, - ['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100&page=1'], - expect.anything() - ) - expect(pr).toMatchObject({ - number: 6011, - state: 'merged', - headSha: 'aaaa1111aaaa1111', - confirmedContainedHeadOid: 'bbbb2222bbbb2222' - }) - }) - - it('keeps hiding a merged branch PR when the head belongs to a different PR (reused branch)', async () => { - mockMergedBranchPRLookupBehindHead() - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([{ number: 42 }]) - }) - - const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake') - - expect(pr).toBeNull() - expect(metadataRequests).toHaveBeenCalledTimes(3) - - mockMergedBranchPRLookupBehindHead() - const second = await getPRForBranch('/repo-root', 'fix-hibernation-wake') - - expect(second).toBeNull() - expect(metadataRequests).toHaveBeenCalledTimes(5) - }) - - it('keeps hiding a merged branch PR when the commit membership probe fails', async () => { - mockMergedBranchPRLookupBehindHead() - metadataRequests.mockRejectedValueOnce(new Error('HTTP 422: No commit found')) - - const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake') - - expect(pr).toBeNull() - }) - - it('skips the membership probe while the core rate-limit budget is blocked', async () => { - mockMergedBranchPRLookupBehindHead() - rateLimitGuardMock.mockImplementation((bucket?: string) => - bucket === 'core' - ? { blocked: true, remaining: 0, limit: 5000, resetAt: 0 } - : { blocked: false } - ) - - const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake') - - expect(pr).toBeNull() - expect(metadataRequests).toHaveBeenCalledTimes(2) - }) - - it('reuses the cached membership answer instead of re-querying per poll', async () => { - mockMergedBranchPRLookupBehindHead() - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([{ number: 6011 }]) - }) - const first = await getPRForBranch('/repo-root', 'fix-hibernation-wake') - expect(first).toMatchObject({ number: 6011, confirmedContainedHeadOid: 'bbbb2222bbbb2222' }) - expect(metadataRequests).toHaveBeenCalledTimes(3) - - mockMergedBranchPRLookupBehindHead() - const second = await getPRForBranch('/repo-root', 'fix-hibernation-wake') - - expect(second).toMatchObject({ number: 6011, confirmedContainedHeadOid: 'bbbb2222bbbb2222' }) - expect(metadataRequests).toHaveBeenCalledTimes(5) - }) - - it('uses the caller-supplied worktree head for the membership probe without shelling out', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 6012, - title: 'Merged PR queried by checks panel', - state: 'closed', - merged_at: '2026-07-03T21:27:36Z', - html_url: 'https://github.com/acme/widgets/pull/6012', - updated_at: '2026-07-03T21:27:36Z', - draft: false, - mergeable_state: 'clean', - head: { ref: 'fix-hibernation-wake', sha: 'aaaa1111aaaa1111' }, - base: { ref: 'main', sha: 'base-oid' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 6012, - title: 'Merged PR queried by checks panel', - state: 'MERGED', - url: 'https://github.com/acme/widgets/pull/6012', - statusCheckRollup: [], - updatedAt: '2026-07-03T21:27:36Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'fix-hibernation-wake', - baseRefOid: 'base-oid', - headRefOid: 'aaaa1111aaaa1111' - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([{ number: 6012 }]) - }) - - const outcome = await getPRForBranchOutcome( - '/repo-root', - 'fix-hibernation-wake', - null, - null, - null, - { - currentHeadOid: 'cccc3333cccc3333' - } - ) - - // Use the supplied head without rev-parse; independent default-branch probes remain allowed. - expect( - gitExecFileAsyncMock.mock.calls.some( - (call) => call[0][0] === 'rev-parse' && call[0][1] === 'HEAD' - ) - ).toBe(false) - expect(outcome).toMatchObject({ - kind: 'found', - pr: { - number: 6012, - headRefName: 'fix-hibernation-wake', - confirmedContainedHeadOid: 'cccc3333cccc3333' - } - }) - }) - - function mockMergedLinkedPRLookup(prNumber = 7447) { - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [{ owner: 'acme', repo: 'widgets' }], - headRepo: { owner: 'acme', repo: 'widgets' } - }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: prNumber, - title: 'Merged linked PR', - state: 'MERGED', - url: `https://github.com/acme/widgets/pull/${prNumber}`, - statusCheckRollup: [], - updatedAt: '2026-07-03T21:27:36Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'old-linked-branch', - baseRefOid: 'base-oid', - headRefOid: 'aaaa1111aaaa1111' - }) - }) - } - - it('stamps confirmedContainedHeadOid for a linked merged PR when HEAD is its commit', async () => { - mockMergedLinkedPRLookup() - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([{ number: 7447 }]) - }) - - const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { - currentHeadOid: 'bbbb2222bbbb2222' - }) - - expect(outcome).toMatchObject({ - kind: 'found', - pr: { - number: 7447, - state: 'merged', - confirmedContainedHeadOid: 'bbbb2222bbbb2222' - } - }) - expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( - undefined - ) - }) - - it('stamps headDivergedFromMergedPRAtOid for a linked merged PR with a definite miss', async () => { - mockMergedLinkedPRLookup() - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([{ number: 42 }]) - }) - - const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { - currentHeadOid: 'bbbb2222bbbb2222' - }) - - expect(outcome).toMatchObject({ - kind: 'found', - pr: { - number: 7447, - state: 'merged', - headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' - } - }) - }) - - it('stamps linked merged divergence when a later membership page proves absence', async () => { - mockMergedLinkedPRLookup() - // A short second page proves absence after a full first page. - const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index })) - metadataRequests - .mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 2000 }]) }) - - const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { - currentHeadOid: 'bbbb2222bbbb2222' - }) - - expect(outcome).toMatchObject({ - kind: 'found', - pr: { number: 7447, state: 'merged', headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' } - }) - }) - - it('leaves linked merged divergence unset when membership pages stay full to the cap', async () => { - mockMergedLinkedPRLookup() - // Full pages through the cap cannot prove absence, so preserve the durable link. - const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index })) - for (let page = 0; page < 5; page += 1) { - metadataRequests.mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) }) - } - - const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { - currentHeadOid: 'bbbb2222bbbb2222' - }) - - expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) - expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( - undefined - ) - }) - - it('stamps linked merged divergence via the PR url when no repo candidates resolve', async () => { - // Without resolved candidates, derive the membership-probe repository from the PR URL. - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null }) - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7447, - title: 'Merged linked PR', - state: 'MERGED', - url: 'https://github.com/acme/widgets/pull/7447', - statusCheckRollup: [], - updatedAt: '2026-07-03T21:27:36Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'old-linked-branch', - baseRefOid: 'base-oid', - headRefOid: 'aaaa1111aaaa1111' - }) - }) - .mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 2000 }]) }) - - const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { - currentHeadOid: 'bbbb2222bbbb2222' - }) - - expect(outcome).toMatchObject({ - kind: 'found', - pr: { number: 7447, state: 'merged', headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' } - }) - expect(metadataRequests).toHaveBeenCalledWith( - ['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100&page=1'], - expect.anything() - ) - }) - - it('leaves linked merged divergence unset when the membership probe is rate-limited', async () => { - mockMergedLinkedPRLookup() - rateLimitGuardMock.mockImplementation((bucket?: string) => - bucket === 'core' - ? { blocked: true, remaining: 0, limit: 5000, resetAt: 0 } - : { blocked: false } - ) - - const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { - currentHeadOid: 'bbbb2222bbbb2222' - }) - - expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) - expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( - undefined - ) - expect(metadataRequests).toHaveBeenCalledTimes(2) - }) - - it('leaves linked merged divergence unset when the membership probe throws', async () => { - mockMergedLinkedPRLookup() - metadataRequests.mockRejectedValueOnce(new Error('HTTP 422: No commit found')) - - const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { - currentHeadOid: 'bbbb2222bbbb2222' - }) - - expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) - expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( - undefined - ) - }) - - it('leaves linked merged divergence unset when the membership probe returns a non-array payload', async () => { - mockMergedLinkedPRLookup() - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify({ message: 'Server Error' }) - }) - - const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { - currentHeadOid: 'bbbb2222bbbb2222' - }) - - expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) - expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( - undefined - ) - }) - - it('leaves linked merged divergence unset without a current head oid', async () => { - mockMergedLinkedPRLookup() - - const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null) - - expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) - expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( - undefined - ) - expect(metadataRequests).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/github/client-pr-branch-discovery.test.ts b/src/main/github/client-pr-branch-discovery.test.ts deleted file mode 100644 index 292ebd65e26..00000000000 --- a/src/main/github/client-pr-branch-discovery.test.ts +++ /dev/null @@ -1,584 +0,0 @@ -import { completePRCheckGraphQLResponse } from '../../shared/github/complete-pr-check-test-fixture' -import type * as GitExecFileModule from '../git/command-runner/git-exec-file' -import type * as GhExecFileModule from '../git/command-runner/gh-exec-file' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as GithubApiRepositoryModule from './github-api-repository' -import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' - -const { clientMocks, moduleMocks } = await vi.hoisted(async () => { - const moduleMocks = await import('./client-test-mocks') - return { clientMocks: moduleMocks.createGitHubClientMocks(), moduleMocks } -}) - -vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) -vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) - -vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal()), - gitExecFileAsync: clientMocks.gitExecFileAsyncMock -})) - -vi.mock('../git/command-runner/gh-exec-file', async (importOriginal) => ({ - ...(await importOriginal()), - ghExecFileAsync: clientMocks.ghExecFileAsyncMock -})) -vi.mock('./local-git-config-signature', () => - moduleMocks.localGitConfigSignatureModuleMock(clientMocks) -) -vi.mock('./github-enterprise-repository', async (importOriginal) => - moduleMocks.githubEnterpriseRepositoryModuleMock( - await importOriginal() - ) -) -vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(clientMocks)) -vi.mock('./github-api-repository', async (importOriginal) => - moduleMocks.githubApiRepositoryModuleMock( - clientMocks, - await importOriginal() - ) -) - -import { getPRForBranch } from './client/lookup/get-pr-for-branch' -import { resetPRForBranchMocks } from './client-test-harness' -import { canShowGitHubPRAutoMergeControl } from '../../shared/github/pull-request-auto-merge-availability' - -const { - execFileAsyncMock, - ghExecFileAsyncMock, - getOwnerRepoMock, - resolvePRRepositoryCandidatesMock -} = clientMocks - -describe('getPRForBranch', () => { - beforeEach(() => { - resetPRForBranchMocks(clientMocks) - }) - - it('publishes complete cancellation after REST discovery is replaced by exact hydration', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 7, - title: 'Cancelled run', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/7', - head: { ref: 'feature/test', sha: 'head-oid' }, - base: { ref: 'main' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'Cancelled run', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/7', - headRefOid: 'head-oid', - statusCheckRollup: [{ status: 'COMPLETED', conclusion: 'CANCELLED' }] - }) - }) - .mockResolvedValueOnce( - completePRCheckGraphQLResponse(7, 'head-oid', [ - { - __typename: 'CheckRun', - status: 'COMPLETED', - conclusion: 'CANCELLED', - checkSuite: { databaseId: 1 } - } - ]) - ) - const pr = await getPRForBranch('/repo-root', 'feature/test') - expect(ghExecFileAsyncMock.mock.calls.slice(0, 2).map(([args]) => args.slice(0, 2))).toEqual([ - ['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=10'], - ['pr', 'view'] - ]) - expect(pr).toMatchObject({ - checksStatus: 'failure', - headSha: 'head-oid', - checksSummary: { - state: 'failure', - total: 1, - passed: 0, - failed: 1, - cancelled: 1, - pending: 0, - neutral: 0 - } - }) - }) - - it('keeps branch-list fallback coarse when exact hydration cannot read a failure beyond page 100', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [{ owner: 'acme', repo: 'widgets' }], - headRepo: null - }) - const completeChecks = Array.from({ length: 101 }, (_, index) => ({ - status: 'COMPLETED', - conclusion: index === 100 ? 'FAILURE' : 'CANCELLED' - })) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'pr' && args[1] === 'list') { - return { - stdout: JSON.stringify([ - { - number: 7, - title: 'Partial check page', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/7', - headRefOid: 'head-oid', - statusCheckRollup: completeChecks.slice(0, 100) - } - ]) - } - } - throw new Error('network unavailable') - }) - const pr = await getPRForBranch('/repo-root', 'feature/test') - expect(ghExecFileAsyncMock.mock.calls.slice(0, 2).map(([args]) => args.slice(0, 2))).toEqual([ - ['pr', 'list'], - ['pr', 'view'] - ]) - expect(pr).toMatchObject({ - number: 7, - title: 'Partial check page', - headSha: 'head-oid', - checksStatus: 'neutral' - }) - expect(pr?.checksSummary).toBeUndefined() - }) - - it.each([ - { restEnabled: false, graphqlEnabled: true }, - { restEnabled: true, graphqlEnabled: false } - ])( - 'uses exact GraphQL auto-merge state ($graphqlEnabled) over REST discovery ($restEnabled)', - async ({ restEnabled, graphqlEnabled }) => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Fix PR discovery', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - updated_at: '2026-10-21T16:31:00Z', - draft: false, - mergeable: true, - mergeable_state: 'blocked', - auto_merge: restEnabled ? { merge_method: 'squash' } : null, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: 'Fix PR discovery', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/42', - updatedAt: '2026-10-21T16:32:00Z', - statusCheckRollup: [], - isDraft: false, - mergeable: 'MERGEABLE', - mergeStateStatus: 'BLOCKED', - autoMergeRequest: graphqlEnabled ? { enabledAt: '2026-10-21T16:32:00Z' } : null, - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefName: 'feature/test', - headRefOid: 'head-oid' - }) - }) - .mockResolvedValue({ - stdout: JSON.stringify({ - data: { - repository: { - autoMergeAllowed: true, - mergeQueue: null, - ref: { rules: { nodes: [{ type: 'PULL_REQUEST' }] } } - } - } - }) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(pr).toMatchObject({ number: 42, autoMergeEnabled: graphqlEnabled }) - } - ) - - it.each([ - ['unstable', true, false], - ['unstable', null, false], - ['blocked', true, true], - ['behind', true, true], - ['dirty', false, false], - ['unknown', null, false] - ] as const)( - 'preserves REST %s eligibility when exact GraphQL lookup fails (mergeable=%s)', - async (mergeable_state, mergeable, autoMergeAvailable) => { - getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('network unavailable')) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 31259, - title: 'fix(i18n): restore localized editor safety copy', - state: 'open', - html_url: 'https://github.com/stablyai/orca/pull/31259', - updated_at: '2026-10-21T16:31:00Z', - draft: false, - mergeable, - mergeable_state, - auto_merge: null, - base: { ref: 'main', sha: 'base-oid' }, - head: { - ref: 'fix/editor-safety-copy', - sha: 'e4353ead012043474fc723e50aea198a76d32979' - } - }) - }) - .mockResolvedValue({ - stdout: JSON.stringify({ - data: { - repository: { - autoMergeAllowed: true, - mergeQueue: null, - ref: { rules: { nodes: [{ type: 'PULL_REQUEST' }] } } - } - } - }) - }) - - const pr = await getPRForBranch('/repo-root', 'fix/editor-safety-copy', 31259) - expect(pr).toMatchObject({ - mergeStateStatus: mergeable_state.toUpperCase(), - autoMergeEnabled: false - }) - if (!pr) { - throw new Error('Expected a discovered PR') - } - expect(canShowGitHubPRAutoMergeControl(pr)).toBe(autoMergeAvailable) - } - ) - - it('does not infer auto-merge eligibility from a branch list result when exact lookup fails', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 31259, - title: 'fix(i18n): restore localized editor safety copy', - state: 'open', - html_url: 'https://github.com/stablyai/orca/pull/31259', - updated_at: '2026-10-21T16:31:00Z', - draft: false, - auto_merge: null, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'fix/editor-safety-copy', sha: 'e4353ead012043474fc723e50aea198a76d32979' } - } - ]) - }) - .mockRejectedValueOnce(new Error('network unavailable')) - - const pr = await getPRForBranch('/repo-root', 'fix/editor-safety-copy') - expect(pr).toMatchObject({ number: 31259, mergeable: 'UNKNOWN', autoMergeEnabled: false }) - expect(pr?.mergeStateStatus).toBeUndefined() - if (!pr) { - throw new Error('Expected a discovered PR') - } - expect(canShowGitHubPRAutoMergeControl(pr)).toBe(false) - }) - - it('queries GitHub by head branch when the remote is on github.com', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Fix PR discovery', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - ]) - }) - - const pr = await getPRForBranch('/repo-root', 'refs/heads/feature/test') - - expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', undefined) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=10', - '--include' - ], - { cwd: '/repo-root' } - ) - expect(pr?.number).toBe(42) - expect(pr?.state).toBe('open') - expect(pr?.mergeable).toBe('MERGEABLE') - expect(pr?.prRepo).toEqual({ owner: 'acme', repo: 'widgets' }) - expect(pr?.headRepo).toEqual({ owner: 'acme', repo: 'widgets' }) - expect(pr?.checksSummary).toBeUndefined() - }) - - it('resolves fork PRs from the upstream PR repo with the origin head owner', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [ - { owner: 'stablyai', repo: 'orca' }, - { owner: 'fork', repo: 'orca' } - ], - headRepo: { owner: 'fork', repo: 'orca' } - }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 1738, - title: 'Fork PR', - state: 'open', - html_url: 'https://github.com/stablyai/orca/pull/1738', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable_state: 'clean', - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - ]) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - 'repos/stablyai/orca/pulls?head=fork%3Afeature%2Ftest&state=all&per_page=10', - '--include' - ], - { cwd: '/repo-root' } - ) - expect(pr).toMatchObject({ - number: 1738, - prRepo: { owner: 'stablyai', repo: 'orca' }, - headRepo: { owner: 'fork', repo: 'orca' } - }) - }) - - it('uses REST branch lookup directly when origin head repo is known', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 43, - title: 'REST branch lookup', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/43', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - head: { ref: 'feature/test', sha: 'rest-head-oid' }, - base: { ref: 'main', sha: 'rest-base-oid' } - } - ]) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=10', - '--include' - ], - { cwd: '/repo-root' } - ) - expect(pr).toMatchObject({ - number: 43, - title: 'REST branch lookup', - state: 'open', - url: 'https://github.com/acme/widgets/pull/43', - checksStatus: 'neutral', - mergeable: 'MERGEABLE', - headSha: 'rest-head-oid' - }) - }) - - // GitHub serves a renamed repository under its old name, but reports the new name in the PR. - function primeRenamedOriginBranchLookup(headRepoName: string): void { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'old-widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 44, - title: 'Renamed repository lookup', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/44', - updated_at: '2026-10-28T00:00:00Z', - draft: false, - mergeable: true, - head: { - ref: 'feature/test', - sha: 'renamed-head-oid', - repo: { name: headRepoName, owner: { login: 'acme' } } - }, - base: { ref: 'main', sha: 'renamed-base-oid' } - } - ]) - }) - } - - it('keeps a same-repository PR when origin still uses the pre-rename URL', async () => { - primeRenamedOriginBranchLookup('widgets') - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - 'repos/acme/old-widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=10', - '--include' - ], - { cwd: '/repo-root' } - ) - expect(pr).toMatchObject({ number: 44, url: 'https://github.com/acme/widgets/pull/44' }) - }) - - it('still rejects a cross-repository head when origin uses the pre-rename URL', async () => { - primeRenamedOriginBranchLookup('other-widgets') - - expect(await getPRForBranch('/repo-root', 'feature/test')).toBeNull() - }) - - it('prefers the open PR over a newer closed PR on the same branch', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 5001, - title: 'Abandoned retry', - state: 'closed', - html_url: 'https://github.com/acme/widgets/pull/5001', - updated_at: '2026-03-29T00:00:00Z', - draft: false, - head: { ref: 'feature/test', sha: 'head-oid' }, - base: { ref: 'main', sha: 'base-oid' } - }, - { - number: 5000, - title: 'Reopened original', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/5000', - updated_at: '2026-03-30T00:00:00Z', - draft: false, - head: { ref: 'feature/test', sha: 'head-oid' }, - base: { ref: 'main', sha: 'base-oid' } - } - ]) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(pr).toMatchObject({ number: 5000, state: 'open' }) - }) - - it('falls back to the newest PR when none on the branch is open', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 5001, - title: 'Newer closed', - state: 'closed', - html_url: 'https://github.com/acme/widgets/pull/5001', - updated_at: '2026-03-29T00:00:00Z', - draft: false, - head: { ref: 'feature/test', sha: 'head-oid' }, - base: { ref: 'main', sha: 'base-oid' } - }, - { - number: 5000, - title: 'Older closed', - state: 'closed', - html_url: 'https://github.com/acme/widgets/pull/5000', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - head: { ref: 'feature/test', sha: 'head-oid' }, - base: { ref: 'main', sha: 'base-oid' } - } - ]) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(pr?.number).toBe(5001) - }) - - it('returns null for empty branch (e.g. during rebase with detached HEAD)', async () => { - const pr = await getPRForBranch('/repo-root', '') - expect(pr).toBeNull() - expect(execFileAsyncMock).not.toHaveBeenCalled() - }) - - it('returns null for refs/heads/ only branch (detached after strip)', async () => { - const pr = await getPRForBranch('/repo-root', 'refs/heads/') - expect(pr).toBeNull() - expect(execFileAsyncMock).not.toHaveBeenCalled() - }) - - it('uses fallback PR number for empty branch when detached', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: 'Detached fallback lookup', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/42', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - }) - }) - - const pr = await getPRForBranch('/repo-root', '', null, null, 42) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'pr', - 'view', - '42', - '--repo', - 'acme/widgets', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root' } - ) - expect(pr).toMatchObject({ number: 42, title: 'Detached fallback lookup' }) - }) - - it('returns null when pr list returns an empty array', async () => { - execFileAsyncMock - .mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' }) - .mockResolvedValueOnce({ stdout: '[]' }) - - const pr = await getPRForBranch('/repo-root', 'no-pr-branch') - - expect(pr).toBeNull() - }) -}) diff --git a/src/main/github/client-pr-check-details.test.ts b/src/main/github/client-pr-check-details.test.ts index 1eaed6fa6d4..ce233645e71 100644 --- a/src/main/github/client-pr-check-details.test.ts +++ b/src/main/github/client-pr-check-details.test.ts @@ -62,177 +62,6 @@ describe('getPRCheckDetails', () => { _resetOwnerRepoCache() }) - it('fetches check-run output, annotations, and workflow jobs for inline details', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - const actionUrl = 'https://github.com/acme/widgets/actions/runs/77/job/88' - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - name: 'track-community-pr', - status: 'completed', - conclusion: 'success', - html_url: actionUrl, - details_url: actionUrl, - started_at: '2026-05-18T19:00:00Z', - completed_at: '2026-05-18T19:02:00Z', - output: { - title: 'Successful', - summary: 'Tracked community PR', - text: 'No issues found.' - }, - check_suite: { workflow_run: { id: 77 } } - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - path: '.github/workflows/community.yml', - start_line: 12, - end_line: 12, - annotation_level: 'notice', - title: 'Tracked', - message: 'Community PR tracked.', - raw_details: 'details' - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - jobs: [ - { - id: 8801, - name: 'track-community-pr', - status: 'completed', - conclusion: 'success', - started_at: '2026-05-18T19:00:00Z', - completed_at: '2026-05-18T19:02:00Z', - html_url: actionUrl, - steps: [{ name: 'Run tracker', status: 'completed', conclusion: 'success' }] - } - ] - }) - }) - - const details = await getPRCheckDetails('/repo-root', { - checkRunId: 88, - checkName: 'track-community-pr' - }) - - expect(details).toMatchObject({ - name: 'track-community-pr', - status: 'completed', - conclusion: 'success', - title: 'Successful', - summary: 'Tracked community PR', - text: 'No issues found.', - annotations: [ - { - path: '.github/workflows/community.yml', - startLine: 12, - endLine: 12, - annotationLevel: 'notice', - title: 'Tracked', - message: 'Community PR tracked.', - rawDetails: 'details' - } - ], - jobs: [ - { - id: 8801, - name: 'track-community-pr', - conclusion: 'success', - logTail: null, - steps: [{ name: 'Run tracker', status: 'completed', conclusion: 'success' }] - } - ] - }) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - ['api', 'repos/acme/widgets/check-runs/88'], - expect.objectContaining({ - cwd: '/repo-root', - host: 'github.com', - signal: expect.any(AbortSignal) - }), - expect.objectContaining({ onTerminationPending: expect.any(Function) }) - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['api', 'repos/acme/widgets/check-runs/88/annotations?per_page=20'], - expect.objectContaining({ - cwd: '/repo-root', - host: 'github.com', - signal: expect.any(AbortSignal) - }), - expect.objectContaining({ onTerminationPending: expect.any(Function) }) - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, - ['api', 'repos/acme/widgets/actions/runs/77/jobs?per_page=100'], - expect.objectContaining({ - cwd: '/repo-root', - host: 'github.com', - signal: expect.any(AbortSignal) - }), - expect.objectContaining({ onTerminationPending: expect.any(Function) }) - ) - }) - - it.each([ - { status: 'completed', count: 0, calls: 1 }, - { status: 'in_progress', count: 0, calls: 2 }, - { status: 'completed', count: 1, calls: 2 }, - { status: 'completed', count: undefined, calls: 2 } - ])( - 'only skips authoritative empty annotations for a completed check: %j', - async ({ status, count, calls }) => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[1] === 'repos/acme/widgets/check-runs/88') { - return { - stdout: JSON.stringify({ - name: 'verify', - status, - conclusion: status === 'completed' ? 'failure' : null, - output: { title: 'Output retained', summary: 'Details', annotations_count: count } - }) - } - } - if (args[1] === 'repos/acme/widgets/check-runs/88/annotations?per_page=20') { - return { - stdout: JSON.stringify( - count === 1 - ? [ - { - path: 'src/index.ts', - start_line: 1, - end_line: 1, - annotation_level: 'failure', - message: 'Retained' - } - ] - : [] - ) - } - } - throw new Error(`Unexpected request: ${args.join(' ')}`) - }) - const details = await getPRCheckDetails('/repo-root', { checkRunId: 88 }) - expect(details).toMatchObject({ title: 'Output retained', summary: 'Details', status }) - expect(details?.annotations).toHaveLength(count === 1 ? 1 : 0) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(calls) - } - ) - - it('rejects provider failures so callers can distinguish them from unavailable details', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockRejectedValueOnce(new Error('authentication failed')) - - await expect(getPRCheckDetails('/repo-root', { checkRunId: 88 })).rejects.toThrow( - 'Could not load check details.' - ) - }) - it('aborts host work at the cumulative check-details deadline', async () => { vi.useFakeTimers() try { @@ -277,133 +106,4 @@ describe('getPRCheckDetails', () => { vi.useRealTimers() } }) - - it('fetches sliced log tails for failed workflow jobs only', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - const actionUrl = 'https://github.com/acme/widgets/actions/runs/77/job/88' - const logLines = Array.from({ length: 210 }, (_, index) => `line ${index} ${'x'.repeat(120)}`) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - name: 'verify', - status: 'completed', - conclusion: 'failure', - html_url: actionUrl, - details_url: actionUrl, - check_suite: { workflow_run: { id: 77 } } - }) - }) - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - jobs: [ - { - id: 8801, - name: 'verify', - status: 'completed', - conclusion: 'startup_failure', - completed_at: '2026-05-18T19:02:00Z', - html_url: actionUrl, - steps: [{ name: 'Run tests', status: 'completed', conclusion: 'startup_failure' }] - }, - { - id: 8802, - name: 'lint', - status: 'completed', - conclusion: 'success', - completed_at: '2026-05-18T19:02:00Z', - html_url: actionUrl, - steps: [{ name: 'Run lint', status: 'completed', conclusion: 'success' }] - } - ] - }) - }) - .mockResolvedValueOnce({ stdout: logLines.join('\n') }) - - const details = await getPRCheckDetails('/repo-root', { checkRunId: 88 }) - - expect(details?.jobs[0]).toMatchObject({ - id: 8801, - name: 'verify', - conclusion: 'startup_failure' - }) - expect(details?.jobs[0].logTail).toContain('line 209') - expect(details?.jobs[0].logTail).not.toContain('line 0') - expect(Buffer.from(details?.jobs[0].logTail ?? '', 'utf8').byteLength).toBeLessThanOrEqual( - 16 * 1024 - ) - expect(details?.jobs[1]).toMatchObject({ - id: 8802, - name: 'lint', - conclusion: 'success', - logTail: null - }) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 4, - ['api', 'repos/acme/widgets/actions/jobs/8801/logs', '--allow-escape-sequences'], - expect.objectContaining({ - cwd: '/repo-root', - host: 'github.com', - signal: expect.any(AbortSignal) - }), - expect.objectContaining({ onTerminationPending: expect.any(Function) }) - ) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(4) - }) - - it('isolates failed-job log tails for the same job ID on different GitHub hosts', async () => { - ghExecFileAsyncMock.mockImplementation(async (args: string[], options?: { host?: string }) => { - const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' - const enterprise = options?.host === 'github.acme-corp.com' - if (endpoint.endsWith('/check-runs/99')) { - return { - stdout: JSON.stringify({ - name: 'verify', - status: 'completed', - conclusion: 'failure', - check_suite: { workflow_run: { id: 77 } } - }) - } - } - if (endpoint.endsWith('/check-runs/99/annotations?per_page=20')) { - return { stdout: '[]' } - } - if (endpoint.endsWith('/actions/runs/77/jobs?per_page=100')) { - return { - stdout: JSON.stringify({ - jobs: [ - { - id: 9901, - name: 'verify', - status: 'completed', - conclusion: 'failure', - steps: [] - } - ] - }) - } - } - if (endpoint.endsWith('/actions/jobs/9901/logs')) { - return { stdout: enterprise ? 'enterprise log' : 'github.com log' } - } - throw new Error(`unexpected gh call: ${args.join(' ')}`) - }) - - const githubDotCom = await getPRCheckDetails('/repo-root', { - checkRunId: 99, - prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } - }) - const enterprise = await getPRCheckDetails('/repo-root', { - checkRunId: 99, - prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } - }) - - expect(githubDotCom?.jobs[0]?.logTail).toBe('github.com log') - expect(enterprise?.jobs[0]?.logTail).toBe('enterprise log') - const logCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => - args.some((arg) => arg.endsWith('/actions/jobs/9901/logs')) - ) - expect(logCalls).toHaveLength(2) - expect(logCalls[1]?.[1]).toEqual(expect.objectContaining({ host: 'github.acme-corp.com' })) - }) }) diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts deleted file mode 100644 index 48e603ac080..00000000000 --- a/src/main/github/client-pr-checks.test.ts +++ /dev/null @@ -1,835 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - execFileAsyncMock, - ghExecFileAsyncMock, - getOwnerRepoMock, - getIssueOwnerRepoMock, - gitExecFileAsyncMock, - extractExecErrorMock, - getRateLimitMock, - repositoryRateLimitGuardMock, - noteRepositoryRateLimitSpendMock, - spendsSharedGitHubComQuotaMock, - acquireMock, - releaseMock -} = vi.hoisted(() => ({ - execFileAsyncMock: vi.fn(), - ghExecFileAsyncMock: vi.fn(), - getOwnerRepoMock: vi.fn(), - getIssueOwnerRepoMock: vi.fn(), - gitExecFileAsyncMock: vi.fn(), - extractExecErrorMock: vi.fn((err: unknown) => { - if (err && typeof err === 'object') { - const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown } - return { - stderr: typeof e.stderr === 'string' ? e.stderr : String(e.message ?? err), - stdout: typeof e.stdout === 'string' ? e.stdout : '' - } - } - return { stderr: String(err), stdout: '' } - }), - getRateLimitMock: vi.fn(), - repositoryRateLimitGuardMock: vi.fn(() => ({ blocked: false })), - noteRepositoryRateLimitSpendMock: vi.fn(), - spendsSharedGitHubComQuotaMock: vi.fn< - (repository?: { host?: string } | null, options?: { wslDistro?: string }) => boolean - >(() => true), - acquireMock: vi.fn(), - releaseMock: vi.fn() -})) - -vi.mock('./gh-utils', () => ({ - execFileAsync: execFileAsyncMock, - ghExecFileAsync: ghExecFileAsyncMock, - githubRepoContext: ( - repoPath: string, - connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} - ) => ({ - repoPath, - connectionId: connectionId ?? null, - ...localGitOptions - }), - ghRepoExecOptions: (context: { repoPath: string; wslDistro?: string }) => ({ - cwd: context.repoPath, - ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) - }), - getOwnerRepo: getOwnerRepoMock, - getIssueOwnerRepo: getIssueOwnerRepoMock, - getOwnerRepoForRemote: ( - repoPath: string, - remoteName: string, - connectionId?: string | null, - localGitOptions?: unknown - ) => - remoteName === 'origin' - ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) - : Promise.resolve(null), - extractExecError: extractExecErrorMock, - acquire: acquireMock, - release: releaseMock, - _resetOwnerRepoCache: vi.fn() -})) - -vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal>()), - gitExecFileAsync: gitExecFileAsyncMock -})) - -vi.mock('./rate-limit', () => ({ - getRateLimit: getRateLimitMock, - repositoryRateLimitGuard: repositoryRateLimitGuardMock, - noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, - spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock -})) - -import { getPRChecks, getPRChecksViaRestFallback } from './client/check/get-pr-checks' -import { rerunPRChecks } from './client/check/rerun-pr-checks' -import { _resetOwnerRepoCache } from './gh-utils' - -import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' - -beforeEach(() => { - _resetOriginGitHubApiRepositoryCache() -}) - -function graphQLChecksResponse({ - contexts = [], - checkSuites = [], - headRefOid = 'head-oid', - commitOid = headRefOid -}: { - contexts?: unknown[] - checkSuites?: unknown[] - headRefOid?: string - commitOid?: string -} = {}): { stdout: string } { - return { - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - headRefOid, - commits: { - nodes: [ - { - commit: { - oid: commitOid, - statusCheckRollup: { contexts: { nodes: contexts } }, - checkSuites: { nodes: checkSuites } - } - } - ] - } - } - } - } - }) - } -} - -function graphQLCheckRun( - overrides: Partial<{ - databaseId: number - name: string - status: string - conclusion: string | null - detailsUrl: string | null - url: string | null - checkSuiteId: number | null - workflowRunId: number | null - }> = {} -): Record { - const checkSuiteId = overrides.checkSuiteId ?? 1000 - const workflowRunId = overrides.workflowRunId ?? 1 - return { - __typename: 'CheckRun', - databaseId: overrides.databaseId ?? 88, - name: overrides.name ?? 'build', - status: overrides.status ?? 'COMPLETED', - conclusion: overrides.conclusion ?? 'SUCCESS', - detailsUrl: overrides.detailsUrl ?? 'https://github.com/acme/widgets/actions/runs/1', - url: overrides.url ?? 'https://github.com/acme/widgets/runs/88', - checkSuite: { - databaseId: checkSuiteId, - workflowRun: workflowRunId === null ? null : { databaseId: workflowRunId } - } - } -} - -function graphQLStatusContext( - overrides: Partial<{ - context: string - state: string - targetUrl: string | null - }> = {} -): Record { - return { - __typename: 'StatusContext', - context: overrides.context ?? 'continuous-integration/jenkins/pr-merge', - state: overrides.state ?? 'PENDING', - targetUrl: overrides.targetUrl ?? 'https://jenkins.example.com/job/merge/1' - } -} - -function graphQLCheckSuite( - overrides: Partial<{ - databaseId: number - status: string - conclusion: string | null - url: string | null - app: { name?: string | null; slug?: string | null } | null - }> = {} -): Record { - const databaseId = overrides.databaseId ?? 1001 - return { - databaseId, - status: overrides.status ?? 'COMPLETED', - conclusion: overrides.conclusion ?? 'ACTION_REQUIRED', - url: - overrides.url ?? - `https://github.com/acme/widgets/commit/head-oid/checks?check_suite_id=${databaseId}`, - app: overrides.app ?? { name: 'GitHub Actions', slug: 'github-actions' } - } -} - -function expectGraphQLRollupCall(callIndex = 1, noCache = false): void { - const args = ghExecFileAsyncMock.mock.calls[callIndex - 1]?.[0] as string[] - expect(args.slice(0, 2)).toEqual(['api', 'graphql']) - expect(args).toEqual( - expect.arrayContaining(['-f', 'owner=acme', '-f', 'repo=widgets', '-F', 'pr=42']) - ) - if (noCache) { - expect(args).not.toContain('--cache') - } else { - expect(args).toEqual(expect.arrayContaining(['--cache', '60s'])) - } - const queryArg = args.find((arg) => arg.startsWith('query=')) - expect(queryArg).toContain('statusCheckRollup') - expect(queryArg).toContain('checkSuites') -} - -describe('getPRChecks', () => { - it('shares concurrent checks reads and honors explicit uncached refreshes', async () => { - vi.clearAllMocks() - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - acquireMock.mockResolvedValue(undefined) - const response = graphQLChecksResponse() - let finish: ((value: typeof response) => void) | undefined - ghExecFileAsyncMock.mockImplementation( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const first = getPRChecks('/repo', 12) - const second = getPRChecks('/repo', 12) - await vi.waitFor(() => expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)) - finish?.(response) - await expect(Promise.all([first, second])).resolves.toEqual([[], []]) - ghExecFileAsyncMock.mockResolvedValue(response) - await getPRChecks('/repo', 12, undefined, undefined, { noCache: true }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(ghExecFileAsyncMock.mock.calls[1][0]).not.toContain('--cache') - }) - - beforeEach(() => { - execFileAsyncMock.mockReset() - ghExecFileAsyncMock.mockReset() - getOwnerRepoMock.mockReset() - getIssueOwnerRepoMock.mockReset() - gitExecFileAsyncMock.mockReset() - extractExecErrorMock.mockClear() - getRateLimitMock.mockReset() - getRateLimitMock.mockResolvedValue({ resources: {} }) - repositoryRateLimitGuardMock.mockReset() - repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) - noteRepositoryRateLimitSpendMock.mockReset() - spendsSharedGitHubComQuotaMock.mockReset() - spendsSharedGitHubComQuotaMock.mockImplementation( - (repository?: { host?: string } | null, options?: { wslDistro?: string }) => - (!repository?.host || repository.host.toLowerCase() === 'github.com') && !options?.wslDistro - ) - acquireMock.mockReset() - releaseMock.mockReset() - acquireMock.mockResolvedValue(undefined) - _resetOwnerRepoCache() - }) - - it('starts an explicit checks read beside a pending one, which later reads then join', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - const response = graphQLChecksResponse() - const pending = Promise.withResolvers() - const explicit = Promise.withResolvers() - ghExecFileAsyncMock.mockReturnValueOnce(pending.promise).mockReturnValueOnce(explicit.promise) - const uncached = { noCache: true } - const before = getPRChecks('/repo', 12, 'head-a', undefined, uncached) - await vi.waitFor(() => expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)) - const click = getPRChecks('/repo', 12, 'head-a', undefined, { ...uncached, userRefresh: true }) - await vi.waitFor(() => expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)) - const later = getPRChecks('/repo', 12, 'head-a', undefined, uncached) - explicit.resolve(response) - pending.resolve(response) - await Promise.all([before, click, later]) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('queries GitHub rollup details with one cached GraphQL call', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: graphQLChecksResponse({ - contexts: [ - graphQLCheckRun({ - databaseId: 88, - name: 'build', - detailsUrl: 'https://github.com/acme/widgets/actions/runs/1' - }) - ] - }).stdout - }) - - const checks = await getPRChecks('/repo-root', 42, 'head-oid') - - expectGraphQLRollupCall() - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(checks).toEqual([ - { - name: 'build', - status: 'completed', - conclusion: 'success', - url: 'https://github.com/acme/widgets/actions/runs/1', - checkRunId: 88, - workflowRunId: 1 - } - ]) - }) - - it('merges rollup check-runs with legacy commit status contexts', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce( - graphQLChecksResponse({ - contexts: [ - graphQLCheckRun({ - databaseId: 88, - name: 'Summary', - detailsUrl: 'https://github.com/acme/widgets/actions/runs/88', - workflowRunId: 88 - }), - graphQLStatusContext(), - graphQLStatusContext({ - context: 'Summary', - state: 'SUCCESS', - targetUrl: 'https://example.com/duplicate-summary' - }) - ] - }) - ) - - const checks = await getPRChecks('/repo-root', 42, 'head-oid') - - expectGraphQLRollupCall() - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(checks).toEqual([ - { - name: 'Summary', - status: 'completed', - conclusion: 'success', - url: 'https://github.com/acme/widgets/actions/runs/88', - checkRunId: 88, - workflowRunId: 88 - }, - { - name: 'continuous-integration/jenkins/pr-merge', - status: 'queued', - conclusion: 'pending', - url: 'https://jenkins.example.com/job/merge/1' - } - ]) - }) - - it('surfaces an action_required check suite that has no check run', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce( - graphQLChecksResponse({ - contexts: [ - graphQLCheckRun({ - name: 'track-community-pr', - databaseId: 66, - checkSuiteId: 1000, - detailsUrl: 'https://github.com/acme/widgets/actions/runs/1' - }) - ], - checkSuites: [ - graphQLCheckSuite({ databaseId: 1000, conclusion: 'SUCCESS' }), - graphQLCheckSuite({ databaseId: 1001 }), - graphQLCheckSuite({ databaseId: 1002 }) - ] - }) - ) - - const checks = await getPRChecks('/repo-root', 42, 'head-oid') - - expectGraphQLRollupCall() - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(checks).toEqual([ - { - name: 'track-community-pr', - status: 'completed', - conclusion: 'success', - url: 'https://github.com/acme/widgets/actions/runs/1', - checkRunId: 66, - workflowRunId: 1 - }, - { - name: 'GitHub Actions #1001', - status: 'completed', - conclusion: 'action_required', - url: 'https://github.com/acme/widgets/commit/head-oid/checks?check_suite_id=1001' - }, - { - name: 'GitHub Actions #1002', - status: 'completed', - conclusion: 'action_required', - url: 'https://github.com/acme/widgets/commit/head-oid/checks?check_suite_id=1002' - } - ]) - }) - - it('maps stale and startup_failure conclusions to failure and action_required to its own state', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce( - graphQLChecksResponse({ - contexts: [ - graphQLCheckRun({ name: 'needs-approval', conclusion: 'ACTION_REQUIRED' }), - graphQLCheckRun({ name: 'old-run', conclusion: 'STALE' }), - graphQLCheckRun({ name: 'boot', conclusion: 'STARTUP_FAILURE' }) - ] - }) - ) - - const checks = await getPRChecks('/repo-root', 42, 'head-oid') - - expect(checks.map((check) => check.conclusion)).toEqual([ - 'action_required', - 'failure', - 'failure' - ]) - }) - - it('surfaces an action_required suite even when there are zero check runs', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce( - graphQLChecksResponse({ - checkSuites: [graphQLCheckSuite({ databaseId: 1001 })] - }) - ) - - const checks = await getPRChecks('/repo-root', 42, 'head-oid') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(checks).toEqual([ - { - name: 'GitHub Actions #1001', - status: 'completed', - conclusion: 'action_required', - url: 'https://github.com/acme/widgets/commit/head-oid/checks?check_suite_id=1001' - } - ]) - }) - - it('returns an empty list when the rollup has no checks or pending suites', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce(graphQLChecksResponse()) - - const checks = await getPRChecks('/repo-root', 42, 'head-oid') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(checks).toEqual([]) - }) - - it('uses REST fallback when the GraphQL rollup is unavailable', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('GraphQL rollup failed')) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - total_count: 1, - check_runs: [ - { - id: 88, - name: 'Summary', - status: 'completed', - conclusion: 'success', - html_url: 'https://github.com/acme/widgets/actions/runs/88', - details_url: null - } - ] - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - total_count: 1, - statuses: [ - { - context: 'continuous-integration/jenkins/pr-merge', - state: 'pending', - target_url: 'https://jenkins.example.com/job/merge/1' - } - ] - }) - }) - .mockResolvedValueOnce({ stdout: JSON.stringify({ total_count: 0, check_suites: [] }) }) - - const checks = await getPRChecks('/repo-root', 42, 'head-oid') - - expectGraphQLRollupCall() - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - '--cache', - '60s', - 'repos/acme/widgets/commits/head-oid/check-runs?per_page=100&page=1' - ], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, - ['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/status?per_page=100&page=1'], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(checks).toEqual([ - { - name: 'Summary', - status: 'completed', - conclusion: 'success', - url: 'https://github.com/acme/widgets/actions/runs/88', - checkRunId: 88, - workflowRunId: 88 - }, - { - name: 'continuous-integration/jenkins/pr-merge', - status: 'queued', - conclusion: 'pending', - url: 'https://jenkins.example.com/job/merge/1' - } - ]) - }) - - it('treats gh pr checks "no checks reported" as an empty fallback list', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('GraphQL rollup failed')) - .mockRejectedValueOnce( - Object.assign(new Error('Command failed: gh pr checks 42'), { - stderr: "no checks reported on the 'codex/keybindings-toml' branch\n", - stdout: '' - }) - ) - - const checks = await getPRChecks('/repo-root', 42) - - expect(checks).toEqual([]) - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'getPRChecks via GraphQL rollup failed, falling back to gh pr checks:', - expect.any(Error) - ) - consoleWarnSpy.mockRestore() - }) - - it('throws unexpected gh pr checks fallback failures so callers preserve cache', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('GraphQL rollup failed')) - .mockRejectedValueOnce( - Object.assign(new Error('Command failed: gh pr checks 42'), { - stderr: 'GraphQL: Could not resolve to a PullRequest', - stdout: '' - }) - ) - - await expect(getPRChecks('/repo-root', 42)).rejects.toThrow('Command failed: gh pr checks 42') - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'getPRChecks via GraphQL rollup failed, falling back to gh pr checks:', - expect.any(Error) - ) - expect(consoleWarnSpy).toHaveBeenCalledWith('getPRChecks failed:', expect.any(Error)) - consoleWarnSpy.mockRestore() - }) - - it('falls back to gh pr checks when GraphQL and REST details are unavailable', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('GraphQL rollup failed')) - .mockRejectedValueOnce(new Error('REST details failed')) - .mockResolvedValueOnce({ - stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: 'https://example.com/lint' }]) - }) - - const checks = await getPRChecks('/repo-root', 42, 'a'.repeat(40)) - - expectGraphQLRollupCall() - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, - ['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(checks).toEqual([ - { - name: 'lint', - status: 'completed', - conclusion: 'success', - url: 'https://example.com/lint', - workflowRunId: undefined - } - ]) - }) - - it('reruns GitHub Actions checks for a PR', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce( - graphQLChecksResponse({ - contexts: [ - graphQLCheckRun({ - name: 'lint', - conclusion: 'FAILURE', - detailsUrl: 'https://github.com/acme/widgets/actions/runs/77/job/88', - workflowRunId: 77 - }) - ] - }) - ) - .mockResolvedValueOnce({ stdout: '' }) - - const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true }) - - expect(result).toEqual({ ok: true, count: 1 }) - expectGraphQLRollupCall(1, true) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['api', '-X', 'POST', 'repos/acme/widgets/actions/runs/77/rerun-failed-jobs'], - { - cwd: '/repo-root', - env: { ...process.env, GH_PROMPT_DISABLED: '1' }, - host: 'github.com' - } - ) - }) - it('reports a resource-neutral not-found error when a workflow-run rerun 404s', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce( - graphQLChecksResponse({ - contexts: [ - graphQLCheckRun({ - name: 'lint', - conclusion: 'FAILURE', - detailsUrl: 'https://github.com/acme/widgets/actions/runs/77/job/88', - workflowRunId: 77 - }) - ] - }) - ) - .mockRejectedValueOnce( - Object.assign(new Error('Command failed: gh api'), { - stderr: 'gh: HTTP 404 Not Found (repos/acme/widgets/actions/runs/77/rerun-failed-jobs)', - stdout: '' - }) - ) - - const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true }) - - expect(result).toMatchObject({ - ok: false, - error: 'GitHub resource to rerun was not found — it may have expired or been deleted.' - }) - }) - - it('reports a resource-neutral not-found error when a standalone check-run rerequest 404s', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce( - graphQLChecksResponse({ - contexts: [ - { - __typename: 'CheckRun', - databaseId: 88, - name: 'external-ci', - status: 'COMPLETED', - conclusion: 'FAILURE', - detailsUrl: 'https://ci.example.com/builds/88', - url: 'https://ci.example.com/builds/88', - checkSuite: { databaseId: 1000, workflowRun: null } - } - ] - }) - ) - .mockRejectedValueOnce( - Object.assign(new Error('Command failed: gh api'), { - stderr: 'gh: HTTP 404 Not Found (repos/acme/widgets/check-runs/88/rerequest)', - stdout: '' - }) - ) - - const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true }) - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['api', '-X', 'POST', 'repos/acme/widgets/check-runs/88/rerequest'], - expect.objectContaining({ cwd: '/repo-root' }) - ) - expect(result).toMatchObject({ - ok: false, - error: 'GitHub resource to rerun was not found — it may have expired or been deleted.' - }) - }) - - it('routes local WSL check retrieval and reruns through the selected distro', async () => { - const localGitOptions = { wslDistro: 'Ubuntu' } - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce( - graphQLChecksResponse({ - contexts: [ - graphQLCheckRun({ - name: 'build', - detailsUrl: 'https://github.com/acme/widgets/actions/runs/66', - workflowRunId: 66 - }) - ] - }) - ) - .mockResolvedValueOnce( - graphQLChecksResponse({ - contexts: [ - graphQLCheckRun({ - name: 'lint', - conclusion: 'FAILURE', - detailsUrl: 'https://github.com/acme/widgets/actions/runs/77/job/88', - workflowRunId: 77 - }) - ] - }) - ) - .mockResolvedValueOnce({ stdout: '' }) - - await getPRChecks('/repo-root', 42, 'head-oid', undefined, undefined, null, localGitOptions) - await rerunPRChecks('/repo-root', 42, { failedOnly: true }, null, localGitOptions) - - expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) - expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( - true - ) - expect(ghExecFileAsyncMock).toHaveBeenLastCalledWith( - ['api', '-X', 'POST', 'repos/acme/widgets/actions/runs/77/rerun-failed-jobs'], - expect.objectContaining({ - cwd: '/repo-root', - wslDistro: 'Ubuntu', - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) - }) - ) - expect(getRateLimitMock).not.toHaveBeenCalled() - expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( - { owner: 'acme', repo: 'widgets', host: 'github.com' }, - 'graphql', - expect.objectContaining({ cwd: '/repo-root', wslDistro: 'Ubuntu', host: 'github.com' }) - ) - }) - - it('uses explicit PR repo for rollup and gh pr checks fallback', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'widgets' }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('GraphQL rollup failed')) - .mockResolvedValueOnce({ - stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: 'https://example.com/lint' }]) - }) - - await getPRChecks('/repo-root', 42, undefined, { - owner: 'acme', - repo: 'widgets', - host: 'github.com' - }) - - expect(getOwnerRepoMock).not.toHaveBeenCalled() - expectGraphQLRollupCall() - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'], - { cwd: '/repo-root', host: 'github.com' } - ) - }) - - it('throws when both GraphQL rollup and gh pr checks fail', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('GraphQL rollup failed')) - .mockRejectedValueOnce(new Error('rate limited')) - - await expect(getPRChecks('/repo-root', 42)).rejects.toThrow('rate limited') - }) -}) - -it('carries the actual matching GraphQL commit identity rather than the requested head', async () => { - vi.clearAllMocks() - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce( - graphQLChecksResponse({ - headRefOid: 'b'.repeat(40), - contexts: [{ __typename: 'StatusContext', context: 'Build', state: 'SUCCESS' }] - }) - ) - const checks = await getPRChecks('/repo-root', 42, 'a'.repeat(40)) - expect(JSON.parse(JSON.stringify(checks))).toEqual([ - { - name: 'Build', - status: 'completed', - conclusion: 'success', - url: null, - observedHeadSha: 'b'.repeat(40) - } - ]) - ghExecFileAsyncMock.mockResolvedValueOnce( - graphQLChecksResponse({ - headRefOid: 'a'.repeat(40), - commitOid: 'b'.repeat(40), - contexts: [{ __typename: 'StatusContext', context: 'Build', state: 'SUCCESS' }] - }) - ) - expect((await getPRChecks('/repo-root', 42, 'a'.repeat(40)))[0].observedHeadSha).toBeUndefined() -}) - -it('carries immutable REST endpoint identity through useful incomplete enrichment', async () => { - vi.clearAllMocks() - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - total_count: 1, - check_runs: [ - { - id: 88, - name: 'Build', - status: 'completed', - conclusion: 'failure', - html_url: '', - details_url: null - } - ] - }) - }) - .mockRejectedValueOnce(new Error('statuses unavailable')) - .mockResolvedValueOnce({ stdout: JSON.stringify({ total_count: 0, check_suites: [] }) }) - const checks = await getPRChecksViaRestFallback( - { owner: 'acme', repo: 'widgets', host: 'github.com' }, - 'a'.repeat(40), - { cwd: '/repo-root' }, - true - ) - expect(checks).toMatchObject([{ name: 'Build', observedHeadSha: 'a'.repeat(40) }]) - expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( - `repos/acme/widgets/commits/${'a'.repeat(40)}/check-runs?per_page=100&page=1` - ) -}) diff --git a/src/main/github/client-pr-comment-reactions.test.ts b/src/main/github/client-pr-comment-reactions.test.ts deleted file mode 100644 index edb90d43779..00000000000 --- a/src/main/github/client-pr-comment-reactions.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import type * as GitExecFileModule from '../git/command-runner/git-exec-file' -import type * as GhExecFileModule from '../git/command-runner/gh-exec-file' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as GithubApiRepositoryModule from './github-api-repository' -import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' - -const { clientMocks, moduleMocks } = await vi.hoisted(async () => { - const moduleMocks = await import('./client-test-mocks') - return { clientMocks: moduleMocks.createGitHubClientMocks(), moduleMocks } -}) - -vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) -vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) - -vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal()), - gitExecFileAsync: clientMocks.gitExecFileAsyncMock -})) - -vi.mock('../git/command-runner/gh-exec-file', async (importOriginal) => ({ - ...(await importOriginal()), - ghExecFileAsync: clientMocks.ghExecFileAsyncMock -})) -vi.mock('./local-git-config-signature', () => - moduleMocks.localGitConfigSignatureModuleMock(clientMocks) -) -vi.mock('./github-enterprise-repository', async (importOriginal) => - moduleMocks.githubEnterpriseRepositoryModuleMock( - await importOriginal() - ) -) -vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(clientMocks)) -vi.mock('./github-api-repository', async (importOriginal) => - moduleMocks.githubApiRepositoryModuleMock( - clientMocks, - await importOriginal() - ) -) - -import { getPRComments } from './client/fetch/get-pr-comments' -import { setPRCommentReaction } from './client/update/pr-comment-reaction' -import { resetGraphQLRateLimitGuardMocks } from './client-test-harness' -import type { RateLimitGuardResult } from './client-test-mocks' - -const { ghExecFileAsyncMock, getOwnerRepoMock, rateLimitGuardMock, noteRateLimitSpendMock } = - clientMocks - -describe('GitHub GraphQL rate-limit guard', () => { - beforeEach(() => { - resetGraphQLRateLimitGuardMocks(clientMocks) - }) - - afterEach(() => vi.restoreAllMocks()) - - it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => { - rateLimitGuardMock.mockImplementation(((bucket: string) => - bucket === 'graphql' - ? { blocked: true, remaining: 4, limit: 5000, resetAt: 1_800_000_000 } - : { blocked: false }) as () => RateLimitGuardResult) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - id: 10, - user: { login: 'octo', avatar_url: 'https://avatar', type: 'User' }, - body: 'top-level', - created_at: '2026-04-01T00:00:00Z', - html_url: 'https://github.com/acme/widgets/pull/7#issuecomment-10' - } - ]) - }) - .mockResolvedValueOnce({ stdout: '[]' }) - - const comments = await getPRComments('/repo-root', 7) - - expect(comments).toHaveLength(1) - expect(comments[0].body).toBe('top-level') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false) - expect(noteRateLimitSpendMock).not.toHaveBeenCalledWith('graphql') - }) - - it('maps review summary reaction subjects from GraphQL', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - reviewThreads: { nodes: [] }, - comments: { nodes: [] }, - reviews: { - nodes: [ - { - id: 'PRR_44', - databaseId: 44, - author: { - __typename: 'Bot', - login: 'coderabbitai', - avatarUrl: 'https://avatar' - }, - body: 'Automated review summary', - createdAt: '2026-04-01T00:00:00Z', - url: 'https://github.com/acme/widgets/pull/7#pullrequestreview-44', - reactionGroups: [ - { - content: 'ROCKET', - viewerHasReacted: true, - reactors: { totalCount: 2 } - } - ] - } - ] - } - } - } - } - }) - }) - .mockResolvedValueOnce({ stdout: '[]' }) - .mockResolvedValueOnce({ stdout: '[]' }) - - await expect(getPRComments('/repo-root', 7)).resolves.toEqual([ - expect.objectContaining({ - id: 44, - reactionSubjectId: 'PRR_44', - isBot: true, - reactions: [{ content: 'rocket', count: 2, viewerHasReacted: true }] - }) - ]) - expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual( - expect.arrayContaining([expect.stringContaining('reviews(first: 100)')]) - ) - }) - - it('uses explicit PR repo for comments when a fork PR is discovered', async () => { - rateLimitGuardMock.mockImplementation(((bucket: string) => - bucket === 'graphql' - ? { blocked: true, remaining: 4, limit: 5000, resetAt: 1_800_000_000 } - : { blocked: false }) as () => RateLimitGuardResult) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - id: 10, - user: { login: 'octo', avatar_url: 'https://avatar', type: 'User' }, - body: 'top-level', - created_at: '2026-04-01T00:00:00Z', - html_url: 'https://github.com/stablyai/orca/pull/7#issuecomment-10' - } - ]) - }) - .mockResolvedValueOnce({ stdout: '[]' }) - - await getPRComments( - '/repo-root', - 7, - { prRepo: { owner: 'stablyai', repo: 'orca', host: 'github.com' } }, - undefined - ) - - expect(getOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - ['api', '--cache', '60s', 'repos/stablyai/orca/issues/7/comments?per_page=100'], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['api', '--cache', '60s', 'repos/stablyai/orca/pulls/7/reviews?per_page=100'], - { cwd: '/repo-root', host: 'github.com' } - ) - }) - - it('returns GraphQL reaction subjects and viewer state for PR comments', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - comments: { - nodes: [ - { - id: 'IC_1', - databaseId: 10, - author: { login: 'octo', avatarUrl: '', __typename: 'User' }, - body: 'Issue comment', - createdAt: '2026-04-01T00:00:00Z', - url: 'https://example.test/issue-comment', - reactionGroups: [ - { - content: 'THUMBS_UP', - viewerHasReacted: true, - reactors: { totalCount: 2 } - } - ] - } - ] - }, - reviewThreads: { - nodes: [ - { - id: 'PRRT_1', - isResolved: false, - line: 4, - startLine: null, - originalLine: 4, - originalStartLine: null, - comments: { - nodes: [ - { - id: 'PRRC_1', - databaseId: 11, - author: { login: 'reviewer', avatarUrl: '', __typename: 'User' }, - body: 'Inline comment', - createdAt: '2026-04-01T00:01:00Z', - url: 'https://example.test/review-comment', - path: 'src/app.ts', - reactionGroups: [ - { - content: 'EYES', - viewerHasReacted: false, - reactors: { totalCount: 1 } - } - ] - } - ] - } - } - ] - } - } - } - } - }) - }) - .mockResolvedValueOnce({ stdout: '[]' }) - .mockResolvedValueOnce({ stdout: '[]' }) - - const comments = await getPRComments('/repo-root', 7, { - prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } - }) - - expect(comments).toEqual([ - expect.objectContaining({ - id: 10, - reactionSubjectId: 'IC_1', - reactions: [{ content: '+1', count: 2, viewerHasReacted: true }] - }), - expect.objectContaining({ - id: 11, - reactionSubjectId: 'PRRC_1', - reactions: [{ content: 'eyes', count: 1, viewerHasReacted: false }] - }) - ]) - }) - - it('adds and removes PR comment reactions through GraphQL', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: '{}', stderr: '' }) - - await expect(setPRCommentReaction('/repo-root', 'IC_1', '+1', true)).resolves.toBe(true) - await expect(setPRCommentReaction('/repo-root', 'PRRC_1', 'eyes', false)).resolves.toBe(true) - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining(['subjectId=IC_1', 'content=THUMBS_UP']), - expect.any(Object) - ) - expect(ghExecFileAsyncMock.mock.calls[0]?.[0].join(' ')).toContain('addReaction') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - expect.arrayContaining(['subjectId=PRRC_1', 'content=EYES']), - expect.any(Object) - ) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0].join(' ')).toContain('removeReaction') - expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/github/client-pr-conflict-summary.test.ts b/src/main/github/client-pr-conflict-summary.test.ts deleted file mode 100644 index 8a43f7d634b..00000000000 --- a/src/main/github/client-pr-conflict-summary.test.ts +++ /dev/null @@ -1,497 +0,0 @@ -import type * as GitExecFileModule from '../git/command-runner/git-exec-file' -import type * as GhExecFileModule from '../git/command-runner/gh-exec-file' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as GithubApiRepositoryModule from './github-api-repository' -import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' - -const { clientMocks, moduleMocks } = await vi.hoisted(async () => { - const moduleMocks = await import('./client-test-mocks') - return { clientMocks: moduleMocks.createGitHubClientMocks(), moduleMocks } -}) - -vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) -vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) - -vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal()), - gitExecFileAsync: clientMocks.gitExecFileAsyncMock -})) - -vi.mock('../git/command-runner/gh-exec-file', async (importOriginal) => ({ - ...(await importOriginal()), - ghExecFileAsync: clientMocks.ghExecFileAsyncMock -})) -vi.mock('../git/local-git-object-quarantine', () => - moduleMocks.localGitObjectQuarantineModuleMock() -) -vi.mock('./local-git-config-signature', () => - moduleMocks.localGitConfigSignatureModuleMock(clientMocks) -) -vi.mock('./github-enterprise-repository', async (importOriginal) => - moduleMocks.githubEnterpriseRepositoryModuleMock( - await importOriginal() - ) -) -vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(clientMocks)) -vi.mock('./github-api-repository', async (importOriginal) => - moduleMocks.githubApiRepositoryModuleMock( - clientMocks, - await importOriginal() - ) -) - -import { getPRForBranch } from './client/lookup/get-pr-for-branch' -import { installIndependentCheckFixtures, resetPRForBranchMocks } from './client-test-harness' - -const { getOwnerRepoMock, resolvePRRepositoryCandidatesMock, gitExecFileAsyncMock } = clientMocks - -const metadataRequests = vi.fn() -const unexpectedChecks: string[][] = [] -afterEach(() => expect(unexpectedChecks).toEqual([])) - -describe('getPRForBranch', () => { - beforeEach(() => { - resetPRForBranchMocks(clientMocks) - installIndependentCheckFixtures(clientMocks, metadataRequests, unexpectedChecks) - }) - - it('derives a read-only conflict summary for conflicting PRs when the base ref exists locally', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Fix PR discovery', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable_state: 'dirty', - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - ]) - }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '/repo-root/.git\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ stdout: '3\n' }) - .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/a.ts\u0000src/b.ts\u0000' }) - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(pr?.conflictSummary).toEqual({ - baseRef: 'main', - baseCommit: 'latest-', - commitsBehind: 3, - files: ['src/a.ts', 'src/b.ts'] - }) - }) - - it('routes local WSL branch status and conflict summary git probes through the selected distro', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Fix PR discovery', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - updated_at: '2026-06-16T00:00:00Z', - draft: false, - mergeable_state: 'dirty', - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - ]) - }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '/repo-root/.git\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ stdout: '2\n' }) - .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) - - const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, null, { - localGitExecOptions: { wslDistro: 'Ubuntu' } - }) - - expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts']) - expect(resolvePRRepositoryCandidatesMock).toHaveBeenCalledWith('/repo-root', null, { - wslDistro: 'Ubuntu' - }) - expect(metadataRequests).toHaveBeenCalledWith( - expect.any(Array), - expect.objectContaining({ - cwd: '/repo-root', - wslDistro: 'Ubuntu' - }) - ) - for (const [, options] of gitExecFileAsyncMock.mock.calls) { - expect(options).toMatchObject({ cwd: '/repo-root', wslDistro: 'Ubuntu' }) - } - expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( - [ - 'merge-tree', - '--write-tree', - '--name-only', - '-z', - '--no-messages', - '--merge-base', - 'merge-base-oid', - 'head-oid', - 'latest-base-oid' - ], - { - cwd: '/repo-root', - wslDistro: 'Ubuntu' - } - ) - }) - - it('treats GitHub DIRTY merge state as conflicting when mergeable is still unknown', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: 'Fix PR discovery', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/42', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'UNKNOWN', - mergeStateStatus: 'DIRTY', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } }) - }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '/repo-root/.git\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ stdout: '1\n' }) - .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) - - const pr = await getPRForBranch('/repo-root', 'feature/test', 42) - - expect(pr?.mergeable).toBe('CONFLICTING') - expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts']) - }) - - it('omits conflict summaries for SSH-backed repos', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Fix PR discovery', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable_state: 'dirty', - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - ]) - }) - - const pr = await getPRForBranch('/remote/repo-root', 'feature/test', undefined, 'ssh-1') - - expect(pr?.mergeable).toBe('CONFLICTING') - expect(pr?.conflictSummary).toBeUndefined() - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('keeps conflicted file paths when git merge-tree exits 1 with stdout', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Fix PR discovery', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable_state: 'dirty', - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - ]) - }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '/repo-root/.git\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ stdout: '2\n' }) - .mockRejectedValueOnce({ - stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts']) - }) - - it('falls back to the legacy merge-tree invocation when Git lacks --merge-base', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Fix PR discovery', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable_state: 'dirty', - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - ]) - }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '/repo-root/.git\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ stdout: '2\n' }) - .mockRejectedValueOnce({ - stderr: "error: unknown option `merge-base'" - }) - .mockRejectedValueOnce({ - stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'merge-tree', - '--write-tree', - '--name-only', - '-z', - '--no-messages', - '--merge-base', - 'merge-base-oid', - 'head-oid', - 'latest-base-oid' - ], - { cwd: '/repo-root' } - ) - expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( - [ - 'merge-tree', - '--write-tree', - '--name-only', - '-z', - '--no-messages', - 'head-oid', - 'latest-base-oid' - ], - { cwd: '/repo-root' } - ) - expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts']) - }) - - it('skips the unsupported merge-tree --merge-base retry after the first capability miss', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - const branchLookup = { - number: 42, - title: 'Fix PR discovery', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable_state: 'dirty', - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - const exactLookup = { - number: 42, - title: 'Fix PR discovery', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/42', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'CONFLICTING', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - } - // Change head OID to bypass the result cache while retaining the learned Git capability. - const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } } - const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' } - metadataRequests - .mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { repository: { autoMergeAllowed: true, mergeQueue: null } } - }) - }) - .mockResolvedValueOnce({ stdout: JSON.stringify([pushedBranchLookup]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify(pushedExactLookup) }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '/repo-root/.git\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ stdout: '2\n' }) - .mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" }) - .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ stdout: '2\n' }) - .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) - - await getPRForBranch('/repo-root', 'feature/test') - await getPRForBranch('/repo-root', 'feature/test') - - const modernMergeTreeCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('--merge-base') - ) - const legacyMergeTreeCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => { - const argv = args as string[] - return argv[0] === 'merge-tree' && !argv.includes('--merge-base') - }) - - expect(modernMergeTreeCalls).toHaveLength(1) - expect(legacyMergeTreeCalls).toHaveLength(2) - }) - - it('does not retry legacy merge-tree for older Git failures unrelated to --merge-base', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Fix PR discovery', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable_state: 'dirty', - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - ]) - }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '/repo-root/.git\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ stdout: '2\n' }) - .mockRejectedValueOnce({ - stderr: 'usage: git merge-tree ' - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(6) - expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( - [ - 'merge-tree', - '--write-tree', - '--name-only', - '-z', - '--no-messages', - '--merge-base', - 'merge-base-oid', - 'head-oid', - 'latest-base-oid' - ], - { cwd: '/repo-root' } - ) - expect(pr?.mergeable).toBe('CONFLICTING') - expect(pr?.conflictSummary).toBeUndefined() - }) - - it('marks the conflict summary as locally clean when GitHub reports dirty but merge-tree has no conflicted files', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Fix PR discovery', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - updated_at: '2026-06-20T22:16:43Z', - draft: false, - mergeable_state: 'dirty', - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - ]) - }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '/repo-root/.git\n' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ stdout: '1\n' }) - .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000' }) - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(pr?.mergeable).toBe('CONFLICTING') - expect(pr?.conflictSummary).toEqual({ - baseRef: 'main', - baseCommit: 'latest-', - commitsBehind: 1, - files: [], - localMergeState: 'clean' - }) - }) - - it('falls back to GitHub baseRefOid when fetching or resolving the base ref fails', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Fix PR discovery', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable_state: 'dirty', - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'head-oid' } - } - ]) - }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '/repo-root/.git\n' }) - .mockRejectedValueOnce(new Error('fetch failed')) - .mockRejectedValueOnce(new Error('missing refs/remotes/origin/main')) - .mockRejectedValueOnce(new Error('missing origin/main')) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ stdout: '1\n' }) - .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/fallback.ts\u0000' }) - - const pr = await getPRForBranch('/repo-root', 'feature/test') - - expect(pr?.conflictSummary).toEqual({ - baseRef: 'main', - baseCommit: 'base-oi', - commitsBehind: 1, - files: ['src/fallback.ts'] - }) - }) -}) diff --git a/src/main/github/client-pr-conversation-requests.test.ts b/src/main/github/client-pr-conversation-requests.test.ts index 16fc85b10ba..e75ea868b39 100644 --- a/src/main/github/client-pr-conversation-requests.test.ts +++ b/src/main/github/client-pr-conversation-requests.test.ts @@ -40,7 +40,7 @@ vi.mock('./github-api-repository', async (importOriginal) => import { getPRComments } from './client/fetch/get-pr-comments' import { resetGraphQLRateLimitGuardMocks } from './client-test-harness' -const { ghExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } = clientMocks +const { ghExecFileAsyncMock, rateLimitGuardMock } = clientMocks const repository = { owner: 'acme', repo: 'widgets', host: 'github.com' } const options = { prRepo: repository } @@ -55,76 +55,11 @@ const comment = { function response(pr: unknown, errors?: unknown): { stdout: string } { return { stdout: JSON.stringify({ data: { repository: { pullRequest: pr } }, errors }) } } -function completeResponse() { - return response({ - comments: { nodes: [comment] }, - reviews: { nodes: [] }, - reviewThreads: { nodes: [] } - }) -} describe('PR conversation request reuse', () => { beforeEach(() => resetGraphQLRateLimitGuardMocks(clientMocks)) afterEach(() => vi.restoreAllMocks()) - it('shares simultaneous reads, spends no REST quota, and reads fresh data after completion', async () => { - ghExecFileAsyncMock.mockResolvedValue(completeResponse()) - const results = await Promise.all( - Array.from({ length: 8 }, () => getPRComments('/repo', 7, options)) - ) - expect( - results.every( - (comments) => comments.length === 1 && comments[0].reactionSubjectId === 'IC_10' - ) - ).toBe(true) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(noteRateLimitSpendMock.mock.calls).toEqual([['graphql']]) - ghExecFileAsyncMock.mockResolvedValue( - response({ comments: { nodes: [] }, reviews: { nodes: [] }, reviewThreads: { nodes: [] } }) - ) - await expect(getPRComments('/repo', 7, options)).resolves.toEqual([]) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('loads reactions and threads even when the REST allowance is exhausted', async () => { - rateLimitGuardMock.mockImplementation((bucket) => - bucket === 'core' - ? { blocked: true, remaining: 0, limit: 5000, resetAt: 1800000000 } - : { blocked: false } - ) - ghExecFileAsyncMock.mockResolvedValue(completeResponse()) - await expect(getPRComments('/repo', 7, options)).resolves.toMatchObject([ - { id: 10, body: 'Conversation' } - ]) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - - it('orders submitted draft reviews by submission time while preserving comment creation time', async () => { - ghExecFileAsyncMock.mockResolvedValue( - response({ - comments: { nodes: [comment] }, - reviews: { - nodes: [ - { - ...comment, - id: 'PRR_11', - databaseId: 11, - body: 'Submitted review', - createdAt: '2026-03-01T00:00:00Z', - submittedAt: '2026-04-02T00:00:00Z' - } - ] - }, - reviewThreads: { nodes: [] } - }) - ) - await expect(getPRComments('/repo', 7, options)).resolves.toMatchObject([ - { id: 10, createdAt: comment.createdAt }, - { id: 11, createdAt: '2026-04-02T00:00:00Z' } - ]) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - it.each([false, true])( 'preserves quota failures when REST recovery is needed (GraphQL blocked: %s)', async (graphQLBlocked) => { @@ -141,44 +76,6 @@ describe('PR conversation request reuse', () => { } ) - it('keeps valid inline comments when another comment has no numeric database ID', async () => { - ghExecFileAsyncMock.mockResolvedValue( - response({ - comments: { nodes: [comment] }, - reviews: { nodes: [] }, - reviewThreads: { - nodes: [ - { - id: 'PRRT_1', - isResolved: true, - line: null, - startLine: null, - originalLine: 8, - originalStartLine: 6, - comments: { - nodes: [ - { ...comment, databaseId: null, path: 'src/app.ts' }, - { - ...comment, - id: 'PRRC_11', - databaseId: 11, - body: 'Valid inline', - path: 'src/app.ts' - } - ] - } - } - ] - } - }) - ) - await expect(getPRComments('/repo', 7, options)).resolves.toMatchObject([ - { id: 10, body: 'Conversation' }, - { id: 11, body: 'Valid inline', line: 8, startLine: 6, isOutdated: true, isResolved: true } - ]) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - it('recovers only the failed GraphQL source from nonzero-exit partial data', async () => { const partial = response( { comments: { nodes: [comment] }, reviews: null, reviewThreads: { nodes: [] } }, @@ -226,45 +123,4 @@ describe('PR conversation request reuse', () => { ]) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) }) - - it('does not let a missing reviews connection hide REST review summaries', async () => { - ghExecFileAsyncMock - .mockResolvedValueOnce(response({ comments: { nodes: [] }, reviewThreads: { nodes: [] } })) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - id: 11, - user: null, - body: 'Recovered review', - submitted_at: '2026-04-01T00:01:00Z', - html_url: 'https://example.test/review' - } - ]) - }) - await expect(getPRComments('/repo', 7, options)).resolves.toMatchObject([ - { body: 'Recovered review' } - ]) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('never shares explicit refreshes and keeps host, environment, and execution runtimes separate', async () => { - ghExecFileAsyncMock.mockResolvedValue(completeResponse()) - const reads = [ - getPRComments('/repo', 7, options), - getPRComments('/repo', 7, { ...options, noCache: true }), - getPRComments('/repo', 7, { ...options, noCache: true }), - getPRComments('/repo', 7, { prRepo: { ...repository, host: 'github.example.com' } }), - getPRComments('/repo', 7, options, null, { wslDistro: 'Ubuntu' }), - getPRComments('/repo', 7, options, 'ssh-1') - ] - vi.stubEnv('GH_TOKEN', 'different-test-account') - reads.push(getPRComments('/repo', 7, options)) - try { - const results = await Promise.all(reads) - expect(results.every((comments) => comments[0]?.id === 10)).toBe(true) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(7) - } finally { - vi.unstubAllEnvs() - } - }) }) diff --git a/src/main/github/client-pr-fallback-number.test.ts b/src/main/github/client-pr-fallback-number.test.ts index 7a311c8023f..11888eae4e9 100644 --- a/src/main/github/client-pr-fallback-number.test.ts +++ b/src/main/github/client-pr-fallback-number.test.ts @@ -38,16 +38,9 @@ vi.mock('./github-api-repository', async (importOriginal) => ) import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' -import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { resetPRForBranchMocks, mockPRLookupRoutes } from './client-test-harness' -const { - ghExecFileAsyncMock, - getOwnerRepoMock, - getOwnerRepoForRemoteMock, - resolvePRRepositoryCandidatesMock, - gitExecFileAsyncMock -} = clientMocks +const { ghExecFileAsyncMock, resolvePRRepositoryCandidatesMock } = clientMocks const unexpectedRequests: string[][] = [] function metadataCalls() { @@ -74,278 +67,6 @@ describe('getPRForBranch', () => { resetPRForBranchMocks(clientMocks) }) - it('prefers branch lookup over a fallback PR number', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - mockLookupRoutes([ - [ - 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=10', - async () => ({ - stdout: JSON.stringify([ - { - number: 43, - title: 'Branch PR wins', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/43', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - head: { ref: 'feature/test', sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, - base: { ref: 'main', sha: 'branch-base-oid' } - } - ]) - }) - ], - [ - 'view:acme/widgets:43', - async () => ({ - stdout: JSON.stringify({ - number: 43, - title: 'Hydrated branch PR wins', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/43', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'branch-base-oid', - headRefOid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - }) - }) - ] - ]) - - const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42) - - expect(metadataCalls()).toHaveLength(2) - expect(metadataCalls()[0]).toEqual([ - [ - 'api', - 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=10', - '--include' - ], - { cwd: '/repo-root' } - ]) - expect(pr).toMatchObject({ number: 43, title: 'Hydrated branch PR wins' }) - }) - - it('uses a fallback PR number only after branch lookup misses', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - mockLookupRoutes([ - [ - 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=10', - async () => ({ stdout: JSON.stringify([]) }) - ], - [ - 'view:acme/widgets:42', - async () => ({ - stdout: JSON.stringify({ - number: 42, - title: 'Fallback PR lookup', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/42', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'contributor/original', - baseRefOid: 'base-oid', - headRefOid: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' - }) - }) - ] - ]) - - const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42) - - expect(metadataCalls()[0]).toEqual([ - [ - 'api', - 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=10', - '--include' - ], - { cwd: '/repo-root' } - ]) - expect(metadataCalls()[1]).toEqual([ - [ - 'pr', - 'view', - '42', - '--repo', - 'acme/widgets', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root' } - ]) - expect(pr).toMatchObject({ number: 42, title: 'Fallback PR lookup' }) - }) - - it('reports upstream error when fallback branch discovery fails transiently then retry misses', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [{ owner: 'stablyai', repo: 'orca' }], - headRepo: null - }) - mockLookupRoutes([ - [ - 'list:stablyai/orca:feature/test', - async () => { - throw new Error('HTTP 429: API rate limit exceeded') - } - ], - [ - 'repos/stablyai/orca/pulls?head=stablyai%3Afeature%2Ftest&state=all&per_page=10', - async () => ({ stdout: JSON.stringify([]) }) - ] - ]) - - const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') - - expect(outcome).toMatchObject({ - kind: 'upstream-error', - errorType: 'rate_limited' - }) - expect(metadataCalls()[0]).toEqual([ - [ - 'pr', - 'list', - '--repo', - 'stablyai/orca', - '--head', - 'feature/test', - '--state', - 'all', - '--limit', - '10', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root' } - ]) - expect(metadataCalls()[1]).toEqual([ - [ - 'api', - 'repos/stablyai/orca/pulls?head=stablyai%3Afeature%2Ftest&state=all&per_page=10', - '--include' - ], - { cwd: '/repo-root' } - ]) - }) - - it('propagates a Retry-After cooldown into the rate-limited retry schedule', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [{ owner: 'stablyai', repo: 'orca' }], - headRepo: null - }) - // gh puts the diagnostic on `.stderr`; a secondary limit carries Retry-After. - mockLookupRoutes([ - [ - 'list:stablyai/orca:feature/test', - async () => { - throw Object.assign(new Error('gh exited with 1.'), { - stderr: 'HTTP 403: You have exceeded a secondary rate limit\nRetry-After: 120' - }) - } - ], - [ - 'repos/stablyai/orca/pulls?head=stablyai%3Afeature%2Ftest&state=all&per_page=10', - async () => ({ stdout: JSON.stringify([]) }) - ] - ]) - - const before = Date.now() - const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') - expect(outcome.kind).toBe('upstream-error') - if (outcome.kind !== 'upstream-error') { - throw new Error('expected upstream-error') - } - expect(outcome.errorType).toBe('rate_limited') - expect(outcome.retryDisabledUntil).toBeDefined() - expect(outcome.nextAutoRetryAt).toBe(outcome.retryDisabledUntil) - expect(outcome.retryDisabledUntil ?? 0).toBeGreaterThanOrEqual(before + 119_000) - expect(outcome.retryDisabledUntil ?? 0).toBeLessThanOrEqual(Date.now() + 121_000) - }) - - it('reports no PR when fallback branch discovery cleanly misses', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [{ owner: 'stablyai', repo: 'orca' }], - headRepo: null - }) - mockLookupRoutes([ - ['list:stablyai/orca:feature/test', async () => ({ stdout: JSON.stringify([]) })] - ]) - - const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') - - expect(outcome.kind).toBe('no-pr') - expect(metadataCalls()).toHaveLength(1) - }) - - it('returns found when fallback branch discovery retry finds the PR', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [{ owner: 'stablyai', repo: 'orca' }], - headRepo: null - }) - mockLookupRoutes([ - [ - 'list:stablyai/orca:feature/test', - async () => { - throw new Error('HTTP 429: API rate limit exceeded') - } - ], - [ - 'repos/stablyai/orca/pulls?head=stablyai%3Afeature%2Ftest&state=all&per_page=10', - async () => ({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Retry branch PR', - state: 'open', - html_url: 'https://github.com/stablyai/orca/pull/42', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'feature/test', sha: 'dddddddddddddddddddddddddddddddddddddddd' } - } - ]) - }) - ], - [ - 'view:stablyai/orca:42', - async () => ({ - stdout: JSON.stringify({ - number: 42, - title: 'Hydrated retry branch PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/42', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'dddddddddddddddddddddddddddddddddddddddd' - }) - }) - ] - ]) - - const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') - - expect(outcome).toMatchObject({ - kind: 'found', - pr: { - number: 42, - title: 'Hydrated retry branch PR', - prRepo: { owner: 'stablyai', repo: 'orca' } - } - }) - }) - it('lets fallback PR number recovery win after fallback branch queries throw', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'stablyai', repo: 'orca' }], @@ -408,62 +129,6 @@ describe('getPRForBranch', () => { ]) }) - it('reports upstream error when fallback branch discovery has a network failure', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [{ owner: 'stablyai', repo: 'orca' }], - headRepo: null - }) - mockLookupRoutes([ - [ - 'list:stablyai/orca:feature/test', - async () => { - throw new Error('could not resolve host: api.github.com') - } - ], - [ - 'repos/stablyai/orca/pulls?head=stablyai%3Afeature%2Ftest&state=all&per_page=10', - async () => { - throw new Error('could not resolve host: api.github.com') - } - ] - ]) - - const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') - - expect(outcome).toMatchObject({ - kind: 'upstream-error', - errorType: 'network' - }) - }) - - it('reports a GitHub server error when fallback branch discovery receives 5xx responses', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [{ owner: 'stablyai', repo: 'orca' }], - headRepo: null - }) - mockLookupRoutes([ - [ - 'list:stablyai/orca:feature/test', - async () => { - throw new Error('HTTP 503: Service Unavailable') - } - ], - [ - 'repos/stablyai/orca/pulls?head=stablyai%3Afeature%2Ftest&state=all&per_page=10', - async () => { - throw new Error('HTTP 502: Bad Gateway') - } - ] - ]) - - const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') - - expect(outcome).toMatchObject({ - kind: 'upstream-error', - errorType: 'server_error' - }) - }) - it('keeps a pending fallback branch error when a later candidate cleanly misses', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ @@ -510,320 +175,4 @@ describe('getPRForBranch', () => { { cwd: '/repo-root' } ]) }) - - it('treats a merged branch lookup as a miss before using a fallback PR number', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - mockLookupRoutes( - [ - [ - 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=10', - async () => ({ - stdout: JSON.stringify([ - { - number: 5511, - title: 'Merged branch PR', - state: 'closed', - merged_at: '2026-06-16T17:15:33Z', - html_url: 'https://github.com/stablyai/orca/pull/5511', - updated_at: '2026-06-16T17:15:33Z', - draft: false, - mergeable_state: 'clean', - head: { ref: 'feature/test', sha: 'cccccccccccccccccccccccccccccccccccccccc' }, - base: { ref: 'main', sha: 'base-oid' } - } - ]) - }) - ], - [ - 'view:acme/widgets:5511', - async () => ({ - stdout: JSON.stringify({ - number: 5511, - title: 'Merged branch PR', - state: 'MERGED', - url: 'https://github.com/stablyai/orca/pull/5511', - statusCheckRollup: [], - updatedAt: '2026-06-16T17:15:33Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'cccccccccccccccccccccccccccccccccccccccc' - }) - }) - ], - [ - 'view:acme/widgets:42', - async () => ({ - stdout: JSON.stringify({ - number: 42, - title: 'Open fallback PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/42', - statusCheckRollup: [], - updatedAt: '2026-06-17T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'contributor/original', - baseRefOid: 'base-oid', - headRefOid: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' - }) - }) - ] - ], - true - ) - - const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42) - - expect(pr).toMatchObject({ number: 42, title: 'Open fallback PR' }) - }) - - it('returns a merged PR when branch lookup and fallback point at the same PR', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - mockLookupRoutes([ - [ - 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=10', - async () => ({ - stdout: JSON.stringify([ - { - number: 5511, - title: 'Merged current PR', - state: 'closed', - merged_at: '2026-06-16T17:15:33Z', - html_url: 'https://github.com/acme/widgets/pull/5511', - updated_at: '2026-06-16T17:15:33Z', - draft: false, - mergeable_state: 'clean', - head: { ref: 'feature/test', sha: 'cccccccccccccccccccccccccccccccccccccccc' }, - base: { ref: 'main', sha: 'base-oid' } - } - ]) - }) - ], - [ - 'view:acme/widgets:5511', - async () => ({ - stdout: JSON.stringify({ - number: 5511, - title: 'Merged current PR', - state: 'MERGED', - url: 'https://github.com/acme/widgets/pull/5511', - statusCheckRollup: [], - updatedAt: '2026-06-16T17:15:33Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'cccccccccccccccccccccccccccccccccccccccc' - }) - }) - ], - [ - 'view:acme/widgets:5511', - async () => ({ - stdout: JSON.stringify({ - number: 5511, - title: 'Merged current PR', - state: 'MERGED', - url: 'https://github.com/acme/widgets/pull/5511', - statusCheckRollup: [], - updatedAt: '2026-06-16T17:15:33Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'cccccccccccccccccccccccccccccccccccccccc' - }) - }) - ] - ]) - - const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 5511) - - expect(metadataCalls()[2]).toEqual([ - [ - 'pr', - 'view', - '5511', - '--repo', - 'acme/widgets', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root' } - ]) - expect(pr).toMatchObject({ number: 5511, state: 'merged', title: 'Merged current PR' }) - }) - - it('does not carry a merged upstream branch head repo into a fallback PR number', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [{ owner: 'stablyai', repo: 'orca' }], - headRepo: { owner: 'origin-owner', repo: 'orca' } - }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'orca' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'local-created-from-pr\0fork/contributor/original\n', - stderr: '' - }) - mockLookupRoutes( - [ - [ - 'repos/stablyai/orca/pulls?head=origin-owner%3Alocal-created-from-pr&state=all&per_page=10', - async () => ({ stdout: JSON.stringify([]) }) - ], - [ - 'repos/stablyai/orca/pulls?head=fork-owner%3Acontributor%2Foriginal&state=all&per_page=10', - async () => ({ - stdout: JSON.stringify([ - { - number: 5511, - title: 'Merged upstream branch PR', - state: 'closed', - merged_at: '2026-06-16T17:15:33Z', - html_url: 'https://github.com/stablyai/orca/pull/5511', - updated_at: '2026-06-16T17:15:33Z', - draft: false, - mergeable_state: 'clean', - head: { - ref: 'contributor/original', - sha: 'cccccccccccccccccccccccccccccccccccccccc' - }, - base: { ref: 'main', sha: 'base-oid' } - } - ]) - }) - ], - [ - 'view:stablyai/orca:5511', - async () => ({ - stdout: JSON.stringify({ - number: 5511, - title: 'Merged upstream branch PR', - state: 'MERGED', - url: 'https://github.com/stablyai/orca/pull/5511', - statusCheckRollup: [], - updatedAt: '2026-06-16T17:15:33Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'contributor/original', - baseRefOid: 'base-oid', - headRefOid: 'cccccccccccccccccccccccccccccccccccccccc' - }) - }) - ], - [ - 'view:stablyai/orca:42', - async () => ({ - stdout: JSON.stringify({ - number: 42, - title: 'Open fallback PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/42', - statusCheckRollup: [], - updatedAt: '2026-06-17T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'fresh/fallback', - baseRefOid: 'base-oid', - headRefOid: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' - }) - }) - ] - ], - true - ) - - const pr = await getPRForBranch('/repo-root', 'local-created-from-pr', null, null, 42) - - expect(pr).toMatchObject({ - number: 42, - title: 'Open fallback PR', - headRepo: { owner: 'origin-owner', repo: 'orca' } - }) - }) - - it('ignores merged PRs discovered only from a fallback PR number', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - mockLookupRoutes([ - [ - 'repos/acme/widgets/pulls?head=acme%3Aadd-guide-for-mobile-emulator-use&state=all&per_page=10', - async () => ({ stdout: JSON.stringify([]) }) - ], - [ - 'view:acme/widgets:5511', - async () => ({ - stdout: JSON.stringify({ - number: 5511, - title: 'Merged fallback PR', - state: 'MERGED', - url: 'https://github.com/stablyai/orca/pull/5511', - statusCheckRollup: [], - updatedAt: '2026-06-16T17:15:33Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'add-guide-for-mobile-emulator-use', - baseRefOid: 'base-oid', - headRefOid: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' - }) - }) - ] - ]) - - const pr = await getPRForBranch( - '/repo-root', - 'add-guide-for-mobile-emulator-use', - null, - null, - 5511 - ) - - expect(pr).toBeNull() - }) - - it('returns a merged fallback PR when visible fallback lifecycle is accepted', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - mockLookupRoutes([ - [ - 'repos/acme/widgets/pulls?head=acme%3Adeleted-head&state=all&per_page=10', - async () => ({ stdout: JSON.stringify([]) }) - ], - [ - 'view:acme/widgets:5511', - async () => ({ - stdout: JSON.stringify({ - number: 5511, - title: 'Merged visible fallback PR', - state: 'MERGED', - url: 'https://github.com/acme/widgets/pull/5511', - statusCheckRollup: [], - updatedAt: '2026-06-16T17:15:33Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'deleted-head', - baseRefOid: 'base-oid', - headRefOid: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' - }) - }) - ] - ]) - - const pr = await getPRForBranch('/repo-root', 'deleted-head', null, null, 5511, { - acceptMergedFallbackPR: true - }) - - expect(pr).toMatchObject({ - number: 5511, - state: 'merged', - title: 'Merged visible fallback PR' - }) - }) }) diff --git a/src/main/github/client-pr-linked-lookup.test.ts b/src/main/github/client-pr-linked-lookup.test.ts index d38707d6f67..7d095407183 100644 --- a/src/main/github/client-pr-linked-lookup.test.ts +++ b/src/main/github/client-pr-linked-lookup.test.ts @@ -40,7 +40,7 @@ vi.mock('./github-api-repository', async (importOriginal) => import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { installIndependentCheckFixtures, resetPRForBranchMocks } from './client-test-harness' -const { getOwnerRepoMock, resolvePRRepositoryCandidatesMock, gitExecFileAsyncMock } = clientMocks +const { getOwnerRepoMock } = clientMocks const metadataRequests = vi.fn() const unexpectedChecks: string[][] = [] @@ -52,336 +52,6 @@ describe('getPRForBranch', () => { installIndependentCheckFixtures(clientMocks, metadataRequests, unexpectedChecks) }) - it('looks up a linked PR number across PR repo candidates', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [ - { owner: 'stablyai', repo: 'orca' }, - { owner: 'fork', repo: 'orca' } - ], - headRepo: { owner: 'fork', repo: 'orca' } - }) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' }) - metadataRequests.mockRejectedValueOnce(new Error('HTTP 404: Not Found')).mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 99, - title: 'Linked fork PR', - state: 'OPEN', - url: 'https://github.com/fork/orca/pull/99', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'linked-head-oid' - }) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test', 99) - - expect(metadataRequests).toHaveBeenNthCalledWith( - 1, - [ - 'pr', - 'view', - '99', - '--repo', - 'stablyai/orca', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root' } - ) - expect(metadataRequests).toHaveBeenNthCalledWith( - 2, - [ - 'pr', - 'view', - '99', - '--repo', - 'fork/orca', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root' } - ) - expect(pr?.prRepo).toEqual({ owner: 'fork', repo: 'orca' }) - }) - - it('prefers exact linked PR lookup when the repo identity is known', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 99, - title: 'Linked PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/99', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'someone/fix', - baseRefOid: 'base-oid', - headRefOid: 'linked-head-oid' - }) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99) - - expect(metadataRequests).toHaveBeenCalledTimes(2) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - expect(metadataRequests).toHaveBeenCalledWith( - [ - 'pr', - 'view', - '99', - '--repo', - 'acme/widgets', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root' } - ) - expect(pr).toMatchObject({ - number: 99, - title: 'Linked PR', - state: 'open', - headSha: 'linked-head-oid' - }) - }) - - it('caches exact REST stack probes across linked PR refreshes', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - metadataRequests.mockImplementation(async (args: string[]) => { - if (args[0] === 'pr') { - return { - stdout: JSON.stringify({ - number: 99, - title: 'Linked PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/99', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature', - headRefOid: 'head-oid' - }) - } - } - return { - stdout: JSON.stringify({ - number: 99, - title: 'Linked PR', - state: 'open', - stack: null - }) - } - }) - - await getPRForBranch('/repo-root', 'feature', 99) - await getPRForBranch('/repo-root', 'feature', 99) - await getPRForBranch('/repo-root', 'feature', 99, 'ssh-1') - - expect( - metadataRequests.mock.calls.filter(([args]) => args[0] === 'api' && args[1]?.includes('/99')) - ).toHaveLength(2) - expect(metadataRequests.mock.calls.filter(([args]) => args[0] === 'pr')).toHaveLength(3) - }) - - it('caches failed REST stack probes across linked PR refreshes', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - metadataRequests.mockImplementation(async (args: string[]) => { - if (args[0] === 'pr') { - return { - stdout: JSON.stringify({ - number: 99, - title: 'Linked PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/99', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature', - headRefOid: 'head-oid' - }) - } - } - throw new Error('GitHub is temporarily unavailable') - }) - - await getPRForBranch('/repo-root', 'feature', 99) - await getPRForBranch('/repo-root', 'feature', 99) - - expect( - metadataRequests.mock.calls.filter(([args]) => args[0] === 'api' && args[1]?.includes('/99')) - ).toHaveLength(1) - }) - - it('hydrates repository merge method settings for exact PR lookups', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 99, - title: 'Linked PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/99', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - autoMergeRequest: null, - baseRefName: 'main', - headRefName: 'someone/fix', - baseRefOid: 'base-oid', - headRefOid: 'linked-head-oid' - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - viewerDefaultMergeMethod: 'REBASE', - mergeCommitAllowed: false, - rebaseMergeAllowed: true, - squashMergeAllowed: true, - autoMergeAllowed: true, - mergeQueue: null - } - } - }) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99) - - expect(pr?.mergeMethodSettings).toEqual({ - defaultMethod: 'rebase', - allowedMethods: { - squash: true, - merge: false, - rebase: true - } - }) - expect(pr?.mergeQueueRequired).toBe(false) - expect(pr?.autoMergeAllowed).toBe(true) - expect(metadataRequests).toHaveBeenNthCalledWith( - 2, - expect.arrayContaining([ - 'api', - 'graphql', - '-f', - 'owner=acme', - '-f', - 'repo=widgets', - '-f', - 'branch=main' - ]), - { cwd: '/repo-root' } - ) - }) - - it('isolates viewer-dependent merge metadata across SSH connections', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - let metadataProbe = 0 - metadataRequests.mockImplementation(async (args: string[]) => { - if (args.includes('graphql')) { - metadataProbe += 1 - return { - stdout: JSON.stringify({ - data: { - repository: { - autoMergeAllowed: metadataProbe === 1, - mergeQueue: null - } - } - }) - } - } - if (args[0] === 'pr') { - return { - stdout: JSON.stringify({ - number: 99, - title: 'Linked PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/99', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - autoMergeRequest: null, - baseRefName: 'main', - headRefName: 'feature', - headRefOid: 'head-oid' - }) - } - } - return { stdout: JSON.stringify({ number: 99, state: 'open', stack: null }) } - }) - - const firstAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-1') - const secondAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-2') - - expect(firstAccount?.autoMergeAllowed).toBe(true) - expect(secondAccount?.autoMergeAllowed).toBe(false) - expect(metadataProbe).toBe(2) - }) - - it('treats linked PR metadata as authoritative even when the branch head differs', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-worktree-head\n', stderr: '' }) - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 99, - title: 'Stale linked PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/99', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'someone/other-work', - baseRefOid: 'base-oid', - headRefOid: 'stale-linked-head' - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Branch PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/42', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'current-worktree-head' - } - ]) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test', 99) - - expect(metadataRequests).toHaveBeenCalledTimes(2) - expect(pr?.number).toBe(99) - }) - it('does not fall back to branch discovery when linked PR metadata is stale', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) metadataRequests.mockRejectedValueOnce(new Error('HTTP 404: Not Found')).mockResolvedValueOnce({ @@ -421,276 +91,4 @@ describe('getPRForBranch', () => { expect(metadataRequests).toHaveBeenCalledTimes(1) expect(pr).toBeNull() }) - - it('returns no PR when linked PR REST fallback also misses', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests - .mockRejectedValueOnce(new Error('GraphQL: could not resolve to PullRequest')) - .mockRejectedValueOnce(new Error('HTTP 404: Not Found')) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Branch PR after stale linked miss', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/42', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - } - ]) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test', 99) - - expect(metadataRequests).toHaveBeenNthCalledWith( - 2, - ['api', 'repos/acme/widgets/pulls/99', '--include'], - { - cwd: '/repo-root' - } - ) - expect(metadataRequests).toHaveBeenCalledTimes(2) - expect(pr).toBeNull() - }) - - it('returns no PR when linked PR REST fallback has an unclassified failure', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests - .mockRejectedValueOnce(new Error('GraphQL: server exploded')) - .mockRejectedValueOnce(new Error('HTTP 500: server error')) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Branch PR after exact lookup outage', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/42', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - } - ]) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test', 99) - - expect(metadataRequests).toHaveBeenNthCalledWith( - 2, - ['api', 'repos/acme/widgets/pulls/99', '--include'], - { - cwd: '/repo-root' - } - ) - expect(metadataRequests).toHaveBeenCalledTimes(2) - expect(pr).toBeNull() - }) - - it('does not continue to branch discovery when linked PR REST fallback is rate limited', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests - .mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded')) - .mockRejectedValueOnce(new Error('REST API rate limit already exceeded')) - - const pr = await getPRForBranch('/repo-root', 'feature/test', 99) - - expect(metadataRequests).toHaveBeenNthCalledWith( - 1, - [ - 'pr', - 'view', - '99', - '--repo', - 'acme/widgets', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root' } - ) - expect(metadataRequests).toHaveBeenNthCalledWith( - 2, - ['api', 'repos/acme/widgets/pulls/99', '--include'], - { - cwd: '/repo-root' - } - ) - expect(metadataRequests).toHaveBeenCalledTimes(2) - expect(pr).toBeNull() - }) - - it('uses linked PR number as the source of truth when provided', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 77, - title: 'Linked PR lookup', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/77', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'contributor/original', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - }) - }) - - const pr = await getPRForBranch('/repo-root', 'refs/heads/local-created-from-pr', 77) - - expect(metadataRequests).toHaveBeenCalledTimes(2) - expect(metadataRequests).toHaveBeenCalledWith( - [ - 'pr', - 'view', - '77', - '--repo', - 'acme/widgets', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root' } - ) - expect(pr?.number).toBe(77) - }) - - it('normalizes exact linked PR fallback metadata when no GitHub remote is resolved', async () => { - getOwnerRepoMock.mockResolvedValueOnce(null) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 77, - title: 'Linked fallback PR', - state: 'OPEN', - url: 'https://example.com/pr/77', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - reviewDecision: '', - autoMergeRequest: { enabledAt: '2026-03-28T00:00:00Z' }, - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - }) - }) - - const pr = await getPRForBranch('/non-github-repo', 'feature/test', 77) - - expect(metadataRequests).toHaveBeenCalledWith( - [ - 'pr', - 'view', - '77', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/non-github-repo' } - ) - expect(pr).toMatchObject({ - number: 77, - reviewDecision: null, - autoMergeEnabled: true - }) - expect(pr?.mergeQueueRequired).toBeUndefined() - }) - - it('falls back to gh pr view when the remote cannot be resolved to GitHub', async () => { - getOwnerRepoMock.mockResolvedValueOnce(null) - gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) - metadataRequests.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'Fallback lookup', - state: 'OPEN', - url: 'https://example.com/pr/7', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: true, - mergeable: 'CONFLICTING', - baseRefName: 'main', - headRefName: 'feature/test', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - }) - }) - - const pr = await getPRForBranch('/non-github-repo', 'feature/test') - - expect(metadataRequests).toHaveBeenCalledWith( - [ - 'pr', - 'view', - 'feature/test', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/non-github-repo' } - ) - expect(pr?.number).toBe(7) - expect(pr?.state).toBe('draft') - expect(pr?.mergeable).toBe('CONFLICTING') - }) - - it('falls back to REST number lookup when linked PR GraphQL lookup is rate limited', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' }) - metadataRequests - .mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded')) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 99, - title: 'REST linked PR lookup', - state: 'closed', - merged_at: '2026-03-28T00:00:00Z', - html_url: 'https://github.com/acme/widgets/pull/99', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable_state: 'clean', - head: { ref: 'someone/fix', sha: 'linked-head-oid' }, - base: { ref: 'main', sha: 'linked-base-oid' } - }) - }) - - const pr = await getPRForBranch('/repo-root', 'feature/test', 99) - - expect(metadataRequests).toHaveBeenNthCalledWith( - 1, - [ - 'pr', - 'view', - '99', - '--repo', - 'acme/widgets', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root' } - ) - expect(metadataRequests).toHaveBeenNthCalledWith( - 2, - ['api', 'repos/acme/widgets/pulls/99', '--include'], - { - cwd: '/repo-root' - } - ) - expect(metadataRequests).toHaveBeenCalledTimes(2) - expect(pr).toMatchObject({ - number: 99, - state: 'merged', - mergeable: 'MERGEABLE', - headSha: 'linked-head-oid' - }) - }) }) diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index baca07ebcdd..1b975b67916 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -102,22 +102,15 @@ vi.mock('./rate-limit', () => ({ (!repository?.host || repository.host.toLowerCase() === 'github.com') && !executionOptions?.wslDistro })) - import { - addPRReviewComment, addPRReviewCommentReply, - getPRCheckDetails, getPRChecks, - getWorkItemByOwnerRepo, getPRComments, mergePR, removePRReviewers, - rerunPRChecks, requestPRReviewers, resolveReviewThread, setPRAutoMerge, - updatePRDetails, - updatePRState, updatePRTitle } from './client' @@ -153,202 +146,6 @@ describe('GitHub PR local runtime routing', () => { acquireMock.mockResolvedValue(undefined) }) - it('routes PR details and mutations through the selected WSL distro', async () => { - const localGitOptions = { wslDistro: 'Ubuntu' } - const prRepo = { owner: 'acme', repo: 'orca' } - rateLimitGuardMock.mockReturnValue({ - blocked: true, - remaining: 0, - limit: 5000, - resetAt: 1_800_000_000 - }) - getOwnerRepoMock.mockResolvedValue(prRepo) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const endpoint = args.find((arg) => arg.startsWith('repos/acme/orca/')) ?? '' - const query = args.find((arg) => arg.startsWith('query=')) ?? '' - - if (args[0] === 'pr' && args[1] === 'view') { - const jsonFields = args[args.indexOf('--json') + 1] - if (jsonFields === 'id,headRefOid,baseRefName') { - return { - stdout: JSON.stringify({ - id: 'PR_local', - headRefOid: 'head-oid', - baseRefName: 'main' - }) - } - } - return { - stdout: JSON.stringify({ - id: 'PR_kwDO123', - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/acme/orca/pull/7', - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - autoMergeRequest: null, - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' - }) - } - } - if (query.includes('reviewThreads')) { - return { - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - reviewThreads: { nodes: [] }, - comments: { nodes: [] } - } - } - } - }) - } - } - if (endpoint.endsWith('/issues/7/comments?per_page=100')) { - return { stdout: '[]' } - } - if (endpoint.endsWith('/pulls/7/reviews?per_page=100')) { - return { stdout: '[]' } - } - if (endpoint.endsWith('/pulls/7/comments/11/replies')) { - return { - stdout: JSON.stringify({ id: 12, node_id: 'PRRC_reply_12', user: null, body: 'Reply' }) - } - } - if (endpoint.endsWith('/pulls/7/comments')) { - return { - stdout: JSON.stringify({ id: 13, node_id: 'PRRC_inline_13', user: null, body: 'Inline' }) - } - } - if (args.includes('--include') && endpoint === 'repos/acme/orca/pulls/7') { - return { - stdout: `HTTP/2.0 200 OK\n\n${JSON.stringify({ - number: 7, - head: { ref: 'feature', sha: 'head-oid' }, - base: { ref: 'main', sha: 'base-oid' } - })}` - } - } - return { stdout: '', stderr: '' } - }) - - await getPRComments('/repo-root', 7, { prRepo }, null, localGitOptions) - await expect( - resolveReviewThread('/repo-root', 'thread-1', true, null, prRepo, localGitOptions) - ).resolves.toBe(true) - await expect( - addPRReviewCommentReply( - '/repo-root', - 7, - 11, - 'Reply', - 'thread-1', - 'src/app.ts', - 10, - null, - prRepo, - localGitOptions - ) - ).resolves.toMatchObject({ - ok: true, - comment: { reactionSubjectId: 'PRRC_reply_12' } - }) - await expect( - addPRReviewComment({ - repoPath: '/repo-root', - connectionId: null, - localGitOptions, - prRepo, - prNumber: 7, - body: 'Inline', - commitId: 'head-oid', - path: 'src/app.ts', - line: 10 - }) - ).resolves.toMatchObject({ - ok: true, - comment: { reactionSubjectId: 'PRRC_inline_13' } - }) - await expect( - updatePRTitle('/repo-root', 7, 'New title', null, prRepo, localGitOptions) - ).resolves.toBe(true) - await expect( - updatePRDetails('/repo-root', 7, { body: 'New body' }, null, prRepo, localGitOptions) - ).resolves.toEqual({ ok: true }) - await expect( - updatePRState('/repo-root', 7, { state: 'closed' }, null, prRepo, localGitOptions) - ).resolves.toEqual({ ok: true }) - await expect( - requestPRReviewers('/repo-root', 7, ['octo'], null, prRepo, localGitOptions) - ).resolves.toEqual({ ok: true }) - await expect( - removePRReviewers('/repo-root', 7, ['octo'], null, prRepo, localGitOptions) - ).resolves.toEqual({ ok: true }) - await expect( - setPRAutoMerge('/repo-root', 7, true, 'squash', null, prRepo, localGitOptions) - ).resolves.toEqual({ ok: true }) - await expect( - mergePR('/repo-root', 7, 'squash', null, prRepo, localGitOptions) - ).resolves.toEqual({ ok: true }) - - expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) - expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( - true - ) - expect(getRateLimitMock).not.toHaveBeenCalled() - expect(rateLimitGuardMock).not.toHaveBeenCalled() - expect(noteRateLimitSpendMock).not.toHaveBeenCalled() - }) - - it('answers a hover preview with one REST call, following an issue link to its PR', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) - const restCalls: string[] = [] - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const endpoint = args[0] === 'api' ? args[1] : '' - restCalls.push(endpoint) - if (endpoint === 'repos/acme/orca/issues/7') { - return { stdout: JSON.stringify({ number: 7, title: 'Peek', pull_request: {} }) } - } - if (endpoint === 'repos/acme/orca/pulls/7') { - return { - stdout: JSON.stringify({ - number: 7, - title: 'Peek', - state: 'closed', - merged_at: '2026-10-01T00:00:00Z', - additions: 3, - deletions: 1 - }) - } - } - if (endpoint === 'repos/acme/orca/issues/8') { - return { stdout: JSON.stringify({ number: 8, title: 'Bug', state: 'open', comments: 2 }) } - } - throw new Error(`unexpected gh ${args.join(' ')}`) - }) - const ownerRepo = { owner: 'acme', repo: 'orca' } - - await expect( - getWorkItemByOwnerRepo('/repo', ownerRepo, 7, 'issue', null, {}, { preview: true }) - ).resolves.toMatchObject({ type: 'pr', state: 'merged', additions: 3, deletions: 1 }) - await expect( - getWorkItemByOwnerRepo('/repo', ownerRepo, 8, 'issue', null, {}, { preview: true }) - ).resolves.toMatchObject({ type: 'issue', state: 'open', commentCount: 2 }) - expect(restCalls).toEqual([ - 'repos/acme/orca/issues/7', - 'repos/acme/orca/pulls/7', - 'repos/acme/orca/issues/8' - ]) - }) - it('never falls through to the default gh host for an unresolved SSH repository', async () => { const legacyRepo = { owner: 'team', repo: 'orca' } getOwnerRepoMock.mockResolvedValue(null) @@ -416,227 +213,4 @@ describe('GitHub PR local runtime routing', () => { expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) - - it('preserves a ported GHES host in SSH-backed review reads and mutations', async () => { - const enterpriseRepo = { - owner: 'team', - repo: 'orca', - host: 'github.acme-corp.com:8443' - } - getOwnerRepoMock.mockResolvedValue(null) - getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterpriseRepo) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const endpoint = args.find((arg) => arg.startsWith('repos/team/orca/')) ?? '' - const query = args.find((arg) => arg.startsWith('query=')) ?? '' - if (args[0] === 'pr' && args[1] === 'checks') { - return { stdout: '[]' } - } - if (args[0] === 'pr' && args[1] === 'view') { - if (args.includes('id,headRefOid,baseRefName')) { - return { - stdout: JSON.stringify({ - id: 'PR_enterprise', - headRefOid: 'head-sha', - baseRefName: 'main' - }) - } - } - return { - stdout: JSON.stringify({ - number: 7, - title: 'Enterprise PR', - state: 'OPEN', - url: 'https://github.acme-corp.com:8443/team/orca/pull/7', - labels: [], - updatedAt: '2026-07-16T00:00:00Z', - author: { login: 'pr-author' }, - headRefName: 'feature', - baseRefName: 'main' - }) - } - } - if (query.includes('reviewThreads')) { - return { - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - reviewThreads: { nodes: [] }, - comments: { nodes: [] } - } - } - } - }) - } - } - if ( - endpoint.endsWith('/issues/7/comments?per_page=100') || - endpoint.endsWith('/pulls/7/reviews?per_page=100') - ) { - return { stdout: '[]' } - } - if (endpoint.endsWith('/commits/head-sha/check-runs?per_page=100&page=1')) { - return { - stdout: JSON.stringify({ - total_count: 1, - check_runs: [ - { - id: 88, - name: 'lint', - status: 'completed', - conclusion: 'failure', - details_url: 'https://github.acme-corp.com:8443/team/orca/actions/runs/77/job/88' - } - ] - }) - } - } - if (endpoint.endsWith('/commits/head-sha/status?per_page=100&page=1')) { - return { stdout: JSON.stringify({ total_count: 0, statuses: [] }) } - } - if (endpoint.endsWith('/commits/head-sha/check-suites?per_page=100&page=1')) { - return { stdout: JSON.stringify({ total_count: 0, check_suites: [] }) } - } - if (endpoint.endsWith('/check-runs/88')) { - return { - stdout: JSON.stringify({ - id: 88, - name: 'lint', - status: 'completed', - conclusion: 'failure', - details_url: 'https://github.acme-corp.com:8443/team/orca/actions/runs/77/job/88', - output: { title: 'Lint failed', summary: 'One error' } - }) - } - } - if (endpoint.endsWith('/check-runs/88/annotations?per_page=20')) { - return { stdout: '[]' } - } - if (args.includes('--include') && endpoint === 'repos/team/orca/pulls/7') { - return { - stdout: `HTTP/2.0 200 OK\n\n${JSON.stringify({ - number: 7, - head: { ref: 'feature', sha: 'head-sha' }, - base: { ref: 'main', sha: 'base-sha' } - })}` - } - } - if (query) { - return { stdout: JSON.stringify({ data: { repository: {} } }) } - } - return { - stdout: JSON.stringify({ - id: 13, - user: null, - body: 'Enterprise inline comment' - }) - } - }) - - await expect( - getWorkItemByOwnerRepo('/remote/repo', enterpriseRepo, 7, 'pr', 'ssh-1') - ).resolves.toMatchObject({ number: 7, title: 'Enterprise PR' }) - await expect( - getWorkItemByOwnerRepo('/remote/repo', { owner: 'team', repo: 'orca' }, 7, 'pr', 'ssh-1') - ).resolves.toMatchObject({ number: 7, title: 'Enterprise PR' }) - await expect( - getPRComments('/remote/repo', 7, { prRepo: enterpriseRepo }, 'ssh-1') - ).resolves.toEqual([]) - await expect( - getPRChecks('/remote/repo', 7, undefined, enterpriseRepo, undefined, 'ssh-1') - ).resolves.toEqual([]) - await expect( - getPRCheckDetails('/remote/repo', { checkRunId: 88, prRepo: enterpriseRepo }, 'ssh-1') - ).resolves.toMatchObject({ name: 'lint', conclusion: 'failure' }) - await expect( - rerunPRChecks( - '/remote/repo', - 7, - { headSha: 'head-sha', failedOnly: true, prRepo: enterpriseRepo }, - 'ssh-1' - ) - ).resolves.toEqual({ ok: true, count: 1 }) - await expect( - resolveReviewThread('/remote/repo', 'thread-1', true, 'ssh-1', enterpriseRepo) - ).resolves.toBe(true) - await expect( - addPRReviewCommentReply( - '/remote/repo', - 7, - 11, - 'Enterprise reply', - 'thread-1', - 'src/enterprise.ts', - 10, - 'ssh-1', - enterpriseRepo - ) - ).resolves.toMatchObject({ ok: true }) - await expect( - addPRReviewComment({ - repoPath: '/remote/repo', - connectionId: 'ssh-1', - prRepo: enterpriseRepo, - prNumber: 7, - body: 'Enterprise inline comment', - commitId: 'head-sha', - path: 'src/enterprise.ts', - line: 10 - }) - ).resolves.toMatchObject({ ok: true }) - await expect( - updatePRTitle('/remote/repo', 7, 'New title', 'ssh-1', enterpriseRepo) - ).resolves.toBe(true) - await expect( - updatePRDetails('/remote/repo', 7, { body: 'New body' }, 'ssh-1', enterpriseRepo) - ).resolves.toEqual({ ok: true }) - await expect( - updatePRState('/remote/repo', 7, { state: 'closed' }, 'ssh-1', enterpriseRepo) - ).resolves.toEqual({ - ok: true - }) - await expect( - requestPRReviewers('/remote/repo', 7, ['octo'], 'ssh-1', enterpriseRepo) - ).resolves.toEqual({ - ok: true - }) - await expect( - removePRReviewers('/remote/repo', 7, ['octo'], 'ssh-1', enterpriseRepo) - ).resolves.toEqual({ - ok: true - }) - await expect( - setPRAutoMerge('/remote/repo', 7, true, 'squash', 'ssh-1', enterpriseRepo) - ).resolves.toEqual({ ok: true }) - await expect( - setPRAutoMerge('/remote/repo', 7, false, 'squash', 'ssh-1', enterpriseRepo) - ).resolves.toEqual({ ok: true }) - await expect(mergePR('/remote/repo', 7, 'squash', 'ssh-1', enterpriseRepo)).resolves.toEqual({ - ok: true - }) - - const prViewCall = ghExecFileAsyncMock.mock.calls.find( - ([args]) => args[0] === 'pr' && args[1] === 'view' - ) - // Host qualification happens inside the real runner, after this mocked boundary. - expect(prViewCall?.[0]).toEqual(expect.arrayContaining(['--repo', 'team/orca'])) - expect(prViewCall?.[1]).toEqual({ host: 'github.acme-corp.com:8443' }) - const prCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'pr') - expect( - prCalls.every( - ([args]) => args.includes('--repo') && args[args.indexOf('--repo') + 1] === 'team/orca' - ) - ).toBe(true) - const apiCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'api') - expect(apiCalls.length).toBeGreaterThan(0) - expect( - apiCalls.every( - ([args, options]) => - !args.includes('--hostname') && - options.host === 'github.acme-corp.com:8443' && - options.cwd === undefined && - options.wslDistro === undefined - ) - ).toBe(true) - }) }) diff --git a/src/main/github/client-pr-lookup-coalescing.test.ts b/src/main/github/client-pr-lookup-coalescing.test.ts index c8bb5a63d6d..92d658630d9 100644 --- a/src/main/github/client-pr-lookup-coalescing.test.ts +++ b/src/main/github/client-pr-lookup-coalescing.test.ts @@ -51,58 +51,6 @@ afterEach(() => { }) describe('PR lookup coalescing', () => { - it('shares pending reads across callers and releases them after settlement', async () => { - let finish: ((value: PRRefreshOutcome) => void) | undefined - mocks.resolve.mockImplementation( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const first = getPRForBranchOutcome('/repo', 'refs/heads/topic') - const second = getPRForBranchOutcome('/repo', 'topic') - await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(1)) - finish?.(outcome) - expect(await Promise.all([first, second])).toEqual([outcome, outcome]) - mocks.resolve.mockResolvedValue(outcome) - await getPRForBranchOutcome('/repo', 'topic') - expect(mocks.resolve).toHaveBeenCalledTimes(2) - }) - - it('shares a background read with a foreground caller of the same lookup', async () => { - mocks.resolve.mockResolvedValue(outcome) - await Promise.all([ - getPRForBranchOutcome('/repo', 'topic', null, null, null, { - localGitExecOptions: { admissionTier: 'background' } - }), - getPRForBranchOutcome('/repo', 'topic', null, null, null, { - localGitExecOptions: { admissionTier: 'interactive' } - }) - ]) - expect(mocks.resolve).toHaveBeenCalledTimes(1) - }) - - it('isolates heads, fallback hints, accounts, execution hosts, and credentials', async () => { - mocks.resolve.mockResolvedValue(outcome) - const requests = [ - getPRForBranchOutcome('/repo', 'topic'), - getPRForBranchOutcome('/repo', 'topic', 12), - getPRForBranchOutcome('/repo', 'topic', null, 'ssh-1'), - getPRForBranchOutcome('/repo', 'topic', null, null, 12), - getPRForBranchOutcome('/repo', 'topic', null, null, null, { currentHeadOid: 'other-head' }), - getPRForBranchOutcome('/repo', 'topic', null, null, null, { - localGitExecOptions: { wslDistro: 'Ubuntu' } - }), - getPRForBranchOutcome('/repo', 'topic', null, null, null, { - localGitExecOptions: { ghAccount: { host: 'github.com', user: 'other' } } - }) - ] - vi.stubEnv('GH_TOKEN', 'test-only-other-token') - requests.push(getPRForBranchOutcome('/repo', 'topic')) - await Promise.all(requests) - expect(mocks.resolve).toHaveBeenCalledTimes(8) - }) - it('cleans up errors so later reads can recover', async () => { mocks.resolve.mockRejectedValueOnce(new Error('network down')).mockResolvedValue(outcome) const results = await Promise.all([ @@ -166,42 +114,6 @@ describe('PR lookup coalescing', () => { expect(mocks.resolve).toHaveBeenCalledTimes(2) } ) - - it('keeps native, WSL, and SSH pending reads scoped during invalidation', async () => { - const completions: ((value: PRRefreshOutcome) => void)[] = [] - mocks.resolve.mockImplementation( - () => - new Promise((resolve) => { - completions.push(resolve) - }) - ) - const native = () => getPRForBranchOutcome('/repo', 'topic') - const wsl = () => - getPRForBranchOutcome('/repo', 'topic', null, null, null, { - localGitExecOptions: { wslDistro: 'Ubuntu' } - }) - const ssh = () => getPRForBranchOutcome('/repo', 'topic', null, 'host / encoded') - const reads = [native(), wsl(), ssh()] - try { - await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(3)) - invalidateHostedReviewBranchCache('/other', 'local') - reads.push(native(), wsl(), ssh()) - invalidateHostedReviewBranchCache( - '/repo', - getRepoExecutionHostId({ connectionId: 'host / encoded' }) - ) - reads.push(native(), wsl(), ssh()) - await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(4)) - invalidateHostedReviewBranchCache('/repo', 'local') - reads.push(native(), wsl(), ssh()) - await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(6)) - } finally { - for (const finish of completions) { - finish(outcome) - } - await Promise.all(reads) - } - }) }) it.each([true, false])( diff --git a/src/main/github/client-pr-push-target.test.ts b/src/main/github/client-pr-push-target.test.ts deleted file mode 100644 index 45268fbf158..00000000000 --- a/src/main/github/client-pr-push-target.test.ts +++ /dev/null @@ -1,442 +0,0 @@ -import type * as GitExecFileModule from '../git/command-runner/git-exec-file' -import type * as GhExecFileModule from '../git/command-runner/gh-exec-file' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as GithubApiRepositoryModule from './github-api-repository' -import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' - -const { clientMocks, moduleMocks } = await vi.hoisted(async () => { - const moduleMocks = await import('./client-test-mocks') - return { clientMocks: moduleMocks.createGitHubClientMocks(), moduleMocks } -}) - -vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) -vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) - -vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal()), - gitExecFileAsync: clientMocks.gitExecFileAsyncMock -})) - -vi.mock('../git/command-runner/gh-exec-file', async (importOriginal) => ({ - ...(await importOriginal()), - ghExecFileAsync: clientMocks.ghExecFileAsyncMock -})) -vi.mock('./local-git-config-signature', () => - moduleMocks.localGitConfigSignatureModuleMock(clientMocks) -) -vi.mock('./github-enterprise-repository', async (importOriginal) => - moduleMocks.githubEnterpriseRepositoryModuleMock( - await importOriginal() - ) -) -vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(clientMocks)) -vi.mock('./github-api-repository', async (importOriginal) => - moduleMocks.githubApiRepositoryModuleMock( - clientMocks, - await importOriginal() - ) -) - -import { getRepoSlug, getRepoUpstream } from './client/fetch/repo-slug-upstream' -import { getWorkItem } from './client/fetch/get-work-item' -import { getPullRequestPushTarget } from './client/lookup/pull-request-push-target' -import { resetPRForBranchMocks } from './client-test-harness' - -const { - ghExecFileAsyncMock, - getOwnerRepoMock, - getOwnerRepoForRemoteMock, - resolvePRRepositoryCandidatesMock, - getRemoteUrlForRepoMock, - gitExecFileAsyncMock -} = clientMocks - -describe('getPRForBranch', () => { - beforeEach(() => { - resetPRForBranchMocks(clientMocks) - }) - - it('resolves fork PR push target using the origin URL protocol', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - head: { - ref: 'prateek/fix-sidebar-agents-toggle', - repo: { - full_name: 'prateek/orca', - name: 'orca', - clone_url: 'https://github.com/prateek/orca.git', - ssh_url: 'git@github.com:prateek/orca.git', - owner: { login: 'prateek' } - } - } - }) - }) - getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:stablyai/orca.git') - - const target = await getPullRequestPushTarget('/repo-root', 1738) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/stablyai/orca/pulls/1738'], { - cwd: '/repo-root' - }) - expect(target).toEqual({ - pushTarget: { - remoteName: 'pr-prateek-orca', - branchName: 'prateek/fix-sidebar-agents-toggle', - remoteUrl: 'git@github.com:prateek/orca.git' - } - }) - }) - - it('pins explicit origin push-target lookup when upstream has the same PR number', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'fork', repo: 'orca' }) - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [ - { owner: 'upstream', repo: 'orca' }, - { owner: 'fork', repo: 'orca' } - ], - headRepo: { owner: 'fork', repo: 'orca' } - }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - head: { - ref: 'contributor/fix', - repo: { - full_name: 'contributor/orca', - name: 'orca', - clone_url: 'https://github.com/contributor/orca.git', - ssh_url: 'git@github.com:contributor/orca.git', - owner: { login: 'contributor' } - } - } - }) - }) - getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:fork/orca.git') - - await getPullRequestPushTarget('/repo-root', 1738, null, {}, 'origin') - - expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/fork/orca/pulls/1738'], { - cwd: '/repo-root', - host: 'github.com' - }) - expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( - ['api', 'repos/upstream/orca/pulls/1738'], - expect.anything() - ) - }) - - it('surfaces maintainer_can_modify=false alongside a fork PR push target', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - maintainer_can_modify: false, - head: { - ref: 'prateek/fix-sidebar-agents-toggle', - repo: { - full_name: 'prateek/orca', - name: 'orca', - clone_url: 'https://github.com/prateek/orca.git', - ssh_url: 'git@github.com:prateek/orca.git', - owner: { login: 'prateek' } - } - } - }) - }) - getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:stablyai/orca.git') - - await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({ - pushTarget: { - remoteName: 'pr-prateek-orca', - branchName: 'prateek/fix-sidebar-agents-toggle', - remoteUrl: 'git@github.com:prateek/orca.git' - }, - maintainerCanModify: false - }) - }) - - it('uses origin for same-repository PR push targets', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - head: { - ref: 'fix-sidebar', - repo: { - full_name: 'stablyai/orca', - name: 'orca', - clone_url: 'https://github.com/stablyai/orca.git', - ssh_url: 'git@github.com:stablyai/orca.git', - owner: { login: 'stablyai' } - } - } - }) - }) - - await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({ - pushTarget: { - remoteName: 'origin', - branchName: 'fix-sidebar' - } - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('keeps getRepoSlug origin-based on a fork checkout (#7331)', async () => { - getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => - remoteName === 'origin' - ? { owner: 'fsdwen', repo: 'orca' } - : { owner: 'stablyai', repo: 'orca' } - ) - // Set both fixture seams because the bridge prefers the explicit origin probe. - getOwnerRepoMock.mockResolvedValue({ owner: 'fsdwen', repo: 'orca' }) - - await expect(getRepoSlug('/repo-root')).resolves.toEqual({ - owner: 'fsdwen', - repo: 'orca', - host: 'github.com' - }) - }) - - it('resolves a distinct upstream remote as the repo upstream', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'tmchow', repo: 'orca' }) - getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => - remoteName === 'origin' - ? { owner: 'tmchow', repo: 'orca' } - : { owner: 'stablyai', repo: 'orca' } - ) - - await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ - owner: 'stablyai', - repo: 'orca' - }) - - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('does not treat a same-repository upstream remote as a fork', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'StablyAI', repo: 'Orca' }) - getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => - remoteName === 'origin' - ? { owner: 'StablyAI', repo: 'Orca' } - : { owner: 'stablyai', repo: 'orca' } - ) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ isFork: false, parent: null }) - }) - - await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - // Explicit positional host prevents ambient GH_HOST redirection. - ['repo', 'view', 'github.com/StablyAI/Orca', '--json', 'isFork,parent'], - { cwd: '/repo-root', host: 'github.com', timeout: 10_000 } - ) - }) - - it('does not mark an upstream-only GitHub remote as a fork', async () => { - getOwnerRepoForRemoteMock.mockResolvedValueOnce(null) - - await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() - - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledTimes(1) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined, {}) - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('falls back to the GitHub parent when no upstream remote is configured', async () => { - getOwnerRepoForRemoteMock - .mockResolvedValueOnce({ owner: 'tmchow', repo: 'orca' }) - .mockResolvedValueOnce(null) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - isFork: true, - parent: { name: 'orca', owner: { login: 'stablyai' } } - }) - }) - - await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - }) - - it('routes GHES push-target probes through the Enterprise host', async () => { - const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [ghes], - headRepo: ghes - }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce(ghes) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - head: { - ref: 'feature', - repo: { - full_name: 'team/orca', - name: 'orca', - clone_url: 'https://github.acme-corp.com/team/orca.git', - ssh_url: 'git@github.acme-corp.com:team/orca.git', - owner: { login: 'team' } - } - } - }) - }) - - await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({ - pushTarget: { remoteName: 'origin', branchName: 'feature' } - }) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['api', 'repos/team/orca/pulls/7'], - expect.objectContaining({ host: 'github.acme-corp.com' }) - ) - }) - - it('does not confuse same-slug PR repositories across GitHub hosts', async () => { - const enterprise = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } - const dotCom = { owner: 'team', repo: 'orca', host: 'github.com' } - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [enterprise, dotCom], - headRepo: dotCom - }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce(dotCom) - getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:team/orca.git') - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - head: { - ref: 'feature', - repo: { - full_name: 'team/orca', - name: 'orca', - clone_url: 'https://github.acme-corp.com/team/orca.git', - ssh_url: 'git@github.acme-corp.com:team/orca.git', - owner: { login: 'team' } - } - } - }) - }) - - await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({ - pushTarget: { - remoteName: 'pr-team-orca', - branchName: 'feature', - remoteUrl: 'git@github.acme-corp.com:team/orca.git' - } - }) - }) - - it('probes additional PR repo candidates when the first lookup is not found', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [ - { owner: 'fork', repo: 'orca' }, - { owner: 'stablyai', repo: 'orca' } - ], - headRepo: { owner: 'fork', repo: 'orca' } - }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('HTTP 404: Not Found')) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - head: { - ref: 'feature/test', - repo: { - full_name: 'fork/orca', - name: 'orca', - clone_url: 'https://github.com/fork/orca.git', - ssh_url: 'git@github.com:fork/orca.git', - owner: { login: 'fork' } - } - } - }) - }) - - await expect(getPullRequestPushTarget('/repo-root', 1849)).resolves.toEqual({ - pushTarget: { - remoteName: 'origin', - branchName: 'feature/test' - } - }) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/fork/orca/pulls/1849'], { - cwd: '/repo-root' - }) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['api', 'repos/stablyai/orca/pulls/1849'], - { cwd: '/repo-root' } - ) - }) - - it('probes the next PR work-item candidate after a permission denial', async () => { - const upstream = { owner: 'upstream', repo: 'orca', host: 'github.com' } - const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [upstream, origin], - headRepo: origin - }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('GraphQL: Resource not accessible by integration')) - .mockRejectedValueOnce(new Error('GraphQL: Resource not accessible by integration')) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: 'Origin PR', - state: 'OPEN', - url: 'https://github.com/fork/orca/pull/42', - labels: [], - updatedAt: '2026-07-16T00:00:00Z', - author: { login: 'octo' }, - isDraft: false - }) - }) - .mockRejectedValueOnce(new Error('metadata unavailable')) - - await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toMatchObject({ - number: 42, - title: 'Origin PR', - prRepo: origin - }) - - expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual( - expect.arrayContaining(['pr', 'view', '--repo', 'upstream/orca']) - ) - expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual(['api', 'repos/upstream/orca/pulls/42']) - expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual( - expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca']) - ) - }) - - it('normalizes reviewer avatars from REST pull request payloads', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: 'Review me', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/42', - labels: [], - updated_at: '2026-03-28T00:00:00Z', - user: { login: 'author' }, - draft: false, - requested_reviewers: [ - { - login: 'AmethystLiang', - avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' - } - ] - }) - }) - - await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toMatchObject({ - reviewRequests: [ - { - login: 'AmethystLiang', - avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' - } - ] - }) - }) -}) diff --git a/src/main/github/client-stack-merge-guard.test.ts b/src/main/github/client-stack-merge-guard.test.ts index 846ce0b6e4c..ec9d96e7fce 100644 --- a/src/main/github/client-stack-merge-guard.test.ts +++ b/src/main/github/client-stack-merge-guard.test.ts @@ -36,15 +36,13 @@ vi.mock('./github-api-repository', async (importOriginal) => await importOriginal() ) ) - -import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { mergePR } from './client/merge/merge-pr' import { installIndependentCheckFixtures, resetGraphQLRateLimitGuardMocks } from './client-test-harness' -const { getOwnerRepoMock, acquireMock, releaseMock } = clientMocks +const { acquireMock, releaseMock } = clientMocks const metadataRequests = vi.fn() const unexpectedChecks: string[][] = [] @@ -58,206 +56,8 @@ describe('GitHub GraphQL rate-limit guard', () => { afterEach(() => vi.restoreAllMocks()) - it('hydrates GitHub-registered stack metadata for exact linked PRs', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca', host: 'github.com' }) - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - number: 202, - title: 'Stack API', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/202', - updatedAt: '2026-08-10T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - reviewDecision: null, - mergeStateStatus: 'CLEAN', - autoMergeRequest: null, - baseRefName: 'stack/models', - headRefName: 'stack/api', - baseRefOid: 'b'.repeat(40), - headRefOid: 'a'.repeat(40), - headRepositoryOwner: { login: 'stablyai' }, - headRepository: { name: 'orca' }, - commits: { - nodes: [ - { - commit: { - oid: 'a'.repeat(40), - statusCheckRollup: null, - checkSuites: { - nodes: [], - pageInfo: { hasNextPage: false, endCursor: null } - } - } - } - ] - }, - stack: { number: 51, size: 2, baseRefName: 'main' }, - stackEntry: { position: 2 } - } - } - } - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - autoMergeAllowed: true, - mergeQueue: null, - ref: { rules: { nodes: [] } } - } - } - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - stack: { - number: 51, - size: 2, - baseRefName: 'main', - entries: { - nodes: [ - { - position: 1, - pullRequest: { - number: 201, - title: 'Stack models', - url: 'https://github.com/stablyai/orca/pull/201', - state: 'OPEN', - isDraft: false, - mergeable: 'MERGEABLE', - statusCheckRollup: { state: 'SUCCESS' } - } - }, - { - position: 2, - pullRequest: { - number: 202, - title: 'Stack API', - url: 'https://github.com/stablyai/orca/pull/202', - state: 'OPEN', - isDraft: false, - mergeable: 'MERGEABLE', - statusCheckRollup: { state: 'SUCCESS' } - } - } - ] - } - } - } - } - } - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - mergeQueue: null, - ref: { rules: { nodes: [{ type: 'MERGE_QUEUE' }] } } - } - } - }) - }) - - const pr = await getPRForBranch('/repo-root', 'stack/api', 202) - - expect(pr?.stack).toMatchObject({ - number: 51, - position: 2, - size: 2, - baseRefName: 'main', - entries: [ - { number: 201, position: 1 }, - { number: 202, position: 2 } - ] - }) - expect(pr?.mergeQueueRequired).toBe(true) - expect(metadataRequests).toHaveBeenCalledTimes(4) - expect(metadataRequests.mock.calls[0][0]).toContain('number=202') - expect(metadataRequests.mock.calls[1][0]).toContain('branch=stack/models') - const mergeQueueMetadataCall = metadataRequests.mock.calls.find( - ([args]) => args.includes('graphql') && args.includes('branch=main') - ) - expect(mergeQueueMetadataCall?.[0]).toEqual(expect.arrayContaining(['-f', 'branch=main'])) - }) - const validStackHeadSha = 'a'.repeat(40) const validStackBaseSha = 'b'.repeat(40) - const validSha256HeadSha = 'c'.repeat(64) - - it.each([ - { - objectFormat: 'SHA-1 with a base SHA', - headSha: validStackHeadSha, - baseSha: validStackBaseSha - }, - { - objectFormat: 'SHA-256 without a base SHA', - headSha: validSha256HeadSha, - baseSha: undefined - } - ])('uses async merge only for GitHub-registered stacks using $objectFormat', async (scenario) => { - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 202, - title: 'Stack API', - state: 'open', - head: { ref: 'stack/api', sha: scenario.headSha }, - base: { ref: 'stack/models', sha: 'models-sha' }, - stack: { - number: 51, - position: 2, - size: 2, - base: { - ref: 'main', - ...(scenario.baseSha ? { sha: scenario.baseSha } : {}) - } - } - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } }) - }) - - await expect( - mergePR('/repo-root', 202, 'squash', undefined, { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toEqual({ ok: true }) - - const mergeCall = metadataRequests.mock.calls.find(([args]) => - args.includes('repos/stablyai/orca/pulls/202/merge-async') - ) - expect(mergeCall?.[0]).toEqual( - expect.arrayContaining([ - 'PUT', - 'repos/stablyai/orca/pulls/202/merge-async', - 'merge_action=merge_queue', - `sha=${scenario.headSha}` - ]) - ) - expect(mergeCall?.[0]).not.toContain('merge_method=squash') - expect(acquireMock).toHaveBeenCalledTimes(2) - expect(releaseMock).toHaveBeenCalledTimes(2) - expect( - metadataRequests.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge') - ).toBe(false) - }) it('never falls back to legacy merge after an async stack merge transport failure', async () => { metadataRequests @@ -633,100 +433,4 @@ describe('GitHub GraphQL rate-limit guard', () => { ) expect.soft(consoleWarnSpy).toHaveBeenCalledTimes(1) }) - - it.each([ - { stackVariant: 'omits stack', stackField: {} }, - { stackVariant: 'sets stack to null', stackField: { stack: null } } - ])('keeps legacy merge when an ordinary GitHub response $stackVariant', async (scenario) => { - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 13866, - title: 'Fail closed on unavailable stack metadata', - state: 'open', - head: { - ref: 'sta-3924-stack-merge-fail-closed', - sha: validStackHeadSha - }, - base: { ref: 'main', sha: validStackBaseSha }, - ...scenario.stackField - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 13866, - title: 'Fail closed on unavailable stack metadata', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/13866', - statusCheckRollup: [], - updatedAt: '2026-08-11T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - baseRefOid: validStackBaseSha, - headRefName: 'sta-3924-stack-merge-fail-closed', - headRefOid: validStackHeadSha - }) - }) - .mockResolvedValueOnce({ stdout: '', stderr: '' }) - - await expect( - mergePR('/repo-root', 13866, 'squash', undefined, { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toEqual({ ok: true }) - - expect(metadataRequests).toHaveBeenNthCalledWith( - 3, - ['pr', 'merge', '13866', '--squash', '--repo', 'stablyai/orca'], - expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) - ) - expect(acquireMock).toHaveBeenCalledTimes(1) - expect(releaseMock).toHaveBeenCalledTimes(1) - }) - - it('keeps legacy merge for unregistered dependent PR chains', async () => { - metadataRequests - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 202, - title: 'Dependent API', - state: 'open', - head: { ref: 'feature/api', sha: 'api-sha' }, - base: { ref: 'feature/models', sha: 'models-sha' }, - stack: null - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 202, - title: 'Dependent API', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/202', - statusCheckRollup: [], - updatedAt: '2026-08-10T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'feature/models', - headRefOid: 'api-sha' - }) - }) - .mockResolvedValueOnce({ stdout: '', stderr: '' }) - - await expect( - mergePR('/repo-root', 202, 'squash', undefined, { - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - ).resolves.toEqual({ ok: true }) - - expect(metadataRequests).toHaveBeenNthCalledWith( - 3, - ['pr', 'merge', '202', '--squash', '--repo', 'stablyai/orca'], - expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) - ) - }) }) diff --git a/src/main/github/client-tracked-upstream-default-branch.test.ts b/src/main/github/client-tracked-upstream-default-branch.test.ts index 86f2132e5bd..ec2c206f327 100644 --- a/src/main/github/client-tracked-upstream-default-branch.test.ts +++ b/src/main/github/client-tracked-upstream-default-branch.test.ts @@ -38,7 +38,6 @@ vi.mock('./github-api-repository', async (importOriginal) => ) import { getPRForBranch } from './client/lookup/get-pr-for-branch' -import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' import { resetPRForBranchMocks } from './client-test-harness' import type { RestPullRequest } from './client/lookup/pull-request-lookup-data' import type { OwnerRepo } from './gh-utils' @@ -46,14 +45,11 @@ import type { OwnerRepo } from './gh-utils' const { ghExecFileAsyncMock, getOwnerRepoMock, - getOwnerRepoForRemoteMock, resolvePRRepositoryCandidatesMock, gitExecFileAsyncMock } = clientMocks const ACME = { owner: 'acme', repo: 'widgets' } -const UPSTREAM = { owner: 'stablyai', repo: 'widgets' } -const CONTRIBUTOR = { owner: 'contributor', repo: 'widgets' } function primeGit( trackedUpstream: string, @@ -164,312 +160,12 @@ function primeGh( ) } -function primeForkWithUpstream(): void { - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [UPSTREAM, ACME], - headRepo: ACME - }) - getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => - remoteName === 'upstream' ? UPSTREAM : remoteName === 'origin' ? ACME : null - ) -} - describe('issue #26948: a branch tracking the default branch', () => { beforeEach(() => { resetPRForBranchMocks(clientMocks) getOwnerRepoMock.mockResolvedValue(ACME) }) - it('does not attach a PR whose head is the default branch', async () => { - primeGit('refs/remotes/origin/develop') - primeGh({ 'acme:develop': [restPR('develop')] }) - - const pr = await getPRForBranch('/repo-root', 'feature/my-change') - - expect(pr).toBeNull() - }) - - it('drops a cached PR number whose head is the tracked default branch', async () => { - primeGit('refs/remotes/origin/develop') - primeGh({}) - - const outcome = await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7) - - expect(outcome.kind).toBe('no-pr') - }) - - it('identifies the rejected merged integration PR so head-current caches can clear it', async () => { - primeGit('refs/remotes/origin/develop') - const integrationPR = { - ...restPR(), - merged_at: '2025-01-02T00:00:00Z', - head: { ...restPR().head, sha: 'feature-head-oid' } - } - primeGh({ 'acme:develop': [integrationPR] }) - - expect( - await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7, { - currentHeadOid: 'feature-head-oid', - acceptMergedFallbackPR: true - }) - ).toMatchObject({ - kind: 'no-pr', - rejectedPRUrls: ['https://github.com/acme/widgets/pull/7'] - }) - }) - - it('skips the default branch on a second remote that PRs target (fork checkout off upstream)', async () => { - primeForkWithUpstream() - primeGit('refs/remotes/upstream/develop', { origin: 'develop', upstream: 'develop' }) - primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) - - const pr = await getPRForBranch('/repo-root', 'feature/my-change') - - expect(pr).toBeNull() - }) - - it("uses the tracked remote's own default branch when it differs from origin's", async () => { - primeForkWithUpstream() - primeGit('refs/remotes/upstream/develop', { origin: 'main', upstream: 'develop' }) - primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) - - const pr = await getPRForBranch('/repo-root', 'feature/my-change') - - expect(pr).toBeNull() - }) - - it("keeps a PR headed by a tracked branch that is only origin's default", async () => { - primeForkWithUpstream() - primeGit('refs/remotes/upstream/develop', { origin: 'develop', upstream: 'main' }) - primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) - - const pr = await getPRForBranch('/repo-root', 'feature/my-change') - - expect(pr).toMatchObject({ number: 7 }) - }) - - it("still follows a contributor fork's branch that shares the default branch's name", async () => { - getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => - remoteName === 'contributor' ? { owner: 'contributor', repo: 'widgets' } : ACME - ) - primeGit('refs/remotes/contributor/develop') - primeGh({ 'contributor:develop': [{ ...restPR('develop', CONTRIBUTOR), state: 'open' }] }) - - const pr = await getPRForBranch('/repo-root', 'feature/my-change') - - expect(pr).toMatchObject({ number: 7, headRepo: { owner: 'contributor', repo: 'widgets' } }) - }) - - it('preserves a cached contributor PR sharing the tracked default branch name', async () => { - primeGit('refs/remotes/origin/develop') - primeGh({}, { ...restPR('develop', CONTRIBUTOR), state: 'open' }) - const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7) - expect(pr).toMatchObject({ number: 7, headRepo: CONTRIBUTOR }) - }) - - it('preserves a PR from the origin fork default branch into upstream', async () => { - primeForkWithUpstream() - primeGit('refs/remotes/origin/develop') - primeGh({ 'acme:develop': [{ ...restPR('develop', ACME, UPSTREAM), state: 'open' }] }) - const pr = await getPRForBranch('/repo-root', 'feature/my-change') - expect(pr).toMatchObject({ number: 7, headRepo: ACME, prRepo: UPSTREAM }) - }) - - it('clears an old cached integration PR after upstream tracking is removed', async () => { - primeGit('') - primeGh({}) - expect( - (await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7)).kind - ).toBe('no-pr') - }) - - it.each(['closed', 'merged'] as const)( - 'clears a cached %s integration PR in a fork after upstream tracking is removed', - async (state) => { - primeForkWithUpstream() - primeGit('', { origin: 'main' }) - const integrationPR = restPR('develop', UPSTREAM, UPSTREAM) - primeGh( - {}, - { - ...integrationPR, - merged_at: state === 'merged' ? '2025-01-02T00:00:00Z' : null, - head: { - ...integrationPR.head, - sha: 'feature-head-oid', - repo: { name: 'widgets', owner: { login: 'stablyai' }, default_branch: 'develop' } - } - } - ) - - expect( - await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7, { - currentHeadOid: 'feature-head-oid', - acceptMergedFallbackPR: true - }) - ).toMatchObject({ - kind: 'no-pr', - rejectedPRUrls: ['https://github.com/stablyai/widgets/pull/7'] - }) - } - ) - - it('preserves a cached renamed feature PR using its own repository default metadata', async () => { - primeForkWithUpstream() - primeGit('', { origin: 'develop' }) - const featurePR = restPR('develop', UPSTREAM, UPSTREAM) - primeGh( - {}, - { - ...featurePR, - state: 'open', - head: { - ...featurePR.head, - repo: { name: 'widgets', owner: { login: 'stablyai' }, default_branch: 'main' } - } - } - ) - - expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toMatchObject({ - number: 7, - headRefName: 'develop' - }) - }) - - it.each(['unavailable', 'changed-head'] as const)( - 'preserves an untracked cached PR when REST metadata is %s', - async (metadataState) => { - primeForkWithUpstream() - primeGit('', { origin: 'develop' }) - primeGh({}, { ...restPR('develop', UPSTREAM, UPSTREAM), state: 'open' }) - const ghImplementation = ghExecFileAsyncMock.getMockImplementation() - ghExecFileAsyncMock.mockImplementation(async (args: string[], options) => { - if (args[0] === 'api' && args[1] === 'repos/stablyai/widgets/pulls/7') { - if (metadataState === 'unavailable') { - throw new Error('network unavailable') - } - const pr = restPR('develop', ACME, UPSTREAM) - return { - stdout: JSON.stringify({ - ...pr, - head: { - ...pr.head, - repo: { name: 'widgets', owner: { login: 'acme' }, default_branch: 'develop' } - } - }) - } - } - return ghImplementation?.(args, options) - }) - - expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toMatchObject({ - number: 7, - headRepo: UPSTREAM - }) - } - ) - - it('tries the cached feature PR after rejecting an unrelated upstream result', async () => { - primeGit('refs/remotes/origin/develop') - primeGh( - { 'acme:develop': [restPR()] }, - { ...restPR('feature/my-change'), number: 8, state: 'open' } - ) - const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 8) - expect(pr).toMatchObject({ number: 8, headRefName: 'feature/my-change' }) - }) - - it('does not borrow origin default when upstream HEAD is unknown', async () => { - primeForkWithUpstream() - primeGit('refs/remotes/upstream/develop', { origin: 'develop' }) - primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) - expect(await getPRForBranch('/repo-root', 'feature/my-change')).toMatchObject({ number: 7 }) - }) - - it('uses PR head default metadata when upstream has no recorded HEAD', async () => { - primeForkWithUpstream() - primeGit('refs/remotes/upstream/develop', { origin: 'main' }) - const pr = restPR('develop', UPSTREAM, UPSTREAM) - primeGh({ - 'stablyai:develop': [ - { - ...pr, - head: { - ...pr.head, - repo: { name: 'widgets', owner: { login: 'stablyai' }, default_branch: 'develop' } - } - } - ] - }) - expect(await getPRForBranch('/repo-root', 'feature/my-change')).toBeNull() - }) - - it('preserves explicit links to integration PRs', async () => { - primeGit('refs/remotes/origin/develop') - primeGh({}) - expect(await getPRForBranch('/repo-root', 'feature/my-change', 7)).toMatchObject({ number: 7 }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('preserves cached review context while detached', async () => { - primeGh({}) - expect(await getPRForBranch('/repo-root', '', null, null, 7)).toMatchObject({ number: 7 }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('does not infer a head repository for a cached PR with unavailable fork metadata', async () => { - primeGit('refs/remotes/origin/develop') - const pr = restPR() - primeGh({}, { ...pr, head: { ref: 'develop', sha: 'deleted-fork-oid', repo: null } }) - expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toMatchObject({ - number: 7 - }) - }) - - it('does not fetch the rejected integration PR twice when its number is cached', async () => { - primeGit('refs/remotes/origin/develop') - primeGh({ 'acme:develop': [restPR()] }) - expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toBeNull() - const exactCalls = ghExecFileAsyncMock.mock.calls.filter( - ([args]) => args[0] === 'pr' && args[1] === 'view' - ) - expect(exactCalls).toHaveLength(1) - }) - - it('does not reject a cached PR because another repository rejected the same number', async () => { - primeForkWithUpstream() - primeGit('refs/remotes/upstream/develop', { origin: 'main', upstream: 'develop' }) - const unrelated = restPR('develop', UPSTREAM, UPSTREAM) - const cached = { ...restPR('feature/my-change'), state: 'open' } - primeGh({ 'stablyai:develop': [unrelated] }, [unrelated, cached]) - const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7) - expect(pr).toMatchObject({ number: 7, prRepo: ACME, headRefName: 'feature/my-change' }) - }) - - it('continues cached number recovery after rejecting another repository default PR', async () => { - primeForkWithUpstream() - primeGit('refs/remotes/upstream/develop', { origin: 'main', upstream: 'develop' }) - primeGh({}, [ - restPR('develop', UPSTREAM, UPSTREAM), - { ...restPR('feature/my-change'), state: 'open' } - ]) - const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7) - expect(pr).toMatchObject({ number: 7, prRepo: ACME, headRefName: 'feature/my-change' }) - }) - - it('does not accept another repository head with the same owner and branch name', async () => { - const other = { owner: 'acme', repo: 'other-widgets' } - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [other, ACME], - headRepo: ACME - }) - primeGit('') - primeGh({ - 'acme:feature/my-change': [{ ...restPR('feature/my-change', other, other), state: 'open' }] - }) - expect(await getPRForBranch('/repo-root', 'feature/my-change')).toBeNull() - }) - it('does not query another GitHub server for a known public GitHub head', async () => { const enterprise = { ...ACME, host: 'ghe.example' } resolvePRRepositoryCandidatesMock.mockResolvedValue({ @@ -487,38 +183,4 @@ describe('issue #26948: a branch tracking the default branch', () => { ghExecFileAsyncMock.mock.calls.some(([, options]) => options?.host === 'ghe.example') ).toBe(false) }) - - it('preserves a tracked contributor fork with a different repository name', async () => { - const fork = { owner: 'contributor', repo: 'renamed-widgets' } - getOwnerRepoForRemoteMock.mockImplementation(async (_path: string, remote: string) => - remote === 'contributor' ? fork : ACME - ) - primeGit('refs/remotes/contributor/develop') - primeGh({ 'contributor:develop': [{ ...restPR('develop', fork), state: 'open' }] }) - expect(await getPRForBranch('/repo-root', 'feature/my-change')).toMatchObject({ - number: 7, - headRepo: fork, - prRepo: ACME - }) - }) - - it('continues to a valid branch result after another repository returns the wrong head', async () => { - const other = { owner: 'acme', repo: 'other-widgets' } - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [other, ACME], - headRepo: ACME - }) - primeGit('') - primeGh({ - 'acme:feature/my-change': [ - { ...restPR('feature/my-change', other, other), state: 'open' }, - { ...restPR('feature/my-change'), state: 'open' } - ] - }) - expect(await getPRForBranch('/repo-root', 'feature/my-change')).toMatchObject({ - number: 7, - headRepo: ACME, - prRepo: ACME - }) - }) }) diff --git a/src/main/github/client-tracked-upstream-snapshot.test.ts b/src/main/github/client-tracked-upstream-snapshot.test.ts index bfaf61706b9..7b9474a76ab 100644 --- a/src/main/github/client-tracked-upstream-snapshot.test.ts +++ b/src/main/github/client-tracked-upstream-snapshot.test.ts @@ -46,7 +46,6 @@ import { resetPRForBranchMocks } from './client-test-harness' const { ghExecFileAsyncMock, - getOwnerRepoMock, getOwnerRepoForRemoteMock, resolvePRRepositoryCandidatesMock, gitExecFileAsyncMock, @@ -58,472 +57,6 @@ describe('getPRForBranch', () => { resetPRForBranchMocks(clientMocks) }) - it('falls back to the tracked upstream branch when the local branch name differs', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 78, - title: 'Upstream branch PR', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/78', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'contributor/original', sha: 'upstream-head-oid' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 78, - title: 'Hydrated upstream branch PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/78', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'contributor/original', - baseRefOid: 'base-oid', - headRefOid: 'upstream-head-oid' - }) - }) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'local-created-from-pr\0origin/contributor/original\n', - stderr: '' - }) - - const pr = await getPRForBranch('/repo-root', 'local-created-from-pr') - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - 'repos/acme/widgets/pulls?head=acme%3Alocal-created-from-pr&state=all&per_page=10', - '--include' - ], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - 'repos/acme/widgets/pulls?head=acme%3Acontributor%2Foriginal&state=all&per_page=10', - '--include' - ], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, - [ - 'pr', - 'view', - '78', - '--repo', - 'acme/widgets', - '--json', - 'number,title,state,url,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository,isCrossRepository' - ], - { cwd: '/repo-root' } - ) - expect(pr).toMatchObject({ - number: 78, - title: 'Hydrated upstream branch PR', - headSha: 'upstream-head-oid' - }) - }) - - it('does not repeat missing tracked-upstream probes during PR refresh polling', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [{ owner: 'acme', repo: 'widgets' }], - headRepo: { owner: 'acme', repo: 'widgets' } - }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'no-pr-branch\0\n', stderr: '' }) - - await getPRForBranch('/repo-root', 'no-pr-branch') - await getPRForBranch('/repo-root', 'no-pr-branch') - await getPRForBranch('/repo-root', 'no-pr-branch') - - const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('refs/heads') - ) - expect(trackedUpstreamCalls).toHaveLength(1) - }) - - it('releases tracked-upstream probe generations across runtime identities', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) - - await getPRForBranch('/repo-root', 'feature') - await getPRForBranch('/repo-root', 'feature', null, null, null, { - localGitExecOptions: { wslDistro: 'Ubuntu' } - }) - - expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ - snapshots: 2, - inFlight: 0, - generations: 0 - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('bounds unique tracked-upstream snapshots and sweeps expired identities', async () => { - vi.useFakeTimers() - try { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) - - for (let index = 0; index < 513; index += 1) { - await getPRForBranch(`/repo-root-${index}`, 'feature') - } - await getPRForBranch('/repo-root-512', 'feature') - - expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ - snapshots: 512, - inFlight: 0, - generations: 0 - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(513) - - await vi.advanceTimersByTimeAsync(30_001) - await getPRForBranch('/repo-root-fresh', 'feature') - - expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ - snapshots: 1, - inFlight: 0, - generations: 0 - }) - } finally { - vi.useRealTimers() - } - }) - - it('keeps stale probe cleanup from releasing a replacement generation', async () => { - let resolveOldProbe: (value: { stdout: string; stderr: string }) => void - let resolveCurrentProbe: (value: { stdout: string; stderr: string }) => void - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) - gitExecFileAsyncMock - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveOldProbe = resolve - }) - ) - .mockResolvedValueOnce({ stdout: 'replacement\0\n', stderr: '' }) - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveCurrentProbe = resolve - }) - ) - - const oldLookup = getPRForBranch('/repo-root', 'old') - await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)) - __resetTrackedUpstreamBranchCacheForTests() - await getPRForBranch('/repo-root', 'replacement') - const currentLookup = getPRForBranch('/repo-root', 'current') - await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3)) - - resolveOldProbe!({ stdout: 'old\0\n', stderr: '' }) - await oldLookup - expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ - snapshots: 0, - inFlight: 1, - generations: 1 - }) - - resolveCurrentProbe!({ stdout: 'current\0\n', stderr: '' }) - await currentLookup - expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ - snapshots: 1, - inFlight: 0, - generations: 0 - }) - }) - - it('releases tracked-upstream probe state when setup rejects', async () => { - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) - readLocalGitConfigSignatureMock.mockRejectedValue(new Error('git config unavailable')) - - await expect(getPRForBranch('/repo-root', 'feature')).resolves.toBeNull() - expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ - snapshots: 0, - inFlight: 0, - generations: 0 - }) - }) - - it('does not fan out tracked-upstream probes after a transient for-each-ref failure', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [{ owner: 'acme', repo: 'widgets' }], - headRepo: { owner: 'acme', repo: 'widgets' } - }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) - gitExecFileAsyncMock - .mockRejectedValueOnce(new Error('fatal: cannot lock ref')) - .mockResolvedValue({ stdout: 'alpha\0\nbeta\0\ngamma\0\n', stderr: '' }) - - await getPRForBranch('/repo-root', 'alpha') - await getPRForBranch('/repo-root', 'beta') - await getPRForBranch('/repo-root', 'gamma') - - const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('refs/heads') - ) - expect(trackedUpstreamCalls).toHaveLength(2) - }) - - it('refreshes the tracked-upstream snapshot when a branch appears inside the TTL', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [{ owner: 'acme', repo: 'widgets' }], - headRepo: { owner: 'acme', repo: 'widgets' } - }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'existing\0\n', stderr: '' }) - .mockResolvedValueOnce({ - stdout: 'existing\0\nnew-feature\0origin/contributor/original\n', - stderr: '' - }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 78, - title: 'New branch upstream PR', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/78', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'contributor/original', sha: 'upstream-head-oid' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 78, - title: 'Hydrated new branch upstream PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/78', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'contributor/original', - baseRefOid: 'base-oid', - headRefOid: 'upstream-head-oid' - }) - }) - - await getPRForBranch('/repo-root', 'existing') - const pr = await getPRForBranch('/repo-root', 'new-feature') - - const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('refs/heads') - ) - expect(trackedUpstreamCalls).toHaveLength(2) - expect(pr).toMatchObject({ - number: 78, - title: 'Hydrated new branch upstream PR' - }) - }) - - it('parses full local branch refs from the tracked-upstream snapshot', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'refs/heads/feature\0origin/contributor/original\n', - stderr: '' - }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 80, - title: 'Ambiguous ref upstream PR', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/80', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'contributor/original', sha: 'upstream-head-oid' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 80, - title: 'Hydrated ambiguous ref upstream PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/80', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'contributor/original', - baseRefOid: 'base-oid', - headRefOid: 'upstream-head-oid' - }) - }) - - const pr = await getPRForBranch('/repo-root', 'feature') - - expect(pr).toMatchObject({ - number: 80, - title: 'Hydrated ambiguous ref upstream PR' - }) - }) - - it('parses full upstream refs from the tracked-upstream snapshot', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'widgets' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'refs/heads/feature\0refs/remotes/fork/contributor/original\n', - stderr: '' - }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 83, - title: 'Full upstream ref PR', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/83', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'contributor/original', sha: 'upstream-head-oid' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 83, - title: 'Hydrated full upstream ref PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/83', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'contributor/original', - baseRefOid: 'base-oid', - headRefOid: 'upstream-head-oid' - }) - }) - - const pr = await getPRForBranch('/repo-root', 'feature') - - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'fork', undefined) - expect(pr).toMatchObject({ - number: 83, - title: 'Hydrated full upstream ref PR', - headRepo: { owner: 'fork-owner', repo: 'widgets' } - }) - }) - - it('ignores full local-branch upstream refs from the tracked-upstream snapshot', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'refs/heads/feature\0refs/heads/main\n', - stderr: '' - }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - - const pr = await getPRForBranch('/repo-root', 'feature') - - expect(pr).toBeNull() - expect(getOwnerRepoForRemoteMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - - it('invalidates the local tracked-upstream snapshot when git config changes', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [{ owner: 'acme', repo: 'widgets' }], - headRepo: { owner: 'acme', repo: 'widgets' } - }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - readLocalGitConfigSignatureMock - .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-a\u0000100') - .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-a\u0000100') - .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') - .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') - .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'feature\0\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 79, - title: 'Reconfigured upstream PR', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/79', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'contributor/original', sha: 'upstream-head-oid' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 79, - title: 'Hydrated reconfigured upstream PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/79', - statusCheckRollup: [], - updatedAt: '2026-03-28T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - baseRefName: 'main', - headRefName: 'contributor/original', - baseRefOid: 'base-oid', - headRefOid: 'upstream-head-oid' - }) - }) - - await getPRForBranch('/repo-root', 'feature') - const pr = await getPRForBranch('/repo-root', 'feature') - - const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('refs/heads') - ) - expect(trackedUpstreamCalls).toHaveLength(2) - expect(pr).toMatchObject({ - number: 79, - title: 'Hydrated reconfigured upstream PR' - }) - }) - it('does not cache positive tracked-upstream entries when config changes during the snapshot', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], @@ -571,75 +104,6 @@ describe('getPRForBranch', () => { }) }) - it('does not cache null tracked-upstream entries when config changes during the snapshot', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [{ owner: 'acme', repo: 'widgets' }], - headRepo: { owner: 'acme', repo: 'widgets' } - }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - readLocalGitConfigSignatureMock - .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-a\u0000100') - .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') - .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') - .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'feature\0\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 85, - title: 'Unstable config upstream PR', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/85', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'contributor/original', sha: 'upstream-head-oid' } - } - ]) - }) - - await getPRForBranch('/repo-root', 'feature') - const pr = await getPRForBranch('/repo-root', 'feature') - - const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('refs/heads') - ) - expect(trackedUpstreamCalls).toHaveLength(2) - expect(pr).toMatchObject({ - number: 85, - title: 'Unstable config upstream PR' - }) - }) - - it('coalesces concurrent missing tracked-upstream probes', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [{ owner: 'acme', repo: 'widgets' }], - headRepo: { owner: 'acme', repo: 'widgets' } - }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) - gitExecFileAsyncMock.mockImplementation(async () => { - await Promise.resolve() - return { stdout: 'no-pr-branch\0\n', stderr: '' } - }) - - await Promise.all([ - getPRForBranch('/repo-root', 'no-pr-branch'), - getPRForBranch('/repo-root', 'no-pr-branch'), - getPRForBranch('/repo-root', 'no-pr-branch') - ]) - - const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('refs/heads') - ) - expect(trackedUpstreamCalls).toHaveLength(1) - }) - it('does not cache synthetic nulls from concurrent tracked-upstream waiters', async () => { let resolveSnapshot: (value: { stdout: string; stderr: string }) => void resolvePRRepositoryCandidatesMock.mockResolvedValue({ @@ -692,57 +156,4 @@ describe('getPRForBranch', () => { title: 'Concurrent waiter upstream PR' }) }) - - it('keeps missing tracked-upstream probes separate for host and WSL runtimes', async () => { - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [{ owner: 'acme', repo: 'widgets' }], - headRepo: { owner: 'acme', repo: 'widgets' } - }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'no-pr-branch\0\n', stderr: '' }) - - await getPRForBranch('/repo-root', 'no-pr-branch') - await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { - localGitExecOptions: { wslDistro: 'Ubuntu' } - }) - await getPRForBranch('/repo-root', 'no-pr-branch') - await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { - localGitExecOptions: { wslDistro: 'Ubuntu' } - }) - - const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('refs/heads') - ) - expect(trackedUpstreamCalls).toHaveLength(2) - expect(trackedUpstreamCalls[0][1]).toEqual({ cwd: '/repo-root' }) - expect(trackedUpstreamCalls[1][1]).toEqual({ - cwd: '/repo-root', - wslDistro: 'Ubuntu' - }) - }) - - it('rechecks missing tracked-upstream probes after the null-cache TTL expires', async () => { - vi.useFakeTimers() - try { - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [{ owner: 'acme', repo: 'widgets' }], - headRepo: { owner: 'acme', repo: 'widgets' } - }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) - gitExecFileAsyncMock - .mockRejectedValueOnce(new Error("fatal: no upstream configured for branch 'feature'")) - .mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' }) - - await getPRForBranch('/repo-root', 'feature') - await vi.advanceTimersByTimeAsync(30_001) - await getPRForBranch('/repo-root', 'feature') - - const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('refs/heads') - ) - expect(trackedUpstreamCalls).toHaveLength(2) - } finally { - vi.useRealTimers() - } - }) }) diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts deleted file mode 100644 index a6c16eec51c..00000000000 --- a/src/main/github/client-work-items-query-paging.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as GithubApiRepositoryModule from './github-api-repository' - -// Keep legacy REST request/failure coverage; API-boundary suites exercise the GraphQL path. -vi.mock('./client/list/work-item-search-page', () => ({ usesGraphqlWorkItemSearch: () => false })) - -const { - execFileAsyncMock, - ghExecFileAsyncMock, - getOwnerRepoMock, - getIssueOwnerRepoMock, - getOwnerRepoForRemoteMock, - resolveIssueSourceMock, - gitExecFileAsyncMock, - rateLimitGuardMock, - noteRateLimitSpendMock, - repositoryRateLimitGuardMock, - noteRepositoryRateLimitSpendMock, - spendsSharedGitHubComQuotaMock, - acquireMock, - releaseMock -} = vi.hoisted(() => ({ - execFileAsyncMock: vi.fn(), - ghExecFileAsyncMock: vi.fn(), - getOwnerRepoMock: vi.fn(), - getIssueOwnerRepoMock: vi.fn(), - getOwnerRepoForRemoteMock: vi.fn(), - resolveIssueSourceMock: vi.fn(), - gitExecFileAsyncMock: vi.fn(), - rateLimitGuardMock: vi.fn((_bucket?: unknown) => ({ blocked: false })), - noteRateLimitSpendMock: vi.fn(), - repositoryRateLimitGuardMock: vi.fn((_repo: unknown, bucket: string) => - rateLimitGuardMock(bucket) - ), - noteRepositoryRateLimitSpendMock: vi.fn(), - spendsSharedGitHubComQuotaMock: vi.fn(() => true), - acquireMock: vi.fn(), - releaseMock: vi.fn() -})) - -vi.mock('./gh-utils', () => ({ - execFileAsync: execFileAsyncMock, - ghExecFileAsync: ghExecFileAsyncMock, - githubRepoContext: ( - repoPath: string, - connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} - ) => ({ - repoPath, - connectionId: connectionId ?? null, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }), - ghRepoExecOptions: (context: { - repoPath: string - connectionId?: string | null - wslDistro?: string - }) => - context.connectionId - ? {} - : { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) }, - getOwnerRepo: getOwnerRepoMock, - getIssueOwnerRepo: getIssueOwnerRepoMock, - getOwnerRepoForRemote: getOwnerRepoForRemoteMock, - resolveIssueSource: resolveIssueSourceMock, - acquire: acquireMock, - release: releaseMock, - _resetOwnerRepoCache: vi.fn(), - classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }), - classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }), - classifyListPrsError: (stderr: string) => ({ type: 'unknown', message: stderr }) -})) - -vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal>()), - gitExecFileAsync: gitExecFileAsyncMock -})) - -vi.mock('./rate-limit', () => ({ - rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock, - getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), - repositoryRateLimitGuard: repositoryRateLimitGuardMock, - noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, - spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock -})) - -vi.mock('./github-api-repository', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - // Bridge hosted source resolution to the suite's gh-utils fixtures. - resolveIssueGitHubApiRepositorySource: ( - repoPath: string, - preference: unknown, - connectionId?: string | null, - localGitOptions?: unknown - ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), - getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => - getIssueOwnerRepoMock(repoPath, connectionId), - getOriginGitHubApiRepository: ( - repoPath: string, - connectionId?: string | null, - localGitOptions?: unknown - ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), - getGitHubApiRepositoryForRemote: ( - repoPath: string, - remoteName: string, - connectionId?: string | null, - localGitOptions?: unknown - ) => - remoteName === 'origin' - ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) - : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions) - } -}) - -import { countWorkItems } from './client/list/count-work-items' -import { listWorkItems } from './client/list/list-work-items' -import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache' -import { _resetOwnerRepoCache } from './gh-utils' -import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github/work-items-query-bounds' - -import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' - -beforeEach(() => { - _resetOriginGitHubApiRepositoryCache() -}) -describe('listWorkItems query paging', () => { - beforeEach(() => { - execFileAsyncMock.mockReset() - ghExecFileAsyncMock.mockReset() - getOwnerRepoMock.mockReset() - getIssueOwnerRepoMock.mockReset() - getOwnerRepoForRemoteMock.mockReset() - resolveIssueSourceMock.mockReset() - gitExecFileAsyncMock.mockReset() - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) - noteRateLimitSpendMock.mockReset() - repositoryRateLimitGuardMock.mockClear() - noteRepositoryRateLimitSpendMock.mockReset() - spendsSharedGitHubComQuotaMock.mockReset() - spendsSharedGitHubComQuotaMock.mockReturnValue(true) - acquireMock.mockReset() - releaseMock.mockReset() - acquireMock.mockResolvedValue(undefined) - // Preserve one source lookup through the preference-aware resolver. - resolveIssueSourceMock.mockImplementation(async () => ({ - source: await getIssueOwnerRepoMock(), - fellBack: false - })) - getOwnerRepoForRemoteMock.mockResolvedValue(null) - _resetOwnerRepoCache() - _resetMergeQueueCacheForTests() - }) - - it('returns zero for oversized count queries before resolving repo sources', async () => { - const secret = 'main-github-work-items-secret' - const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) - - await expect(countWorkItems('/repo-root', oversizedQuery)).resolves.toBe(0) - - expect(resolveIssueSourceMock).not.toHaveBeenCalled() - expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() - expect(getOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(acquireMock).not.toHaveBeenCalled() - expect(releaseMock).not.toHaveBeenCalled() - }) - - it('uses an implicit WSL UNC cwd for search quota accounting', async () => { - const repository = { owner: 'acme', repo: 'widgets', host: 'github.com' } - const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\me\widgets` - getIssueOwnerRepoMock.mockResolvedValueOnce(repository) - getOwnerRepoMock.mockResolvedValueOnce(repository) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '3' }) - spendsSharedGitHubComQuotaMock.mockReturnValue(false) - - await expect(countWorkItems(repoPath, 'is:issue is:open')).resolves.toBe(3) - - const executionOptions = { cwd: repoPath, host: 'github.com' } - expect(spendsSharedGitHubComQuotaMock).toHaveBeenCalledWith(repository, executionOptions) - expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( - repository, - 'search', - executionOptions - ) - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( - repository, - 'search', - 1, - executionOptions - ) - }) - - it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'review-requested:@me is:open') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'is:open review-requested:@me sort:created-desc']), - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( - expect.arrayContaining(['--review-requested']), - expect.anything() - ) - }) - - it('uses the requested numbered Search API page for issues', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'is:issue is:open', 2) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`, - '--jq', - '.items' - ], - { cwd: '/repo-root' } - ) - }) - - it('fetches and slices stable PR results for the requested numbered page', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify( - [1, 2, 3, 4].map((number) => ({ - number, - title: `PR ${number}`, - state: 'OPEN', - url: `https://github.com/acme/widgets/pull/${number}`, - labels: [], - updatedAt: `2026-07-0${number}T00:00:00Z`, - author: { login: 'octocat' }, - isDraft: false, - headRefName: `feature/${number}`, - headRefOid: `head-${number}`, - baseRefName: 'main' - })) - ) - }) - - const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--limit', '4', '--search', 'is:open sort:created-desc']), - { cwd: '/repo-root' } - ) - expect(items.map((item) => item.number)).toEqual([4, 3]) - }) - - it('lifts a swallowed PR-side failure onto errors.prs instead of reading as end-of-data', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - // A forbidden source must remain distinguishable from an empty listing. - ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403: Forbidden')) - - const envelope = await listWorkItems('/repo-root', 10, 'is:pr is:open', 2) - - expect(envelope.items).toEqual([]) - expect(envelope.errors?.issues).toBeUndefined() - expect(envelope.errors?.prs).toEqual({ - type: 'unknown', - message: expect.stringContaining('HTTP 403') - }) - }) - - it('filters pull request rows out of issue Search API results', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 2, - title: 'PR-shaped search row', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/2', - labels: [], - updated_at: '2026-07-02T00:00:00Z', - user: { login: 'octocat' }, - pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' } - }, - { - number: 1, - title: 'Issue row', - state: 'open', - html_url: 'https://github.com/acme/widgets/issues/1', - labels: [], - updated_at: '2026-07-01T00:00:00Z', - user: { login: 'octocat' } - } - ]) - }) - - const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open') - - expect(items.map((item) => item.id)).toEqual(['issue:1']) - }) -}) diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index 1bdecd3e748..f42be8891cf 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -107,12 +107,9 @@ vi.mock('./github-api-repository', async (importOriginal) => { : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions) } }) - -import { countWorkItems } from './client/list/count-work-items' import { listWorkItems } from './client/list/list-work-items' import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache' import { _resetOwnerRepoCache } from './gh-utils' -import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github/work-items-query-bounds' import { _resetRemoteNameListingCache } from '../git/remotes/remote-name-listing' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' @@ -151,45 +148,6 @@ describe('listWorkItems', () => { _resetMergeQueueCacheForTests() }) - it('routes GHES work-item listing through the Enterprise host', async () => { - const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } - getIssueOwnerRepoMock.mockResolvedValue(ghes) - getOwnerRepoMock.mockResolvedValue(ghes) - ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10) - - const calls = ghExecFileAsyncMock.mock.calls - expect(calls.length).toBeGreaterThan(0) - expect(calls.every(([, options]) => options?.host === 'github.acme-corp.com')).toBe(true) - }) - - it('marks proven GitLab remotes without listing them', async () => { - getIssueOwnerRepoMock.mockResolvedValue(null) - getOwnerRepoMock.mockResolvedValue(null) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => ({ - stdout: args[1] === '-v' ? 'origin\tgit@gitlab.com:team/project.git (fetch)\n' : 'origin\n' - })) - - const result = await listWorkItems('/repo', 24) - expect(result.sources).toMatchObject({ issues: null, prs: null, ineligible: true }) - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it.each([ - 'origin\tgit@custom.example.com:team/project.git (fetch)\n', - 'origin\tgit@gitlab.com:team/project.git (fetch)\nupstream\tgit@github.com:team/project.git (fetch)\n' - ])('keeps unproven or mixed remote identity unresolved: %s', async (stdout) => { - getIssueOwnerRepoMock.mockResolvedValue(null) - getOwnerRepoMock.mockResolvedValue(null) - gitExecFileAsyncMock.mockResolvedValue({ stdout }) - - const result = await listWorkItems('/repo', 24) - - expect(result.sources.ineligible).toBeUndefined() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('does not run unscoped work-item queries when a local repository has no GitHub source', async () => { getIssueOwnerRepoMock.mockResolvedValue(null) getOwnerRepoMock.mockResolvedValue(null) @@ -207,643 +165,6 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) - it('queries only the resolved PR source when the issue source is unavailable', async () => { - getIssueOwnerRepoMock.mockResolvedValue(null) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' }) - - await expect(listWorkItems('/private-repo', 36)).resolves.toMatchObject({ items: [] }) - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['pr', 'list', '--repo', 'acme/widgets']), - { cwd: '/private-repo' } - ) - }) - - it('runs both issue and PR GitHub searches for a mixed query and merges the results by recency', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 12, - title: 'Fix bug', - state: 'OPEN', - url: 'https://github.com/acme/widgets/issues/12', - labels: [], - updatedAt: '2026-03-29T00:00:00Z', - author: { login: 'octocat' }, - assignees: [ - { - login: 'test-assignee', - name: 'Test Assignee', - databaseId: 1 - } - ] - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Add feature', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/42', - labels: [], - updatedAt: '2026-03-28T00:00:00Z', - author: { login: 'octocat' }, - isDraft: false, - headRefName: 'feature/add-feature', - headRefOid: 'head-42', - baseRefName: 'main', - reviewRequests: [ - { - requestedReviewer: { - login: 'test-assignee', - name: 'Test Assignee', - avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' - } - } - ] - } - ]) - }) - const { items, sources } = await listWorkItems('/repo-root', 10, 'assignee:@me') - expect(sources).toMatchObject({ - issues: { owner: 'acme', repo: 'widgets' }, - prs: { owner: 'acme', repo: 'widgets' } - }) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue assignee:@me')}&sort=created&order=desc&per_page=10&page=1`, - '--jq', - '.items' - ], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'pr', - 'list', - '--limit', - '10', - '--state', - 'all', - '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', - '--repo', - 'acme/widgets', - '--search', - 'assignee:@me sort:created-desc' - ], - { cwd: '/repo-root' } - ) - const prListFields = ghExecFileAsyncMock.mock.calls[1][0].join(',') - expect(prListFields).not.toContain('statusCheckRollup') - expect(prListFields).toContain('reviewRequests') - expect(prListFields).not.toContain('mergeStateStatus') - expect(items).toEqual([ - { - id: 'pr:42', - type: 'pr', - number: 42, - title: 'Add feature', - state: 'open', - url: 'https://github.com/acme/widgets/pull/42', - labels: [], - updatedAt: '2026-03-28T00:00:00Z', - author: 'octocat', - branchName: 'feature/add-feature', - baseRefName: 'main', - headSha: 'head-42', - prRepo: { owner: 'acme', repo: 'widgets' }, - reviewRequests: [ - { - login: 'test-assignee', - name: 'Test Assignee', - avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' - } - ] - }, - { - id: 'issue:12', - type: 'issue', - number: 12, - title: 'Fix bug', - state: 'open', - url: 'https://github.com/acme/widgets/issues/12', - labels: [], - updatedAt: '2026-03-29T00:00:00Z', - author: 'octocat', - assignees: [ - { - login: 'test-assignee', - name: 'Test Assignee', - avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' - } - ] - } - ]) - }) - - it('routes local WSL work-item listing through repo resolution and gh execution options', async () => { - const localGitOptions = { wslDistro: 'Ubuntu' } - resolveIssueSourceMock.mockResolvedValue({ - source: { owner: 'acme', repo: 'widgets' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoForRemoteMock.mockImplementation( - async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => - remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null - ) - ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' }) - - await listWorkItems( - '/repo-root', - 5, - undefined, - undefined, - undefined, - null, - false, - localGitOptions - ) - - expect(resolveIssueSourceMock).toHaveBeenCalledWith( - '/repo-root', - undefined, - null, - localGitOptions - ) - expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( - '/repo-root', - 'upstream', - null, - localGitOptions - ) - expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( - true - ) - }) - - it('skips upstream PR source probing when the clone only has origin', async () => { - getIssueOwnerRepoMock.mockResolvedValue(null) - getOwnerRepoMock.mockResolvedValue({ owner: 'fork', repo: 'orca' }) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'origin\n' }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' }) - - await expect(listWorkItems('/origin-only-repo', 10, 'is:pr')).resolves.toMatchObject({ - items: [], - sources: { - issues: null, - prs: { owner: 'fork', repo: 'orca' }, - originCandidate: { owner: 'fork', repo: 'orca' }, - upstreamCandidate: null - } - }) - - expect(getOwnerRepoForRemoteMock.mock.calls.map(([, remote]) => remote)).not.toContain( - 'upstream' - ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['remote'], - expect.objectContaining({ cwd: '/origin-only-repo' }) - ) - }) - - it('rejects oversized queries before resolving repo sources or executing gh', async () => { - const secret = 'main-github-work-items-secret' - const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) - - await expect(listWorkItems('/repo-root', 10, oversizedQuery)).resolves.toEqual({ - items: [], - sources: { - issues: null, - prs: null, - originCandidate: null, - upstreamCandidate: null - } - }) - - expect(resolveIssueSourceMock).not.toHaveBeenCalled() - expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() - expect(getOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(acquireMock).not.toHaveBeenCalled() - expect(releaseMock).not.toHaveBeenCalled() - }) - - it('hydrates PR list rows with repository merge metadata', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 42, - title: 'Add feature', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/42', - labels: [], - updatedAt: '2026-03-28T00:00:00Z', - author: { login: 'octocat' }, - isDraft: false, - headRefName: 'feature/add-feature', - headRefOid: 'head-42', - baseRefName: 'main' - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - viewerDefaultMergeMethod: 'REBASE', - mergeCommitAllowed: false, - rebaseMergeAllowed: true, - squashMergeAllowed: true, - autoMergeAllowed: false - } - } - }) - }) - - const { items } = await listWorkItems('/repo-root', 10, 'is:pr') - - expect(items).toHaveLength(1) - expect(items[0]?.mergeMethodSettings).toEqual({ - defaultMethod: 'rebase', - allowedMethods: { - squash: true, - merge: false, - rebase: true - } - }) - expect(items[0]?.autoMergeAllowed).toBe(false) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - expect.arrayContaining(['api', 'graphql', '-f', 'owner=acme', '-f', 'repo=widgets']), - { cwd: '/repo-root' } - ) - }) - - it('routes draft queries to PR search only', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 7, - title: 'Draft work', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/7', - labels: [], - updatedAt: '2026-03-30T00:00:00Z', - author: { login: 'octocat' }, - isDraft: true, - headRefName: 'draft/work', - headRefOid: 'head-7', - baseRefName: 'main' - } - ]) - }) - const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:draft') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'pr', - 'list', - '--limit', - '10', - '--state', - 'all', - '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', - '--repo', - 'acme/widgets', - '--search', - 'is:open draft:true sort:created-desc' - ], - { cwd: '/repo-root' } - ) - expect(items).toEqual([ - { - id: 'pr:7', - type: 'pr', - number: 7, - title: 'Draft work', - state: 'draft', - url: 'https://github.com/acme/widgets/pull/7', - labels: [], - updatedAt: '2026-03-30T00:00:00Z', - author: 'octocat', - branchName: 'draft/work', - baseRefName: 'main', - headSha: 'head-7', - prRepo: { owner: 'acme', repo: 'widgets' } - } - ]) - }) - - it('routes merged queries to PR search only and maps MERGED PR state', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 8, - title: 'Merged work', - state: 'MERGED', - url: 'https://github.com/acme/widgets/pull/8', - labels: [], - updatedAt: '2026-03-31T00:00:00Z', - author: { login: 'octocat' }, - isDraft: false, - headRefName: 'feature/merged', - headRefOid: 'head-8', - baseRefName: 'main' - } - ]) - }) - - const { items } = await listWorkItems('/repo-root', 10, 'is:merged') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'pr', - 'list', - '--limit', - '10', - '--state', - 'all', - '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', - '--repo', - 'acme/widgets', - '--search', - 'is:merged sort:created-desc' - ], - { cwd: '/repo-root' } - ) - expect(items).toMatchObject([ - { - id: 'pr:8', - type: 'pr', - number: 8, - state: 'merged' - } - ]) - }) - - it('passes state:all through to gh instead of using the default open state', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'is:pr state:all') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith(expect.arrayContaining(['--state', 'all']), { - cwd: '/repo-root' - }) - }) - - it('excludes merged PRs from closed PR searches', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 9, - title: 'Closed without merge', - state: 'CLOSED', - url: 'https://github.com/acme/widgets/pull/9', - labels: [], - updatedAt: '2026-04-01T00:00:00Z', - author: { login: 'octocat' }, - isDraft: false, - headRefName: 'feature/closed', - headRefOid: 'head-9', - baseRefName: 'main' - }, - { - number: 8, - title: 'Merged work', - state: 'MERGED', - url: 'https://github.com/acme/widgets/pull/8', - labels: [], - updatedAt: '2026-03-31T00:00:00Z', - author: { login: 'octocat' }, - isDraft: false, - headRefName: 'feature/merged', - headRefOid: 'head-8', - baseRefName: 'main' - } - ]) - }) - - const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed') - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining([ - '--state', - 'all', - '--search', - 'is:closed -is:merged sort:created-desc' - ]), - { cwd: '/repo-root' } - ) - expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }]) - }) - - it('quotes spaced label qualifiers when counting search results', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '12' }) - - const count = await countWorkItems('/repo-root', 'is:pr label:"needs review"') - - const apiPath = ghExecFileAsyncMock.mock.calls[0][0][3] as string - expect(count).toBe(12) - expect(decodeURIComponent(apiPath)).toContain('label:"needs review"') - }) - - it('does not add the merged exclusion to issue-only closed count queries', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '4' }) - - await countWorkItems('/repo-root', 'is:issue is:closed') - - const apiPath = decodeURIComponent(ghExecFileAsyncMock.mock.calls[0][0][3] as string) - expect(apiPath).toContain('is:issue is:closed') - expect(apiPath).not.toContain('-is:merged') - }) - - it('returns zero without spawning gh when the search bucket is rate-limit blocked', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - rateLimitGuardMock.mockReturnValue({ - blocked: true, - remaining: 0, - limit: 30, - resetAt: Math.floor(Date.now() / 1000) + 60 - } as unknown as { blocked: boolean }) - - await expect(countWorkItems('/repo-root', 'is:issue is:open')).resolves.toBe(0) - - expect(rateLimitGuardMock).toHaveBeenCalledWith('search') - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('returns open issues and PRs for the all-open preset query', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 1, - title: 'Open issue', - state: 'OPEN', - url: 'https://github.com/acme/widgets/issues/1', - labels: [], - updatedAt: '2026-03-31T00:00:00Z', - author: { login: 'octocat' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 2, - title: 'Open PR', - state: 'OPEN', - url: 'https://github.com/acme/widgets/pull/2', - labels: [], - updatedAt: '2026-03-30T00:00:00Z', - author: { login: 'octocat' }, - isDraft: false, - headRefName: 'feature/open-pr', - headRefOid: 'head-2', - baseRefName: 'main' - } - ]) - }) - const { items } = await listWorkItems('/repo-root', 10, 'is:open') - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=1`, - '--jq', - '.items' - ], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'pr', - 'list', - '--limit', - '10', - '--state', - 'all', - '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', - '--repo', - 'acme/widgets', - '--search', - 'is:open sort:created-desc' - ], - { cwd: '/repo-root' } - ) - expect(items).toEqual([ - { - id: 'pr:2', - type: 'pr', - number: 2, - title: 'Open PR', - state: 'open', - url: 'https://github.com/acme/widgets/pull/2', - labels: [], - updatedAt: '2026-03-30T00:00:00Z', - author: 'octocat', - branchName: 'feature/open-pr', - baseRefName: 'main', - headSha: 'head-2', - prRepo: { owner: 'acme', repo: 'widgets' } - }, - { - id: 'issue:1', - type: 'issue', - number: 1, - title: 'Open issue', - state: 'open', - url: 'https://github.com/acme/widgets/issues/1', - labels: [], - updatedAt: '2026-03-31T00:00:00Z', - author: 'octocat' - } - ]) - }) - - it('marks fork PRs as cross-repository when REST payload only includes head.label', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 1849, - title: 'Fork PR with missing head repo', - state: 'open', - html_url: 'https://github.com/stablyai/orca/pull/1849', - updated_at: '2026-04-01T00:00:00Z', - user: { login: 'contributor' }, - head: { - ref: 'feat/onboarding-model-choice-782', - sha: 'head-1849', - repo: null, - label: 'contributor:feat/onboarding-model-choice-782' - }, - base: { ref: 'main' } - } - ]) - }) - - const { items } = await listWorkItems('/repo-root', 10) - expect(items).toEqual([ - { - id: 'pr:1849', - type: 'pr', - number: 1849, - title: 'Fork PR with missing head repo', - state: 'open', - url: 'https://github.com/stablyai/orca/pull/1849', - labels: [], - updatedAt: '2026-04-01T00:00:00Z', - author: 'contributor', - branchName: 'feat/onboarding-model-choice-782', - baseRefName: 'main', - headSha: 'head-1849', - prRepo: { owner: 'stablyai', repo: 'orca' }, - isCrossRepository: true - } - ]) - }) - it('rejects unresolved SSH repositories without running unscoped GitHub work-item queries', async () => { getIssueOwnerRepoMock.mockResolvedValue(null) getOwnerRepoMock.mockResolvedValue(null) diff --git a/src/main/github/client/check/check-details-admission-lifetime.test.ts b/src/main/github/client/check/check-details-admission-lifetime.test.ts index d9a0997e633..fa0d6db1ed3 100644 --- a/src/main/github/client/check/check-details-admission-lifetime.test.ts +++ b/src/main/github/client/check/check-details-admission-lifetime.test.ts @@ -19,10 +19,6 @@ vi.mock('@orca/process-host/process-tree-termination', () => ({ })) vi.mock('../../github-api-repository', () => ({ resolveGitHubRepoExecution: resolverMock })) import { getPRCheckDetails } from './get-pr-check-details' -import { - createCheckDetailsAdmissionLifetime, - runCheckDetailsCall -} from './check-details-admission-lifetime' import { prCheckLogTailCache } from './check-job-log-tails' import { clearGhCapabilityStateForTests } from '../../gh-capability-state' import { clearGhAccountTokenCacheForTests } from '../../gh-account-token' @@ -147,40 +143,6 @@ describe('check details existing admission ownership', () => { expect(logChildren).toHaveLength(5) }) - it('seals logical continuation separately from actual child and releases exactly once', async () => { - const release = vi.fn() - const gate = createCheckDetailsAdmissionLifetime(release) - const controller = new AbortController() - let finishResult = (): void => {} - let finishChild = (): void => {} - const physical = new Promise((resolve) => { - finishChild = resolve - }) - const call = runCheckDetailsCall( - (hooks) => { - hooks.onTerminationPending(physical) - return new Promise((resolve) => { - finishResult = resolve - }) - }, - controller.signal, - gate.retainUntil - ) - await vi.advanceTimersByTimeAsync(0) - const rejected = expect(call.result).rejects.toMatchObject({ name: 'AbortError' }) - controller.abort() - gate.finish() - gate.finish() - await rejected - finishChild() - await vi.advanceTimersByTimeAsync(0) - expect(release).not.toHaveBeenCalled() - finishResult() - await call.terminated - await vi.advanceTimersByTimeAsync(0) - expect(release).toHaveBeenCalledOnce() - }) - it('prevents the jobs stage after a swallowed annotation PID error until close', async () => { const controller = new AbortController() const result = getPRCheckDetails( @@ -203,24 +165,6 @@ describe('check details existing admission ownership', () => { expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain('private-output') }) - it('preserves metadata after an ordinary closed annotation failure and continues jobs', async () => { - const result = getPRCheckDetails('/folder', { checkRunId: 1, workflowRunId: 2 }) - await vi.advanceTimersByTimeAsync(0) - completeFakeSpawn(children[0], { stdout: '{"name":"loaded","output":{"summary":"retained"}}' }) - await vi.advanceTimersByTimeAsync(0) - completeFakeSpawn(children[1], { code: 1, stderr: 'private-output' }) - await vi.advanceTimersByTimeAsync(0) - expect(children).toHaveLength(3) - completeFakeSpawn(children[2], { stdout: '{"jobs":[]}' }) - await expect(result).resolves.toMatchObject({ - name: 'loaded', - summary: 'retained', - annotations: [], - jobs: [] - }) - expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain('private-output') - }) - it('retains all four creator permits through joined help and later token continuations after departure', async () => { const options: GhExecOptions = { cwd: '/folder', diff --git a/src/main/github/client/check/check-job-log-host-measurement.test.ts b/src/main/github/client/check/check-job-log-host-measurement.test.ts deleted file mode 100644 index e819f05f999..00000000000 --- a/src/main/github/client/check/check-job-log-host-measurement.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { prepareHostMeasurement, validateHostRecord } from './check-job-log-host-measurement.mjs' - -let context: Awaited> -let copy: string -beforeAll(async () => { - context = await prepareHostMeasurement() - copy = await mkdtemp(path.join(tmpdir(), 'orca-host-measurement-identity-')) - for (const name of Object.keys(context.identity.sourceHashes)) { - const target = path.join(copy, name) - await mkdir(path.dirname(target), { recursive: true }) - await writeFile(target, await readFile(path.join(context.root, name))) - } -}) -afterAll(async () => { - await context?.cleanup() - if (copy) { - await rm(copy, { recursive: true, force: true }) - } -}) - -describe('standalone host measurement identity', () => { - it.each([ - 'src/main/github/client/check/check-job-log-host-measurement.mjs', - 'src/main/github/client/check/check-job-log-cache-retention-child.mjs', - 'config/scripts/performance/check-job-log/check-job-log-performance-cases.mjs', - 'config/scripts/performance/check-job-log/check-job-log-byte-cap-benchmark.mjs', - 'src/shared/github/repository-identity-key.ts', - 'src/main/git/command-runner/abort-error.ts' - ])('rejects stale record identity after an owned-copy mutation of %s', async (name) => { - const target = path.join(copy, name) - const original = await readFile(target) - await writeFile(target, Buffer.concat([original, Buffer.from('\n// identity mutation\n')])) - try { - const changed = await prepareHostMeasurement(copy) - try { - expect(changed.identity.sourceHashes[name]).not.toBe(context.identity.sourceHashes[name]) - expect(changed.identityHash).not.toBe(context.identityHash) - expect(() => validateHostRecord(changed, { identityHash: context.identityHash })).toThrow( - 'Measurement identity mismatch' - ) - expect(changed.identity.bundleHash).toBe(context.identity.bundleHash) - } finally { - await changed.cleanup() - } - } finally { - await writeFile(target, original) - } - }) -}) diff --git a/src/main/github/client/check/check-job-log-shared-pipeline.test.ts b/src/main/github/client/check/check-job-log-shared-pipeline.test.ts index 4c41803e77f..f5ee323e723 100644 --- a/src/main/github/client/check/check-job-log-shared-pipeline.test.ts +++ b/src/main/github/client/check/check-job-log-shared-pipeline.test.ts @@ -10,7 +10,6 @@ import { } from '../../../../shared/__fixtures__/fake-spawned-child' import { attachLogFixture, failedLogJob } from './check-job-log-test-fixture' import { prCheckLogTailCache } from './check-job-log-tails' -import parserFixture from './__fixtures__/gh-api-unknown-escape-flag.json' const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) vi.mock('node:child_process', async (original) => ({ ...(await original()), spawn: spawnMock })) @@ -104,95 +103,6 @@ describe('actual shared text pipeline through host capture, cache and jobs', () expect(excerpt.sanitizeCheckJobLogExcerpt).toHaveBeenCalledOnce() }) - it('uses the saved old-parser fallback with real fixture output and only one final sanitation', async () => { - spawnMock.mockImplementationOnce(() => { - const child = createFakeSpawnedChild() - children.push(child) - queueMicrotask(() => completeFakeSpawn(child, parserFixture)) - return child - }) - const jobs = [failedLogJob()] - await attachLogFixture(jobs) - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock.mock.calls[1][1]).not.toContain('--allow-escape-sequences') - expect(jobs[0].logTail).toContain(failureFixture.expectedDiagnostic) - expect(jobs[0].logTail).toContain(failureFixture.expectedStackFrame) - expect(excerpt.sanitizeCheckJobLogExcerpt).toHaveBeenCalledOnce() - await attachLogFixture(jobs) - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(excerpt.sanitizeCheckJobLogExcerpt).toHaveBeenCalledOnce() - }) - - it('treats the actual escape refusal as an ordinary failed capture, then retries unchanged identity', async () => { - code = 1 - raw = '' - stderr = failureFixture.escapeRefusalStderr - const jobs = [ - failedLogJob(77, { - steps: [ - { - name: 'Test', - status: 'completed', - conclusion: 'failure', - startedAt: 'start', - completedAt: 'done' - } - ] - }) - ] - const identity = { ...jobs[0], steps: [...jobs[0].steps] } - await attachLogFixture(jobs) - expect(spawnMock).toHaveBeenCalledOnce() - expect(jobs[0]).toEqual({ ...identity, logState: 'error' }) - expect(prCheckLogTailCache.size).toBe(0) - expect(excerpt.sanitizeCheckJobLogExcerpt).not.toHaveBeenCalled() - code = 0 - raw = failureFixture.rawLog - await attachLogFixture(jobs) - expectSafe(jobs[0].logTail) - expect(jobs[0].logTail).toContain(failureFixture.expectedDiagnostic) - await attachLogFixture(jobs) - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(excerpt.sanitizeCheckJobLogExcerpt).toHaveBeenCalledOnce() - expect(jobs[0]).toMatchObject({ - ...identity, - logTail: expect.any(String), - logState: 'available' - }) - }) - - it.each(['\x1b[31m\x00\x07\x08\x1b[0m', ' \t \n'])( - 'caches control-only empty versus available whitespace %#', - async (value) => { - raw = value - const jobs = [failedLogJob()] - await attachLogFixture(jobs) - await attachLogFixture(jobs) - const available = value.startsWith(' ') - expect(jobs[0]).toMatchObject({ - logState: available ? 'available' : 'empty', - logTail: available ? value : null - }) - expect([...prCheckLogTailCache.values()][0]).toEqual({ - logState: available ? 'available' : 'empty', - logTail: available ? value : null - }) - expect(extraction.sliceCheckLogTail).toHaveBeenCalledOnce() - expect(excerpt.sanitizeCheckJobLogExcerpt).toHaveBeenCalledOnce() - expect(spawnMock).toHaveBeenCalledOnce() - } - ) - - it('normalizes captured CRLF/bare CR, preserves tabs/LF, and retains ANSI-interrupted earlier diagnostics', async () => { - raw = `\x1b[31mErr\x1b[0mor: HOST_CR_DIAGNOSTIC\x00\r\tat HOST_FRAME.ts:18:8\r\n${Array.from({ length: 250 }, (_, i) => `ordinary ${i}`).join('\r')}\r\tend\n` - const jobs = [failedLogJob()] - await attachLogFixture(jobs) - expectSafe(jobs[0].logTail) - expect(jobs[0].logTail).toContain('Error: HOST_CR_DIAGNOSTIC\n\tat HOST_FRAME.ts:18:8') - expect(jobs[0].logTail).toContain('\n\tend\n') - expect(excerpt.sanitizeCheckJobLogExcerpt).toHaveBeenCalledOnce() - }) - it('sends bounded Unicode output from oversized captured stdout and handles a cut escape', async () => { raw = `discarded-parent-marker${'🙂'.repeat(20_000)}\x1b]unfinished\n\x1b[31mError: HOST_CUT\x1b[0m\n\tat CUT_FRAME.ts:1:2\n` const jobs = [failedLogJob()] @@ -206,48 +116,4 @@ describe('actual shared text pipeline through host capture, cache and jobs', () expect(Object.keys(cached).sort()).toEqual(['logState', 'logTail']) expect(Buffer.byteLength(JSON.stringify(jobs))).toBeLessThan(20 * 1024) }) - - it('keeps mixed successful, empty, error and limit-skipped metadata with sequential five-job selection', async () => { - spawnMock.mockImplementation((_program, argv: string[]) => { - const child = createFakeSpawnedChild() - children.push(child) - active += 1 - peak = Math.max(peak, active) - const id = argv - .find((arg) => arg.endsWith('/logs')) - ?.split('/') - .at(-2) - queueMicrotask(() => { - active -= 1 - completeFakeSpawn( - child, - id === '2' - ? { code: 1, stderr: failureFixture.escapeRefusalStderr } - : { stdout: id === '3' ? '\x1b[31m\x00\x1b[0m' : raw } - ) - }) - return child - }) - const jobs = Array.from({ length: 6 }, (_, index) => failedLogJob(index + 1)) - await attachLogFixture(jobs) - expect(jobs.map((job) => job.logState)).toEqual([ - 'available', - 'error', - 'empty', - 'available', - 'available', - 'not_requested' - ]) - expect(jobs.map((job) => job.name)).toEqual( - Array.from({ length: 6 }, (_, index) => `job ${index + 1}`) - ) - expect(spawnMock).toHaveBeenCalledTimes(5) - expect(peak).toBe(1) - expect(excerpt.sanitizeCheckJobLogExcerpt).toHaveBeenCalledTimes(4) - expect(prCheckLogTailCache.size).toBe(4) - expect( - jobs.reduce((bytes, job) => bytes + Buffer.byteLength(job.logTail ?? ''), 0) - ).toBeLessThanOrEqual(80 * 1024) - expect('logState' in failedLogJob()).toBe(false) - }) }) diff --git a/src/main/github/client/check/check-job-log-subscribers.test.ts b/src/main/github/client/check/check-job-log-subscribers.test.ts index ec3bb5bdc75..97b4113a3d2 100644 --- a/src/main/github/client/check/check-job-log-subscribers.test.ts +++ b/src/main/github/client/check/check-job-log-subscribers.test.ts @@ -7,7 +7,6 @@ import { import { attachLogFixture, failedLogJob } from './check-job-log-test-fixture' import { prCheckLogTailCache } from './check-job-log-tails' import { createCheckDetailsAdmissionLifetime } from './check-details-admission-lifetime' -import { getPRCheckDetails } from './get-pr-check-details' const { spawnMock, signalMock, forceMock, resolverMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), signalMock: vi.fn(), @@ -133,27 +132,7 @@ describe('independent readers and positively closed retirement', () => { expect(children).toHaveLength(1) await vi.advanceTimersByTimeAsync(12_000) }) - it('rejects an already-aborted joiner and cleans reader listeners without affecting work', async () => { - const creator = new AbortController(), - follower = new AbortController() - const add = vi.spyOn(follower.signal, 'addEventListener'), - remove = vi.spyOn(follower.signal, 'removeEventListener') - const first = attachLogFixture([failedLogJob()], { controller: creator }) - const second = attachLogFixture([failedLogJob()], { controller: follower }).catch(() => {}) - await vi.advanceTimersByTimeAsync(0) - follower.abort() - await second - const before = children.length - await expect( - attachLogFixture([failedLogJob()], { controller: follower }) - ).rejects.toMatchObject({ name: 'AbortError' }) - expect(children).toHaveLength(before) - expect(remove.mock.calls.filter((call) => call[0] === 'abort').length).toBe( - add.mock.calls.filter((call) => call[0] === 'abort').length - ) - completeFakeSpawn(children[0], { stdout: 'ok' }) - await first - }) + it('natural completion racing with last abort neither mutates nor caches canceled output', async () => { const controller = new AbortController(), jobs = [failedLogJob()] @@ -184,63 +163,4 @@ describe('independent readers and positively closed retirement', () => { expect(children).toHaveLength(1) expect(prCheckLogTailCache.size).toBe(0) }) - it('four retained shared creators prevent a fifth admitted log at unverified grace', async () => { - const logs: ChildProcess[] = [] - spawnMock.mockImplementation((_program, argv: string[]) => { - const child = spawn() - const endpoint = argv.find((arg) => arg.startsWith('repos/')) ?? '' - if (endpoint.endsWith('/logs')) { - logs.push(child) - } else { - const id = Number(endpoint.match(/runs\/(\d+)/)?.[1]) - queueMicrotask(() => - completeFakeSpawn(child, { - stdout: JSON.stringify({ - jobs: [ - { - id, - name: 'job', - status: 'completed', - conclusion: 'failure', - completed_at: 'done' - } - ] - }) - }) - ) - } - return child - }) - const controllers = Array.from({ length: 4 }, () => new AbortController()) - const requests = controllers.map((controller, i) => - getPRCheckDetails( - '/folder', - { workflowRunId: i + 1 }, - undefined, - {}, - controller.signal - ).catch(() => {}) - ) - await vi.advanceTimersByTimeAsync(0) - expect(logs).toHaveLength(4) - const fifthController = new AbortController() - const fifth = getPRCheckDetails( - '/folder', - { workflowRunId: 5 }, - undefined, - {}, - fifthController.signal - ).catch(() => {}) - for (const controller of controllers) { - controller.abort() - } - await Promise.all(requests) - await vi.advanceTimersByTimeAsync(12_000) - expect(logs).toHaveLength(4) - logs[0].emit('close', null, 'SIGKILL') - await vi.advanceTimersByTimeAsync(0) - expect(logs).toHaveLength(5) - fifthController.abort() - await fifth - }) }) diff --git a/src/main/github/client/check/check-job-log-tails.test.ts b/src/main/github/client/check/check-job-log-tails.test.ts index e29077a38e1..263f705e4ad 100644 --- a/src/main/github/client/check/check-job-log-tails.test.ts +++ b/src/main/github/client/check/check-job-log-tails.test.ts @@ -8,7 +8,6 @@ import { attachLogFixture, failedLogJob } from './check-job-log-test-fixture' import { prCheckLogTailCache } from './check-job-log-tails' import { clearGhCapabilityStateForTests } from '../../gh-capability-state' import { clearGhAccountTokenCacheForTests } from '../../gh-account-token' -import parserFixture from './__fixtures__/gh-api-unknown-escape-flag.json' const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) vi.mock('node:child_process', async (original) => ({ ...(await original()), spawn: spawnMock })) const children: ChildProcess[] = [] @@ -50,130 +49,7 @@ describe('completed scoped excerpts through actual capture and runner', () => { vi.useRealTimers() vi.restoreAllMocks() }) - it.each([1, 5])( - 'coalesces two cold %s-job callers, sequentially, and uses warm zero downloads', - async (count) => { - const first = Array.from({ length: count }, (_, i) => failedLogJob(i + 1)) - const second = first.map((job) => ({ ...job })) - const pending = Promise.all([attachLogFixture(first), attachLogFixture(second)]) - await vi.advanceTimersByTimeAsync(0) - await pending - expect(spawnMock).toHaveBeenCalledTimes(count) - expect(peak).toBe(1) - expect(first).toEqual(second) - await attachLogFixture(first) - expect(spawnMock).toHaveBeenCalledTimes(count) - expect(first.every((job) => job.logState === 'available')).toBe(true) - } - ) - it('retries an unchanged failed job and caches only its later successful result', async () => { - spawnMock.mockImplementationOnce((_program, _argv) => { - const child = createFakeSpawnedChild() - queueMicrotask(() => completeFakeSpawn(child, { stderr: 'temporary failure', code: 1 })) - return child - }) - const jobs = [failedLogJob()] - await attachLogFixture(jobs) - expect(jobs[0]).toMatchObject({ - id: 77, - completedAt: 'done', - logTail: null, - logState: 'error', - name: 'job 77' - }) - expect(prCheckLogTailCache.size).toBe(0) - await attachLogFixture(jobs) - expect(jobs[0]).toMatchObject({ logState: 'available', logTail: raw }) - await attachLogFixture(jobs) - expect(spawnMock).toHaveBeenCalledTimes(2) - }) - it.each([1, 5])( - 'shares old-parser %s-job callers with two physical attempts per unique job', - async (count) => { - spawnMock.mockImplementation((_program, argv: string[]) => { - const child = createFakeSpawnedChild() - children.push(child) - active += 1 - peak = Math.max(peak, active) - queueMicrotask(() => { - active -= 1 - completeFakeSpawn( - child, - argv.includes('--allow-escape-sequences') - ? { code: parserFixture.code, stderr: parserFixture.stderr } - : { stdout: 'retrieved' } - ) - }) - return child - }) - const jobs = () => Array.from({ length: count }, (_, i) => failedLogJob(i + 1)) - await Promise.all([attachLogFixture(jobs()), attachLogFixture(jobs())]) - expect(spawnMock).toHaveBeenCalledTimes(count * 2) - expect( - spawnMock.mock.calls.filter((call) => call[1].includes('--allow-escape-sequences')) - ).toHaveLength(count) - expect(peak).toBe(1) - await attachLogFixture(jobs()) - expect(spawnMock).toHaveBeenCalledTimes(count * 2) - } - ) - it.each(['', ' \t \n'])( - 'stores successful exact empty versus whitespace distinctly %#', - async (value) => { - raw = value - const jobs = [failedLogJob()] - await attachLogFixture(jobs) - await attachLogFixture(jobs) - expect(spawnMock).toHaveBeenCalledOnce() - expect(jobs[0]).toMatchObject({ - logState: value.length ? 'available' : 'empty', - logTail: value.length ? value : null - }) - expect([...prCheckLogTailCache.values()][0]).toEqual({ - logState: value.length ? 'available' : 'empty', - logTail: value.length ? value : null - }) - } - ) - it.each([{ status: 'in_progress' }, { completedAt: null }, { completedAt: '' }])( - 'does not cache volatile or missing-completion output %#', - async (fields) => { - const jobs = [failedLogJob(77, fields)] - await attachLogFixture(jobs) - await attachLogFixture(jobs) - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(prCheckLogTailCache.size).toBe(0) - } - ) - it('selects the first five eligible IDs, includes warm hits, and retains partial successes', async () => { - await attachLogFixture([failedLogJob(1)]) - spawnMock.mockImplementation((_program, argv, options) => { - if (argv.some((arg: string) => arg.endsWith('/2/logs'))) { - const child = createFakeSpawnedChild() - queueMicrotask(() => completeFakeSpawn(child, { code: 1, stderr: 'ordinary failure' })) - return child - } - return automaticSpawn(_program, argv, options) - }) - const jobs = [ - failedLogJob(10, { conclusion: 'success' }), - failedLogJob(11, { id: null }), - ...Array.from({ length: 6 }, (_, i) => failedLogJob(i + 1)) - ] - await attachLogFixture(jobs) - expect(spawnMock).toHaveBeenCalledTimes(5) - expect(jobs.map((job) => job.logState)).toEqual([ - 'not_requested', - 'not_requested', - 'available', - 'error', - 'available', - 'available', - 'available', - 'not_requested' - ]) - expect(peak).toBe(1) - }) + it('bounds owned encoded cache content with FIFO eviction and five-job payload', async () => { raw = '🙂'.repeat(6000) for (let id = 1; id <= 129; id += 1) { @@ -194,86 +70,4 @@ describe('completed scoped excerpts through actual capture and runner', () => { await attachLogFixture([failedLogJob(1)]) expect(spawnMock).toHaveBeenCalledTimes(130) }) - it('partitions repository, completion, env and folder scopes', async () => { - await attachLogFixture([failedLogJob()]) - await attachLogFixture([failedLogJob(77, { completedAt: 'later' })]) - await attachLogFixture([failedLogJob()], { repo: { owner: 'other', repo: 'widgets' } }) - await attachLogFixture([failedLogJob()], { - options: { cwd: '/other-folder', env: { GH_TOKEN: 'fixture-token' } } - }) - await attachLogFixture([failedLogJob()], { - options: { cwd: '/folder', env: { GH_TOKEN: 'different' } } - }) - expect(spawnMock).toHaveBeenCalledTimes(5) - await attachLogFixture([failedLogJob()]) - expect(spawnMock).toHaveBeenCalledTimes(5) - }) - it.each(['github.com', 'github.localhost', 'github.enterprise.test'])( - 'proven bound %s is cold/warm/concurrent 1/0/1 and isolates exact account user', - async (host) => { - const repo = { owner: 'acme', repo: 'widgets', host } - const options = { cwd: '/folder', ghAccount: { host, user: 'Alice' }, env: {} } - await attachLogFixture([failedLogJob()], { repo, options }) - const countLogs = () => spawnMock.mock.calls.filter((call) => call[1][0] === 'api').length - expect(countLogs()).toBe(1) - await attachLogFixture([failedLogJob()], { repo, options }) - expect(countLogs()).toBe(1) - prCheckLogTailCache.clear() - await Promise.all([ - attachLogFixture([failedLogJob()], { repo, options }), - attachLogFixture([failedLogJob()], { repo, options }) - ]) - expect(countLogs()).toBe(2) - await attachLogFixture([failedLogJob()], { - repo, - options: { ...options, ghAccount: { host, user: 'alice' } } - }) - expect(countLogs()).toBe(3) - const api = spawnMock.mock.calls.find((call) => call[1][0] === 'api') - expect( - api?.[2].env[host === 'github.enterprise.test' ? 'GH_ENTERPRISE_TOKEN' : 'GH_TOKEN'] - ).toBe('bound-Alice') - } - ) - it('classic Enterprise secondary token retains 1/0/1 and changed token misses', async () => { - const repo = { owner: 'acme', repo: 'widgets', host: 'github.enterprise.test' } - const options = { - cwd: '/folder', - env: { GH_ENTERPRISE_TOKEN: '', GITHUB_ENTERPRISE_TOKEN: 'secondary' } - } - await attachLogFixture([failedLogJob()], { repo, options }) - await attachLogFixture([failedLogJob()], { repo, options }) - expect(spawnMock).toHaveBeenCalledOnce() - prCheckLogTailCache.clear() - await Promise.all([ - attachLogFixture([failedLogJob()], { repo, options }), - attachLogFixture([failedLogJob()], { repo, options }) - ]) - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock.mock.calls[1][2].env.GITHUB_ENTERPRISE_TOKEN).toBe('secondary') - await attachLogFixture([failedLogJob()], { - repo, - options: { ...options, env: { GITHUB_ENTERPRISE_TOKEN: 'changed' } } - }) - expect(spawnMock).toHaveBeenCalledTimes(3) - }) - it.each(['github.com', 'tenant.ghe.com', 'api.github.com', 'github.com:443'])( - 'unproven %s bypass is 1/1/2', - async (host) => { - const repo = { owner: 'acme', repo: 'widgets', host } - const options = { - cwd: '/folder', - env: host === 'github.com' ? {} : { GH_TOKEN: 'fixture-token' } - } - await attachLogFixture([failedLogJob()], { repo, options }) - await attachLogFixture([failedLogJob()], { repo, options }) - expect(spawnMock).toHaveBeenCalledTimes(2) - await Promise.all([ - attachLogFixture([failedLogJob()], { repo, options }), - attachLogFixture([failedLogJob()], { repo, options }) - ]) - expect(spawnMock).toHaveBeenCalledTimes(4) - expect(prCheckLogTailCache.size).toBe(0) - } - ) }) diff --git a/src/main/github/complete-pr-check-observation.test.ts b/src/main/github/complete-pr-check-observation.test.ts index 67a1f68a126..fd60a304f27 100644 --- a/src/main/github/complete-pr-check-observation.test.ts +++ b/src/main/github/complete-pr-check-observation.test.ts @@ -84,21 +84,6 @@ it('counts a hidden failure and suite-only blocker after both first pages withou }) }) -it('counts unnamed and name-colliding failed observations instead of using detail-list filtering', async () => { - exec.mockResolvedValueOnce( - page([ - success, - { ...failure, name: '' }, - { __typename: 'StatusContext', context: 'same-name', state: 'FAILURE' } - ]) - ) - expect(summarizePRCheckRollup((await read())!)).toMatchObject({ - state: 'failure', - total: 3, - failed: 2 - }) -}) - it.each(['failed-page', 'changed-head', 'broken-cursor'])( 'never certifies the successful prefix after %s', async (kind) => { diff --git a/src/main/github/conflict-summary.test.ts b/src/main/github/conflict-summary.test.ts index 23144fa02f5..31f20c4ceef 100644 --- a/src/main/github/conflict-summary.test.ts +++ b/src/main/github/conflict-summary.test.ts @@ -30,8 +30,6 @@ vi.mock('node:fs/promises', async (importOriginal) => { import { findGitSubcommandIndex } from '../../shared/git/commands/git-command-classification' import { GIT_CAPABILITY_RETRY_INTERVAL_MS } from '../../shared/git/commands/git-capability-cache' -import { getLocalGitCapabilityCache } from '../git/command-runner/git-capability-state' -import { CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS } from './conflict-summary-cache' import { __resetPRConflictSummaryCachesForTests, getPRConflictSummary } from './conflict-summary' type GitResult = { stdout: string } @@ -104,201 +102,6 @@ describe('getPRConflictSummary caching', () => { vi.useRealTimers() }) - it('runs exactly one fetch and one derivation chain, then zero subprocesses on repeat calls', async () => { - mockGitDispatch() - - const first = await deriveSummary() - expect(first).toEqual(expectedSummary) - expect(spawnCount('fetch')).toBe(1) - - const initialSpawns = spawnCount() - const second = await deriveSummary() - const third = await deriveSummary() - expect(second).toEqual(expectedSummary) - expect(third).toEqual(expectedSummary) - expect(spawnCount()).toBe(initialSpawns) - }) - - it('re-derives when headRefOid changes without re-fetching inside the throttle window', async () => { - mockGitDispatch() - - await deriveSummary('head-oid-1') - const afterPush = await deriveSummary('head-oid-2') - - expect(afterPush).toEqual(expectedSummary) - expect(spawnCount('fetch')).toBe(1) - expect(spawnCount('rev-parse')).toBe(1) - expect(spawnCount('merge-base')).toBe(2) - expect(spawnCount('rev-list')).toBe(2) - expect(spawnCount('merge-tree')).toBe(2) - }) - - it('fetches again after the throttle window and skips derivation when the base tip is unchanged', async () => { - mockGitDispatch() - - await deriveSummary() - vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) - const afterExpiry = await deriveSummary() - - expect(afterExpiry).toEqual(expectedSummary) - expect(spawnCount('fetch')).toBe(2) - expect(spawnCount('rev-parse')).toBe(2) - expect(spawnCount('merge-base')).toBe(1) - expect(spawnCount('rev-list')).toBe(1) - expect(spawnCount('merge-tree')).toBe(1) - }) - - it('re-derives after the throttle window when the base tip moved', async () => { - mockGitDispatch() - await deriveSummary() - - vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) - mockGitDispatch({ - 'rev-parse': async () => ({ stdout: 'base-tip-2\n' }), - 'rev-list': async () => ({ stdout: '5\n' }) - }) - const afterBaseMove = await deriveSummary() - - expect(afterBaseMove).toEqual({ ...expectedSummary, baseCommit: 'base-ti', commitsBehind: 5 }) - expect(spawnCount('fetch')).toBe(2) - expect(spawnCount('merge-base')).toBe(2) - expect(spawnCount('rev-list')).toBe(2) - expect(spawnCount('merge-tree')).toBe(2) - }) - - it('dedupes concurrent identical calls onto one in-flight subprocess chain', async () => { - let releaseFetch: ((value: GitResult) => void) | undefined - mockGitDispatch({ - fetch: () => - new Promise((resolve) => { - releaseFetch = resolve - }) - }) - - const firstCall = deriveSummary() - const secondCall = deriveSummary() - await vi.waitFor(() => expect(spawnCount('fetch')).toBe(1)) - releaseFetch?.({ stdout: '' }) - const [first, second] = await Promise.all([firstCall, secondCall]) - - expect(first).toEqual(expectedSummary) - expect(second).toEqual(expectedSummary) - }) - - it('dedupes overlapping derivations after resolving the same live base tip', async () => { - let releaseFetch: ((value: GitResult) => void) | undefined - mockGitDispatch({ - fetch: () => - new Promise((resolve) => { - releaseFetch = resolve - }) - }) - - const backgroundRefresh = getPRConflictSummary( - '/repo-root', - 'main', - 'older-github-base-oid', - 'head-oid-1', - {} - ) - const manualRefresh = getPRConflictSummary( - '/repo-root', - 'main', - 'newer-github-base-oid', - 'head-oid-1', - {} - ) - await vi.waitFor(() => expect(spawnCount('fetch')).toBe(1)) - releaseFetch?.({ stdout: '' }) - const [backgroundSummary, manualSummary] = await Promise.all([backgroundRefresh, manualRefresh]) - - expect(backgroundSummary).toEqual(expectedSummary) - expect(manualSummary).toEqual(expectedSummary) - expect(spawnCount('fetch')).toBe(1) - expect(spawnCount('rev-parse')).toBe(1) - expect(spawnCount('merge-base')).toBe(1) - expect(spawnCount('rev-list')).toBe(1) - expect(spawnCount('merge-tree')).toBe(1) - }) - - it('shares one base fetch across concurrent PRs on the same base branch', async () => { - let releaseFetch: ((value: GitResult) => void) | undefined - mockGitDispatch({ - fetch: () => - new Promise((resolve) => { - releaseFetch = resolve - }) - }) - - const prA = deriveSummary('head-oid-a') - const prB = deriveSummary('head-oid-b') - await vi.waitFor(() => expect(spawnCount('fetch')).toBe(1)) - releaseFetch?.({ stdout: '' }) - const [summaryA, summaryB] = await Promise.all([prA, prB]) - - expect(summaryA).toEqual(expectedSummary) - expect(summaryB).toEqual(expectedSummary) - expect(spawnCount('fetch')).toBe(1) - expect(spawnCount('rev-parse')).toBe(1) - expect(spawnCount('merge-base')).toBe(2) - }) - - it('keeps concurrent fallback base OIDs isolated per PR when the base ref is unavailable', async () => { - let releaseFetch: ((value: GitResult) => void) | undefined - mockGitDispatch({ - fetch: () => - new Promise((resolve) => { - releaseFetch = resolve - }), - 'rev-parse': () => Promise.reject(new Error('missing remote-tracking ref')), - 'rev-list': async (argv) => ({ - stdout: argv[2]?.includes('bbbb2222-base') ? '7\n' : '4\n' - }), - 'merge-tree': async (argv) => ({ - stdout: argv.includes('bbbb2222-base') - ? 'tree-oid\u0000src/b.ts\u0000' - : 'tree-oid\u0000src/a.ts\u0000' - }) - }) - - const prA = getPRConflictSummary('/repo-root', 'main', 'aaaa1111-base', 'head-oid-a', {}) - const prB = getPRConflictSummary('/repo-root', 'main', 'bbbb2222-base', 'head-oid-b', {}) - await vi.waitFor(() => expect(spawnCount('fetch')).toBe(1)) - releaseFetch?.({ stdout: '' }) - const [summaryA, summaryB] = await Promise.all([prA, prB]) - - expect(summaryA).toEqual({ - baseRef: 'main', - baseCommit: 'aaaa111', - commitsBehind: 4, - files: ['src/a.ts'] - }) - expect(summaryB).toEqual({ - baseRef: 'main', - baseCommit: 'bbbb222', - commitsBehind: 7, - files: ['src/b.ts'] - }) - expect(spawnCount('fetch')).toBe(1) - expect(spawnCount('merge-base')).toBe(2) - }) - - it('falls back to the local remote-tracking ref when fetch fails', async () => { - mockGitDispatch({ - fetch: () => Promise.reject(new Error('offline')) - }) - - const summary = await deriveSummary() - - expect(summary).toEqual(expectedSummary) - expect(spawnCount('fetch')).toBe(1) - - // Failed fetches need the same throttle or offline polling repeatedly pays the timeout. - const repeat = await deriveSummary() - expect(repeat).toEqual(expectedSummary) - expect(spawnCount('fetch')).toBe(1) - }) - it('stops waiting on a hung base fetch after 10 s and uses the local tracking ref', async () => { mockGitDispatch({ fetch: () => new Promise(() => {}) }) @@ -309,102 +112,6 @@ describe('getPRConflictSummary caching', () => { await expect(summary).resolves.toEqual(expectedSummary) }) - it("falls back to GitHub's baseRefOid when fetch and remote-tracking refs are unavailable", async () => { - mockGitDispatch({ - fetch: () => Promise.reject(new Error('offline')), - 'rev-parse': () => Promise.reject(new Error('unknown revision')) - }) - - const summary = await deriveSummary() - - expect(summary).toEqual({ ...expectedSummary, baseCommit: 'github-' }) - expect( - gitExecFileAsyncMock.mock.calls.some( - ([argv]) => argv[0] === 'merge-base' && argv.includes('github-base-oid') - ) - ).toBe(true) - }) - - it('keeps WSL-distro derivations isolated from host derivations', async () => { - mockGitDispatch() - - await deriveSummary() - await deriveSummary('head-oid-1', 'Ubuntu') - - expect(spawnCount('fetch')).toBe(2) - expect( - gitExecFileAsyncMock.mock.calls.filter( - ([argv, options]) => commandKey(argv) === 'fetch' && options?.wslDistro === 'Ubuntu' - ) - ).toHaveLength(1) - }) - - it('preserves background admission across the complete WSL derivation chain', async () => { - mockGitDispatch() - // Known support adds the quarantine's common-dir read to the chain. - getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' }).rememberSupported('merge-tree-write-tree') - - await getPRConflictSummary('/repo-root', 'main', 'github-base-oid', 'head-oid-1', { - wslDistro: 'Ubuntu', - admissionTier: 'background' - }) - - for (const [, options] of gitExecFileAsyncMock.mock.calls) { - expect(options).toEqual( - expect.objectContaining({ - cwd: '/repo-root', - wslDistro: 'Ubuntu', - admissionTier: 'background' - }) - ) - } - }) - - it('keeps identities distinct when paths or ref names contain a joiner character', async () => { - mockGitDispatch() - - // Pipe joining would alias these distinct repo/ref segment boundaries. - await getPRConflictSummary('/repo|x', 'main', 'github-base-oid', 'head-oid-1', {}) - await getPRConflictSummary('/repo', 'x|main', 'github-base-oid', 'head-oid-1', {}) - - expect(spawnCount('fetch')).toBe(2) - expect(spawnCount('merge-base')).toBe(2) - }) - - it('caches failed derivations only until the throttle window expires', async () => { - mockGitDispatch({ - 'merge-base': () => Promise.reject(new Error('bad object')) - }) - - await expect(deriveSummary()).resolves.toBeUndefined() - const failureSpawns = spawnCount() - - await expect(deriveSummary()).resolves.toBeUndefined() - expect(spawnCount()).toBe(failureSpawns) - - vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) - mockGitDispatch() - await expect(deriveSummary()).resolves.toEqual(expectedSummary) - expect(spawnCount('merge-base')).toBe(2) - }) - - it('does not repeat merge-tree --write-tree after an old-Git rejection', async () => { - mockGitDispatch({ - 'merge-tree': () => - Promise.reject( - Object.assign(new Error('unknown option'), { - stdout: 'usage: git merge-tree ' - }) - ) - }) - - await expect(deriveSummary('head-oid-1')).resolves.toBeUndefined() - await expect(deriveSummary('head-oid-2')).resolves.toBeUndefined() - - expect(spawnCount('merge-base')).toBe(2) - expect(spawnCount('merge-tree')).toBe(1) - }) - it('makes no scratch folder for a Git that rejects merge-tree --write-tree, retries included', async () => { // Git 2.25's exact rejection of both argument shapes. const rejected = Object.assign(new Error('Command failed: git merge-tree --write-tree'), { diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts deleted file mode 100644 index 2a2b46f72ab..00000000000 --- a/src/main/github/default-branch-stale-pr.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -// Implicit non-open PRs must not attach to the default branch; exercise real default-ref resolution over mocked Git. -import { beforeEach, describe, expect, it, vi } from 'vitest' - -type RateLimitGuardResult = - | { blocked: false } - | { blocked: true; remaining: number; limit: number; resetAt: number } - -const { - execFileAsyncMock, - ghExecFileAsyncMock, - getOwnerRepoMock, - getIssueOwnerRepoMock, - getOwnerRepoForRemoteMock, - resolvePRRepositoryCandidatesMock, - getRemoteUrlForRepoMock, - gitExecFileAsyncMock, - getRateLimitMock, - rateLimitGuardMock, - noteRateLimitSpendMock, - noteRepositoryRateLimitSpendMock, - ghRepoExecOptionsMock, - githubRepoContextMock, - readLocalGitConfigSignatureMock, - acquireMock, - releaseMock -} = vi.hoisted(() => ({ - execFileAsyncMock: vi.fn(), - ghExecFileAsyncMock: vi.fn(), - getOwnerRepoMock: vi.fn(), - getIssueOwnerRepoMock: vi.fn(), - getOwnerRepoForRemoteMock: vi.fn(), - resolvePRRepositoryCandidatesMock: vi.fn(), - getRemoteUrlForRepoMock: vi.fn(), - gitExecFileAsyncMock: vi.fn(), - getRateLimitMock: vi.fn(), - rateLimitGuardMock: vi.fn<(bucket?: string) => RateLimitGuardResult>(() => ({ - blocked: false - })), - noteRateLimitSpendMock: vi.fn(), - noteRepositoryRateLimitSpendMock: vi.fn(), - ghRepoExecOptionsMock: vi.fn((context) => - context.connectionId - ? {} - : { - cwd: context.repoPath, - ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) - } - ), - githubRepoContextMock: vi.fn((repoPath, connectionId, localGitOptions) => ({ - repoPath, - connectionId: connectionId ?? null, - ...localGitOptions - })), - readLocalGitConfigSignatureMock: vi.fn(), - acquireMock: vi.fn(), - releaseMock: vi.fn() -})) - -vi.mock('./gh-utils', () => ({ - execFileAsync: execFileAsyncMock, - ghExecFileAsync: ghExecFileAsyncMock, - getOwnerRepo: getOwnerRepoMock, - getIssueOwnerRepo: getIssueOwnerRepoMock, - getOwnerRepoForRemote: getOwnerRepoForRemoteMock, - resolvePRRepositoryCandidates: resolvePRRepositoryCandidatesMock, - getRemoteUrlForRepo: getRemoteUrlForRepoMock, - gitExecFileAsync: gitExecFileAsyncMock, - ghRepoExecOptions: ghRepoExecOptionsMock, - githubRepoContext: githubRepoContextMock, - classifyGhError: (stderr: string) => { - const lower = stderr.toLowerCase() - if (lower.includes('not found') || stderr.includes('HTTP 404')) { - return { type: 'not_found', message: stderr } - } - if (lower.includes('rate limit')) { - return { type: 'rate_limited', message: stderr } - } - return { type: 'unknown', message: stderr } - }, - parseGitHubOwnerRepo: (remoteUrl: string) => { - const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) - return match ? { owner: match[1], repo: match[2] } : null - }, - acquire: acquireMock, - release: releaseMock, - _resetOwnerRepoCache: vi.fn() -})) - -vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal>()), - gitExecFileAsync: gitExecFileAsyncMock -})) - -vi.mock('./local-git-config-signature', () => ({ - readLocalGitConfigSignature: readLocalGitConfigSignatureMock -})) - -vi.mock('./rate-limit', () => ({ - getRateLimit: getRateLimitMock, - rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock, - noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock -})) - -import { getPRForBranch } from './client/lookup/get-pr-for-branch' -import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' -import { _resetOwnerRepoCache } from './gh-utils' -import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache' -import { __resetTrackedUpstreamBranchCacheForTests } from './client/lookup/tracked-upstream-cache' -import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' -import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership' -import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' - -const DEFAULT_BRANCH_REF = 'refs/remotes/origin/master' - -function primeGitExecForDefaultBranch({ - defaultRef = DEFAULT_BRANCH_REF, - headOid = 'checkout-head-oid' -}: { defaultRef?: string; headOid?: string } = {}): void { - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { - return { stdout: `refs/remotes/origin/HEAD\0${defaultRef}\n`, stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === 'HEAD') { - return { stdout: `${headOid}\n`, stderr: '' } - } - if (args[0] === 'for-each-ref') { - return { stdout: '', stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }) -} - -type RestPROverrides = { - number?: number - state?: string - merged_at?: string | null - head_ref?: string - head_sha?: string -} - -function restPR({ - number = 7, - state = 'closed', - merged_at = null, - head_ref = 'master', - head_sha = 'stale-master-oid' -}: RestPROverrides = {}): Record { - return { - number, - title: 'Historical PR', - state, - merged_at, - html_url: `https://github.com/acme/widgets/pull/${number}`, - updated_at: '2024-01-01T00:00:00Z', - draft: false, - mergeable: null, - base: { ref: 'old-release', sha: 'base-oid' }, - head: { ref: head_ref, sha: head_sha } - } -} - -// Fixture REST branch lookup only; hydration failures retain its payload. -function primeGhExecWithBranchList(list: Record[]): void { - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'api' && args[1]?.includes('pulls?head=')) { - return { stdout: `HTTP/2.0 200 OK\n\n${JSON.stringify(list)}` } - } - throw new Error(`gh unavailable: ${args.join(' ')}`) - }) -} - -describe('issue #9171: default-branch checkout must not attach a stale non-open PR', () => { - beforeEach(() => { - execFileAsyncMock.mockReset() - ghExecFileAsyncMock.mockReset() - getOwnerRepoMock.mockReset() - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - getIssueOwnerRepoMock.mockReset() - getOwnerRepoForRemoteMock.mockReset() - getOwnerRepoForRemoteMock.mockImplementation( - async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => - remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null - ) - resolvePRRepositoryCandidatesMock.mockReset() - resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { - const origin = await getOwnerRepoMock(repoPath, connectionId) - return { candidates: origin ? [origin] : [], headRepo: origin } - }) - getRemoteUrlForRepoMock.mockReset() - gitExecFileAsyncMock.mockReset() - getRateLimitMock.mockReset() - getRateLimitMock.mockResolvedValue({ resources: {} }) - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) - noteRateLimitSpendMock.mockReset() - ghRepoExecOptionsMock.mockClear() - githubRepoContextMock.mockClear() - readLocalGitConfigSignatureMock.mockReset() - readLocalGitConfigSignatureMock.mockResolvedValue(undefined) - acquireMock.mockReset() - releaseMock.mockReset() - acquireMock.mockResolvedValue(undefined) - _resetOwnerRepoCache() - _resetMergeQueueCacheForTests() - __resetTrackedUpstreamBranchCacheForTests() - __resetPRConflictSummaryCachesForTests() - resetMergedPRCommitMembershipCacheForTest() - __resetRepoDefaultBranchCacheForTests() - }) - - it('hides a stale CLOSED PR when checked out on the default branch (master)', async () => { - primeGitExecForDefaultBranch() - primeGhExecWithBranchList([restPR({ number: 7, state: 'closed' })]) - - const pr = await getPRForBranch('/repo-root', 'master') - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['api', 'repos/acme/widgets/pulls?head=acme%3Amaster&state=all&per_page=10', '--include'], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(pr).toBeNull() - }) - - it('reports kind="no-pr" from getPRForBranchOutcome on the default branch', async () => { - primeGitExecForDefaultBranch() - primeGhExecWithBranchList([restPR({ number: 7, state: 'closed' })]) - - const outcome = await getPRForBranchOutcome('/repo-root', 'master') - - expect(outcome.kind).toBe('no-pr') - }) - - it('keeps a genuinely OPEN PR on the default branch visible', async () => { - primeGitExecForDefaultBranch() - primeGhExecWithBranchList([restPR({ number: 8, state: 'open' })]) - - const pr = await getPRForBranch('/repo-root', 'master') - - expect(pr?.number).toBe(8) - expect(pr?.state).toBe('open') - // Default-ref resolution stays lazy for open PRs; unrelated remote-name enumeration remains allowed. - expect( - gitExecFileAsyncMock.mock.calls - .map(([args]) => args[0]) - .filter((command) => command !== 'remote') - ).toEqual([]) - }) - - it('keeps a CLOSED PR on a feature branch visible (behavior preserved)', async () => { - primeGitExecForDefaultBranch() - primeGhExecWithBranchList([ - restPR({ number: 9, state: 'closed', head_ref: 'feature-x', head_sha: 'feature-oid' }) - ]) - - const pr = await getPRForBranch('/repo-root', 'feature-x') - - expect(pr?.number).toBe(9) - expect(pr?.state).toBe('closed') - }) - - it('preserves the merged-at-head carve-out on a feature branch', async () => { - primeGitExecForDefaultBranch({ headOid: 'merged-head-oid' }) - primeGhExecWithBranchList([ - restPR({ - number: 10, - state: 'closed', - merged_at: '2024-02-02T00:00:00Z', - head_ref: 'feature-x', - head_sha: 'merged-head-oid' - }) - ]) - - const pr = await getPRForBranch('/repo-root', 'feature-x') - - expect(pr?.number).toBe(10) - expect(pr?.state).toBe('merged') - }) - - it('hides a CLOSED fallback-number PR on the default branch (self-heal for persisted stale PRs)', async () => { - primeGitExecForDefaultBranch() - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'api' && args[1]?.includes('pulls?head=')) { - return { stdout: 'HTTP/2.0 200 OK\n\n[]' } - } - if (args[0] === 'api' && args[1] === 'graphql' && args.includes('number=7')) { - return { - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - number: 7, - title: 'Historical PR', - state: 'CLOSED', - url: 'https://github.com/acme/widgets/pull/7', - reviewDecision: null, - mergeStateStatus: 'UNKNOWN', - autoMergeRequest: null, - headRepositoryOwner: { login: 'acme' }, - headRepository: { name: 'widgets' }, - commits: { - nodes: [{ commit: { oid: 'stale-master-oid', statusCheckRollup: null } }] - }, - stack: null, - stackEntry: null, - updatedAt: '2024-01-01T00:00:00Z', - isDraft: false, - mergeable: 'UNKNOWN', - baseRefName: 'old-release', - headRefName: 'master', - baseRefOid: 'base-oid', - headRefOid: 'stale-master-oid' - } - } - } - }) - } - } - throw new Error(`gh unavailable: ${args.join(' ')}`) - }) - - const outcome = await getPRForBranchOutcome('/repo-root', 'master', null, null, 7) - - expect(outcome.kind).toBe('no-pr') - }) - - it('overrides acceptMergedFallbackPR preservation for a merged fallback PR on the default branch', async () => { - primeGitExecForDefaultBranch() - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'api' && args[1]?.includes('pulls?head=')) { - return { stdout: 'HTTP/2.0 200 OK\n\n[]' } - } - if (args[0] === 'api' && args[1] === 'graphql' && args.includes('number=7')) { - return { - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - number: 7, - title: 'Historical PR', - state: 'MERGED', - url: 'https://github.com/acme/widgets/pull/7', - reviewDecision: null, - mergeStateStatus: 'UNKNOWN', - autoMergeRequest: null, - headRepositoryOwner: { login: 'acme' }, - headRepository: { name: 'widgets' }, - commits: { - nodes: [{ commit: { oid: 'stale-master-oid', statusCheckRollup: null } }] - }, - stack: null, - stackEntry: null, - updatedAt: '2024-01-01T00:00:00Z', - isDraft: false, - mergeable: 'UNKNOWN', - baseRefName: 'old-release', - headRefName: 'master', - baseRefOid: 'base-oid', - headRefOid: 'stale-master-oid' - } - } - } - }) - } - } - throw new Error(`gh unavailable: ${args.join(' ')}`) - }) - - const outcome = await getPRForBranchOutcome('/repo-root', 'master', null, null, 7, { - acceptMergedFallbackPR: true - }) - - expect(outcome.kind).toBe('no-pr') - }) - - it('fails open when the default branch cannot be resolved (stale PR still returned)', async () => { - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'for-each-ref') { - return { stdout: '', stderr: '' } - } - throw new Error(`fatal: git unavailable: ${args.join(' ')}`) - }) - primeGhExecWithBranchList([restPR({ number: 7, state: 'closed' })]) - - const pr = await getPRForBranch('/repo-root', 'master') - - expect(pr?.number).toBe(7) - expect(pr?.state).toBe('closed') - }) -}) diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index dbf9b6f41e0..e8502f0c600 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -21,37 +21,10 @@ import { _resetRemoteNameListingCache } from '../git/remotes/remote-name-listing import { _getOwnerRepoCacheSize, _resetOwnerRepoCache, - classifyGhError, - classifyListIssuesError, - getIssueOwnerRepo, getOwnerRepo, - getOwnerRepoForRemote, - parseGitHubRemoteIdentity, - parseGitHubOwnerRepo, - resolvePRRepositoryCandidates, - resolveIssueSource + getOwnerRepoForRemote } from './gh-utils' -import { - __resetLocalGitConfigSignatureCacheForTests, - readLocalGitConfigSignature -} from './local-git-config-signature' -import { GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN } from '../../shared/github/work-items-query-bounds' - -function mockGitRemoteCommands(remotes: Record): void { - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'remote' && args[1] !== 'get-url') { - return { stdout: `${Object.keys(remotes).join('\n')}\n` } - } - if (args[0] === 'remote' && args[1] === 'get-url') { - const url = remotes[args[2] ?? ''] - if (!url) { - throw new Error(`fatal: No such remote '${args[2]}'`) - } - return { stdout: url } - } - throw new Error(`unexpected git ${args.join(' ')}`) - }) -} +import { __resetLocalGitConfigSignatureCacheForTests } from './local-git-config-signature' function gitRemoteGetUrlCalls(remoteName: string): unknown[][] { return gitExecFileAsyncMock.mock.calls.filter( @@ -60,12 +33,6 @@ function gitRemoteGetUrlCalls(remoteName: string): unknown[][] { ) } -function gitRemoteListCalls(): unknown[][] { - return gitExecFileAsyncMock.mock.calls.filter( - ([args]) => Array.isArray(args) && args[0] === 'remote' && args[1] !== 'get-url' - ) -} - describe('github owner/repo resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() @@ -74,125 +41,6 @@ describe('github owner/repo resolution', () => { __resetLocalGitConfigSignatureCacheForTests() }) - it('parses GitHub HTTPS and SSH remotes', () => { - expect(parseGitHubOwnerRepo('https://github.com/acme/widgets.git')).toEqual({ - owner: 'acme', - repo: 'widgets' - }) - expect(parseGitHubOwnerRepo('https://alice@github.com/acme/widgets.git')).toEqual({ - owner: 'acme', - repo: 'widgets' - }) - expect(parseGitHubOwnerRepo('https://github.com:443/acme/widgets.git')).toEqual({ - owner: 'acme', - repo: 'widgets' - }) - expect(parseGitHubOwnerRepo('git@github.com:stablyai/orca.git')).toEqual({ - owner: 'stablyai', - repo: 'orca' - }) - expect(parseGitHubOwnerRepo('git@github.com:TheBoredTeam/boring.notch.git')).toEqual({ - owner: 'TheBoredTeam', - repo: 'boring.notch' - }) - expect(parseGitHubOwnerRepo('ssh://git@github.com/stablyai/orca.git')).toEqual({ - owner: 'stablyai', - repo: 'orca' - }) - expect(parseGitHubOwnerRepo('ssh://git@ssh.github.com:443/stablyai/orca.git')).toEqual({ - owner: 'stablyai', - repo: 'orca' - }) - expect(parseGitHubOwnerRepo('git@example.com:stablyai/orca.git')).toBeNull() - }) - - it('parses GitHub Enterprise host identity', () => { - expect(parseGitHubRemoteIdentity('https://ghe.acme.internal/acme/orca.git')).toEqual({ - host: 'ghe.acme.internal', - owner: 'acme', - repo: 'orca' - }) - expect(parseGitHubRemoteIdentity('git@ghe.acme.internal:acme/orca.git')).toEqual({ - host: 'ghe.acme.internal', - owner: 'acme', - repo: 'orca' - }) - expect(parseGitHubOwnerRepo('https://ghe.acme.internal/acme/orca.git')).toBeNull() - }) - - it('prefers upstream for PR owner/repo resolution (#7331)', async () => { - mockGitRemoteCommands({ - origin: 'git@github.com:fork/orca.git\n', - upstream: 'git@github.com:stablyai/orca.git\n' - }) - - await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' }) - expect(gitRemoteGetUrlCalls('upstream')).toHaveLength(1) - }) - - it('does not spawn git remote get-url upstream on an origin-only clone', async () => { - mockGitRemoteCommands({ - origin: 'https://alice@github.com:443/acme/widgets.git\n' - }) - - await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'acme', repo: 'widgets' }) - await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'acme', repo: 'widgets' }) - expect(gitRemoteListCalls()).toHaveLength(1) - expect(gitRemoteGetUrlCalls('upstream')).toHaveLength(0) - expect(gitRemoteGetUrlCalls('origin')).toHaveLength(1) - }) - - it('falls back to origin when upstream is present but non-GitHub', async () => { - mockGitRemoteCommands({ - origin: 'git@github.com:fork/orca.git\n', - upstream: 'git@example.com:stablyai/orca.git\n' - }) - - await expect(getIssueOwnerRepo('/repo')).resolves.toEqual({ owner: 'fork', repo: 'orca' }) - expect(gitRemoteGetUrlCalls('upstream')).toHaveLength(1) - expect(gitRemoteGetUrlCalls('origin')).toHaveLength(1) - }) - - it('does not mix origin and upstream cache entries for the same repo path', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'git@github.com:fork/orca.git\n' }) - .mockResolvedValueOnce({ stdout: 'git@github.com:stablyai/orca.git\n' }) - - await expect(getOwnerRepoForRemote('/repo', 'origin')).resolves.toEqual({ - owner: 'fork', - repo: 'orca' - }) - await expect(getOwnerRepoForRemote('/repo', 'upstream')).resolves.toEqual({ - owner: 'stablyai', - repo: 'orca' - }) - }) - - it('coalesces concurrent missing remote probes for the same repo and remote', async () => { - gitExecFileAsyncMock.mockImplementation(async () => { - await Promise.resolve() - throw new Error("error: No such remote 'upstream'") - }) - - await expect( - Promise.all([ - getOwnerRepoForRemote('/repo', 'upstream'), - getOwnerRepoForRemote('/repo', 'upstream'), - getOwnerRepoForRemote('/repo', 'upstream'), - getOwnerRepoForRemote('/repo', 'upstream') - ]) - ).resolves.toEqual([null, null, null, null]) - - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], { - cwd: '/repo', - timeout: 30_000 - }) - - await expect(getOwnerRepoForRemote('/repo', 'upstream')).resolves.toBeNull() - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - it('keeps local host and local WSL owner/repo cache entries separate for the same path', async () => { gitExecFileAsyncMock.mockImplementation( async (args: string[], options: { wslDistro?: string } = {}) => { @@ -234,147 +82,6 @@ describe('github owner/repo resolution', () => { }) }) - it('prunes expired distinct owner/repo cache entries on later lookups', async () => { - const nowSpy = vi.spyOn(Date, 'now') - try { - nowSpy.mockReturnValue(1_000) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'git@github.com:stablyai/orca.git\n' - }) - await expect(getOwnerRepoForRemote('/repo-a', 'origin')).resolves.toEqual({ - owner: 'stablyai', - repo: 'orca' - }) - expect(_getOwnerRepoCacheSize()).toBe(1) - - nowSpy.mockReturnValue(32_000) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'git@github.com:acme/widgets.git\n' - }) - await expect(getOwnerRepoForRemote('/repo-b', 'origin')).resolves.toEqual({ - owner: 'acme', - repo: 'widgets' - }) - - expect(_getOwnerRepoCacheSize()).toBe(1) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - } finally { - nowSpy.mockRestore() - } - }) - - it('resolves PR candidates as upstream then origin and de-dupes matching slugs', async () => { - mockGitRemoteCommands({ - origin: 'git@github.com:acme/orca.git\n', - upstream: 'git@github.com:Acme/Orca.git\n' - }) - - await expect(resolvePRRepositoryCandidates('/repo')).resolves.toEqual({ - candidates: [{ owner: 'Acme', repo: 'Orca' }], - headRepo: { owner: 'acme', repo: 'orca' } - }) - expect(gitRemoteGetUrlCalls('upstream')).toHaveLength(1) - }) - - it('does not spawn git remote get-url upstream for origin-only PR candidates', async () => { - mockGitRemoteCommands({ origin: 'git@github.com:fork/orca.git\n' }) - - await expect(resolvePRRepositoryCandidates('/repo')).resolves.toEqual({ - candidates: [{ owner: 'fork', repo: 'orca' }], - headRepo: { owner: 'fork', repo: 'orca' } - }) - await expect(resolvePRRepositoryCandidates('/repo')).resolves.toEqual({ - candidates: [{ owner: 'fork', repo: 'orca' }], - headRepo: { owner: 'fork', repo: 'orca' } - }) - expect(gitRemoteListCalls()).toHaveLength(1) - expect(gitRemoteGetUrlCalls('upstream')).toHaveLength(0) - }) - - it('ignores non-GitHub upstream while keeping origin as the head repo', async () => { - mockGitRemoteCommands({ - origin: 'git@github.com:fork/orca.git\n', - upstream: 'git@example.com:Acme/Orca.git\n' - }) - - await expect(resolvePRRepositoryCandidates('/repo')).resolves.toEqual({ - candidates: [{ owner: 'fork', repo: 'orca' }], - headRepo: { owner: 'fork', repo: 'orca' } - }) - }) - - it('expires cached remote owner/repo entries after the TTL', async () => { - vi.useFakeTimers() - try { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'git@github.com:old/orca.git\n' }) - .mockResolvedValueOnce({ stdout: 'git@github.com:new/orca.git\n' }) - - await expect(getOwnerRepoForRemote('/repo', 'origin')).resolves.toEqual({ - owner: 'old', - repo: 'orca' - }) - await expect(getOwnerRepoForRemote('/repo', 'origin')).resolves.toEqual({ - owner: 'old', - repo: 'orca' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(30_001) - - await expect(getOwnerRepoForRemote('/repo', 'origin')).resolves.toEqual({ - owner: 'new', - repo: 'orca' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('keeps local missing-remote probes cached beyond the short positive TTL', async () => { - const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) - await mkdir(join(repoPath, '.git')) - await writeFile(join(repoPath, '.git', 'config'), '[core]\n\trepositoryformatversion = 0\n') - vi.useFakeTimers() - try { - vi.setSystemTime(1_000) - gitExecFileAsyncMock.mockRejectedValue(new Error("error: No such remote 'origin'")) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull() - vi.setSystemTime(32_000) - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull() - - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - await rm(repoPath, { recursive: true, force: true }) - } - }) - - it('treats stderr-only missing-remote errors as stable negatives', async () => { - const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) - await mkdir(join(repoPath, '.git')) - await writeFile(join(repoPath, '.git', 'config'), '[core]\n\trepositoryformatversion = 0\n') - vi.useFakeTimers() - try { - vi.setSystemTime(1_000) - gitExecFileAsyncMock.mockRejectedValue( - Object.assign(new Error('Command failed'), { - stderr: "fatal: No such remote 'origin'" - }) - ) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull() - vi.setSystemTime(32_000) - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull() - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - await rm(repoPath, { recursive: true, force: true }) - } - }) - it('does not apply the long negative TTL when git remote get-url fails transiently', async () => { const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) await mkdir(join(repoPath, '.git')) @@ -395,36 +102,6 @@ describe('github owner/repo resolution', () => { } }) - it('invalidates a cached local missing remote when git config changes', async () => { - const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) - await mkdir(join(repoPath, '.git')) - const configPath = join(repoPath, '.git', 'config') - await writeFile(configPath, '[core]\n\trepositoryformatversion = 0\n') - vi.useFakeTimers() - try { - vi.setSystemTime(1_000) - gitExecFileAsyncMock - .mockRejectedValueOnce(new Error("error: No such remote 'origin'")) - .mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' }) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull() - await writeFile( - configPath, - '[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n' - ) - vi.setSystemTime(32_000) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({ - owner: 'acme', - repo: 'widgets' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - await rm(repoPath, { recursive: true, force: true }) - } - }) - it('invalidates a cached local missing remote when an included git config changes', async () => { const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) await mkdir(join(repoPath, '.git')) @@ -458,353 +135,4 @@ describe('github owner/repo resolution', () => { await rm(repoPath, { recursive: true, force: true }) } }) - - it('tracks included git config paths with inline comments', async () => { - const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) - await mkdir(join(repoPath, '.git')) - const includedConfigPath = join(repoPath, 'remote-with-comment.inc') - await writeFile( - join(repoPath, '.git', 'config'), - `[core]\n\trepositoryformatversion = 0\n[include]\n\tpath = ${includedConfigPath} # origin remote lives here\n` - ) - await writeFile(includedConfigPath, '') - vi.useFakeTimers() - try { - vi.setSystemTime(1_000) - gitExecFileAsyncMock - .mockRejectedValueOnce(new Error("error: No such remote 'origin'")) - .mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' }) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull() - await writeFile( - includedConfigPath, - '[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n' - ) - vi.setSystemTime(32_000) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({ - owner: 'acme', - repo: 'widgets' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - await rm(repoPath, { recursive: true, force: true }) - } - }) - - it('tracks included git config paths when section headers have inline comments', async () => { - const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) - await mkdir(join(repoPath, '.git')) - const includedConfigPath = join(repoPath, 'section-comment.inc') - await writeFile( - join(repoPath, '.git', 'config'), - `[core]\n\trepositoryformatversion = 0\n[include] # comment\n\tpath = ${includedConfigPath}\n` - ) - await writeFile(includedConfigPath, '') - vi.useFakeTimers() - try { - vi.setSystemTime(1_000) - gitExecFileAsyncMock - .mockRejectedValueOnce(new Error("error: No such remote 'origin'")) - .mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' }) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull() - await writeFile( - includedConfigPath, - '[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n' - ) - vi.setSystemTime(32_000) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({ - owner: 'acme', - repo: 'widgets' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - await rm(repoPath, { recursive: true, force: true }) - } - }) - - it('tracks quoted included git config paths with inline comments', async () => { - const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) - await mkdir(join(repoPath, '.git')) - const includedConfigPath = join(repoPath, 'quoted-comment.inc') - await writeFile( - join(repoPath, '.git', 'config'), - `[core]\n\trepositoryformatversion = 0\n[include]\n\tpath = "${includedConfigPath}" # comment\n` - ) - await writeFile(includedConfigPath, '') - vi.useFakeTimers() - try { - vi.setSystemTime(1_000) - gitExecFileAsyncMock - .mockRejectedValueOnce(new Error("error: No such remote 'origin'")) - .mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' }) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull() - await writeFile( - includedConfigPath, - '[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n' - ) - vi.setSystemTime(32_000) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({ - owner: 'acme', - repo: 'widgets' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - await rm(repoPath, { recursive: true, force: true }) - } - }) - - it('tracks quoted included git config paths with comment characters in the path', async () => { - const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) - await mkdir(join(repoPath, '.git')) - const includeDir = join(repoPath, 'include # hash') - await mkdir(includeDir) - const includedConfigPath = join(includeDir, 'remote.inc') - await writeFile( - join(repoPath, '.git', 'config'), - `[core]\n\trepositoryformatversion = 0\n[include]\n\tpath = "${includedConfigPath}"\n` - ) - await writeFile(includedConfigPath, '') - vi.useFakeTimers() - try { - vi.setSystemTime(1_000) - gitExecFileAsyncMock - .mockRejectedValueOnce(new Error("error: No such remote 'origin'")) - .mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' }) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull() - await writeFile( - includedConfigPath, - '[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n' - ) - vi.setSystemTime(32_000) - - await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({ - owner: 'acme', - repo: 'widgets' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - await rm(repoPath, { recursive: true, force: true }) - } - }) - - it('includes per-worktree git config in local config signatures', async () => { - const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) - const gitDir = join(repoPath, '.git') - await mkdir(gitDir) - await writeFile(join(gitDir, 'config'), '[core]\n\trepositoryformatversion = 0\n') - try { - const firstSignature = await readLocalGitConfigSignature({ - repoPath, - connectionId: null - }) - - await writeFile( - join(gitDir, 'config.worktree'), - '[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n' - ) - const secondSignature = await readLocalGitConfigSignature({ - repoPath, - connectionId: null - }) - - expect(secondSignature).not.toEqual(firstSignature) - } finally { - await rm(repoPath, { recursive: true, force: true }) - } - }) - - it('includes linked worktree config in local config signatures', async () => { - const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) - const commonGitDir = join(repoPath, 'common-git') - const worktreeGitDir = join(commonGitDir, 'worktrees', 'feature') - const worktreePath = join(repoPath, 'feature-worktree') - await mkdir(worktreeGitDir, { recursive: true }) - await mkdir(worktreePath) - await writeFile(join(worktreePath, '.git'), `gitdir: ${worktreeGitDir}\n`) - await writeFile(join(worktreeGitDir, 'commondir'), '../..\n') - await writeFile(join(commonGitDir, 'config'), '[core]\n\trepositoryformatversion = 0\n') - try { - const firstSignature = await readLocalGitConfigSignature({ - repoPath: worktreePath, - connectionId: null - }) - - await writeFile( - join(worktreeGitDir, 'config.worktree'), - '[branch "feature"]\n\tremote = origin\n\tmerge = refs/heads/contributor/original\n' - ) - const secondSignature = await readLocalGitConfigSignature({ - repoPath: worktreePath, - connectionId: null - }) - - expect(secondSignature).not.toEqual(firstSignature) - } finally { - await rm(repoPath, { recursive: true, force: true }) - } - }) - - it('tracks includeIf paths with comment markers inside quoted section headers', async () => { - const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) - const gitDir = join(repoPath, '.git') - const includedDir = join(repoPath, 'Work #1') - const includedConfigPath = join(includedDir, 'included.gitconfig') - await mkdir(gitDir) - await mkdir(includedDir) - await writeFile( - includedConfigPath, - '[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n' - ) - await writeFile( - join(gitDir, 'config'), - `[includeIf "gitdir:${includedDir}/"]\n\tpath = "${includedConfigPath}"\n` - ) - try { - const firstSignature = await readLocalGitConfigSignature({ - repoPath, - connectionId: null - }) - - await writeFile( - includedConfigPath, - '[remote "origin"]\n\turl = git@github.com:acme/renamed-widgets.git\n' - ) - const secondSignature = await readLocalGitConfigSignature({ - repoPath, - connectionId: null - }) - - expect(secondSignature).not.toEqual(firstSignature) - } finally { - await rm(repoPath, { recursive: true, force: true }) - } - }) -}) - -describe('resolveIssueSource', () => { - beforeEach(() => { - gitExecFileAsyncMock.mockReset() - _resetOwnerRepoCache() - _resetRemoteNameListingCache() - }) - - it("'auto' + upstream exists → upstream, fellBack=false", async () => { - mockGitRemoteCommands({ - origin: 'git@github.com:fork/orca.git\n', - upstream: 'git@github.com:stablyai/orca.git\n' - }) - - await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - expect(gitRemoteGetUrlCalls('upstream')).toHaveLength(1) - }) - - it("'auto' + no github upstream → origin, fellBack=false", async () => { - mockGitRemoteCommands({ - origin: 'git@github.com:solo/orca.git\n', - upstream: 'git@example.com:stablyai/orca.git\n' - }) - - await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({ - source: { owner: 'solo', repo: 'orca' }, - fellBack: false - }) - }) - - it("'upstream' + upstream exists → upstream, fellBack=false", async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'git@github.com:stablyai/orca.git\n' - }) - - await expect(resolveIssueSource('/repo', 'upstream')).resolves.toEqual({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - }) - - it("'upstream' + no upstream remote → origin, fellBack=true", async () => { - gitExecFileAsyncMock - .mockRejectedValueOnce(new Error('fatal: No such remote')) - .mockResolvedValueOnce({ stdout: 'git@github.com:solo/orca.git\n' }) - - await expect(resolveIssueSource('/repo', 'upstream')).resolves.toEqual({ - source: { owner: 'solo', repo: 'orca' }, - fellBack: true - }) - }) - - it("'origin' + upstream exists → origin (ignores upstream), fellBack=false", async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'git@github.com:fork/orca.git\n' - }) - - await expect(resolveIssueSource('/repo', 'origin')).resolves.toEqual({ - source: { owner: 'fork', repo: 'orca' }, - fellBack: false - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo', - timeout: 30_000 - }) - }) - - it("'origin' + no upstream → origin, fellBack=false", async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'git@github.com:solo/orca.git\n' - }) - - await expect(resolveIssueSource('/repo', 'origin')).resolves.toEqual({ - source: { owner: 'solo', repo: 'orca' }, - fellBack: false - }) - }) - - it('undefined preference is treated identically to auto', async () => { - mockGitRemoteCommands({ - origin: 'git@github.com:fork/orca.git\n', - upstream: 'git@github.com:stablyai/orca.git\n' - }) - - await expect(resolveIssueSource('/repo', undefined)).resolves.toEqual({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - }) -}) - -describe('gh error classification', () => { - it('classifies "has disabled issues" stderr as issues_disabled', () => { - const stderr = - "Command failed: gh issue list --limit 36 --json number,title,state --repo brennanb2025/orca --state open\nthe 'brennanb2025/orca' repository has disabled issues" - expect(classifyGhError(stderr)).toMatchObject({ - type: 'issues_disabled', - message: 'Issues are disabled on this repository.' - }) - expect(classifyListIssuesError(stderr)).toEqual({ - type: 'issues_disabled', - message: 'Issues are disabled on this repository.' - }) - }) - - // Renderer window detection matches this phrase; preserve raw stderr in validation errors. - it('keeps the search-window phrase in validation_error list messages', () => { - const stderr = - 'Command failed: gh api --hostname github.com search/issues\nValidation Failed: Only the first 1000 search results are available (HTTP 422)' - const classified = classifyListIssuesError(stderr) - expect(classified.type).toBe('validation_error') - expect(classified.message).toMatch(GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN) - }) }) diff --git a/src/main/github/github-api-repository.test.ts b/src/main/github/github-api-repository.test.ts index 0eddd19e02f..fb3da271755 100644 --- a/src/main/github/github-api-repository.test.ts +++ b/src/main/github/github-api-repository.test.ts @@ -31,13 +31,10 @@ vi.mock('./github-enterprise-repository', async (importOriginal) => ({ vi.mock('../git/remotes/remote-name-listing', () => ({ shouldProbeGitRemote: shouldProbeGitRemoteMock })) - import { _resetOriginGitHubApiRepositoryCache, getGitHubApiRepositoryForRemote, - getIssueGitHubApiRepository, getOriginGitHubApiRepository, - githubHostExecOptions, resolveGitHubApiRepository, resolveGitHubApiRepositoryCandidates, resolveGitHubRepoExecution @@ -52,45 +49,7 @@ beforeEach(() => { shouldProbeGitRemoteMock.mockReset().mockResolvedValue(true) }) -describe('githubHostExecOptions', () => { - it('pins every known repository host', () => { - expect(githubHostExecOptions({ owner: 'acme', repo: 'widgets', host: 'github.com' })).toEqual({ - host: 'github.com' - }) - expect( - githubHostExecOptions({ owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }) - ).toEqual({ host: 'github.acme-corp.com' }) - }) - - it('does not invent a host when repository identity is unavailable or legacy', () => { - expect(githubHostExecOptions({ owner: 'acme', repo: 'widgets' })).toEqual({}) - expect(githubHostExecOptions(null)).toEqual({}) - }) -}) - describe('resolveGitHubRepoExecution', () => { - it('combines local repository and GitHub host execution options', async () => { - const ownerRepo = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com:8443' } - isGitHubHostAuthenticatedMock.mockResolvedValue(true) - - await expect( - resolveGitHubRepoExecution('/repo', ownerRepo, null, { wslDistro: 'Ubuntu' }) - ).resolves.toEqual({ - ownerRepo, - ghOptions: { - cwd: '/repo', - wslDistro: 'Ubuntu', - host: 'github.acme-corp.com:8443' - } - }) - expect(isGitHubHostAuthenticatedMock).toHaveBeenCalledWith( - 'github.acme-corp.com:8443', - '/repo', - null, - { wslDistro: 'Ubuntu' } - ) - }) - it('rejects an explicit Enterprise host absent from the local gh auth inventory', async () => { await expect( resolveGitHubApiRepository( @@ -126,45 +85,6 @@ describe('resolveGitHubRepoExecution', () => { expect(isGitHubHostAuthenticatedMock).not.toHaveBeenCalled() }) - it.each(['acme', 'octocat_acme'])( - 'normalizes github.com for %s without an auth inventory probe', - async (owner) => { - await expect( - resolveGitHubApiRepository('/repo', { - owner, - repo: 'widgets', - host: ' GitHub.COM ' - }) - ).resolves.toEqual({ owner, repo: 'widgets', host: 'github.com' }) - - expect(isGitHubHostAuthenticatedMock).not.toHaveBeenCalled() - } - ) - - it('backfills the origin host for a host-less caller-specific resolver', async () => { - const ownerRepo = { owner: 'upstream', repo: 'widgets' } - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'fork', repo: 'widgets' }) - - await expect(resolveGitHubRepoExecution('/repo', async () => ownerRepo)).resolves.toEqual({ - ownerRepo: { ...ownerRepo, host: 'github.com' }, - ghOptions: { cwd: '/repo', host: 'github.com' } - }) - }) - - it('backfills an Enterprise origin host for a host-less caller-specific resolver', async () => { - const ownerRepo = { owner: 'upstream', repo: 'widgets' } - getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ - owner: 'fork', - repo: 'widgets', - host: 'github.acme-corp.com' - }) - - await expect(resolveGitHubRepoExecution('/repo', async () => ownerRepo)).resolves.toEqual({ - ownerRepo: { ...ownerRepo, host: 'github.acme-corp.com' }, - ghOptions: { cwd: '/repo', host: 'github.acme-corp.com' } - }) - }) - it('rejects a host-less caller-specific resolver for a connection-backed repository', async () => { await expect( resolveGitHubRepoExecution( @@ -175,12 +95,6 @@ describe('resolveGitHubRepoExecution', () => { ).resolves.toEqual({ ownerRepo: null, ghOptions: {} }) }) - it('rejects a host-less caller-specific resolver for an unresolved local repository', async () => { - await expect( - resolveGitHubRepoExecution('/repo', async () => ({ owner: 'upstream', repo: 'widgets' })) - ).resolves.toEqual({ ownerRepo: null, ghOptions: { cwd: '/repo' } }) - }) - it('preserves an authoritative null from a caller-specific resolver', async () => { getOwnerRepoMock.mockResolvedValue({ owner: 'origin', repo: 'widgets' }) @@ -232,123 +146,9 @@ describe('origin repository cache', () => { ).resolves.toEqual(enterprise) expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) }) - - it('does not cache an indeterminate Enterprise auth probe', async () => { - const enterprise = { - owner: 'acme', - repo: 'widgets', - host: 'github.acme-corp.com' - } - getEnterpriseGitHubRepoSlugMock - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce(enterprise) - - await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() - await expect(getOriginGitHubApiRepository('/repo')).resolves.toEqual(enterprise) - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) - }) - - it('still caches a definitive negative Enterprise probe', async () => { - await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() - await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) - }) }) describe('skip missing upstream remote probes', () => { - it('starts the issue origin probe before checking whether upstream exists', async () => { - let releaseRemoteProbe: (value: boolean) => void = () => undefined - shouldProbeGitRemoteMock.mockReturnValue( - new Promise((resolve) => { - releaseRemoteProbe = resolve - }) - ) - let originStarted = false - getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) => { - if (remote === 'origin') { - originStarted = true - return { owner: 'fork', repo: 'orca' } - } - return { owner: 'stablyai', repo: 'orca' } - }) - - const resultPromise = getIssueGitHubApiRepository('/repo') - expect(originStarted).toBe(true) - - releaseRemoteProbe(true) - await expect(resultPromise).resolves.toEqual({ - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - }) - - it('does not probe upstream for issue identity when that remote is absent', async () => { - shouldProbeGitRemoteMock.mockResolvedValue(false) - getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) => - remote === 'origin' ? { owner: 'acme', repo: 'widgets' } : null - ) - - await expect(getIssueGitHubApiRepository('/repo')).resolves.toEqual({ - owner: 'acme', - repo: 'widgets', - host: 'github.com' - }) - expect(getOwnerRepoForRemoteMock.mock.calls.map(([, remote]) => remote)).toEqual(['origin']) - }) - - it('still probes upstream for issue identity when that remote is present', async () => { - getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) => - remote === 'upstream' ? { owner: 'stablyai', repo: 'orca' } : { owner: 'fork', repo: 'orca' } - ) - - await expect(getIssueGitHubApiRepository('/repo')).resolves.toEqual({ - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo', 'upstream', undefined, {}) - }) - - it('observes a rejected origin probe when upstream resolves the issue repository', async () => { - const originError = new Error('origin probe failed') - getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) => { - if (remote === 'origin') { - throw originError - } - return { owner: 'stablyai', repo: 'orca' } - }) - - await expect(getIssueGitHubApiRepository('/repo')).resolves.toEqual({ - owner: 'stablyai', - repo: 'orca', - host: 'github.com' - }) - }) - - it('preserves a rejected origin probe when upstream cannot resolve the issue repository', async () => { - const originError = new Error('origin probe failed') - getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) => { - if (remote === 'origin') { - throw originError - } - return null - }) - - await expect(getIssueGitHubApiRepository('/repo')).rejects.toBe(originError) - }) - - it('does not probe upstream for PR candidates when that remote is absent', async () => { - shouldProbeGitRemoteMock.mockResolvedValue(false) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'fork', repo: 'orca' }) - - await expect(resolveGitHubApiRepositoryCandidates('/repo')).resolves.toEqual({ - candidates: [{ owner: 'fork', repo: 'orca', host: 'github.com' }], - headRepo: { owner: 'fork', repo: 'orca', host: 'github.com' } - }) - expect(getOwnerRepoForRemoteMock.mock.calls.map(([, remote]) => remote)).toEqual(['origin']) - }) - it('observes and propagates a verified origin probe failure while listing remotes', async () => { let releaseRemoteProbe: (value: boolean) => void = () => undefined shouldProbeGitRemoteMock.mockReturnValue( @@ -378,22 +178,4 @@ describe('skip missing upstream remote probes', () => { await expect(resultPromise).rejects.toBe(originError) }) - - it('still probes upstream for PR candidates when that remote is present', async () => { - getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) => - remote === 'upstream' ? { owner: 'Acme', repo: 'Orca' } : { owner: 'acme', repo: 'orca' } - ) - - await expect(resolveGitHubApiRepositoryCandidates('/repo')).resolves.toEqual({ - candidates: [{ owner: 'Acme', repo: 'Orca', host: 'github.com' }], - headRepo: { owner: 'acme', repo: 'orca', host: 'github.com' } - }) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( - '/repo', - 'upstream', - undefined, - {}, - { requireVerifiedSshProbe: true } - ) - }) }) diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index 3db08f43670..025609e3880 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -27,12 +27,10 @@ vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ vi.mock('../ssh/config/ssh-g-config-resolution', () => ({ resolveWithSshG: resolveWithSshGMock })) - import { _resetGitHubHostAuthCache, getEnterpriseGitHubRepoSlug, - isGitHubHostAuthenticated, - isGitHubHostAuthenticatedForGlobalCli + isGitHubHostAuthenticated } from './github-enterprise-repository' import { _resetSshHostnameResolutionCache } from './github-ssh-host-alias-resolution' @@ -45,19 +43,6 @@ function mockOriginRemote(url: string): void { }) } -function sshConfig(hostname: string, port = 22) { - return { - hostname, - port, - identityFile: [], - identitiesOnly: false, - forwardAgent: false, - proxyUseFdpass: false, - controlMaster: 'no', - controlPersist: 'no' - } -} - function mockHostAuthenticated(host = 'github.acme-corp.com'): void { mockAuthenticatedHosts([host]) } @@ -71,15 +56,6 @@ function mockAuthenticatedHosts(hosts: string[]): void { }) } -function mockHostNotAuthenticated(): void { - ghExecFileAsyncMock.mockRejectedValue( - Object.assign(new Error('exit 1'), { - stdout: '', - stderr: 'You are not logged into any GitHub hosts. To log in, run: gh auth login' - }) - ) -} - describe('getEnterpriseGitHubRepoSlug', () => { beforeEach(() => { commandExecFileAsyncMock.mockReset() @@ -91,119 +67,6 @@ describe('getEnterpriseGitHubRepoSlug', () => { _resetSshHostnameResolutionCache() }) - it('resolves a GHES remote whose host the user is gh-authenticated to (#8312)', async () => { - mockOriginRemote('https://github.acme-corp.com/team/orca.git') - mockHostAuthenticated() - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ - owner: 'team', - repo: 'orca', - host: 'github.acme-corp.com' - }) - // Inventory configured hosts without sending ambient credentials to an untrusted remote. - expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { cwd: '/repo' }) - }) - - it('resolves a GHES SCP-style SSH remote', async () => { - mockOriginRemote('git@github.acme-corp.com:team/orca.git') - mockHostAuthenticated() - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ - owner: 'team', - repo: 'orca', - host: 'github.acme-corp.com' - }) - }) - - it('expands an SSH Host alias to the authenticated GHES HostName (#10284)', async () => { - mockOriginRemote('git@ghe-work:team/orca.git') - resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.acme-corp.com')) - mockHostAuthenticated('github.acme-corp.com') - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ - owner: 'team', - repo: 'orca', - host: 'github.acme-corp.com' - }) - expect(resolveWithSshGMock).toHaveBeenCalledWith('ghe-work') - }) - - it('keeps a failed GHES alias probe indeterminate and recovers on retry', async () => { - vi.useFakeTimers() - mockOriginRemote('git@ghe-work:team/orca.git') - resolveWithSshGMock - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(sshConfig('github.acme-corp.com')) - mockHostNotAuthenticated() - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeUndefined() - - await vi.advanceTimersByTimeAsync(5_001) - ghExecFileAsyncMock.mockReset() - mockHostAuthenticated('github.acme-corp.com') - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ - owner: 'team', - repo: 'orca', - host: 'github.acme-corp.com' - }) - }) - - it('returns null for a Host alias that resolves to github.com (dotcom path owns it)', async () => { - mockOriginRemote('git@github-work:team/orca.git') - resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443)) - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('expands aliases in the repository WSL runtime', async () => { - mockOriginRemote('git@github-work:team/orca.git') - commandExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'hostname github.com\nport 22\n', - stderr: '' - }) - - await expect( - getEnterpriseGitHubRepoSlug('/repo', null, { - localGitExecOptions: { wslDistro: 'Ubuntu' } - }) - ).resolves.toBeNull() - expect(commandExecFileAsyncMock).toHaveBeenCalledWith('ssh', ['-G', '--', 'github-work'], { - cwd: '/repo', - timeout: 5_000, - wslDistro: 'Ubuntu' - }) - expect(resolveWithSshGMock).not.toHaveBeenCalled() - }) - - it('uses the unique ported auth host for a hostname-only SSH remote', async () => { - mockOriginRemote('git@ghe.acme.com:team/orca.git') - mockHostAuthenticated('ghe.acme.com:8443') - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ - owner: 'team', - repo: 'orca', - host: 'ghe.acme.com:8443' - }) - }) - - it.each([ - [['ghe.acme.com', 'ghe.acme.com:9443', 'ghe.acme.com:8443']], - [['ghe.acme.com:8443', 'ghe.acme.com:9443', 'ghe.acme.com']] - ])( - 'requires exact host:port authentication regardless of auth inventory order: %j', - async (authenticatedHosts) => { - mockOriginRemote('https://ghe.acme.com:8443/team/orca.git') - mockAuthenticatedHosts(authenticatedHosts) - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ - owner: 'team', - repo: 'orca', - host: 'ghe.acme.com:8443' - }) - } - ) - it.each([ 'https://ghe.acme.com:8443/team/orca.git', 'https://ghe.acme.com:80/team/orca.git', @@ -215,84 +78,6 @@ describe('getEnterpriseGitHubRepoSlug', () => { await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() }) - it.each(['http://ghe.acme.com:80/team/orca.git', 'https://ghe.acme.com:443/team/orca.git'])( - 'matches a protocol-default port to the portless auth host: %s', - async (remoteUrl) => { - mockOriginRemote(remoteUrl) - mockHostAuthenticated('ghe.acme.com') - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ - owner: 'team', - repo: 'orca', - host: 'ghe.acme.com' - }) - } - ) - - it('preserves exact authentication for an HTTP endpoint on port 443', async () => { - mockOriginRemote('http://ghe.acme.com:443/team/orca.git') - mockHostAuthenticated('ghe.acme.com:443') - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ - owner: 'team', - repo: 'orca', - host: 'ghe.acme.com:443' - }) - }) - - it('preserves exact authentication for an HTTPS endpoint on port 80', async () => { - mockOriginRemote('https://ghe.acme.com:80/team/orca.git') - mockHostAuthenticated('ghe.acme.com:80') - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ - owner: 'team', - repo: 'orca', - host: 'ghe.acme.com:80' - }) - }) - - it('probes gh in the repository WSL runtime, not the host/default distro', async () => { - mockOriginRemote('https://github.acme-corp.com/team/orca.git') - mockHostAuthenticated() - - await getEnterpriseGitHubRepoSlug('/repo', null, { - localGitExecOptions: { wslDistro: 'Ubuntu' } - }) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { - cwd: '/repo', - wslDistro: 'Ubuntu' - }) - }) - - it('leaves github.com to getOwnerRepo without probing gh auth', async () => { - mockOriginRemote('https://github.com/team/orca.git') - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('declines a custom host the user is not gh-authenticated to (leaves it for Gitea)', async () => { - mockOriginRemote('https://gitea.example.com/team/orca.git') - mockHostNotAuthenticated() - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() - }) - - it('returns null for an unparseable remote', async () => { - mockOriginRemote('not-a-remote-url') - mockHostAuthenticated() - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('returns null when the origin remote lookup fails', async () => { - gitExecFileAsyncMock.mockRejectedValue(new Error('no such remote')) - - await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() - }) - it('preserves an indeterminate auth inventory failure so a later probe can recover', async () => { mockOriginRemote('git@ghe.acme.com:team/orca.git') ghExecFileAsyncMock.mockRejectedValueOnce( @@ -317,76 +102,6 @@ describe('isGitHubHostAuthenticated', () => { _resetGitHubHostAuthCache() }) - it('runs gh in the SSH-local runtime (no cwd) for connection-backed repos', async () => { - mockHostAuthenticated() - - await expect( - isGitHubHostAuthenticated('github.acme-corp.com', '/remote/repo', 'ssh-1') - ).resolves.toBe(true) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {}) - }) - - it('caches per runtime+host so detection polling does not re-spawn gh', async () => { - mockHostAuthenticated() - - await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') - await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - - it('retains a known authenticated host for 15 minutes', async () => { - mockHostAuthenticated() - await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') - await vi.advanceTimersByTimeAsync(14 * 60_000) - await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(60_000) - await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('rechecks host authentication after the credential environment changes', async () => { - mockHostAuthenticated() - await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') - vi.stubEnv('GH_ENTERPRISE_TOKEN', 'test-only-changed-token') - try { - await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - } finally { - vi.unstubAllEnvs() - } - }) - - it('coalesces concurrent probes for the same runtime and host', async () => { - let finishProbe: (() => void) | undefined - ghExecFileAsyncMock.mockImplementation( - () => - new Promise((resolve) => { - finishProbe = () => - resolve({ - stdout: - 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', - stderr: '' - }) - }) - ) - - const first = isGitHubHostAuthenticated('github.acme-corp.com', '/repo') - const second = isGitHubHostAuthenticated('github.acme-corp.com', '/repo') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - finishProbe?.() - await expect(Promise.all([first, second])).resolves.toEqual([true, true]) - }) - - it('does not share cache state across WSL distros', async () => { - mockHostAuthenticated() - - await isGitHubHostAuthenticated('github.acme-corp.com', '/repo', null, { wslDistro: 'Ubuntu' }) - await isGitHubHostAuthenticated('github.acme-corp.com', '/repo', null, { wslDistro: 'Debian' }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - it('derives cache scope from an implicit WSL UNC repository path', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -486,62 +201,4 @@ describe('isGitHubHostAuthenticated', () => { await expect(isGitHubHostAuthenticated('ghe.acme.com', '/repo')).resolves.toBe(false) } ) - - it.each([ - [ - ['ghe.acme.com', false], - ['ghe.acme.com:8443', true] - ], - [ - ['ghe.acme.com:8443', true], - ['ghe.acme.com', false] - ] - ] as const)( - 'keeps exact-port and ambiguous hostname auth cached independently: %j', - async (...requests) => { - mockAuthenticatedHosts(['ghe.acme.com:8443', 'ghe.acme.com:9443']) - - for (const [host, expected] of requests) { - await expect(isGitHubHostAuthenticated(host, '/repo')).resolves.toBe(expected) - } - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - } - ) - - it('treats default web ports as the same auth host', async () => { - mockHostAuthenticated('ghe.acme.com:443') - - await expect(isGitHubHostAuthenticated('ghe.acme.com', '/repo')).resolves.toBe(true) - }) - - it('validates a global project host by inventory without targeting it', async () => { - mockHostAuthenticated('github.acme-corp.com') - - await expect(isGitHubHostAuthenticatedForGlobalCli('evil.example.test')).resolves.toBe(false) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {}) - expect(ghExecFileAsyncMock.mock.calls.flat()).not.toContain('evil.example.test') - }) - - it('treats a listed host as authenticated even when gh exits non-zero', async () => { - ghExecFileAsyncMock.mockRejectedValue( - Object.assign(new Error('exit 1'), { - stdout: '', - stderr: - 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)\n X github.com: token expired' - }) - ) - - await expect(isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).resolves.toBe(true) - }) - - it('does not cache a hard gh failure so a later probe can recover', async () => { - ghExecFileAsyncMock.mockRejectedValueOnce( - Object.assign(new Error('not installed'), { stdout: '', stderr: '' }) - ) - expect(await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(false) - - mockHostAuthenticated() - expect(await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(true) - }) }) diff --git a/src/main/github/github-pr-stack.test.ts b/src/main/github/github-pr-stack.test.ts index 0168ddd2774..e352ce9493a 100644 --- a/src/main/github/github-pr-stack.test.ts +++ b/src/main/github/github-pr-stack.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { GitHubPRStack } from '../../shared/github/pull-request-types' const { ghExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), @@ -15,21 +14,9 @@ vi.mock('./rate-limit', () => ({ repositoryRateLimitGuard: rateLimitGuardMock, noteRepositoryRateLimitSpend: noteRateLimitSpendMock })) - -import { - _resetGitHubPRStackCacheForTests, - hydrateGitHubPRStack, - mergeGitHubPRStack -} from './github-pr-stack' +import { _resetGitHubPRStackCacheForTests, mergeGitHubPRStack } from './github-pr-stack' const repository = { owner: 'stablyai', repo: 'orca', host: 'github.com' } -const summary: GitHubPRStack = { - number: 51, - position: 2, - size: 3, - baseRefName: 'main', - baseSha: 'base-sha' -} beforeEach(() => { vi.useRealTimers() @@ -40,276 +27,7 @@ beforeEach(() => { _resetGitHubPRStackCacheForTests() }) -describe('hydrateGitHubPRStack', () => { - it('maps and caches every entry in one GraphQL request', async () => { - ghExecFileAsyncMock.mockResolvedValue({ - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - stack: { - number: 51, - size: 3, - baseRefName: 'main', - entries: { - nodes: [ - { - position: 2, - pullRequest: { - number: 202, - title: 'API', - url: 'https://github.com/stablyai/orca/pull/202', - updatedAt: '2026-08-10T00:00:00Z', - state: 'OPEN', - isDraft: false, - headRefName: 'stack/api', - headRefOid: 'api-sha', - mergeable: 'MERGEABLE', - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - statusCheckRollup: { state: 'SUCCESS' } - } - }, - { - position: 1, - pullRequest: { - number: 201, - title: 'Models', - url: 'https://github.com/stablyai/orca/pull/201', - updatedAt: '2026-08-10T00:00:00Z', - state: 'OPEN', - isDraft: false, - mergeable: 'UNKNOWN', - statusCheckRollup: { state: 'PENDING' } - } - } - ] - } - } - } - } - } - }) - }) - - const first = await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' }) - const sibling = await hydrateGitHubPRStack( - repository, - 201, - { ...summary, position: 1 }, - { cwd: '/repo' } - ) - - expect(first).toMatchObject({ - number: 51, - position: 2, - baseSha: 'base-sha', - entries: [ - { number: 201, position: 1, checksStatus: 'pending' }, - { - number: 202, - position: 2, - checksStatus: 'success', - reviewDecision: 'APPROVED' - } - ] - }) - expect(sibling.position).toBe(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(noteRateLimitSpendMock).toHaveBeenCalledWith( - repository, - 'graphql', - 1, - expect.any(Object) - ) - }) - - it('refreshes cached stack details when the current PR changed', async () => { - const response = (updatedAt: string, isDraft: boolean) => ({ - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - stack: { - number: 51, - size: 3, - baseRefName: 'main', - entries: { - nodes: [ - { - position: 2, - pullRequest: { - number: 202, - title: 'API', - url: 'https://github.com/stablyai/orca/pull/202', - updatedAt, - state: 'OPEN', - isDraft, - mergeable: 'MERGEABLE' - } - } - ] - } - } - } - } - } - }) - }) - ghExecFileAsyncMock - .mockResolvedValueOnce(response('2026-08-10T00:00:00Z', true)) - .mockResolvedValueOnce(response('2026-08-10T00:01:00Z', false)) - - const draft = await hydrateGitHubPRStack( - repository, - 202, - summary, - { cwd: '/repo' }, - '2026-08-10T00:00:00Z' - ) - const ready = await hydrateGitHubPRStack( - repository, - 202, - summary, - { cwd: '/repo' }, - '2026-08-10T00:01:00Z' - ) - - expect(draft.entries?.[0]?.state).toBe('draft') - expect(ready.entries?.[0]?.state).toBe('open') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('keeps the REST summary when GraphQL is unavailable', async () => { - ghExecFileAsyncMock.mockRejectedValue(new Error('field is unavailable')) - - await expect(hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' })).resolves.toEqual( - summary - ) - }) - - it('isolates cached details across execution scopes', async () => { - ghExecFileAsyncMock.mockResolvedValue({ - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - stack: { number: 51, size: 3, baseRefName: 'main', entries: { nodes: [] } } - } - } - } - }) - }) - - await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' }, undefined, 'local:host') - await hydrateGitHubPRStack(repository, 202, summary, {}, undefined, 'ssh:ssh-1') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) -}) - describe('mergeGitHubPRStack', () => { - it('submits the expected head and polls pending merges to completion', async () => { - vi.useFakeTimers() - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ status: 'merged', details: { message: 'Merged' } }) - }) - - const result = mergeGitHubPRStack({ - repository, - prNumber: 202, - method: 'squash', - mergeAction: 'direct_merge', - headSha: 'api-sha', - ghOptions: { cwd: '/repo' } - }) - await vi.advanceTimersByTimeAsync(1_000) - - await expect(result).resolves.toEqual({ ok: true }) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'PUT', - 'repos/stablyai/orca/pulls/202/merge-async', - 'merge_method=squash', - 'merge_action=direct_merge', - 'sha=api-sha' - ]), - expect.objectContaining({ cwd: '/repo', host: 'github.com' }) - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['api', 'repos/stablyai/orca/pulls/202/merge-async/merge-uuid'], - expect.objectContaining({ cwd: '/repo', host: 'github.com' }) - ) - }) - - it('returns GitHub atomic failure details', async () => { - ghExecFileAsyncMock.mockResolvedValue({ - stdout: JSON.stringify({ - status: 'failed', - details: { message: 'A pull request has merge conflicts.' } - }) - }) - - await expect( - mergeGitHubPRStack({ - repository, - prNumber: 202, - method: 'rebase', - mergeAction: 'direct_merge', - ghOptions: { cwd: '/repo' } - }) - ).resolves.toEqual({ ok: false, error: 'A pull request has merge conflicts.' }) - }) - - it('omits the unsupported merge method when queueing a stack', async () => { - ghExecFileAsyncMock.mockResolvedValue({ - stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } }) - }) - - await expect( - mergeGitHubPRStack({ - repository, - prNumber: 202, - method: 'squash', - mergeAction: 'merge_queue', - ghOptions: { cwd: '/repo' } - }) - ).resolves.toEqual({ ok: true }) - - const command = ghExecFileAsyncMock.mock.calls[0]?.[0] - expect(command).toContain('merge_action=merge_queue') - expect(command).not.toContain('merge_method=squash') - }) - - it('continues polling after a transient transport failure', async () => { - vi.useFakeTimers() - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) - }) - .mockRejectedValueOnce(new Error('temporary gateway failure')) - .mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'merged' }) }) - - const result = mergeGitHubPRStack({ - repository, - prNumber: 202, - method: 'squash', - mergeAction: 'direct_merge', - ghOptions: { cwd: '/repo' } - }) - await vi.advanceTimersByTimeAsync(1_000) - await vi.advanceTimersByTimeAsync(1_000) - - await expect(result).resolves.toEqual({ ok: true }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) - }) - it('reports an in-progress merge after polling is exhausted', async () => { vi.useFakeTimers() ghExecFileAsyncMock.mockResolvedValue({ diff --git a/src/main/github/github-project-account.test.ts b/src/main/github/github-project-account.test.ts index cb2957425ff..0e1fed5dd77 100644 --- a/src/main/github/github-project-account.test.ts +++ b/src/main/github/github-project-account.test.ts @@ -118,25 +118,6 @@ it.each(['cold', 'warm'] as const)( } ) -it('preserves explicit options on ownership misses without adding a refusal', () => { - release = installGitHubProjectAccounts({ getRepos: () => [repo()] }) - expect(options('/unseen-worktree', null, { ghAccount: bob })).toMatchObject({ ghAccount: bob }) - expect(options('/unseen-worktree')).toEqual({ cwd: '/unseen-worktree' }) -}) - -it('uses WSL namespace and Windows path spelling without merging POSIX case', () => { - release = installGitHubProjectAccounts({ - getRepos: () => [ - repo({ path: String.raw`\\wsl.localhost\Ubuntu\home\work` }), - repo({ id: 'win', path: String.raw`C:\Work`, ghAccount: bob }) - ] - }) - expect(options('/home/work', null, { wslDistro: 'ubuntu' }).ghAccount).toEqual(alice) - expect(options('/home/Work', null, { wslDistro: 'Ubuntu' }).ghAccount).toBeUndefined() - expect(options('/home/work', null, { wslDistro: 'Debian' }).ghAccount).toBeUndefined() - expect(options('c:/work/').ghAccount).toEqual(bob) -}) - it('keeps folder cards on their git child account and plain folders outside project gh selection', () => { release = installGitHubProjectAccounts({ getRepos: () => [ @@ -148,15 +129,6 @@ it('keeps folder cards on their git child account and plain folders outside proj expect(options('/folder').ghAccount).toBeUndefined() }) -it('does not let an earlier owner release a replacement installation', () => { - const previous = installGitHubProjectAccounts({ getRepos: () => [repo()] }) - release = installGitHubProjectAccounts({ getRepos: () => [repo({ ghAccount: bob })] }) - previous() - expect(options('/repo').ghAccount).toEqual(bob) - installGitHubProjectAccounts(null) - expect(options('/repo').ghAccount).toBeUndefined() -}) - it('selects before an asynchronous repository lookup and retains that account for execution', async () => { let current = repo() release = installGitHubProjectAccounts({ getRepos: () => [current] }) diff --git a/src/main/github/github-remote-identity-parsing.test.ts b/src/main/github/github-remote-identity-parsing.test.ts deleted file mode 100644 index 6369deda5d8..00000000000 --- a/src/main/github/github-remote-identity-parsing.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - effectiveGitHubRemoteHost, - gitHubSshConfigHostAlias, - parseGitHubOwnerRepo, - parseGitHubOwnerRepoWithResolvedSshHostname, - parseGitHubRemoteIdentity, - rawSshTransportHost, - remoteUrlUsesSshTransport -} from './github-remote-identity-parsing' - -describe('parseGitHubRemoteIdentity', () => { - it('parses a plain github.com https remote', () => { - expect(parseGitHubRemoteIdentity('https://github.com/team/orca.git')).toEqual({ - host: 'github.com', - owner: 'team', - repo: 'orca' - }) - }) - - it('parses an SCP-style github.com remote', () => { - expect(parseGitHubRemoteIdentity('git@github.com:team/orca.git')).toEqual({ - host: 'github.com', - owner: 'team', - repo: 'orca' - }) - }) - - it('preserves a custom port on a GHES https remote', () => { - // HTTPS ports identify the Enterprise API endpoint. - expect(parseGitHubRemoteIdentity('https://ghe.acme.com:8443/team/orca.git')).toEqual({ - host: 'ghe.acme.com:8443', - owner: 'team', - repo: 'orca' - }) - }) - - it('preserves a custom port on a GHES http remote', () => { - expect(parseGitHubRemoteIdentity('http://ghe.acme.com:8080/team/orca.git')).toEqual({ - host: 'ghe.acme.com:8080', - owner: 'team', - repo: 'orca' - }) - }) - - it('drops the default https port so github.com stays bare', () => { - expect(parseGitHubRemoteIdentity('https://github.com:443/team/orca.git')?.host).toBe( - 'github.com' - ) - }) - - it('drops the default https port on a GHES host', () => { - expect(parseGitHubRemoteIdentity('https://ghe.acme.com:443/team/orca.git')?.host).toBe( - 'ghe.acme.com' - ) - }) - - it('normalizes ssh.github.com:443 (SSH-over-HTTPS) to github.com without a port', () => { - // SSH ports select transport rather than the API endpoint. - expect(parseGitHubRemoteIdentity('ssh://git@ssh.github.com:443/team/orca.git')).toEqual({ - host: 'github.com', - owner: 'team', - repo: 'orca' - }) - }) - - it('drops a custom ssh transport port on a GHES ssh remote', () => { - expect(parseGitHubRemoteIdentity('ssh://git@ghe.acme.com:2222/team/orca.git')?.host).toBe( - 'ghe.acme.com' - ) - }) - - it('drops the transport port for git+ssh and git remotes', () => { - expect(parseGitHubRemoteIdentity('git+ssh://git@ghe.acme.com:2222/team/orca.git')?.host).toBe( - 'ghe.acme.com' - ) - expect(parseGitHubRemoteIdentity('git://ghe.acme.com:9418/team/orca.git')?.host).toBe( - 'ghe.acme.com' - ) - }) - - it('lowercases the host while keeping the port', () => { - expect(parseGitHubRemoteIdentity('https://GHE.Acme.COM:8443/team/orca.git')?.host).toBe( - 'ghe.acme.com:8443' - ) - }) - - it('returns null for an unparseable remote', () => { - expect(parseGitHubRemoteIdentity('not-a-remote')).toBeNull() - }) -}) - -describe('parseGitHubOwnerRepo', () => { - it('returns owner/repo for github.com', () => { - expect(parseGitHubOwnerRepo('https://github.com/team/orca.git')).toEqual({ - owner: 'team', - repo: 'orca' - }) - }) - - it('returns null for a custom-port GHES remote (not github.com)', () => { - expect(parseGitHubOwnerRepo('https://ghe.acme.com:8443/team/orca.git')).toBeNull() - }) - - it('still recognizes github.com even with an explicit default port', () => { - expect(parseGitHubOwnerRepo('https://github.com:443/team/orca.git')).toEqual({ - owner: 'team', - repo: 'orca' - }) - }) - - it('returns null for an SSH Host alias remote without HostName resolution', () => { - expect(parseGitHubOwnerRepo('git@github-work:team/orca.git')).toBeNull() - expect(parseGitHubOwnerRepo('git@github.com-work:team/orca.git')).toBeNull() - expect(parseGitHubOwnerRepo('ssh://git@github-work/team/orca.git')).toBeNull() - }) -}) - -describe('SSH Host alias identity (#10284)', () => { - it('detects SCP and ssh:// remotes as SSH transport', () => { - expect(remoteUrlUsesSshTransport('git@github-work:team/orca.git')).toBe(true) - expect(remoteUrlUsesSshTransport('ssh://git@github-work/team/orca.git')).toBe(true) - expect(remoteUrlUsesSshTransport('git+ssh://git@github-work/team/orca.git')).toBe(true) - expect(remoteUrlUsesSshTransport('https://github.com/team/orca.git')).toBe(false) - }) - - it('exposes Host aliases that need ssh -G expansion', () => { - expect(gitHubSshConfigHostAlias('git@github-work:team/orca.git')).toBe('github-work') - expect(gitHubSshConfigHostAlias('git@github.com-work:team/orca.git')).toBe('github.com-work') - expect(gitHubSshConfigHostAlias('ssh://git@github-work/team/orca.git')).toBe('github-work') - expect(gitHubSshConfigHostAlias('git@github.com:team/orca.git')).toBeNull() - expect(gitHubSshConfigHostAlias('https://github.com/team/orca.git')).toBeNull() - }) - - it('preserves SSH Host alias case for OpenSSH Host matching', () => { - expect(gitHubSshConfigHostAlias('git@GitHub-Work:team/orca.git')).toBe('GitHub-Work') - expect(gitHubSshConfigHostAlias('ssh://git@GitHub-Work/team/orca.git')).toBe('GitHub-Work') - expect(gitHubSshConfigHostAlias('git+ssh://git@GitHub-Work/team/orca.git')).toBe('GitHub-Work') - }) - - it('returns owner/repo when resolved HostName is github.com', () => { - expect( - parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', 'github.com') - ).toEqual({ owner: 'team', repo: 'orca' }) - }) - - it('returns owner/repo when resolved HostName is ssh.github.com (SSH-over-HTTPS)', () => { - expect( - parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', 'ssh.github.com') - ).toEqual({ owner: 'team', repo: 'orca' }) - }) - - it('keeps owner/repo for literal github.com even if resolved host is unused', () => { - expect( - parseGitHubOwnerRepoWithResolvedSshHostname('git@github.com:team/orca.git', null) - ).toEqual({ owner: 'team', repo: 'orca' }) - }) - - it('returns null when resolved HostName is a non-GitHub forge', () => { - expect( - parseGitHubOwnerRepoWithResolvedSshHostname('git@gitlab-work:team/orca.git', 'gitlab.com') - ).toBeNull() - }) - - it('does not apply SSH HostName resolution to HTTPS remotes', () => { - expect( - parseGitHubOwnerRepoWithResolvedSshHostname('https://github-work/team/orca.git', 'github.com') - ).toBeNull() - }) - - it('returns null when SSH resolution is missing', () => { - expect( - parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', null) - ).toBeNull() - expect( - parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', ' ') - ).toBeNull() - }) - - it('normalizes effective host for enterprise routing after HostName expansion', () => { - expect(effectiveGitHubRemoteHost('github-work', 'ssh.github.com')).toBe('github.com') - expect(effectiveGitHubRemoteHost('ghe-work', 'ghe.acme.com')).toBe('ghe.acme.com') - expect(effectiveGitHubRemoteHost('github.com', null)).toBe('github.com') - }) -}) - -describe('SSH certificate org remotes (#30601)', () => { - const certRemote = 'org-123456@github.com:Acme/app.git' - - it('parses an org-id SCP remote as a GitHub identity', () => { - expect(parseGitHubRemoteIdentity(certRemote)).toEqual({ - host: 'github.com', - owner: 'Acme', - repo: 'app' - }) - expect(parseGitHubOwnerRepo(certRemote)).toEqual({ owner: 'Acme', repo: 'app' }) - }) - - it('treats an org-id SCP remote as SSH transport', () => { - expect(remoteUrlUsesSshTransport(certRemote)).toBe(true) - }) - - it('reads the transport host from an org-id SCP remote', () => { - expect(rawSshTransportHost(certRemote)).toBe('github.com') - }) - - it('does not need an SSH config expansion for an org-id github.com remote', () => { - expect(gitHubSshConfigHostAlias(certRemote)).toBeNull() - expect(parseGitHubOwnerRepoWithResolvedSshHostname(certRemote, null)).toEqual({ - owner: 'Acme', - repo: 'app' - }) - }) - - it('still ignores non-GitHub hosts carrying an org-id user', () => { - expect(parseGitHubOwnerRepo('org-123456@github-work:Acme/app.git')).toBeNull() - expect(gitHubSshConfigHostAlias('org-123456@github-work:Acme/app.git')).toBe('github-work') - }) -}) diff --git a/src/main/github/github-repository-identity.ssh-host-alias.test.ts b/src/main/github/github-repository-identity.ssh-host-alias.test.ts index e72b4ad6fa5..e92a9ea3e3f 100644 --- a/src/main/github/github-repository-identity.ssh-host-alias.test.ts +++ b/src/main/github/github-repository-identity.ssh-host-alias.test.ts @@ -81,58 +81,6 @@ afterEach(() => { }) describe('#10284 SSH Host alias → github.com owner/repo', () => { - it('resolveGitHubOwnerRepoFromRemoteUrl expands HostName ssh.github.com', async () => { - resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443)) - - await expect( - resolveGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git') - ).resolves.toEqual({ owner: 'team', repo: 'orca' }) - expect(resolveWithSshGMock).toHaveBeenCalledWith('github-work') - }) - - it('getOwnerRepoForRemote resolves SCP alias remote used for multi-account GitHub', async () => { - mockRemoteUrl('git@github-work:team/orca.git') - resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com')) - - await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ - owner: 'team', - repo: 'orca' - }) - expect(resolveWithSshGMock).toHaveBeenCalledWith('github-work') - }) - - it('getOwnerRepoForRemote resolves ssh:// Host alias remotes', async () => { - mockRemoteUrl('ssh://git@github.com-work/acme/widgets.git') - resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443)) - - await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ - owner: 'acme', - repo: 'widgets' - }) - expect(resolveWithSshGMock).toHaveBeenCalledWith('github.com-work') - }) - - it('resolves aliases inside the repository WSL runtime', async () => { - mockRemoteUrl('git@github-work:team/orca.git') - commandExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'hostname github.com\nport 22\n', - stderr: '' - }) - - await expect( - getOwnerRepoForRemote(REPO, 'origin', null, { wslDistro: 'Ubuntu' }) - ).resolves.toEqual({ - owner: 'team', - repo: 'orca' - }) - expect(commandExecFileAsyncMock).toHaveBeenCalledWith('ssh', ['-G', '--', 'github-work'], { - cwd: REPO, - timeout: 5_000, - wslDistro: 'Ubuntu' - }) - expect(resolveWithSshGMock).not.toHaveBeenCalled() - }) - it('does not read an SSH repoPath locally when its provider is missing', async () => { await expect( getOwnerRepoForRemote( @@ -168,74 +116,6 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => { expect(commandExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('does not call ssh -G for literal github.com remotes', async () => { - mockRemoteUrl('git@github.com:team/orca.git') - - await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ - owner: 'team', - repo: 'orca' - }) - expect(resolveWithSshGMock).not.toHaveBeenCalled() - }) - - it('does not call ssh -G for https remotes', async () => { - mockRemoteUrl('https://github.com/team/orca.git') - - await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ - owner: 'team', - repo: 'orca' - }) - expect(resolveWithSshGMock).not.toHaveBeenCalled() - }) - - it('returns null when alias resolves to a non-GitHub host', async () => { - mockRemoteUrl('git@gitlab-work:team/orca.git') - resolveWithSshGMock.mockResolvedValueOnce(sshConfig('gitlab.com')) - - await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() - }) - - it('returns null when ssh -G fails for an alias', async () => { - mockRemoteUrl('git@github-work:team/orca.git') - resolveWithSshGMock.mockResolvedValueOnce(null) - - await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() - }) - - it('does not rewrite transport: identity resolution only consumes HostName', async () => { - const remote = 'git@github-work:team/orca.git' - mockRemoteUrl(remote) - resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com')) - - await getOwnerRepoForRemote(REPO, 'origin') - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['remote', 'get-url', 'origin'], - expect.objectContaining({ cwd: REPO }) - ) - const gitArgLists = gitExecFileAsyncMock.mock.calls.map(([args]) => args.join(' ')) - expect(gitArgLists.every((cmd) => cmd.startsWith('remote get-url'))).toBe(true) - }) - - it('classifies ssh -G failure as indeterminate (not stable not-github)', async () => { - resolveWithSshGMock.mockResolvedValueOnce(null) - await expect( - classifyGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git') - ).resolves.toEqual({ kind: 'indeterminate' }) - }) - - it('does not long-negative-cache owner/repo when ssh -G is indeterminate', async () => { - mockRemoteUrl('git@github-work:team/orca.git') - resolveWithSshGMock.mockResolvedValue(null) - - await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() - expect(_getOwnerRepoCacheSize()).toBe(0) - - _resetSshHostnameResolutionCache() - await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() - expect(resolveWithSshGMock).toHaveBeenCalledTimes(2) - expect(_getOwnerRepoCacheSize()).toBe(0) - }) - it('does not pin an SSH-config-dependent miss to the Git config signature', async () => { vi.useFakeTimers() mockRemoteUrl('git@forge-work:team/orca.git') @@ -251,21 +131,6 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => { }) }) - it('caches a successful HostName expansion so repeat probes skip ssh -G', async () => { - mockRemoteUrl('git@github-work:team/orca.git') - resolveWithSshGMock.mockResolvedValue(sshConfig('github.com')) - - await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ - owner: 'team', - repo: 'orca' - }) - await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ - owner: 'team', - repo: 'orca' - }) - expect(resolveWithSshGMock).toHaveBeenCalledTimes(1) - }) - it('isolates case-sensitive OpenSSH Host aliases in the cache', async () => { resolveWithSshGMock.mockImplementation(async (host: string) => sshConfig(host === 'GitHub-Work' ? 'github.com' : 'gitlab.com') @@ -285,11 +150,4 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => { }) expect(resolveWithSshGMock).toHaveBeenCalledTimes(2) }) - - it('classifies a resolved non-GitHub HostName as not-github', async () => { - resolveWithSshGMock.mockResolvedValueOnce(sshConfig('gitlab.com')) - await expect( - classifyGitHubOwnerRepoFromRemoteUrl('git@gitlab-work:team/orca.git') - ).resolves.toEqual({ kind: 'not-github', cacheWithGitConfigSignature: false }) - }) }) diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 16314c36ab2..c57e06a235c 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -65,12 +65,8 @@ vi.mock('./github-api-repository', async (importOriginal) => { } } }) - -import { getIssue, listIssues } from './issues' import { createIssue } from './issue-create' import { updateIssue } from './issue-update' -import { addIssueComment } from './issue-comment' -import { listAssignableUsers, listLabels } from './issue-field-options' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' @@ -93,214 +89,6 @@ describe('issue source operations', () => { })) }) - it('gets a single issue from the issue owner/repo', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 923, - title: 'Use upstream issues', - state: 'open', - html_url: 'https://github.com/stablyai/orca/issues/923', - labels: [], - body: 'The issue body' - }) - }) - - await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ - number: 923, - description: 'The issue body' - }) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['api', '--cache', '300s', 'repos/stablyai/orca/issues/923'], - { cwd: '/repo-root', host: 'github.com' } - ) - }) - - it('routes local WSL issue operations through repo resolution and gh execution options', async () => { - const localGitOptions = { wslDistro: 'Ubuntu' } - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - resolveIssueSourceMock.mockResolvedValue({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 923, - title: 'Use upstream issues', - state: 'open', - html_url: 'https://github.com/stablyai/orca/issues/923', - labels: [] - }) - }) - .mockResolvedValueOnce({ stdout: '[]' }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 924, - html_url: 'https://github.com/stablyai/orca/issues/924' - }) - }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - id: 1, - user: { login: 'octo', avatar_url: '', type: 'User' }, - body: 'Comment', - created_at: '2026-06-16T00:00:00.000Z', - html_url: 'https://github.com/stablyai/orca/issues/923#issuecomment-1' - }) - }) - .mockResolvedValueOnce({ stdout: 'bug\nfrontend\n' }) - .mockResolvedValueOnce({ stdout: '{"login":"octo","avatar_url":""}\n' }) - - await getIssue('/repo-root', 923, null, localGitOptions) - await listIssues('/repo-root', 5, undefined, null, localGitOptions) - await createIssue( - '/repo-root', - 'New issue', - 'Body', - undefined, - null, - undefined, - localGitOptions - ) - await updateIssue('/repo-root', 923, { body: 'Updated' }, null, localGitOptions) - await addIssueComment('/repo-root', 923, 'Comment', null, null, localGitOptions) - await listLabels('/repo-root', undefined, null, localGitOptions) - await listAssignableUsers('/repo-root', undefined, null, localGitOptions) - - expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) - expect(resolveIssueSourceMock).toHaveBeenCalledWith( - '/repo-root', - undefined, - null, - localGitOptions - ) - expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( - true - ) - }) - - it('routes PR conversation comments to the supplied Enterprise host', async () => { - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - id: 9, - node_id: 'IC_enterprise_9', - user: { login: 'octo', avatar_url: '', type: 'User' }, - body: 'Enterprise comment', - created_at: '2026-07-16T00:00:00.000Z', - html_url: 'https://github.acme-corp.com/team/orca/pull/7#issuecomment-9' - }) - }) - - await expect( - addIssueComment('/remote/repo', 7, 'Enterprise comment', 'ssh-1', { - owner: 'team', - repo: 'orca', - host: 'github.acme-corp.com' - }) - ).resolves.toMatchObject({ - ok: true, - comment: { reactionSubjectId: 'IC_enterprise_9' } - }) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['api', '-X', 'POST', 'repos/team/orca/issues/7/comments', '--input', '-'], - expect.objectContaining({ - host: 'github.acme-corp.com', - stdin: JSON.stringify({ body: 'Enterprise comment' }) - }) - ) - }) - - it('lists issues from the issue owner/repo', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [] }) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/issues?per_page=5&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root', host: 'github.com' } - ) - }) - - it('surfaces a classified permission_denied error instead of collapsing to empty', async () => { - // A private-upstream 403 must retain a retryable error instead of becoming an empty backlog. - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockRejectedValueOnce( - new Error('HTTP 403: Resource not accessible by integration') - ) - - const result = await listIssues('/repo-root', 5) - - expect(result.items).toEqual([]) - expect(result.error?.type).toBe('permission_denied') - }) - - it('creates issues in the issue owner/repo', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 924, - html_url: 'https://github.com/stablyai/orca/issues/924' - }) - }) - - await expect(createIssue('/repo-root', 'New issue', 'Body')).resolves.toEqual({ - ok: true, - number: 924, - url: 'https://github.com/stablyai/orca/issues/924' - }) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['api', '-X', 'POST', 'repos/stablyai/orca/issues', '--input', '-'], - { - cwd: '/repo-root', - host: 'github.com', - stdin: JSON.stringify({ title: 'New issue', body: 'Body' }) - } - ) - }) - - it('creates issues with labels and assignees', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 925, - html_url: 'https://github.com/stablyai/orca/issues/925' - }) - }) - - await expect( - createIssue('/repo-root', 'New issue', 'Body', undefined, undefined, { - labels: ['bug', 'frontend'], - assignees: ['octo'] - }) - ).resolves.toEqual({ - ok: true, - number: 925, - url: 'https://github.com/stablyai/orca/issues/925' - }) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['api', '-X', 'POST', 'repos/stablyai/orca/issues', '--input', '-'], - { - cwd: '/repo-root', - host: 'github.com', - stdin: JSON.stringify({ - title: 'New issue', - body: 'Body', - labels: ['bug', 'frontend'], - assignees: ['octo'] - }) - } - ) - }) - it('recovers issue 7704 oversized inline-image creation', async () => { const imagePrefix = 'data:image/png;base64,' const body = imagePrefix + 'x'.repeat(133596 - imagePrefix.length) @@ -363,30 +151,6 @@ describe('issue source operations', () => { ) }) - it('recognizes the oversized-body response from structured gh stderr', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock - .mockRejectedValueOnce( - Object.assign(new Error('Command failed: gh'), { - stderr: 'gh: body is too long (maximum is 65536 characters) (HTTP 422)' - }) - ) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 929, - html_url: 'https://github.com/stablyai/orca/issues/929' - }) - }) - .mockResolvedValueOnce({ stdout: '' }) - - await expect(createIssue('/repo-root', 'Image issue', 'data:image')).resolves.toEqual({ - ok: true, - number: 929, - url: 'https://github.com/stablyai/orca/issues/929' - }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) - }) - it('does not retry unrelated create failures', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: assignees is invalid')) @@ -412,49 +176,6 @@ describe('issue source operations', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - it('preserves fields during oversized-body recovery', async () => { - const localGitOptions = { wslDistro: 'Ubuntu' } - const body = `data:image/png;base64,${'x'.repeat(100)}` - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)')) - .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 927, url: 'issue-url' }) }) - .mockResolvedValueOnce({ stdout: '' }) - - await expect( - createIssue( - '/repo-root', - 'Fields issue', - body, - undefined, - null, - { - labels: ['bug'], - assignees: ['octo'] - }, - localGitOptions - ) - ).resolves.toEqual({ ok: true, number: 927, url: 'issue-url' }) - const createArgs = ['api', '-X', 'POST', 'repos/stablyai/orca/issues', '--input', '-'] - expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual(createArgs) - expect(ghExecFileAsyncMock.mock.calls[0][1]?.stdin).toBe( - JSON.stringify({ title: 'Fields issue', body, labels: ['bug'], assignees: ['octo'] }) - ) - expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual(createArgs) - expect(ghExecFileAsyncMock.mock.calls[1][1]?.stdin).toBe( - JSON.stringify({ title: 'Fields issue', body: '', labels: ['bug'], assignees: ['octo'] }) - ) - expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( - true - ) - expect(resolveIssueSourceMock).toHaveBeenCalledWith( - '/repo-root', - undefined, - null, - localGitOptions - ) - }) - it('reports partial success when oversized-body patch fails', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock @@ -490,54 +211,6 @@ describe('issue source operations', () => { ) }) - it('closes issues with completed, not planned, and duplicate reasons', async () => { - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) - - await expect( - updateIssue('/repo-root', 924, { state: 'closed', stateReason: 'completed' }) - ).resolves.toEqual({ ok: true }) - await expect( - updateIssue('/repo-root', 925, { state: 'closed', stateReason: 'not_planned' }) - ).resolves.toEqual({ ok: true }) - await expect( - updateIssue('/repo-root', 926, { - state: 'closed', - stateReason: 'duplicate', - duplicateOf: 99 - }) - ).resolves.toEqual({ ok: true }) - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - ['issue', 'close', '924', '--repo', 'stablyai/orca', '--reason', 'completed'], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['issue', 'close', '925', '--repo', 'stablyai/orca', '--reason', 'not planned'], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, - ['issue', 'close', '926', '--repo', 'stablyai/orca', '--duplicate-of', '99'], - { cwd: '/repo-root', host: 'github.com' } - ) - }) - - it('reopens issues through gh issue reopen', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) - - await expect(updateIssue('/repo-root', 924, { state: 'open' })).resolves.toEqual({ - ok: true - }) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['issue', 'reopen', '924', '--repo', 'stablyai/orca'], - { cwd: '/repo-root', host: 'github.com' } - ) - }) - it('reports a failed close whose gh error mentions "already"', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockRejectedValueOnce( diff --git a/src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts b/src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts deleted file mode 100644 index acde10c5c8e..00000000000 --- a/src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => { - const moduleMocks = await import('./pr-refresh-coordinator-test-mocks') - return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks } -}) - -vi.mock('electron', () => moduleMocks.electronModuleMock(coordinatorMocks)) -vi.mock('./client', () => moduleMocks.clientModuleMock(coordinatorMocks)) -vi.mock('./github-api-repository', () => - moduleMocks.githubApiRepositoryModuleMock(coordinatorMocks) -) -vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks)) -vi.mock('../ipc/ui', () => moduleMocks.ipcUiModuleMock(coordinatorMocks)) - -import { makeCandidate } from './pr-refresh-coordinator-test-harness' - -const { sendMock, getPRForBranchOutcomeMock } = coordinatorMocks - -describe('pr-refresh-coordinator', () => { - beforeEach(() => { - moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('paces a burst of distinct active refreshes', async () => { - const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockResolvedValue({ - kind: 'upstream-error', - errorType: 'unknown', - message: 'missing upstream', - fetchedAt: Date.now() - }) - - for (let index = 0; index < 10; index += 1) { - enqueuePRRefresh( - makeCandidate({ - cacheKey: `/repo::feature/${index}`, - branch: `feature/${index}`, - worktreeId: `wt-${index}` - }), - 'active', - 80, - 1 - ) - } - - await vi.advanceTimersByTimeAsync(0) - - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3) - expect( - getPRForBranchOutcomeMock.mock.calls.map(([repoPath, branch]) => [repoPath, branch]) - ).toEqual([ - ['/repo', 'feature/9'], - ['/repo', 'feature/8'], - ['/repo', 'feature/7'] - ]) - - await vi.advanceTimersByTimeAsync(29_999) - - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3) - expect( - sendMock.mock.calls - .map(([, event]) => event) - .some((event) => event.reason === 'active' && event.status === 'queued') - ).toBe(true) - - await vi.advanceTimersByTimeAsync(1) - - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(6) - }) - - it('treats a same-key active reactivation as the latest active signal', async () => { - const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockResolvedValue({ - kind: 'upstream-error', - errorType: 'unknown', - message: 'missing upstream', - fetchedAt: Date.now() - }) - - for (let index = 0; index < 10; index += 1) { - enqueuePRRefresh( - makeCandidate({ - cacheKey: `/repo::feature/${index}`, - branch: `feature/${index}`, - worktreeId: `wt-${index}` - }), - 'active', - 80, - 1 - ) - } - await vi.advanceTimersByTimeAsync(0) - - enqueuePRRefresh( - makeCandidate({ - cacheKey: '/repo::feature/0', - branch: 'feature/0', - worktreeId: 'wt-0' - }), - 'active', - 80, - 1 - ) - await vi.advanceTimersByTimeAsync(30_000) - - expect(getPRForBranchOutcomeMock.mock.calls[3]?.[1]).toBe('feature/0') - }) - - it('does not let one capped active window block another window', async () => { - const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockResolvedValue({ - kind: 'upstream-error', - errorType: 'unknown', - message: 'missing upstream', - fetchedAt: Date.now() - }) - - for (let index = 0; index < 10; index += 1) { - enqueuePRRefresh( - makeCandidate({ - cacheKey: `/repo::feature/${index}`, - branch: `feature/${index}`, - worktreeId: `wt-${index}` - }), - 'active', - 80, - 1 - ) - } - enqueuePRRefresh( - makeCandidate({ - cacheKey: '/repo::feature/other-window', - branch: 'feature/other-window', - worktreeId: 'wt-other-window' - }), - 'active', - 80, - 2 - ) - - await vi.advanceTimersByTimeAsync(0) - - expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toContain( - 'feature/other-window' - ) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(4) - }) - - it('does not let capped host active work block WSL or SSH active scopes', async () => { - const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockResolvedValue({ - kind: 'upstream-error', - errorType: 'unknown', - message: 'missing upstream', - fetchedAt: Date.now() - }) - - for (let index = 0; index < 10; index += 1) { - enqueuePRRefresh( - makeCandidate({ - cacheKey: `/repo::feature/${index}`, - branch: `feature/${index}`, - worktreeId: `wt-${index}` - }), - 'active', - 80, - 1 - ) - } - enqueuePRRefresh( - makeCandidate({ - cacheKey: 'wsl::repo-1::feature/wsl', - branch: 'feature/wsl', - localGitOptions: { wslDistro: 'Ubuntu' }, - worktreeId: 'wt-wsl' - }), - 'active', - 80, - 1 - ) - enqueuePRRefresh( - makeCandidate({ - cacheKey: 'ssh:ssh-1::repo-1::feature/ssh', - branch: 'feature/ssh', - connectionId: 'ssh-1', - worktreeId: 'wt-ssh' - }), - 'active', - 80, - 1 - ) - - await vi.advanceTimersByTimeAsync(0) - - const startedBranches = getPRForBranchOutcomeMock.mock.calls.map((call) => call[1]) - expect(startedBranches).toContain('feature/wsl') - expect(startedBranches).toContain('feature/ssh') - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(5) - }) - - it('does not let a capped active scope block ready visible work', async () => { - const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } = - await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockResolvedValue({ - kind: 'upstream-error', - errorType: 'unknown', - message: 'missing upstream', - fetchedAt: Date.now() - }) - - for (let index = 0; index < 10; index += 1) { - enqueuePRRefresh( - makeCandidate({ - cacheKey: `/repo::feature/${index}`, - branch: `feature/${index}`, - worktreeId: `wt-${index}` - }), - 'active', - 80, - 1 - ) - } - reportVisiblePRRefreshCandidates( - [ - makeCandidate({ - cacheKey: '/repo::feature/visible', - branch: 'feature/visible', - worktreeId: 'wt-visible' - }) - ], - 1, - 1 - ) - - await vi.advanceTimersByTimeAsync(0) - - expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toContain('feature/visible') - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(4) - }) - - it('wakes for visible budget spacing before a capped active burst opens', async () => { - const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } = - await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockResolvedValue({ - kind: 'upstream-error', - errorType: 'unknown', - message: 'missing upstream', - fetchedAt: Date.now() - }) - - reportVisiblePRRefreshCandidates( - [ - makeCandidate({ - cacheKey: '/repo::feature/visible-first', - branch: 'feature/visible-first', - worktreeId: 'wt-visible-first' - }) - ], - 1, - 1 - ) - await vi.advanceTimersByTimeAsync(0) - - for (let index = 0; index < 10; index += 1) { - enqueuePRRefresh( - makeCandidate({ - cacheKey: `/repo::feature/${index}`, - branch: `feature/${index}`, - worktreeId: `wt-${index}` - }), - 'active', - 80, - 1 - ) - } - reportVisiblePRRefreshCandidates( - [ - makeCandidate({ - cacheKey: '/repo::feature/visible-second', - branch: 'feature/visible-second', - worktreeId: 'wt-visible-second' - }) - ], - 2, - 1 - ) - - await vi.advanceTimersByTimeAsync(0) - - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(4) - - await vi.advanceTimersByTimeAsync(9_999) - - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(4) - - await vi.advanceTimersByTimeAsync(1) - - expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toContain( - 'feature/visible-second' - ) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(5) - }) -}) diff --git a/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts b/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts index 43e691c19b3..e3adf28f253 100644 --- a/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts +++ b/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-refresh-types' const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./pr-refresh-coordinator-test-mocks') @@ -100,196 +99,6 @@ describe('pr-refresh-coordinator', () => { expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) }) - it('probes with the survivor head after the representative alias is invalidated', async () => { - const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockResolvedValue({ - kind: 'found', - pr: makePR({ state: 'merged' }), - fetchedAt: Date.now() - }) - - const survivor = makeCandidate({ - cacheKey: '/repo::feature/b', - branch: 'feature/b', - linkedPRNumber: 12, - worktreeId: 'wt-b', - currentHeadOid: 'head-b' - }) - const representative = makeCandidate({ - cacheKey: '/repo::feature/a', - branch: 'feature/a', - linkedPRNumber: 12, - worktreeId: 'wt-a', - currentHeadOid: 'head-a' - }) - - // Promote the second alias, then invalidate it to force rebinding to the queued survivor. - enqueuePRRefresh(survivor, 'active', 80, 1) - enqueuePRRefresh(representative, 'active', 80, 1) - enqueuePRRefresh({ ...representative, isArchived: true }, 'active', 80, 1) - await vi.runOnlyPendingTimersAsync() - - const probedHeads = getPRForBranchOutcomeMock.mock.calls.map((call) => call[5]?.currentHeadOid) - expect(probedHeads).toContain('head-b') - expect(probedHeads).not.toContain('head-a') - }) - - it('probes with the survivor head after the representative worktree is pruned', async () => { - const { enqueuePRRefresh, pruneWorktreePRRefreshAliases } = - await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockResolvedValue({ - kind: 'found', - pr: makePR({ state: 'merged' }), - fetchedAt: Date.now() - }) - - const survivor = makeCandidate({ - cacheKey: '/repo::feature/b', - branch: 'feature/b', - linkedPRNumber: 12, - worktreeId: 'wt-b', - currentHeadOid: 'head-b' - }) - const representative = makeCandidate({ - cacheKey: '/repo::feature/a', - branch: 'feature/a', - linkedPRNumber: 12, - worktreeId: 'wt-a', - currentHeadOid: 'head-a' - }) - - enqueuePRRefresh(survivor, 'active', 80, 1) - enqueuePRRefresh(representative, 'active', 80, 1) - pruneWorktreePRRefreshAliases('wt-a') - await vi.runOnlyPendingTimersAsync() - - const probedHeads = getPRForBranchOutcomeMock.mock.calls.map((call) => call[5]?.currentHeadOid) - expect(probedHeads).toContain('head-b') - expect(probedHeads).not.toContain('head-a') - }) - - it('refreshes the representative head when the same worktree re-reports a moved head', async () => { - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockResolvedValue({ - kind: 'found', - pr: makePR({ state: 'merged' }), - fetchedAt: Date.now() - }) - - // Coalescing without promotion must still update the representative head. - reportVisiblePRRefreshCandidates( - [makeCandidate({ linkedPRNumber: 12, worktreeId: 'wt-a', currentHeadOid: 'head-a' })], - 1, - 1 - ) - reportVisiblePRRefreshCandidates( - [makeCandidate({ linkedPRNumber: 12, worktreeId: 'wt-a', currentHeadOid: 'head-b' })], - 2, - 1 - ) - await vi.runOnlyPendingTimersAsync() - - const probedHeads = getPRForBranchOutcomeMock.mock.calls.map((call) => call[5]?.currentHeadOid) - expect(probedHeads).toContain('head-b') - expect(probedHeads).not.toContain('head-a') - }) - - it('does not coalesce local and SSH refreshes for the same branch', async () => { - const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock - .mockResolvedValueOnce({ - kind: 'found', - pr: makePR({ number: 12 }), - fetchedAt: Date.now() - }) - .mockResolvedValueOnce({ - kind: 'found', - pr: makePR({ number: 44 }), - fetchedAt: Date.now() - }) - - enqueuePRRefresh(makeCandidate({ cacheKey: 'local::repo-1::feature/test' }), 'active', 80, 1) - enqueuePRRefresh( - makeCandidate({ - cacheKey: 'ssh:ssh-1::repo-1::feature/test', - connectionId: 'ssh-1' - }), - 'active', - 80, - 1 - ) - await vi.runOnlyPendingTimersAsync() - await vi.runOnlyPendingTimersAsync() - - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith( - 1, - '/repo', - 'feature/test', - null, - null, - null, - { localGitExecOptions: { admissionTier: 'background' } } - ) - expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith( - 2, - '/repo', - 'feature/test', - null, - 'ssh-1', - null, - { localGitExecOptions: { admissionTier: 'background' } } - ) - }) - - it('does not coalesce host and WSL refreshes for the same local branch', async () => { - const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock - .mockResolvedValueOnce({ - kind: 'found', - pr: makePR({ number: 12 }), - fetchedAt: Date.now() - }) - .mockResolvedValueOnce({ - kind: 'found', - pr: makePR({ number: 44 }), - fetchedAt: Date.now() - }) - - enqueuePRRefresh(makeCandidate({ cacheKey: 'host::repo-1::feature/test' }), 'active', 80, 1) - enqueuePRRefresh( - makeCandidate({ - cacheKey: 'wsl::repo-1::feature/test', - localGitOptions: { wslDistro: 'Ubuntu' } - }), - 'active', - 80, - 1 - ) - await vi.runOnlyPendingTimersAsync() - await vi.runOnlyPendingTimersAsync() - - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith( - 1, - '/repo', - 'feature/test', - null, - null, - null, - { localGitExecOptions: { admissionTier: 'background' } } - ) - expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith( - 2, - '/repo', - 'feature/test', - null, - null, - null, - { localGitExecOptions: { wslDistro: 'Ubuntu', admissionTier: 'background' } } - ) - }) - it('preserves coalesced aliases across visible follow-up refreshes', async () => { const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock @@ -336,61 +145,4 @@ describe('pr-refresh-coordinator', () => { ]) expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) }) - - describe('pruneWorktreePRRefreshAliases', () => { - // Linked-PR keys share one queue entry while retaining each worktree alias. - const LINKED_PR_KEY = 'local::runtime:host::/repo::pr::42' - - function makeLinkedCandidate(worktreeId: string): GitHubPRRefreshCandidate { - return makeCandidate({ - worktreeId, - cacheKey: `/repo::${worktreeId}`, - branch: `feature/${worktreeId}`, - linkedPRNumber: 42 - }) - } - - it('drops a removed worktree alias and deletes the entry when it was the last', async () => { - const { enqueuePRRefresh, pruneWorktreePRRefreshAliases, _getPRRefreshAliasCountForTests } = - await import('./pr-refresh-coordinator') - - enqueuePRRefresh(makeLinkedCandidate('wt-1'), 'visible', 40, 1) - enqueuePRRefresh(makeLinkedCandidate('wt-2'), 'visible', 40, 1) - enqueuePRRefresh(makeLinkedCandidate('wt-3'), 'visible', 40, 1) - expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(3) - - pruneWorktreePRRefreshAliases('wt-2') - expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(2) - - pruneWorktreePRRefreshAliases('wt-1') - pruneWorktreePRRefreshAliases('wt-3') - expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(0) - }) - - it('keeps the entry alive and rebinds the candidate when other aliases remain', async () => { - const { - enqueuePRRefresh, - pruneWorktreePRRefreshAliases, - _getPRRefreshAliasCountForTests, - _getPRRefreshQueueSizeForTests - } = await import('./pr-refresh-coordinator') - - enqueuePRRefresh(makeLinkedCandidate('wt-1'), 'visible', 40, 1) - enqueuePRRefresh(makeLinkedCandidate('wt-2'), 'visible', 40, 1) - expect(_getPRRefreshQueueSizeForTests()).toBe(1) - - pruneWorktreePRRefreshAliases('wt-1') - expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(1) - expect(_getPRRefreshQueueSizeForTests()).toBe(1) - }) - - it('is a no-op for a worktree with no queued aliases', async () => { - const { enqueuePRRefresh, pruneWorktreePRRefreshAliases, _getPRRefreshAliasCountForTests } = - await import('./pr-refresh-coordinator') - - enqueuePRRefresh(makeLinkedCandidate('wt-1'), 'visible', 40, 1) - pruneWorktreePRRefreshAliases('wt-unknown') - expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(1) - }) - }) }) diff --git a/src/main/github/pr-refresh-coordinator-visibility-policy.test.ts b/src/main/github/pr-refresh-coordinator-visibility-policy.test.ts index 8897da22153..4189dfaa429 100644 --- a/src/main/github/pr-refresh-coordinator-visibility-policy.test.ts +++ b/src/main/github/pr-refresh-coordinator-visibility-policy.test.ts @@ -14,11 +14,7 @@ vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks)) vi.mock('../ipc/ui', () => moduleMocks.ipcUiModuleMock(coordinatorMocks)) import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness' -const { getAllWebContentsMock, getPRForBranchOutcomeMock, sendMock } = coordinatorMocks - -function twoWindows(): void { - getAllWebContentsMock.mockReturnValue([1, 2].map((id) => ({ id, isDestroyed: () => false }))) -} +const { getPRForBranchOutcomeMock, sendMock } = coordinatorMocks function found(state: 'open' | 'merged' = 'open'): PRRefreshOutcome { return { kind: 'found', pr: makePR({ state, checksStatus: 'success' }), fetchedAt: Date.now() } } @@ -32,73 +28,6 @@ describe('visibility-aware coordinator scheduling', () => { vi.useRealTimers() }) - it.each([ - [true, 60_000], - [false, 120_000] - ] as const)('refreshes selected=%s at %s ms', async (isSelected, interval) => { - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - reportVisiblePRRefreshCandidates([makeCandidate({ isSelected })], 1, 1) - await vi.advanceTimersByTimeAsync(interval - 1) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(1) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - }) - - it('uses the strongest tier across windows and slows down when its owner hides', async () => { - twoWindows() - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(20_000) - reportVisiblePRRefreshCandidates([makeCandidate({ isSelected: true })], 1, 2) - await vi.advanceTimersByTimeAsync(40_000) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - reportVisiblePRRefreshCandidates([], 2, 2) - await vi.advanceTimersByTimeAsync(119_999) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - await vi.advanceTimersByTimeAsync(1) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3) - reportVisiblePRRefreshCandidates([], 2, 1) - await vi.advanceTimersByTimeAsync(600_000) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3) - }) - - it('re-times a fresh cached follow-up when selected changes', async () => { - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - const candidate = makeCandidate({ - cachedFetchedAt: Date.now(), - cachedHasPR: true, - cachedPRState: 'open' - }) - reportVisiblePRRefreshCandidates([candidate], 1, 1) - await vi.advanceTimersByTimeAsync(20_000) - reportVisiblePRRefreshCandidates([{ ...candidate, isSelected: true }], 2, 1) - await vi.advanceTimersByTimeAsync(39_999) - expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) - }) - - it('preserves a selection change during an in-flight lookup', async () => { - twoWindows() - let resolve: (outcome: PRRefreshOutcome) => void = () => {} - getPRForBranchOutcomeMock.mockImplementationOnce( - () => - new Promise((done) => { - resolve = done - }) - ) - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) - await vi.advanceTimersByTimeAsync(0) - reportVisiblePRRefreshCandidates([makeCandidate({ isSelected: true })], 1, 2) - resolve(found()) - await vi.advanceTimersByTimeAsync(59_999) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(1) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - }) - it('does not resurrect periodic work after every window hides during a lookup', async () => { let resolve: (outcome: PRRefreshOutcome) => void = () => {} getPRForBranchOutcomeMock.mockImplementationOnce( @@ -118,61 +47,6 @@ describe('visibility-aware coordinator scheduling', () => { expect(_getPRRefreshQueueSizeForTests()).toBe(0) }) - it('allows one settled-merged lookup on re-exposure after cooldown', async () => { - getPRForBranchOutcomeMock.mockImplementation(async () => found('merged')) - const { reportVisiblePRRefreshCandidates, _getPRRefreshQueueSizeForTests } = - await import('./pr-refresh-coordinator') - const candidate = makeCandidate() - reportVisiblePRRefreshCandidates([candidate], 1, 1) - await vi.advanceTimersByTimeAsync(0) - reportVisiblePRRefreshCandidates([], 2, 1) - const merged = makeCandidate({ - cachedFetchedAt: Date.now(), - cachedHasPR: true, - cachedPRState: 'merged', - cachedChecksStatus: 'neutral' - }) - await vi.advanceTimersByTimeAsync(9_999) - reportVisiblePRRefreshCandidates([merged], 3, 1) - await vi.advanceTimersByTimeAsync(0) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) - reportVisiblePRRefreshCandidates([], 4, 1) - await vi.advanceTimersByTimeAsync(1) - reportVisiblePRRefreshCandidates([merged], 5, 1) - await vi.advanceTimersByTimeAsync(0) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - reportVisiblePRRefreshCandidates([merged], 6, 1) - await vi.advanceTimersByTimeAsync(24 * 60 * 60_000) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - expect(_getPRRefreshQueueSizeForTests()).toBe(0) - }) - - it.each(['merged', 'closed'] as const)( - 'discovers new work when a cached %s head changes', - async (cachedPRState) => { - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - reportVisiblePRRefreshCandidates( - [ - makeCandidate({ - cachedFetchedAt: Date.now(), - cachedHasPR: true, - cachedPRState, - cachedChecksStatus: 'success', - cachedHeadOid: 'old', - currentHeadOid: 'new' - }) - ], - 1, - 1 - ) - await vi.advanceTimersByTimeAsync(0) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) - expect(getPRForBranchOutcomeMock.mock.calls[0][5]?.currentHeadOid).toBe('new') - await vi.advanceTimersByTimeAsync(120_000) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - } - ) - it('retains stale PR state and error backoff across reports and hiding', async () => { getPRForBranchOutcomeMock.mockImplementation(async () => ({ kind: 'upstream-error', @@ -208,23 +82,6 @@ describe('visibility-aware coordinator scheduling', () => { ]) }) - it('pulls a closed follow-up forward when HEAD changes while it is queued', async () => { - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - const candidate = makeCandidate({ - cachedFetchedAt: Date.now(), - cachedHasPR: true, - cachedPRState: 'closed', - cachedHeadOid: 'old', - currentHeadOid: 'old' - }) - reportVisiblePRRefreshCandidates([candidate], 1, 1) - await vi.advanceTimersByTimeAsync(20_000) - reportVisiblePRRefreshCandidates([{ ...candidate, currentHeadOid: 'new' }], 2, 1) - await vi.advanceTimersByTimeAsync(0) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) - expect(getPRForBranchOutcomeMock.mock.calls[0][5]?.currentHeadOid).toBe('new') - }) - it('keeps an in-flight head change behind the lookup failure backoff', async () => { let resolve: (outcome: PRRefreshOutcome) => void = () => {} getPRForBranchOutcomeMock.mockImplementationOnce( @@ -276,71 +133,4 @@ describe('visibility-aware coordinator scheduling', () => { expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) } ) - - it('discovers a new PR on settled-merged re-exposure and resumes selected polling', async () => { - getPRForBranchOutcomeMock - .mockImplementationOnce(async () => found('merged')) - .mockImplementation(async () => ({ - kind: 'found', - pr: makePR({ number: 13 }), - fetchedAt: Date.now() - })) - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - reportVisiblePRRefreshCandidates([makeCandidate({ isSelected: true })], 1, 1) - await vi.advanceTimersByTimeAsync(0) - const merged = makeCandidate({ - isSelected: true, - cachedFetchedAt: Date.now(), - cachedHasPR: true, - cachedPRState: 'merged', - cachedChecksStatus: 'success' - }) - reportVisiblePRRefreshCandidates([], 2, 1) - await vi.advanceTimersByTimeAsync(120_000) - reportVisiblePRRefreshCandidates([merged], 3, 1) - await vi.advanceTimersByTimeAsync(0) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - expect( - sendMock.mock.calls.map(([, event]) => event.outcome?.pr?.number).filter(Boolean) - ).toEqual([12, 13]) - await vi.advanceTimersByTimeAsync(59_999) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - await vi.advanceTimersByTimeAsync(1) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3) - }) - - it.each([ - [true, 60_000], - [false, 900_000] - ] as const)('discovers missing PRs at selected=%s cadence', async (isSelected, interval) => { - getPRForBranchOutcomeMock.mockImplementation(async () => ({ - kind: 'no-pr', - fetchedAt: Date.now() - })) - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - reportVisiblePRRefreshCandidates([makeCandidate({ isSelected })], 1, 1) - await vi.advanceTimersByTimeAsync(interval - 1) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(1) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - }) -}) - -it('foreground exposure bypasses list spacing once while preserving cooldown and periodic budget', async () => { - moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks) - getPRForBranchOutcomeMock.mockImplementation(async () => found()) - const { reportVisiblePRRefreshCandidates, enqueuePRRefresh } = - await import('./pr-refresh-coordinator') - const other = makeCandidate({ branch: 'other', cacheKey: 'other', worktreeId: 'other' }) - const selected = makeCandidate({ branch: 'selected', cacheKey: 'selected', isSelected: true }) - reportVisiblePRRefreshCandidates([other, selected], 1, 1) - await vi.advanceTimersByTimeAsync(0) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) - enqueuePRRefresh(selected, 'visible', 80, 1) - await vi.advanceTimersByTimeAsync(0) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - enqueuePRRefresh(selected, 'visible', 80, 1) - await vi.advanceTimersByTimeAsync(9_999) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - vi.useRealTimers() }) diff --git a/src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts b/src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts index e7443c7e779..11efcbd42d1 100644 --- a/src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts +++ b/src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts @@ -15,7 +15,7 @@ vi.mock('../ipc/ui', () => moduleMocks.ipcUiModuleMock(coordinatorMocks)) import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness' -const { getAllWebContentsMock, getPRForBranchOutcomeMock } = coordinatorMocks +const { getPRForBranchOutcomeMock } = coordinatorMocks describe('pr-refresh-coordinator', () => { beforeEach(() => { @@ -51,107 +51,6 @@ describe('pr-refresh-coordinator', () => { expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) }) - it('clears visible retry backoff when a non-visible manual refresh steals the retry', async () => { - const { - _getPRRefreshErrorBackoffCountForTests, - refreshPRNow, - reportVisiblePRRefreshCandidates - } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock - .mockResolvedValueOnce({ - kind: 'upstream-error', - errorType: 'network', - message: 'network down', - fetchedAt: Date.now() - }) - .mockResolvedValueOnce({ - kind: 'found', - pr: makePR({ checksStatus: 'success' }), - fetchedAt: Date.now() - }) - - const candidate = makeCandidate() - reportVisiblePRRefreshCandidates([candidate], 1, 1) - await vi.advanceTimersByTimeAsync(0) - expect(_getPRRefreshErrorBackoffCountForTests()).toBe(1) - - getAllWebContentsMock.mockReturnValue([]) - await refreshPRNow(candidate) - - expect(_getPRRefreshErrorBackoffCountForTests()).toBe(0) - }) - - it('uses the regular cadence for automatic unknown mergeability refreshes', async () => { - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock - .mockResolvedValueOnce({ - kind: 'found', - pr: makePR({ checksStatus: 'success', mergeable: 'UNKNOWN' }), - fetchedAt: Date.now() - }) - .mockResolvedValueOnce({ - kind: 'found', - pr: makePR({ checksStatus: 'success', mergeable: 'MERGEABLE' }), - fetchedAt: Date.now() - }) - - reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(119_999) - - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(1) - - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - }) - - it('does a prompt visible follow-up after a manual refresh returns unknown mergeability', async () => { - const { refreshPRNow, reportVisiblePRRefreshCandidates } = - await import('./pr-refresh-coordinator') - const visibleCandidate = makeCandidate() - const candidate = makeCandidate({ - cachedFetchedAt: Date.now(), - cachedHasPR: true, - cachedPRState: 'open', - cachedChecksStatus: 'success', - cachedMergeable: 'MERGEABLE', - cachedMergeStateStatus: 'CLEAN' - }) - getPRForBranchOutcomeMock - .mockResolvedValueOnce({ - kind: 'found', - pr: makePR({ checksStatus: 'success', mergeable: 'MERGEABLE' }), - fetchedAt: Date.now() - }) - .mockResolvedValueOnce({ - kind: 'found', - pr: makePR({ checksStatus: 'success', mergeable: 'UNKNOWN' }), - fetchedAt: Date.now() - }) - .mockResolvedValueOnce({ - kind: 'found', - pr: makePR({ checksStatus: 'success', mergeable: 'CONFLICTING' }), - fetchedAt: Date.now() - }) - - reportVisiblePRRefreshCandidates([visibleCandidate], 1, 1) - await vi.advanceTimersByTimeAsync(0) - await refreshPRNow(candidate, 'manual', { userRefresh: true }) - - expect(getPRForBranchOutcomeMock.mock.calls[1]?.[5]).toMatchObject({ userRefresh: true }) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - - await vi.advanceTimersByTimeAsync(2_499) - - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - - await vi.advanceTimersByTimeAsync(1) - - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3) - expect(getPRForBranchOutcomeMock.mock.calls[2]?.[5]).not.toHaveProperty('userRefresh') - }) - it('stops polling settled merged PRs, including repeated visibility reports', async () => { const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockImplementation(async () => ({ @@ -174,91 +73,4 @@ describe('pr-refresh-coordinator', () => { await vi.advanceTimersByTimeAsync(24 * 60 * 60_000 - 31 * 60_000) expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) }) - - it.each([ - ['closed', 'success', 15 * 60_000], - ['merged', 'pending', 60_000] - ] as const)('keeps watching %s PRs with %s checks', async (state, checksStatus, interval) => { - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockImplementation(async () => ({ - kind: 'found', - pr: makePR({ checksStatus, state }), - fetchedAt: Date.now() - })) - reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) - await vi.advanceTimersByTimeAsync(interval) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - }) - - it('checks merged reviews with missing cached checks again after 60 seconds', async () => { - const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockResolvedValue({ - kind: 'found', - pr: makePR({ state: 'merged', checksStatus: 'success' }), - fetchedAt: Date.now() - }) - reportVisiblePRRefreshCandidates( - [ - makeCandidate({ - cachedFetchedAt: Date.now(), - cachedHasPR: true, - cachedPRState: 'merged', - cachedChecksStatus: null - }) - ], - 1, - 1 - ) - await vi.advanceTimersByTimeAsync(59_999) - expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) - }) - - it('resumes visible polling when a manual refresh discovers a live PR', async () => { - const { refreshPRNow, reportVisiblePRRefreshCandidates } = - await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock - .mockImplementationOnce(async () => ({ - kind: 'found', - pr: makePR({ state: 'merged', checksStatus: 'success' }), - fetchedAt: Date.now() - })) - .mockImplementation(async () => ({ - kind: 'found', - pr: makePR({ number: 13, checksStatus: 'pending' }), - fetchedAt: Date.now() - })) - const candidate = makeCandidate() - reportVisiblePRRefreshCandidates([candidate], 1, 1) - await vi.advanceTimersByTimeAsync(0) - await refreshPRNow(candidate) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) - await vi.advanceTimersByTimeAsync(120_000) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3) - }) - - it.each(['active', 'manual', 'post-push'] as const)( - 'refreshes merged PRs on %s', - async (reason) => { - const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - getPRForBranchOutcomeMock.mockResolvedValue({ - kind: 'found', - pr: makePR(), - fetchedAt: Date.now() - }) - enqueuePRRefresh( - makeCandidate({ - cachedFetchedAt: Date.now(), - cachedPRState: 'merged', - cachedChecksStatus: 'success' - }), - reason, - 80, - 1 - ) - await vi.advanceTimersByTimeAsync(2_500) - expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) - } - ) }) diff --git a/src/main/github/pr-start-point.test.ts b/src/main/github/pr-start-point.test.ts index 49830082148..f3bab273c52 100644 --- a/src/main/github/pr-start-point.test.ts +++ b/src/main/github/pr-start-point.test.ts @@ -33,165 +33,6 @@ describe('resolveGitHubPrStartPoint', () => { ) }) - it('falls back to the GitHub PR head ref when a direct branch fetch fails', async () => { - getPullRequestPushTargetMock.mockResolvedValue({ - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'fix-issue-6933', - remoteUrl: 'git@github.com:contributor/orca.git' - } - }) - const fetchRemoteTrackingRef = vi.fn(async (_remote: string, branch: string) => { - if (branch === 'fix-issue-6933') { - throw new Error('fatal: could not find remote ref') - } - }) - const gitExec = vi.fn(async (args: string[]) => { - const url = remoteGetUrl(args) - if (url) { - return url - } - if (args[0] === 'rev-parse') { - return { stdout: 'def456\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - const result = await resolveGitHubPrStartPoint({ - repoPath: '/repo-root', - prNumber: 6934, - headRefName: 'fix-issue-6933', - baseRefName: 'main', - gitExec, - fetchRemoteTrackingRef, - fetchPullRequestHeadRef: fetchPullRequestHeadRefMock, - resolveRemote: async () => 'origin' - }) - - expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'fix-issue-6933') - expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'main') - expect(fetchPullRequestHeadRefMock).toHaveBeenCalledWith('origin', 6934) - expect(result).toEqual({ - baseBranch: 'def456', - compareBaseRef: 'refs/remotes/origin/main', - headSha: 'def456', - branchNameOverride: 'fix-issue-6933', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'fix-issue-6933', - remoteUrl: 'git@github.com:contributor/orca.git' - } - }) - }) - - it('keeps the PR head ref fallback when push-target discovery also fails', async () => { - getPullRequestPushTargetMock.mockRejectedValue(new Error('head repo is unavailable')) - const fetchRemoteTrackingRef = vi.fn(async () => { - throw new Error('fatal: could not find remote ref') - }) - const gitExec = vi.fn(async (args: string[]) => { - const url = remoteGetUrl(args) - if (url) { - return url - } - if (args[0] === 'rev-parse') { - return { stdout: 'def456\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - const result = await resolveGitHubPrStartPoint({ - repoPath: '/repo-root', - prNumber: 1849, - headRefName: 'feat/onboarding-model-choice-782', - gitExec, - fetchRemoteTrackingRef, - fetchPullRequestHeadRef: fetchPullRequestHeadRefMock, - resolveRemote: async () => 'origin' - }) - - expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( - '/repo-root', - 1849, - null, - {}, - undefined - ) - expect(result).toEqual({ - baseBranch: 'def456', - headSha: 'def456', - branchNameOverride: 'feat/onboarding-model-choice-782' - }) - }) - - it('resolves an inaccessible fork PR even when push-target discovery fails', async () => { - getPullRequestPushTargetMock.mockRejectedValue(new Error('head repo is unavailable')) - const fetchRemoteTrackingRef = vi.fn(async () => {}) - const gitExec = vi.fn(async (args: string[]) => { - const url = remoteGetUrl(args) - if (url) { - return url - } - if (args[0] === 'rev-parse') { - return { stdout: 'abc123\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - const result = await resolveGitHubPrStartPoint({ - repoPath: '/repo-root', - prNumber: 1849, - headRefName: 'feat/onboarding-model-choice-782', - isCrossRepository: true, - gitExec, - fetchRemoteTrackingRef, - fetchPullRequestHeadRef: fetchPullRequestHeadRefMock, - resolveRemote: async () => 'origin' - }) - - expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( - '/repo-root', - 1849, - null, - {}, - undefined - ) - expect(fetchPullRequestHeadRefMock).toHaveBeenCalledWith('origin', 1849) - expect(result).toEqual({ - baseBranch: 'abc123', - headSha: 'abc123', - branchNameOverride: 'feat/onboarding-model-choice-782' - }) - }) - - it('prefers the pull-head error when the branch miss triggered a failing fallback', async () => { - // Surface the failed pull-head fallback, rather than the earlier branch miss. - const fetchRemoteTrackingRef = vi.fn(async () => { - throw new Error('fatal: could not find remote ref refs/heads/feature/fix') - }) - fetchPullRequestHeadRefMock.mockRejectedValue( - new Error( - 'This SSH host is running an older Orca relay that cannot fetch pull request heads.' - ) - ) - const gitExec = vi.fn(async () => ({ stdout: '', stderr: '' })) - - const result = await resolveGitHubPrStartPoint({ - repoPath: '/repo-root', - prNumber: 77, - headRefName: 'feature/fix', - gitExec, - fetchRemoteTrackingRef, - fetchPullRequestHeadRef: fetchPullRequestHeadRefMock, - resolveRemote: async () => 'origin' - }) - - expect(result).toMatchObject({ - error: - 'Failed to fetch refs/pull/77/head: This SSH host is running an older Orca relay that cannot fetch pull request heads.' - }) - }) - it('captures the fork PR head from a dedicated ref, not the shared FETCH_HEAD', async () => { getPullRequestPushTargetMock.mockRejectedValue(new Error('head repo is unavailable')) const fetchRemoteTrackingRef = vi.fn(async () => {}) @@ -362,152 +203,4 @@ describe('resolveGitHubPrStartPoint', () => { warnSpy.mockRestore() } }) - - it('uses PR metadata when the caller did not pass a head ref', async () => { - getWorkItemMock.mockResolvedValue({ - type: 'pr', - branchName: 'contributor/fix', - baseRefName: 'main', - isCrossRepository: true - }) - getPullRequestPushTargetMock.mockResolvedValue({ - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'git@github.com:contributor/orca.git' - } - }) - const fetchRemoteTrackingRef = vi.fn(async () => {}) - const gitExec = vi.fn(async (args: string[]) => { - const url = remoteGetUrl(args) - if (url) { - return url - } - if (args[0] === 'rev-parse') { - return { stdout: 'abc123\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - const result = await resolveGitHubPrStartPoint({ - repoPath: '/repo-root', - prNumber: 1738, - issueSourcePreference: 'origin', - localGitOptions: { ghAccount: { host: 'github.com', user: 'alice' } }, - gitExec, - fetchRemoteTrackingRef, - fetchPullRequestHeadRef: fetchPullRequestHeadRefMock, - resolveRemote: async () => 'origin' - }) - - expect(getWorkItemMock).toHaveBeenCalledWith( - '/repo-root', - 1738, - 'pr', - null, - { - ghAccount: { host: 'github.com', user: 'alice' } - }, - 'origin' - ) - expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( - '/repo-root', - 1738, - null, - { ghAccount: { host: 'github.com', user: 'alice' } }, - 'origin' - ) - expect(result).toEqual({ - baseBranch: 'abc123', - compareBaseRef: 'refs/remotes/origin/main', - headSha: 'abc123', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'git@github.com:contributor/orca.git' - } - }) - }) - - it('surfaces maintainerCanModify=false for a fork PR so the caller can warn', async () => { - getPullRequestPushTargetMock.mockResolvedValue({ - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'git@github.com:contributor/orca.git' - }, - maintainerCanModify: false - }) - const fetchRemoteTrackingRef = vi.fn(async () => {}) - const gitExec = vi.fn(async (args: string[]) => { - const url = remoteGetUrl(args) - if (url) { - return url - } - if (args[0] === 'rev-parse') { - return { stdout: 'abc123\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - const result = await resolveGitHubPrStartPoint({ - repoPath: '/repo-root', - prNumber: 1849, - headRefName: 'contributor/fix', - isCrossRepository: true, - gitExec, - fetchRemoteTrackingRef, - fetchPullRequestHeadRef: fetchPullRequestHeadRefMock, - resolveRemote: async () => 'origin' - }) - - expect(result).toEqual({ - baseBranch: 'abc123', - headSha: 'abc123', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'git@github.com:contributor/orca.git' - }, - maintainerCanModify: false - }) - }) - - it('returns the verified head SHA, branch override, and push target when same-repo branch fetch succeeds', async () => { - const fetchRemoteTrackingRef = vi.fn(async () => {}) - const gitExec = vi.fn(async (args: string[]) => { - const url = remoteGetUrl(args) - if (url) { - return url - } - if (args[0] === 'rev-parse') { - return { stdout: 'abc123\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - const result = await resolveGitHubPrStartPoint({ - repoPath: '/repo-root', - prNumber: 42, - headRefName: 'feature/add-feature', - baseRefName: 'develop', - gitExec, - fetchRemoteTrackingRef, - fetchPullRequestHeadRef: fetchPullRequestHeadRefMock, - resolveRemote: async () => 'origin' - }) - - expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'feature/add-feature') - expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'develop') - expect(gitExec).toHaveBeenCalledWith(['rev-parse', '--verify', 'origin/feature/add-feature']) - expect(result).toEqual({ - baseBranch: 'abc123', - compareBaseRef: 'refs/remotes/origin/develop', - headSha: 'abc123', - branchNameOverride: 'feature/add-feature', - pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' } - }) - }) }) diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts index 2a44c12657d..b5dac755d7d 100644 --- a/src/main/github/project-view.test.ts +++ b/src/main/github/project-view.test.ts @@ -1,10 +1,9 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { GITHUB_PROJECT_REF_INPUT_MAX_BYTES, GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR } from '../../shared/github/project-ref-input' import { - PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES, _getProjectViewCacheSizesForTests, _getProjectViewOwnerTypeForTests, _hasProjectViewParentFieldRetriedForTests, @@ -13,165 +12,11 @@ import { _markProjectViewParentFieldWarningLoggedForTests, _rememberProjectViewOwnerTypeForTests, _resetProjectViewCachesForTests, - classifyProjectError, - isValidOwnerSlug, - isValidRepoSlug, parseProjectPaste, resolveProjectRef } from './project-view' -describe('classifyProjectError', () => { - it('classifies HTTP 404 as not_found', () => { - expect(classifyProjectError('HTTP 404 Not Found', '').type).toBe('not_found') - }) - - it('classifies "Could not resolve to a User" as not_found', () => { - expect(classifyProjectError('Could not resolve to a User with the login of foo', '').type).toBe( - 'not_found' - ) - }) - - it('classifies "could not resolve host" as network_error, NOT not_found', () => { - // DNS host failures overlap could-not-resolve text; distinguish them from unresolved GraphQL objects. - expect(classifyProjectError('could not resolve host: api.github.com', '').type).toBe( - 'network_error' - ) - }) - - it('classifies "dial tcp" timeouts as network_error', () => { - expect(classifyProjectError('dial tcp 140.82.112.3:443: i/o timeout', '').type).toBe( - 'network_error' - ) - }) - - it('classifies rate-limit text as rate_limited', () => { - expect(classifyProjectError('API rate limit exceeded for user', '').type).toBe('rate_limited') - }) - - it('classifies missing-scope as scope_missing', () => { - expect( - classifyProjectError('your token has not been granted the required scopes', '').type - ).toBe('scope_missing') - }) - - it('classifies auth-required when gh is not signed in', () => { - expect(classifyProjectError('gh auth login required', '').type).toBe('auth_required') - }) - - it('pins Enterprise auth and scope remediation to the selected host', () => { - expect( - classifyProjectError('gh auth login required', '', 'github.acme.test').message - ).toContain('gh auth login --hostname github.acme.test') - expect( - classifyProjectError( - 'your token has not been granted the required scopes', - '', - 'github.acme.test' - ).message - ).toContain('gh auth refresh --hostname github.acme.test') - }) -}) - -describe('isValidOwnerSlug', () => { - it('accepts plain alphanumerics and hyphens', () => { - expect(isValidOwnerSlug('acme')).toBe(true) - expect(isValidOwnerSlug('acme-co')).toBe(true) - expect(isValidOwnerSlug('user1')).toBe(true) - }) - - it('accepts Enterprise Managed User logins (GitHub appends `_`)', () => { - expect(isValidOwnerSlug('octocat_acme')).toBe(true) - expect(isValidOwnerSlug('acme_co')).toBe(true) - }) - - it('rejects leading underscore, hyphen and dot', () => { - expect(isValidOwnerSlug('_acme')).toBe(false) - expect(isValidOwnerSlug('-acme')).toBe(false) - expect(isValidOwnerSlug('.acme')).toBe(false) - }) - - it('rejects empty and slash-containing values', () => { - expect(isValidOwnerSlug('')).toBe(false) - expect(isValidOwnerSlug('a/b')).toBe(false) - expect(isValidOwnerSlug(123)).toBe(false) - }) -}) - -describe('isValidRepoSlug', () => { - it('accepts leading underscore (GitHub allows it for repo names)', () => { - expect(isValidRepoSlug('_internal')).toBe(true) - }) - - it('accepts leading dot', () => { - expect(isValidRepoSlug('.github')).toBe(true) - }) - - it('accepts dots, dashes, underscores anywhere', () => { - expect(isValidRepoSlug('repo-name')).toBe(true) - expect(isValidRepoSlug('repo.name')).toBe(true) - expect(isValidRepoSlug('repo_name')).toBe(true) - }) - - it('rejects reserved single/double dot', () => { - expect(isValidRepoSlug('.')).toBe(false) - expect(isValidRepoSlug('..')).toBe(false) - }) - - it('rejects path separators and empty', () => { - expect(isValidRepoSlug('a/b')).toBe(false) - expect(isValidRepoSlug('')).toBe(false) - }) -}) - describe('parseProjectPaste', () => { - it('parses owner/number shorthand', () => { - expect(parseProjectPaste('acme/42')).toEqual({ kind: 'bare', owner: 'acme', number: 42 }) - }) - - it('accepts shorthand with an Enterprise Managed User owner (renderer parity)', () => { - // Keep shorthand owner validation aligned with the renderer. - expect(parseProjectPaste('octocat_acme/1')).toEqual({ - kind: 'bare', - owner: 'octocat_acme', - number: 1 - }) - expect(parseProjectPaste('_acme/45')).toBeNull() - }) - - it('parses a user URL with an Enterprise Managed User owner', () => { - expect(parseProjectPaste('https://github.com/users/octocat_acme/projects/1/views/1')).toEqual({ - kind: 'user', - owner: 'octocat_acme', - number: 1, - host: 'github.com', - viewNumber: 1 - }) - }) - - it('parses org URL with view number', () => { - expect(parseProjectPaste('https://github.com/orgs/acme/projects/42/views/3')).toEqual({ - kind: 'org', - owner: 'acme', - number: 42, - host: 'github.com', - viewNumber: 3 - }) - }) - - it('parses user URL', () => { - expect(parseProjectPaste('https://github.com/users/octocat/projects/1')).toEqual({ - kind: 'user', - owner: 'octocat', - number: 1, - host: 'github.com' - }) - }) - - it('rejects URLs whose owner has invalid characters', () => { - expect(parseProjectPaste('https://github.com/orgs/_acme/projects/1')).toBeNull() - expect(parseProjectPaste('https://github.com/orgs/.acme/projects/1')).toBeNull() - }) - it('accepts enterprise-host URLs only when that host is provided (GHES)', () => { const url = 'https://github.corp.example/orgs/acme/projects/7/views/2' expect(parseProjectPaste(url, 'github.corp.example')).toEqual({ @@ -187,42 +32,12 @@ describe('parseProjectPaste', () => { ).toEqual({ kind: 'org', owner: 'acme', number: 7, host: 'github.com' }) }) - it('preserves a GHES custom port while parsing project URLs', () => { - expect( - parseProjectPaste( - 'https://github.corp.example:8443/orgs/acme/projects/7', - 'github.corp.example:8443' - ) - ).toEqual({ - kind: 'org', - owner: 'acme', - number: 7, - host: 'github.corp.example:8443' - }) - }) - it('rejects credentials and paths that only begin like a Project URL', () => { expect(parseProjectPaste('https://user:token@github.com/orgs/acme/projects/1')).toBeNull() expect(parseProjectPaste('https://github.com/orgs/acme/projects/1evil')).toBeNull() expect(parseProjectPaste('https://github.com/orgs/acme/projects/1/views/2evil')).toBeNull() expect(parseProjectPaste('https://github.com/orgs/acme/projects/1/files')).toBeNull() }) - - it('returns null for empty input', () => { - expect(parseProjectPaste('')).toBeNull() - expect(parseProjectPaste(' ')).toBeNull() - }) - - it('rejects oversized valid-looking URLs without parsing the secret-bearing tail', () => { - const secret = 'project-url-secret' - const input = [ - 'https://github.com/orgs/acme/projects/42?', - secret, - 'x'.repeat(GITHUB_PROJECT_REF_INPUT_MAX_BYTES) - ].join('') - - expect(parseProjectPaste(input)).toBeNull() - }) }) describe('resolveProjectRef', () => { @@ -247,45 +62,3 @@ describe('resolveProjectRef', () => { }) }) }) - -describe('project view owner caches', () => { - beforeEach(() => { - _resetProjectViewCachesForTests() - }) - - it('LRU-evicts old owner type probes', () => { - for (let i = 0; i <= PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES; i++) { - _rememberProjectViewOwnerTypeForTests(`owner-${i}`, i % 2 === 0 ? 'organization' : 'user') - } - - expect(_getProjectViewCacheSizesForTests().ownerTypes).toBe( - PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES - ) - expect(_getProjectViewOwnerTypeForTests('owner-0')).toBeUndefined() - expect(_getProjectViewOwnerTypeForTests('owner-1')).toBe('user') - }) - - it('shares owner type probes between implicit and explicit github.com hosts', () => { - _rememberProjectViewOwnerTypeForTests('acme', 'organization') - - expect(_getProjectViewOwnerTypeForTests('acme', 'github.com')).toBe('organization') - expect(_getProjectViewCacheSizesForTests().ownerTypes).toBe(1) - }) - - it('LRU-evicts old parent-field retry and warning probes', () => { - for (let i = 0; i <= PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES; i++) { - const scopeKey = `owner-${i}\u0000organization` - _markProjectViewParentFieldRetriedForTests(scopeKey) - _markProjectViewParentFieldWarningLoggedForTests(scopeKey) - } - - expect(_getProjectViewCacheSizesForTests()).toMatchObject({ - parentFieldRetries: PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES, - parentFieldWarnings: PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES - }) - expect(_hasProjectViewParentFieldRetriedForTests('owner-0\u0000organization')).toBe(false) - expect(_hasProjectViewParentFieldWarningLoggedForTests('owner-0\u0000organization')).toBe(false) - expect(_hasProjectViewParentFieldRetriedForTests('owner-1\u0000organization')).toBe(true) - expect(_hasProjectViewParentFieldWarningLoggedForTests('owner-1\u0000organization')).toBe(true) - }) -}) diff --git a/src/main/github/pull-request-sidebar-query.test.ts b/src/main/github/pull-request-sidebar-query.test.ts index af7f7bba5ef..84b7b72533d 100644 --- a/src/main/github/pull-request-sidebar-query.test.ts +++ b/src/main/github/pull-request-sidebar-query.test.ts @@ -17,7 +17,6 @@ vi.mock('./rate-limit', () => ({ })) import { getPRByNumber, lookupPRByNumber } from './client/lookup/pr-number-lookup' -import { derivePRRefreshData } from './client/lookup/branch-lookup-derived-data' import { _resetPRStackSummaryCacheForTests } from './client/lookup/pr-stack-summary-cache' import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache' import { readPullRequestSidebarQuery } from './client/lookup/pull-request-sidebar-query' @@ -79,24 +78,6 @@ function response(pr: Record = fields, errors?: unknown[]) { return { stdout: JSON.stringify({ data: { repository: { pullRequest: pr } }, errors }) } } -function restResponse() { - return { - stdout: `HTTP/2.0 200 OK\r\nContent-Type: application/json\r\n\r\n${JSON.stringify({ - number: 42, - title: fields.title, - state: 'closed', - html_url: fields.url, - head: { sha: fields.headRefOid }, - stack: null - })}` - } -} - -function legacyResponse() { - const { commits: _commits, stack: _stack, stackEntry: _entry, ...metadata } = fields - return { stdout: JSON.stringify({ ...metadata, statusCheckRollup: [successCheck] }) } -} - beforeEach(() => { vi.resetAllMocks() _resetPRStackSummaryCacheForTests() @@ -129,50 +110,6 @@ describe('sidebar PR query', () => { expect(exec).toHaveBeenCalledTimes(1) }) - it.each(['HTTP 401: Bad credentials', 'HTTP 429: secondary rate limit', 'ETIMEDOUT'])( - 'uses REST recovery directly after %s without another GraphQL attempt', - async (message) => { - exec.mockImplementation(async (args: string[]) => { - if (args.includes('graphql') || args[0] === 'pr') { - throw new Error(message) - } - return restResponse() - }) - await expect( - getPRByNumber(repository, 42, options, 'local:host', undefined, { - includeStackSummary: true - }) - ).resolves.toMatchObject({ - number: 42 - }) - expect(exec.mock.calls[1][0]).toContain('repos/acme/widgets/pulls/42') - } - ) - - it.each([true, false])( - 'accepts a scoped NOT_FOUND response without another request (nonzero exit: %s)', - async (failed) => { - const missing = { - stdout: JSON.stringify({ - data: { repository: { pullRequest: null } }, - errors: [{ type: 'NOT_FOUND', path: ['repository', 'pullRequest'] }] - }) - } - if (failed) { - exec.mockRejectedValueOnce({ code: 1, ...missing }) - } else { - exec.mockResolvedValueOnce(missing) - } - await expect( - getPRByNumber(repository, 42, options, 'local:host', undefined, { - includeStackSummary: true - }) - ).resolves.toBeNull() - expect(exec).toHaveBeenCalledTimes(1) - expect(spend).toHaveBeenCalledTimes(1) - } - ) - it('retains PR metadata but never certifies a successful prefix after continuation failure', async () => { exec .mockResolvedValueOnce(response({ ...fields, commits: commits([successCheck], 'next') })) @@ -187,114 +124,6 @@ describe('sidebar PR query', () => { }) }) - it('keeps capability hydration on the PR base before attaching its stack summary', async () => { - exec - .mockResolvedValueOnce( - response({ - ...fields, - state: 'OPEN', - stack: { number: 12, size: 3, baseRefName: 'release' }, - stackEntry: { position: 2 } - }) - ) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { autoMergeAllowed: true, mergeQueue: null, ref: { rules: { nodes: [] } } } - } - }) - }) - await expect( - getPRByNumber(repository, 42, options, 'local:host', undefined, { includeStackSummary: true }) - ).resolves.toMatchObject({ - autoMergeAllowed: true, - stack: { baseRefName: 'release', position: 2 } - }) - expect(exec).toHaveBeenCalledTimes(2) - expect(exec.mock.calls[1][0]).toContain('branch=main') - expect(exec.mock.calls[1][0]).not.toContain('branch=release') - }) - - it('reads linked PR metadata and confirms no stack with one request through the refresh pipeline', async () => { - exec.mockImplementation(async (args: string[]) => { - if (args[0] === 'api' && args[1] === 'graphql') { - return response() - } - if (args[0] === 'pr' && args[1] === 'view') { - return legacyResponse() - } - if (args[0] === 'api' && args[1] === 'repos/acme/widgets/pulls/42') { - return restResponse() - } - throw new Error(`Unexpected request: ${args.join(' ')}`) - }) - - const { data } = await lookupPRByNumber({ - candidates: [repository], - number: 42, - ghOptions: options, - executionScope: 'local:host' - }) - if (!data) { - throw new Error('Expected linked PR') - } - const derived = await derivePRRefreshData({ - data, - dataRepo: repository, - repoPath: options.cwd, - localGitOptions: {}, - ghOptions: options, - executionScope: 'local:host', - usedExactNumberLookup: true - }) - - expect(exec).toHaveBeenCalledTimes(1) - expect(spend).toHaveBeenCalledTimes(1) - expect(data).toMatchObject({ - title: fields.title, - state: 'CLOSED', - headRefOid: fields.headRefOid, - reviewDecision: null, - autoMergeEnabled: false, - stackMetadataChecked: true, - statusCheckRollup: [{ __typename: 'StatusContext', state: 'SUCCESS' }] - }) - expect(derived).toMatchObject({ stack: undefined, mergeable: 'UNKNOWN' }) - }) - - it('does not cache mutable PR fields or delay a later refresh', async () => { - exec - .mockResolvedValueOnce(response()) - .mockResolvedValueOnce(response({ ...fields, title: 'Updated' })) - await getPRByNumber(repository, 42, options, 'local:host', undefined, { - includeStackSummary: true - }) - await expect( - getPRByNumber(repository, 42, options, 'local:host', undefined, { includeStackSummary: true }) - ).resolves.toMatchObject({ - title: 'Updated' - }) - expect(exec).toHaveBeenCalledTimes(2) - }) - - it('maps stack position and the stack base independently of the PR base', async () => { - exec.mockResolvedValueOnce( - response({ - ...fields, - stack: { number: 12, size: 3, baseRefName: 'release' }, - stackEntry: { position: 2 } - }) - ) - await expect( - getPRByNumber(repository, 42, options, 'local:host', undefined, { includeStackSummary: true }) - ).resolves.toMatchObject({ - baseRefName: 'main', - stackMetadataChecked: true, - stack: { number: 12, size: 3, position: 2, baseRefName: 'release' } - }) - expect(exec).toHaveBeenCalledTimes(1) - }) - it('keeps a failure beyond the first 100 checks and reuses the first page', async () => { const failingCheck = { __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'FAILURE' } exec @@ -342,65 +171,6 @@ describe('sidebar PR query', () => { } ) - it('preserves known branch discovery metadata while reusing the combined exact check page', async () => { - exec.mockResolvedValueOnce(response()) - const known = { - ...fields, - statusCheckRollup: [], - headDefaultBranchName: 'trunk', - stackMetadataChecked: true, - stack: undefined - } - const data = await getPRByNumber(repository, 42, options, 'local:host', known) - expect(data).toMatchObject({ headDefaultBranchName: 'trunk', stackMetadataChecked: true }) - expect(exec.mock.calls[0][0].slice(0, 2)).toEqual(['api', 'graphql']) - }) - - it.each([ - { stack: undefined, stackEntry: undefined }, - { stack: { number: 12, size: 1, baseRefName: 'release' }, stackEntry: { position: 2 } } - ])( - 'recovers missing or invalid stack metadata through the existing reader', - async (stackFields) => { - exec - .mockResolvedValueOnce(response({ ...fields, ...stackFields })) - .mockResolvedValueOnce(restResponse()) - const data = await getPRByNumber(repository, 42, options, 'local:host', undefined, { - includeStackSummary: true - }) - if (!data) { - throw new Error('Expected linked PR') - } - await derivePRRefreshData({ - data, - dataRepo: repository, - repoPath: options.cwd, - localGitOptions: {}, - ghOptions: options, - executionScope: 'local:host', - usedExactNumberLookup: true - }) - expect(data.stackMetadataChecked).toBe(true) - expect(exec.mock.calls[1][0]).toContain('repos/acme/widgets/pulls/42') - } - ) - - it('retains usable metadata from a stack field error on a nonzero gh exit', async () => { - exec.mockRejectedValueOnce( - Object.assign( - new Error('GraphQL partial error'), - response(fields, [{ path: ['repository', 'pullRequest', 'stack'], message: 'Unavailable' }]) - ) - ) - await expect( - getPRByNumber(repository, 42, options, 'local:host', undefined, { includeStackSummary: true }) - ).resolves.toMatchObject({ - title: fields.title, - stackMetadataChecked: false - }) - expect(exec).toHaveBeenCalledTimes(1) - }) - it.each([ { data: { repository: { pullRequest: { ...fields, number: 99 } } } }, { @@ -419,44 +189,6 @@ describe('sidebar PR query', () => { }) expect(exec.mock.calls[1][0].slice(0, 2)).toEqual(['pr', 'view']) }) - - it.each(['github.corp.example', undefined])( - 'uses host-scoped metadata and the complete collector for host %s', - async (host) => { - exec.mockResolvedValueOnce({ stdout: JSON.stringify({ ...fields, statusCheckRollup: [] }) }) - await expect( - getPRByNumber({ ...repository, host }, 42, options, 'local:host', undefined, { - includeStackSummary: true - }) - ).resolves.toMatchObject({ number: 42 }) - expect(exec.mock.calls[0][0].slice(0, 2)).toEqual(['pr', 'view']) - } - ) - - it.each([ - { ghAccount: { host: 'github.com', user: 'reviewer' }, env: { GH_TOKEN: 'fixture' } }, - { wslDistro: 'Ubuntu' }, - {} - ])('retains the caller execution and account options: %j', async (execution) => { - exec.mockResolvedValueOnce( - response({ - ...fields, - headRepository: null, - headRepositoryOwner: null, - headRefOid: null, - commits: { nodes: [{ commit: { oid: fields.headRefOid, statusCheckRollup: null } }] } - }) - ) - await expect( - readPullRequestSidebarQuery(repository, 42, { ...options, ...execution }) - ).resolves.toMatchObject({ - headRefOid: undefined, - headRepository: null, - statusCheckRollup: [], - stackMetadataChecked: true - }) - expect(exec.mock.calls[0][1]).toEqual({ ...options, ...execution, host: 'github.com' }) - }) }) it.each([ @@ -553,18 +285,3 @@ it.each([1, 10])( expect(exec.mock.calls.every(([args]) => args[0] === 'api' && args[1] === 'graphql')).toBe(true) } ) - -it('retains the rich exact metadata path when the optional combined read fails', async () => { - exec - .mockRejectedValueOnce(new Error('unsupported optional query')) - .mockResolvedValueOnce(legacyResponse()) - await expect( - getPRByNumber(repository, 42, options, 'local:host', { - ...fields, - stack: undefined, - statusCheckRollup: [], - headDefaultBranchName: 'trunk' - }) - ).resolves.toMatchObject({ reviewDecision: null, headDefaultBranchName: 'trunk' }) - expect(exec.mock.calls[1][0].slice(0, 2)).toEqual(['pr', 'view']) -}) diff --git a/src/main/github/rate-limit.test.ts b/src/main/github/rate-limit.test.ts index 9771d3afa7c..cb6b639822b 100644 --- a/src/main/github/rate-limit.test.ts +++ b/src/main/github/rate-limit.test.ts @@ -13,16 +13,7 @@ vi.mock('./gh-utils', () => ({ acquire: vi.fn(), release: vi.fn() })) - -import { - getRateLimit, - rateLimitGuard, - noteRateLimitSpend, - spendsSharedGitHubComQuota, - repositoryRateLimitGuard, - noteRepositoryRateLimitSpend, - _resetRateLimitCache -} from './rate-limit' +import { rateLimitGuard, _resetRateLimitCache } from './rate-limit' import { clearGhRateLimitBlock, getGhRateLimitBlockedUntilMs, @@ -30,236 +21,6 @@ import { type GhRateLimitBucket } from '../git/gh-rate-limit-breaker' -const PAYLOAD = JSON.stringify({ - resources: { - core: { limit: 5000, remaining: 4200, reset: 1_700_000_000 }, - search: { limit: 30, remaining: 28, reset: 1_700_000_000 }, - graphql: { limit: 5000, remaining: 4900, reset: 1_700_000_000 } - } -}) - -describe('getRateLimit', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - ghExecFileAsyncMock.mockReset() - _resetRateLimitCache() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('parses bucket counts from gh api rate_limit', async () => { - ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD }) - - const result = await getRateLimit() - - expect(result).toEqual({ - ok: true, - snapshot: { - core: { limit: 5000, remaining: 4200, resetAt: 1_700_000_000 }, - search: { limit: 30, remaining: 28, resetAt: 1_700_000_000 }, - graphql: { limit: 5000, remaining: 4900, resetAt: 1_700_000_000 }, - fetchedAt: 1_000 - } - }) - // The real runner adds --hostname after this mocked boundary. - expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'rate_limit'], { - encoding: 'utf-8', - host: 'github.com' - }) - }) - - it('caches a failed probe for the TTL instead of re-spawning gh', async () => { - // Negative-cache failed probes so repeated refreshes do not respawn doomed gh calls. - ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.')) - - const first = await getRateLimit() - const second = await getRateLimit() - - expect(first).toEqual({ ok: false, error: 'HTTP 404: Rate limiting is not enabled.' }) - expect(second).toEqual(first) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - - it('re-probes after the failure TTL elapses and recovers on success', async () => { - ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom')) - ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD }) - - expect((await getRateLimit()).ok).toBe(false) - expect((await getRateLimit()).ok).toBe(false) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - - vi.setSystemTime(1_000 + 30_000) - - expect((await getRateLimit()).ok).toBe(true) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('bypasses the failure cache when forced', async () => { - ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom')) - ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD }) - - expect((await getRateLimit()).ok).toBe(false) - expect((await getRateLimit({ force: true })).ok).toBe(true) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) -}) - -describe('rateLimitGuard', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - ghExecFileAsyncMock.mockReset() - _resetRateLimitCache() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - async function cacheExhaustedSnapshot(resetEpochSeconds: number): Promise { - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - resources: { - core: { limit: 5000, remaining: 3, reset: resetEpochSeconds }, - search: { limit: 30, remaining: 28, reset: resetEpochSeconds }, - graphql: { limit: 5000, remaining: 4900, reset: resetEpochSeconds } - } - }) - }) - expect((await getRateLimit()).ok).toBe(true) - } - - it('blocks below the floor while resetAt is in the future', async () => { - await cacheExhaustedSnapshot(61) - - expect(rateLimitGuard('core')).toEqual({ - blocked: true, - remaining: 3, - limit: 5000, - resetAt: 61 - }) - }) - - it('fails open once the blocking bucket resetAt has elapsed', async () => { - // Exhausted snapshots from an ended window must not keep the refresh loop paused. - await cacheExhaustedSnapshot(61) - - vi.setSystemTime(61_000) - - expect(rateLimitGuard('core')).toEqual({ blocked: false }) - }) -}) - -// Only native github.com calls share this snapshot; Enterprise and WSL have separate quotas. -describe('shared-quota host scoping', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - ghExecFileAsyncMock.mockReset() - _resetRateLimitCache() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - async function cacheExhaustedSnapshot(resetEpochSeconds: number): Promise { - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - resources: { - core: { limit: 5000, remaining: 3, reset: resetEpochSeconds }, - search: { limit: 30, remaining: 28, reset: resetEpochSeconds }, - graphql: { limit: 5000, remaining: 4900, reset: resetEpochSeconds } - } - }) - }) - expect((await getRateLimit()).ok).toBe(true) - } - - it('treats github.com / no host on the native runtime as sharing the budget', () => { - expect(spendsSharedGitHubComQuota(undefined)).toBe(true) - expect(spendsSharedGitHubComQuota(null)).toBe(true) - expect(spendsSharedGitHubComQuota({ host: 'github.com' })).toBe(true) - expect(spendsSharedGitHubComQuota({ host: 'GitHub.com' })).toBe(true) - }) - - it('excludes GHES hosts and WSL runtimes from the shared budget', () => { - expect(spendsSharedGitHubComQuota({ host: 'github.acme-corp.com' })).toBe(false) - expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { wslDistro: 'Ubuntu' })).toBe(false) - expect(spendsSharedGitHubComQuota(undefined, { wslDistro: 'Ubuntu' })).toBe(false) - }) - - it('treats a WSL UNC cwd as non-shared even without an explicit wslDistro', () => { - // UNC cwd routes execution into the distro and spends its quota. - expect( - spendsSharedGitHubComQuota( - { host: 'github.com' }, - { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' } - ) - ).toBe(false) - expect( - spendsSharedGitHubComQuota( - { host: 'github.com' }, - { cwd: '\\\\wsl$\\Ubuntu\\home\\me\\repo' } - ) - ).toBe(false) - expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: 'C:\\repos\\a' })).toBe(true) - expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: '/Users/me/repo' })).toBe(true) - }) - - it('bypasses the guard and spend for a WSL UNC cwd', async () => { - await cacheExhaustedSnapshot(61) - - const cwd = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' - expect(repositoryRateLimitGuard({ host: 'github.com' }, 'core', { cwd })).toEqual({ - blocked: false - }) - - noteRepositoryRateLimitSpend({ host: 'github.com' }, 'core', 1, { cwd }) - const guard = rateLimitGuard('core') - expect(guard.blocked && guard.remaining).toBe(3) - }) - - it('applies the shared guard for github.com but bypasses non-shared scopes', async () => { - await cacheExhaustedSnapshot(61) - - expect(repositoryRateLimitGuard({ host: 'github.com' }, 'core')).toEqual({ - blocked: true, - remaining: 3, - limit: 5000, - resetAt: 61 - }) - expect(repositoryRateLimitGuard({ host: 'github.acme-corp.com' }, 'core')).toEqual({ - blocked: false - }) - expect( - repositoryRateLimitGuard({ host: 'github.com' }, 'core', { wslDistro: 'Ubuntu' }) - ).toEqual({ blocked: false }) - }) - - it('debits shared-budget spend but no-ops for non-shared scopes', async () => { - await cacheExhaustedSnapshot(61) - - noteRepositoryRateLimitSpend({ host: 'github.acme-corp.com' }, 'core') - const afterEnterprise = rateLimitGuard('core') - expect(afterEnterprise.blocked && afterEnterprise.remaining).toBe(3) - - noteRepositoryRateLimitSpend({ host: 'github.com' }, 'core') - const afterShared = rateLimitGuard('core') - expect(afterShared.blocked && afterShared.remaining).toBe(2) - }) - - it('matches noteRateLimitSpend when the scope is shared github.com', async () => { - await cacheExhaustedSnapshot(61) - noteRateLimitSpend('core') - const guard = rateLimitGuard('core') - expect(guard.blocked && guard.remaining).toBe(2) - }) -}) - // Refine only the tripping host/runtime's breaker; preserve the shared native snapshot. describe('scope-aware breaker reset probe', () => { const WSL_SCOPE = 'wsl:ubuntu:github.com' diff --git a/src/main/github/repository-merge-metadata-reuse.test.ts b/src/main/github/repository-merge-metadata-reuse.test.ts index 97dccf011c5..a981357ccd9 100644 --- a/src/main/github/repository-merge-metadata-reuse.test.ts +++ b/src/main/github/repository-merge-metadata-reuse.test.ts @@ -34,16 +34,6 @@ afterEach(() => { }) describe('repository merge metadata reuse', () => { - it('isolates cwd when no home or config directory is available', async () => { - const env = { PATH: '/fixture/bin' } - exec.mockResolvedValueOnce(response(true)).mockResolvedValueOnce(response(false)) - await detectRepositoryMergeMetadata(repository, 'main', { cwd: '/a', env }, 'local:host') - await expect( - detectRepositoryMergeMetadata(repository, 'main', { cwd: '/b', env }, 'local:host') - ).resolves.toMatchObject({ autoMergeAllowed: false }) - expect(exec).toHaveBeenCalledTimes(2) - }) - it.each([true, false])( 'retains a successful concurrent read when a newer read fails (failure first: %s)', async (failureFirst) => { @@ -75,181 +65,6 @@ describe('repository merge metadata reuse', () => { } ) - it('starts immediately and joins overlapping PR probes for the same base branch', async () => { - const pending = Promise.withResolvers<{ stdout: string }>() - exec.mockReturnValueOnce(pending.promise) - - const reads = Array.from({ length: 10 }, () => - detectRepositoryMergeMetadata(repository, 'main', options, 'local:host') - ) - - expect(exec).toHaveBeenCalledTimes(1) - pending.resolve(response(true)) - expect(await Promise.all(reads)).toEqual( - Array.from({ length: 10 }, () => ({ autoMergeAllowed: true, mergeQueueRequired: false })) - ) - expect(spend).toHaveBeenCalledTimes(1) - await expect( - detectRepositoryMergeMetadata(repository, 'main', options, 'local:host') - ).resolves.toMatchObject({ autoMergeAllowed: true }) - expect(exec).toHaveBeenCalledTimes(1) - }) - - it('reuses context across admission tiers but refreshes at the existing TTL', async () => { - vi.useFakeTimers() - exec.mockResolvedValueOnce(response(true)).mockResolvedValueOnce(response(false)) - - await detectRepositoryMergeMetadata(repository, 'main', options, 'local:host') - await expect( - detectRepositoryMergeMetadata( - repository, - 'main', - { ...options, admissionTier: 'interactive' }, - 'local:host' - ) - ).resolves.toMatchObject({ autoMergeAllowed: true }) - expect(exec).toHaveBeenCalledTimes(1) - vi.advanceTimersByTime(10 * 60_000) - - await expect( - detectRepositoryMergeMetadata(repository, 'main', options, 'local:host') - ).resolves.toMatchObject({ autoMergeAllowed: false }) - expect(exec).toHaveBeenCalledTimes(2) - }) - - it('keeps repository-wide settings distinct from a branch with the old sentinel name', async () => { - exec.mockResolvedValue(response(true)) - - const [all, branch] = await Promise.all([ - detectRepositoryMergeMetadata(repository, undefined, options, 'local:host'), - detectRepositoryMergeMetadata(repository, '__repo__', options, 'local:host') - ]) - - expect(all.mergeQueueRequired).toBeNull() - expect(branch.mergeQueueRequired).toBe(false) - expect(exec).toHaveBeenCalledTimes(2) - }) - - it('preserves repository-wide cache reuse across different worktree directories', async () => { - exec.mockResolvedValueOnce(response(true)).mockResolvedValueOnce(response(false)) - - await detectRepositoryMergeMetadata(repository, 'main', options, 'local:host') - await expect( - detectRepositoryMergeMetadata(repository, 'main', { cwd: '/another-worktree' }, 'local:host') - ).resolves.toMatchObject({ autoMergeAllowed: true }) - - expect(exec).toHaveBeenCalledTimes(1) - }) - - it.each([ - ['GH_CONFIG_DIR', false], - ['GH_CONFIG_DIR', true], - ['XDG_CONFIG_HOME', false], - ['XDG_CONFIG_HOME', true] - ] as const)('isolates relative %s directories (inherited: %s)', async (variable, inherited) => { - vi.stubEnv(variable, '.gh') - const env = inherited ? undefined : { [variable]: '.gh' } - exec.mockResolvedValueOnce(response(true)).mockResolvedValueOnce(response(false)) - - await detectRepositoryMergeMetadata(repository, 'main', { ...options, env }, 'local:host') - await expect( - detectRepositoryMergeMetadata( - repository, - 'main', - { cwd: '/another-worktree', env }, - 'local:host' - ) - ).resolves.toMatchObject({ autoMergeAllowed: false }) - expect(exec).toHaveBeenCalledTimes(2) - }) - - it('honors an absolute config override above a relative XDG directory', async () => { - const env = { - GH_CONFIG_DIR: process.platform === 'win32' ? 'C:\\gh' : '/home/gh', - XDG_CONFIG_HOME: '.xdg' - } - exec.mockResolvedValueOnce(response(true)) - - await detectRepositoryMergeMetadata(repository, 'main', { ...options, env }, 'local:host') - await expect( - detectRepositoryMergeMetadata( - repository, - 'main', - { cwd: '/another-worktree', env }, - 'local:host' - ) - ).resolves.toMatchObject({ autoMergeAllowed: true }) - expect(exec).toHaveBeenCalledTimes(1) - }) - - it.each([undefined, 'Ubuntu'])( - 'isolates a relative default config directory in %s', - async (wslDistro) => { - const env = - !wslDistro && process.platform === 'win32' ? { APPDATA: '.gh' } : { HOME: '.home' } - exec.mockResolvedValueOnce(response(true)).mockResolvedValueOnce(response(false)) - - await detectRepositoryMergeMetadata( - repository, - 'main', - { ...options, env, wslDistro }, - 'local:host' - ) - await expect( - detectRepositoryMergeMetadata( - repository, - 'main', - { cwd: '/another-worktree', env, wslDistro }, - 'local:host' - ) - ).resolves.toMatchObject({ autoMergeAllowed: false }) - expect(exec).toHaveBeenCalledTimes(2) - } - ) - - it.each([undefined, 'Ubuntu'])('shares an absolute config directory in %s', async (wslDistro) => { - const configDir = !wslDistro && process.platform === 'win32' ? 'C:\\gh' : '/home/gh' - const env = { GH_CONFIG_DIR: configDir } - exec.mockResolvedValueOnce(response(true)) - - await detectRepositoryMergeMetadata( - repository, - 'main', - { ...options, env, wslDistro }, - 'local:host' - ) - await expect( - detectRepositoryMergeMetadata( - repository, - 'main', - { cwd: '/another-worktree', env, wslDistro }, - 'local:host' - ) - ).resolves.toMatchObject({ autoMergeAllowed: true }) - expect(exec).toHaveBeenCalledTimes(1) - }) - - it('keeps Windows-looking relative config paths isolated inside WSL', async () => { - const env = { GH_CONFIG_DIR: 'C:\\gh' } - exec.mockResolvedValueOnce(response(true)).mockResolvedValueOnce(response(false)) - - await detectRepositoryMergeMetadata( - repository, - 'main', - { ...options, env, wslDistro: 'Ubuntu' }, - 'local:Ubuntu' - ) - await expect( - detectRepositoryMergeMetadata( - repository, - 'main', - { cwd: '/another-worktree', env, wslDistro: 'Ubuntu' }, - 'local:Ubuntu' - ) - ).resolves.toMatchObject({ autoMergeAllowed: false }) - expect(exec).toHaveBeenCalledTimes(2) - }) - it('does not couple independent abort signals or cache an aborted result', async () => { const canceled = Promise.withResolvers<{ stdout: string }>() const surviving = Promise.withResolvers<{ stdout: string }>() @@ -305,23 +120,6 @@ describe('repository merge metadata reuse', () => { expect(exec).toHaveBeenCalledTimes(2) }) - it('still coalesces a shared signal and reuses its successful completed result', async () => { - const pending = Promise.withResolvers<{ stdout: string }>() - const signal = new AbortController().signal - exec.mockReturnValueOnce(pending.promise) - const reads = Array.from({ length: 10 }, () => - detectRepositoryMergeMetadata(repository, 'main', { ...options, signal }, 'local:host') - ) - expect(exec).toHaveBeenCalledTimes(1) - pending.resolve(response(true)) - await Promise.all(reads) - - await expect( - detectRepositoryMergeMetadata(repository, 'main', options, 'local:host') - ).resolves.toMatchObject({ autoMergeAllowed: true }) - expect(exec).toHaveBeenCalledTimes(1) - }) - it('retains a surviving cache writer when a newer independent probe aborts', async () => { const older = Promise.withResolvers<{ stdout: string }>() const canceled = Promise.withResolvers<{ stdout: string }>() @@ -363,19 +161,6 @@ describe('repository merge metadata reuse', () => { expect(spend).not.toHaveBeenCalled() }) - it('keeps inferred WSL distro credentials separate from native and other distros', async () => { - exec.mockResolvedValueOnce(response(true)).mockResolvedValue(response(false)) - - await detectRepositoryMergeMetadata(repository, 'main', options, 'local:host') - for (const cwd of ['\\\\wsl.localhost\\Ubuntu\\repo', '\\\\wsl.localhost\\Debian\\repo']) { - await expect( - detectRepositoryMergeMetadata(repository, 'main', { cwd }, 'local:host') - ).resolves.toMatchObject({ autoMergeAllowed: false }) - } - - expect(exec).toHaveBeenCalledTimes(3) - }) - it('keeps newer worktree settings when an older directory probe completes later', async () => { const older = Promise.withResolvers<{ stdout: string }>() const newer = Promise.withResolvers<{ stdout: string }>() @@ -398,96 +183,6 @@ describe('repository merge metadata reuse', () => { expect(exec).toHaveBeenCalledTimes(2) }) - it('isolates credential/host/branch/route caches and cwd-sensitive overlapping probes', async () => { - const pending = Promise.withResolvers<{ stdout: string }>() - exec.mockReturnValue(pending.promise) - const requests = [ - { repository, branch: 'main', options, scope: 'local:host' }, - { repository, branch: 'release', options, scope: 'local:host' }, - { - repository: { ...repository, host: 'ghe.example' }, - branch: 'main', - options, - scope: 'local:host' - }, - { repository, branch: 'main', options, scope: 'ssh:one' }, - { repository, branch: 'main', options, scope: 'ssh:two' }, - { - repository, - branch: 'main', - options: { ...options, wslDistro: 'Ubuntu' }, - scope: 'local:Ubuntu' - }, - { - repository, - branch: 'main', - options: { ...options, cwd: '/another-repo' }, - scope: 'local:host' - }, - { - repository, - branch: 'main', - options: { ...options, ghAccount: { host: 'github.com', user: 'other' } }, - scope: 'local:host' - }, - { - repository, - branch: 'main', - options: { ...options, env: { GH_TOKEN: 'one' } }, - scope: 'local:host' - }, - { - repository, - branch: 'main', - options: { ...options, env: { GH_TOKEN: 'two' } }, - scope: 'local:host' - } - ] - - const reads = requests.map((request) => - detectRepositoryMergeMetadata( - request.repository, - request.branch, - request.options, - request.scope - ) - ) - expect(exec).toHaveBeenCalledTimes(requests.length) - pending.resolve(response(true)) - expect((await Promise.all(reads)).every((value) => value.autoMergeAllowed)).toBe(true) - for (const request of requests) { - await expect( - detectRepositoryMergeMetadata( - request.repository, - request.branch, - request.options, - request.scope - ) - ).resolves.toMatchObject({ autoMergeAllowed: true }) - } - expect(exec).toHaveBeenCalledTimes(requests.length) - }) - - it('shares failed probes and retries unknown settings at the existing short TTL', async () => { - vi.useFakeTimers() - const pending = Promise.withResolvers<{ stdout: string }>() - exec.mockReturnValueOnce(pending.promise).mockResolvedValueOnce(response(true)) - const first = detectRepositoryMergeMetadata(repository, 'main', options, 'local:host') - const second = detectRepositoryMergeMetadata(repository, 'main', options, 'local:host') - pending.reject(new Error('offline')) - - expect(await Promise.all([first, second])).toEqual([ - { autoMergeAllowed: null, mergeQueueRequired: null }, - { autoMergeAllowed: null, mergeQueueRequired: null } - ]) - expect(exec).toHaveBeenCalledTimes(1) - vi.advanceTimersByTime(60_000) - await expect( - detectRepositoryMergeMetadata(repository, 'main', options, 'local:host') - ).resolves.toMatchObject({ autoMergeAllowed: true }) - expect(exec).toHaveBeenCalledTimes(2) - }) - it('allows a stale successor without letting the older completion replace its cache', async () => { vi.useFakeTimers() const older = Promise.withResolvers<{ stdout: string }>() diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts index 82ee05afe80..e0e1647be75 100644 --- a/src/main/github/work-item-details-enterprise-host.test.ts +++ b/src/main/github/work-item-details-enterprise-host.test.ts @@ -92,201 +92,6 @@ describe('getWorkItemDetails Enterprise host routing', () => { noteRepositoryRateLimitSpendMock.mockReset() }) - it('uses the GitHub Enterprise host for SSH-backed issue work item details', async () => { - const enterpriseRepository = { - owner: 'team', - repo: 'orca', - host: 'github.acme-corp.com' - } - getWorkItemMock.mockResolvedValueOnce({ - id: 'issue:7', - type: 'issue', - number: 7, - title: 'Enterprise issue', - state: 'open', - url: 'https://github.acme-corp.com/team/orca/issues/7', - labels: [], - updatedAt: '2026-07-16T00:00:00Z', - author: 'issue-author' - }) - getOwnerRepoMock.mockResolvedValue(null) - getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterpriseRepository) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const query = args.find((arg) => arg.startsWith('query=')) ?? '' - if (query.includes('comments(first: 100)')) { - return { - stdout: JSON.stringify({ - data: { - repository: { - issue: { - body: 'Enterprise issue body', - assignees: { nodes: [] }, - participants: { nodes: [] }, - comments: { nodes: [] } - } - } - } - }) - } - } - const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' - if (endpoint === 'repos/team/orca/issues/7/timeline?per_page=100&page=1') { - return { stdout: '' } - } - throw new Error(`unexpected gh call: ${args.join(' ')}`) - }) - - const details = await getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1') - - expect(details?.body).toBe('Enterprise issue body') - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1', {}, undefined) - expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) - expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(enterpriseRepository, 'graphql', { - host: 'github.acme-corp.com' - }) - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalled() - expect( - ghExecFileAsyncMock.mock.calls.every( - ([, options]) => options?.host === 'github.acme-corp.com' - ) - ).toBe(true) - }) - - it('does not query the default host when an SSH issue work item is not found', async () => { - getWorkItemMock.mockResolvedValue(null) - - await expect(getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1')).resolves.toBeNull() - - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1', {}, undefined) - expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('uses the GitHub Enterprise host for SSH-backed PR work item details', async () => { - getWorkItemMock.mockResolvedValueOnce({ - id: 'pr:7', - type: 'pr', - number: 7, - title: 'Enterprise PR files', - state: 'open', - url: 'https://github.acme-corp.com/team/orca/pull/7', - labels: [], - updatedAt: '2026-07-16T00:00:00Z', - author: 'pr-author', - prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } - }) - getPRCommentsMock.mockResolvedValue([]) - getPRChecksMock.mockResolvedValue([]) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' - if (endpoint === 'repos/team/orca/pulls/7') { - return { - stdout: JSON.stringify({ - body: 'Enterprise PR body', - head: { sha: 'head-sha' }, - base: { sha: 'base-sha' } - }) - } - } - if (endpoint === 'repos/team/orca/pulls/7/files?per_page=100') { - return { - stdout: JSON.stringify([ - { - filename: 'src/enterprise.ts', - status: 'modified', - additions: 2, - deletions: 1, - changes: 3, - patch: '@@ -1 +1 @@' - } - ]) - } - } - const query = args.find((arg) => arg.startsWith('query=')) ?? '' - if (query.includes('viewerViewedState')) { - return { - stdout: JSON.stringify({ - data: { - repository: { - viewedPullRequest: { - id: 'PR_enterprise', - files: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [{ path: 'src/enterprise.ts', viewerViewedState: 'VIEWED' }] - } - } - } - } - }) - } - } - if (query.includes('participants(first: 100)')) { - return { - stdout: JSON.stringify({ - data: { repository: { pullRequest: { participants: { nodes: [] } } } } - }) - } - } - throw new Error(`unexpected gh call: ${args.join(' ')}`) - }) - - const details = await getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1') - - expect(details?.body).toBe('Enterprise PR body') - expect(details?.headSha).toBe('head-sha') - expect(details?.baseSha).toBe('base-sha') - expect(details?.filesUnavailable).toBe(false) - expect(details?.files).toEqual([ - { - path: 'src/enterprise.ts', - oldPath: undefined, - status: 'modified', - additions: 2, - deletions: 1, - isBinary: false, - reviewCommentLineNumbers: [], - viewerViewedState: 'VIEWED' - } - ]) - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1', {}, undefined) - expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() - expect(getPRCommentsMock).toHaveBeenCalledWith( - '/remote/repo', - 7, - { prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } }, - 'ssh-1' - ) - expect(getPRChecksMock).toHaveBeenCalledWith( - '/remote/repo', - 7, - 'head-sha', - { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, - undefined, - 'ssh-1' - ) - const apiCalls = ghExecFileAsyncMock.mock.calls - .map(([args]) => args as string[]) - .filter((args) => args[0] === 'api') - expect(apiCalls.length).toBeGreaterThan(0) - expect(apiCalls.every((args) => !args.includes('--hostname'))).toBe(true) - expect( - ghExecFileAsyncMock.mock.calls.every( - ([, options]) => options?.host === 'github.acme-corp.com' - ) - ).toBe(true) - }) - - it('does not query the default host when an SSH PR work item is not found', async () => { - getWorkItemMock.mockResolvedValue(null) - - await expect(getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1')).resolves.toBeNull() - - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1', {}, undefined) - expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('surfaces a found SSH PR without any default-host gh call when the repository is unresolved', async () => { // A found PR without a resolved Enterprise host must not fall back to github.com. getWorkItemMock.mockResolvedValueOnce({ diff --git a/src/main/github/work-item-details-file-viewed.test.ts b/src/main/github/work-item-details-file-viewed.test.ts deleted file mode 100644 index bc3fd79098a..00000000000 --- a/src/main/github/work-item-details-file-viewed.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -type RateLimitGuardResult = - | { blocked: false } - | { blocked: true; remaining: number; limit: number; resetAt: number } - -const { - ghExecFileAsyncMock, - getOwnerRepoMock, - getIssueOwnerRepoMock, - getWorkItemMock, - getPRChecksMock, - getPRCommentsMock, - rateLimitGuardMock, - noteRateLimitSpendMock, - repositoryRateLimitGuardMock, - noteRepositoryRateLimitSpendMock, - acquireMock, - releaseMock -} = vi.hoisted(() => ({ - ghExecFileAsyncMock: vi.fn(), - getOwnerRepoMock: vi.fn(), - getIssueOwnerRepoMock: vi.fn(), - getWorkItemMock: vi.fn(), - getPRChecksMock: vi.fn(), - getPRCommentsMock: vi.fn(), - rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })), - noteRateLimitSpendMock: vi.fn(), - repositoryRateLimitGuardMock: vi.fn< - ( - repository: { host?: string } | null | undefined, - bucket: string, - options?: { cwd?: string; host?: string } - ) => RateLimitGuardResult - >(() => ({ blocked: false })), - noteRepositoryRateLimitSpendMock: - vi.fn< - ( - repository: { host?: string } | null | undefined, - bucket: string, - cost?: number, - options?: { cwd?: string; host?: string } - ) => void - >(), - acquireMock: vi.fn(), - releaseMock: vi.fn() -})) - -vi.mock('./gh-utils', () => ({ - ghExecFileAsync: ghExecFileAsyncMock, - getOwnerRepoForRemote: getOwnerRepoMock, - getIssueOwnerRepo: getIssueOwnerRepoMock, - ghRepoExecOptions: vi.fn((context) => (context.connectionId ? {} : { cwd: context.repoPath })), - githubRepoContext: vi.fn((repoPath, connectionId) => ({ - repoPath, - connectionId: connectionId ?? null - })), - acquire: acquireMock, - release: releaseMock -})) - -vi.mock('./client', async () => ({ - getWorkItem: getWorkItemMock, - getWorkItemWithRepository: ( - await vi.importActual('./work-item-details-test-lookup') - ).makeWorkItemDetailsLookupMock(getWorkItemMock), - getWorkItemByOwnerRepo: vi.fn(), - getPRChecks: getPRChecksMock, - getPRComments: getPRCommentsMock -})) - -vi.mock('./rate-limit', () => ({ - rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock, - repositoryRateLimitGuard: repositoryRateLimitGuardMock, - noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock -})) - -import { getWorkItemDetails } from './work-item-details' - -import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' - -beforeEach(() => { - _resetOriginGitHubApiRepositoryCache() -}) - -describe('getWorkItemDetails PR file viewed state', () => { - beforeEach(() => { - ghExecFileAsyncMock.mockReset() - getOwnerRepoMock.mockReset() - getIssueOwnerRepoMock.mockReset() - getWorkItemMock.mockReset() - getPRChecksMock.mockReset() - getPRCommentsMock.mockReset() - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) - noteRateLimitSpendMock.mockReset() - repositoryRateLimitGuardMock.mockReset() - repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) - noteRepositoryRateLimitSpendMock.mockReset() - acquireMock.mockReset() - releaseMock.mockReset() - acquireMock.mockResolvedValue(undefined) - }) - - it('merges GitHub viewer viewed state into PR files', async () => { - getWorkItemMock.mockResolvedValueOnce({ - id: 'pr:42', - type: 'pr', - number: 42, - title: 'Review files', - state: 'open', - url: 'https://github.com/stablyai/orca/pull/42', - labels: [], - updatedAt: '2026-04-01T00:00:00Z', - author: null - }) - getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - getPRCommentsMock.mockResolvedValue([]) - getPRChecksMock.mockResolvedValue([]) - ghExecFileAsyncMock.mockImplementation((args: string[]) => { - const query = args.find((arg) => arg.startsWith('query=')) ?? '' - if (query.includes('viewerViewedState')) { - return Promise.resolve({ - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - body: 'PR body', - headRefOid: 'head-sha', - baseRefOid: 'base-sha', - participants: { nodes: [] } - }, - viewedPullRequest: { - id: 'PR_kwDO123', - files: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [ - { path: 'src/viewed.ts', viewerViewedState: 'VIEWED' }, - { path: 'src/changed.ts', viewerViewedState: 'DISMISSED' } - ] - } - } - } - } - }) - }) - } - if (query.includes('participants')) { - return Promise.resolve({ - stdout: JSON.stringify({ - data: { repository: { pullRequest: { participants: { nodes: [] } } } } - }) - }) - } - const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' - if (endpoint === 'repos/stablyai/orca/pulls/42') { - return Promise.resolve({ - stdout: JSON.stringify({ - body: 'PR body', - head: { sha: 'head-sha' }, - base: { sha: 'base-sha' } - }) - }) - } - if (endpoint === 'repos/stablyai/orca/pulls/42/files?per_page=100') { - return Promise.resolve({ - stdout: JSON.stringify([ - { - filename: 'src/viewed.ts', - status: 'modified', - additions: 3, - deletions: 1, - changes: 4, - patch: '@@' - }, - { - filename: 'src/changed.ts', - status: 'modified', - additions: 1, - deletions: 0, - changes: 1, - patch: '@@' - } - ]) - }) - } - return Promise.reject(new Error(`unexpected gh call: ${args.join(' ')}`)) - }) - - const details = await getWorkItemDetails('/repo-root', 42, 'pr') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) - expect( - ghExecFileAsyncMock.mock.calls.find(([args]) => - args.includes('repos/stablyai/orca/pulls/42') - )?.[0] - ).toEqual(['api', '--cache', '60s', 'repos/stablyai/orca/pulls/42']) - expect(details?.body).toBe('PR body') - expect(details?.pullRequestId).toBe('PR_kwDO123') - expect(details?.headSha).toBe('head-sha') - expect(details?.baseSha).toBe('base-sha') - expect(details?.files?.map((file) => [file.path, file.viewerViewedState])).toEqual([ - ['src/viewed.ts', 'VIEWED'], - ['src/changed.ts', 'DISMISSED'] - ]) - expect(getPRChecksMock).toHaveBeenCalledWith( - '/repo-root', - 42, - 'head-sha', - { owner: 'stablyai', repo: 'orca', host: 'github.com' }, - undefined, - undefined - ) - expect(repositoryRateLimitGuardMock.mock.calls.length).toBeGreaterThan(0) - expect( - repositoryRateLimitGuardMock.mock.calls.every(([, bucket, options]) => - bucket === 'graphql' - ? options?.cwd === '/repo-root' && options?.host === 'github.com' - : true - ) - ).toBe(true) - expect(noteRepositoryRateLimitSpendMock.mock.calls.length).toBeGreaterThan(0) - expect( - noteRepositoryRateLimitSpendMock.mock.calls.every(([, bucket, , options]) => - bucket === 'graphql' - ? options?.cwd === '/repo-root' && options?.host === 'github.com' - : true - ) - ).toBe(true) - }) - - it('keeps PR details loadable when checks lookup fails', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - getWorkItemMock.mockResolvedValueOnce({ - id: 'pr:42', - type: 'pr', - number: 42, - title: 'Review files', - state: 'open', - url: 'https://github.com/stablyai/orca/pull/42', - labels: [], - updatedAt: '2026-04-01T00:00:00Z', - author: null - }) - getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - getPRCommentsMock.mockResolvedValue([]) - getPRChecksMock.mockRejectedValue( - Object.assign(new Error('Command failed: gh pr checks 42'), { - stderr: "no checks reported on the 'codex/keybindings-toml' branch\n", - stdout: '' - }) - ) - ghExecFileAsyncMock.mockImplementation((args: string[]) => { - const query = args.find((arg) => arg.startsWith('query=')) ?? '' - if (query.includes('viewerViewedState')) { - return Promise.resolve({ - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - id: 'PR_kwDO123', - files: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] } - } - } - } - }) - }) - } - if (query.includes('participants')) { - return Promise.resolve({ - stdout: JSON.stringify({ - data: { repository: { pullRequest: { participants: { nodes: [] } } } } - }) - }) - } - const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' - if (endpoint === 'repos/stablyai/orca/pulls/42') { - return Promise.resolve({ - stdout: JSON.stringify({ - body: 'PR body', - head: { sha: 'head-sha' }, - base: { sha: 'base-sha' } - }) - }) - } - if (endpoint === 'repos/stablyai/orca/pulls/42/files?per_page=100') { - return Promise.resolve({ stdout: '[]' }) - } - return Promise.reject(new Error(`unexpected gh call: ${args.join(' ')}`)) - }) - - const details = await getWorkItemDetails('/repo-root', 42, 'pr') - - expect(details?.body).toBe('PR body') - expect(details?.headSha).toBe('head-sha') - expect(details?.checks).toEqual([]) - expect(consoleWarnSpy).toHaveBeenCalledOnce() - consoleWarnSpy.mockRestore() - }) -}) diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts index 5a6aa2d10fe..ff94be71d4e 100644 --- a/src/main/github/work-item-details-pr-files.test.ts +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -74,8 +74,7 @@ vi.mock('./rate-limit', () => ({ repositoryRateLimitGuard: repositoryRateLimitGuardMock, noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock })) - -import { getPRFileContents, getWorkItemDetails } from './work-item-details' +import { getWorkItemDetails } from './work-item-details' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' @@ -185,90 +184,6 @@ describe('getWorkItemDetails PR file listing', () => { ).toHaveLength(3) }) - it('flags filesUnavailable when the file fetch fails', async () => { - getWorkItemMock.mockResolvedValueOnce(pullRequestItem(8305, 'Files fetch fails')) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const target = args.at(-1) - if (target === 'repos/acme/widgets/pulls/8305') { - return { - stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) - } - } - if (target === 'repos/acme/widgets/pulls/8305/files?per_page=100') { - throw new Error('gh: API rate limit exceeded (403)') - } - return auxiliaryPRResponse(args) - }) - - const details = await getWorkItemDetails('/repo-root', 8305, 'pr') - - expect(details?.filesUnavailable).toBe(true) - expect(details?.files).toBeUndefined() - }) - - it('preserves an empty file list as an available result', async () => { - getWorkItemMock.mockResolvedValueOnce(pullRequestItem(8306, 'Empty PR')) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const target = args.at(-1) - if (target === 'repos/acme/widgets/pulls/8306') { - return { - stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) - } - } - if (target === 'repos/acme/widgets/pulls/8306/files?per_page=100') { - return { stdout: '[]' } - } - return auxiliaryPRResponse(args) - }) - - const details = await getWorkItemDetails('/repo-root', 8306, 'pr') - - expect(details?.filesUnavailable).toBe(false) - expect(details?.files).toEqual([]) - }) - - it('backfills the Enterprise origin host before a host-less PR detail fan-out', async () => { - const enterprise = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } - getWorkItemMock.mockResolvedValueOnce({ - ...pullRequestItem(8, 'Host-less Enterprise PR'), - author: '', - prRepo: { owner: 'team', repo: 'orca' } - }) - getOwnerRepoMock.mockResolvedValue(null) - getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterprise) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' - if (endpoint === 'repos/team/orca/pulls/8') { - return { stdout: JSON.stringify({ body: 'Enterprise body' }) } - } - if (endpoint === 'repos/team/orca/pulls/8/files?per_page=100') { - return { stdout: '[]' } - } - return auxiliaryPRResponse(args) - }) - - const details = await getWorkItemDetails('/remote/repo', 8, 'pr', 'ssh-1') - - expect(details?.item.prRepo).toEqual(enterprise) - expect(getPRCommentsMock).toHaveBeenCalledWith( - '/remote/repo', - 8, - { prRepo: enterprise }, - 'ssh-1' - ) - expect(getPRChecksMock).toHaveBeenCalledWith( - '/remote/repo', - 8, - undefined, - enterprise, - undefined, - 'ssh-1' - ) - expect( - ghExecFileAsyncMock.mock.calls.every(([, options]) => options?.host === enterprise.host) - ).toBe(true) - }) - it('does not run bare PR detail commands when local host resolution fails', async () => { getWorkItemMock.mockResolvedValueOnce({ ...pullRequestItem(9, 'Unresolved PR'), @@ -283,107 +198,4 @@ describe('getWorkItemDetails PR file listing', () => { expect(getPRChecksMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) - - it('uses the selected upstream GitHub Enterprise repo for both PR file sides', async () => { - const prRepo = { - owner: 'team', - repo: 'orca', - host: 'github.acme-corp.com' - } - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' - if (endpoint === 'repos/team/orca/contents/src/path%23with%3Fchars.ts?ref=base-sha') { - return { stdout: 'base content' } - } - if (endpoint === 'repos/team/orca/contents/src/path%23with%3Fchars.ts?ref=head-sha') { - return { stdout: 'head content' } - } - throw new Error(`unexpected gh call: ${args.join(' ')}`) - }) - - const contents = await getPRFileContents({ - repoPath: '/repo-root', - prRepo, - prNumber: 7, - path: 'src/path#with?chars.ts', - status: 'modified', - headSha: 'head-sha', - baseSha: 'base-sha' - }) - - expect(contents).toMatchObject({ - original: 'base content', - modified: 'head content', - originalIsBinary: false, - modifiedIsBinary: false - }) - const apiCalls = ghExecFileAsyncMock.mock.calls.map(([args]) => args as string[]) - expect(apiCalls).toHaveLength(2) - expect(apiCalls.every((args) => !args.includes('--hostname'))).toBe(true) - expect( - ghExecFileAsyncMock.mock.calls.every( - ([, options]) => options?.host === 'github.acme-corp.com' - ) - ).toBe(true) - expect( - repositoryRateLimitGuardMock.mock.calls.filter(([, bucket]) => bucket === 'core') - ).toHaveLength(2) - expect( - noteRepositoryRateLimitSpendMock.mock.calls.filter(([, bucket]) => bucket === 'core') - ).toHaveLength(2) - }) - - it('skips an added file base read and preserves a head max-buffer overflow', async () => { - ghExecFileAsyncMock.mockRejectedValueOnce( - Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' }) - ) - - const contents = await getPRFileContents({ - repoPath: '/repo-root', - prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, - prNumber: 7, - path: 'src/large.ts', - status: 'added', - headSha: 'head-sha', - baseSha: 'base-sha' - }) - - expect(contents).toEqual({ - original: '', - modified: '', - originalIsBinary: false, - modifiedIsBinary: false, - originalTooLarge: undefined, - modifiedTooLarge: true - }) - expect(ghExecFileAsyncMock).toHaveBeenCalledOnce() - expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( - 'repos/team/orca/contents/src/large.ts?ref=head-sha' - ) - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledOnce() - }) - - it('does not fetch raw PR file contents while the repository core budget is blocked', async () => { - repositoryRateLimitGuardMock.mockReturnValue({ - blocked: true, - remaining: 0, - limit: 5000, - resetAt: 1_800_000_000 - }) - - await expect( - getPRFileContents({ - repoPath: '/repo-root', - prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, - prNumber: 7, - path: 'src/file.ts', - status: 'modified', - headSha: 'head-sha', - baseSha: 'base-sha' - }) - ).resolves.toMatchObject({ original: '', modified: '' }) - - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 92e848b445d..3beee4e9554 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -85,7 +85,6 @@ vi.mock('./rate-limit', () => ({ })) import { getWorkItemDetails } from './work-item-details' -import { getMentionParticipants } from './work-item-participants' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' @@ -116,267 +115,6 @@ describe('getWorkItemDetails', () => { acquireMock.mockResolvedValue(undefined) }) - it('uses the collapsed GraphQL issue query with timeline activity enrichment', async () => { - getWorkItemMock.mockResolvedValueOnce({ - id: 'issue:923', - type: 'issue', - number: 923, - title: 'Use upstream issues', - state: 'open', - url: 'https://github.com/acme/widgets/issues/923', - labels: [], - updatedAt: '2026-04-01T00:00:00Z', - author: 'issue-author' - }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - const timelineEvents = [ - { - id: 101, - event: 'assigned', - actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' }, - assignee: { login: 'assigned-user' }, - created_at: '2026-04-01T01:00:00Z' - }, - { - id: 102, - event: 'cross-referenced', - actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' }, - created_at: '2026-04-01T02:00:00Z', - source: { - issue: { - number: 6180, - title: 'Synthetic reference PR', - html_url: 'https://github.com/acme/widgets/pull/6180', - repository: { owner: { login: 'acme' }, name: 'widgets' }, - pull_request: {} - } - } - }, - { - id: 103, - event: 'moved_columns_in_project', - actor: { login: 'github-project-automation', avatar_url: 'https://x/bot' }, - created_at: '2026-04-01T03:00:00Z', - previous_column_name: 'Doing', - project_column_name: 'Complete', - project: { name: 'Example Project' } - }, - { - id: 104, - event: 'closed', - actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' }, - created_at: '2026-04-01T04:00:00Z', - state_reason: 'completed', - closer: { - number: 6180, - title: 'Synthetic reference PR', - html_url: 'https://github.com/acme/widgets/pull/6180', - repository: { owner: { login: 'acme' }, name: 'widgets' }, - pull_request: {} - } - } - ] - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - issue: { - body: 'Issue body', - assignees: { nodes: [{ login: 'assigned-user' }] }, - participants: { - nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y', name: 'Issue Author' }] - }, - comments: { - nodes: [ - { - databaseId: 7, - body: 'first', - createdAt: '2026-04-01T00:00:00Z', - url: 'https://github.com/acme/widgets/issues/923#issuecomment-7', - author: { login: 'issue-author', avatarUrl: 'https://x/y' } - } - ] - } - } - } - } - }) - }) - .mockResolvedValueOnce({ - stdout: timelineEvents.map((event) => JSON.stringify(event)).join('\n') - }) - - const details = await getWorkItemDetails('/repo-root', 923, 'issue') - - expect(getWorkItemMock).toHaveBeenCalledWith( - '/repo-root', - 923, - 'issue', - undefined, - {}, - undefined - ) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(ghExecFileAsyncMock.mock.calls[0][0][0]).toBe('api') - expect(ghExecFileAsyncMock.mock.calls[0][0][1]).toBe('graphql') - expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([ - 'api', - '--cache', - '60s', - 'repos/acme/widgets/issues/923/timeline?per_page=100&page=1', - '--jq', - '.[] | @json' - ]) - expect(details?.body).toBe('Issue body') - expect(details?.assignees).toEqual(['assigned-user']) - expect(details?.comments).toHaveLength(1) - expect(details?.comments[0].id).toBe(7) - expect(details?.timelineItems).toMatchObject([ - { event: 'assigned', actor: 'timeline-actor', assignee: 'assigned-user' }, - { - event: 'cross-referenced', - source: { - type: 'pr', - number: 6180, - repository: 'acme/widgets' - } - }, - { - event: 'moved_columns_in_project', - actor: 'github-project-automation', - previousColumnName: 'Doing', - columnName: 'Complete', - projectName: 'Example Project' - }, - { - event: 'closed', - stateReason: 'completed', - closer: { type: 'pr', number: 6180 } - } - ]) - expect(details?.participants?.[0]?.login).toBe('issue-author') - expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( - { owner: 'acme', repo: 'widgets', host: 'github.com' }, - 'graphql', - { cwd: '/repo-root', host: 'github.com' } - ) - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( - { owner: 'acme', repo: 'widgets', host: 'github.com' }, - 'graphql', - 1, - { cwd: '/repo-root', host: 'github.com' } - ) - expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( - { owner: 'acme', repo: 'widgets', host: 'github.com' }, - 'core', - { cwd: '/repo-root', host: 'github.com' } - ) - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( - { owner: 'acme', repo: 'widgets', host: 'github.com' }, - 'core', - 1, - { cwd: '/repo-root', host: 'github.com' } - ) - }) - - it('scopes collapsed issue GraphQL accounting to an implicit WSL UNC runtime', async () => { - const repository = { owner: 'acme', repo: 'widgets' } - const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\me\widgets` - getWorkItemMock.mockResolvedValueOnce({ - id: 'issue:923', - type: 'issue', - number: 923, - title: 'Use upstream issues', - state: 'open', - url: 'https://github.com/acme/widgets/issues/923', - labels: [], - updatedAt: '2026-04-01T00:00:00Z', - author: 'issue-author' - }) - getOwnerRepoForRemoteMock.mockResolvedValue(repository) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - issue: { - body: 'WSL issue body', - assignees: { nodes: [] }, - participants: { nodes: [] }, - comments: { nodes: [] } - } - } - } - }) - }) - .mockResolvedValueOnce({ stdout: '' }) - - const details = await getWorkItemDetails(repoPath, 923, 'issue') - - expect(details?.body).toBe('WSL issue body') - expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( - { ...repository, host: 'github.com' }, - 'graphql', - { cwd: repoPath, host: 'github.com' } - ) - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( - { ...repository, host: 'github.com' }, - 'graphql', - 1, - { cwd: repoPath, host: 'github.com' } - ) - expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.cwd === repoPath)).toBe(true) - }) - - it('enriches a non-participating assignee avatar from the GraphQL assignees connection', async () => { - // Assignees can be absent from participants; enrich their avatars from the assignees connection too. - getWorkItemMock.mockResolvedValueOnce({ - id: 'issue:924', - type: 'issue', - number: 924, - title: 'Assignee avatar', - state: 'open', - url: 'https://github.com/acme/widgets/issues/924', - labels: [], - updatedAt: '2026-04-01T00:00:00Z', - author: 'issue-author', - assignees: [{ login: 'ghe-assignee', name: 'GHE Assignee', avatarUrl: '' }] - }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - issue: { - body: 'Issue body', - assignees: { - nodes: [ - { - login: 'ghe-assignee', - name: 'GHE Assignee', - avatarUrl: 'https://ghe.example.com/avatars/ghe-assignee' - } - ] - }, - participants: { nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y' }] }, - comments: { nodes: [] } - } - } - } - }) - }) - .mockResolvedValueOnce({ stdout: '' }) - - const details = await getWorkItemDetails('/repo-root', 924, 'issue') - - expect(details?.item.assignees?.[0]).toMatchObject({ - login: 'ghe-assignee', - avatarUrl: 'https://ghe.example.com/avatars/ghe-assignee' - }) - }) - it('caps issue timeline pagination by supported activity items', async () => { getWorkItemMock.mockResolvedValueOnce({ id: 'issue:923', @@ -453,389 +191,5 @@ describe('getWorkItemDetails', () => { expect(timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' }) }) - it('falls back to REST + GraphQL when the collapsed issue query fails', async () => { - getWorkItemMock.mockResolvedValueOnce({ - id: 'issue:923', - type: 'issue', - number: 923, - title: 'Use upstream issues', - state: 'open', - url: 'https://github.com/acme/widgets/issues/923', - labels: [], - updatedAt: '2026-04-01T00:00:00Z', - author: 'issue-author' - }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockRejectedValueOnce(new Error('GraphQL error')) - .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body' }) }) - .mockResolvedValueOnce({ stdout: '[]' }) - .mockResolvedValueOnce({ stdout: '[]' }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { repository: { issue: { participants: { nodes: [] } } } } - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ data: {} }) - }) - - const details = await getWorkItemDetails('/repo-root', 923, 'issue') - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['api', '--cache', '60s', 'repos/acme/widgets/issues/923'], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, - ['api', '--cache', '60s', 'repos/acme/widgets/issues/923/comments?per_page=100'], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 4, - [ - 'api', - '--cache', - '60s', - 'repos/acme/widgets/issues/923/timeline?per_page=100&page=1', - '--jq', - '.[] | @json' - ], - { cwd: '/repo-root', host: 'github.com' } - ) - expect(details?.body).toBe('Issue body') - }) - - it('skips optional GraphQL issue detail calls when the cached GraphQL budget is low', async () => { - repositoryRateLimitGuardMock.mockImplementation((_repository, bucket) => - bucket === 'graphql' - ? { - blocked: true, - remaining: 3, - limit: 5000, - resetAt: 1_800_000_000 - } - : { blocked: false } - ) - getWorkItemMock.mockResolvedValueOnce({ - id: 'issue:923', - type: 'issue', - number: 923, - title: 'Use upstream issues', - state: 'open', - url: 'https://github.com/acme/widgets/issues/923', - labels: [], - updatedAt: '2026-04-01T00:00:00Z', - author: 'issue-author' - }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body', assignees: [] }) }) - .mockResolvedValueOnce({ stdout: '[]' }) - .mockResolvedValueOnce({ stdout: '[]' }) - - const details = await getWorkItemDetails('/repo-root', 923, 'issue') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) - expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false) - expect( - noteRepositoryRateLimitSpendMock.mock.calls.some(([, bucket]) => bucket === 'graphql') - ).toBe(false) - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( - { owner: 'acme', repo: 'widgets', host: 'github.com' }, - 'core', - 2, - { cwd: '/repo-root', host: 'github.com' } - ) - expect(details?.body).toBe('Issue body') - expect(details?.participants).toEqual([]) - }) - - it('uses SSH connection context for issue details without local cwd', async () => { - getWorkItemMock.mockResolvedValueOnce({ - id: 'issue:923', - type: 'issue', - number: 923, - title: 'Use upstream issues', - state: 'open', - url: 'https://github.com/acme/widgets/issues/923', - labels: [], - updatedAt: '2026-04-01T00:00:00Z', - author: 'issue-author' - }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - issue: { - body: 'Remote issue body', - assignees: { nodes: [] }, - participants: { nodes: [] }, - comments: { nodes: [] } - } - } - } - }) - }) - .mockResolvedValueOnce({ stdout: '[]' }) - - const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1') - - expect(getWorkItemMock).toHaveBeenCalledWith( - '/home/tester/widgets', - 923, - 'issue', - 'ssh-test-1', - {}, - undefined - ) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( - '/home/tester/widgets', - 'upstream', - 'ssh-test-1', - {} - ) - expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ host: 'github.com' }) - expect(details?.body).toBe('Remote issue body') - }) - - it('routes local WSL PR detail fan-out through the selected distro', async () => { - const localGitOptions = { wslDistro: 'Ubuntu' } - getWorkItemMock.mockResolvedValueOnce({ - id: 'pr:42', - type: 'pr', - number: 42, - title: 'Review drawer WSL', - state: 'open', - url: 'https://github.com/acme/widgets/pull/42', - labels: [], - updatedAt: '2026-04-01T00:00:00Z', - author: 'pr-author' - }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - getPRCommentsMock.mockResolvedValue([]) - getPRChecksMock.mockResolvedValue([]) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const target = args.at(-1) - if (target === 'repos/acme/widgets/pulls/42') { - return { - stdout: JSON.stringify({ - body: 'PR body', - head: { sha: 'head-sha' }, - base: { sha: 'base-sha' } - }) - } - } - if (target === 'repos/acme/widgets/pulls/42/files?per_page=100') { - return { stdout: '[]' } - } - const query = args.find((arg) => arg.startsWith('query=')) ?? '' - if (query.includes('viewerViewedState')) { - return { - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - id: 'PR_kwDO123', - files: { pageInfo: { hasNextPage: false }, nodes: [] } - } - } - } - }) - } - } - if (query.includes('participants(first: 100)')) { - return { - stdout: JSON.stringify({ - data: { repository: { pullRequest: { participants: { nodes: [] } } } } - }) - } - } - return { stdout: JSON.stringify({ data: {} }) } - }) - - const details = await getWorkItemDetails('/repo-root', 42, 'pr', null, localGitOptions) - - expect(details?.body).toBe('PR body') - expect(getWorkItemMock).toHaveBeenCalledWith( - '/repo-root', - 42, - 'pr', - null, - localGitOptions, - undefined - ) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( - '/repo-root', - 'origin', - null, - localGitOptions - ) - expect(getPRCommentsMock).toHaveBeenCalledWith( - '/repo-root', - 42, - { prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } }, - null, - localGitOptions - ) - expect(getPRChecksMock).toHaveBeenCalledWith( - '/repo-root', - 42, - 'head-sha', - { owner: 'acme', repo: 'widgets', host: 'github.com' }, - undefined, - null, - localGitOptions - ) - expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( - true - ) - }) - - it('keeps an ordinary open with an issue repository cached and lenient', async () => { - const url = 'https://github.com/upstream-owner/widgets/issues/5' - getWorkItemByOwnerRepoMock.mockResolvedValue({ type: 'issue', number: 5, title: 'Up', url }) - const comments = { nodes: [], pageInfo: { hasNextPage: false } } - const issue = JSON.stringify({ - data: { repository: { issue: { body: 'upstream', comments } } } - }) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args.join().includes('/timeline?')) { - throw new Error('timeline 502') - } - return { stdout: issue } - }) - // No canonical flag: the open stays cached and survives a failed timeline read. - const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, undefined, { - owner: 'upstream-owner', - repo: 'widgets', - host: 'github.com' - }) - expect(details?.body).toBe('upstream') - const timeline = ghExecFileAsyncMock.mock.calls.find(([args]) => - args.join().includes('/timeline?') - ) - expect(timeline?.[0]).toContain('--cache') - }) - // Number-based detail lookups must retain the list's source, avoiding same-number fork/upstream collisions. - it('forwards the explicit origin source preference to the work item lookup', async () => { - getWorkItemMock.mockResolvedValueOnce(null) - - await expect(getWorkItemDetails('/repo-root', 42, 'pr', null, {}, 'origin')).resolves.toBeNull() - - expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, {}, 'origin') - }) - - it('reuses participant profiles and enriches only missing or incomplete Enterprise users', async () => { - getWorkItemMock.mockResolvedValueOnce({ - id: 'pr:1102', - type: 'pr', - number: 1102, - title: 'Enterprise PR', - state: 'open', - url: 'https://ghe.example.com/acme/widgets/pull/1102', - labels: [], - updatedAt: '2026-07-11T00:00:00Z', - author: 'seah', - reviewRequests: [{ login: 'ludi', name: null, avatarUrl: '' }], - latestReviews: [{ login: 'inho', state: 'APPROVED', avatarUrl: '' }], - assignees: [{ login: 'seah', name: 'Seah', avatarUrl: 'https://avatars.example.com/u/0?v=4' }] - }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - getPRCommentsMock.mockResolvedValue([]) - getPRChecksMock.mockResolvedValue([]) - const avatars: Record = { - seah: 'https://avatars.example.com/u/1?v=4', - ludi: 'https://avatars.example.com/u/2?v=4', - inho: 'https://avatars.example.com/u/3?v=4' - } - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const target = args.at(-1) - if (target === 'repos/acme/widgets/pulls/1102') { - return { - stdout: JSON.stringify({ body: 'PR body', head: { sha: 'h' }, base: { sha: 'b' } }) - } - } - if (target === 'repos/acme/widgets/pulls/1102/files?per_page=100') { - return { stdout: '[]' } - } - const query = args.find((arg) => arg.startsWith('query=')) ?? '' - if (query.includes('participants(first: 100)')) { - return { - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - participants: { - nodes: [ - { login: 'seah', name: null, avatarUrl: avatars.seah }, - { login: 'ludi', name: 'Ludi', avatarUrl: '' } - ] - } - } - } - } - }) - } - } - if (query.includes('user(login:')) { - const data: Record = {} - let index = 0 - for (const login of Object.keys(avatars)) { - if (query.includes(`user(login: "${login}")`)) { - data[`u${index}`] = { login, name: null, avatarUrl: avatars[login] } - index += 1 - } - } - return { stdout: JSON.stringify({ data }) } - } - return { stdout: JSON.stringify({ data: {} }) } - }) - - const details = await getWorkItemDetails('/repo-root', 1102, 'pr') - - expect(details?.item.authorAvatarUrl).toBe(avatars.seah) - expect(details?.item.reviewRequests?.[0]?.avatarUrl).toBe(avatars.ludi) - expect(details?.item.latestReviews?.[0]?.avatarUrl).toBe(avatars.inho) - expect(details?.item.assignees?.[0]?.avatarUrl).toBe(avatars.seah) - const userQuery = ghExecFileAsyncMock.mock.calls - .flatMap(([args]) => args) - .find((arg: string) => arg.includes('user(login:')) - expect(userQuery).not.toContain('user(login: "seah")') - expect(userQuery).toContain('user(login: "ludi")') - expect(userQuery).toContain('user(login: "inho")') - expect(details?.participants).toContainEqual({ - login: 'ludi', - name: 'Ludi', - avatarUrl: avatars.ludi - }) - }) - - it('skips profile reads when participants already cover visible users regardless of login case', async () => { - const participants = [ - { login: 'alice', name: null, avatarUrl: 'https://ghe.example.com/avatar/1' }, - { login: 'bob', name: 'Bob', avatarUrl: 'https://ghe.example.com/avatar/2' } - ] - const result = await getMentionParticipants( - '/repo-root', - { - number: 42, - type: 'pr', - author: 'Alice', - reviewRequests: [{ login: 'BOB', name: null, avatarUrl: '' }], - latestReviews: [{ login: 'Bob', state: 'APPROVED', avatarUrl: null }], - assignees: [{ login: 'alice', name: null, avatarUrl: '' }] - }, - [], - participants, - { owner: 'acme', repo: 'widgets', host: 'ghe.example.com' } - ) - expect(result).toEqual(participants) - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/github/work-item-pr-cursor-page.test.ts b/src/main/github/work-item-pr-cursor-page.test.ts index 4f8070926b1..c838b2121c0 100644 --- a/src/main/github/work-item-pr-cursor-page.test.ts +++ b/src/main/github/work-item-pr-cursor-page.test.ts @@ -1,10 +1,7 @@ -import { expect, it, vi } from 'vitest' -import { api, capture, sourceContext } from './work-item-search-test-harness' +import { expect, it } from 'vitest' +import { api } from './work-item-search-test-harness' import { parseTaskQuery } from '../../shared/task-query' import { listPullRequestWorkItemPage } from './client/list/work-item-pr-page' -import { listWorkItems } from './client/list/list-work-items' -import { searchWorkItemPage } from './client/list/work-item-search-page' -import { requestWorkItemSearch } from './client/list/work-item-search-batch' const ownerRepo = { owner: 'fixture', repo: 'repo', host: 'github.com' } function rows() { @@ -60,223 +57,3 @@ it('reads only the next PR page while browsing ten numbered pages', async () => expect(api.graphqlFields).toBe(10) expect(api.calls.some(({ args }) => args[0] === 'pr')).toBe(false) }) - -it('keeps content uncached and preserves rich rows through the production listing', async () => { - api.specialNodes = rows() - const first = await listWorkItems('fixture/repo', 24, 'is:pr is:open') - expect(first.items[0]).toMatchObject({ - title: 'PR 0', - labels: ['bug'], - branchName: 'branch-0', - baseRefName: 'main', - headSha: 'head-0', - isCrossRepository: true, - author: 'author', - reviewRequests: [{ login: 'reviewer', name: 'Reviewer' }] - }) - api.specialNodes[0].title = 'New title' - expect((await page(1))[0].title).toBe('New title') - expect( - api.calls.filter(({ args }) => args.some((arg) => arg.includes('r0: search('))) - ).toHaveLength(2) -}) - -it('starts without saved cursors on a manual refresh of a later page', async () => { - api.specialNodes = rows() - await page(1) - await page(2) - const before = api.graphqlCalls - expect((await page(2, true))[0].number).toBe(9976) - expect(api.graphqlCalls - before).toBe(1) - expect(api.calls.at(-1)?.args.join(' ')).toContain('first: 48') - expect(api.calls.at(-1)?.args.join(' ')).not.toContain('after:') -}) - -it.each(['labels', 'reviewRequests', 'unavailable'])( - 'retains the rich CLI fallback for %s', - async (reason) => { - api.specialNodes = rows() - if (reason !== 'unavailable') { - api.specialNodes[0][reason] = { nodes: [], pageInfo: { hasNextPage: true } } - } - capture.mockImplementation(async (binary, args, options) => { - if (reason === 'unavailable' && args.includes('graphql')) { - throw Object.assign(new Error('GraphQL unavailable'), { stderr: 'HTTP 500: unavailable' }) - } - return args[0] === 'pr' - ? { - stdout: JSON.stringify([ - { - number: 7, - title: 'Fallback', - state: 'OPEN', - labels: [{ name: 'all labels' }], - reviewRequests: [{ login: 'fallback-reviewer' }] - } - ]), - stderr: '' - } - : api.capture(binary, args, options) - }) - expect((await page(1))[0]).toMatchObject({ - title: 'Fallback', - labels: ['all labels'], - reviewRequests: [{ login: 'fallback-reviewer' }] - }) - expect( - capture.mock.calls.some( - ([, args]) => args.includes('--search') && args.includes('is:open sort:created-desc') - ) - ).toBe(true) - } -) - -it('retains the Enterprise CLI route', async () => { - sourceContext.host = 'github.example.com' - await listWorkItems('fixture/repo', 24, 'is:pr') - expect(api.graphqlCalls).toBe(0) - expect(api.calls[0].args).toContain('--json') -}) - -it('does not share PR cursors across credentials', async () => { - api.specialNodes = rows() - await page(1) - vi.stubEnv('GH_TOKEN', 'fixture-new') - const before = api.graphqlCalls - expect((await page(2))[0].number).toBe(9976) - expect(api.graphqlCalls - before).toBe(1) - expect(api.calls.at(-1)?.args.join(' ')).toContain('first: 48') - expect(api.calls.at(-1)?.args.join(' ')).not.toContain('after:') -}) - -it('routes sequential production PR pages through cursor reads', async () => { - api.specialNodes = rows() - const numbers: number[] = [] - capture.mockImplementation(async (binary, args, options) => { - if (args[0] === 'pr' && args[1] === 'list') { - const limit = Number(args[args.indexOf('--limit') + 1]) - return { - stdout: JSON.stringify( - rows() - .slice(0, limit) - .map((row) => ({ - ...row, - labels: row.labels.nodes, - reviewRequests: row.reviewRequests.nodes - })) - ), - stderr: '' - } - } - return api.capture(binary, args, options) - }) - for (let number = 1; number <= 10; number++) { - numbers.push( - ...(await listWorkItems('fixture/repo', 24, 'is:pr is:open', number)).items.map( - (item) => item.number - ) - ) - } - expect(numbers).toEqual(Array.from({ length: 240 }, (_, index) => 10000 - index)) - expect( - capture.mock.calls.filter(([, args]) => args[0] === 'pr' && args[1] === 'list') - ).toHaveLength(0) - expect( - capture.mock.calls.filter(([, args]) => args.some((arg) => arg.includes('r0: search('))) - ).toHaveLength(10) -}) - -it('keeps Boolean search operators inside the repository and PR constraints', async () => { - api.specialNodes = rows() - api.expectedSearch = - '( is:open title:foo OR title:bar sort:created-desc ) repo:fixture/repo type:pr' - expect( - await listPullRequestWorkItemPage({ - ownerRepo, - query: parseTaskQuery('is:pr is:open title:foo OR title:bar'), - limit: 24, - page: 1, - options: {} - }) - ).toHaveLength(24) - expect(api.calls.some(({ args }) => args[0] === 'pr')).toBe(false) -}) - -it('combines a cold short prefix with the requested PR page', async () => { - api.specialNodes = rows() - expect((await page(2))[0].number).toBe(9976) - expect(api.graphqlCalls).toBe(1) - expect(api.calls[0].args.join(' ')).toContain('first: 48') -}) - -it('uses advanced search for the grouped PR query and preserves CLI bot author display', async () => { - api.specialNodes = rows() - api.specialNodes[0].author = { - __typename: 'Bot', - login: 'dependabot', - avatarUrl: 'https://example.test/bot' - } - expect((await page(1))[0].author).toBe('app/dependabot') - expect(api.calls[0].args.join(' ')).toContain('search(type: ISSUE_ADVANCED,') -}) - -it('isolates search response coalescing, content and cursor identity by search type', async () => { - api.specialNodes = rows() - const args = { - search: 'repo:fixture/search-type-isolation is:open', - nodeSelection: '__typename ... on PullRequest { number }', - limit: 24, - page: 1, - options: {} - } - await Promise.all([ - searchWorkItemPage({ ...args, searchType: 'ISSUE' }), - searchWorkItemPage({ ...args, searchType: 'ISSUE_ADVANCED' }) - ]) - expect(api.graphqlFields).toBe(2) - await searchWorkItemPage({ ...args, searchType: 'ISSUE' }) - await searchWorkItemPage({ ...args, searchType: 'ISSUE_ADVANCED' }) - expect(api.graphqlFields).toBe(2) - - const cold = { ...args, search: 'repo:fixture/cursor-type-isolation is:open' } - await searchWorkItemPage({ ...cold, searchType: 'ISSUE' }) - expect(await searchWorkItemPage({ ...cold, page: 2, searchType: 'ISSUE_ADVANCED' })).toHaveLength( - 24 - ) - expect(api.calls.at(-1)?.args.join(' ')).toContain('first: 48') -}) - -it('does not attach legacy count readers to advanced pages or seed their cache', async () => { - api.specialNodes = rows() - const search = 'repo:fixture/count-type-isolation is:open' - const advancedPage = () => - searchWorkItemPage({ - search, - searchType: 'ISSUE_ADVANCED', - nodeSelection: '__typename ... on PullRequest { number }', - limit: 24, - page: 1, - options: {} - }) - const legacyCount = () => - requestWorkItemSearch({ search, first: 1, selection: 'issueCount', options: {} }) - await Promise.all([advancedPage(), legacyCount()]) - expect(api.graphqlFields).toBe(2) - - await searchWorkItemPage({ - search: `${search} label:only-advanced`, - searchType: 'ISSUE_ADVANCED', - nodeSelection: '__typename ... on PullRequest { number }', - limit: 24, - page: 1, - options: {} - }) - const before = api.graphqlFields - await requestWorkItemSearch({ - search: `${search} label:only-advanced`, - first: 1, - selection: 'issueCount', - options: {} - }) - expect(api.graphqlFields - before).toBe(1) -}) diff --git a/src/main/gitlab/client-mr-branch-lookup.test.ts b/src/main/gitlab/client-mr-branch-lookup.test.ts index 35dcba02571..7303ea89036 100644 --- a/src/main/gitlab/client-mr-branch-lookup.test.ts +++ b/src/main/gitlab/client-mr-branch-lookup.test.ts @@ -1,12 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' -import { derivePipelineStatus, mapMRInfo } from './mappers' -import { gitlabMrCheckCases } from '../../shared/source-control/checks/gitlab/gitlab-mr-checks.test-fixture' -import { mapGitLabReview } from '../source-control/forge-review-mappers' -import { - __resetHostedReviewBranchCacheForTests, - withHostedReviewBranchCache -} from '../source-control/hosted-review-branch-cache' +import type { mapMRInfo } from './mappers' +import { __resetHostedReviewBranchCacheForTests } from '../source-control/hosted-review-branch-cache' const { glabExecFileAsyncMock, @@ -48,12 +43,7 @@ vi.mock('./gl-utils', async () => { release: releaseMock } }) - -import { - getMergeRequest, - getMergeRequestForBranch, - getMergeRequestForBranchOrThrow -} from './client' +import { getMergeRequestForBranch, getMergeRequestForBranchOrThrow } from './client' import { resetGitLabMrMocks } from './client-mr-test-harness' type MergeRequestDetail = Parameters[0] @@ -83,156 +73,8 @@ describe('gitlab client — MR operations', () => { }) }) - it('getMergeRequestForBranchOrThrow surfaces a glab failure instead of null (finding 4)', async () => { - getProjectRefMock.mockResolvedValue({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockRejectedValue(new Error('glab: connection refused')) - - await expect(getMergeRequestForBranch('/repo', 'feature/x')).resolves.toBeNull() - // Strict lookup preserves unavailable transport as distinct from a confirmed missing review. - await expect(getMergeRequestForBranchOrThrow('/repo', 'feature/x')).rejects.toThrow( - /connection refused/ - ) - }) - - it.each(gitlabMrCheckCases)('maps $name to the shared consumer review', ({ raw, review }) => { - expect(mapGitLabReview(mapMRInfo(raw, derivePipelineStatus(raw.head_pipeline)))).toStrictEqual( - review - ) - }) - - describe('getMergeRequest', () => { - it('fetches the MR with rolled-up pipeline status', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 10, - title: 'Add feature', - state: 'opened', - web_url: 'https://gitlab.com/g/p/-/merge_requests/10', - updated_at: '2026-05-05T00:00:00Z', - sha: 'deadbeef', - head_pipeline: { status: 'success' }, - detailed_merge_status: 'mergeable' - }) - }) - const mr = await getMergeRequest('/repo', 10) - expect(mr).toMatchObject({ - number: 10, - title: 'Add feature', - state: 'opened', - url: 'https://gitlab.com/g/p/-/merge_requests/10', - pipelineStatus: 'success', - mergeable: 'MERGEABLE', - headSha: 'deadbeef' - }) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['api', 'projects/g%2Fp/merge_requests/10'], - { cwd: '/repo' } - ) - }) - - it('falls back to `glab mr view` when project ref is unresolved', async () => { - getProjectRefMock.mockResolvedValueOnce(null) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ iid: 5, title: 't', state: 'opened' }) - }) - await getMergeRequest('/repo', 5) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['mr', 'view', '5', '--output', 'json'], { - cwd: '/repo' - }) - }) - - it('returns null when glab errors', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not found')) - await expect(getMergeRequest('/repo', 99)).resolves.toBeNull() - }) - - it('treats neutral pipeline (no head_pipeline) as neutral status', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 1, - title: 't', - state: 'opened', - head_pipeline: null - }) - }) - const mr = await getMergeRequest('/repo', 1) - expect(mr?.pipelineStatus).toBe('neutral') - }) - }) - describe('getMergeRequestForBranch', () => { - it('settles mergeable manual pipelines consistently for by-number, linked and discovered MRs', async () => { - getProjectRefMock.mockResolvedValue({ host: 'gitlab.com', path: 'g/p' }) - const detail = { - iid: 7, - title: 'Manual release', - state: 'opened', - head_pipeline: { status: 'manual' }, - detailed_merge_status: 'mergeable' - } - glabExecFileAsyncMock.mockImplementation(async (args: string[]) => ({ - stdout: JSON.stringify(args[1].includes('source_branch=') ? [{ iid: 7 }] : detail) - })) - const reviews = await Promise.all([ - getMergeRequest('/repo', 7), - getMergeRequestForBranch('/repo', 'feature/manual', 7), - getMergeRequestForBranch('/repo', 'feature/manual') - ]) - expect(reviews.every((mr) => mr?.pipelineStatus === 'neutral')).toBe(true) - expect(reviews.every((mr) => mr?.mergeable === 'MERGEABLE')).toBe(true) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4) - }) - - it('finds the most recently updated MR for a branch across states', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - mockBranchLookup({ - iid: 7, - title: 'WIP', - state: 'merged', - sha: 'abc', - head_pipeline: { status: 'success' } - }) - - const mr = await getMergeRequestForBranch('/repo', 'feature/foo') - expect(mr?.number).toBe(7) - expect(mr?.state).toBe('merged') - expect(mr?.pipelineStatus).toBe('success') - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - 'projects/g%2Fp/merge_requests?source_branch=feature%2Ffoo&order_by=updated_at&sort=desc&per_page=1&with_merge_status_recheck=true' - ], - { cwd: '/repo' } - ) - }) - // List endpoints require explicit asynchronous recheck before merge status can converge. - it('asks GitLab to recheck merge status on the branch lookup', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await getMergeRequestForBranch('/repo', 'feature/recheck') - const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[] - expect(callArgs[1]).toContain('with_merge_status_recheck=true') - }) - - it('uses legacy pipeline payloads when branch MR lists omit head_pipeline', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - mockBranchLookup({ - iid: 8, - title: 'Legacy pipeline branch', - state: 'opened', - sha: 'def', - pipeline: { status: 'failed' } - }) - - const mr = await getMergeRequestForBranch('/repo', 'feature/legacy-pipeline') - expect(mr?.number).toBe(8) - expect(mr?.pipelineStatus).toBe('failure') - }) it.each([{}, { head_pipeline: null }, { head_pipeline: { status: 'success' } }])( 'reads the failed head pipeline from detail instead of the branch list: %j', @@ -283,174 +125,6 @@ describe('gitlab client — MR operations', () => { expect(releaseMock).toHaveBeenCalledTimes(3) }) - it.each([10, 100])( - 'caches list and detail together while polling %i branch rows', - async (rows) => { - vi.useFakeTimers() - getProjectRefMock.mockResolvedValue({ host: 'gitlab.com', path: 'g/p' }) - const detail = { - iid: 7, - title: 'CI failure', - state: 'opened', - head_pipeline: { status: 'failed' } - } - glabExecFileAsyncMock.mockImplementation(async (args: string[]) => ({ - stdout: JSON.stringify(args[1].includes('source_branch=') ? [{ iid: 7 }] : detail) - })) - const poll = () => - Promise.all( - Array.from({ length: rows }, (_, row) => { - const branch = `feature/${row}` - return withHostedReviewBranchCache( - { repoPath: '/repo', executionHostId: 'local', branch }, - { headOid: null }, - async () => { - const mr = await getMergeRequestForBranchOrThrow('/repo', branch) - return mr ? mapGitLabReview(mr) : null - } - ) - }) - ) - const results = await Promise.all([poll(), poll()]) - expect(results[0]).toHaveLength(rows) - expect(results[0].every((review) => review?.status === 'failure')).toBe(true) - await vi.advanceTimersByTimeAsync(59_999) - await poll() - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2 * rows) - await vi.advanceTimersByTimeAsync(1) - await poll() - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4 * rows) - } - ) - - it('strips refs/heads/ prefix from the branch arg', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await getMergeRequestForBranch('/repo', 'refs/heads/feature/bar') - const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[] - expect(callArgs[1]).toContain('source_branch=feature%2Fbar') - }) - - it('returns null when no MR matches the branch', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - await expect(getMergeRequestForBranch('/repo', 'feature')).resolves.toBeNull() - }) - - it('resolves a linked MR by iid without querying the branch', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 9, - title: 'Linked MR', - state: 'opened', - pipeline: { status: 'success' } - }) - }) - - const mr = await getMergeRequestForBranch('/repo', 'local-review-branch', 9) - expect(mr?.number).toBe(9) - expect(mr?.pipelineStatus).toBe('success') - expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['api', 'projects/g%2Fp/merge_requests/9?with_merge_status_recheck=true'], - { cwd: '/repo' } - ) - }) - - it('uses the explicitly linked MR when the branch still matches a different MR', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 2, - title: 'Replacement linked MR', - state: 'opened', - sha: 'head-2', - head_pipeline: { status: 'pending' } - }) - }) - - const mr = await getMergeRequestForBranch('/repo', 'qa/real-mr', 2) - - expect(mr).toMatchObject({ - number: 2, - title: 'Replacement linked MR', - pipelineStatus: 'pending' - }) - expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['api', 'projects/g%2Fp/merge_requests/2?with_merge_status_recheck=true'], - { cwd: '/repo' } - ) - }) - - it('uses one exact lookup when the linked MR also matches the branch', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 2, - title: 'Already selected MR', - state: 'opened', - sha: 'head-2', - head_pipeline: { status: 'success' } - }) - }) - - await expect( - getMergeRequestForBranch('/repo', 'qa/replacement-mr', 2) - ).resolves.toMatchObject({ number: 2 }) - expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['api', 'projects/g%2Fp/merge_requests/2?with_merge_status_recheck=true'], - { cwd: '/repo' } - ) - }) - - it('preserves merged state when resolving a linked MR iid', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 10, - title: 'Merged linked MR', - state: 'merged', - pipeline: { status: 'success' } - }) - }) - - const mr = await getMergeRequestForBranch('/repo', 'local-review-branch', 10) - - expect(mr).toMatchObject({ - number: 10, - state: 'merged', - pipelineStatus: 'success' - }) - }) - - it('routes local WSL merge-request branch lookup through the selected distro', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - mockBranchLookup({ - iid: 12, - title: 'WSL branch', - state: 'opened', - sha: 'abc', - head_pipeline: { status: 'success' } - }) - - const mr = await getMergeRequestForBranch('/repo', 'feature/wsl', null, null, { - localGitExecOptions: { wslDistro: 'Ubuntu' } - }) - - expect(mr?.number).toBe(12) - expect(getProjectRefMock).toHaveBeenCalledWith('/repo', ['gitlab.com'], null, { - wslDistro: 'Ubuntu' - }) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith(expect.any(Array), { - cwd: '/repo', - wslDistro: 'Ubuntu' - }) - }) - it('hides a stale closed MR whose source branch is the repo default branch (#9171)', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) mockBranchLookup({ @@ -476,86 +150,5 @@ describe('gitlab client — MR operations', () => { await expect(getMergeRequestForBranch('/repo', 'main')).resolves.toBeNull() }) - - it('keeps an open MR whose source branch is the repo default branch', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - mockBranchLookup({ - iid: 8, - title: 'main → release', - state: 'opened', - sha: 'abc', - head_pipeline: { status: 'success' } - }) - - const mr = await getMergeRequestForBranch('/repo', 'main') - expect(mr?.number).toBe(8) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('keeps a closed MR on a feature branch (behavior preserved)', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - mockBranchLookup({ - iid: 9, - title: 'Rejected work', - state: 'closed', - sha: 'def', - head_pipeline: { status: 'failed' } - }) - - const mr = await getMergeRequestForBranch('/repo', 'feature/rejected') - expect(mr?.number).toBe(9) - expect(mr?.state).toBe('closed') - }) - - it('resolves a linked default-branch MR without consulting a branch shadow (#9171)', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 42, - title: 'Linked MR', - state: 'merged', - pipeline: { status: 'success' } - }) - }) - - const mr = await getMergeRequestForBranch('/repo', 'main', 42) - - expect(mr).toMatchObject({ number: 42, state: 'merged' }) - expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['api', 'projects/g%2Fp/merge_requests/42?with_merge_status_recheck=true'], - { cwd: '/repo' } - ) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('keeps a linked non-open MR on the default branch', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 7, - title: 'Linked trunk MR', - state: 'merged', - sha: 'abc', - head_pipeline: { status: 'success' } - }) - }) - - const mr = await getMergeRequestForBranch('/repo', 'main', 7) - - expect(mr).toMatchObject({ number: 7, state: 'merged' }) - expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() - }) - - it('returns null for an empty / detached-HEAD branch arg', async () => { - await expect(getMergeRequestForBranch('/repo', '')).resolves.toBeNull() - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('returns null when project ref cannot be resolved', async () => { - getProjectRefMock.mockResolvedValueOnce(null) - await expect(getMergeRequestForBranch('/repo', 'feature')).resolves.toBeNull() - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) }) }) diff --git a/src/main/gitlab/client-mr-listing.test.ts b/src/main/gitlab/client-mr-listing.test.ts index 4b46cdc6dc3..95da44eb7ab 100644 --- a/src/main/gitlab/client-mr-listing.test.ts +++ b/src/main/gitlab/client-mr-listing.test.ts @@ -41,8 +41,7 @@ vi.mock('./gl-utils', async () => { release: releaseMock } }) - -import { listMergeRequests, listWorkItems } from './client' +import { listMergeRequests } from './client' import { resetGitLabMrMocks } from './client-mr-test-harness' describe('gitlab client — MR operations', () => { @@ -67,286 +66,6 @@ describe('gitlab client — MR operations', () => { })) }) - it('returns MRs via the GitLab API', async () => { - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify([ - { - id: 100, - iid: 1, - title: 'first', - state: 'opened', - web_url: 'https://gitlab.com/g/p/-/merge_requests/1', - updated_at: '2026-05-05', - source_branch: 'feat-1', - target_branch: 'main', - author: { username: 'alice' }, - source_project_id: 5, - target_project_id: 5 - } - ]), - headers: { 'x-total': '1', 'x-total-pages': '1' } - }) - - const result = await listMergeRequests('/repo', 'opened', 1, 20) - expect(result.items).toHaveLength(1) - expect(result.items[0]).toMatchObject({ - type: 'mr', - number: 1, - title: 'first', - state: 'opened', - branchName: 'feat-1', - baseRefName: 'main', - author: 'alice', - isCrossRepository: false, - repoId: 'g/p' - }) - expect(glabApiWithHeadersMock).toHaveBeenCalledWith( - [ - 'projects/g%2Fp/merge_requests?page=1&per_page=20&order_by=updated_at&sort=desc&with_merge_status_recheck=false&state=opened' - ], - { cwd: '/repo' } - ) - }) - - it('routes local WSL MR listing through project resolution and glab API options', async () => { - const localGitOptions = { wslDistro: 'Ubuntu' } - glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) - - await listMergeRequests('/repo', 'opened', 1, 20, undefined, undefined, null, localGitOptions) - - expect(resolveIssueSourceMock).toHaveBeenCalledWith( - '/repo', - undefined, - ['gitlab.com'], - null, - localGitOptions - ) - expect(glabApiWithHeadersMock).toHaveBeenCalledWith( - [ - 'projects/g%2Fp/merge_requests?page=1&per_page=20&order_by=updated_at&sort=desc&with_merge_status_recheck=false&state=opened' - ], - { cwd: '/repo', wslDistro: 'Ubuntu' } - ) - }) - - it('routes local WSL combined work-item listing through MR and issue glab options', async () => { - const localGitOptions = { wslDistro: 'Ubuntu' } - glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo', 'opened', 1, 20, undefined, undefined, null, localGitOptions) - - expect(resolveIssueSourceMock).toHaveBeenCalledWith( - '/repo', - undefined, - ['gitlab.com'], - null, - localGitOptions - ) - expect(glabApiWithHeadersMock).toHaveBeenCalledWith( - expect.any(Array), - expect.objectContaining({ cwd: '/repo', wslDistro: 'Ubuntu' }) - ) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - expect.any(Array), - expect.objectContaining({ cwd: '/repo', wslDistro: 'Ubuntu' }) - ) - }) - - it("omits state when state='all'", async () => { - glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) - - await listMergeRequests('/repo', 'all', 1, 20) - const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[] - expect(callArgs[0]).not.toContain('state=') - }) - - it('passes through Open / Merged / Closed states as API params', async () => { - for (const state of ['opened', 'merged', 'closed'] as const) { - glabApiWithHeadersMock.mockReset() - glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) - await listMergeRequests('/repo', state, 1, 20) - const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[] - expect(callArgs[0]).toContain(`state=${state}`) - } - }) - - it('appends an encoded &search= param when a query is supplied', async () => { - glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) - await listMergeRequests('/repo', 'opened', 1, 20, undefined, 'fix login') - const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[] - expect(callArgs[0]).toContain('&search=fix%20login') - }) - - it('omits &search= for an empty or whitespace-only query', async () => { - glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) - await listMergeRequests('/repo', 'opened', 1, 20, undefined, ' ') - const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[] - expect(callArgs[0]).not.toContain('search=') - }) - - it('flags fork MRs as cross-repository', async () => { - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify([ - { - id: 200, - iid: 2, - title: 'fork mr', - state: 'opened', - source_branch: 'feat', - target_branch: 'main', - source_project_id: 11, - target_project_id: 5 - } - ]), - headers: {} - }) - - const result = await listMergeRequests('/repo', 'opened', 1, 20) - expect(result.items[0].isCrossRepository).toBe(true) - }) - - it('probes one more page when GitLab sends no pagination totals', async () => { - const mr = (iid: number) => ({ - id: iid, - iid, - title: `mr ${iid}`, - state: 'opened', - source_branch: `b${iid}`, - target_branch: 'main' - }) - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify([mr(1), mr(2)]), - headers: {} - }) - expect(await listMergeRequests('/repo', 'all', 3, 2)).toMatchObject({ - totalPages: 4, - totalCount: 0 - }) - - glabApiWithHeadersMock.mockResolvedValueOnce({ body: JSON.stringify([mr(3)]), headers: {} }) - expect((await listMergeRequests('/repo', 'all', 4, 2)).totalPages).toBe(4) - - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify([mr(1), mr(2)]), - headers: { 'x-total': '7' } - }) - expect(await listMergeRequests('/repo', 'all', 1, 2)).toMatchObject({ - totalPages: 4, - totalCount: 7 - }) - - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify([mr(1), mr(2)]), - headers: {} - }) - expect((await listMergeRequests('/repo', 'all', 1, 2)).totalPages).toBe(2) - - glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) - expect((await listMergeRequests('/repo', 'all', 2, 2)).totalPages).toBe(2) - - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify([mr(1), mr(2)]), - headers: { 'x-total-pages': '5', 'x-total': '7' } - }) - expect((await listMergeRequests('/repo', 'all', 1, 2)).totalPages).toBe(5) - }) - - it('retains MR inference when a known-other remote has an unresolved companion', async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false }) - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: - 'origin\tgit@github.com:team/project.git (fetch)\nupstream\t/local/project.git (fetch)\n' - }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - const result = await listMergeRequests('/repo') - - expect(result.error).toBeUndefined() - expect(glabExecFileAsyncMock).toHaveBeenCalledWith(expect.arrayContaining(['mr', 'list']), { - cwd: '/repo' - }) - }) - - it('falls back to glab mr list when project ref is unresolved', async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: null, - fellBack: false - }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - id: 300, - iid: 3, - title: 'fallback mr', - state: 'opened', - web_url: 'https://gitlab.com/-/merge_requests/3', - updated_at: '2026-05-05', - source_project_id: 5, - target_project_id: 5 - } - ]) - }) - const result = await listMergeRequests('/repo', 'opened') - expect(result.items).toHaveLength(1) - expect(result.items[0].title).toBe('fallback mr') - expect(glabApiWithHeadersMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'mr', - 'list', - '--output', - 'json', - '--per-page', - '20', - '--page', - '1', - '--order', - 'updated_at', - '--sort', - 'desc' - ], - { cwd: '/repo' } - ) - }) - - it('threads --search into the cwd fallback when a query is supplied', async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: null, - fellBack: false - }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - await listMergeRequests('/repo', 'opened', 1, 20, undefined, 'fix login') - expect(glabApiWithHeadersMock).not.toHaveBeenCalled() - const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[] - const searchIdx = callArgs.indexOf('--search') - expect(searchIdx).toBeGreaterThanOrEqual(0) - expect(callArgs[searchIdx + 1]).toBe('fix login') - }) - - it('omits --search from the cwd fallback for a whitespace-only query', async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: null, - fellBack: false - }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - await listMergeRequests('/repo', 'opened', 1, 20, undefined, ' ') - const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[] - expect(callArgs).not.toContain('--search') - }) - - it('classifies fallback errors into the result envelope', async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: null, - fellBack: false - }) - glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) - const result = await listMergeRequests('/repo', 'opened') - expect(result.error?.type).toBe('permission_denied') - expect(result.items).toEqual([]) - expect(glabApiWithHeadersMock).not.toHaveBeenCalled() - }) - it('does not run the cwd fallback for unresolved SSH repos', async () => { resolveIssueSourceMock.mockResolvedValueOnce({ source: null, @@ -367,58 +86,8 @@ describe('gitlab client — MR operations', () => { expect(glabApiWithHeadersMock).not.toHaveBeenCalled() }) - it('classifies API errors into the result envelope', async () => { - glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) - const result = await listMergeRequests('/repo', 'opened') - expect(result.error?.type).toBe('permission_denied') - expect(result.items).toEqual([]) - }) - // A classifier keyword in a content title must not replace the opaque response diagnostic. - it('reports the body instead of ".map is not a function" when the API returns a non-array', async () => { - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify({ data: [{ iid: 7, title: 'fix network timeout' }] }), - headers: {} - }) - const result = await listMergeRequests('/repo', 'opened') - expect(result.items).toEqual([]) - expect(result.error?.type).toBe('unknown') - expect(result.error?.message).toContain('fix network timeout') - expect(result.error?.message).not.toContain('is not a function') - }) - - it('reports the body instead of ".map is not a function" when the cwd fallback returns a non-array', async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ data: [], total: 0 }) - }) - const result = await listMergeRequests('/repo', 'opened') - expect(result.items).toEqual([]) - expect(result.error?.message).toContain('{"data":[],"total":0}') - expect(result.error?.message).not.toContain('is not a function') - }) - - it('classifies a GitLab error envelope returned on exit 0', async () => { - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify({ message: '403 Forbidden' }), - headers: {} - }) - const result = await listMergeRequests('/repo', 'opened') - expect(result.items).toEqual([]) - expect(result.error?.type).toBe('permission_denied') - }) // Only the envelope message is diagnostic; sibling payload keywords must not change classification. - it('classifies an error envelope by its message, not its sibling payload', async () => { - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify({ - message: '404 Project Not Found', - data: [{ iid: 7, title: '403 forbidden in CI' }] - }), - headers: {} - }) - const result = await listMergeRequests('/repo', 'opened') - expect(result.error?.type).toBe('not_found') - }) }) }) diff --git a/src/main/gitlab/client-mr-review-actions.test.ts b/src/main/gitlab/client-mr-review-actions.test.ts index b5fea0d39bc..3a5b59f7989 100644 --- a/src/main/gitlab/client-mr-review-actions.test.ts +++ b/src/main/gitlab/client-mr-review-actions.test.ts @@ -41,42 +41,13 @@ vi.mock('./gl-utils', async () => { release: releaseMock } }) - -import { - addMRComment, - getJobTrace, - addMRInlineComment, - closeMR, - mergeMR, - reopenMR, - resolveMRDiscussion, - retryJob, - updateMR, - updateMRReviewers -} from './client' +import { closeMR, mergeMR, reopenMR, updateMR } from './client' import { resetGitLabMrMocks } from './client-mr-test-harness' import type { HostedReviewInfo } from '../../shared/source-control/hosted-review/hosted-review-contracts' import { __resetHostedReviewBranchCacheForTests, withHostedReviewBranchCache } from '../source-control/hosted-review-branch-cache' -import { stripGitLabDraftTitlePrefix } from './merge-request-draft-title' - -describe('stripGitLabDraftTitlePrefix', () => { - it.each([ - ['Draft: Ship it', 'Ship it'], - ['wip: Ship it', 'Ship it'], - ['[Draft] Ship it', 'Ship it'], - ['(WIP)Ship it', 'Ship it'], - ['Draft - Ship it', 'Ship it'] - ])('strips a GitLab draft marker from %s', (title, expected) => { - expect(stripGitLabDraftTitlePrefix(title)).toBe(expected) - }) - - it('leaves markerless titles unchanged', () => { - expect(stripGitLabDraftTitlePrefix('Ship it')).toBeNull() - }) -}) describe('gitlab client — MR operations', () => { beforeEach(() => { @@ -92,438 +63,6 @@ describe('gitlab client — MR operations', () => { }) }) - it('routes local WSL MR review-management and job actions through project resolution and glab options', async () => { - const localGitOptions = { wslDistro: 'Ubuntu' } - glabExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '{}' }) - .mockResolvedValueOnce({ stdout: '{}' }) - .mockResolvedValueOnce({ stdout: '{}' }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - id: 1, - author: { username: 'alice', avatar_url: '', state: 'active' }, - body: 'Comment', - created_at: '2026-06-16T00:00:00.000Z' - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - id: 'discussion-1', - notes: [ - { - id: 2, - author: { username: 'alice', avatar_url: '', state: 'active' }, - body: 'Inline', - created_at: '2026-06-16T00:00:00.000Z', - position: { new_path: 'src/app.ts', new_line: 12 } - } - ] - }) - }) - .mockResolvedValueOnce({ stdout: '{}' }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - reviewers: [{ id: 1, username: 'alice', name: 'Alice', avatar_url: '', state: 'active' }] - }) - }) - .mockResolvedValueOnce({ stdout: 'trace output' }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - id: 100, - pipeline: { id: 50 }, - name: 'test', - stage: 'test', - status: 'pending', - web_url: 'https://gitlab.com/g/p/-/jobs/100', - duration: null - }) - }) - .mockResolvedValueOnce({ stdout: '{}' }) - - await closeMR('/repo', 12, undefined, null, undefined, localGitOptions) - await reopenMR('/repo', 12, undefined, null, undefined, localGitOptions) - await mergeMR('/repo', 12, 'squash', undefined, null, undefined, localGitOptions) - await addMRComment('/repo', 12, 'Comment', undefined, null, undefined, localGitOptions) - await addMRInlineComment( - '/repo', - 12, - { - body: 'Inline', - path: 'src/app.ts', - line: 12, - baseSha: 'base', - startSha: 'start', - headSha: 'head' - }, - undefined, - null, - undefined, - localGitOptions - ) - await resolveMRDiscussion( - '/repo', - 12, - 'discussion-1', - true, - undefined, - null, - undefined, - localGitOptions - ) - await updateMRReviewers('/repo', 12, [1], undefined, null, undefined, localGitOptions) - await getJobTrace('/repo', 99, undefined, null, undefined, localGitOptions) - await retryJob('/repo', 99, undefined, null, undefined, localGitOptions) - await updateMR('/repo', 12, { title: 'Renamed' }, undefined, null, undefined, localGitOptions) - - expect(resolveIssueSourceMock).toHaveBeenCalledWith( - '/repo', - undefined, - ['gitlab.com'], - null, - localGitOptions - ) - expect(glabExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( - true - ) - }) - - describe('updateMR', () => { - beforeEach(() => { - resolveIssueSourceMock.mockImplementation(async () => ({ - source: { host: 'git.internal', path: 'g/p' }, - fellBack: false - })) - }) - - it.each([ - { - owner: 'connection-backed host', - connectionId: 'conn-1', - localGitOptions: {}, - hostnameArgs: ['--hostname', 'git.internal'], - execOptions: {} - }, - { - owner: 'local WSL workspace', - connectionId: null, - localGitOptions: { wslDistro: 'Ubuntu' }, - hostnameArgs: [], - execOptions: { cwd: '/repo', wslDistro: 'Ubuntu' } - } - ])('routes semantic readiness through the $owner', async (route) => { - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { mergeRequestSetDraft: { mergeRequest: { iid: '12' }, errors: [] } } - }) - }) - - await expect( - updateMR( - '/repo', - 12, - { readyForReview: true }, - 'upstream', - route.connectionId, - undefined, - route.localGitOptions - ) - ).resolves.toEqual({ ok: true }) - - expect(resolveIssueSourceMock).toHaveBeenCalledWith( - '/repo', - 'upstream', - ['gitlab.com'], - route.connectionId, - route.localGitOptions - ) - expect(glabExecFileAsyncMock).toHaveBeenCalledExactlyOnceWith( - [ - 'api', - ...route.hostnameArgs, - 'graphql', - '-i', - '-f', - 'query=mutation { mergeRequestSetDraft(input: { projectPath: "g/p", iid: "12", draft: false }) { mergeRequest { iid } errors } }' - ], - route.execOptions - ) - }) - - it('updates title, body, and labels through the selected SSH GitLab host', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' }) - - await expect( - updateMR( - '/repo', - 12, - { - title: 'Renamed', - body: 'Updated body', - addLabels: ['bug'], - removeLabels: ['stale'] - }, - 'upstream', - 'conn-1' - ) - ).resolves.toEqual({ ok: true }) - - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--hostname', - 'git.internal', - '-X', - 'PUT', - 'projects/g%2Fp/merge_requests/12', - '-f', - 'title=Renamed', - '-f', - 'description=Updated body', - '-f', - 'add_labels=bug', - '-f', - 'remove_labels=stale' - ], - {} - ) - }) - }) - - describe('resolveMRDiscussion', () => { - beforeEach(() => { - resolveIssueSourceMock.mockImplementation(async () => ({ - source: { host: 'git.internal', path: 'g/p' }, - fellBack: false - })) - }) - - it('updates the discussion resolved state through the selected SSH GitLab host', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' }) - - await expect( - resolveMRDiscussion('/repo', 12, 'discussion-1', true, 'upstream', 'conn-1') - ).resolves.toEqual({ ok: true }) - - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--hostname', - 'git.internal', - '-X', - 'PUT', - 'projects/g%2Fp/merge_requests/12/discussions/discussion-1', - '-f', - 'resolved=true' - ], - {} - ) - }) - - it('rejects an empty discussion id without calling glab', async () => { - await expect(resolveMRDiscussion('/repo', 12, ' ', true)).resolves.toMatchObject({ - ok: false, - error: 'Discussion id is required' - }) - - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) - }) - - describe('addMRInlineComment', () => { - beforeEach(() => { - resolveIssueSourceMock.mockImplementation(async () => ({ - source: { host: 'git.internal', path: 'g/p' }, - fellBack: false - })) - }) - - it('posts an inline discussion with GitLab position fields', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - id: 'discussion-1', - notes: [ - { - id: 500, - author: { username: 'alice', avatar_url: 'https://example.com/a.png' }, - body: 'please fix', - created_at: '2026-05-05T10:00:00Z', - position: { new_path: 'src/app.ts', new_line: 12 } - } - ] - }) - }) - - await expect( - addMRInlineComment( - '/repo', - 12, - { - body: 'please fix', - path: 'src/app.ts', - line: 12, - baseSha: 'base', - startSha: 'start', - headSha: 'head' - }, - 'upstream', - 'conn-1' - ) - ).resolves.toMatchObject({ - ok: true, - comment: { - id: 500, - threadId: 'discussion-1', - path: 'src/app.ts', - line: 12 - } - }) - - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--hostname', - 'git.internal', - '-X', - 'POST', - 'projects/g%2Fp/merge_requests/12/discussions', - '-f', - 'body=please fix', - '-f', - 'position[position_type]=text', - '-f', - 'position[base_sha]=base', - '-f', - 'position[start_sha]=start', - '-f', - 'position[head_sha]=head', - '-f', - 'position[old_path]=src/app.ts', - '-f', - 'position[new_path]=src/app.ts', - '-f', - 'position[new_line]=12' - ], - {} - ) - }) - }) - - describe('updateMRReviewers', () => { - beforeEach(() => { - resolveIssueSourceMock.mockImplementation(async () => ({ - source: { host: 'git.internal', path: 'g/p' }, - fellBack: false - })) - }) - - it('sets reviewers through reviewer_ids on the selected SSH GitLab host', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - reviewers: [ - { - id: 1, - username: 'alice', - name: 'Alice', - avatar_url: 'https://example.com/a.png', - state: 'active' - } - ] - }) - }) - - await expect(updateMRReviewers('/repo', 12, [1], 'upstream', 'conn-1')).resolves.toEqual({ - ok: true, - reviewers: [ - { - id: 1, - username: 'alice', - name: 'Alice', - avatarUrl: 'https://example.com/a.png', - state: 'active' - } - ] - }) - - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--hostname', - 'git.internal', - '-X', - 'PUT', - 'projects/g%2Fp/merge_requests/12', - '-f', - 'reviewer_ids[]=1' - ], - {} - ) - }) - }) - // mr subcommands reject --hostname; route self-hosted instances through GITLAB_HOST. - describe('mr subcommands against a self-hosted SSH host', () => { - const projectRef = { host: 'gitlab.example.internal', path: 'g/p' } - - it('merges without --hostname and passes the host through GITLAB_HOST', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) - - await expect(mergeMR('/repo', 6, 'merge', undefined, 'conn-1', projectRef)).resolves.toEqual({ - ok: true - }) - - const [args, options] = glabExecFileAsyncMock.mock.calls[0] - expect(args).toEqual(['mr', 'merge', '6', '-R', 'g/p', '--yes']) - expect(options.env?.GITLAB_HOST).toBe('gitlab.example.internal') - }) - - it('keeps the squash flag while routing the host through the environment', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) - - await expect(mergeMR('/repo', 6, 'squash', undefined, 'conn-1', projectRef)).resolves.toEqual( - { ok: true } - ) - - const [args, options] = glabExecFileAsyncMock.mock.calls[0] - expect(args).toEqual(['mr', 'merge', '6', '-R', 'g/p', '--yes', '--squash']) - expect(options.env?.GITLAB_HOST).toBe('gitlab.example.internal') - }) - - it('closes without --hostname and passes the host through GITLAB_HOST', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) - - await expect(closeMR('/repo', 6, undefined, 'conn-1', projectRef)).resolves.toEqual({ - ok: true - }) - - const [args, options] = glabExecFileAsyncMock.mock.calls[0] - expect(args).toEqual(['mr', 'close', '6', '-R', 'g/p']) - expect(options.env?.GITLAB_HOST).toBe('gitlab.example.internal') - }) - - it('reopens without --hostname and passes the host through GITLAB_HOST', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) - - await expect(reopenMR('/repo', 6, undefined, 'conn-1', projectRef)).resolves.toEqual({ - ok: true - }) - - const [args, options] = glabExecFileAsyncMock.mock.calls[0] - expect(args).toEqual(['mr', 'reopen', '6', '-R', 'g/p']) - expect(options.env?.GITLAB_HOST).toBe('gitlab.example.internal') - }) - - it('leaves the environment untouched for a local workspace', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) - - await expect(mergeMR('/repo', 6, 'merge', undefined, null, projectRef)).resolves.toEqual({ - ok: true - }) - - const [args, options] = glabExecFileAsyncMock.mock.calls[0] - expect(args).toEqual(['mr', 'merge', '6', '-R', 'g/p', '--yes']) - expect(options.env).toBeUndefined() - }) - }) - describe('glab state-change refusals', () => { const projectRef = { host: 'gitlab.com', path: 'g/p' } const mutations = { close: closeMR, reopen: reopenMR } diff --git a/src/main/gitlab/gitlab-known-host-probe.test.ts b/src/main/gitlab/gitlab-known-host-probe.test.ts index 80b96cc9742..5cba896bab4 100644 --- a/src/main/gitlab/gitlab-known-host-probe.test.ts +++ b/src/main/gitlab/gitlab-known-host-probe.test.ts @@ -6,21 +6,15 @@ vi.mock('../git/command-runner/glab-exec-file', async (importOriginal) => ({ ...(await importOriginal>()), glabExecFileAsync: glabExecFileAsyncMock })) - import { _getKnownHostsCacheSize, _resetKnownHostsCache, getGlabKnownHosts, - KNOWN_HOSTS_CACHE_MAX_ENTRIES, rememberGlabKnownHost, - rememberGlabKnownHosts, KNOWN_HOSTS_FAILURE_INITIAL_BACKOFF_MS } from './gitlab-known-host-probe' describe('getGlabKnownHosts', () => { - // A locally-keyed probe must never answer from the default WSL distro. - const LOCAL_PROBE_OPTIONS = { timeout: 10_000, allowDefaultWslFallback: false } - afterEach(() => vi.restoreAllMocks()) beforeEach(() => { @@ -28,97 +22,6 @@ describe('getGlabKnownHosts', () => { _resetKnownHostsCache() }) - it('returns gitlab.com plus auth-status hosts, deduped', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: '✓ Logged in to gitlab.com as user\n✓ Logged in to gitlab.example.com as user\n', - stderr: '' - }) - - await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com']) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], LOCAL_PROBE_OPTIONS) - }) - - it('preserves WSL and background admission on the cold auth-status probe', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) - - await getGlabKnownHosts(undefined, { - wslDistro: 'Ubuntu', - admissionTier: 'background' - }) - - expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { - ...LOCAL_PROBE_OPTIONS, - wslDistro: 'Ubuntu', - admissionTier: 'background' - }) - }) - - it('falls back to default when glab auth status fails', async () => { - glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated')) - - await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com']) - }) - - it('caches the result across calls', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: '✓ Logged in to gitlab.com as user\n', - stderr: '' - }) - - await getGlabKnownHosts() - await getGlabKnownHosts() - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - - it('coalesces many simultaneous callers in one execution context', async () => { - let resolveProbe!: (value: { stdout: string; stderr: string }) => void - glabExecFileAsyncMock.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveProbe = resolve - }) - ) - - const probes = Array.from({ length: 64 }, () => getGlabKnownHosts()) - - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) - resolveProbe({ stdout: 'Logged in to gitlab.concurrent.test as user\n', stderr: '' }) - const results = await Promise.all(probes) - expect(results.every((result) => result === results[0])).toBe(true) - expect(results[0]).toEqual(['gitlab.com', 'gitlab.concurrent.test']) - }) - - it('keeps simultaneous native, WSL distro, and connection probes isolated', async () => { - glabExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'Logged in to ubuntu.test as user\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'Logged in to debian.test as user\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' }) - - const [ubuntu, ubuntuAgain, debian, native, ssh] = await Promise.all([ - getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }), - getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }), - getGlabKnownHosts(undefined, { wslDistro: 'Debian' }), - getGlabKnownHosts(), - getGlabKnownHosts('conn-1') - ]) - - expect(ubuntuAgain).toBe(ubuntu) - expect(ubuntu).toEqual(['gitlab.com', 'ubuntu.test']) - expect(debian).toEqual(['gitlab.com', 'debian.test']) - expect(native).toEqual(['gitlab.com', 'native.test']) - expect(ssh).toEqual(['gitlab.com', 'ssh.test']) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4) - expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['auth', 'status'], { - ...LOCAL_PROBE_OPTIONS, - wslDistro: 'Ubuntu' - }) - expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['auth', 'status'], { - ...LOCAL_PROBE_OPTIONS, - wslDistro: 'Debian' - }) - }) - it('preserves a native auth refresh while an older native probe is in flight', async () => { let resolveProbe!: (value: { stdout: string; stderr: string }) => void glabExecFileAsyncMock.mockImplementationOnce( @@ -153,112 +56,6 @@ describe('getGlabKnownHosts', () => { await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test']) }) - it('keeps a remembered native host out of WSL and SSH caches', async () => { - glabExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'Logged in to wsl.test as user\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' }) - - await Promise.all([ - getGlabKnownHosts(), - getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }), - getGlabKnownHosts('conn-1') - ]) - rememberGlabKnownHost('gitlab.refreshed.test') - - await expect(getGlabKnownHosts()).resolves.toEqual([ - 'gitlab.com', - 'native.test', - 'gitlab.refreshed.test' - ]) - await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([ - 'gitlab.com', - 'wsl.test' - ]) - await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com', 'ssh.test']) - }) - - it('batch-normalizes and deduplicates hosts in first-seen order per execution context', async () => { - rememberGlabKnownHosts([' Native-B.test ', 'native-a.test', 'NATIVE-B.TEST']) - rememberGlabKnownHosts(['WSL-B.test', ' wsl-a.test ', 'wsl-b.test'], undefined, { - wslDistro: 'Ubuntu' - }) - rememberGlabKnownHosts(['SSH-B.test', 'ssh-a.test', ' ssh-b.test '], 'conn-batch') - - await expect(getGlabKnownHosts()).resolves.toEqual([ - 'gitlab.com', - 'native-b.test', - 'native-a.test' - ]) - await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([ - 'gitlab.com', - 'wsl-b.test', - 'wsl-a.test' - ]) - await expect(getGlabKnownHosts('conn-batch')).resolves.toEqual([ - 'gitlab.com', - 'ssh-b.test', - 'ssh-a.test' - ]) - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('bounds execution-context cache keys across SSH reconnect churn', () => { - for (let index = 0; index < KNOWN_HOSTS_CACHE_MAX_ENTRIES + 20; index += 1) { - rememberGlabKnownHost(`host-${index}.example`, `connection-${index}`) - } - - expect(_getKnownHostsCacheSize()).toBe(KNOWN_HOSTS_CACHE_MAX_ENTRIES) - }) - - it('recognizes a self-hosted host on a non-default port', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: '✓ Logged in to gitlab.example.com:8080 as user\n', - stderr: '' - }) - - await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com:8080']) - }) - - it('caches per connection — the local probe does not satisfy a connection probe', async () => { - glabExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.com as user\n', stderr: '' }) - .mockResolvedValueOnce({ - stdout: '✓ Logged in to gitlab.example.com:8080 as user\n', - stderr: '' - }) - - await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com']) - await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([ - 'gitlab.com', - 'gitlab.example.com:8080' - ]) - await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([ - 'gitlab.com', - 'gitlab.example.com:8080' - ]) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('does not permanently cache the failure fallback — a later probe can re-discover hosts', async () => { - const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000) - glabExecFileAsyncMock - .mockRejectedValueOnce(new Error('ssh tunnel not ready')) - .mockResolvedValueOnce({ - stdout: '✓ Logged in to gitlab.example.com:8080 as user\n', - stderr: '' - }) - - // Failed probes only back off briefly so recovery can discover the actual host. - await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com']) - clock.mockReturnValue(1_000 + KNOWN_HOSTS_FAILURE_INITIAL_BACKOFF_MS) - await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([ - 'gitlab.com', - 'gitlab.example.com:8080' - ]) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - it('removes a timed-out probe from in-flight state so a later call retries', async () => { const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000) let rejectProbe!: (error: Error) => void diff --git a/src/main/gitlab/gitlab-partial-auth-recovery.test.ts b/src/main/gitlab/gitlab-partial-auth-recovery.test.ts index a6acd670466..1a9fa072b57 100644 --- a/src/main/gitlab/gitlab-partial-auth-recovery.test.ts +++ b/src/main/gitlab/gitlab-partial-auth-recovery.test.ts @@ -31,13 +31,9 @@ import { _resetProjectRefCache } from './gitlab-project-ref-resolution' import { _resetKnownHostsCache, getGlabKnownHosts, - getGlabPartialHosts, - rememberGlabKnownHosts, - parseGlabAuthStatusHosts, - KNOWN_HOSTS_CACHE_MAX_ENTRIES + getGlabPartialHosts } from './gitlab-known-host-probe' import { PROBE_COALESCE_STALE_MS } from '../git/remotes/coalesced-probe' -import { NEGATIVE_ENTRY_TTL_MS } from '../git/remotes/remote-ref-probe-cache' const completed = { code: 1, killed: false, signal: null, outputTruncated: false } const failure = (stderr = '', metadata = {}) => Object.assign(new Error('auth failed'), completed, { stdout: '', stderr }, metadata) @@ -81,33 +77,7 @@ beforeEach(() => { }) }) afterEach(() => vi.restoreAllMocks()) -it.each([false, true])( - 'preserves cached misses and current remote, late facts=%s', - async (late) => { - remoteUrl = 'https://beta.test/group/project.git' - expect((await fetchIssues()).error?.type).toBe('not_found') - vi.mocked(Date.now).mockReturnValue(15_999) - expect((await fetchIssues()).error?.type).toBe('not_found') - expect(counts()).toEqual([1, 1]) - vi.mocked(Date.now).mockReturnValue(16_000) - globalError = late ? failure(login('beta.test')) : undefined - const result = await fetchIssues() - if (!late) { - expect(result).toMatchObject({ items: [{ number: 7, title: 'Recovered' }], totalPages: 1 }) - expect(execute.mock.lastCall?.[0]).toContain( - 'projects/group%2Fproject/issues?page=1&per_page=20&order_by=updated_at&sort=desc&state=opened' - ) - } else { - expect(result.error?.type).toBe('not_found') - expect(urlReads()).toBe(1) - remoteUrl = 'https://changed.test/group/project.git' - vi.mocked(Date.now).mockReturnValue(1_000 + NEGATIVE_ENTRY_TTL_MS) - expect((await fetchIssues()).error?.type).toBe('not_found') - expect(urlReads()).toBe(2) - } - expect(counts()).toEqual(late ? [3, 2] : [2, 1]) - } -) + it('applies late partial facts only to the current unsigned remote', async () => { signature.mockResolvedValue(undefined) remoteUrl = 'https://beta.test/group/project.git' @@ -121,77 +91,7 @@ it('applies late partial facts only to the current unsigned remote', async () => expect(urlReads()).toBe(3) expect(counts()).toEqual([2, 2]) }) -it.each([100, 1_000])( - 'bounds global/project work and finite evidence at %i consumers/hosts', - async (n) => { - const corpus = `${Array.from({ length: n }, (_, i) => login(`host-${i}.test`)).join('')}${login('alpha.test:8443')}` - const output = - n === 100 - ? `${corpus}gitlab.com\n x gitlab.com: API call failed: 401\n` - : `${corpus}${login('gitlab.com')}failed.test\n x failed.test: API call failed: 401\n ✓ Token found in operating system keyring: ***\n ! Run glab auth login --hostname failed.test to move it into the operating system keyring.\n` - .replaceAll('✓', '\u001b[32m✓\u001b[0m') - .replaceAll('(config file)', '(keyring)') - .replaceAll('\n', '\r\n') - globalError = failure(output) - const burst = () => Promise.all(Array.from({ length: n }, fetchIssues)) - expect((await burst()).every((r) => r.items[0]?.number === 7)).toBe(true) - await burst() - expect(counts()).toEqual([1, 1]) - expect(getGlabPartialHosts()).not.toContain(n === 100 ? 'gitlab.com' : 'failed.test') - expect(scan.mock.calls.map(([bytes]) => bytes)).toEqual([ - output.length + 1, - 'auth failed'.length - ]) - vi.mocked(Date.now).mockReturnValue(16_000) - globalError = failure(login('incomplete.test'), { killed: true }) - await burst() - vi.mocked(Date.now).mockReturnValue(45_999) - await burst() - expect(counts()).toEqual([2, 1]) - expect(getGlabPartialHosts()).not.toContain('incomplete.test') - vi.mocked(Date.now).mockReturnValue(46_000) - globalError = failure() - await burst() - expect(counts()).toEqual([3, 1]) - expect(getGlabPartialHosts()).toContain('alpha.test:8443') - for (let i = 0; i < KNOWN_HOSTS_CACHE_MAX_ENTRIES; i++) { - await getGlabKnownHosts(`evict-${i}`) - } - expect(getGlabPartialHosts()).toEqual([]) - vi.mocked(Date.now).mockReturnValue(1_000 + NEGATIVE_ENTRY_TTL_MS - 1) - expect((await burst()).every((r) => r.items[0]?.number === 7)).toBe(true) - expect(counts()).toEqual([132, 1]) - hostError = failure('alpha.test:8443\n x alpha.test:8443: API call failed: 401\n') - vi.mocked(Date.now).mockReturnValue(1_000 + NEGATIVE_ENTRY_TTL_MS) - await burst() - expect(counts()).toEqual([132, 2]) - vi.mocked(Date.now).mockReturnValue(2_000_000) - await burst() - expect(counts()).toEqual([132, 2]) - expect(scan.mock.calls).toHaveLength(133) - const parseCalls = scan.mock.calls.length - const bytes = scan.mock.calls.reduce((sum, [bytes]) => sum + bytes, 0) - process.stdout.write(`${JSON.stringify({ n, probes: counts(), parseCalls, bytes })}\n`) - } -) -it.each([{ input: [] }, { input: ['gitlab.com', 'GITLAB.COM'] }, { input: ['alpha.test:8443'] }])( - 'preserves refresh/remember input additions: $input', - async ({ input }) => { - await getGlabKnownHosts() - rememberGlabKnownHosts(input) - const positive = input.includes('alpha.test:8443') - expect(await getGlabKnownHosts()).toEqual( - positive ? ['gitlab.com', 'alpha.test:8443'] : ['gitlab.com'] - ) - expect(counts()[0]).toBe(positive ? 1 : 2) - globalError = undefined - vi.mocked(Date.now).mockReturnValue(16_000) - expect(await getGlabKnownHosts()).toContain('alpha.test:8443') - rememberGlabKnownHosts([]) - await getGlabKnownHosts() - expect(counts()[0]).toBe(positive ? 1 : 3) - } -) + it.each([ { killed: true }, { signal: 'SIGTERM' }, @@ -242,18 +142,3 @@ it.each( await getGlabKnownHosts() expect(counts()[0]).toBe(3) }) -it('shares strict login recognition without losing configured headings', () => { - expect( - parseGlabAuthStatusHosts( - 'configured.test\n x API: You must be logged in to forged.test\n ✓ Logged in to empty.test as \n ✓ Logged in to source.test as (keyring)\n' - ) - ).toEqual(['configured.test']) -}) -it('keeps the ordinary host-specific success and its retry-stopping remember', async () => { - hostError = undefined - expect((await fetchIssues()).items[0]?.number).toBe(7) - expect(counts()).toEqual([1, 1]) - vi.mocked(Date.now).mockReturnValue(2_000_000) - expect((await fetchIssues()).items[0]?.number).toBe(7) - expect(counts()).toEqual([1, 1]) -}) diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 77790e95cdc..0dc0af56435 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -21,32 +21,17 @@ vi.mock('../git/command-runner/glab-exec-file', async (importOriginal) => ({ vi.mock('../github/local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) - import { _getProjectRefCacheSize, _resetProjectRefCache, - classifyGlabError, - classifyJobLogError, - classifyListFetchError, - classifyListIssuesError, acquire, release, GITLAB_ADMISSION_TIMEOUT_MS, getIssueProjectRef, - parseGlabJsonList, - parseGlabPaginationHeader, - isMissingJobLogError, - glabHostEnvOptions, getProjectRef, - getProjectRefForRemote, - parseGlabApiResponse, - parseGlabAuthStatusHosts, - resolveIssueSource + getProjectRefForRemote } from './gl-utils' -import { GlabNonListResponseError } from './glab-api-response' import { _resetRemoteNameListingCache } from '../git/remotes/remote-name-listing' -import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remotes/remote-url-probe' -import { NEGATIVE_ENTRY_TTL_MS } from '../git/remotes/remote-ref-probe-cache' function mockGitRemoteCommands(remotes: Record): void { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -71,12 +56,6 @@ function gitRemoteGetUrlCalls(remoteName: string): unknown[][] { ) } -function gitRemoteListCalls(): unknown[][] { - return gitExecFileAsyncMock.mock.calls.filter( - ([args]) => Array.isArray(args) && args[0] === 'remote' && args[1] !== 'get-url' - ) -} - describe('gitlab project ref resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() @@ -94,40 +73,6 @@ describe('gitlab project ref resolution', () => { vi.useRealTimers() }) - it('keeps getProjectRef origin-based', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'git@gitlab.com:fork/orca.git\n' - }) - - await expect(getProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'fork/orca' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo', - timeout: REMOTE_URL_PROBE_TIMEOUT_MS - }) - }) - - it.each([undefined, 'Ubuntu'])( - 'rechecks a warm GitLab identity after migration (%s)', - async (wslDistro) => { - const localGitOptions = wslDistro ? { wslDistro } : {} - mockGitRemoteCommands({ origin: 'git@gitlab.com:team/project.git\n' }) - await expect(getProjectRef('/repo', undefined, null, localGitOptions)).resolves.toEqual({ - host: 'gitlab.com', - path: 'team/project' - }) - - mockGitRemoteCommands({ origin: 'git@github.com:team/project.git\n' }) - if (!wslDistro) { - readLocalGitConfigSignatureMock.mockResolvedValue('sig-2') - } - - await expect(getProjectRef('/repo', undefined, null, localGitOptions)).resolves.toBeNull() - } - ) - it.each(['sig-2', undefined])( 'does not return a warm identity after freshness was lost (%s) and the probe timed out', async (signature) => { @@ -173,43 +118,6 @@ describe('gitlab project ref resolution', () => { } ) - it('returns upstream before a slow origin, retaining the late origin cache fill', async () => { - mockGitRemoteCommands({ - origin: 'git@gitlab.com:fork/orca.git\n', - upstream: 'git@gitlab.com:stablyai/orca.git\n' - }) - - const origin = Promise.withResolvers<{ stdout: string }>() - const serve = gitExecFileAsyncMock.getMockImplementation() - gitExecFileAsyncMock.mockImplementation((args: string[]) => - args[1] === 'get-url' && args[2] === 'origin' ? origin.promise : serve?.(args) - ) - await expect(getIssueProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'stablyai/orca' - }) - expect(gitRemoteGetUrlCalls('upstream')).toHaveLength(1) - origin.resolve({ stdout: 'git@gitlab.com:fork/orca.git' }) - expect((await getProjectRef('/repo'))?.path).toBe('fork/orca') - expect(gitRemoteGetUrlCalls('origin')).toHaveLength(1) - }) - - it('does not spawn git remote get-url upstream on an origin-only clone', async () => { - mockGitRemoteCommands({ origin: 'git@gitlab.com:fork/orca.git\n' }) - - await expect(getIssueProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'fork/orca' - }) - await expect(getIssueProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'fork/orca' - }) - expect(gitRemoteListCalls()).toHaveLength(1) - expect(gitRemoteGetUrlCalls('upstream')).toHaveLength(0) - expect(gitRemoteGetUrlCalls('origin')).toHaveLength(1) - }) - it('falls back on an upstream timeout and retries upstream on the next attempt', async () => { mockGitRemoteCommands({ origin: 'git@gitlab.com:fork/orca.git\n', @@ -230,197 +138,6 @@ describe('gitlab project ref resolution', () => { expect((await getIssueProjectRef('/repo'))?.path).toBe('team/recovered') expect(gitRemoteGetUrlCalls('upstream')).toHaveLength(2) }) - - it('finds the GitLab project on other remotes, preferring one named gitlab like glab', async () => { - mockGitRemoteCommands({ - origin: 'git@github.com:stablyai/orca.git\n', - 'a-fork': 'git@gitlab.com:someone/orca.git\n', - gitlab: 'git@gitlab.com:stablyai/orca.git\n' - }) - - await expect(getIssueProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'stablyai/orca' - }) - }) - - it('adds no glab probe for extra remotes on a GitHub-only repo', async () => { - mockGitRemoteCommands({ - origin: 'git@github.com:stablyai/orca.git\n', - fork: 'git@github.com:someone/orca.git\n' - }) - - await expect(getIssueProjectRef('/repo')).resolves.toBeNull() - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - - it('does not mix origin and upstream cache entries for the same repo path', async () => { - mockGitRemoteCommands({ - origin: 'git@gitlab.com:fork/orca.git\n', - upstream: 'git@gitlab.com:stablyai/orca.git\n' - }) - - await expect(getProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'fork/orca' - }) - await expect(getIssueProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'stablyai/orca' - }) - }) - - it('keeps local host and local WSL project-ref cache entries separate for the same path', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'git@gitlab.com:host/orca.git\n' }) - .mockResolvedValue({ stdout: 'git@gitlab.com:wsl/orca.git\n' }) - - await expect(getProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'host/orca' - }) - await expect(getProjectRef('/repo', undefined, null, { wslDistro: 'Ubuntu' })).resolves.toEqual( - { - host: 'gitlab.com', - path: 'wsl/orca' - } - ) - await expect(getProjectRef('/repo', undefined, null, { wslDistro: 'Ubuntu' })).resolves.toEqual( - { - host: 'gitlab.com', - path: 'wsl/orca' - } - ) - - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { - cwd: '/repo', - timeout: REMOTE_URL_PROBE_TIMEOUT_MS - }) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { - cwd: '/repo', - wslDistro: 'Ubuntu', - timeout: REMOTE_URL_PROBE_TIMEOUT_MS - }) - }) - - it('coalesces concurrent missing remote probes for the same repo and remote', async () => { - gitExecFileAsyncMock.mockImplementation(async () => { - await Promise.resolve() - throw new Error("error: No such remote 'upstream'") - }) - - await expect( - Promise.all([ - getProjectRefForRemote('/repo', 'upstream'), - getProjectRefForRemote('/repo', 'upstream'), - getProjectRefForRemote('/repo', 'upstream'), - getProjectRefForRemote('/repo', 'upstream') - ]) - ).resolves.toEqual([null, null, null, null]) - - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], { - cwd: '/repo', - timeout: REMOTE_URL_PROBE_TIMEOUT_MS - }) - - await expect(getProjectRefForRemote('/repo', 'upstream')).resolves.toBeNull() - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - - it('bounds cached project refs for distinct repo paths', async () => { - gitExecFileAsyncMock.mockResolvedValue({ - stdout: 'git@gitlab.com:stablyai/orca.git\n', - stderr: '' - }) - - for (let i = 0; i < 513; i += 1) { - await getProjectRef(`/repo-${i}`) - } - - expect(_getProjectRefCacheSize()).toBe(512) - }) - - it('does not cache a local probe killed on its deadline as a definitive miss', async () => { - gitExecFileAsyncMock - .mockRejectedValueOnce(new Error('git timed out.')) - .mockResolvedValueOnce({ stdout: 'git@gitlab.com:fork/orca.git\n' }) - - await expect(getProjectRef('/repo')).resolves.toBeNull() - await expect(getProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'fork/orca' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('re-probes a repo whose GitLab remote could have been added since the miss', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000_000) - gitExecFileAsyncMock.mockRejectedValueOnce(new Error("error: No such remote 'origin'")) - - await expect(getProjectRef('/repo')).resolves.toBeNull() - await expect(getProjectRef('/repo')).resolves.toBeNull() - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - - // Negative expiry reveals newly configured remotes without requiring local config watchers. - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.com:fork/orca.git\n' }) - vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) - - await expect(getProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'fork/orca' - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('periodically revalidates a signed project ref for external configuration changes', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000_000) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.com:fork/orca.git\n' }) - - await expect(getProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'fork/orca' - }) - vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS * 10) - await expect(getProjectRef('/repo')).resolves.toEqual({ - host: 'gitlab.com', - path: 'fork/orca' - }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('re-resolves a self-hosted remote once glab auth knows its host', async () => { - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.internal:team/orca.git\n' }) - glabExecFileAsyncMock.mockRejectedValue(new Error('not authenticated')) - - await expect(getProjectRefForRemote('/repo', 'origin', ['gitlab.com'])).resolves.toBeNull() - await expect( - getProjectRefForRemote('/repo', 'origin', ['gitlab.com', 'gitlab.internal']) - ).resolves.toEqual({ host: 'gitlab.internal', path: 'team/orca' }) - }) - - it('asks glab about an unauthenticated host once per interval, not once per repo', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000_000) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@github.com:team/orca.git\n' }) - glabExecFileAsyncMock.mockRejectedValue(new Error('not authenticated')) - - // Per-repo identity expiry must still share one negative auth probe per host. - for (const repoPath of ['/repo-a', '/repo-b', '/repo-c']) { - await expect(getProjectRef(repoPath)).resolves.toBeNull() - } - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) - - vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) - for (const repoPath of ['/repo-a', '/repo-b', '/repo-c']) { - await expect(getProjectRef(repoPath)).resolves.toBeNull() - } - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) }) describe('GitLab operation admission', () => { @@ -446,342 +163,3 @@ describe('GitLab operation admission', () => { await expect(acquire()).resolves.toBeUndefined() }) }) - -describe('resolveIssueSource', () => { - beforeEach(() => { - gitExecFileAsyncMock.mockReset() - _resetProjectRefCache() - _resetRemoteNameListingCache() - }) - - it("'auto' + upstream exists → upstream, fellBack=false", async () => { - mockGitRemoteCommands({ - origin: 'git@gitlab.com:fork/orca.git\n', - upstream: 'git@gitlab.com:stablyai/orca.git\n' - }) - - await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({ - source: { host: 'gitlab.com', path: 'stablyai/orca' }, - fellBack: false - }) - expect(gitRemoteGetUrlCalls('upstream')).toHaveLength(1) - }) - - it("'auto' + no gitlab upstream → origin, fellBack=false", async () => { - mockGitRemoteCommands({ - origin: 'git@gitlab.com:solo/orca.git\n', - upstream: 'git@example.com:stablyai/orca.git\n' - }) - - await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({ - source: { host: 'gitlab.com', path: 'solo/orca' }, - fellBack: false - }) - }) - - it("'upstream' + no upstream remote → origin, fellBack=true", async () => { - gitExecFileAsyncMock - .mockRejectedValueOnce(new Error('fatal: No such remote')) - .mockResolvedValueOnce({ stdout: 'git@gitlab.com:solo/orca.git\n' }) - - await expect(resolveIssueSource('/repo', 'upstream')).resolves.toEqual({ - source: { host: 'gitlab.com', path: 'solo/orca' }, - fellBack: true - }) - }) - - it("'origin' + upstream exists → origin (ignores upstream), fellBack=false", async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'git@gitlab.com:fork/orca.git\n' - }) - - await expect(resolveIssueSource('/repo', 'origin')).resolves.toEqual({ - source: { host: 'gitlab.com', path: 'fork/orca' }, - fellBack: false - }) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo', - timeout: REMOTE_URL_PROBE_TIMEOUT_MS - }) - }) - - it('undefined preference is treated identically to auto', async () => { - mockGitRemoteCommands({ - origin: 'git@gitlab.com:fork/orca.git\n', - upstream: 'git@gitlab.com:stablyai/orca.git\n' - }) - - await expect(resolveIssueSource('/repo', undefined)).resolves.toEqual({ - source: { host: 'gitlab.com', path: 'stablyai/orca' }, - fellBack: false - }) - }) -}) - -describe('glab error classification', () => { - it('classifies 403/forbidden as permission_denied', () => { - expect(classifyGlabError('HTTP 403 Forbidden').type).toBe('permission_denied') - expect(classifyGlabError('insufficient_scope').type).toBe('permission_denied') - }) - - it('classifies 404 / project not found as not_found', () => { - expect(classifyGlabError('HTTP 404 Not Found').type).toBe('not_found') - expect(classifyGlabError('Project Not Found').type).toBe('not_found') - }) - - it('classifies 422 / unprocessable as validation_error', () => { - expect(classifyGlabError('HTTP 422 Unprocessable Entity').type).toBe('validation_error') - }) - - it('classifies rate-limit signals as rate_limited', () => { - expect(classifyGlabError('HTTP 429 Too Many Requests').type).toBe('rate_limited') - expect(classifyGlabError('rate limit exceeded').type).toBe('rate_limited') - }) - - it('classifies timeout / dns / network as network_error', () => { - expect(classifyGlabError('connection timeout').type).toBe('network_error') - expect(classifyGlabError('could not resolve host: gitlab.com').type).toBe('network_error') - expect(classifyGlabError('network unreachable').type).toBe('network_error') - }) - - it('falls back to unknown for unrecognized stderr', () => { - expect(classifyGlabError('something weird happened').type).toBe('unknown') - }) - - it('rewrites copy for read contexts via classifyListIssuesError', () => { - expect(classifyListIssuesError('HTTP 403').message).toMatch(/permission to read issues/i) - expect(classifyListIssuesError('HTTP 404').message).toBe('Project not found.') - }) - - it('rewrites issue-edit copy for job logs via classifyJobLogError', () => { - expect(classifyJobLogError('HTTP 403').message).toMatch(/permission to read this job's log/i) - expect(classifyJobLogError('HTTP 403').message).not.toMatch(/issue/i) - expect(classifyJobLogError('boom').message).toBe('Failed to load the job log: boom') - }) - - it('treats a 404 job log as missing, but keeps a missing project an error', () => { - expect(isMissingJobLogError('HTTP 404 Not Found')).toBe(true) - expect(isMissingJobLogError('HTTP 404: Project Not Found')).toBe(false) - expect(isMissingJobLogError('HTTP 403 Forbidden')).toBe(false) - }) -}) - -describe('glab auth status host parsing', () => { - it('extracts hosts from "Logged in to " lines', () => { - const out = ` -✓ Logged in to gitlab.com as user1 (oauth2) -✓ Logged in to gitlab.example.com as user2 (token) - ` - expect(parseGlabAuthStatusHosts(out).sort()).toEqual(['gitlab.com', 'gitlab.example.com']) - }) - - it('extracts hosts from header-style lines', () => { - const out = ` -gitlab.example.com: - Logged in as user2 - ` - expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com') - }) - - it('extracts hosts from bare auth-status section headers', () => { - const out = ` -gitlab.com - ✓ Logged in to gitlab.com as user1 (/home/user/.config/glab-cli/config.yml) - ✓ Token: ************************** -gitlab.internal - ✓ Logged in as user2 - ✓ Token: ************************** -Self-hosted-git - ✓ Logged in as user3 - ` - expect(parseGlabAuthStatusHosts(out).sort()).toEqual([ - 'gitlab.com', - 'gitlab.internal', - 'self-hosted-git' - ]) - }) - - it('returns empty list for output with no hosts', () => { - expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([]) - }) - - it('captures a non-default port on header-style lines', () => { - const out = ` -gitlab.example.com:8080: - ✓ Logged in as user - ` - expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com:8080') - }) - - it('keeps two services on the same host distinct by port', () => { - const out = ` -✓ Logged in to gitlab.example.com:8443 as user (token) -✓ Logged in to gitlab.example.com:3030 as user (token) - ` - expect(parseGlabAuthStatusHosts(out).sort()).toEqual([ - 'gitlab.example.com:3030', - 'gitlab.example.com:8443' - ]) - }) -}) - -describe('parseGlabJsonList', () => { - it('returns the parsed list unchanged', () => { - expect(parseGlabJsonList<{ iid: number }>('[{"iid":1}]')).toEqual([{ iid: 1 }]) - }) - - it.each([ - ['null', 'null'], - ['a number', '0'], - ['a string', '"nope"'], - ['an object', '{"data":[]}'] - ])('reports the raw payload for %s as an unclassifiable body', (_label, payload) => { - expect(() => parseGlabJsonList(payload)).toThrow(GlabNonListResponseError) - expect(() => parseGlabJsonList(payload)).toThrow(payload) - }) - - // Bound opaque bodies before a proxy's multi-megabyte response reaches the error banner. - it.each([ - ['an opaque body', `{"data":"${'x'.repeat(50_000)}"}`], - ['an error envelope', `{"message":"${'x'.repeat(50_000)}"}`] - ])('bounds the reported payload for %s', (_label, payload) => { - expect(() => parseGlabJsonList(payload)).toThrow( - /^GitLab returned (?:a non-list response|an error): .{300}$/ - ) - }) - - it.each([ - ['message', '{"message":"403 Forbidden"}', '403 Forbidden'], - ['error', '{"error":"insufficient_scope"}', 'insufficient_scope'], - ['error when message is blank', '{"message":" ","error":"real_error"}', 'real_error'], - [ - 'message when both are set', - '{"message":"404 Project Not Found","error":"insufficient_scope"}', - '404 Project Not Found' - ] - ])('reports a GitLab error envelope by its %s field', (_label, payload, reported) => { - expect(() => parseGlabJsonList(payload)).toThrow(`GitLab returned an error: ${reported}`) - expect(() => parseGlabJsonList(payload)).not.toThrow(GlabNonListResponseError) - }) -}) - -describe('classifyListFetchError', () => { - it('keeps opaque payload text away from the classifier', () => { - // Content keywords must not turn opaque payloads into classified connectivity errors. - const payload = '{"data":[{"title":"fix network timeout"}]}' - let thrown: unknown - try { - parseGlabJsonList(payload) - } catch (err) { - thrown = err - } - expect(thrown).toBeInstanceOf(GlabNonListResponseError) - const classified = classifyListFetchError(thrown) - expect(classified.type).toBe('unknown') - expect(classified.message).toContain('fix network timeout') - }) - - it('still classifies ordinary glab failures by their stderr', () => { - expect(classifyListFetchError(new Error('HTTP 403 Forbidden')).type).toBe('permission_denied') - }) -}) - -describe('parseGlabApiResponse', () => { - it('splits headers and body at the first blank line (LF)', () => { - const stdout = 'HTTP/2.0 200 OK\nX-Total: 42\nX-Total-Pages: 3\n\n[{"iid":1}]' - const parsed = parseGlabApiResponse(stdout) - expect(parsed.headers).toEqual({ 'x-total': '42', 'x-total-pages': '3' }) - expect(parsed.body).toBe('[{"iid":1}]') - }) - - it('handles CRLF line endings', () => { - const stdout = 'HTTP/2.0 200 OK\r\nX-Total: 7\r\n\r\n[]' - const parsed = parseGlabApiResponse(stdout) - expect(parsed.headers['x-total']).toBe('7') - expect(parsed.body).toBe('[]') - }) - - it('splits large bodies without full-output separator matching', () => { - const matchSpy = vi.spyOn(String.prototype, 'match') - const body = '[{"iid":1}]'.repeat(10_000) - const parsed = parseGlabApiResponse(`HTTP/2.0 200 OK\r\nX-Total: 7\r\n\r\n${body}`) - - expect(parsed.headers['x-total']).toBe('7') - expect(parsed.body).toBe(body) - const usedSeparatorMatch = matchSpy.mock.calls.some( - ([pattern]) => pattern instanceof RegExp && pattern.source === '\\r?\\n\\r?\\n' - ) - expect(usedSeparatorMatch).toBe(false) - }) - - it('returns the full input as body when there is no header separator', () => { - const stdout = '{"iid":1}' - const parsed = parseGlabApiResponse(stdout) - expect(parsed.body).toBe(stdout) - expect(parsed.headers).toEqual({}) - }) -}) - -describe('parseGlabPaginationHeader', () => { - it('reads a usable header value', () => { - expect(parseGlabPaginationHeader('25', 1)).toBe(25) - expect(parseGlabPaginationHeader(' 9 ', 1)).toBe(9) - }) - - it('returns undefined for an absent or unparseable header', () => { - expect(parseGlabPaginationHeader(undefined, 0)).toBeUndefined() - expect(parseGlabPaginationHeader('', 0)).toBeUndefined() - expect(parseGlabPaginationHeader('abc', 0)).toBeUndefined() - }) - - // Distinguish total 0 from missing totals while rejecting total-pages 0. - it('rejects values below the minimum', () => { - expect(parseGlabPaginationHeader('0', 1)).toBeUndefined() - expect(parseGlabPaginationHeader('0', 0)).toBe(0) - expect(parseGlabPaginationHeader('-3', 0)).toBeUndefined() - }) -}) - -describe('glabHostEnvOptions', () => { - const projectRef = { host: 'gitlab.example.internal' } - - afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() - }) - - it('carries the host in GITLAB_HOST for a connection-backed workspace', () => { - const { env } = glabHostEnvOptions(projectRef, 'conn-1') - - expect(env?.GITLAB_HOST).toBe('gitlab.example.internal') - }) - - it('returns nothing for a local workspace', () => { - expect(glabHostEnvOptions(projectRef, null)).toEqual({}) - expect(glabHostEnvOptions(projectRef, undefined)).toEqual({}) - }) - - it('returns nothing when the project ref carries no host', () => { - expect(glabHostEnvOptions(null, 'conn-1')).toEqual({}) - expect(glabHostEnvOptions({ host: '' }, 'conn-1')).toEqual({}) - }) - - // GITLAB_HOST must cross wsl.exe through WSLENV. - it('forwards GITLAB_HOST through WSLENV on Windows', () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - - const { env } = glabHostEnvOptions(projectRef, 'conn-1') - - expect(env?.GITLAB_HOST).toBe('gitlab.example.internal') - expect(env?.WSLENV?.split(':')).toContain('GITLAB_HOST') - }) - - it('leaves WSLENV alone off Windows', () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - - const { env } = glabHostEnvOptions(projectRef, 'conn-1') - - expect(env?.WSLENV).toBe(process.env.WSLENV) - }) -}) diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts index 17e3dc530d7..467f07c9c26 100644 --- a/src/main/gitlab/issues.test.ts +++ b/src/main/gitlab/issues.test.ts @@ -32,50 +32,8 @@ vi.mock('./gl-utils', async () => { release: releaseMock } }) - -import { parseGlabApiResponse } from './gl-utils' -import { addIssueComment, createIssue, getIssue, listIssues } from './issues' +import { getIssue, listIssues } from './issues' import { updateIssue } from './issue-update' -import { listAssignableUsers, listLabels } from './project-label-and-member-lookup' - -function glabIncludeOutput(headers: Record, body: unknown): string { - const headerLines = Object.entries(headers) - .map(([name, value]) => `${name}: ${value}\r\n`) - .join('') - return `HTTP/2.0 200 OK\r\nContent-Type: application/json\r\n${headerLines}\r\n${JSON.stringify(body)}\n` -} - -function serveGlabApiPages(stdoutByEndpoint: Map): void { - glabApiWithHeadersMock.mockImplementation(async (args: string[]) => { - const stdout = stdoutByEndpoint.get(args.at(-1) ?? '') - if (stdout === undefined) { - throw new Error(`unexpected glab api call: ${args.join(' ')}`) - } - return parseGlabApiResponse(stdout) - }) -} - -/** Model glab assignment parsing: + adds, -/! removes, unprefixed names replace everyone. */ -function glabAssigneeEdits(args: string[]): { add: string[]; remove: string[]; replace: string[] } { - const add: string[] = [] - const remove: string[] = [] - const replace: string[] = [] - for (const arg of args) { - if (!arg.startsWith('--assignee=')) { - continue - } - for (const value of arg.slice('--assignee='.length).split(',')) { - if (value.startsWith('+')) { - add.push(value.slice(1)) - } else if (value.startsWith('-') || value.startsWith('!')) { - remove.push(value.slice(1)) - } else { - replace.push(value) - } - } - } - return { add, remove, replace } -} describe('gitlab issue operations', () => { beforeEach(() => { @@ -94,225 +52,6 @@ describe('gitlab issue operations', () => { })) }) - it('gets a single issue from the project ref', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 923, - title: 'Use upstream issues', - state: 'opened', - web_url: 'https://gitlab.com/stablyai/orca/-/issues/923', - labels: [] - }) - }) - - await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ number: 923 }) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['api', 'projects/stablyai%2Forca/issues/923'], - { cwd: '/repo-root' } - ) - }) - - it('routes local WSL issue operations through project resolution and glab execution options', async () => { - const localGitOptions = { wslDistro: 'Ubuntu' } - getIssueProjectRefMock.mockResolvedValue({ host: 'gitlab.com', path: 'stablyai/orca' }) - resolveIssueSourceMock.mockResolvedValue({ - source: { host: 'gitlab.com', path: 'stablyai/orca' }, - fellBack: false - }) - glabExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 923, - title: 'Use WSL', - state: 'opened', - web_url: 'https://gitlab.com/stablyai/orca/-/issues/923', - labels: [] - }) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 924, - web_url: 'https://gitlab.com/stablyai/orca/-/issues/924' - }) - }) - .mockResolvedValueOnce({ stdout: '{}' }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - id: 1, - author: { username: 'octo', avatar_url: '', state: 'active' }, - body: 'Comment', - created_at: '2026-06-16T00:00:00.000Z' - }) - }) - glabApiWithHeadersMock - .mockResolvedValueOnce({ body: '[]', headers: {} }) - .mockResolvedValueOnce({ body: '[{"id":1,"name":"bug"}]', headers: { 'x-next-page': '' } }) - .mockResolvedValueOnce({ - body: '[{"id":1,"username":"octo","avatar_url":""}]', - headers: { 'x-next-page': '' } - }) - - await getIssue('/repo-root', 923, null, localGitOptions) - await listIssues('/repo-root', 5, undefined, 'opened', undefined, null, localGitOptions) - await createIssue('/repo-root', 'New issue', 'Body', undefined, null, localGitOptions) - await updateIssue( - '/repo-root', - 923, - { body: 'Updated' }, - undefined, - null, - null, - localGitOptions - ) - await addIssueComment('/repo-root', 923, 'Comment', undefined, null, null, localGitOptions) - await expect(listLabels('/repo-root', undefined, null, localGitOptions)).resolves.toEqual([ - 'bug' - ]) - await expect( - listAssignableUsers('/repo-root', undefined, null, localGitOptions) - ).resolves.toMatchObject([{ username: 'octo' }]) - - expect(getIssueProjectRefMock).toHaveBeenCalledWith( - '/repo-root', - ['gitlab.com'], - null, - localGitOptions - ) - expect(resolveIssueSourceMock).toHaveBeenCalledWith( - '/repo-root', - undefined, - ['gitlab.com'], - null, - localGitOptions - ) - expect(glabExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( - true - ) - expect(glabApiWithHeadersMock).toHaveBeenCalledTimes(3) - expect(glabApiWithHeadersMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( - true - ) - expect(glabApiWithHeadersMock).toHaveBeenCalledWith( - [ - 'projects/stablyai%2Forca/issues?page=1&per_page=5&order_by=updated_at&sort=desc&state=opened' - ], - { cwd: '/repo-root', ...localGitOptions } - ) - }) - - it('encodes nested group paths', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ - host: 'gitlab.com', - path: 'group/subgroup/project' - }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ iid: 1, title: 't', state: 'opened' }) - }) - - await getIssue('/repo-root', 1) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['api', 'projects/group%2Fsubgroup%2Fproject/issues/1'], - { cwd: '/repo-root' } - ) - }) - - it('lists issues with state=opened ordering', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: '[]', - headers: { 'x-total': '123', 'x-total-pages': '25' } - }) - - await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [], totalPages: 25 }) - - expect(glabApiWithHeadersMock).toHaveBeenCalledWith( - [ - 'projects/stablyai%2Forca/issues?page=1&per_page=5&order_by=updated_at&sort=desc&state=opened' - ], - { cwd: '/repo-root' } - ) - }) - - it('forwards an explicit page into the issues API path after localGitOptions', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) - - await expect( - listIssues('/repo-root', 50, undefined, 'opened', undefined, null, {}, 3) - ).resolves.toMatchObject({ totalPages: 3 }) - - expect(glabApiWithHeadersMock).toHaveBeenCalledWith( - [ - 'projects/stablyai%2Forca/issues?page=3&per_page=50&order_by=updated_at&sort=desc&state=opened' - ], - { cwd: '/repo-root' } - ) - }) - - it('derives total pages from x-total when x-total-pages is unavailable', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: { 'x-total': '11' } }) - - await expect(listIssues('/repo-root', 5)).resolves.toMatchObject({ totalPages: 3 }) - }) - - it('keeps a next-page probe when a proxy strips pagination headers', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify( - Array.from({ length: 5 }, (_, index) => ({ - iid: index + 1, - title: `Issue ${index + 1}`, - state: 'opened', - web_url: `https://gitlab.com/stablyai/orca/-/issues/${index + 1}`, - labels: [] - })) - ), - headers: {} - }) - - await expect(listIssues('/repo-root', 5)).resolves.toMatchObject({ totalPages: 2 }) - }) - - it('surfaces a permission_denied error instead of collapsing to empty', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) - - const result = await listIssues('/repo-root', 5) - - expect(result.items).toEqual([]) - expect(result.error?.type).toBe('permission_denied') - }) - - it('reports the body instead of ".map is not a function" when the API returns a non-array', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify({ data: [], total: 0 }), - headers: {} - }) - - const result = await listIssues('/repo-root', 5) - - expect(result.items).toEqual([]) - expect(result.error?.type).toBe('unknown') - expect(result.error?.message).toContain('{"data":[],"total":0}') - expect(result.error?.message).not.toContain('is not a function') - }) - - it('reports a GitLab error envelope by its own message', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabApiWithHeadersMock.mockResolvedValueOnce({ - body: JSON.stringify({ message: '403 Forbidden' }), - headers: {} - }) - - const result = await listIssues('/repo-root', 5) - - expect(result.items).toEqual([]) - expect(result.error?.type).toBe('permission_denied') - }) - it('returns an isolated not_found error (never a cwd-inferred glab call) when the project is unresolved', async () => { // Unresolved SSH projects must return an isolated error without attempting local cwd inference. getIssueProjectRefMock.mockResolvedValueOnce(null) @@ -332,90 +71,7 @@ describe('gitlab issue operations', () => { expect(glabExecFileAsyncMock).not.toHaveBeenCalled() }) - it('threads connectionId into getGlabKnownHosts for listIssues', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) - - await listIssues('/repo-root', 5, undefined, 'opened', undefined, 'conn-7') - - expect(getGlabKnownHostsMock).toHaveBeenCalledWith('conn-7', {}) - }) - - it('creates an issue and returns its iid + web_url', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 924, - web_url: 'https://gitlab.com/stablyai/orca/-/issues/924' - }) - }) - - await expect(createIssue('/repo-root', 'New issue', 'Body')).resolves.toEqual({ - ok: true, - number: 924, - url: 'https://gitlab.com/stablyai/orca/-/issues/924' - }) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '-X', - 'POST', - 'projects/stablyai%2Forca/issues', - '-f', - 'title=New issue', - '-f', - 'description=Body' - ], - { cwd: '/repo-root' } - ) - }) - - it('rejects createIssue with empty title', async () => { - await expect(createIssue('/repo-root', ' ', 'body')).resolves.toMatchObject({ - ok: false, - error: 'Title is required' - }) - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('updateIssue closes via `glab issue close` when state=closed', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) - - await expect(updateIssue('/repo-root', 5, { state: 'closed' })).resolves.toEqual({ ok: true }) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['issue', 'close', '5', '-R', 'stablyai/orca'], - { cwd: '/repo-root' } - ) - }) - // issue update rejects --hostname; pass the instance through GITLAB_HOST. - it('updateIssue edits fields through GITLAB_HOST on an SSH workspace', async () => { - const projectRef = { host: 'gitlab.example.internal', path: 'stablyai/orca' } - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) - - await expect( - updateIssue('/repo-root', 5, { title: 'Renamed' }, undefined, 'conn-1', projectRef) - ).resolves.toEqual({ ok: true }) - - const [args, options] = glabExecFileAsyncMock.mock.calls[0] - expect(args.slice(0, 2)).toEqual(['issue', 'update']) - expect(args).not.toContain('--hostname') - expect(options.env?.GITLAB_HOST).toBe('gitlab.example.internal') - }) - - it('updateIssue closes through GITLAB_HOST on an SSH workspace', async () => { - const projectRef = { host: 'gitlab.example.internal', path: 'stablyai/orca' } - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) - - await expect( - updateIssue('/repo-root', 5, { state: 'closed' }, undefined, 'conn-1', projectRef) - ).resolves.toEqual({ ok: true }) - - const [args, options] = glabExecFileAsyncMock.mock.calls[0] - expect(args).toEqual(['issue', 'close', '5', '-R', 'stablyai/orca']) - expect(options.env?.GITLAB_HOST).toBe('gitlab.example.internal') - }) // glab already-closed success exits 0; an already substring must not hide a real rejection. it('updateIssue reports a failed close even when its text says "already"', async () => { @@ -426,241 +82,4 @@ describe('gitlab issue operations', () => { ok: false }) }) - - it('updateIssue applies field edits via `glab issue update`', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) - - await expect( - updateIssue('/repo-root', 5, { - title: 'Renamed', - addLabels: ['bug'], - removeLabels: ['stale'], - addAssignees: ['alice'], - removeAssignees: ['bob'] - }) - ).resolves.toEqual({ ok: true }) - - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'issue', - 'update', - '5', - '-R', - 'stablyai/orca', - '--title', - 'Renamed', - '--label', - 'bug', - '--unlabel', - 'stale', - '--assignee=+alice', - '--assignee=-bob' - ], - { cwd: '/repo-root' } - ) - }) - - it('updateIssue sends assignee edits glab applies as add/remove, never as a replace', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) - - await updateIssue('/repo-root', 5, { - addAssignees: ['alice', 'carol'], - removeAssignees: ['bob'] - }) - - const [args] = glabExecFileAsyncMock.mock.calls[0] - expect(glabAssigneeEdits(args)).toEqual({ - add: ['alice', 'carol'], - remove: ['bob'], - replace: [] - }) - expect(args.filter((arg: string) => arg.startsWith('--unassign'))).toEqual([]) - }) - - it('updateIssue applies body edits via the issue API', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) - - await expect(updateIssue('/repo-root', 5, { body: 'Updated body' })).resolves.toEqual({ - ok: true - }) - - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['api', '-X', 'PUT', 'projects/stablyai%2Forca/issues/5', '-f', 'description=Updated body'], - { cwd: '/repo-root' } - ) - }) - - it('walks label and member pages of glab api -i output on the selected SSH GitLab host', async () => { - getIssueProjectRefMock - .mockResolvedValueOnce({ host: 'git.internal', path: 'stablyai/orca' }) - .mockResolvedValueOnce({ host: 'git.internal', path: 'stablyai/orca' }) - serveGlabApiPages( - new Map([ - [ - 'projects/stablyai%2Forca/labels?per_page=100&page=1', - glabIncludeOutput({ 'X-Next-Page': '2', 'X-Page': '1', 'X-Total-Pages': '2' }, [ - { id: 1, name: 'bug', color: '#d9534f', is_project_label: true } - ]) - ], - [ - 'projects/stablyai%2Forca/labels?per_page=100&page=2', - glabIncludeOutput({ 'X-Next-Page': '', 'X-Page': '2', 'X-Total-Pages': '2' }, [ - { id: 4, name: 'feature', color: '#f0ad4e', is_project_label: false } - ]) - ], - [ - 'projects/stablyai%2Forca/members/all?per_page=100&page=1', - glabIncludeOutput({ 'X-Next-Page': '', 'X-Page': '1' }, [ - { - id: 12, - username: 'alice', - name: 'Alice', - state: 'active', - avatar_url: 'https://example.com/a.png', - web_url: 'https://git.internal/alice', - access_level: 30 - } - ]) - ] - ]) - ) - - await expect(listLabels('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([ - 'bug', - 'feature' - ]) - await expect(listAssignableUsers('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([ - { - id: 12, - username: 'alice', - name: 'Alice', - avatarUrl: 'https://example.com/a.png', - state: 'active' - } - ]) - - expect(glabApiWithHeadersMock.mock.calls.map(([args]) => args)).toEqual([ - ['--hostname', 'git.internal', 'projects/stablyai%2Forca/labels?per_page=100&page=1'], - ['--hostname', 'git.internal', 'projects/stablyai%2Forca/labels?per_page=100&page=2'], - ['--hostname', 'git.internal', 'projects/stablyai%2Forca/members/all?per_page=100&page=1'] - ]) - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('probes past a full label page when a proxy strips the pagination headers', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - const fullPage = Array.from({ length: 100 }, (_, index) => ({ id: index, name: `l${index}` })) - serveGlabApiPages( - new Map([ - ['projects/stablyai%2Forca/labels?per_page=100&page=1', glabIncludeOutput({}, fullPage)], - [ - 'projects/stablyai%2Forca/labels?per_page=100&page=2', - glabIncludeOutput({}, [{ id: 100, name: 'last' }]) - ] - ]) - ) - - const labels = await listLabels('/repo-root') - - expect(labels).toHaveLength(101) - expect(labels.at(-1)).toBe('last') - expect(glabApiWithHeadersMock).toHaveBeenCalledTimes(2) - }) - - it('stops paging when x-next-page does not advance and returns no members on an error body', async () => { - getIssueProjectRefMock - .mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - .mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - serveGlabApiPages( - new Map([ - [ - 'projects/stablyai%2Forca/labels?per_page=100&page=1', - glabIncludeOutput({ 'X-Next-Page': '1' }, [{ id: 1, name: 'bug' }]) - ], - [ - 'projects/stablyai%2Forca/members/all?per_page=100&page=1', - glabIncludeOutput({}, { message: '403 Forbidden' }) - ] - ]) - ) - - await expect(listLabels('/repo-root')).resolves.toEqual(['bug']) - await expect(listAssignableUsers('/repo-root')).resolves.toEqual([]) - expect(glabApiWithHeadersMock).toHaveBeenCalledTimes(2) - }) - - it('addIssueComment posts to /notes and maps the response', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - id: 100, - author: { username: 'alice', avatar_url: 'https://example.com/a.png' }, - body: 'Hello', - created_at: '2026-05-05T10:00:00Z' - }) - }) - - const result = await addIssueComment('/repo-root', 5, 'Hello') - expect(result).toEqual({ - ok: true, - comment: { - id: 100, - author: 'alice', - authorAvatarUrl: 'https://example.com/a.png', - body: 'Hello', - createdAt: '2026-05-05T10:00:00Z', - url: '', - isBot: false - } - }) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['api', '-X', 'POST', 'projects/stablyai%2Forca/issues/5/notes', '-f', 'body=Hello'], - { cwd: '/repo-root' } - ) - }) - - it('addIssueComment passes hostname for SSH-backed self-hosted repos', async () => { - getIssueProjectRefMock.mockResolvedValueOnce({ - host: 'gitlab.example.com', - path: 'stablyai/orca' - }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ id: 100, body: 'Hello' }) - }) - - await addIssueComment('/repo-root', 5, 'Hello', undefined, 'conn-1') - - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--hostname', - 'gitlab.example.com', - '-X', - 'POST', - 'projects/stablyai%2Forca/issues/5/notes', - '-f', - 'body=Hello' - ], - {} - ) - }) - - it('returns null from getIssue when project ref cannot be resolved', async () => { - getIssueProjectRefMock.mockResolvedValueOnce(null) - // An unresolved project returns null without invoking cwd-dependent glab. - glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not a glab repo')) - - await expect(getIssue('/repo-root', 1)).resolves.toBeNull() - }) - - it('updateIssue returns error when project ref cannot be resolved', async () => { - getIssueProjectRefMock.mockResolvedValueOnce(null) - await expect(updateIssue('/repo-root', 5, { state: 'closed' })).resolves.toMatchObject({ - ok: false, - error: 'Could not resolve GitLab project for this repository' - }) - }) }) diff --git a/src/main/gitlab/mappers.test.ts b/src/main/gitlab/mappers.test.ts deleted file mode 100644 index d32137007ff..00000000000 --- a/src/main/gitlab/mappers.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { derivePipelineStatus, mapGitLabIssueInfo, mapMRInfo, mapMRState } from './mappers' -import { - mapGitLabPipelineJobStatusToCheckStatus as mapPipelineJobStatusToCheckStatus, - mapGitLabPipelineJobStatusToConclusion as mapPipelineJobStatusToConclusion -} from '../../shared/source-control/checks/gitlab/gitlab-pipeline-checks' - -describe('mapPipelineJobStatusToCheckStatus', () => { - it('classifies queued lifecycle states', () => { - expect(mapPipelineJobStatusToCheckStatus('created')).toBe('queued') - expect(mapPipelineJobStatusToCheckStatus('pending')).toBe('queued') - expect(mapPipelineJobStatusToCheckStatus('waiting_for_resource')).toBe('queued') - expect(mapPipelineJobStatusToCheckStatus('preparing')).toBe('queued') - }) - - it('classifies running as in_progress', () => { - expect(mapPipelineJobStatusToCheckStatus('running')).toBe('in_progress') - }) - - it('classifies success/failed/canceled/skipped/manual as completed', () => { - expect(mapPipelineJobStatusToCheckStatus('success')).toBe('completed') - expect(mapPipelineJobStatusToCheckStatus('failed')).toBe('completed') - expect(mapPipelineJobStatusToCheckStatus('canceled')).toBe('completed') - expect(mapPipelineJobStatusToCheckStatus('skipped')).toBe('completed') - expect(mapPipelineJobStatusToCheckStatus('manual')).toBe('completed') - }) -}) - -describe('mapPipelineJobStatusToConclusion', () => { - it('maps terminal outcomes', () => { - expect(mapPipelineJobStatusToConclusion('success')).toBe('success') - expect(mapPipelineJobStatusToConclusion('failed')).toBe('failure') - expect(mapPipelineJobStatusToConclusion('canceled')).toBe('cancelled') - expect(mapPipelineJobStatusToConclusion('canceling')).toBe('cancelled') - expect(mapPipelineJobStatusToConclusion('skipped')).toBe('skipped') - }) - - it('keeps manual gates neutral while action-required stays actionable', () => { - expect(mapPipelineJobStatusToConclusion('manual')).toBe('neutral') - expect(mapPipelineJobStatusToConclusion('action_required')).toBe('action_required') - }) - - it('maps active lifecycle states to pending', () => { - expect(mapPipelineJobStatusToConclusion('running')).toBe('pending') - expect(mapPipelineJobStatusToConclusion('pending')).toBe('pending') - expect(mapPipelineJobStatusToConclusion('scheduled')).toBe('pending') - }) - - it('returns null for unknown', () => { - expect(mapPipelineJobStatusToConclusion('weird-status')).toBeNull() - }) -}) - -describe('mapMRState', () => { - it('maps merged/closed/locked directly', () => { - expect(mapMRState('merged')).toBe('merged') - expect(mapMRState('closed')).toBe('closed') - expect(mapMRState('locked')).toBe('locked') - }) - - it('returns draft when the draft flag is set', () => { - expect(mapMRState('opened', true)).toBe('draft') - }) - - it("infers draft from a 'Draft:' title prefix", () => { - expect(mapMRState('opened', false, 'Draft: refactor auth')).toBe('draft') - expect(mapMRState('opened', undefined, 'WIP: in progress')).toBe('draft') - }) - - it("returns 'opened' for plain open MRs", () => { - expect(mapMRState('opened', false, 'Add gitlab support')).toBe('opened') - expect(mapMRState('opened')).toBe('opened') - }) -}) - -describe('mapGitLabIssueInfo', () => { - it('uses iid as the number when present', () => { - expect( - mapGitLabIssueInfo({ - iid: 42, - title: 'A', - state: 'opened', - web_url: 'https://gitlab.com/g/p/-/issues/42', - labels: [{ name: 'bug' }, { name: 'p1' }] - }) - ).toEqual({ - number: 42, - title: 'A', - state: 'opened', - url: 'https://gitlab.com/g/p/-/issues/42', - labels: ['bug', 'p1'] - }) - }) - - it('falls back to number when iid is absent', () => { - expect(mapGitLabIssueInfo({ number: 7, title: 'B', state: 'closed' })).toEqual({ - number: 7, - title: 'B', - state: 'closed', - url: '', - labels: [] - }) - }) - - it('handles string-only labels', () => { - expect(mapGitLabIssueInfo({ iid: 1, title: 'C', state: 'opened', labels: ['bug'] })).toEqual({ - number: 1, - title: 'C', - state: 'opened', - url: '', - labels: ['bug'] - }) - }) - - it('passes description / author / authorAvatarUrl through when present', () => { - const info = mapGitLabIssueInfo({ - iid: 9, - title: 'bug', - state: 'opened', - description: 'Steps to reproduce.', - author: { username: 'bob', avatar_url: 'https://example.com/b.png' } - }) - expect(info.description).toBe('Steps to reproduce.') - expect(info.author).toBe('bob') - expect(info.authorAvatarUrl).toBe('https://example.com/b.png') - }) -}) - -describe('mapMRInfo', () => { - it('settles mergeable manual pipelines without claiming they passed, while preserving blockers', () => { - for (const pipelineField of ['head_pipeline', 'pipeline']) { - for (const [status, detailed_merge_status, expected] of [ - ['manual', 'mergeable', 'neutral'], - ['manual', 'ci_must_pass', 'pending'], - ['manual', 'merge_request_blocked', 'pending'], - ['manual', 'draft_status', 'pending'], - ['manual', undefined, 'pending'], - ['running', 'mergeable', 'pending'], - ['failed', 'mergeable', 'failure'] - ] as const) { - const raw = { - iid: 1, - title: 'Manual release stage', - state: 'opened', - [pipelineField]: { status }, - detailed_merge_status - } - const info = mapMRInfo(raw, derivePipelineStatus({ status })) - expect(info.pipelineStatus, `${pipelineField}: ${status} / ${detailed_merge_status}`).toBe( - expected - ) - } - } - }) - - it('builds an MRInfo from a typical glab payload', () => { - expect( - mapMRInfo( - { - iid: 10, - title: 'Add gitlab support', - state: 'opened', - draft: false, - web_url: 'https://gitlab.com/g/p/-/merge_requests/10', - updated_at: '2026-05-05T10:00:00Z', - sha: 'deadbeef', - has_conflicts: false, - detailed_merge_status: 'mergeable' - }, - 'success' - ) - ).toEqual({ - number: 10, - title: 'Add gitlab support', - state: 'opened', - url: 'https://gitlab.com/g/p/-/merge_requests/10', - pipelineStatus: 'success', - updatedAt: '2026-05-05T10:00:00Z', - mergeable: 'MERGEABLE', - mergeStateStatus: 'mergeable', - headSha: 'deadbeef' - }) - }) - - it('marks CONFLICTING when has_conflicts is true', () => { - const info = mapMRInfo( - { - iid: 1, - title: 't', - state: 'opened', - has_conflicts: true, - head_pipeline: { status: 'manual' }, - detailed_merge_status: 'mergeable' - }, - 'pending' - ) - expect(info.mergeable).toBe('CONFLICTING') - expect(info.mergeStateStatus).toBe('conflict') - expect(info.pipelineStatus).toBe('pending') - }) - - it('marks UNKNOWN when detailed_merge_status is non-mergeable but not a conflict', () => { - const info = mapMRInfo( - { iid: 1, title: 't', state: 'opened', detailed_merge_status: 'checking' }, - 'pending' - ) - expect(info.mergeable).toBe('UNKNOWN') - expect(info.mergeStateStatus).toBe('checking') - }) - - // GitLab <15.6 lacks detailed_merge_status; legacy positive mergeability keeps actions usable. - it('falls back to legacy merge_status when detailed_merge_status is absent', () => { - const info = mapMRInfo( - { - iid: 1, - title: 't', - state: 'opened', - merge_status: 'can_be_merged', - head_pipeline: { status: 'manual' } - }, - 'pending' - ) - expect(info.mergeable).toBe('MERGEABLE') - expect(info.pipelineStatus).toBe('pending') - }) - - it('does not let legacy merge_status override a present detailed_merge_status', () => { - const info = mapMRInfo( - { - iid: 1, - title: 't', - state: 'opened', - detailed_merge_status: 'not_approved', - merge_status: 'can_be_merged' - }, - 'success' - ) - expect(info.mergeable).toBe('UNKNOWN') - expect(info.mergeStateStatus).toBe('not_approved') - }) - - it('keeps legacy cannot_be_merged as UNKNOWN rather than guessing a conflict', () => { - const info = mapMRInfo( - { iid: 1, title: 't', state: 'opened', merge_status: 'cannot_be_merged' }, - 'success' - ) - expect(info.mergeable).toBe('UNKNOWN') - }) - - it('returns draft state when draft flag is set', () => { - const info = mapMRInfo({ iid: 1, title: 't', state: 'opened', draft: true }, 'neutral') - expect(info.state).toBe('draft') - }) - - it('passes description / author / authorAvatarUrl through when present', () => { - const info = mapMRInfo( - { - iid: 5, - title: 't', - state: 'opened', - description: '## Body\n\nDetails here.', - author: { username: 'alice', avatar_url: 'https://example.com/a.png' } - }, - 'success' - ) - expect(info.description).toBe('## Body\n\nDetails here.') - expect(info.author).toBe('alice') - expect(info.authorAvatarUrl).toBe('https://example.com/a.png') - }) - - it('omits description / author when absent (distinguishes from list payloads)', () => { - // Missing description means unloaded list data; empty string means a known blank body. - const info = mapMRInfo({ iid: 5, title: 't', state: 'opened' }, 'success') - expect('description' in info).toBe(false) - expect('author' in info).toBe(false) - expect('authorAvatarUrl' in info).toBe(false) - }) -}) - -describe('derivePipelineStatus', () => { - it('returns neutral for null/undefined/empty', () => { - expect(derivePipelineStatus(null)).toBe('neutral') - expect(derivePipelineStatus(undefined)).toBe('neutral') - expect(derivePipelineStatus([])).toBe('neutral') - }) - - it('classifies a top-level pipeline string', () => { - expect(derivePipelineStatus('success')).toBe('success') - expect(derivePipelineStatus('failed')).toBe('failure') - expect(derivePipelineStatus('running')).toBe('pending') - expect(derivePipelineStatus('waiting_for_callback')).toBe('pending') - // Pipeline manual means blocked on human action, not failed or complete. - expect(derivePipelineStatus('manual')).toBe('pending') - expect(derivePipelineStatus({ status: 'manual' })).toBe('pending') - }) - - // Preserve pipeline-level neutral skipped/canceled states independently of job-level rollup tones. - it('keeps skipped and canceled pipeline strings neutral (deferred tone changes)', () => { - expect(derivePipelineStatus('skipped')).toBe('neutral') - expect(derivePipelineStatus({ status: 'skipped' })).toBe('neutral') - expect(derivePipelineStatus('canceled')).toBe('neutral') - expect(derivePipelineStatus('canceling')).toBe('neutral') - expect(derivePipelineStatus([{ status: 'canceled' }])).toBe('failure') - }) - - it('rolls up an array of jobs', () => { - expect(derivePipelineStatus([{ status: 'success' }, { status: 'success' }])).toBe('success') - expect(derivePipelineStatus([{ status: 'success' }, { status: 'failed' }])).toBe('failure') - expect(derivePipelineStatus([{ status: 'success' }, { status: 'running' }])).toBe('pending') - }) - - it('failure beats pending in the rollup', () => { - expect(derivePipelineStatus([{ status: 'failed' }, { status: 'running' }])).toBe('failure') - expect(derivePipelineStatus([{ status: 'action_required' }, { status: 'success' }])).toBe( - 'failure' - ) - }) - - it('keeps a manual deploy gate from failing an otherwise green pipeline', () => { - expect(derivePipelineStatus([{ status: 'manual' }, { status: 'success' }])).toBe('success') - }) - - it('leaves a manual-only pipeline unresolved rather than green', () => { - expect(derivePipelineStatus([{ status: 'manual' }])).toBe('neutral') - expect(derivePipelineStatus([{ status: 'manual' }, { status: 'manual' }])).toBe('neutral') - }) - - it('counts skipped jobs as passing', () => { - expect(derivePipelineStatus([{ status: 'skipped' }, { status: 'skipped' }])).toBe('success') - }) - - it('handles a single object with status', () => { - expect(derivePipelineStatus({ status: 'success' })).toBe('success') - }) - - it('keeps malformed and unknown array jobs neutral', () => { - expect(derivePipelineStatus([{ status: 'future_status' }])).toBe('neutral') - expect(derivePipelineStatus([{}])).toBe('neutral') - }) - - it('lets a passing job outweigh an unknown one', () => { - expect(derivePipelineStatus([{ status: 'success' }, { status: 'future_status' }])).toBe( - 'success' - ) - }) -}) diff --git a/src/main/gitlab/work-item-details.test.ts b/src/main/gitlab/work-item-details.test.ts index fc9b887f040..f5ee71548ec 100644 --- a/src/main/gitlab/work-item-details.test.ts +++ b/src/main/gitlab/work-item-details.test.ts @@ -31,9 +31,6 @@ vi.mock('./gl-utils', () => ({ glabHostnameArgs: vi.fn(() => []), glabRepoExecOptions: glabRepoExecOptionsMock })) - -import { fetchMRFiles } from './mr-file-diffs' -import { derivePipelineStatus, mapMRInfo } from './mappers' import { getWorkItemDetails } from './work-item-details' describe('getWorkItemDetails', () => { @@ -52,416 +49,6 @@ describe('getWorkItemDetails', () => { }) }) - it.each(['head_pipeline', 'pipeline'])( - 'preserves a failed %s when no jobs exist', - async (field) => { - glabExecFileAsyncMock.mockImplementation(async (args: string[]) => ({ - stdout: - args.at(-1) === 'projects/g%2Fp/merge_requests/12' - ? JSON.stringify({ - iid: 12, - title: 'Invalid pipeline', - state: 'opened', - [field]: { - id: 99, - status: 'failed', - web_url: 'https://gitlab.com/g/p/-/pipelines/99' - } - }) - : '[]' - })) - - const details = await getWorkItemDetails( - '/repo', - 12, - 'mr', - undefined, - null, - undefined, - {}, - { - includeReviewDetails: false - } - ) - - expect(details).toMatchObject({ - pipeline: { - id: 99, - status: 'failed', - webUrl: 'https://gitlab.com/g/p/-/pipelines/99' - }, - pipelineJobs: [] - }) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4) - } - ) - - it.each([ - ['legacy', { merge_status: 'can_be_merged' }, 'pending'], - ['mergeable', { detailed_merge_status: 'mergeable' }, 'neutral'] - ] as const)( - 'reads a manual parent on a %s MR with the MR aggregate rule', - async (_label, mergeFacts, expected) => { - const mr = { - iid: 12, - title: 'Manual deploy', - state: 'opened', - has_conflicts: false, - ...mergeFacts, - head_pipeline: { id: 99, status: 'manual' } - } - glabExecFileAsyncMock.mockImplementation(async (args: string[]) => ({ - stdout: args.at(-1) === 'projects/g%2Fp/merge_requests/12' ? JSON.stringify(mr) : '[]' - })) - - const details = await getWorkItemDetails( - '/repo', - 12, - 'mr', - undefined, - null, - undefined, - {}, - { - includeReviewDetails: false - } - ) - - const aggregate = mapMRInfo(mr, derivePipelineStatus(mr.head_pipeline)).pipelineStatus - expect(aggregate).toBe(expected) - expect(details?.pipeline?.checkStatus).toBe(aggregate) - } - ) - - it('caps MR detail discussions, jobs, and file diffs to one API page', async () => { - glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const endpoint = args.at(-1) - if (endpoint === 'projects/g%2Fp/merge_requests/12') { - return { - stdout: JSON.stringify({ - id: 120, - iid: 12, - title: 'Bound detail payloads', - state: 'opened', - web_url: 'https://gitlab.com/g/p/-/merge_requests/12', - updated_at: '2026-05-31T12:00:00Z', - source_branch: 'feature/bounds', - target_branch: 'main', - description: 'MR body', - sha: 'head-sha', - diff_refs: { base_sha: 'base-sha', start_sha: 'start-sha' }, - head_pipeline: { id: 99 } - }) - } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/discussions?per_page=100') { - return { - stdout: JSON.stringify([ - { - id: 'discussion-1', - notes: [ - { - id: 1, - body: 'Review note', - created_at: '2026-05-31T12:01:00Z', - author: { username: 'alice', avatar_url: 'https://example.com/a.png' } - } - ] - } - ]) - } - } - if (endpoint === 'projects/g%2Fp/pipelines/99/jobs?per_page=100') { - return { - stdout: JSON.stringify([ - { - id: 10, - name: 'verify', - stage: 'test', - status: 'success', - web_url: 'https://gitlab.com/g/p/-/jobs/10', - duration: 12 - } - ]) - } - } - if (endpoint === 'projects/g%2Fp/pipelines/99/bridges?per_page=100') { - return { stdout: '[]' } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/reviewers') { - return { stdout: JSON.stringify([{ user: { id: 1, username: 'alice', name: 'Alice' } }]) } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/approvals') { - return { - stdout: JSON.stringify({ - approvals_required: 1, - approvals_left: 0, - approved_by: [{ user: { id: 1, username: 'alice', name: 'Alice' } }] - }) - } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/approval_state') { - return { - stdout: JSON.stringify({ - rules: [{ id: 5, name: 'Maintainer', approvals_required: 1, approved: true }] - }) - } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/diffs?per_page=100') { - return { - stdout: JSON.stringify([ - { - new_path: 'src/app.ts', - old_path: 'src/app.ts', - diff: '@@ -1 +1 @@\n-old\n+new' - } - ]) - } - } - throw new Error(`unexpected glab call: ${args.join(' ')}`) - }) - - const details = await getWorkItemDetails('/repo', 12, 'mr') - - expect(details?.comments).toHaveLength(1) - expect(details?.pipelineJobs).toHaveLength(1) - expect(details?.files).toHaveLength(1) - expect(details?.files?.[0]).toMatchObject({ - path: 'src/app.ts', - additions: 1, - deletions: 1 - }) - expect(details?.reviewers).toMatchObject([{ id: 1, username: 'alice' }]) - expect(details?.approvalState).toMatchObject({ - approvalsRequired: 1, - approvalsLeft: 0, - approvedBy: [{ id: 1, username: 'alice' }], - rules: [{ id: 5, approved: true }] - }) - expect(glabExecFileAsyncMock.mock.calls).toHaveLength(8) - expect(glabExecFileAsyncMock.mock.calls.map(([args]) => args)).toContainEqual([ - 'api', - 'projects/g%2Fp/merge_requests/12/diffs?per_page=100' - ]) - expect(glabExecFileAsyncMock.mock.calls.flatMap(([args]) => args)).not.toContain('--paginate') - - glabExecFileAsyncMock.mockClear() - const explicitFull = await getWorkItemDetails( - '/repo', - 12, - 'mr', - undefined, - undefined, - undefined, - {}, - { - includeReviewDetails: true - } - ) - expect(explicitFull).toEqual(details) - expect(glabExecFileAsyncMock.mock.calls).toHaveLength(8) - - glabExecFileAsyncMock.mockClear() - const checks = await getWorkItemDetails( - '/repo', - 12, - 'mr', - undefined, - undefined, - undefined, - {}, - { - includeReviewDetails: false - } - ) - expect(checks).toEqual({ ...details, files: [], reviewers: [], approvalState: undefined }) - expect(glabExecFileAsyncMock.mock.calls.map(([args]) => args.at(-1)).sort()).toEqual([ - 'projects/g%2Fp/merge_requests/12', - 'projects/g%2Fp/merge_requests/12/discussions?per_page=100', - 'projects/g%2Fp/pipelines/99/bridges?per_page=100', - 'projects/g%2Fp/pipelines/99/jobs?per_page=100' - ]) - }) - - it('expands bridge child-pipeline jobs into the checks list', async () => { - glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const endpoint = args.at(-1) - if (endpoint === 'projects/g%2Fp/merge_requests/12') { - return { - stdout: JSON.stringify({ - id: 120, - iid: 12, - title: 'Bridge pipeline', - state: 'opened', - web_url: 'https://gitlab.com/g/p/-/merge_requests/12', - updated_at: '2026-05-31T12:00:00Z', - source_branch: 'feature/bridges', - target_branch: 'main', - description: 'MR body', - sha: 'head-sha', - head_pipeline: { id: 99 } - }) - } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/discussions?per_page=100') { - return { stdout: '[]' } - } - if (endpoint === 'projects/g%2Fp/pipelines/99/jobs?per_page=100') { - return { - stdout: JSON.stringify([ - { - id: 10, - name: 'semgrep-sast', - stage: 'test', - status: 'success', - web_url: 'https://gitlab.com/g/p/-/jobs/10', - duration: 12 - } - ]) - } - } - if (endpoint === 'projects/g%2Fp/pipelines/99/bridges?per_page=100') { - return { - stdout: JSON.stringify([ - { - id: 50, - name: 'trigger-ci', - stage: 'test', - status: 'success', - web_url: 'https://gitlab.com/g/p/-/jobs/50', - downstream_pipeline: { - id: 200, - status: 'failed', - web_url: 'https://gitlab.com/g/p/-/pipelines/200' - } - } - ]) - } - } - if (endpoint === 'projects/g%2Fp/pipelines/200/jobs?per_page=100') { - return { - stdout: JSON.stringify([ - { - id: 300, - name: 'unit', - stage: 'test', - status: 'failed', - web_url: 'https://gitlab.com/g/p/-/jobs/300', - duration: 40 - }, - { - id: 301, - name: 'lint', - stage: 'test', - status: 'success', - web_url: 'https://gitlab.com/g/p/-/jobs/301', - duration: 20 - } - ]) - } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/reviewers') { - return { stdout: '[]' } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/approvals') { - return { stdout: JSON.stringify({ approvals_required: 0, approvals_left: 0 }) } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/approval_state') { - return { stdout: JSON.stringify({ rules: [] }) } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/diffs?per_page=100') { - return { stdout: '[]' } - } - throw new Error(`unexpected glab call: ${args.join(' ')}`) - }) - - const details = await getWorkItemDetails('/repo', 12, 'mr') - const names = (details?.pipelineJobs ?? []).map((job) => job.name).sort() - expect(names).toEqual(['lint', 'semgrep-sast', 'trigger-ci', 'unit']) - expect(details?.pipelineJobs?.find((job) => job.name === 'unit')).toMatchObject({ - id: 300, - status: 'failed', - pipelineId: 200 - }) - expect(details?.pipelineJobs?.find((job) => job.name === 'trigger-ci')).toMatchObject({ - id: 0, - status: 'failed', - pipelineId: 99 - }) - - glabExecFileAsyncMock.mockClear() - const narrow = await getWorkItemDetails( - '/repo', - 12, - 'mr', - undefined, - undefined, - undefined, - {}, - { - includeReviewDetails: false - } - ) - expect(narrow).toEqual({ ...details, approvalState: undefined }) - expect(glabExecFileAsyncMock.mock.calls.map(([args]) => args.at(-1)).sort()).toEqual([ - 'projects/g%2Fp/merge_requests/12', - 'projects/g%2Fp/merge_requests/12/discussions?per_page=100', - 'projects/g%2Fp/pipelines/200/jobs?per_page=100', - 'projects/g%2Fp/pipelines/99/bridges?per_page=100', - 'projects/g%2Fp/pipelines/99/jobs?per_page=100' - ]) - }) - - it('preserves issue details when review enrichment is disabled', async () => { - glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args.at(-1) === 'projects/g%2Fp/issues/12') { - return { - stdout: JSON.stringify({ - iid: 12, - title: 'Issue', - state: 'opened', - description: 'Issue body', - assignees: [{ username: 'alice' }] - }) - } - } - if (args.at(-1) === 'projects/g%2Fp/issues/12/discussions?per_page=100') { - return { - stdout: JSON.stringify([ - { - id: 'issue-thread', - notes: [{ id: 7, body: 'Issue note', author: { username: 'alice' } }] - } - ]) - } - } - throw new Error(`unexpected issue detail endpoint: ${args.at(-1)}`) - }) - const full = await getWorkItemDetails('/repo', 12, 'issue') - const fullCalls = glabExecFileAsyncMock.mock.calls.map(([args]) => args) - expect(full).toMatchObject({ - body: 'Issue body', - assignees: ['alice'], - comments: [{ body: 'Issue note' }] - }) - glabExecFileAsyncMock.mockClear() - const narrow = await getWorkItemDetails( - '/repo', - 12, - 'issue', - undefined, - undefined, - undefined, - {}, - { - includeReviewDetails: false - } - ) - expect(narrow).toEqual(full) - expect(glabExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual(fullCalls) - expect(fullCalls).toHaveLength(2) - }) - // Every child fetch launches glab; bound the pool during Checks polling. it('bounds concurrent child-pipeline fetches', async () => { const BRIDGE_COUNT = 25 @@ -523,124 +110,4 @@ describe('getWorkItemDetails', () => { expect(peakJobPages).toBeLessThanOrEqual(5) expect(details?.pipelineJobs).toHaveLength(BRIDGE_COUNT) }) - - it('routes local WSL MR detail fetches through project resolution and glab options', async () => { - const localGitOptions = { wslDistro: 'Ubuntu' } - glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const endpoint = args.at(-1) - if (endpoint === 'projects/g%2Fp/merge_requests/12') { - return { - stdout: JSON.stringify({ - id: 120, - iid: 12, - title: 'WSL detail', - state: 'opened', - web_url: 'https://gitlab.com/g/p/-/merge_requests/12', - updated_at: '2026-06-16T00:00:00Z', - description: 'MR body', - sha: 'head-sha', - head_pipeline: null, - reviewers: [] - }) - } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/discussions?per_page=100') { - return { stdout: '[]' } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/reviewers') { - return { stdout: '[]' } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/approvals') { - return { stdout: JSON.stringify({ approvals_required: 0, approvals_left: 0 }) } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/approval_state') { - return { stdout: JSON.stringify({ rules: [] }) } - } - if (endpoint === 'projects/g%2Fp/merge_requests/12/diffs?per_page=100') { - return { stdout: '[]' } - } - throw new Error(`unexpected glab call: ${args.join(' ')}`) - }) - - const details = await getWorkItemDetails( - '/repo', - 12, - 'mr', - undefined, - null, - undefined, - localGitOptions - ) - - expect(details?.item.number).toBe(12) - expect(resolveIssueSourceMock).toHaveBeenCalledWith( - '/repo', - undefined, - ['gitlab.com'], - null, - localGitOptions - ) - expect(glabExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( - true - ) - }) -}) - -describe('GitLab MR file diff counts', () => { - async function fileFor(diff: string) { - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([{ new_path: 'src/app.ts', diff }]) - }) - const files = await fetchMRFiles('/repo', { host: 'gitlab.com', path: 'g/p' }, 12) - expect(files).toHaveLength(1) - return files[0] - } - - beforeEach(() => glabExecFileAsyncMock.mockReset()) - - it('counts header-like content inside the header-less payload GitLab returns', async () => { - expect(await fileFor('@@ -1 +1 @@\n--- old comment\n+++ new comment')).toMatchObject({ - additions: 1, - deletions: 1 - }) - }) - - it('ignores prefixes before the first hunk', async () => { - expect(await fileFor('--- a/x\n+++ b/x\n+not a hunk\n-unknown')).toMatchObject({ - additions: 0, - deletions: 0 - }) - }) - - it('accumulates asymmetric counts across hunks', async () => { - expect(await fileFor('@@ -1 +1 @@\n-a\n+b\n@@ -5 +5,2 @@\n-c\n+d\n+e')).toMatchObject({ - additions: 3, - deletions: 2 - }) - }) - - it('counts CRLF and Unicode content using line prefixes', async () => { - expect(await fileFor('@@ -1 +1 @@\r\n-é\r\n+🚀\r\n')).toMatchObject({ - additions: 1, - deletions: 1 - }) - }) - - it('treats a lone CR as content within a removed line', async () => { - expect(await fileFor('@@ -1 +1 @@\n-old\r+new')).toMatchObject({ - additions: 0, - deletions: 1 - }) - }) - - it('counts a large diff without splitting it into an array of lines', async () => { - const diff = `@@ -1 +1 @@\n${'-old\n+new\n context\n'.repeat(10000)}` - const split = vi.spyOn(String.prototype, 'split') - try { - expect(await fileFor(diff)).toMatchObject({ additions: 10000, deletions: 10000 }) - expect(split).not.toHaveBeenCalled() - } finally { - split.mockRestore() - } - }) }) diff --git a/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot.test.ts b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot.test.ts index 6f4ba400a79..f5c58ce5416 100644 --- a/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot.test.ts +++ b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot.test.ts @@ -151,31 +151,6 @@ it("keeps a WSL host's projects across a restart, with only the row fields", () }) }) -it('replaces the saved list with each answer and saves only when it changed', () => { - const { open } = profile() - const store = open() - let writes = 0 - store.onStateWritten((domains) => { - if (domains?.includes('hostRepoCatalogSnapshots')) { - writes++ - } - }) - recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld, django])) - expect(writes).toBe(1) - - // Every catalog refresh lands here; an identical answer must not schedule a save. - recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld, django])) - expect(writes).toBe(1) - - // django was deleted on the host. - recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld])) - expect(writes).toBe(2) - store.flushOrThrow() - expect( - listHostRepoCatalogSnapshots(open(), [wslHost])['env-wsl']?.repos.map((repo) => repo.id) - ).toEqual(['hello']) -}) - it('keeps each distro separately and ignores failures, other methods and paired servers', () => { const { open } = profile() const store = open() @@ -204,24 +179,6 @@ it('keeps each distro separately and ignores failures, other methods and paired expect(stored['env-wsl-2']?.repos.map((repo) => repo.id)).toEqual(['django']) }) -it('lists only WSL hosts, even when a paired server has a saved entry', () => { - const { open } = profile() - const store = open() - store.setHostRepoCatalogSnapshot('env-paired', { repos: [notes] }) - store.setHostRepoCatalogSnapshot('env-wsl', { repos: [django] }) - expect(Object.keys(listHostRepoCatalogSnapshots(store, [wslHost, pairedServer]))).toEqual([ - 'env-wsl' - ]) -}) - -it('never lists a host no longer saved (removed outside Settings)', () => { - const { open } = profile() - const store = open() - recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld])) - recordHostRepoCatalogAnswer(store, otherWslHost, 'repo.list', repoListAnswer([django])) - expect(Object.keys(listHostRepoCatalogSnapshots(store, [wslHost]))).toEqual(['env-wsl']) -}) - it('does not seed stale projects when a removed WSL host is added back under the same id', async () => { const { open } = profile() const store = open() diff --git a/src/main/host/native-chat-visual-guest-theme.test.ts b/src/main/host/native-chat-visual-guest-theme.test.ts index 150f0cf0363..a3dd2e7ca48 100644 --- a/src/main/host/native-chat-visual-guest-theme.test.ts +++ b/src/main/host/native-chat-visual-guest-theme.test.ts @@ -11,8 +11,7 @@ import { } from '../browser/site-settings/browser-site-display-store' import { NATIVE_CHAT_VISUAL_GUEST_THEME_PUBLISH as PUBLISH, - NATIVE_CHAT_VISUAL_GUEST_THEME_READ as READ, - NATIVE_CHAT_VISUAL_GUEST_THEME_UPDATE as UPDATE + NATIVE_CHAT_VISUAL_GUEST_THEME_READ as READ } from '../../shared/native-chat/native-chat-visual-shell' const { ipc, classify, display, profileForSession, sshProfileForSession } = vi.hoisted(() => ({ @@ -120,91 +119,6 @@ describe('visual guest theme handoff', () => { expect(ordinary.returnValue).toBeNull() }) - it('updates once per theme, releases navigating/destroyed guests and removes listeners with host', () => { - const { host, guest, event, publish, read } = setup() - publish(event(host), dark) - read(event()) - read(event()) - expect(display.onGuestDocumentStart).toHaveBeenCalledWith(guest, { - url: guest.mainFrame.url, - profileId: 'default', - colorScheme: 'dark' - }) - publish(event(host), dark) - expect(guest.send).not.toHaveBeenCalled() - const light = { ...dark, colorScheme: 'light' } - publish(event(host), light) - expect(guest.send).toHaveBeenCalledExactlyOnceWith( - UPDATE, - expect.objectContaining({ colorScheme: 'light' }) - ) - guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: true }) - publish(event(host), dark) - expect(guest.send).toHaveBeenCalledTimes(2) - guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) - publish(event(host), light) - expect(guest.send).toHaveBeenCalledTimes(3) - guest.emit('did-navigate', {}, guest.mainFrame.url) - expect(display.onMainFrameNavigationCommitted).toHaveBeenCalledExactlyOnceWith(guest.id) - publish(event(host), dark) - expect(guest.send).toHaveBeenCalledTimes(4) - classify.mockReturnValue(false) - guest.emit('did-navigate', {}, 'file:///workspace/ordinary.html') - expect(display.setGuestColorSchemeFallback).toHaveBeenLastCalledWith(guest, undefined) - publish(event(host), light) - expect(guest.send).toHaveBeenCalledTimes(4) - classify.mockReturnValue(true) - read(event()) - guest.emit('destroyed') - expect(display.onGuestRetired).toHaveBeenCalledExactlyOnceWith(guest.id) - publish(event(host), dark) - expect(guest.send).toHaveBeenCalledTimes(4) - host.emit('destroyed') - expect(ipc.removeListener).toHaveBeenCalledTimes(2) - }) - - it.each([10, 100])( - 'bounds theme broadcasts to one operation per live guest at population %s', - (population) => { - const { host, event, publish, read } = setup() - publish(event(host), dark) - const guests = Array.from({ length: population }, (_, index) => contents(index + 10, host)) - for (const guest of guests) { - read(event(guest)) - read(event(guest)) - expect(guest.listenerCount('did-navigate')).toBe(1) - expect(guest.listenerCount('destroyed')).toBe(1) - } - expect(profileForSession).toHaveBeenCalledTimes(2 * population) - display.setGuestColorSchemeFallback.mockClear() - profileForSession.mockClear() - publish(event(host), dark) - expect(display.setGuestColorSchemeFallback).not.toHaveBeenCalled() - publish(event(host), { ...dark, colorScheme: 'light' }) - expect(profileForSession).not.toHaveBeenCalled() - expect(display.setGuestColorSchemeFallback).toHaveBeenCalledTimes(population) - expect(guests.reduce((sum, guest) => sum + guest.send.mock.calls.length, 0)).toBe(population) - for (const guest of guests) { - guest.emit('destroyed') - } - publish(event(host), dark) - expect(guests.reduce((sum, guest) => sum + guest.send.mock.calls.length, 0)).toBe(population) - } - ) - - it('resolves the prepared SSH profile only after local Session lookup misses', () => { - const { host, guest, event, publish, read } = setup() - profileForSession.mockReturnValue(null) - sshProfileForSession.mockReturnValue('ssh-work') - publish(event(host), dark) - read(event()) - expect(sshProfileForSession).toHaveBeenCalledExactlyOnceWith(guest.session) - expect(display.onGuestDocumentStart).toHaveBeenCalledWith( - guest, - expect.objectContaining({ profileId: 'ssh-work' }) - ) - }) - it('applies saved media at the authenticated frame URL before every synchronous reply', () => { const directory = mkdtempSync(join(tmpdir(), 'orca-visual-media-')) configureBrowserSiteDisplayStore(directory) diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index c0e6cbb9813..fcce63080a8 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -128,358 +128,7 @@ afterEach(() => { vi.resetModules() }) -describe('agentStatus:getSnapshot IPC', () => { - it('returns the hook cache snapshot', async () => { - const snapshot = [ - { - paneKey: PANE_KEY, - state: 'done', - prompt: 'p', - agentType: 'claude', - receivedAt: 1_700_000_000_000, - stateStartedAt: 1_699_999_999_000 - } - ] - getStatusSnapshot.mockReturnValue(snapshot) - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = handleHandlers.get('agentStatus:getSnapshot') - expect(handler).toBeDefined() - expect(handler!({})).toEqual(snapshot) - }) - - // Keep one writer per pane while the renderer feed bridge owns structured rows. - it('omits structured rows the renderer feed bridge still owns', async () => { - getStatusSnapshot.mockReturnValue([ - { - paneKey: PANE_KEY, - state: 'done', - prompt: 'hook row', - agentType: 'claude', - connectionId: null, - receivedAt: 1_700_000_000_000, - stateStartedAt: 1_699_999_999_000 - }, - { - paneKey: CHILD_PANE_KEY, - state: 'working', - prompt: 'native chat row', - agentType: 'codex', - connectionId: null, - structuredHost: 'owned', - receivedAt: 1_700_000_001_000, - stateStartedAt: 1_700_000_000_500 - } - ]) - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const rows = handleHandlers.get('agentStatus:getSnapshot')!({}) as { paneKey: string }[] - expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY]) - }) - - it('enriches the hook cache snapshot with runtime lineage metadata', async () => { - const snapshot = [ - { - paneKey: PANE_KEY, - state: 'done', - prompt: 'parent', - agentType: 'codex', - connectionId: null, - receivedAt: 1_700_000_000_000, - stateStartedAt: 1_699_999_999_000 - }, - { - paneKey: CHILD_PANE_KEY, - state: 'done', - prompt: 'child', - agentType: 'codex', - connectionId: null, - receivedAt: 1_700_000_001_000, - stateStartedAt: 1_700_000_000_500 - } - ] - getStatusSnapshot.mockReturnValue(snapshot) - const runtime = { - getAgentStatusTerminalHandleForPaneKey: vi.fn((paneKey: string) => - paneKey === PANE_KEY ? 'term-parent' : paneKey === CHILD_PANE_KEY ? 'term-child' : undefined - ), - getAgentStatusOrchestrationContextForPaneKey: vi.fn((paneKey: string) => - paneKey === CHILD_PANE_KEY - ? { - taskId: 'task-child', - dispatchId: 'dispatch-child', - parentTerminalHandle: 'term-parent', - parentPaneKey: PANE_KEY, - coordinatorHandle: 'term-parent' - } - : undefined - ), - getTerminalProcessIncarnation: vi.fn(() => 'pty-1:inc-1') - } - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers(runtime) - - const handler = handleHandlers.get('agentStatus:getSnapshot') - expect(handler).toBeDefined() - expect(handler!({})).toEqual([ - { - ...snapshot[0], - terminalHandle: 'term-parent' - }, - { - ...snapshot[1], - terminalHandle: 'term-child', - orchestration: { - taskId: 'task-child', - dispatchId: 'dispatch-child', - parentTerminalHandle: 'term-parent', - parentPaneKey: PANE_KEY, - coordinatorHandle: 'term-parent' - } - } - ]) - }) -}) - -describe('agentStatus:inferInterrupt IPC', () => { - it('forwards valid inference requests to the hook server', async () => { - inferInterrupt.mockReturnValue(true) - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = handleHandlers.get('agentStatus:inferInterrupt') - expect(handler).toBeDefined() - const request = { - paneKey: PANE_KEY, - baselineUpdatedAt: 1_000, - baselineStateStartedAt: 900, - baselinePrompt: 'long task', - baselineAgentType: 'codex', - intent: 'ctrl-c' - } - - expect(handler!({}, request)).toBe(true) - expect(inferInterrupt).toHaveBeenCalledWith(request) - }) - - it('rejects malformed requests before the hook server boundary', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = handleHandlers.get('agentStatus:inferInterrupt') - expect(handler).toBeDefined() - for (const value of [null, undefined, '', 123, true]) { - expect(handler!({}, value)).toBe(false) - } - expect(inferInterrupt).not.toHaveBeenCalled() - }) -}) - -describe('agentStatus:drop IPC', () => { - it('forwards drop to dropStatusEntry', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = onHandlers.get('agentStatus:drop') - expect(handler).toBeDefined() - handler!({}, PANE_KEY) - expect(dropStatusEntry).toHaveBeenCalledWith(PANE_KEY) - expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY) - }) - - it('forwards a runtime-owned legacy numeric row dismissal', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = onHandlers.get('agentStatus:drop')! - handler!({}, 'tab-1:0') - - expect(dropStatusEntry).toHaveBeenCalledWith('tab-1:0') - expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith('tab-1:0') - }) - - it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = onHandlers.get('agentStatus:drop')! - const bad: unknown[] = [ - 123, - undefined, - '', - null, - {}, - [], - 'no-colon', // missing colon — rejected by isValidPaneKey - ':leading', // empty tabId half - 'trailing:', // empty leafId half - 'a:b:c' // multiple colons - ] - for (const value of bad) { - expect(() => handler({}, value)).not.toThrow() - } - expect(dropStatusEntry).not.toHaveBeenCalled() - }) -}) - -describe('agentStatus:dropPersisted IPC', () => { - it('forwards a validated cache identity without clearing pane state', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = onHandlers.get('agentStatus:dropPersisted') - expect(handler).toBeDefined() - const identity = { - paneKey: PANE_KEY, - receivedAt: 2_000, - stateStartedAt: 1_000 - } - handler!({}, identity) - expect(dropPersistedStatusEntry).toHaveBeenCalledWith(identity) - expect(dropStatusEntry).not.toHaveBeenCalled() - }) - - it('forwards a batch, keeping only valid identities, and clears migration state per evicted pane', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = onHandlers.get('agentStatus:dropPersistedBatch') - expect(handler).toBeDefined() - const good = { paneKey: PANE_KEY, receivedAt: 2_000, stateStartedAt: 1_000 } - const alsoGood = { paneKey: CHILD_PANE_KEY, receivedAt: 3_000, stateStartedAt: 2_500 } - dropPersistedStatusEntries.mockReturnValue([PANE_KEY]) - handler!({}, [good, { paneKey: 'not-a-pane-key', receivedAt: 1, stateStartedAt: 1 }, alsoGood]) - expect(dropPersistedStatusEntries).toHaveBeenCalledWith([good, alsoGood]) - expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY) - expect(clearMigrationUnsupportedPtysForPaneKey).not.toHaveBeenCalledWith(CHILD_PANE_KEY) - expect(dropPersistedStatusEntry).not.toHaveBeenCalled() - }) - - it('ignores a batch that is not an array or is empty after validation', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = onHandlers.get('agentStatus:dropPersistedBatch')! - for (const value of [null, {}, 'x', [], [{ paneKey: PANE_KEY }]]) { - expect(() => handler({}, value)).not.toThrow() - } - expect(dropPersistedStatusEntries).not.toHaveBeenCalled() - }) - - it('rejects malformed cache identities', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = onHandlers.get('agentStatus:dropPersisted')! - for (const value of [ - null, - undefined, - {}, - { paneKey: PANE_KEY }, - { paneKey: PANE_KEY, receivedAt: Number.NaN, stateStartedAt: 1 }, - { paneKey: PANE_KEY, receivedAt: 2, stateStartedAt: Number.POSITIVE_INFINITY }, - { paneKey: 'not-a-pane-key', receivedAt: 2, stateStartedAt: 1 }, - { paneKey: PANE_KEY, receivedAt: '2', stateStartedAt: 1 } - ]) { - expect(() => handler({}, value)).not.toThrow() - } - expect(dropPersistedStatusEntry).not.toHaveBeenCalled() - }) -}) - -describe('agentStatus:dropByTabPrefix IPC', () => { - it('forwards valid tab ids to tab-prefix cache eviction', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = onHandlers.get('agentStatus:dropByTabPrefix') - expect(handler).toBeDefined() - handler!({}, 'tab-1') - expect(dropStatusEntriesByTabPrefix).toHaveBeenCalledWith('tab-1') - expect(clearMigrationUnsupportedPtysByTabPrefix).toHaveBeenCalledWith('tab-1') - }) - - it('rejects malformed tab ids', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - const handler = onHandlers.get('agentStatus:dropByTabPrefix')! - const bad: unknown[] = [ - 123, - undefined, - '', - null, - {}, - [], - 'tab-1:leaf', - ' leading-space', - 'trailing-space ', - 'x'.repeat(161) - ] - for (const value of bad) { - expect(() => handler({}, value)).not.toThrow() - } - expect(dropStatusEntriesByTabPrefix).not.toHaveBeenCalled() - expect(clearMigrationUnsupportedPtysByTabPrefix).not.toHaveBeenCalled() - }) - - it('removes any existing listener before registering the tab-prefix channel', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:dropByTabPrefix') - }) -}) - describe('agent pane authority IPC', () => { - it('retires one validated pane authority', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - onHandlers.get('agentStatus:retirePaneAuthority')!({}, PANE_KEY) - - expect(retirePaneAuthority).toHaveBeenCalledWith(PANE_KEY, undefined) - expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY) - }) - - it('transfers validated pane authority with its provider PTY', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - onHandlers.get('agentStatus:transferPaneAuthority')!( - {}, - { - fromPaneKey: PANE_KEY, - toPaneKey: CHILD_PANE_KEY, - ptyId: 'pty-1' - } - ) - - expect(transferPaneAuthority).toHaveBeenCalledWith(PANE_KEY, CHILD_PANE_KEY, 'pty-1') - }) - - it('rejects malformed pane authority messages and replaces prior listeners', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - onHandlers.get('agentStatus:retirePaneAuthority')!({}, 'tab-1:0') - onHandlers.get('agentStatus:transferPaneAuthority')!( - {}, - { - fromPaneKey: PANE_KEY, - toPaneKey: 'invalid', - ptyId: '' - } - ) - - expect(retirePaneAuthority).not.toHaveBeenCalled() - expect(transferPaneAuthority).not.toHaveBeenCalled() - expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:retirePaneAuthority') - expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:transferPaneAuthority') - }) - it('rejects unowned and oversized pane authority transfers', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') registerAgentHookHandlers() diff --git a/src/main/ipc/ai-vault/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault/ai-vault-scan-coalescing.test.ts index 724ce6cb857..caaaecc59ec 100644 --- a/src/main/ipc/ai-vault/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault/ai-vault-scan-coalescing.test.ts @@ -82,17 +82,6 @@ describe('Agent Session History scan coalescing', () => { expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(2) }) - it('does not forward conditional hints into truncated all-host merges', async () => { - registerRuntimeHost() - await _internals.listAiVaultSessions({ - executionHostScope: 'all', - afterFingerprint: 'a'.repeat(64) - }) - expect(mocks.scanRuntimeAiVaultSessions.mock.calls[0]?.[1]).not.toHaveProperty( - 'afterFingerprint' - ) - }) - it.each([ ['local', mocks.scanAiVaultSessionsInService], ['runtime:remote-server', mocks.scanRuntimeAiVaultSessions] @@ -148,24 +137,6 @@ describe('Agent Session History scan coalescing', () => { await expect(beforeDelete).resolves.toMatchObject({ scannedAt: EMPTY_RESULT.scannedAt }) } ) - - it('reports a failed local scan as a host issue rather than rejecting', async () => { - mocks.scanAiVaultSessionsInService.mockRejectedValue(new Error('transcript root is unreadable')) - registerAiVaultHandlers() - const list = ipcHandler('aiVault:listSessions') - - // Like SSH failures, local failures must become issue rows instead of rejected IPC calls. - const result = await list( - { sender: Object.assign(new EventEmitter(), { id: 1 }) }, - { executionHostScope: 'local', requestToken: 'scan' } - ) - expect(result).toMatchObject({ - sessions: [], - scanId: expect.any(String), - issues: [expect.objectContaining({ message: 'transcript root is unreadable', kind: 'host' })] - }) - expect(result).not.toHaveProperty('cancelled') - }) }) function registerRuntimeHost(): void { diff --git a/src/main/ipc/ai-vault/ai-vault-search-all-hosts-real-index.test.ts b/src/main/ipc/ai-vault/ai-vault-search-all-hosts-real-index.test.ts index ad8e3f2c005..b64d0911e23 100644 --- a/src/main/ipc/ai-vault/ai-vault-search-all-hosts-real-index.test.ts +++ b/src/main/ipc/ai-vault/ai-vault-search-all-hosts-real-index.test.ts @@ -139,30 +139,6 @@ it('hands out every hit on every host exactly once, at limit 5 and limit 20', as } }) -it('orders a newest merge by recency across hosts, newest first', async () => { - const response = resultsOf( - await searchAllExecutionHosts( - { query: 'needle', limit: 20, filters: { sort: 'newest' } }, - legs() - ) - ) - const updated = response.hits.map((hit) => hit.updatedAt) - expect(updated).toEqual([...updated].sort().toReversed()) - expect(new Set(response.hits.map((hit) => hit.executionHostId)).size).toBeGreaterThan(1) -}) - -it('rotates hosts in host-id order when merging by relevance', async () => { - const response = resultsOf(await searchAllExecutionHosts({ query: 'needle', limit: 6 }, legs())) - expect(response.hits.map((hit) => hit.executionHostId)).toEqual([ - 'local', - 'ssh:alpha', - 'ssh:beta', - 'local', - 'ssh:alpha', - 'ssh:beta' - ]) -}) - it('fences the purged host and keeps the other two paginating', async () => { const request = { query: 'needle', limit: 5 } const first = resultsOf(await searchAllExecutionHosts(request, legs())) @@ -245,20 +221,3 @@ it('reports an unreachable host, keeps hasMore, and picks it up on the retry', a expect(new Set(seen).size).toBe(seen.length) expect([...seen].sort()).toEqual(everyKey().sort()) }) - -it('reports a disabled host without aborting the merge', async () => { - hosts[2]!.instance.apply({ enabled: false, historyDays: null }) - const { keys } = await paginate(5, 'relevance') - expect(new Set(keys).size).toBe(keys.length) - expect([...keys].sort()).toEqual( - [ - ...hosts[0]!.sessionIds.map((id) => `local/${id}`), - ...hosts[1]!.sessionIds.map((id) => `ssh:alpha/${id}`) - ].sort() - ) - const first = resultsOf(await searchAllExecutionHosts({ query: 'needle', limit: 5 }, legs())) - expect(first.hosts).toContainEqual({ - executionHostId: 'ssh:beta', - outcome: 'disabled' - }) -}) diff --git a/src/main/ipc/ai-vault/ai-vault-search-all-hosts.test.ts b/src/main/ipc/ai-vault/ai-vault-search-all-hosts.test.ts index 8901820c854..5a2e394c738 100644 --- a/src/main/ipc/ai-vault/ai-vault-search-all-hosts.test.ts +++ b/src/main/ipc/ai-vault/ai-vault-search-all-hosts.test.ts @@ -6,7 +6,6 @@ import type { } from '../../../shared/ai-vault/search/ai-vault-search-types' import type { ExecutionHostId } from '../../../shared/execution-host' import { searchAllExecutionHosts, type SessionSearchHostLeg } from './ai-vault-search-all-hosts' -import { encodeMergedSearchCursor } from './ai-vault-search-merged-cursor' /** A host that ranks a fixed list and fences its own cursors on a generation change. */ class StubHost { @@ -107,131 +106,6 @@ function resultsOf(response: AiVaultSearchResponse) { return response } -function unavailableLeg( - executionHostId: ExecutionHostId, - reason: 'disabled' | 'not-ready' | 'no-service' -): SessionSearchHostLeg { - return { - executionHostId, - search: () => Promise.resolve({ kind: 'unavailable', reason }) - } -} - -it('answers an empty merge when no host is reachable to ask', async () => { - const response = resultsOf(await searchAllExecutionHosts({ query: 'needle' }, [])) - expect(response).toMatchObject({ - hits: [], - page: { cursor: null, hasMore: false }, - generation: 0, - hosts: [] - }) -}) - -it('asks every leg for the merged order and passes freshness through unchanged', async () => { - const host = new StubHost('local', sessions('a', 2)) - await searchAllExecutionHosts( - { - query: 'needle', - freshness: 'wait-until-current', - filters: { sort: 'newest' }, - debug: true - }, - [host.leg()] - ) - expect(host.lastRequests[0]).toEqual({ - query: 'needle', - freshness: 'wait-until-current', - filters: { sort: 'newest' } - }) -}) - -it('forces the merged order onto a leg the caller left to default', async () => { - const host = new StubHost('local', sessions('a', 2)) - await searchAllExecutionHosts({ query: 'needle' }, [host.leg()]) - expect(host.lastRequests[0]?.filters).toEqual({ sort: 'relevance' }) -}) - -it('stamps every hit with the host the desktop addressed', async () => { - const host = new StubHost('ssh:box', sessions('a', 2)) - const response = resultsOf(await searchAllExecutionHosts({ query: 'needle' }, [host.leg()])) - expect(response.hits.map((hit) => hit.executionHostId)).toEqual(['ssh:box', 'ssh:box']) -}) - -it('merges newest first across hosts, with undated hits last', async () => { - const left = new StubHost('local', [ - { id: 'l-new', updatedAt: '2026-09-09T00:00:00.000Z' }, - { id: 'l-none', updatedAt: null } - ]) - const right = new StubHost('ssh:box', [ - { id: 'r-mid', updatedAt: '2026-09-05T00:00:00.000Z' }, - { id: 'r-old', updatedAt: '2026-09-01T00:00:00.000Z' } - ]) - const response = resultsOf( - await searchAllExecutionHosts({ query: 'needle', filters: { sort: 'newest' } }, [ - left.leg(), - right.leg() - ]) - ) - expect(response.hits.map((hit) => hit.sessionId)).toEqual(['l-new', 'r-mid', 'r-old', 'l-none']) -}) - -it('breaks a recency tie on execution host id', async () => { - const at = '2026-09-05T00:00:00.000Z' - const response = resultsOf( - await searchAllExecutionHosts({ query: 'needle', filters: { sort: 'newest' } }, [ - new StubHost('ssh:box', [{ id: 'later-host', updatedAt: at }]).leg(), - new StubHost('local', [{ id: 'earlier-host', updatedAt: at }]).leg() - ]) - ) - expect(response.hits.map((hit) => hit.sessionId)).toEqual(['earlier-host', 'later-host']) -}) - -it('rotates hosts by host-id order when merging by relevance', async () => { - const response = resultsOf( - await searchAllExecutionHosts({ query: 'needle', limit: 4 }, [ - new StubHost('ssh:box', sessions('b', 3)).leg(), - new StubHost('local', sessions('a', 3)).leg() - ]) - ) - expect(response.hits.map((hit) => hit.sessionId)).toEqual(['a-0', 'b-0', 'a-1', 'b-1']) -}) - -it('reports a host that refuses its own cursor as stale and keeps the merge going', async () => { - const healthy = new StubHost('local', sessions('a', 6)) - const purged = new StubHost('ssh:box', sessions('b', 6)) - const legs = [healthy.leg(), purged.leg()] - const first = resultsOf(await searchAllExecutionHosts({ query: 'needle', limit: 4 }, legs)) - purged.purge() - const second = resultsOf( - await searchAllExecutionHosts({ query: 'needle', limit: 4, cursor: first.page.cursor! }, legs) - ) - expect(second.hosts).toEqual([ - { executionHostId: 'local', outcome: 'searched' }, - { executionHostId: 'ssh:box', outcome: 'stale' } - ]) - expect(second.hits.every((hit) => hit.executionHostId === 'local')).toBe(true) -}) - -it('names an unavailable host by its reason without losing the other hosts', async () => { - const healthy = new StubHost('local', sessions('a', 2)) - const response = resultsOf( - await searchAllExecutionHosts({ query: 'needle' }, [ - healthy.leg(), - unavailableLeg('ssh:off', 'disabled'), - unavailableLeg('ssh:cold', 'not-ready'), - unavailableLeg('runtime:old', 'no-service') - ]) - ) - expect(response.hosts).toEqual([ - { executionHostId: 'local', outcome: 'searched' }, - { executionHostId: 'runtime:old', outcome: 'no-service' }, - { executionHostId: 'ssh:cold', outcome: 'not-ready' }, - { executionHostId: 'ssh:off', outcome: 'disabled' } - ]) - expect(response.hits).toHaveLength(2) - expect(response.page.hasMore).toBe(false) -}) - it('calls a leg that times out unreachable and retries it on the next page', async () => { vi.useFakeTimers() try { @@ -288,50 +162,3 @@ it('reads at most three pages from one host per merged request', async () => { 'a-11' ]) }) - -it('refuses a cursor that belongs to a different query or host set', async () => { - const host = new StubHost('local', sessions('a', 6)) - const first = resultsOf( - await searchAllExecutionHosts({ query: 'needle', limit: 2 }, [host.leg()]) - ) - const refused = [ - { query: 'needle', limit: 4, cursor: first.page.cursor! }, - { - query: 'needle', - limit: 2, - filters: { sort: 'newest' as const }, - cursor: first.page.cursor! - }, - { query: 'needle', limit: 2, cursor: 'not a cursor' }, - { - query: 'needle', - limit: 2, - cursor: encodeMergedSearchCursor({ - limit: 2, - sort: 'relevance', - hosts: { 'ssh:gone': { c: null, e: 0, g: 1 } } - }) - } - ] - for (const request of refused) { - expect(await searchAllExecutionHosts(request, [host.leg()])).toEqual({ - kind: 'malformed-cursor' - }) - } -}) - -it('sums truncation across the hosts it searched', async () => { - const response = resultsOf( - await searchAllExecutionHosts({ query: 'needle' }, [ - new StubHost('local', sessions('a', 2)).leg(), - new StubHost('ssh:box', sessions('b', 2)).leg(), - unavailableLeg('ssh:off', 'disabled') - ]) - ) - expect(response.truncated).toEqual({ - candidates: false, - snippets: 2, - query: false, - freshness: false - }) -}) diff --git a/src/main/ipc/ai-vault/ai-vault-search.test.ts b/src/main/ipc/ai-vault/ai-vault-search.test.ts index e406582696e..9be7fb237b6 100644 --- a/src/main/ipc/ai-vault/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault/ai-vault-search.test.ts @@ -1,4 +1,3 @@ -import { agentSessionConversationView } from '../../../shared/agent-session/lifecycle/agent-session-conversation-view' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { clearSearch, handlers, sshSearch, sshHostInfos, runtimeSearch } = vi.hoisted(() => ({ @@ -26,15 +25,7 @@ vi.mock('../ssh/ssh', () => ({ import { registerAiVaultSearchHandlers } from './ai-vault-search' import { aiVaultApi } from '../../../preload/api/ai-vault-bridge' import { setSessionSearchService } from '../../ai-vault-search/session-search-service-registry' -import { unavailableSessionSearchStatus } from '../../../shared/ai-vault/search/ai-vault-search-client' -import { AI_VAULT_AGENTS } from '../../../shared/ai-vault/contracts/ai-vault-types' -import { - fakeSearchService, - searchHit, - searchResults -} from '../../../shared/ai-vault/search/ai-vault-search-test-fixture' -import { agentSessionRecordFixture } from '../../../shared/agent-session/lifecycle/agent-session-record.test-fixture' -import { codexProviderHandle } from '../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding' +import { fakeSearchService } from '../../../shared/ai-vault/search/ai-vault-search-test-fixture' import { setStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' beforeEach(() => { handlers.clear() @@ -53,102 +44,6 @@ afterEach(() => { }) describe('desktop IPC and preload search boundary', () => { - it('round-trips local results and separate status through the actual preload', async () => { - setSessionSearchService(fakeSearchService()) - expect(await aiVaultApi.searchSessions({ query: 'needle' })).toMatchObject({ - kind: 'results', - hits: [ - { - source: { presence: 'present', filePath: '/host/transcript.jsonl' }, - resumeCommand: 'host-resume-command' - } - ] - }) - expect(await aiVaultApi.searchSessions({ query: 'needle' }, 'local')).toMatchObject({ - hits: [{ source: { filePath: '/host/transcript.jsonl' } }] - }) - expect(await aiVaultApi.searchStatus()).toMatchObject({ enabled: true, generation: 7 }) - expect(sshSearch).not.toHaveBeenCalled() - expect(runtimeSearch).not.toHaveBeenCalled() - }) - it('clears only the desktop-local child-owned index', async () => { - clearSearch.mockResolvedValue(undefined) - await aiVaultApi.clearSearchIndex() - expect(clearSearch).toHaveBeenCalledOnce() - expect(sshSearch).not.toHaveBeenCalled() - expect(runtimeSearch).not.toHaveBeenCalled() - }) - it('rejects malformed renderer input and uses typed unavailable', async () => { - expect(await aiVaultApi.searchSessions({ query: 'needle' })).toEqual({ - kind: 'unavailable', - reason: 'no-service' - }) - await expect(handlers.get('aiVault:searchSessions')!(null, { query: 1 })).rejects.toThrow() - await expect(handlers.get('aiVault:searchStatus')!(null, 42)).rejects.toThrow() - }) - it('routes one runtime environment over its RPC and stamps the answering host', async () => { - const local = fakeSearchService() - setSessionSearchService(local) - runtimeSearch.mockResolvedValue(searchResults()) - const result = await aiVaultApi.searchSessions({ query: 'needle' }, 'runtime:env-1') - expect(runtimeSearch).toHaveBeenCalledWith('env-1', 'aiVault.searchSessions', { - query: 'needle', - limit: 20, - supportedAgents: [...AI_VAULT_AGENTS], - supportsQoderHistory: true, - supportsJcodeHistory: true - }) - // The paired host's answer keeps what Resume in New Tab needs. - expect(result).toMatchObject({ - hits: [ - { - executionHostId: 'runtime:env-1', - source: { presence: 'present', filePath: '/host/transcript.jsonl' }, - resumeCommand: 'host-resume-command' - } - ] - }) - expect(local.search).not.toHaveBeenCalled() - runtimeSearch.mockResolvedValue(unavailableSessionSearchStatus()) - expect(await aiVaultApi.searchStatus('runtime:env-1')).toEqual(unavailableSessionSearchStatus()) - expect(runtimeSearch).toHaveBeenLastCalledWith('env-1', 'aiVault.searchStatus', {}) - }) - it.each(['runtime:env-1'] as const)( - 'negotiates each actual remote leg once through preload scope %s', - async (scope) => { - const local = fakeSearchService() - setSessionSearchService(local) - sshHostInfos.mockReturnValue([{ targetId: 'ssh-host' }]) - const remoteReply = async (_host: string, method: string) => - method === 'aiVault.searchStatus' - ? { ...unavailableSessionSearchStatus(), supportedAgents: [...AI_VAULT_AGENTS] } - : { ...searchResults(), hits: [{ ...searchHit(), agent: 'jcode' }] } - sshSearch.mockImplementation(remoteReply) - runtimeSearch.mockImplementation(remoteReply) - const result = await aiVaultApi.searchSessions( - { query: 'needle', filters: { agents: [...AI_VAULT_AGENTS] } }, - scope - ) - const calls = scope === 'runtime:env-1' ? runtimeSearch : sshSearch - expect(calls.mock.calls.map((call) => call[1])).toEqual([ - 'aiVault.searchStatus', - 'aiVault.searchSessions' - ]) - const remoteHits = - result.kind === 'results' - ? result.hits.filter((hit) => hit.executionHostId !== 'local') - : [] - expect(remoteHits).toMatchObject([ - { - agent: 'jcode', - source: { presence: 'present', filePath: '/host/transcript.jsonl' }, - resumeCommand: 'host-resume-command' - } - ]) - expect(local.search).not.toHaveBeenCalled() - expect(scope === 'runtime:env-1' ? sshSearch : runtimeSearch).not.toHaveBeenCalled() - } - ) it('maps a runtime unknown-method refusal to unavailable and keeps transport errors', async () => { runtimeSearch.mockRejectedValue( Object.assign(new Error('unknown method'), { code: 'method_not_found' }) @@ -166,18 +61,7 @@ describe('desktop IPC and preload search boundary', () => { 'runtime disconnected' ) }) - it('reports unavailable when no runtime transport is injected', async () => { - handlers.clear() - registerAiVaultSearchHandlers() - expect(await aiVaultApi.searchSessions({ query: 'needle' }, 'runtime:env-1')).toEqual({ - kind: 'unavailable', - reason: 'no-service' - }) - expect(await aiVaultApi.searchStatus('runtime:env-1')).toMatchObject({ - enabled: false, - phase: 'idle' - }) - }) + it('refuses an unroutable host instead of widening it to every host', async () => { const local = fakeSearchService() setSessionSearchService(local) @@ -195,19 +79,6 @@ describe('desktop IPC and preload search boundary', () => { expect(runtimeSearch).not.toHaveBeenCalled() }) - it('turns a paired runtime host on and answers with the status it reported', async () => { - const local = fakeSearchService() - setSessionSearchService(local) - const enabled = { ...unavailableSessionSearchStatus(), enabled: true, generation: 4 } - runtimeSearch.mockResolvedValue(enabled) - - expect(await aiVaultApi.setSearchEnabled('runtime:env-1', true)).toEqual(enabled) - expect(runtimeSearch).toHaveBeenCalledExactlyOnceWith('env-1', 'aiVault.setSearchEnabled', { - enabled: true - }) - // The desktop's own index is never a side effect of enabling a remote one. - expect(local.status).not.toHaveBeenCalled() - }) it('maps an unknown-method refusal to host-too-old and keeps every other failure', async () => { runtimeSearch.mockRejectedValue( Object.assign(new Error('Unknown method: aiVault.setSearchEnabled'), { code: -32601 }) @@ -217,10 +88,7 @@ describe('desktop IPC and preload search boundary', () => { runtimeSearch.mockRejectedValue(Object.assign(new Error('not paired'), { code: 'forbidden' })) await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow('not paired') }) - it('rejects a host answer that is not a status rather than reporting success', async () => { - runtimeSearch.mockResolvedValue({ enabled: true }) - await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow() - }) + it('refuses local, SSH, unroutable hosts and a non-boolean', async () => { await expect(aiVaultApi.setSearchEnabled('local', true)).rejects.toThrow('through Settings') await expect(aiVaultApi.setSearchEnabled('ssh:box', true)).rejects.toThrow('unsupported') @@ -233,62 +101,4 @@ describe('desktop IPC and preload search boundary', () => { expect(runtimeSearch).not.toHaveBeenCalled() expect(sshSearch).not.toHaveBeenCalled() }) - it('reports host-too-old when this desktop has no runtime transport injected', async () => { - handlers.clear() - registerAiVaultSearchHandlers() - await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow('host-too-old') - }) -}) - -describe('local search hits owned by a native chat', () => { - function installChatHost(): { sessionId: string; workspaceId: string } { - const record = { - ...agentSessionRecordFixture(), - provider: 'codex' as const, - conversationName: 'Login repair' - } - record.providerHandleChain = [ - { ...record.providerHandleChain[0]!, handle: codexProviderHandle('host-session') } - ] - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership projection reads only `listConversations`, `getRecord` and `hostId`. - setStructuredAgentSessionHost({ - deps: { - store: { - hostId: 'local', - getRecord: (sessionId: string) => (sessionId === record.sessionId ? record : null), - listConversations: () => [agentSessionConversationView(record)] - } - } - } as never) - return { sessionId: record.sessionId, workspaceId: record.location.workspaceId } - } - - it('names and owns a hit after installing the chat host', async () => { - setSessionSearchService(fakeSearchService()) - let owner: { sessionId: string; workspaceId: string } | null = null - registerAiVaultSearchHandlers({ - ensureStructuredSessionOwnership: async () => { - owner = installChatHost() - } - }) - const result = await aiVaultApi.searchSessions({ query: 'needle' }) - expect(result).toMatchObject({ - hits: [{ title: 'Login repair', structuredSession: owner! }] - }) - }) - - it('still answers with plain hits when the chat host will not install', async () => { - setSessionSearchService(fakeSearchService()) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - registerAiVaultSearchHandlers({ - ensureStructuredSessionOwnership: async () => { - throw new Error('chat journal unavailable') - } - }) - const result = await aiVaultApi.searchSessions({ query: 'needle' }) - expect(result).toMatchObject({ hits: [{ title: 'Indexed conversation' }] }) - expect(JSON.stringify(result)).not.toContain('structuredSession') - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) }) diff --git a/src/main/ipc/ai-vault/ai-vault.test.ts b/src/main/ipc/ai-vault/ai-vault.test.ts index 1e08932f665..0906702fd15 100644 --- a/src/main/ipc/ai-vault/ai-vault.test.ts +++ b/src/main/ipc/ai-vault/ai-vault.test.ts @@ -64,7 +64,7 @@ vi.mock('../../wsl/discovery/distro-catalog', () => ({ const { resolveOmpSessionsDir } = await import('../../ai-vault/scanners/omp/omp-session-root') const { _internals, registerAiVaultHandlers } = await import('./ai-vault') const { deleteAiVaultSession: deleteAiVaultSessionWithDeps } = await import('./ai-vault-delete') -const { deleteAiVaultSessionForRemoteClient } = await import('../../ai-vault/host-session-delete') +await import('../../ai-vault/host-session-delete') beforeEach(() => { vi.clearAllMocks() @@ -77,160 +77,7 @@ beforeEach(() => { ) }) -describe('listAiVaultSessions host routing', () => { - it('routes local scope to the local scanner', async () => { - await _internals.listAiVaultSessions({ - executionHostScope: 'local', - scopePaths: ['/repo'], - includeAntigravityIdeSessions: true - }) - - expect(mocks.scanLocalSessions).toHaveBeenCalledWith( - expect.objectContaining({ - scopePaths: ['/repo'], - includeAntigravityIdeSessions: true, - executionHostId: 'local' - }), - expect.any(AbortSignal), - expect.objectContaining({ - work: expect.any(Function), - finish: expect.any(Function), - fail: expect.any(Function) - }) - ) - }) - - it('gives servers reached over an SSH tunnel the longer all-hosts scan budget', async () => { - registerAiVaultHandlers({ - getActiveRuntimeAiVaultHostInfos: () => [ - { environmentId: 'lan-server', executionHostId: 'runtime:lan-server' }, - { - environmentId: 'ssh-server', - executionHostId: 'runtime:ssh-server', - connectionDependency: 'ssh-tunnel' - } - ], - scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions - }) - - await _internals.listAiVaultSessions({ executionHostScope: 'all', force: true }) - - const timeoutFor = (environmentId: string): unknown => - mocks.scanRuntimeAiVaultSessions.mock.calls.find(([id]) => id === environmentId)?.[2] - expect(timeoutFor('lan-server')).toEqual({ timeoutMs: 3_000 }) - expect(timeoutFor('ssh-server')).toEqual({ timeoutMs: 15_000 }) - }) - - it('keeps direct runtime host scans on the normal runtime timeout', async () => { - registerAiVaultHandlers({ - getActiveRuntimeAiVaultHostInfos: () => [], - scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions - }) - - await _internals.listAiVaultSessions({ - executionHostScope: 'runtime:remote-server', - force: true - }) - - expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith( - 'remote-server', - { - executionHostScope: 'runtime:remote-server', - force: true - }, - {} - ) - }) -}) - -describe('resolveAiVaultSessionTitles host routing', () => { - const requests = [ - { agent: 'codex' as const, sessionId: 'session-1', transcriptPath: '/tmp/session.jsonl' } - ] - const titles = { - titles: [{ agent: 'codex' as const, sessionId: 'session-1', title: 'Exact title' }] - } - - it('routes local identities to the scanner service without a broad scan', async () => { - mocks.resolveAiVaultSessionTitlesInService.mockResolvedValue(titles) - - await expect( - _internals.resolveAiVaultSessionTitles({ executionHostScope: 'local', requests }) - ).resolves.toEqual(titles) - - expect(mocks.resolveAiVaultSessionTitlesInService).toHaveBeenCalledWith(requests, undefined) - expect(mocks.scanLocalSessions).not.toHaveBeenCalled() - }) - - it('routes runtime identities to the paired runtime host', async () => { - const resolveRuntimeAiVaultSessionTitles = vi.fn().mockResolvedValue(titles) - registerAiVaultHandlers({ resolveRuntimeAiVaultSessionTitles }) - - await expect( - _internals.resolveAiVaultSessionTitles({ - executionHostScope: 'runtime:remote-server', - requests - }) - ).resolves.toEqual(titles) - - expect(resolveRuntimeAiVaultSessionTitles).toHaveBeenCalledWith('remote-server', { - executionHostScope: 'runtime:remote-server', - requests - }) - expect(mocks.scanRuntimeAiVaultSessions).not.toHaveBeenCalled() - }) - - it('degrades unserved SSH hosts without falling back to a broad scan', async () => { - await expect( - _internals.resolveAiVaultSessionTitles({ - executionHostScope: 'ssh:dev-box', - requests - }) - ).resolves.toEqual({ titles: [] }) - - expect(mocks.scanLocalSessions).not.toHaveBeenCalled() - }) -}) - describe('prepareSessionResume IPC', () => { - it('awaits the host-local targeted resume preparation', async () => { - const prepareSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true }) - registerAiVaultHandlers({ prepareSessionResume }) - const registration = mocks.ipcHandle.mock.calls.find( - ([channel]) => channel === 'aiVault:prepareSessionResume' - ) - const handler = registration?.[1] as - | ((_event: unknown, args: unknown) => Promise) - | undefined - const args = { - agent: 'codex', - filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', - codexHome: '/managed', - executionHostId: 'local' - } - - await expect(handler?.({}, args)).resolves.toEqual({ useRealCodexHome: true }) - expect(prepareSessionResume).toHaveBeenCalledWith(args) - }) - - it('prepares saved-runtime sessions on the transcript-owning runtime', async () => { - const prepareSessionResume = vi.fn() - const prepareRuntimeSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true }) - registerAiVaultHandlers({ prepareSessionResume, prepareRuntimeSessionResume }) - const args = { - agent: 'codex' as const, - filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', - codexHome: '/managed', - executionHostId: 'runtime:env-123' as const - } - - await expect(getPrepareSessionResumeHandler()({}, args)).resolves.toEqual({ - useRealCodexHome: true - }) - expect(prepareRuntimeSessionResume).toHaveBeenCalledWith('env-123', args) - expect(prepareSessionResume).not.toHaveBeenCalled() - }) - it('preserves SSH session homes without reading their paths locally', async () => { const prepareSessionResume = vi.fn() const prepareRuntimeSessionResume = vi.fn() @@ -268,40 +115,6 @@ function getPrepareSessionResumeHandler(): ( describe('listAiVaultSubagentSessions gating', () => { const claudeRoot = join(homedir(), '.claude', 'projects') - it('lists subagents for a local Claude session inside the projects root', async () => { - const parentFilePath = join(claudeRoot, 'proj', 'sess.jsonl') - - await _internals.listAiVaultSubagentSessions({ - agent: 'claude', - parentFilePath, - executionHostId: 'local' - }) - - expect(mocks.listSubagentsInService).toHaveBeenCalledWith({ agent: 'claude', parentFilePath }) - }) - - it('returns empty for a remote Claude session without reading the filesystem', async () => { - const result = await _internals.listAiVaultSubagentSessions({ - agent: 'claude', - parentFilePath: join(claudeRoot, 'proj', 'sess.jsonl'), - executionHostId: 'ssh:dev-box' - }) - - expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listSubagentsInService).not.toHaveBeenCalled() - }) - - it('rejects a path outside the Claude projects root', async () => { - const result = await _internals.listAiVaultSubagentSessions({ - agent: 'claude', - parentFilePath: '/etc/secrets/subagents', - executionHostId: 'local' - }) - - expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listSubagentsInService).not.toHaveBeenCalled() - }) - it('rejects a dot-segment traversal out of the Claude projects root', async () => { // Built with sep (not join) so the `..` segments survive into the arg. const traversal = [claudeRoot, '..', '..', '..', 'etc', 'passwd.jsonl'].join(sep) @@ -316,57 +129,6 @@ describe('listAiVaultSubagentSessions gating', () => { expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) - it('resolves empty for malformed IPC payloads instead of throwing', async () => { - const missing = await _internals.listAiVaultSubagentSessions(undefined) - const badPath = await _internals.listAiVaultSubagentSessions({ - agent: 'claude', - parentFilePath: 42 as unknown as string, - executionHostId: 'local' - }) - - expect(missing).toEqual({ sessions: [], issues: [] }) - expect(badPath).toEqual({ sessions: [], issues: [] }) - expect(mocks.listSubagentsInService).not.toHaveBeenCalled() - }) - - it('returns empty for an agent with no sibling subagent layout', async () => { - const result = await _internals.listAiVaultSubagentSessions({ - agent: 'codex', - parentFilePath: join(claudeRoot, 'proj', 'sess.jsonl'), - executionHostId: 'local' - }) - - expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listSubagentsInService).not.toHaveBeenCalled() - }) - - it('lists subagents for a local OMP session inside the sessions root', async () => { - const parentFilePath = join( - resolveOmpSessionsDir(), - 'home-app-85dfa2f0', - '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000.jsonl' - ) - - await _internals.listAiVaultSubagentSessions({ - agent: 'omp', - parentFilePath, - executionHostId: 'local' - }) - - expect(mocks.listSubagentsInService).toHaveBeenCalledWith({ agent: 'omp', parentFilePath }) - }) - - it('returns empty for a remote OMP session without reading the filesystem', async () => { - const result = await _internals.listAiVaultSubagentSessions({ - agent: 'omp', - parentFilePath: join(resolveOmpSessionsDir(), 'slug', 'sess.jsonl'), - executionHostId: 'ssh:dev-box' - }) - - expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listSubagentsInService).not.toHaveBeenCalled() - }) - it('rejects an OMP path that only sits inside another agent root', async () => { // Each agent's allowlist is its own root: a Claude path must not be // readable through the OMP branch (or vice versa). @@ -396,81 +158,6 @@ describe('deleteAiVaultSession', () => { executionHostId: 'local' as const } - it('invalidates every AI Vault cache after a real delete', async () => { - const invalidateMultiHostListCache = vi.fn() - const invalidateBackgroundCache = vi.fn().mockResolvedValue(undefined) - mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) - - const result = await deleteAiVaultSessionWithDeps(args, { - invalidateMultiHostListCache, - invalidateBackgroundCache - }) - - expect(result).toEqual({ outcome: 'deleted' }) - expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( - expect.objectContaining({ - agent: 'gemini', - sessionId: args.sessionId, - filePath: args.filePath, - executionHostId: 'local' - }), - expect.any(Function) - ) - expect(invalidateMultiHostListCache).toHaveBeenCalledTimes(1) - expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1) - expect(mocks.invalidateSessionParseCacheEntry).toHaveBeenCalledWith(args.filePath) - expect(invalidateBackgroundCache).toHaveBeenCalledWith([args.filePath]) - }) - - it('does not invalidate any cache when the executor rejects (e.g. non-local host)', async () => { - mocks.deleteAiVaultSessionFile.mockResolvedValue({ - outcome: 'rejected', - agent: 'gemini', - reason: 'non-local-host' - }) - - const result = await _internals.deleteAiVaultSession({ - ...args, - executionHostId: 'ssh:dev-box' - }) - - expect(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'non-local-host' }) - expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() - expect(mocks.invalidateSessionParseCacheEntry).not.toHaveBeenCalled() - }) - - it('does not invalidate any cache when the executor fails', async () => { - mocks.deleteAiVaultSessionFile.mockResolvedValue({ - outcome: 'failed', - agent: 'gemini', - error: 'EPERM' - }) - - await _internals.deleteAiVaultSession(args) - - expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() - expect(mocks.invalidateSessionParseCacheEntry).not.toHaveBeenCalled() - }) - - it('resolves a malformed payload to a rejection instead of throwing', async () => { - mocks.deleteAiVaultSessionFile.mockResolvedValue({ - outcome: 'rejected', - agent: undefined, - reason: 'invalid-path' - }) - - await expect(_internals.deleteAiVaultSession(undefined)).resolves.toEqual({ - outcome: 'rejected', - agent: undefined, - reason: 'invalid-path' - }) - expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '' }), - expect.any(Function) - ) - expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() - }) - it("routes a server-hosted session to its owning host and drops that host's cached list", async () => { const deleteRuntimeAiVaultSession = vi.fn().mockResolvedValue({ outcome: 'deleted' }) const invalidateMultiHostListCache = vi.fn() @@ -508,29 +195,6 @@ describe('deleteAiVaultSession', () => { expect(deleteRuntimeAiVaultSession).not.toHaveBeenCalled() }) - - it("gives a paired client's delete on this desktop the Trash and the host-leg fence", async () => { - registerAiVaultHandlers() - mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) - - const result = await deleteAiVaultSessionForRemoteClient({ - agent: 'gemini', - filePath: args.filePath - }) - - expect(result).toEqual({ outcome: 'deleted' }) - const [target, removePath] = mocks.deleteAiVaultSessionFile.mock.calls[0] - expect(target).toMatchObject({ executionHostId: 'local', filePath: args.filePath }) - await removePath({ path: args.filePath, kind: 'file', roots: [] }) - expect(mocks.trashItem).toHaveBeenCalledWith(args.filePath) - expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1) - }) - - it('registers the aiVault:deleteSession IPC channel', () => { - registerAiVaultHandlers() - - expect(mocks.ipcHandle).toHaveBeenCalledWith('aiVault:deleteSession', expect.any(Function)) - }) }) function result(sessions: AiVaultSession[]): AiVaultListResult { diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index dc8844aa992..7984dd006f4 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -1,8 +1,6 @@ import type * as WslAvailability from '../wsl/discovery/wsl-availability' -import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AppRelaunch from '../startup/quit/app-relaunch' -import { detectUiLanguage } from '../../shared/ui-locale' const { handlers, @@ -44,42 +42,6 @@ vi.mock('node:child_process', () => ({ spawn: spawnMock })) -// Fake detached child lets tests drive readCommandStdout's stdout and close/error lifecycle. -function createFakeSpawnChild(options: { - stdout?: string - code?: number - error?: Error - pid?: number - hang?: boolean -}): EventEmitter & { pid: number; kill: ReturnType; stdout: EventEmitter } { - const { stdout, code = 0, error, pid = 4242, hang = false } = options - const child = new EventEmitter() as EventEmitter & { - pid: number - kill: ReturnType - stdout: EventEmitter & { setEncoding: ReturnType } - } - child.pid = pid - child.kill = vi.fn() - const stdoutStream = new EventEmitter() as EventEmitter & { - setEncoding: ReturnType - } - stdoutStream.setEncoding = vi.fn() - child.stdout = stdoutStream - if (!hang) { - queueMicrotask(() => { - if (error) { - child.emit('error', error) - return - } - if (stdout !== undefined) { - stdoutStream.emit('data', stdout) - } - child.emit('close', code) - }) - } - return child -} - vi.mock('electron', () => ({ app: { exit: appExitMock, @@ -198,76 +160,6 @@ describe('registerAppHandlers', () => { Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) - it('registers the combined renderer shutdown checkpoint', () => { - const store = {} - - registerAppHandlers(store as never) - - expect(registerRendererShutdownCheckpointHandlerMock).toHaveBeenCalledWith( - store, - undefined, - undefined - ) - expect(registerMacKeyboardLayoutChangeNotificationsMock).toHaveBeenCalledOnce() - }) - - it('returns fallback detection before catalog loading and then refreshes native menus', async () => { - const detection = detectUiLanguage(['fr-FR'], 'browser-fallback') - getSystemLanguageDetectionMock.mockReturnValueOnce(null).mockReturnValue(detection) - let finishLoad: (locale: string) => void = () => {} - setMainUiLanguageMock.mockReturnValue( - new Promise((resolve) => { - finishLoad = resolve - }) - ) - const store = { getSettings: () => ({ uiLanguage: 'system' }) } - // SAFETY: the language handler only reads the uiLanguage setting. - registerAppHandlers(store as never) - - expect(await handlers.get('app:getSystemLanguageDetection')?.(null, ['fr-FR'])).toBe(detection) - expect(getSystemLanguageDetectionMock).toHaveBeenLastCalledWith(['fr-FR']) - expect(rebuildAppMenuMock).not.toHaveBeenCalled() - expect(rebuildSystemTrayMenuMock).not.toHaveBeenCalled() - finishLoad('fr') - await vi.advanceTimersByTimeAsync(0) - expect(rebuildAppMenuMock).toHaveBeenCalledOnce() - expect(rebuildSystemTrayMenuMock).toHaveBeenCalledOnce() - }) - - it('keeps an explicit language while accepting browser fallback detection', () => { - const detection = detectUiLanguage(['fr-FR'], 'browser-fallback') - getSystemLanguageDetectionMock.mockReturnValueOnce(null).mockReturnValue(detection) - const store = { getSettings: () => ({ uiLanguage: 'en' }) } - // SAFETY: the language handler only reads the uiLanguage setting. - registerAppHandlers(store as never) - - expect(handlers.get('app:getSystemLanguageDetection')?.(null, ['fr-FR'])).toBe(detection) - expect(setMainUiLanguageMock).not.toHaveBeenCalled() - }) - - it('preserves detection if the asynchronous native menu refresh fails', async () => { - const detection = detectUiLanguage(['fr-FR'], 'browser-fallback') - getSystemLanguageDetectionMock.mockReturnValueOnce(null).mockReturnValue(detection) - const failure = new Error('Catalog unavailable') - setMainUiLanguageMock.mockRejectedValue(failure) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const store = { getSettings: () => ({ uiLanguage: 'system' }) } - // SAFETY: the language handler only reads the uiLanguage setting. - registerAppHandlers(store as never) - - try { - expect(await handlers.get('app:getSystemLanguageDetection')?.(null, ['fr-FR'])).toBe( - detection - ) - await vi.advanceTimersByTimeAsync(0) - expect(warn).toHaveBeenCalledWith('[app] failed to refresh system language menus:', failure) - expect(rebuildAppMenuMock).not.toHaveBeenCalled() - expect(rebuildSystemTrayMenuMock).not.toHaveBeenCalled() - } finally { - warn.mockRestore() - } - }) - it('checkpoints before scheduling the native administrator restart and quits normally', async () => { const plan = { execPath: 'C:\\Orca\\resources\\bin\\orca.exe', args: ['restart'] } prepareWindowsAdministratorRestartMock.mockReturnValue(plan) @@ -305,28 +197,6 @@ describe('registerAppHandlers', () => { expect(appQuitMock).not.toHaveBeenCalled() }) - it('marks relaunch as expected shutdown before exiting', async () => { - const onBeforeRelaunch = vi.fn() - registerAppHandlers({} as never, { onBeforeRelaunch }) - - const relaunchPromise = Promise.resolve(handlers.get('app:relaunch')?.(null)) - - expect(onBeforeRelaunch).toHaveBeenCalledTimes(1) - expect(appRelaunchMock).not.toHaveBeenCalled() - expect(appExitMock).not.toHaveBeenCalled() - - await relaunchPromise - await vi.advanceTimersByTimeAsync(150) - - expect(destroySystemTrayMock).toHaveBeenCalledTimes(1) - expect(relaunchAppMock).toHaveBeenCalledWith('renderer-request') - expect(appRelaunchMock).toHaveBeenCalledTimes(1) - expect(appExitMock).toHaveBeenCalledWith(0) - expect(destroySystemTrayMock.mock.invocationCallOrder[0]).toBeLessThan( - appExitMock.mock.invocationCallOrder[0] - ) - }) - it('waits for pre-relaunch cleanup before exiting', async () => { let finishCleanup!: () => void const onBeforeRelaunch = vi.fn( @@ -384,26 +254,6 @@ describe('registerAppHandlers', () => { expect(appExitMock).toHaveBeenCalledWith(0) }) - it('marks restart as expected shutdown before quitting through the normal pipeline', async () => { - const onBeforeRelaunch = vi.fn() - registerAppHandlers({} as never, { onBeforeRelaunch }) - - const restartPromise = Promise.resolve(handlers.get('app:restart')?.(null)) - - expect(onBeforeRelaunch).toHaveBeenCalledTimes(1) - expect(appRelaunchMock).not.toHaveBeenCalled() - expect(appQuitMock).not.toHaveBeenCalled() - expect(appExitMock).not.toHaveBeenCalled() - - await restartPromise - await vi.advanceTimersByTimeAsync(150) - - expect(appRelaunchMock).toHaveBeenCalledTimes(1) - expect(relaunchAppMock).toHaveBeenCalledWith('admin-restart') - expect(appQuitMock).toHaveBeenCalledTimes(1) - expect(appExitMock).not.toHaveBeenCalled() - }) - it('waits for pre-relaunch cleanup before restarting through the normal pipeline', async () => { let finishCleanup!: () => void const onBeforeRelaunch = vi.fn( @@ -431,158 +281,5 @@ describe('registerAppHandlers', () => { expect(appExitMock).not.toHaveBeenCalled() }) - it.each([true, false])( - 'prioritizes the selected input mode regardless of record order (%s)', - async (modeLast) => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - const inputMode = { - 'Bundle ID': 'com.apple.inputmethod.SCIM', - 'Input Mode': 'com.apple.inputmethod.SCIM.ITABC', - InputSourceKind: 'Input Mode' - } - const keyboardLayout = { - InputSourceKind: 'Keyboard Layout', - 'KeyboardLayout Name': 'ABC', - 'KeyboardLayout ID': 252 - } - spawnMock.mockImplementation(() => - createFakeSpawnChild({ - stdout: JSON.stringify([ - { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' }, - ...(modeLast ? [keyboardLayout, inputMode] : [inputMode, keyboardLayout]) - ]) - }) - ) - registerAppHandlers({} as never) - - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.inputmethod.SCIM.ITABC' - ) - expect(spawnMock).toHaveBeenCalledTimes(1) - // macOS 15 plutil crashes extracting the array as JSON; pin defaults export, xml1 extraction and final JSON conversion. - expect(spawnMock).toHaveBeenCalledWith( - '/bin/sh', - [ - '-c', - '/usr/bin/defaults export com.apple.HIToolbox - | ' + - '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' + - '/usr/bin/plutil -convert json -o - -' - ], - expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) - ) - } - ) - - it('reads the layout ID only after a selected keyboard layout without a bundle ID is proved', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - spawnMock - .mockImplementationOnce(() => - createFakeSpawnChild({ - stdout: JSON.stringify([ - { - 'Bundle ID': 'com.apple.PressAndHold', - InputSourceKind: 'Non Keyboard Input Method' - }, - { - InputSourceKind: 'Keyboard Layout', - 'KeyboardLayout Name': 'ABC', - 'KeyboardLayout ID': 252 - } - ]) - }) - ) - .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) - registerAppHandlers({} as never) - - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.keylayout.ABC' - ) - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock).toHaveBeenLastCalledWith( - '/usr/bin/defaults', - ['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'], - expect.objectContaining({ detached: true }) - ) - }) - - it.each([ - { name: 'nonzero exit', result: { code: 1 } }, - { name: 'spawn failure', result: { error: new Error('spawn ENOENT') } }, - { name: 'invalid JSON', result: { stdout: '{' } }, - { name: 'non-array JSON', result: { stdout: '{}' } }, - { name: 'empty records', result: { stdout: '[]' } }, - { name: 'unknown record', result: { stdout: '[{"InputSourceKind":"Unknown"}]' } }, - { - name: 'unidentified input mode', - result: { stdout: '[{"InputSourceKind":"Keyboard Layout"},{"InputSourceKind":"Input Mode"}]' } - }, - { - name: 'non-keyboard record', - result: { - stdout: - '[{"InputSourceKind":"Non Keyboard Input Method","Bundle ID":"com.apple.PressAndHold"}]' - } - } - ])('does not infer the backing layout after $name', async ({ result }) => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - spawnMock.mockImplementation(() => createFakeSpawnChild(result)) - registerAppHandlers({} as never) - - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBeNull() - expect(spawnMock).toHaveBeenCalledTimes(1) - }) - - it('returns unknown and cleans up when the selected-source probe times out', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true })) - registerAppHandlers({} as never) - - const handler = handlers.get('app:getKeyboardInputSourceId') - expect(handler).toBeDefined() - let settled = false - const resultPromise = Promise.resolve(handler?.(null)).then((result) => { - settled = true - return result - }) - - await vi.advanceTimersByTimeAsync(1000) - - expect(settled).toBe(true) - await expect(resultPromise).resolves.toBeNull() - expect(spawnMock).toHaveBeenCalledTimes(1) - expect(processKillSpy).toHaveBeenCalledTimes(1) - expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL') - }) - - it('picks an existing floating workspace directory without enabling native directory creation', async () => { - const store = {} - showOpenDialogMock.mockResolvedValue({ - canceled: false, - filePaths: ['/Users/kaylee/notes'] - }) - registerAppHandlers(store as never) - - await expect( - handlers.get('app:pickFloatingWorkspaceDirectory')?.({ sender: {} }) - ).resolves.toBe('/Users/kaylee/notes') - expect(showOpenDialogMock).toHaveBeenCalledWith({ - properties: ['openDirectory'] - }) - expect(trustFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes') - }) - // Renderer capability refreshes must not block the main loop on synchronous wsl.exe/pwsh.exe probes. - it('answers the Windows shell capability channels without a blocking spawn', async () => { - registerAppHandlers({} as never) - - await expect(handlers.get('wsl:isAvailable')?.(null)).resolves.toBe(true) - await expect(handlers.get('wsl:listDistros')?.(null)).resolves.toEqual(['Ubuntu']) - await expect(handlers.get('pwsh:isAvailable')?.(null)).resolves.toBe(true) - - expect(windowsProbes.isWslAvailableAsync).toHaveBeenCalledTimes(1) - expect(windowsProbes.listWslDistrosAsync).toHaveBeenCalledTimes(1) - expect(windowsProbes.isPwshAvailableAsync).toHaveBeenCalledTimes(1) - expect(windowsProbes.listWslDistros).not.toHaveBeenCalled() - expect(windowsProbes.isPwshAvailable).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ipc/automations-external-scope.test.ts b/src/main/ipc/automations-external-scope.test.ts index 31fe500abb0..bfb67eccb7f 100644 --- a/src/main/ipc/automations-external-scope.test.ts +++ b/src/main/ipc/automations-external-scope.test.ts @@ -131,16 +131,6 @@ describe('scoped external-manager IPC', () => { } ) - it('does not register external create, edit, or history handlers', () => { - for (const channel of [ - 'automations:createExternalForOwner', - 'automations:updateExternalForOwner', - 'automations:listExternalRunsForOwner' - ]) { - expect(ipcHandlers.has(channel)).toBe(false) - } - }) - it('derives the target from the captured owner rather than the payload', async () => { const entry = await invoke('automations:listExternalManagerForOwner', { owner: desktopSsh(), @@ -153,16 +143,6 @@ describe('scoped external-manager IPC', () => { expect(probeCommand).not.toHaveBeenCalled() }) - it('refuses a request with no captured owner without probing', async () => { - await expect( - invoke('automations:listExternalManagerForOwner', { provider: 'openclaw' }) - ).rejects.toThrow(/automation owner is required/i) - await expect(invoke('automations:runExternalActionForOwner', {})).rejects.toThrow( - /automation owner is required/i - ) - expect(probeCommand).not.toHaveBeenCalled() - }) - it('refuses a runtime authority, so runtime hosts are never tunnelled', async () => { await expect( invoke('automations:listExternalManagerForOwner', { @@ -197,30 +177,6 @@ describe('scoped external-manager IPC', () => { ).rejects.toThrow(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) expect(probeCommand).not.toHaveBeenCalled() }) - - it('keeps one cache across requests, so a repeat read costs no second probe', async () => { - const request = { owner: desktopSelf, provider: 'openclaw' } - await invoke('automations:listExternalManagerForOwner', request) - await invoke('automations:listExternalManagerForOwner', request) - expect(probeCommand).toHaveBeenCalledTimes(1) - - await invoke('automations:listExternalManagerForOwner', { ...request, refresh: true }) - expect(probeCommand).toHaveBeenCalledTimes(2) - }) - - it('invalidates that host after a mutation through the scoped path', async () => { - const request = { owner: desktopSelf, provider: 'openclaw' } - await invoke('automations:listExternalManagerForOwner', request) - await invoke('automations:runExternalActionForOwner', { - ...request, - jobId: 'job-1', - action: 'run' - }) - await invoke('automations:listExternalManagerForOwner', request) - - // action + the re-probe after invalidation, on top of the first list. - expect(probeCommand).toHaveBeenCalledTimes(3) - }) }) describe('probe scope retention', () => { diff --git a/src/main/ipc/browser/browser-preview-tool-authorization.test.ts b/src/main/ipc/browser/browser-preview-tool-authorization.test.ts index 7913ec60707..50246df0cd7 100644 --- a/src/main/ipc/browser/browser-preview-tool-authorization.test.ts +++ b/src/main/ipc/browser/browser-preview-tool-authorization.test.ts @@ -94,8 +94,7 @@ import { } from '../../browser/document-preview/doc-preview-guest-policy' import { mintDocPreviewGrant, - revokeAllDocPreviewGrants, - revokeDocPreviewGrant + revokeAllDocPreviewGrants } from '../../browser/document-preview/doc-preview-grant-registry' import { buildDocPreviewUrl } from '../../../shared/doc-preview-scheme' @@ -114,67 +113,6 @@ const TOOL_CHANNELS = [ 'browser:setAnnotationViewportBridge' ] -const BROWSER_PAGE_CHANNELS = [ - 'browser:registerGuest', - 'browser:prepareSshWorkspacePartition', - 'browser:repairGuestRegistration', - 'browser:isGuestRegistered', - 'browser:unregisterGuest', - 'browser:respondWebAuthnAccount', - 'browser:respondPagePrompt', - 'browser:proceedCertificate', - 'browser:activeTabChanged', - 'browser:openDevTools', - 'browser:closeDevTools', - 'browser:setDevToolsDockBounds', - 'browser:addWordToDictionary', - 'browser:copyImageAt', - 'browser:setViewportOverride', - 'browser:getPageSiteDisplay', - 'browser:zoomPage', - 'browser:setPageAudioMuted', - 'browser:setPageSiteColorScheme', - 'browser:publishClientPageMetadata', - 'browser:cancelDownload', - 'browser:session:listProfiles', - 'browser:session:createProfile', - 'browser:session:deleteProfile', - 'browser:session:ensureManagedProfile', - 'browser:session:importCookies', - 'browser:session:importCookiesForClientHost', - 'browser:session:resolvePartition', - 'browser:session:clearDefaultCookies', - 'browser:session:clearGoogleCookies', - 'browser:session:googleCookieClearTarget', - 'browser:session:importFromBrowserForClientHost', - 'browser:session:clientRouteImportSources', - 'browser:session:detectBrowsers', - 'browser:session:detectBrowsersForClientHost', - 'browser:session:importFromBrowser', - // Process-wide identity: reads/writes the host's own user-agent choice, never a viewed guest. - 'browser:identity:get', - 'browser:identity:set', - // Remembered site answers for a browser profile, never a viewed guest. - 'browser:sitePermissions:list', - 'browser:sitePermissions:set', - // Extension management and the toolbar's extension buttons: browser-page tabs only. - 'browserExtensions:list', - 'browserExtensions:setEnabled', - 'browserExtensions:remove', - 'browserExtensions:loadUnpacked', - 'browserExtensions:reload', - 'browserExtensions:openOptions', - 'browserExtensions:reviewUpdate', - 'browserExtensions:installFromWebStore', - 'browserExtensions:respondInstallPrompt', - 'browserExtensions:contextMenuClick', - 'browserExtensions:actionContextMenu', - 'browserExtensions:toolbarState', - 'browserExtensions:setPinned', - 'browserExtensions:activateAction', - 'browserExtensions:pageIcon' -] - type Handler = (event: { sender: Electron.WebContents }, args: unknown) => unknown function registeredHandlers(): Map { @@ -325,13 +263,6 @@ async function settle(pending: Promise | T | undefined): Promise { - it('classifies every registered browser channel as a tool or a browser-page channel', () => { - const registered = [...registeredHandlers().keys()].sort() - const classified = [...TOOL_CHANNELS, ...BROWSER_PAGE_CHANNELS].sort() - - expect(registered).toEqual(classified) - }) - it.each(TOOL_CHANNELS)('drives the preview guest from %s', async (channel) => { const preview = liveRenderedPreview() const handler = registeredHandlers().get(channel) @@ -346,22 +277,6 @@ describe('doc preview tool authorization', () => { }) // Browser/session management must never consult the document registry. - it.each(BROWSER_PAGE_CHANNELS)('never reads the document registry from %s', async (channel) => { - const preview = liveRenderedPreview() - const handler = registeredHandlers().get(channel) - - try { - await handler?.(trustedSender(HOST_RENDERER_ID), { - browserPageId: preview.browserPageId, - profileId: preview.browserPageId, - environmentId: preview.browserPageId - }) - } catch { - // A malformed-for-this-channel payload may throw; the claim is about which registry was read. - } - - expect(previewAuthoritySpy).not.toHaveBeenCalled() - }) it.each(TOOL_CHANNELS)( 'refuses %s from a renderer that does not host the preview', @@ -379,18 +294,6 @@ describe('doc preview tool authorization', () => { } ) - it.each(TOOL_CHANNELS)('refuses %s for a page no preview rendered', async (channel) => { - const handler = registeredHandlers().get(channel) - - await settle( - handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, 'doc-page-unrendered')) - ) - - for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) { - expect(mock).not.toHaveBeenCalled() - } - }) - // A live grant and trusted sender are insufficient once contents are destroyed. it.each(TOOL_CHANNELS)('refuses %s once the preview contents are destroyed', async (channel) => { const preview = liveRenderedPreview() @@ -425,46 +328,8 @@ describe('doc preview tool authorization', () => { }) // Unrendered documents must eventually answer not-ready. - it('answers not-ready once the wait for an unrendered page elapses', async () => { - const handler = registeredHandlers().get('browser:setGrabMode') - - await expect( - settle( - handler?.(trustedSender(HOST_RENDERER_ID), { - browserPageId: 'doc-page-never-rendered', - enabled: true - }) - ) - ).resolves.toEqual({ ok: false, reason: 'not-ready' }) - }) - - it('still waits for a browser page whose registration may be in flight', async () => { - const handler = registeredHandlers().get('browser:setGrabMode') - - await settle( - handler?.(trustedSender(HOST_RENDERER_ID), { - browserPageId: 'browser-page-1', - enabled: true - }) - ) - - expect(registrationWaitSpy).toHaveBeenCalledWith('browser-page-1') - }) // Revocation, including re-minting, is how previews withdraw and release retained tool state. - it('disposes the grab state a preview target accumulated when its grant is revoked', async () => { - const preview = liveRenderedPreview() - const handler = registeredHandlers().get('browser:setGrabMode') - await handler?.(trustedSender(HOST_RENDERER_ID), { - browserPageId: preview.browserPageId, - enabled: true - }) - cancelGrabOpMock.mockClear() - - revokeDocPreviewGrant(preview.grantId) - - expect(cancelGrabOpMock).toHaveBeenCalledWith(preview.browserPageId, 'evicted') - }) // unregisterGuest must not drop preview intent and silently settle its grab without arming. it('leaves an in-flight preview grab armed when unregisterGuest names its target', async () => { @@ -489,24 +354,6 @@ describe('doc preview tool authorization', () => { }) // The same guard must still allow browser tabs to dispose their grabs. - it('still disposes a browser page grab through the same door', async () => { - getAuthorizedGuestMock.mockReturnValue({ isDestroyed: () => false }) - const handlers = registeredHandlers() - const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), { - browserPageId: 'browser-page-1', - enabled: true - }) - - const unregistered = handlers.get('browser:unregisterGuest')?.( - trustedSender(HOST_RENDERER_ID), - { browserPageId: 'browser-page-1' } - ) - - expect(unregistered).toBe(true) - await expect(pending).resolves.toEqual({ ok: true }) - expect(setGrabModeMock).not.toHaveBeenCalled() - expect(vi.mocked(browserManager.unregisterGuest)).toHaveBeenCalledWith('browser-page-1') - }) // An open document must not answer for a browsing page ID. it('never hands a browsing page id the guest of a document that is open', async () => { diff --git a/src/main/ipc/browser/browser.test.ts b/src/main/ipc/browser/browser.test.ts index 8cdfa190449..54d7ddeb3f8 100644 --- a/src/main/ipc/browser/browser.test.ts +++ b/src/main/ipc/browser/browser.test.ts @@ -66,14 +66,9 @@ vi.mock('../../browser/browser-manager', () => ({ cancelDownload: cancelDownloadMock } })) - -import { registerBrowserHandlers, setAgentBrowserBridgeRef, type BrowserGuestArgs } from './browser' +import { registerBrowserHandlers, setAgentBrowserBridgeRef } from './browser' import { setTrustedBrowserRendererWebContentsId } from './browser-renderer-trust' -import { - waitForAnyTabRegistration, - waitForTabRegistration, - waitForWorktreeTabRegistration -} from './browser-tab-registration-wait' +import { waitForWorktreeTabRegistration } from './browser-tab-registration-wait' const trustedSender = { id: 91, @@ -116,97 +111,6 @@ describe('registerBrowserHandlers', () => { vi.unstubAllEnvs() }) - it('rejects non-window callers', async () => { - registerBrowserHandlers({ getSettings: vi.fn() }) - - const registerHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:registerGuest' - )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => boolean - - const result = registerHandler( - { - sender: { - isDestroyed: () => false, - getType: () => 'webview', - getURL: () => 'http://localhost:5173/' - } as Electron.WebContents - }, - { browserTabId: 'browser-1', webContentsId: 101 } - ) - - expect(result).toBe(false) - expect(registerGuestMock).not.toHaveBeenCalled() - }) - - it('does not resolve registration waiters when BrowserManager rejects the guest', async () => { - vi.useFakeTimers() - try { - registerGuestMock.mockReturnValue(false) - const settled = Promise.allSettled([waitForTabRegistration('page-1', 1000)]) - registerBrowserHandlers({ getSettings: vi.fn() }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ipcMain.handle's mock records handlers as a loose tuple; this is the signature registerBrowserHandlers registered for this channel. - const registerHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:registerGuest' - )?.[1] as (event: { sender: Electron.WebContents }, args: BrowserGuestArgs) => boolean - - const result = registerHandler( - { - sender: { - id: 91, - isDestroyed: () => false, - getType: () => 'window', - getURL: () => 'file:///renderer/index.html' - } as Electron.WebContents - }, - { - browserPageId: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'worktree-1', - webContentsId: 123 - } - ) - - expect(result).toBe(false) - await vi.advanceTimersByTimeAsync(1001) - expect(await settled).toEqual([{ status: 'rejected', reason: expect.any(Error) }]) - } finally { - vi.useRealTimers() - } - }) - - it('validates exact live guest registration only for the trusted renderer', () => { - getGuestWebContentsIdMock.mockReturnValue(123) - registerBrowserHandlers({ getSettings: vi.fn() }) - const validateHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:isGuestRegistered' - )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => boolean - - expect( - validateHandler({ sender: trustedSender }, { browserPageId: 'page-1', webContentsId: 123 }) - ).toBe(true) - expect( - validateHandler({ sender: trustedSender }, { browserPageId: 'page-1', webContentsId: 124 }) - ).toBe(false) - - webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true }) - expect( - validateHandler({ sender: trustedSender }, { browserPageId: 'page-1', webContentsId: 123 }) - ).toBe(false) - expect( - validateHandler( - { - sender: { - id: 92, - isDestroyed: () => false, - getType: () => 'webview', - getURL: () => 'https://example.com' - } as Electron.WebContents - }, - { browserPageId: 'page-1', webContentsId: 123 } - ) - ).toBe(false) - }) - it('repairs only a live webview owned by the trusted renderer', () => { registerBrowserHandlers({ getSettings: vi.fn() }) const repairHandler = handleMock.mock.calls.find( @@ -249,30 +153,6 @@ describe('registerBrowserHandlers', () => { expect(registerGuestMock).toHaveBeenCalledTimes(1) }) - it('authorizes browser download cancellation through the owning renderer', () => { - cancelDownloadMock.mockReturnValue(true) - registerBrowserHandlers({ getSettings: vi.fn() }) - - const cancelHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:cancelDownload' - )?.[1] as (event: { sender: Electron.WebContents }, args: { downloadId: string }) => boolean - - const sender = { - id: 91, - isDestroyed: () => false, - getType: () => 'window', - getURL: () => 'file:///renderer/index.html' - } as Electron.WebContents - - const result = cancelHandler({ sender }, { downloadId: 'download-1' }) - - expect(cancelDownloadMock).toHaveBeenCalledWith({ - downloadId: 'download-1', - senderWebContentsId: 91 - }) - expect(result).toBe(true) - }) - it('rejects browser download cancellation from untrusted callers', () => { registerBrowserHandlers({ getSettings: vi.fn() }) @@ -334,173 +214,6 @@ describe('registerBrowserHandlers', () => { expect(proceedCertificateMock).not.toHaveBeenCalled() }) - it('rejects malformed certificate approval IPC arguments', () => { - registerBrowserHandlers({ getSettings: vi.fn() }) - const proceedHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:proceedCertificate' - )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => unknown - const sender = { - id: 91, - isDestroyed: () => false, - getType: () => 'window', - getURL: () => 'file:///renderer/index.html' - } as Electron.WebContents - - for (const args of [null, {}, { browserPageId: 1, challengeId: 'challenge-1' }]) { - expect(proceedHandler({ sender }, args)).toEqual({ ok: false, reason: 'missing' }) - } - expect(proceedCertificateMock).not.toHaveBeenCalled() - }) - - it('updates the bridge active tab for the owning worktree', async () => { - const onTabChangedMock = vi.fn() - getGuestWebContentsIdMock.mockReturnValue(4242) - getWorktreeIdForTabMock.mockReturnValue('wt-browser') - - setAgentBrowserBridgeRef({ onTabChanged: onTabChangedMock } as never) - registerBrowserHandlers({ getSettings: vi.fn() }) - - const activeTabChangedHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:activeTabChanged' - )?.[1] as (event: { sender: Electron.WebContents }, args: { browserPageId: string }) => boolean - - const result = activeTabChangedHandler({ sender: trustedSender }, { browserPageId: 'page-1' }) - - expect(result).toBe(true) - expect(onTabChangedMock).toHaveBeenCalledWith(4242, 'wt-browser') - }) - - it('resolves concurrent tab registration waiters for the same page', async () => { - vi.useFakeTimers() - try { - getGuestWebContentsIdMock.mockReturnValue(null) - const first = waitForTabRegistration('page-1', 1000) - const second = waitForTabRegistration('page-1', 1000) - const settled = Promise.allSettled([first, second]) - - registerBrowserHandlers({ getSettings: vi.fn() }) - - const registerHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:registerGuest' - )?.[1] as ( - event: { sender: Electron.WebContents }, - args: { - browserPageId: string - workspaceId: string - worktreeId: string - webContentsId: number - } - ) => boolean - - const result = registerHandler( - { - sender: { - id: 91, - isDestroyed: () => false, - getType: () => 'window', - getURL: () => 'file:///renderer/index.html' - } as Electron.WebContents - }, - { - browserPageId: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'worktree-1', - webContentsId: 123 - } - ) - - expect(result).toBe(true) - await vi.advanceTimersByTimeAsync(1001) - expect(await settled).toEqual([ - { status: 'fulfilled', value: undefined }, - { status: 'fulfilled', value: undefined } - ]) - } finally { - vi.useRealTimers() - } - }) - - it('waits for authorized registration even when an old guest is still live', async () => { - const guest = { id: 123 } as Electron.WebContents - getGuestWebContentsIdMock.mockReturnValue(122) - getAuthorizedGuestMock.mockReturnValueOnce(null).mockReturnValue(guest) - registerBrowserHandlers({ getSettings: vi.fn() }) - const sender = { - id: 91, - isDestroyed: () => false, - getType: () => 'window', - getURL: () => 'file:///renderer/index.html' - } as Electron.WebContents - const setGrabModeHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:setGrabMode' - )?.[1] as ( - event: { sender: Electron.WebContents }, - args: { browserPageId: string; enabled: boolean } - ) => Promise - const registerHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:registerGuest' - )?.[1] as ( - event: { sender: Electron.WebContents }, - args: { - browserPageId: string - workspaceId: string - worktreeId: string - webContentsId: number - } - ) => boolean - - const pendingResult = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) - await Promise.resolve() - expect(setGrabModeMock).not.toHaveBeenCalled() - - expect( - registerHandler( - { sender }, - { - browserPageId: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'worktree-1', - webContentsId: 123 - } - ) - ).toBe(true) - - await expect(pendingResult).resolves.toEqual({ ok: true }) - expect(setGrabModeMock).toHaveBeenCalledWith('page-1', true, guest) - }) - - it('returns not-ready when grab registration does not arrive', async () => { - vi.useFakeTimers() - try { - getGuestWebContentsIdMock.mockReturnValue(null) - getAuthorizedGuestMock.mockReturnValue(null) - registerBrowserHandlers({ getSettings: vi.fn() }) - const sender = { - id: 91, - isDestroyed: () => false, - getType: () => 'window', - getURL: () => 'file:///renderer/index.html' - } as Electron.WebContents - const setGrabModeHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:setGrabMode' - )?.[1] as ( - event: { sender: Electron.WebContents }, - args: { browserPageId: string; enabled: boolean } - ) => Promise - - const pendingResult = setGrabModeHandler( - { sender }, - { browserPageId: 'page-1', enabled: true } - ) - await vi.advanceTimersByTimeAsync(1_001) - - await expect(pendingResult).resolves.toEqual({ ok: false, reason: 'not-ready' }) - expect(setGrabModeMock).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - it('does not enable grab mode after a pending request is cancelled', async () => { const guest = { id: 123 } as Electron.WebContents let registered = false @@ -552,58 +265,6 @@ describe('registerBrowserHandlers', () => { expect(setGrabModeMock).not.toHaveBeenCalled() }) - it('coalesces a cancelled pending enable into one later enable', async () => { - const guest = { id: 123 } as Electron.WebContents - let registered = false - getAuthorizedGuestMock.mockImplementation(() => (registered ? guest : null)) - registerBrowserHandlers({ getSettings: vi.fn() }) - const sender = { - id: 91, - isDestroyed: () => false, - getType: () => 'window', - getURL: () => 'file:///renderer/index.html' - } as Electron.WebContents - const setGrabModeHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:setGrabMode' - )?.[1] as ( - event: { sender: Electron.WebContents }, - args: { browserPageId: string; enabled: boolean } - ) => Promise - const registerHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:registerGuest' - )?.[1] as ( - event: { sender: Electron.WebContents }, - args: { - browserPageId: string - workspaceId: string - worktreeId: string - webContentsId: number - } - ) => boolean - - const firstEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) - await setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: false }) - const latestEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) - - registered = true - registerHandler( - { sender }, - { - browserPageId: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'worktree-1', - webContentsId: 123 - } - ) - - await expect(Promise.all([firstEnable, latestEnable])).resolves.toEqual([ - { ok: true }, - { ok: true } - ]) - expect(setGrabModeMock).toHaveBeenCalledTimes(1) - expect(setGrabModeMock).toHaveBeenCalledWith('page-1', true, guest) - }) - it('serializes in-flight mode changes so a stale enable cannot tear down the latest one', async () => { const guest = { id: 123 } as Electron.WebContents let resolveFirstEnable!: (success: boolean) => void @@ -646,94 +307,6 @@ describe('registerBrowserHandlers', () => { ]) }) - it('distinguishes picker injection failure from guest readiness', async () => { - const guest = { id: 123 } as Electron.WebContents - getAuthorizedGuestMock.mockReturnValue(guest) - setGrabModeMock.mockResolvedValue(false) - registerBrowserHandlers({ getSettings: vi.fn() }) - const sender = { - id: 91, - isDestroyed: () => false, - getType: () => 'window', - getURL: () => 'file:///renderer/index.html' - } as Electron.WebContents - const setGrabModeHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:setGrabMode' - )?.[1] as ( - event: { sender: Electron.WebContents }, - args: { browserPageId: string; enabled: boolean } - ) => Promise - - await expect( - setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) - ).resolves.toEqual({ ok: false, reason: 'injection-failed' }) - }) - - it('resolves worktree and any-tab registration waiters when a guest registers', async () => { - vi.useFakeTimers() - try { - getWebContentsIdByTabIdMock.mockReturnValue(new Map()) - const worktreeWait = waitForWorktreeTabRegistration('worktree-1', 1000) - const anyWait = waitForAnyTabRegistration(1000) - const settled = Promise.allSettled([worktreeWait, anyWait]) - - registerBrowserHandlers({ getSettings: vi.fn() }) - - const registerHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:registerGuest' - )?.[1] as ( - event: { sender: Electron.WebContents }, - args: { - browserPageId: string - workspaceId: string - worktreeId: string - webContentsId: number - } - ) => boolean - - const result = registerHandler( - { - sender: { - id: 91, - isDestroyed: () => false, - getType: () => 'window', - getURL: () => 'file:///renderer/index.html' - } as Electron.WebContents - }, - { - browserPageId: 'page-worktree-1', - workspaceId: 'workspace-1', - worktreeId: 'worktree-1', - webContentsId: 123 - } - ) - - expect(result).toBe(true) - await vi.advanceTimersByTimeAsync(1001) - expect(await settled).toEqual([ - { status: 'fulfilled', value: undefined }, - { status: 'fulfilled', value: undefined } - ]) - } finally { - vi.useRealTimers() - } - }) - - it('resolves worktree registration waits immediately when a tab is already registered', async () => { - getWebContentsIdByTabIdMock.mockReturnValue(new Map([['page-1', 123]])) - getWorktreeIdForTabMock.mockReturnValue('worktree-1') - - await expect(waitForWorktreeTabRegistration('worktree-1', 1000)).resolves.toBeUndefined() - - expect(getWorktreeIdForTabMock).toHaveBeenCalledWith('page-1') - }) - - it('resolves any-tab registration waits immediately when a tab is already registered', async () => { - getWebContentsIdByTabIdMock.mockReturnValue(new Map([['page-1', 123]])) - - await expect(waitForAnyTabRegistration(1000)).resolves.toBeUndefined() - }) - it('does not resolve worktree registration waits from a stale registered guest', async () => { getWebContentsIdByTabIdMock.mockReturnValue(new Map([['page-1', 123]])) getWorktreeIdForTabMock.mockReturnValue('worktree-1') @@ -782,47 +355,6 @@ describe('registerBrowserHandlers', () => { expect(resolved).toBe(true) }) - it('validates annotation viewport bridge requests before syncing to the guest', async () => { - registerBrowserHandlers({ getSettings: vi.fn() }) - const guest = { isDestroyed: () => false } as Electron.WebContents - getAuthorizedGuestMock.mockReturnValue(guest) - - const syncHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:setAnnotationViewportBridge' - )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => Promise | boolean - - const sender = { - id: 91, - isDestroyed: () => false, - getType: () => 'window', - getURL: () => 'file:///renderer/index.html' - } as Electron.WebContents - - const result = await syncHandler( - { sender }, - { - browserPageId: 'page-1', - emitViewport: false, - enabled: true, - markers: [], - token: 'annotationviewporttoken' - } - ) - - expect(result).toBe(true) - expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith( - 'page-1', - { - emitViewport: false, - enabled: true, - markers: [], - token: 'annotationviewporttoken' - }, - // Resolve at execution time because queued navigation may replace the guest. - expect.any(Function) - ) - }) - // Annotation tools must enforce the same renderer ownership as grab tools. it('refuses an annotation viewport bridge request from a renderer that does not own the page', async () => { registerBrowserHandlers({ getSettings: vi.fn() }) @@ -853,33 +385,4 @@ describe('registerBrowserHandlers', () => { expect(result).toBe(false) expect(setAnnotationViewportBridgeMock).not.toHaveBeenCalled() }) - - it('rejects invalid annotation viewport bridge requests', async () => { - registerBrowserHandlers({ getSettings: vi.fn() }) - - const syncHandler = handleMock.mock.calls.find( - ([channel]) => channel === 'browser:setAnnotationViewportBridge' - )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => boolean - - const result = syncHandler( - { - sender: { - id: 91, - isDestroyed: () => false, - getType: () => 'window', - getURL: () => 'file:///renderer/index.html' - } as Electron.WebContents - }, - { - browserPageId: 'page-1', - emitViewport: false, - enabled: true, - markers: [], - token: 'short' - } - ) - - expect(result).toBe(false) - expect(setAnnotationViewportBridgeMock).not.toHaveBeenCalled() - }) })