From 097c7434e82dbb8dab373e6b4a492afffa87475d Mon Sep 17 00:00:00 2001 From: Anton Arnaudov Date: Tue, 29 Sep 2026 15:12:24 +0300 Subject: [PATCH 01/28] fix: five bugs the new tests found - Git: disposing a git that failed to spawn signalled pid 0, the whole process group, which is the extension host (it killed the test runner). Every kill now needs a real pid. - Desktop: a chat cancelled just before its message went out wrote to the stdin it had just ended, an uncaught 'error' in the main process. - Desktop: the AI settings started as a shallow copy of a shared constant, so the first connection added before any settings file existed was pushed into @gitstudio/ai's EMPTY_AI_SETTINGS. - AI: an Azure base URL ending in "/" before its ?api-version query produced ".../gpt//chat/completions". - Refs: annotated tags (most releases) came back with no date, because %(committerdate) is empty for a tag object; %(creatordate) is the commit's date for a commit and the tagger's for a tag. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/src/main/aiBridge.ts | 4 +++- apps/desktop/src/main/warmCliSession.ts | 11 +++++++++-- packages/ai/src/providers/openaiCompat.ts | 6 ++++-- packages/git-service/src/GitProcess.ts | 20 ++++++++++++++++---- packages/git-service/src/RefProvider.ts | 10 +++++++--- packages/host-bridge/src/git.ts | 2 +- 6 files changed, 40 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/main/aiBridge.ts b/apps/desktop/src/main/aiBridge.ts index 2c2edb76..79c4cccf 100644 --- a/apps/desktop/src/main/aiBridge.ts +++ b/apps/desktop/src/main/aiBridge.ts @@ -77,7 +77,9 @@ type Send = (event: E, data: IpcEvents[E]) => void; const CONFIRM_TIMEOUT_MS = 120_000; export class AiBridge { - private settings: AiSettings = { ...EMPTY_AI_SETTINGS }; + // A copy down to the array: a shallow spread shared EMPTY_AI_SETTINGS's + // connections, so an add before any settings file was read pushed into it. + private settings: AiSettings = { ...EMPTY_AI_SETTINGS, connections: [...EMPTY_AI_SETTINGS.connections] }; private loaded = false; /** * Keychain-free key storage. Lazily constructed because `app.getPath` is only diff --git a/apps/desktop/src/main/warmCliSession.ts b/apps/desktop/src/main/warmCliSession.ts index d0ce4c57..585d6354 100644 --- a/apps/desktop/src/main/warmCliSession.ts +++ b/apps/desktop/src/main/warmCliSession.ts @@ -89,8 +89,15 @@ export class WarmCliSession { this.dispose(); }; if (handlers.signal) { - if (handlers.signal.aborted) onAbort(); - else handlers.signal.addEventListener("abort", onAbort, { once: true }); + if (handlers.signal.aborted) { + // Cancelled before the message went out (ai:cancel can land while the + // caller is still saving the turn). dispose() has ended stdin and + // failed the turn; a write now would be an 'error' event nobody + // listens for — an uncaught exception in the main process. + onAbort(); + return; + } + handlers.signal.addEventListener("abort", onAbort, { once: true }); } try { proc.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: text } }) + "\n"); diff --git a/packages/ai/src/providers/openaiCompat.ts b/packages/ai/src/providers/openaiCompat.ts index 0d334890..f8bed8f4 100644 --- a/packages/ai/src/providers/openaiCompat.ts +++ b/packages/ai/src/providers/openaiCompat.ts @@ -54,10 +54,12 @@ export class OpenAiCompatProvider implements Provider { private endpoint(): string { const base = this.opts.baseUrl.replace(/\/+$/, ""); - // Azure deployments carry an `?api-version=` query; append the path before it. + // Azure deployments carry an `?api-version=` query; append the path before + // it — trimming the path's own trailing slash too, or "…/gpt/?api-version" + // became "…/gpt//chat/completions". const q = base.indexOf("?"); if (q !== -1) { - return base.slice(0, q) + "/chat/completions" + base.slice(q); + return base.slice(0, q).replace(/\/+$/, "") + "/chat/completions" + base.slice(q); } return base + "/chat/completions"; } diff --git a/packages/git-service/src/GitProcess.ts b/packages/git-service/src/GitProcess.ts index 53188afa..067fb436 100644 --- a/packages/git-service/src/GitProcess.ts +++ b/packages/git-service/src/GitProcess.ts @@ -127,6 +127,18 @@ function feedStdin(child: ChildProcessWithoutNullStreams, input: string | undefi } } +/** + * SIGTERM a child — only one that actually started. A git whose spawn failed + * (a missing cwd, no git binary) has no pid until Node reports the error on the + * next tick, and killing it in that window signals pid 0: the WHOLE process + * group, which here is the extension host (or the test runner, which is how + * this was found). + */ +function killChild(child: ChildProcessWithoutNullStreams): void { + if (child.pid === undefined) return; + child.kill("SIGTERM"); +} + /** Throw away whatever of stdin has not been written yet. Called once the * child is done with — killed or exited — so a large payload is not held in * memory, or retried against a pipe nobody reads, after it stopped mattering. */ @@ -300,7 +312,7 @@ export class GitProcess { return; } settled = true; - spawned.kill("SIGTERM"); + killChild(spawned); dropStdin(spawned); cleanup(); reject(makeAbortError()); @@ -405,7 +417,7 @@ export class GitProcess { return; } failure = makeAbortError(); - spawned.kill("SIGTERM"); + killChild(spawned); ended = true; wake(); }; @@ -480,7 +492,7 @@ export class GitProcess { signal.removeEventListener("abort", onAbort); } if (spawned.exitCode === null && spawned.signalCode === null) { - spawned.kill("SIGTERM"); + killChild(spawned); } dropStdin(spawned); this.children.delete(spawned); @@ -503,7 +515,7 @@ export class GitProcess { dispose(): void { this.disposed = true; for (const child of this.children) { - child.kill("SIGTERM"); + killChild(child); dropStdin(child); } this.children.clear(); diff --git a/packages/git-service/src/RefProvider.ts b/packages/git-service/src/RefProvider.ts index 2fc988e2..1630e9b1 100644 --- a/packages/git-service/src/RefProvider.ts +++ b/packages/git-service/src/RefProvider.ts @@ -9,8 +9,12 @@ const FIELD_SEP = "\x1f"; // // The last four fields cost nothing — this read already runs — and each buys a // fact the UI could not previously state: -// committerdate a remote branch or tag row with only a name and a sha cannot -// be told from its neighbours, or sorted by anything useful +// creatordate a remote branch or tag row with only a name and a sha cannot +// be told from its neighbours, or sorted by anything useful. +// CREATORdate, not committerdate: an annotated tag points at +// a tag object, which has no committer, so committerdate left +// every release tag without a date; creatordate is the +// commit's date for a commit and the tagger's for a tag // contents:subject what the ref actually points AT // objecttype "tag" for an ANNOTATED tag; the only thing that separates // the two kinds, and nothing has ever carried it @@ -20,7 +24,7 @@ const REF_FORMAT = `--format=%(objectname)${FIELD_SEP}%(refname)${FIELD_SEP}` + `%(refname:short)${FIELD_SEP}%(HEAD)${FIELD_SEP}%(upstream:short)` + `${FIELD_SEP}%(upstream:track)${FIELD_SEP}%(*objectname)` + - `${FIELD_SEP}%(committerdate:unix)${FIELD_SEP}%(contents:subject)` + + `${FIELD_SEP}%(creatordate:unix)${FIELD_SEP}%(contents:subject)` + `${FIELD_SEP}%(objecttype)${FIELD_SEP}%(symref:short)` + // WHO. For a commit-pointing ref, authorname/email answer directly; for an // ANNOTATED tag they are empty (the ref points at a tag object), so the diff --git a/packages/host-bridge/src/git.ts b/packages/host-bridge/src/git.ts index c3bfdb40..8d4d9e65 100644 --- a/packages/host-bridge/src/git.ts +++ b/packages/host-bridge/src/git.ts @@ -64,7 +64,7 @@ export interface GitRef { * one in perfect sync. */ gone?: boolean; - /** Tip commit date, epoch seconds — every kind of ref has one. */ + /** When the tip was made, epoch seconds — the commit's date, or an annotated tag's own. */ date?: number; /** Tip commit subject. A remote branch or a tag with only a name and a sha * cannot be told apart from its neighbours at a glance. */ From 102b4d1c53209565271b41d9322026e767fe6236 Mon Sep 17 00:00:00 2001 From: Anton Arnaudov Date: Tue, 29 Sep 2026 15:12:24 +0300 Subject: [PATCH 02/28] test: cover the least-tested code, from the git layer to the desktop renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests written against real temp repositories, a local bare remote and fakes for GitHub, the AI providers and the CLI — never the network. Every test asserts behaviour: the git state left behind, what a page is sent, what the person is asked and told. Bugs they found and that are not fixed yet are marked test.todo / skip with the correct behaviour written down. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/test/aiBridge.agent.test.ts | 405 ++++++++ apps/desktop/test/aiBridge.fakes.ts | 214 ++++ apps/desktop/test/aiBridge.harness.ts | 181 ++++ apps/desktop/test/aiBridge.settings.test.ts | 369 +++++++ apps/desktop/test/aiBridge.tasks.test.ts | 295 ++++++ apps/desktop/test/assistantSessions.test.ts | 246 +++++ apps/desktop/test/autoUpdate.fakeUpdater.ts | 89 ++ apps/desktop/test/autoUpdate.flows.test.ts | 367 +++++++ apps/desktop/test/branchReset.edges.test.ts | 267 +++++ apps/desktop/test/cliProvider.spawn.test.ts | 280 +++++ apps/desktop/test/cloneBridge.flow.test.ts | 299 ++++++ apps/desktop/test/contextMenu.dom.test.ts | 396 +++++++ apps/desktop/test/desktopMainFakeElectron.ts | 82 ++ apps/desktop/test/dialogs.modals.test.ts | 666 ++++++++++++ apps/desktop/test/editors.icons.test.ts | 246 +++++ apps/desktop/test/focusReturn.dom.test.ts | 393 +++++++ apps/desktop/test/ghFakeApi.ts | 105 ++ apps/desktop/test/ghFakeElectron.ts | 50 + apps/desktop/test/gitBridge.noRepo.test.ts | 156 +++ apps/desktop/test/gitBridge.ops.test.ts | 544 ++++++++++ apps/desktop/test/gitBridge.reads.test.ts | 520 ++++++++++ apps/desktop/test/gitBridge.staging.test.ts | 500 +++++++++ apps/desktop/test/gitBridgeFixture.ts | 106 ++ apps/desktop/test/githubBridge.api.test.ts | 504 +++++++++ apps/desktop/test/githubClient.api.test.ts | 440 ++++++++ apps/desktop/test/helpers/miniDom.ts | 820 +++++++++++++++ apps/desktop/test/issues.api.test.ts | 543 ++++++++++ apps/desktop/test/mergeParity.edges.test.ts | 441 ++++++++ apps/desktop/test/prs.api.test.ts | 510 +++++++++ apps/desktop/test/rebaseBridge.plans.test.ts | 159 +++ apps/desktop/test/releases.api.test.ts | 217 ++++ apps/desktop/test/repoBrowse.api.test.ts | 201 ++++ apps/desktop/test/ui.builders.test.ts | 706 +++++++++++++ apps/desktop/test/ui.openMenu.test.ts | 613 +++++++++++ apps/desktop/test/warmCliSession.test.ts | 267 +++++ .../test/blameController.surface.test.ts | 567 ++++++++++ .../test/branchActions.doors.test.ts | 973 ++++++++++++++++++ apps/extension/test/checkoutPr.cov.test.ts | 359 +++++++ .../test/commitActions.single.test.ts | 494 +++++++++ .../test/commitView.branch.cov.test.ts | 366 +++++++ .../test/commitView.commit.cov.test.ts | 293 ++++++ .../test/commitView.doors.cov.test.ts | 540 ++++++++++ .../test/commitView.edges.cov.test.ts | 213 ++++ .../test/commitView.host.cov.test.ts | 303 ++++++ .../test/commitView.push.cov.test.ts | 330 ++++++ .../test/commitView.refresh.cov.test.ts | 98 ++ apps/extension/test/commitViewCovKit.ts | 329 ++++++ apps/extension/test/errorReporter.cov.test.ts | 268 +++++ apps/extension/test/gitDoors.cov.test.ts | 325 ++++++ apps/extension/test/githubApi.cov.test.ts | 594 +++++++++++ apps/extension/test/githubAuth.cov.test.ts | 147 +++ apps/extension/test/graphPanel.host.test.ts | 853 +++++++++++++++ apps/extension/test/graphPanel.kit.ts | 475 +++++++++ apps/extension/test/history.cov.test.ts | 618 +++++++++++ .../test/prContentProvider.cov.test.ts | 89 ++ apps/extension/test/prCreatePage.cov.test.ts | 370 +++++++ apps/extension/test/prFeature.cov.test.ts | 117 +++ apps/extension/test/prPage.cov.test.ts | 566 ++++++++++ .../test/pullRequestsView.cov.test.ts | 137 +++ .../extension/test/rebaseCommands.cov.test.ts | 306 ++++++ .../test/rebaseTodoEditor.cov.test.ts | 323 ++++++ .../test/rebaseWorkspacePanel.cov.test.ts | 454 ++++++++ apps/extension/test/repoContext.cov.test.ts | 56 + apps/extension/test/repoManager.cov.test.ts | 132 +++ apps/extension/test/reviewMode.cov.test.ts | 373 +++++++ .../test/revisionContent.cov.test.ts | 160 +++ apps/extension/test/stashesView.cov.test.ts | 718 +++++++++++++ .../test/statusCluster.terminal.test.ts | 296 ++++++ apps/extension/test/syncStatus.verbs.test.ts | 776 ++++++++++++++ apps/extension/test/undoLedger.cov.test.ts | 374 +++++++ apps/extension/test/worktreesView.cov.test.ts | 766 ++++++++++++++ .../test/worktreesWebview.cov.test.ts | 725 +++++++++++++ apps/mcp/test/server.surface.test.ts | 320 ++++++ packages/ai/test/agent.loop.test.ts | 360 +++++++ packages/ai/test/anthropic.wire.test.ts | 503 +++++++++ packages/ai/test/catalog.knownModels.test.ts | 9 + packages/ai/test/gitTools.catalog.test.ts | 430 ++++++++ packages/ai/test/openaiCompat.wire.test.ts | 438 ++++++++ .../ai/test/providers.makeProvider.test.ts | 88 ++ packages/ai/test/tasks.prompts.test.ts | 182 ++++ packages/engine/test/blame.cov.test.ts | 29 + packages/engine/test/blockStaging.cov.test.ts | 24 + packages/engine/test/documentText.cov.test.ts | 210 ++++ packages/engine/test/gitBrainCore.cov.test.ts | 245 +++++ packages/engine/test/graphLayout.cov.test.ts | 76 ++ packages/engine/test/lineDiff.cov.test.ts | 29 + packages/engine/test/mergeModel.cov.test.ts | 44 + packages/engine/test/parseRemote.cov.test.ts | 47 + packages/engine/test/planEdit.cov.test.ts | 100 ++ packages/engine/test/prCreate.cov.test.ts | 121 +++ packages/engine/test/prList.cov.test.ts | 215 ++++ packages/engine/test/prPage.cov.test.ts | 486 +++++++++ packages/engine/test/pullRequests.cov.test.ts | 86 ++ packages/engine/test/sides.cov.test.ts | 27 + packages/engine/test/stashEnd.cov.test.ts | 52 + packages/engine/test/todo.cov.test.ts | 53 + packages/engine/test/types.cov.test.ts | 30 + .../test/blockStaging.edges.test.ts | 59 ++ .../git-service/test/branchOps.edges.test.ts | 173 ++++ .../test/branchReset.edges.test.ts | 131 +++ .../test/changesInTheWay.edges.test.ts | 284 +++++ .../test/changesInTheWay.retry.test.ts | 305 ++++++ .../test/commitDetailsProvider.edges.test.ts | 98 ++ .../test/conflictOps.failures.test.ts | 500 +++++++++ .../git-service/test/dropCommit.plan.test.ts | 211 ++++ .../git-service/test/dropCommit.undo.test.ts | 327 ++++++ .../test/gitProcess.lifecycle.test.ts | 237 +++++ .../test/gitToolHost.surface.test.ts | 507 +++++++++ .../test/historyProvider.errors.test.ts | 135 +++ packages/git-service/test/indexWrites.test.ts | 115 +++ .../test/multiCommit.edges.test.ts | 64 ++ .../test/operationProvider.edges.test.ts | 340 ++++++ .../operationProvider.gitFailures.test.ts | 133 +++ .../git-service/test/prBranch.edges.test.ts | 159 +++ .../git-service/test/prCheckout.edges.test.ts | 138 +++ .../git-service/test/rebaseInProgress.test.ts | 116 +++ .../test/rebaseRunner.commentChar.test.ts | 60 ++ .../test/rebaseRunner.silentGit.test.ts | 112 ++ .../test/rebaseRunner.stops.test.ts | 353 +++++++ .../test/refProvider.ambiguous.test.ts | 85 ++ .../test/refProvider.listing.test.ts | 351 +++++++ packages/git-service/test/refRestore.test.ts | 212 ++++ packages/git-service/test/remoteOps.test.ts | 195 ++++ .../test/snapshotProvider.branches.test.ts | 264 +++++ .../test/snapshotProvider.capture.test.ts | 215 ++++ .../test/snapshotProvider.fingerprint.test.ts | 308 ++++++ .../test/snapshotProvider.fixture.ts | 118 +++ .../test/snapshotProvider.head.test.ts | 409 ++++++++ .../test/snapshotProvider.rebase.test.ts | 284 +++++ .../test/snapshotProvider.revert.test.ts | 161 +++ .../test/snapshotProvider.stashes.test.ts | 267 +++++ .../test/snapshotProvider.stops.test.ts | 185 ++++ packages/git-service/test/spawnAudit.test.ts | 124 +++ .../test/stagingProvider.commit.test.ts | 138 +++ .../test/stagingProvider.conflicts.test.ts | 105 ++ .../test/stagingProvider.index.test.ts | 372 +++++++ .../git-service/test/stashProvider.kit.ts | 42 + .../test/stashProvider.refusals.test.ts | 280 +++++ .../test/stashProvider.subset.test.ts | 235 +++++ .../test/stashRestore.placement.test.ts | 223 ++++ .../test/statusProvider.states.test.ts | 154 +++ packages/git-service/test/syncOps.fixture.ts | 115 +++ .../test/syncOps.gitFailures.test.ts | 158 +++ .../test/syncOps.pullEdges.test.ts | 151 +++ .../test/syncOps.pushTargets.test.ts | 312 ++++++ packages/git-service/test/tagOps.test.ts | 118 +++ .../test/worktreeProvider.edges.test.ts | 134 +++ .../test/worktreeState.edges.test.ts | 73 ++ .../host-bridge/test/scrub.hardening.test.ts | 187 ++++ .../test/autoRouteHost.cov.test.ts | 244 +++++ .../merge-vscode/test/coexistence.cov.test.ts | 117 +++ .../test/conflictsPanel.cov.test.ts | 405 ++++++++ .../merge-vscode/test/contract.cov.test.ts | 99 ++ packages/merge-vscode/test/demo.cov.test.ts | 148 +++ .../merge-vscode/test/diffPanel.cov.test.ts | 765 ++++++++++++++ packages/merge-vscode/test/host.cov.test.ts | 171 +++ .../test/mergeEditorProvider.cov.test.ts | 306 ++++++ .../test/mergeSession.cov.test.ts | 305 ++++++ .../merge-vscode/test/payload.cov.test.ts | 75 ++ .../merge-vscode/test/register.cov.test.ts | 736 +++++++++++++ .../test/vscodeGitLocator.cov.test.ts | 288 ++++++ packages/webview-ui/test/avatar.cov.test.ts | 142 +++ packages/webview-ui/test/fakeDom.cov.ts | 198 ++++ packages/webview-ui/test/gutter.cov.test.ts | 161 +++ packages/webview-ui/test/markdown.cov.test.ts | 56 + .../webview-ui/test/noTextPanel.cov.test.ts | 226 ++++ packages/webview-ui/test/opText.cov.test.ts | 233 +++++ .../webview-ui/test/refFilter.cov.test.ts | 74 ++ packages/webview-ui/test/refTip.cov.test.ts | 395 +++++++ 169 files changed, 46960 insertions(+) create mode 100644 apps/desktop/test/aiBridge.agent.test.ts create mode 100644 apps/desktop/test/aiBridge.fakes.ts create mode 100644 apps/desktop/test/aiBridge.harness.ts create mode 100644 apps/desktop/test/aiBridge.settings.test.ts create mode 100644 apps/desktop/test/aiBridge.tasks.test.ts create mode 100644 apps/desktop/test/assistantSessions.test.ts create mode 100644 apps/desktop/test/autoUpdate.fakeUpdater.ts create mode 100644 apps/desktop/test/autoUpdate.flows.test.ts create mode 100644 apps/desktop/test/branchReset.edges.test.ts create mode 100644 apps/desktop/test/cliProvider.spawn.test.ts create mode 100644 apps/desktop/test/cloneBridge.flow.test.ts create mode 100644 apps/desktop/test/contextMenu.dom.test.ts create mode 100644 apps/desktop/test/desktopMainFakeElectron.ts create mode 100644 apps/desktop/test/dialogs.modals.test.ts create mode 100644 apps/desktop/test/editors.icons.test.ts create mode 100644 apps/desktop/test/focusReturn.dom.test.ts create mode 100644 apps/desktop/test/ghFakeApi.ts create mode 100644 apps/desktop/test/ghFakeElectron.ts create mode 100644 apps/desktop/test/gitBridge.noRepo.test.ts create mode 100644 apps/desktop/test/gitBridge.ops.test.ts create mode 100644 apps/desktop/test/gitBridge.reads.test.ts create mode 100644 apps/desktop/test/gitBridge.staging.test.ts create mode 100644 apps/desktop/test/gitBridgeFixture.ts create mode 100644 apps/desktop/test/githubBridge.api.test.ts create mode 100644 apps/desktop/test/githubClient.api.test.ts create mode 100644 apps/desktop/test/helpers/miniDom.ts create mode 100644 apps/desktop/test/issues.api.test.ts create mode 100644 apps/desktop/test/mergeParity.edges.test.ts create mode 100644 apps/desktop/test/prs.api.test.ts create mode 100644 apps/desktop/test/rebaseBridge.plans.test.ts create mode 100644 apps/desktop/test/releases.api.test.ts create mode 100644 apps/desktop/test/repoBrowse.api.test.ts create mode 100644 apps/desktop/test/ui.builders.test.ts create mode 100644 apps/desktop/test/ui.openMenu.test.ts create mode 100644 apps/desktop/test/warmCliSession.test.ts create mode 100644 apps/extension/test/blameController.surface.test.ts create mode 100644 apps/extension/test/branchActions.doors.test.ts create mode 100644 apps/extension/test/checkoutPr.cov.test.ts create mode 100644 apps/extension/test/commitActions.single.test.ts create mode 100644 apps/extension/test/commitView.branch.cov.test.ts create mode 100644 apps/extension/test/commitView.commit.cov.test.ts create mode 100644 apps/extension/test/commitView.doors.cov.test.ts create mode 100644 apps/extension/test/commitView.edges.cov.test.ts create mode 100644 apps/extension/test/commitView.host.cov.test.ts create mode 100644 apps/extension/test/commitView.push.cov.test.ts create mode 100644 apps/extension/test/commitView.refresh.cov.test.ts create mode 100644 apps/extension/test/commitViewCovKit.ts create mode 100644 apps/extension/test/errorReporter.cov.test.ts create mode 100644 apps/extension/test/gitDoors.cov.test.ts create mode 100644 apps/extension/test/githubApi.cov.test.ts create mode 100644 apps/extension/test/githubAuth.cov.test.ts create mode 100644 apps/extension/test/graphPanel.host.test.ts create mode 100644 apps/extension/test/graphPanel.kit.ts create mode 100644 apps/extension/test/history.cov.test.ts create mode 100644 apps/extension/test/prContentProvider.cov.test.ts create mode 100644 apps/extension/test/prCreatePage.cov.test.ts create mode 100644 apps/extension/test/prFeature.cov.test.ts create mode 100644 apps/extension/test/prPage.cov.test.ts create mode 100644 apps/extension/test/pullRequestsView.cov.test.ts create mode 100644 apps/extension/test/rebaseCommands.cov.test.ts create mode 100644 apps/extension/test/rebaseTodoEditor.cov.test.ts create mode 100644 apps/extension/test/rebaseWorkspacePanel.cov.test.ts create mode 100644 apps/extension/test/repoContext.cov.test.ts create mode 100644 apps/extension/test/repoManager.cov.test.ts create mode 100644 apps/extension/test/reviewMode.cov.test.ts create mode 100644 apps/extension/test/revisionContent.cov.test.ts create mode 100644 apps/extension/test/stashesView.cov.test.ts create mode 100644 apps/extension/test/statusCluster.terminal.test.ts create mode 100644 apps/extension/test/syncStatus.verbs.test.ts create mode 100644 apps/extension/test/undoLedger.cov.test.ts create mode 100644 apps/extension/test/worktreesView.cov.test.ts create mode 100644 apps/extension/test/worktreesWebview.cov.test.ts create mode 100644 apps/mcp/test/server.surface.test.ts create mode 100644 packages/ai/test/agent.loop.test.ts create mode 100644 packages/ai/test/anthropic.wire.test.ts create mode 100644 packages/ai/test/catalog.knownModels.test.ts create mode 100644 packages/ai/test/gitTools.catalog.test.ts create mode 100644 packages/ai/test/openaiCompat.wire.test.ts create mode 100644 packages/ai/test/providers.makeProvider.test.ts create mode 100644 packages/ai/test/tasks.prompts.test.ts create mode 100644 packages/engine/test/blame.cov.test.ts create mode 100644 packages/engine/test/blockStaging.cov.test.ts create mode 100644 packages/engine/test/documentText.cov.test.ts create mode 100644 packages/engine/test/gitBrainCore.cov.test.ts create mode 100644 packages/engine/test/graphLayout.cov.test.ts create mode 100644 packages/engine/test/lineDiff.cov.test.ts create mode 100644 packages/engine/test/mergeModel.cov.test.ts create mode 100644 packages/engine/test/parseRemote.cov.test.ts create mode 100644 packages/engine/test/planEdit.cov.test.ts create mode 100644 packages/engine/test/prCreate.cov.test.ts create mode 100644 packages/engine/test/prList.cov.test.ts create mode 100644 packages/engine/test/prPage.cov.test.ts create mode 100644 packages/engine/test/pullRequests.cov.test.ts create mode 100644 packages/engine/test/sides.cov.test.ts create mode 100644 packages/engine/test/stashEnd.cov.test.ts create mode 100644 packages/engine/test/todo.cov.test.ts create mode 100644 packages/engine/test/types.cov.test.ts create mode 100644 packages/git-service/test/blockStaging.edges.test.ts create mode 100644 packages/git-service/test/branchOps.edges.test.ts create mode 100644 packages/git-service/test/branchReset.edges.test.ts create mode 100644 packages/git-service/test/changesInTheWay.edges.test.ts create mode 100644 packages/git-service/test/changesInTheWay.retry.test.ts create mode 100644 packages/git-service/test/commitDetailsProvider.edges.test.ts create mode 100644 packages/git-service/test/conflictOps.failures.test.ts create mode 100644 packages/git-service/test/dropCommit.plan.test.ts create mode 100644 packages/git-service/test/dropCommit.undo.test.ts create mode 100644 packages/git-service/test/gitProcess.lifecycle.test.ts create mode 100644 packages/git-service/test/gitToolHost.surface.test.ts create mode 100644 packages/git-service/test/historyProvider.errors.test.ts create mode 100644 packages/git-service/test/indexWrites.test.ts create mode 100644 packages/git-service/test/multiCommit.edges.test.ts create mode 100644 packages/git-service/test/operationProvider.edges.test.ts create mode 100644 packages/git-service/test/operationProvider.gitFailures.test.ts create mode 100644 packages/git-service/test/prBranch.edges.test.ts create mode 100644 packages/git-service/test/prCheckout.edges.test.ts create mode 100644 packages/git-service/test/rebaseInProgress.test.ts create mode 100644 packages/git-service/test/rebaseRunner.commentChar.test.ts create mode 100644 packages/git-service/test/rebaseRunner.silentGit.test.ts create mode 100644 packages/git-service/test/rebaseRunner.stops.test.ts create mode 100644 packages/git-service/test/refProvider.ambiguous.test.ts create mode 100644 packages/git-service/test/refProvider.listing.test.ts create mode 100644 packages/git-service/test/refRestore.test.ts create mode 100644 packages/git-service/test/remoteOps.test.ts create mode 100644 packages/git-service/test/snapshotProvider.branches.test.ts create mode 100644 packages/git-service/test/snapshotProvider.capture.test.ts create mode 100644 packages/git-service/test/snapshotProvider.fingerprint.test.ts create mode 100644 packages/git-service/test/snapshotProvider.fixture.ts create mode 100644 packages/git-service/test/snapshotProvider.head.test.ts create mode 100644 packages/git-service/test/snapshotProvider.rebase.test.ts create mode 100644 packages/git-service/test/snapshotProvider.revert.test.ts create mode 100644 packages/git-service/test/snapshotProvider.stashes.test.ts create mode 100644 packages/git-service/test/snapshotProvider.stops.test.ts create mode 100644 packages/git-service/test/spawnAudit.test.ts create mode 100644 packages/git-service/test/stagingProvider.commit.test.ts create mode 100644 packages/git-service/test/stagingProvider.conflicts.test.ts create mode 100644 packages/git-service/test/stagingProvider.index.test.ts create mode 100644 packages/git-service/test/stashProvider.kit.ts create mode 100644 packages/git-service/test/stashProvider.refusals.test.ts create mode 100644 packages/git-service/test/stashProvider.subset.test.ts create mode 100644 packages/git-service/test/stashRestore.placement.test.ts create mode 100644 packages/git-service/test/statusProvider.states.test.ts create mode 100644 packages/git-service/test/syncOps.fixture.ts create mode 100644 packages/git-service/test/syncOps.gitFailures.test.ts create mode 100644 packages/git-service/test/syncOps.pullEdges.test.ts create mode 100644 packages/git-service/test/syncOps.pushTargets.test.ts create mode 100644 packages/git-service/test/tagOps.test.ts create mode 100644 packages/git-service/test/worktreeProvider.edges.test.ts create mode 100644 packages/git-service/test/worktreeState.edges.test.ts create mode 100644 packages/host-bridge/test/scrub.hardening.test.ts create mode 100644 packages/merge-vscode/test/autoRouteHost.cov.test.ts create mode 100644 packages/merge-vscode/test/coexistence.cov.test.ts create mode 100644 packages/merge-vscode/test/conflictsPanel.cov.test.ts create mode 100644 packages/merge-vscode/test/contract.cov.test.ts create mode 100644 packages/merge-vscode/test/demo.cov.test.ts create mode 100644 packages/merge-vscode/test/diffPanel.cov.test.ts create mode 100644 packages/merge-vscode/test/host.cov.test.ts create mode 100644 packages/merge-vscode/test/mergeEditorProvider.cov.test.ts create mode 100644 packages/merge-vscode/test/mergeSession.cov.test.ts create mode 100644 packages/merge-vscode/test/payload.cov.test.ts create mode 100644 packages/merge-vscode/test/register.cov.test.ts create mode 100644 packages/merge-vscode/test/vscodeGitLocator.cov.test.ts create mode 100644 packages/webview-ui/test/avatar.cov.test.ts create mode 100644 packages/webview-ui/test/fakeDom.cov.ts create mode 100644 packages/webview-ui/test/gutter.cov.test.ts create mode 100644 packages/webview-ui/test/markdown.cov.test.ts create mode 100644 packages/webview-ui/test/noTextPanel.cov.test.ts create mode 100644 packages/webview-ui/test/opText.cov.test.ts create mode 100644 packages/webview-ui/test/refFilter.cov.test.ts create mode 100644 packages/webview-ui/test/refTip.cov.test.ts diff --git a/apps/desktop/test/aiBridge.agent.test.ts b/apps/desktop/test/aiBridge.agent.test.ts new file mode 100644 index 00000000..2d0f95d0 --- /dev/null +++ b/apps/desktop/test/aiBridge.agent.test.ts @@ -0,0 +1,405 @@ +// The Assistant: the agent run (ai:agentRun), the approval gate in front of +// every write, and the persisted chats (ai:chat*) — over an HTTP model that +// calls git tools, and over a warm local `claude`. +// +// What is pinned is what a user relies on: a write happens only after they +// approve it (and never after they deny, cancel, or walk away); the tool steps +// stream to the renderer; a chat remembers its turns and hands them back to +// the model; a local CLI chat pays the cold start once and resumes after. +// +// Real throwaway repository; fake fetch model; scripted FakeChild `claude`. + +import { onEverySpawn, onNextSpawn, resetSpawns, spawned, until, type FakeChild } from "./aiBridge.fakes"; +import { test, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { summarizeArgs } from "../src/main/aiBridge"; +import { cleanupAll, fakeModel, freshUserData, makeBridge, makeRepo, type FakeModel, type Sent } from "./aiBridge.harness"; + +let model: FakeModel; +let userData: string; +beforeEach(() => { + userData = freshUserData(); + model = fakeModel(); + resetSpawns(); +}); +afterEach(() => { + model.restore(); + cleanupAll(); +}); + +async function setup(preset = "ollama") { + const repo = makeRepo(); + const b = await makeBridge(repo.root); + const connId = (await b.bridge.addConnection(preset)).connections[0].id; + return { ...repo, ...b, connId }; +} + +const events = (sent: Sent[], kind?: string) => + sent.filter((s) => s.event === "ai:agentEvent" && (!kind || s.data.kind === kind)).map((s) => s.data); +const confirms = (sent: Sent[]) => sent.filter((s) => s.event === "ai:confirmRequest").map((s) => s.data); +const staged = (git: (...a: string[]) => string) => git("diff", "--cached", "--name-only").trim(); + +// ── agent runs ──────────────────────────────────────────────────────────────── + +test("the agent reads the repo with a tool, and every step streams to the renderer", async () => { + const { bridge, root, sent } = await setup(); + writeFileSync(join(root, "a.txt"), "changed\n"); + model.replies.push( + { content: "Let me look.", toolCalls: [{ id: "c1", name: "git_status", args: {} }] }, + { content: "One file changed: a.txt." }, + ); + const r = await bridge.runAgentTask({ requestId: "q1", goal: "what changed?", allowWrite: false, allowDestructive: false }); + assert.deepEqual(r, { requestId: "q1", ok: true, text: "One file changed: a.txt." }); + + const call = events(sent, "tool_call")[0]; + assert.equal(call.tool, "git_status"); + assert.equal(call.callId, "c1"); + assert.deepEqual(call.args, {}); + const result = events(sent, "tool_result")[0]; + assert.equal(result.isError, false); + assert.match(String(result.text), /a\.txt/, "the tool ran against the real repository"); + assert.equal(events(sent, "done").length, 1); + assert.ok(events(sent).every((e) => e.requestId === "q1")); + + // The tool's output went back to the model on the next turn. + const second = model.chats[1].body.messages!; + assert.ok(second.some((m) => m.role === "tool" && /a\.txt/.test(String(m.content)))); + assert.equal(confirms(sent).length, 0, "a read needs no approval"); + // A read-only run offers no write tools at all. + const tools = (model.chats[0].body.tools as { function: { name: string } }[]).map((t) => t.function.name); + assert.ok(tools.includes("git_status") && !tools.includes("git_stage") && !tools.includes("git_reset")); +}); + +test("the run uses the requested tier, or an explicit model id over it", async () => { + const { bridge } = await setup(); + model.replies.push({ content: "a" }, { content: "b" }); + await bridge.runAgentTask({ requestId: "t", goal: "g", allowWrite: false, allowDestructive: false, model: "fast" }); + assert.equal(model.chats[0].body.model, "llama3.2"); + await bridge.runAgentTask({ requestId: "t2", goal: "g", allowWrite: false, allowDestructive: false, modelId: "custom:7b" }); + assert.equal(model.chats[1].body.model, "custom:7b"); +}); + +test("a write waits for approval, says what it will do, and happens once approved", async () => { + const { bridge, root, git, sent } = await setup(); + writeFileSync(join(root, "a.txt"), "changed\n"); + model.replies.push({ toolCalls: [{ id: "w1", name: "git_stage", args: { paths: ["a.txt"] } }] }, { content: "Staged." }); + const run = bridge.runAgentTask({ requestId: "w", goal: "stage it", allowWrite: true, allowDestructive: false }); + await until(() => confirms(sent).length === 1, "the approval request"); + + const ask = confirms(sent)[0]; + assert.equal(ask.requestId, "w"); + assert.equal(ask.tool, "git_stage"); + assert.equal(ask.mode, "write"); + assert.equal(ask.summary, "Stage: a.txt"); + assert.match(String(ask.callId), /^w:/, "the call id is scoped to its request"); + assert.equal(staged(git), "", "nothing is staged while the user decides"); + + bridge.confirmAnswer({ callId: String(ask.callId), approved: true }); + const r = await run; + assert.equal(r.ok, true); + assert.equal(staged(git), "a.txt", "approved: the write really happened"); +}); + +test("a denied write does not happen, and the model is told", async () => { + const { bridge, root, git, sent } = await setup(); + writeFileSync(join(root, "a.txt"), "changed\n"); + model.replies.push({ toolCalls: [{ id: "w1", name: "git_stage", args: { all: true } }] }, { content: "Okay, left it." }); + const run = bridge.runAgentTask({ requestId: "d", goal: "stage", allowWrite: true, allowDestructive: false }); + await until(() => confirms(sent).length === 1, "the approval request"); + assert.equal(confirms(sent)[0].summary, "Stage all changes."); + bridge.confirmAnswer({ callId: String(confirms(sent)[0].callId), approved: false }); + bridge.confirmAnswer({ callId: String(confirms(sent)[0].callId), approved: true }); // a late second answer is ignored + const r = await run; + assert.equal(r.text, "Okay, left it."); + assert.equal(staged(git), "", "denied: nothing staged"); + assert.equal(events(sent, "tool_denied").length, 1); +}); + +test("a destructive tool asks in destructive mode and spells out the loss", async () => { + const { bridge, root, sent } = await setup(); + writeFileSync(join(root, "a.txt"), "precious edit\n"); + model.replies.push({ toolCalls: [{ id: "x", name: "git_discard", args: { paths: ["a.txt"] } }] }, { content: "Kept." }); + const run = bridge.runAgentTask({ requestId: "z", goal: "discard", allowWrite: true, allowDestructive: true }); + await until(() => confirms(sent).length === 1, "the approval request"); + const ask = confirms(sent)[0]; + assert.equal(ask.mode, "destructive"); + assert.match(String(ask.summary), /can't be undone/); + bridge.confirmAnswer({ callId: String(ask.callId), approved: false }); + await run; + assert.equal(readFileSync(join(root, "a.txt"), "utf8").replace(/\r\n/g, "\n"), "precious edit\n", "the edit survives"); +}); + +test("cancelling a run while it waits for approval denies the write", async () => { + const { bridge, root, git, sent } = await setup(); + writeFileSync(join(root, "a.txt"), "changed\n"); + model.replies.push({ toolCalls: [{ id: "w1", name: "git_stage", args: { paths: ["a.txt"] } }] }, { content: "never asked" }); + const run = bridge.runAgentTask({ requestId: "cx", goal: "stage", allowWrite: true, allowDestructive: false }); + await until(() => confirms(sent).length === 1, "the approval request"); + bridge.cancel("cx"); + const r = await run; + assert.equal(r.text, "", "the run stops rather than answering"); + assert.equal(staged(git), ""); + assert.equal(model.chats.length, 1, "the model is not asked again after a cancel"); + // Approving after the cancel is too late. + bridge.confirmAnswer({ callId: String(confirms(sent)[0].callId), approved: true }); + assert.equal(staged(git), ""); +}); + +test("an approval nobody answers is denied after two minutes", async () => { + const { bridge, root, git, sent } = await setup(); + writeFileSync(join(root, "a.txt"), "changed\n"); + model.replies.push({ toolCalls: [{ id: "w1", name: "git_stage", args: { paths: ["a.txt"] } }] }, { content: "Timed out, left it." }); + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const run = bridge.runAgentTask({ requestId: "to", goal: "stage", allowWrite: true, allowDestructive: false }); + await until(() => confirms(sent).length === 1, "the approval request"); + mock.timers.tick(119_999); + await new Promise((r) => setImmediate(r)); + assert.equal(model.chats.length, 1, "still waiting just before the limit"); + mock.timers.tick(1); + const r = await run; + assert.equal(r.text, "Timed out, left it."); + assert.equal(staged(git), "", "no answer is a no"); + assert.equal(events(sent, "tool_denied").length, 1); + } finally { + mock.timers.reset(); + } +}); + +test("a model failure ends the run as not ok, with the reason as an event", async () => { + const { bridge, sent } = await setup(); + model.replies.push(new Response("", { status: 429 })); + const r = await bridge.runAgentTask({ requestId: "e", goal: "g", allowWrite: false, allowDestructive: false }); + assert.equal(r.ok, false); + assert.match(String(events(sent, "error")[0].text), /rate limit/); +}); + +test("the agent needs a model and an open repository", async () => { + const repo = makeRepo(); + const { bridge } = await makeBridge(repo.root); + const req = { requestId: "n", goal: "g", allowWrite: false, allowDestructive: false }; + assert.deepEqual(await bridge.runAgentTask(req), { + requestId: "n", + ok: false, + expected: true, + message: "No AI model is connected. Add one in Settings ▸ AI.", + }); + const { bridge: noRepo } = await makeBridge(); + await noRepo.addConnection("ollama"); + assert.deepEqual(await noRepo.runAgentTask(req), { requestId: "n", ok: false, expected: true, message: "Open a repository first." }); +}); + +// ── chats ───────────────────────────────────────────────────────────────────── + +test("with no repository there are no chats, and none can be started", async () => { + const { bridge } = await makeBridge(); + await bridge.addConnection("ollama"); + assert.deepEqual(await bridge.chatList(), []); + assert.equal(await bridge.chatCurrent(), undefined); + assert.equal(await bridge.chatNew(), undefined); + await bridge.chatSetCurrent("anything"); // harmless +}); + +test("with no model a chat cannot be started", async () => { + const repo = makeRepo(); + const { bridge } = await makeBridge(repo.root); + assert.equal(await bridge.chatNew(), undefined); +}); + +test("chats are created, listed, reopened and deleted per repository", async () => { + const { bridge, connId } = await setup(); + const a = (await bridge.chatNew())!; + assert.deepEqual(a, { id: a.id, title: "New chat", connectionId: connId, turns: [] }); + const footer = (await bridge.chatNew(false))!; + assert.equal((await bridge.chatCurrent())?.id, a.id, "a footer chat does not take over"); + await bridge.chatSetCurrent(footer.id); + assert.equal((await bridge.chatCurrent())?.id, footer.id); + const list = await bridge.chatList(); + assert.deepEqual(list.map((c) => c.id).sort(), [a.id, footer.id].sort()); + assert.ok(list.every((c) => typeof c.updatedAt === "number" && c.title === "New chat")); + assert.equal((await bridge.chatGet(a.id))?.id, a.id); + await bridge.chatDelete(a.id); + assert.equal(await bridge.chatGet(a.id), undefined); + assert.equal(await bridge.chatGet("never"), undefined); +}); + +test("an HTTP chat keeps its turns and hands the history back to the model", async () => { + const { bridge } = await setup(); + const chat = (await bridge.chatNew())!; + model.replies.push({ content: "Hi! How can I help?" }, { content: "You asked me to say hi." }); + + const r1 = await bridge.chatSend({ chatId: chat.id, requestId: "m1", goal: "say hi", allowWrite: false, allowDestructive: false }); + assert.deepEqual(r1, { requestId: "m1", ok: true, text: "Hi! How can I help?" }); + const r2 = await bridge.chatSend({ chatId: chat.id, requestId: "m2", goal: "what did I ask?", allowWrite: false, allowDestructive: false }); + assert.equal(r2.text, "You asked me to say hi."); + + const second = model.chats[1].body.messages!.filter((m) => m.role !== "system"); + assert.deepEqual( + second.map((m) => [m.role, m.content]), + [ + ["user", "say hi"], + ["assistant", "Hi! How can I help?"], + ["user", "what did I ask?"], + ], + "the earlier turns come first, the new message last", + ); + const view = (await bridge.chatGet(chat.id))!; + assert.equal(view.title, "say hi", "the first message names the chat"); + assert.deepEqual(view.turns.map((t) => t.role), ["user", "assistant", "user", "assistant"]); + + // Persisted: a fresh bridge (an app restart) sees the same transcript. + const { bridge: restarted } = await makeBridge(); + assert.equal((await restarted.chatGet(chat.id))?.turns.length, 4); +}); + +test("an HTTP chat can run approved writes like the agent", async () => { + const { bridge, root, git, sent } = await setup(); + writeFileSync(join(root, "a.txt"), "changed\n"); + const chat = (await bridge.chatNew())!; + model.replies.push({ toolCalls: [{ id: "s", name: "git_stage", args: { paths: ["a.txt"] } }] }, { content: "Done." }); + const run = bridge.chatSend({ chatId: chat.id, requestId: "cw", goal: "stage a", allowWrite: true, allowDestructive: false, modelId: "m-x", thinking: "off" }); + await until(() => confirms(sent).length === 1, "the approval request"); + bridge.confirmAnswer({ callId: String(confirms(sent)[0].callId), approved: true }); + assert.equal((await run).text, "Done."); + assert.equal(staged(git), "a.txt"); + assert.equal(model.chats[0].body.model, "m-x"); + assert.equal(events(sent, "tool_call")[0].tool, "git_stage"); +}); + +test("a failed HTTP chat turn is reported and the user's message is kept", async () => { + const { bridge } = await setup(); + const chat = (await bridge.chatNew())!; + model.replies.push(new Response("", { status: 401 })); + const r = await bridge.chatSend({ chatId: chat.id, requestId: "f", goal: "hello", allowWrite: false, allowDestructive: false }); + assert.equal(r.ok, false); + assert.equal((await bridge.chatGet(chat.id))!.turns[0].text, "hello"); +}); + +test("sending to a chat that is gone, or with no model, or no repository, is refused", async () => { + const { bridge, repos, root } = await setup(); + const msg = { requestId: "x", goal: "g", allowWrite: false, allowDestructive: false }; + assert.deepEqual(await bridge.chatSend({ ...msg, chatId: "gone" }), { + requestId: "x", + ok: false, + expected: true, + message: "This chat no longer exists.", + }); + + const chat = (await bridge.chatNew())!; + repos.closeTab((await repos.current())?.root ?? root); + assert.deepEqual(await bridge.chatSend({ ...msg, chatId: chat.id }), { + requestId: "x", + ok: false, + expected: true, + message: "Open a repository first.", + }); + + await bridge.removeConnection(chat.connectionId); + assert.equal((await bridge.chatSend({ ...msg, chatId: chat.id })).message, "No AI model is connected. Add one in Settings ▸ AI."); + assert.equal(model.chats.length, 0); +}); + +// ── chats over a warm local `claude` ───────────────────────────────────────── + +/** A `claude` that streams `answer(n)` for its n-th message, as session `sid`. */ +function claude(sid: string, answer: (content: string, n: number) => string) { + return (c: FakeChild) => { + let n = 0; + c.json({ type: "system", subtype: "init", session_id: sid }); + c.onStdinLine((line) => { + const content = (JSON.parse(line) as { message: { content: string } }).message.content; + const text = answer(content, n++); + for (const part of text.split(/(?<= )/)) { + c.json({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: part } } }); + } + c.json({ type: "result", session_id: sid }); + }); + }; +} + +test("a Claude Code chat boots once, then answers warm, and remembers its session", async () => { + const { bridge, sent } = await setup("claude-code"); + onEverySpawn(claude("cc-sess-1", (m, n) => `answer ${n} to ${m.split("\n")[0]}`)); + const chat = (await bridge.chatNew())!; + try { + const r1 = await bridge.chatSend({ chatId: chat.id, requestId: "c1", goal: "first", allowWrite: false, allowDestructive: false }); + assert.deepEqual(r1, { requestId: "c1", ok: true, text: "answer 0 to first" }); + const status = events(sent, "status"); + assert.deepEqual(status.map((e) => e.text), ["Loading the agent"], "a cold start says it is loading"); + assert.deepEqual( + sent.filter((s) => s.event === "ai:delta" && s.data.requestId === "c1").map((s) => s.data.delta).join(""), + "answer 0 to first", + ); + const c = spawned[0]; + assert.equal(c.args[c.args.indexOf("--model") + 1], "sonnet", "the configured tier's model"); + assert.ok(c.options?.cwd && /gs-ai-repo-/.test(c.options.cwd), "runs in the repository"); + + const r2 = await bridge.chatSend({ chatId: chat.id, requestId: "c2", goal: "second", allowWrite: false, allowDestructive: false, thinking: "extended" }); + assert.equal(r2.text, "answer 1 to second"); + assert.equal(spawned.length, 1, "the same process answered — warm"); + assert.equal(events(sent, "status").length, 1, "no second Loading"); + assert.match(JSON.parse(c.stdinLines[1]).message.content, /^second\n\nThink hard/, "the thinking directive rides on the message"); + + const disk = JSON.parse(readFileSync(join(userData, "ai-sessions.json"), "utf8")); + const saved = disk.sessions.find((s: { id: string }) => s.id === chat.id); + assert.equal(saved.cliSessionId, "cc-sess-1", "persisted, so a restart resumes it"); + assert.deepEqual(saved.turns.map((t: { text: string }) => t.text), ["first", "answer 0 to first", "second", "answer 1 to second"]); + + // Switching model mid-chat restarts the process, resuming the conversation. + await bridge.chatSend({ chatId: chat.id, requestId: "c3", goal: "third", allowWrite: false, allowDestructive: false, modelId: "opus" }); + assert.equal(spawned.length, 2); + const a2 = spawned[1].args; + assert.equal(a2[a2.indexOf("--model") + 1], "opus"); + assert.equal(a2[a2.indexOf("--resume") + 1], "cc-sess-1"); + assert.equal(spawned[0].killed, true); + } finally { + bridge.dispose(); + } + assert.ok(spawned.every((c) => c.killed), "quitting the app kills every warm process"); +}); + +test("a Claude Code chat whose CLI is missing reports it", async () => { + const { bridge } = await setup("claude-code"); + onNextSpawn((c) => c.fail("ENOENT")); + const chat = (await bridge.chatNew())!; + const r = await bridge.chatSend({ chatId: chat.id, requestId: "m", goal: "hi", allowWrite: false, allowDestructive: false }); + assert.equal(r.ok, false); + assert.match(r.message ?? "", /The `claude` CLI isn't installed or not on PATH\./); + bridge.dispose(); +}); + +test("cancelling a Claude Code chat turn stops the process and reports the cancel", async () => { + const { bridge } = await setup("claude-code"); + onNextSpawn((c) => { + c.onStdinLine(() => { + c.json({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "working" } } }); + }); + }); + const chat = (await bridge.chatNew())!; + const run = bridge.chatSend({ chatId: chat.id, requestId: "cc", goal: "long", allowWrite: false, allowDestructive: false }); + await until(() => spawned.length === 1 && spawned[0].stdinLines.length === 1, "the message to reach claude"); + bridge.cancel("cc"); + const r = await run; + assert.deepEqual(r, { requestId: "cc", ok: false, message: "Session cancelled." }); + assert.equal(spawned[0].killSignal, "SIGTERM"); + bridge.dispose(); +}); + +// ── what the approval asks ──────────────────────────────────────────────────── + +test("each write tool's approval says what it will do in the user's terms", () => { + const t = (name: string) => ({ name, title: `T:${name}`, mode: "write" }) as unknown as Parameters[0]; + assert.equal(summarizeArgs(t("git_commit"), { message: "feat: x\n\nlong body" }), "Commit staged changes:\n“feat: x”", "the subject line only"); + assert.equal(summarizeArgs(t("git_commit"), {}), "Commit staged changes:\n“”"); + assert.equal(summarizeArgs(t("git_unstage"), { all: true }), "Unstage everything."); + assert.equal(summarizeArgs(t("git_unstage"), { paths: ["a", "b"] }), "Unstage: a, b"); + assert.equal(summarizeArgs(t("git_stage"), { paths: "single" }), "Stage: single", "a lone string is shown as-is"); + assert.equal(summarizeArgs(t("git_create_branch"), { name: "feat/x", checkout: true }), "Create branch “feat/x” and switch to it."); + assert.equal(summarizeArgs(t("git_create_branch"), { name: "feat/x" }), "Create branch “feat/x”."); + assert.equal(summarizeArgs(t("git_checkout"), { ref: "main" }), "Switch to “main”."); + assert.equal(summarizeArgs(t("git_stash_save"), { message: "wip" }), "Stash working-tree changes (“wip”)."); + assert.equal(summarizeArgs(t("git_stash_save"), {}), "Stash working-tree changes."); + assert.match(summarizeArgs(t("git_reset"), { ref: "HEAD~1" }), /\(mixed reset\)/, "no mode reads as git's default"); +}); diff --git a/apps/desktop/test/aiBridge.fakes.ts b/apps/desktop/test/aiBridge.fakes.ts new file mode 100644 index 00000000..d324c8da --- /dev/null +++ b/apps/desktop/test/aiBridge.fakes.ts @@ -0,0 +1,214 @@ +// Test doubles for the desktop's AI layer: Electron's `app`, and the agent CLIs +// (`claude`, `codex`, `gemini`) the main process spawns. +// +// Import this FIRST in a test file. From then on: +// +// · `import { app } from "electron"` resolves to a stub whose +// `app.getPath("userData")` is a temp folder the test chooses +// (setUserData) — the real Electron never loads, and nothing is written +// under the developer's own profile; +// · `child_process.spawn` of one of the agent CLIs returns a FakeChild the +// test scripts, instead of a real process. Every other command (git, above +// all) still runs for real, so a bridge under test reads a real repository. +// +// Never a real `claude`/`codex`/`gemini`, never the network: the HTTP providers +// are faked per test through globalThis.fetch. + +import Module from "node:module"; +import { EventEmitter, once } from "node:events"; +import { PassThrough } from "node:stream"; +import { join } from "node:path"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; + +// ── electron ────────────────────────────────────────────────────────────────── + +let userData = mkdtempSync(join(tmpdir(), "gs-ai-userdata-")); + +/** Point `app.getPath("userData")` at `dir` (for the next reads). */ +export function setUserData(dir: string): void { + userData = dir; +} + +export const fakeApp = { + getPath: (name: string): string => { + if (name === "userData") return userData; + return join(userData, name); + }, + isPackaged: false, + getAppPath: (): string => userData, +}; + +const FAKE_ELECTRON = join(__dirname, "__fake_electron__.js"); +type Resolve = (request: string, ...rest: unknown[]) => string; +const mod = Module as unknown as { + _resolveFilename: Resolve; + _cache: Record; +}; +const originalResolve = mod._resolveFilename; +mod._resolveFilename = function (this: unknown, request: string, ...rest: unknown[]) { + return request === "electron" ? FAKE_ELECTRON : originalResolve.call(this, request, ...rest); +}; +{ + const m = new Module(FAKE_ELECTRON) as Module & { loaded: boolean; filename: string }; + m.filename = FAKE_ELECTRON; + m.loaded = true; + m.exports = { app: fakeApp }; + mod._cache[FAKE_ELECTRON] = m; +} + +// ── child processes ────────────────────────────────────────────────────────── + +/** A scripted stand-in for a spawned CLI process. */ +export class FakeChild extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + killed = false; + killSignal: string | undefined; + exited = false; + /** Every line the parent wrote to stdin. */ + readonly stdinLines: string[] = []; + private stdinBuf = ""; + private readonly stdinListeners: ((line: string) => void)[] = []; + + constructor( + readonly command: string, + readonly args: string[], + readonly options: { cwd?: string; stdio?: unknown } | undefined, + ) { + super(); + this.stdin.setEncoding("utf8"); + this.stdin.on("data", (d: string) => { + this.stdinBuf += d; + let nl: number; + while ((nl = this.stdinBuf.indexOf("\n")) !== -1) { + const line = this.stdinBuf.slice(0, nl); + this.stdinBuf = this.stdinBuf.slice(nl + 1); + this.stdinLines.push(line); + for (const fn of this.stdinListeners) fn(line); + } + }); + } + + /** Run `fn` for every line written to stdin — those already written first. */ + onStdinLine(fn: (line: string) => void): void { + this.stdinListeners.push(fn); + for (const line of this.stdinLines) fn(line); + } + + /** Write to stdout. */ + out(text: string): void { + this.stdout.write(text); + } + + /** Write one JSON event line to stdout. */ + json(obj: unknown): void { + this.stdout.write(JSON.stringify(obj) + "\n"); + } + + err(text: string): void { + this.stderr.write(text); + } + + /** + * Exit with `code`: close stdio, and only once every byte has been read, + * emit `exit` and `close` — the order a real ChildProcess keeps. + */ + async exit(code: number | null, signal: string | null = null): Promise { + if (this.exited) return; + this.exited = true; + const ends: Promise[] = []; + for (const s of [this.stdout, this.stderr]) { + if (!s.readableEnded) { + ends.push(once(s, "end")); + s.end(); + s.resume(); + } + } + await Promise.all(ends); + this.emit("exit", code, signal); + this.emit("close", code, signal); + } + + /** Fail to start (e.g. ENOENT) — the `error` event a real spawn emits. */ + fail(code: string, message = `spawn ${this.command} ${code}`): void { + const e = Object.assign(new Error(message), { code }); + this.exited = true; + this.emit("error", e); + } + + kill(signal = "SIGTERM"): boolean { + if (this.killed) return false; + this.killed = true; + this.killSignal = signal; + setImmediate(() => void this.exit(null, signal)); + return true; + } +} + +/** What a test says happens when a CLI is spawned. */ +export type SpawnScript = (child: FakeChild) => void; + +/** The CLIs that are never really run. */ +export const FAKE_COMMANDS = new Set(["claude", "codex", "gemini"]); + +const cp = require("node:child_process") as typeof import("node:child_process"); +const realSpawn = cp.spawn; + +export const spawned: FakeChild[] = []; +const scripts: SpawnScript[] = []; +let fallbackScript: SpawnScript | undefined; +let throwOnSpawn: Error | undefined; +const configurers: ((child: FakeChild) => void)[] = []; + +/** Script the NEXT spawned CLI (queued in order). */ +export function onNextSpawn(script: SpawnScript): void { + scripts.push(script); +} +/** Script every CLI spawn that has no queued script. */ +export function onEverySpawn(script: SpawnScript | undefined): void { + fallbackScript = script; +} +/** Adjust the NEXT spawned child before spawn() returns it (e.g. break its stdin). */ +export function configureNextChild(fn: (child: FakeChild) => void): void { + configurers.push(fn); +} +/** Make the next CLI spawn throw synchronously. */ +export function throwOnNextSpawn(err: Error): void { + throwOnSpawn = err; +} +/** Forget every scripted behaviour and recorded child. */ +export function resetSpawns(): void { + spawned.length = 0; + scripts.length = 0; + fallbackScript = undefined; + throwOnSpawn = undefined; + configurers.length = 0; +} + +(cp as { spawn: unknown }).spawn = function fakeSpawn(command: string, args?: unknown, options?: unknown) { + if (!FAKE_COMMANDS.has(command)) { + return (realSpawn as (...a: unknown[]) => unknown)(command, args, options); + } + if (throwOnSpawn) { + const e = throwOnSpawn; + throwOnSpawn = undefined; + throw e; + } + const child = new FakeChild(command, [...((args as string[]) ?? [])], options as FakeChild["options"]); + spawned.push(child); + configurers.shift()?.(child); + const script = scripts.shift() ?? fallbackScript; + if (script) setImmediate(() => script(child)); + return child; +}; + +/** Wait until `pred` holds, polling on the event loop (never a fixed sleep). */ +export async function until(pred: () => boolean, what = "condition", capMs = 10_000): Promise { + const start = Date.now(); + while (!pred()) { + if (Date.now() - start > capMs) throw new Error(`timed out waiting for ${what}`); + await new Promise((r) => setImmediate(r)); + } +} diff --git a/apps/desktop/test/aiBridge.harness.ts b/apps/desktop/test/aiBridge.harness.ts new file mode 100644 index 00000000..445c892a --- /dev/null +++ b/apps/desktop/test/aiBridge.harness.ts @@ -0,0 +1,181 @@ +// Shared set-up for the AiBridge tests: a fresh Electron userData folder, a +// real throwaway repository, a recorder for what the bridge sends to the +// renderer, and a scripted OpenAI-compatible model behind globalThis.fetch. +// +// Import AFTER ./aiBridge.fakes (which must be the first import of the file). + +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setUserData } from "./aiBridge.fakes"; +import { AiBridge } from "../src/main/aiBridge"; +import { RepoStore } from "../src/main/repoStore"; +import { removeTempRepo } from "./tmpRepo"; + +/** Folders to remove when the test ends. */ +const cleanups: string[] = []; + +export function cleanupAll(): void { + for (const d of cleanups.splice(0)) removeTempRepo(d); +} + +/** + * A fresh userData folder. Seeded with an empty settings file unless `bare`: + * a bridge that starts with NO file shares EMPTY_AI_SETTINGS.connections (see + * the skipped BUG test in aiBridge.settings.test.ts), so one test's connections + * would leak into the next. + */ +export function freshUserData(bare = false): string { + const dir = mkdtempSync(join(tmpdir(), "gs-ai-bridge-")); + cleanups.push(dir); + setUserData(dir); + if (!bare) writeFileSync(join(dir, "ai-settings.json"), JSON.stringify({ connections: [] })); + return dir; +} + +/** A repository with one commit ("initial": a.txt), identity configured. */ +export function makeRepo(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(join(tmpdir(), "gs-ai-repo-")); + cleanups.push(root); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root, encoding: "utf8" }); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", "-q", root]); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + git("config", "commit.gpgsign", "false"); + git("config", "core.autocrlf", "false"); + writeFileSync(join(root, "a.txt"), "alpha\n"); + git("add", "-A"); + git("commit", "-qm", "initial"); + return { root, git }; +} + +export interface Sent { + event: string; + data: Record; +} + +export async function makeBridge(repoRoot?: string): Promise<{ bridge: AiBridge; sent: Sent[]; repos: RepoStore }> { + const repos = new RepoStore([]); + if (repoRoot) await repos.open(repoRoot); + const sent: Sent[] = []; + const bridge = new AiBridge(repos, ((event: string, data: Record) => sent.push({ event, data })) as never); + return { bridge, sent, repos }; +} + +// ── a scripted model ────────────────────────────────────────────────────────── + +export interface ModelRequest { + url: string; + headers: Record; + body: { + model?: string; + stream?: boolean; + messages?: { role: string; content: string | null; tool_calls?: unknown[] }[]; + [k: string]: unknown; + }; + signal?: AbortSignal; +} + +/** One model turn: text, and optionally tool calls. */ +export interface Turn { + content?: string; + toolCalls?: { id: string; name: string; args: Record }[]; + finish?: string; +} + +export type Reply = Turn | Response | ((req: ModelRequest) => Turn | Response | Promise); + +export interface FakeModel { + /** Every chat request (the non-streaming ones the provider falls back to). */ + chats: ModelRequest[]; + /** Every request of any kind, in order. */ + all: ModelRequest[]; + /** Replies for the next chat requests, in order. */ + replies: Reply[]; + /** Responses for the next STREAMING requests (else they get an empty body). */ + streams: Response[]; + /** The reply to `/models` listings. */ + models: Response | (() => Response) | undefined; + restore(): void; +} + +function headersOf(init: RequestInit | undefined): Record { + const out: Record = {}; + const h = init?.headers; + if (!h) return out; + if (h instanceof Headers) h.forEach((v, k) => (out[k] = v)); + else if (Array.isArray(h)) for (const [k, v] of h) out[k] = v; + else Object.assign(out, h); + return out; +} + +/** + * Replace globalThis.fetch with a model that answers from `replies`. + * + * A streaming request (`stream: true`) is answered with an EMPTY body, which + * the OpenAI-compatible provider handles by falling back to one ordinary chat + * turn — so every model turn is exactly one entry in `chats`. + */ +export function fakeModel(): FakeModel { + const saved = globalThis.fetch; + const m: FakeModel = { + chats: [], + all: [], + replies: [], + streams: [], + models: undefined, + restore: () => { + globalThis.fetch = saved; + }, + }; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const req: ModelRequest = { + url, + headers: headersOf(init), + body: init?.body ? JSON.parse(String(init.body)) : {}, + signal: init?.signal ?? undefined, + }; + m.all.push(req); + if (/\/models$/.test(url)) { + const r = typeof m.models === "function" ? m.models() : m.models; + return r ?? new Response("{}", { status: 404 }); + } + if (req.body.stream) return m.streams.shift() ?? new Response(null, { status: 200 }); + m.chats.push(req); + let reply = m.replies.shift() ?? { content: "" }; + if (typeof reply === "function") reply = await reply(req); + if (reply instanceof Response) return reply; + const toolCalls = (reply.toolCalls ?? []).map((c) => ({ + id: c.id, + type: "function", + function: { name: c.name, arguments: JSON.stringify(c.args) }, + })); + return new Response( + JSON.stringify({ + choices: [ + { + message: { content: reply.content ?? "", tool_calls: toolCalls.length ? toolCalls : undefined }, + finish_reason: reply.finish ?? (toolCalls.length ? "tool_calls" : "stop"), + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + return m; +} + +/** The text of every message in a chat request, joined — to look for content. */ +export function promptText(req: ModelRequest): string { + return (req.body.messages ?? []).map((x) => x.content ?? "").join("\n"); +} + +/** A server-sent-events body streaming `deltas` as OpenAI-style content chunks. */ +export function sse(deltas: string[]): Response { + const body = + deltas.map((d) => `data: ${JSON.stringify({ choices: [{ delta: { content: d } }] })}\n\n`).join("") + "data: [DONE]\n\n"; + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} diff --git a/apps/desktop/test/aiBridge.settings.test.ts b/apps/desktop/test/aiBridge.settings.test.ts new file mode 100644 index 00000000..ac4d1497 --- /dev/null +++ b/apps/desktop/test/aiBridge.settings.test.ts @@ -0,0 +1,369 @@ +// Settings ▸ AI: the user's model connections, their keys, the model picker, +// and "Test connection". +// +// What is pinned is what the user and the renderer rely on: connections +// survive a restart; a key is stored encrypted, never shown back and never in +// the settings file; a connection is "usable" only when a request could work; +// the model picker offers configured, live and known models once each; and +// Test says why a connection does not work. Models are a fake fetch, the CLIs +// scripted FakeChilds — nothing leaves the machine. + +import { onNextSpawn, resetSpawns, spawned } from "./aiBridge.fakes"; +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { cleanupAll, fakeModel, freshUserData, makeBridge, type FakeModel } from "./aiBridge.harness"; + +let userData: string; +let model: FakeModel; +beforeEach(() => { + userData = freshUserData(); + model = fakeModel(); + resetSpawns(); +}); +afterEach(() => { + model.restore(); + cleanupAll(); +}); + +const settingsFile = () => JSON.parse(readFileSync(join(userData, "ai-settings.json"), "utf8")); + +test("with nothing configured the AI features stay off", async () => { + userData = freshUserData(true); + assert.equal(existsSync(join(userData, "ai-settings.json")), false, "no settings file at all"); + const { bridge } = await makeBridge(); + const v = await bridge.getSettings(); + assert.deepEqual(v.connections, []); + assert.equal(v.enabled, false); + assert.equal(v.defaultId, undefined); + assert.equal(v.agent.model, "mid", "agent defaults are filled in"); + assert.equal(v.agent.permission, "read"); +}); + +// AiBridge used to start from `{ ...EMPTY_AI_SETTINGS }` — a SHALLOW copy — so +// until a settings file was read, `settings.connections` WAS the shared +// EMPTY_AI_SETTINGS.connections array exported by @gitstudio/ai, and +// addConnection() pushed into that module constant. +test("a bridge with no settings file does not leak its connections into the next one", async () => { + userData = freshUserData(true); + const { bridge } = await makeBridge(); + await bridge.addConnection("ollama"); + userData = freshUserData(true); + const { bridge: other } = await makeBridge(); + assert.deepEqual((await other.getSettings()).connections, [], "a different profile starts empty"); +}); + +test("the first connection becomes the default, and connections survive a restart", async () => { + const { bridge } = await makeBridge(); + const v1 = await bridge.addConnection("ollama"); + const v2 = await bridge.addConnection("openai"); + const [ollama, openai] = v2.connections; + assert.equal(v1.defaultId, ollama.id); + assert.equal(v2.defaultId, ollama.id, "adding another does not move the default"); + assert.equal(ollama.preset, "ollama"); + assert.equal(ollama.local, true); + assert.equal(ollama.usable, true, "a local server needs no key"); + assert.equal(openai.needsKey, true); + assert.equal(openai.usable, false, "a keyed provider without its key is not usable"); + assert.equal(v2.enabled, true, "one usable connection turns the features on"); + + const { bridge: restarted } = await makeBridge(); + const v3 = await restarted.getSettings(); + assert.deepEqual(v3.connections.map((c) => c.id), [ollama.id, openai.id]); + assert.equal(v3.defaultId, ollama.id); +}); + +test("editing a connection changes only what was sent", async () => { + const { bridge } = await makeBridge(); + const { connections } = await bridge.addConnection("ollama"); + const id = connections[0].id; + let v = await bridge.updateConnection({ id, label: "Home box" }); + assert.equal(v.connections[0].label, "Home box"); + assert.equal(v.connections[0].baseUrl, "http://localhost:11434/v1", "untouched"); + v = await bridge.updateConnection({ id, baseUrl: "http://127.0.0.1:9/v1", models: { fast: "a", mid: "b", deep: "c" } }); + assert.equal(v.connections[0].baseUrl, "http://127.0.0.1:9/v1"); + assert.deepEqual(v.connections[0].models, { fast: "a", mid: "b", deep: "c" }); + assert.equal(settingsFile().connections[0].label, "Home box", "persisted"); + + const before = JSON.stringify(await bridge.getSettings()); + await bridge.updateConnection({ id: "missing", label: "x" }); + assert.equal(JSON.stringify(await bridge.getSettings()), before, "an unknown id changes nothing"); +}); + +test("the default can only be a connection that exists", async () => { + const { bridge } = await makeBridge(); + await bridge.addConnection("ollama"); + const { connections } = await bridge.addConnection("lmstudio"); + let v = await bridge.setDefault(connections[1].id); + assert.equal(v.defaultId, connections[1].id); + v = await bridge.setDefault("stale-id"); + assert.equal(v.defaultId, connections[1].id); +}); + +test("removing the default connection promotes the next one and deletes its key", async () => { + const { bridge } = await makeBridge(); + const a = (await bridge.addConnection("openai")).connections[0].id; + const b = (await bridge.addConnection("ollama")).connections[1].id; + await bridge.setKey(a, "sk-secret-a"); + let v = await bridge.removeConnection(a); + assert.deepEqual(v.connections.map((c) => c.id), [b]); + assert.equal(v.defaultId, b); + // Re-adding under the same id is impossible; prove the key file is gone. + const secretsDir = join(userData, "secrets"); + assert.ok(!readdirSync(secretsDir).some((f) => f.startsWith(a)), "the key is deleted with the connection"); + v = await bridge.removeConnection(b); + assert.equal(v.defaultId, undefined, "nothing left to default to"); +}); + +test("a key makes a keyed connection usable, and is never shown or written in plain text", async () => { + const { bridge } = await makeBridge(); + const id = (await bridge.addConnection("openai")).connections[0].id; + const v = await bridge.setKey(id, " sk-live-1234567890 "); + assert.equal(v.connections[0].hasKey, true); + assert.equal(v.connections[0].usable, true); + assert.ok(!JSON.stringify(v).includes("sk-live"), "the renderer's view carries no key"); + assert.ok(!readFileSync(join(userData, "ai-settings.json"), "utf8").includes("sk-live")); + for (const f of readdirSync(join(userData, "secrets"))) { + assert.ok(!readFileSync(join(userData, "secrets", f), "utf8").includes("sk-live"), `${f} is encrypted`); + } + + // The key is used — trimmed — as the bearer token for the provider. + model.replies.push({ content: "OK" }); + await bridge.test(id); + assert.equal(model.chats[0].headers.Authorization, "Bearer sk-live-1234567890"); + + const cleared = await bridge.setKey(id, " "); + assert.equal(cleared.connections[0].hasKey, false, "a blank key removes the stored one"); + assert.equal(cleared.connections[0].usable, false); +}); + +test("a pre-1.4 keyring blob is never read, and is deleted on the way", async () => { + const { bridge } = await makeBridge(); + const id = (await bridge.addConnection("openai")).connections[0].id; + const legacy = join(userData, "ai-keys", `${id}.bin`); + mkdirSync(join(userData, "ai-keys"), { recursive: true }); + writeFileSync(legacy, "keyring-encrypted-bytes"); + const v = await bridge.getSettings(); + assert.equal(v.connections[0].hasKey, false, "a leftover blob does not count as a key"); + assert.equal(v.connections[0].usable, false); + + // Asking for the key (the model list does) drops the blob unread. + await bridge.listModels(id); + assert.equal(existsSync(legacy), false); + + writeFileSync(legacy, "again"); + await bridge.setKey(id, "sk-new"); + assert.equal(existsSync(legacy), false, "entering a key supersedes the blob"); +}); + +test("the catalog lists every preset for the Add menu", async () => { + const { bridge } = await makeBridge(); + const cat = bridge.catalog(); + const ids = cat.map((p) => p.id); + for (const id of ["anthropic", "openai", "claude-code", "codex", "ollama"]) assert.ok(ids.includes(id), id); + const cc = cat.find((p) => p.id === "claude-code")!; + assert.equal(cc.wire, "cli"); + assert.equal(cc.local, true); + assert.equal(cc.needsKey, false); + const anthropic = cat.find((p) => p.id === "anthropic")!; + assert.equal(anthropic.local, false); + assert.deepEqual(anthropic.models, { fast: "claude-haiku-4-5", mid: "claude-sonnet-4-6", deep: "claude-opus-4-8" }); +}); + +test("agent settings merge over the defaults and persist", async () => { + const { bridge } = await makeBridge(); + let v = await bridge.setAgentConfig({ thinking: "extended" }); + assert.equal(v.agent.thinking, "extended"); + assert.equal(v.agent.model, "mid"); + v = await bridge.setAgentConfig({ permission: "write", modelId: "gpt-x" }); + assert.equal(v.agent.thinking, "extended", "earlier choices are kept"); + assert.deepEqual(settingsFile().agent, { model: "mid", thinking: "extended", permission: "write", modelId: "gpt-x" }); +}); + +test("a corrupt or foreign settings file starts empty instead of failing", async () => { + writeFileSync(join(userData, "ai-settings.json"), JSON.stringify({ connections: "nope" })); + let { bridge } = await makeBridge(); + assert.deepEqual((await bridge.getSettings()).connections, []); + writeFileSync(join(userData, "ai-settings.json"), "{{{"); + ({ bridge } = await makeBridge()); + assert.deepEqual((await bridge.getSettings()).connections, []); +}); + +test("a settings write that fails never fails the action", async () => { + const file = join(userData, "blocked"); + writeFileSync(file, ""); + // userData is a file: mkdir/writeFile underneath fail. + const { setUserData } = await import("./aiBridge.fakes"); + setUserData(file); + const { bridge } = await makeBridge(); + const v = await bridge.addConnection("ollama"); + assert.equal(v.connections.length, 1, "kept in memory for this run"); +}); + +// ── local CLIs ─────────────────────────────────────────────────────────────── + +test("a CLI connection shows as usable only when its binary is found", async () => { + const { bridge } = await makeBridge(); + await bridge.addConnection("claude-code"); + onNextSpawn((c) => { + c.out("2.0.1 (Claude Code)\n"); + void c.exit(0); + }); + let v = await bridge.getSettings(); + assert.equal(spawned[0].command, "claude"); + assert.deepEqual(spawned[0].args, ["--version"]); + assert.equal(v.connections[0].usable, true); + assert.equal(v.enabled, true); + + onNextSpawn((c) => c.fail("ENOENT")); + v = await bridge.getSettings(); + assert.equal(v.connections[0].usable, false, "not installed"); + assert.equal(v.enabled, false); +}); + +test("Test on a CLI connection only checks the binary — it spends no quota", async () => { + const { bridge } = await makeBridge(); + const id = (await bridge.addConnection("codex")).connections[0].id; + onNextSpawn((c) => { + c.out("codex-cli 0.9\n"); + void c.exit(0); + }); + assert.deepEqual(await bridge.test(id), { ok: true, message: "Found `codex` (codex-cli 0.9)." }); + assert.equal(spawned.length, 1); + assert.deepEqual(spawned[0].args, ["--version"], "never a prompt"); + + onNextSpawn((c) => void c.exit(0)); + assert.deepEqual(await bridge.test(id), { ok: true, message: "Found `codex`." }, "no version line"); + + onNextSpawn((c) => c.fail("ENOENT")); + const missing = await bridge.test(id); + assert.equal(missing.ok, false); + assert.equal(missing.expected, true, "a missing CLI is the user's setup, not our defect"); + assert.match(missing.message, /`codex` isn't installed or not on PATH\. Install the Codex CLI/); + assert.equal(spawned.length, 3); + onNextSpawn((c) => void c.exit(0)); + assert.equal((await bridge.getSettings()).connections[0].usable, true, "getSettings probes again, and it is back"); + assert.equal(spawned.length, 4); +}); + +test("Test on a CLI preset the app does not know says so", async () => { + writeFileSync( + join(userData, "ai-settings.json"), + JSON.stringify({ + connections: [{ id: "m", label: "Mystery", preset: "mystery-cli", wire: "cli", baseUrl: "", models: { fast: "", mid: "", deep: "" }, needsKey: false }], + }), + ); + const { bridge } = await makeBridge(); + assert.deepEqual(await bridge.test("m"), { ok: false, message: "Unknown local CLI." }); + assert.equal(spawned.length, 0); +}); + +// ── Test on an HTTP connection ──────────────────────────────────────────────── + +test("Test on an HTTP connection asks the fast model for a one-word reply", async () => { + const { bridge } = await makeBridge(); + const id = (await bridge.addConnection("ollama")).connections[0].id; + model.replies.push({ content: "OK" }); + const r = await bridge.test(id); + assert.deepEqual(r, { ok: true, message: "Connected — llama3.2 responded.", model: "llama3.2" }); + const req = model.chats[0]; + assert.equal(req.url, "http://localhost:11434/v1/chat/completions"); + assert.equal(req.body.model, "llama3.2", "the fast tier"); + assert.equal(req.body.max_tokens, 16); + assert.match(String(req.body.messages?.[0].content), /Reply with exactly: OK/); +}); + +test("Test reports an empty reply, and a model that stopped cleanly with no text still counts", async () => { + const { bridge } = await makeBridge(); + const id = (await bridge.addConnection("ollama")).connections[0].id; + model.replies.push({ content: "", finish: "length" }); + assert.deepEqual(await bridge.test(id), { ok: false, message: "The model returned an empty response." }); + model.replies.push({ content: "", finish: "stop" }); + assert.equal((await bridge.test(id)).ok, true); +}); + +test("Test explains a rejected key in the provider's words", async () => { + const { bridge } = await makeBridge(); + const id = (await bridge.addConnection("openai")).connections[0].id; + await bridge.setKey(id, "sk-bad"); + model.replies.push(new Response("{}", { status: 401 })); + const r = await bridge.test(id); + assert.equal(r.ok, false); + assert.match(r.message, /api\.openai\.com rejected the API key \(HTTP 401\)/); +}); + +test("Test on a connection removed a moment ago is an expected condition", async () => { + const { bridge } = await makeBridge(); + assert.deepEqual(await bridge.test("gone"), { ok: false, expected: true, message: "Connection not found." }); +}); + +// ── the model picker ───────────────────────────────────────────────────────── + +test("with no connection the model picker is empty", async () => { + const { bridge } = await makeBridge(); + assert.deepEqual(await bridge.listModels(), []); +}); + +test("the model picker offers configured models, then live ones, then known ones — once each", async () => { + const { bridge } = await makeBridge(); + const id = (await bridge.addConnection("openai")).connections[0].id; + await bridge.setKey(id, "sk-k"); + model.models = new Response(JSON.stringify({ data: [{ id: "gpt-4o" }, { id: "gpt-live-1" }, { name: "named-model" }, {}] })); + const ids = (await bridge.listModels(id)).map((m) => m.id); + // configured: mid gpt-4o, deep gpt-4o (dup), fast gpt-4o-mini + assert.deepEqual(ids, ["gpt-4o", "gpt-4o-mini", "gpt-live-1", "named-model", "o3", "o3-mini"]); + const listing = model.all.find((r) => r.url.endsWith("/models"))!; + assert.equal(listing.url, "https://api.openai.com/v1/models"); + assert.equal(listing.headers.Authorization, "Bearer sk-k"); +}); + +test("a keyless server is listed without an Authorization header, from a `models` array", async () => { + const { bridge } = await makeBridge(); + const id = (await bridge.addConnection("ollama")).connections[0].id; + await bridge.updateConnection({ id, baseUrl: "http://localhost:11434/v1///" }); + model.models = new Response(JSON.stringify({ models: [{ name: "qwen3" }] })); + const ids = (await bridge.listModels()).map((m) => m.id); + assert.deepEqual(ids, ["qwen2.5-coder", "qwen2.5-coder:32b", "llama3.2", "qwen3"], "the default connection is used"); + const listing = model.all.find((r) => r.url.endsWith("/models"))!; + assert.equal(listing.url, "http://localhost:11434/v1/models", "trailing slashes trimmed"); + assert.equal(listing.headers.Authorization, undefined); +}); + +test("Anthropic's model list needs the key, sent as x-api-key", async () => { + const { bridge } = await makeBridge(); + const id = (await bridge.addConnection("anthropic")).connections[0].id; + model.models = new Response(JSON.stringify({ data: [{ id: "claude-live" }] })); + let ids = (await bridge.listModels(id)).map((m) => m.id); + assert.equal(model.all.length, 0, "no key: no request"); + assert.deepEqual(ids, ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5"]); + + await bridge.setKey(id, "sk-ant"); + ids = (await bridge.listModels(id)).map((m) => m.id); + const listing = model.all[0]; + assert.equal(listing.url, "https://api.anthropic.com/v1/models"); + assert.equal(listing.headers["x-api-key"], "sk-ant"); + assert.equal(listing.headers["anthropic-version"], "2023-06-01"); + assert.ok(ids.includes("claude-live")); +}); + +test("a failing or unreachable model list falls back to the known catalog", async () => { + const { bridge } = await makeBridge(); + const id = (await bridge.addConnection("ollama")).connections[0].id; + model.models = new Response("oops", { status: 500 }); + assert.deepEqual((await bridge.listModels(id)).map((m) => m.id), ["qwen2.5-coder", "qwen2.5-coder:32b", "llama3.2"]); + model.models = () => { + throw new TypeError("fetch failed"); + }; + assert.deepEqual((await bridge.listModels(id)).map((m) => m.id), ["qwen2.5-coder", "qwen2.5-coder:32b", "llama3.2"]); + model.models = new Response("not json"); + assert.equal((await bridge.listModels(id)).length, 3); +}); + +test("a CLI connection's models come from the known catalog, with no request", async () => { + const { bridge } = await makeBridge(); + const id = (await bridge.addConnection("claude-code")).connections[0].id; + assert.deepEqual((await bridge.listModels(id)).map((m) => m.id), ["sonnet", "opus", "haiku"]); + assert.equal(model.all.length, 0); + assert.equal(spawned.length, 0); +}); diff --git a/apps/desktop/test/aiBridge.tasks.test.ts b/apps/desktop/test/aiBridge.tasks.test.ts new file mode 100644 index 00000000..49929eb8 --- /dev/null +++ b/apps/desktop/test/aiBridge.tasks.test.ts @@ -0,0 +1,295 @@ +// The one-shot ✨ tasks (ai:task): which diff or history each task reads from +// the REAL repository, what reaches the model, what streams back to the +// renderer, and the message a user sees when there is nothing to do or the +// model fails. +// +// The repository is a real throwaway one; the model is a fake fetch +// (aiBridge.harness.ts) and a local CLI a scripted FakeChild — no network, no +// real `claude`. + +import { onNextSpawn, resetSpawns, spawned } from "./aiBridge.fakes"; +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { cleanupAll, fakeModel, freshUserData, makeBridge, makeRepo, promptText, sse, type FakeModel } from "./aiBridge.harness"; + +let model: FakeModel; +beforeEach(() => { + freshUserData(); + model = fakeModel(); + resetSpawns(); +}); +afterEach(() => { + model.restore(); + cleanupAll(); +}); + +/** A bridge on a fresh repo with one local (keyless) OpenAI-compatible model. */ +async function setup() { + const repo = makeRepo(); + const b = await makeBridge(repo.root); + const connId = (await b.bridge.addConnection("ollama")).connections[0].id; + return { ...repo, ...b, connId }; +} + +test("a commit message is drafted from the STAGED diff, in the style of recent subjects", async () => { + const { bridge, root, git, sent } = await setup(); + writeFileSync(join(root, "a.txt"), "alpha\nbeta-staged\n"); + git("add", "a.txt"); + writeFileSync(join(root, "a.txt"), "alpha\nbeta-staged\ngamma-unstaged\n"); + model.replies.push({ content: "feat: add beta" }); + + const r = await bridge.runTask("r1", "commitMessage", {}); + assert.deepEqual(r, { requestId: "r1", ok: true, text: "feat: add beta" }); + const prompt = promptText(model.chats[0]); + assert.match(prompt, /\+beta-staged/, "the staged change is what is described"); + assert.ok(!prompt.includes("gamma-unstaged"), "an unstaged change is not"); + assert.match(prompt, /- initial/, "recent subjects set the tone"); + assert.equal(model.chats[0].body.model, "llama3.2", "a commit message uses the fast model"); + assert.deepEqual( + sent.filter((s) => s.event === "ai:delta").map((s) => s.data), + [{ requestId: "r1", delta: "feat: add beta" }], + "the answer streams to the renderer under its request id", + ); +}); + +test("a streamed answer reaches the renderer chunk by chunk", async () => { + const { bridge, sent } = await setup(); + model.streams.push(sse(["Rebase ", "rewrites ", "history."])); + const r = await bridge.runTask("rs", "assist", { description: "what is rebase" }); + assert.deepEqual(r, { requestId: "rs", ok: true, text: "Rebase rewrites history." }); + assert.deepEqual( + sent.filter((s) => s.event === "ai:delta").map((s) => s.data.delta), + ["Rebase ", "rewrites ", "history."], + ); + assert.equal(model.all[0].body.stream, true); +}); + +test("with nothing staged there is nothing to summarize — and the model is not asked", async () => { + const { bridge, root } = await setup(); + writeFileSync(join(root, "a.txt"), "unstaged only\n"); + const r = await bridge.runTask("r", "commitMessage", {}); + assert.deepEqual(r, { requestId: "r", ok: false, expected: true, message: "Nothing is staged to summarize." }); + assert.equal(model.chats.length, 0); +}); + +test("a diff and subjects handed in by the caller are used as given", async () => { + const { bridge } = await setup(); + model.replies.push({ content: "fix: thing" }); + const r = await bridge.runTask("r", "commitMessage", { diff: "+handed-in line", commits: ["style: earlier"] }); + assert.equal(r.text, "fix: thing"); + const prompt = promptText(model.chats[0]); + assert.match(prompt, /handed-in line/); + assert.match(prompt, /- style: earlier/); +}); + +test("Explain on a commit reads that commit's own diff against its parent", async () => { + const { bridge, root, git } = await setup(); + writeFileSync(join(root, "b.txt"), "second file\n"); + git("add", "b.txt"); + git("commit", "-qm", "add b"); + const sha = git("rev-parse", "HEAD").trim(); + writeFileSync(join(root, "a.txt"), "working-tree edit\n"); + model.replies.push({ content: "It adds b.txt." }); + const r = await bridge.runTask("r", "explainDiff", { sha }); + assert.equal(r.text, "It adds b.txt."); + const prompt = promptText(model.chats[0]); + assert.match(prompt, /\+second file/); + assert.ok(!prompt.includes("working-tree edit")); + assert.equal(model.chats[0].body.model, "qwen2.5-coder", "explaining uses the mid model"); +}); + +test("Explain on a base…head comparison reads exactly that range, optionally one file", async () => { + const { bridge, root, git } = await setup(); + git("branch", "base"); + writeFileSync(join(root, "a.txt"), "alpha\nfrom-head\n"); + writeFileSync(join(root, "c.txt"), "other file\n"); + git("add", "-A"); + git("commit", "-qm", "head change"); + git("checkout", "-q", "-b", "elsewhere", "base"); + model.replies.push({ content: "x" }, { content: "y" }); + + await bridge.runTask("r", "explainDiff", { base: "base", head: "main" }); + assert.match(promptText(model.chats[0]), /\+from-head[\s\S]*\+other file/, "the explicit head, not HEAD"); + + await bridge.runTask("r2", "explainDiff", { base: "base", head: "main", path: "c.txt" }); + const p = promptText(model.chats[1]); + assert.match(p, /\+other file/); + assert.ok(!p.includes("from-head"), "the path limits the diff"); +}); + +test("Explain with no target reads the working tree, and the staged diff when that is empty", async () => { + const { bridge, root, git } = await setup(); + writeFileSync(join(root, "a.txt"), "alpha\nworking\n"); + model.replies.push({ content: "w" }, { content: "s" }); + await bridge.runTask("r", "explainDiff", {}); + assert.match(promptText(model.chats[0]), /\+working/); + + git("add", "a.txt"); + await bridge.runTask("r2", "explainDiff", {}); + assert.match(promptText(model.chats[1]), /\+working/, "all of it staged now: falls back to the index"); +}); + +test("each diff task refuses a clean tree without asking the model", async () => { + const { bridge } = await setup(); + const cases: [Parameters[1], string][] = [ + ["explainDiff", "No changes to explain."], + ["summarizeChanges", "No changes to summarize."], + ["reviewDiff", "No changes to review."], + ]; + for (const [task, message] of cases) { + assert.deepEqual(await bridge.runTask("r", task, {}), { requestId: "r", ok: false, expected: true, message }); + } + assert.equal(model.chats.length, 0); +}); + +test("Summarize and Review send the changes; Review uses the deep model", async () => { + const { bridge, root } = await setup(); + writeFileSync(join(root, "a.txt"), "alpha\nrisky change\n"); + model.replies.push({ content: "- one change" }, { content: "Looks safe." }); + assert.equal((await bridge.runTask("s", "summarizeChanges", {})).text, "- one change"); + assert.equal((await bridge.runTask("v", "reviewDiff", {})).text, "Looks safe."); + assert.match(promptText(model.chats[0]), /\+risky change/); + assert.match(promptText(model.chats[1]), /reviewing the following diff[\s\S]*\+risky change/); + assert.equal(model.chats[1].body.model, "qwen2.5-coder:32b"); +}); + +test("a PR description reads the branch's commits and diff against its base", async () => { + const { bridge, root, git } = await setup(); + git("checkout", "-q", "-b", "feature"); + writeFileSync(join(root, "f.txt"), "feature work\n"); + git("add", "-A"); + git("commit", "-qm", "feat: the feature"); + model.replies.push({ content: "## Title" }); + const r = await bridge.runTask("r", "prDescription", {}); + assert.equal(r.text, "## Title"); + const prompt = promptText(model.chats[0]); + assert.match(prompt, /feat: the feature/, "the commits since main"); + assert.ok(!prompt.includes("initial"), "not the ones main already has"); + assert.match(prompt, /\+feature work/); +}); + +test("a PR description with caller-supplied commits does not walk the history", async () => { + const { bridge } = await setup(); + model.replies.push({ content: "ok" }); + await bridge.runTask("r", "prDescription", { commits: ["given subject"], diff: "+given diff", base: "whatever" }); + const prompt = promptText(model.chats[0]); + assert.match(prompt, /given subject/); + assert.match(prompt, /given diff/); +}); + +test("release notes list the subjects since the base, or the whole history without one", async () => { + const { bridge, root, git } = await setup(); + git("tag", "v1"); + writeFileSync(join(root, "n.txt"), "n\n"); + git("add", "-A"); + git("commit", "-qm", "fix: after the tag"); + model.replies.push({ content: "notes 1" }, { content: "notes 2" }); + await bridge.runTask("r", "changelog", { base: "v1" }); + const since = promptText(model.chats[0]); + assert.match(since, /- fix: after the tag/); + assert.ok(!since.includes("- initial")); + await bridge.runTask("r", "changelog", {}); + assert.match(promptText(model.chats[1]), /- fix: after the tag\n- initial/); +}); + +test("branch-name and free-form assist tasks send the user's description", async () => { + const { bridge } = await setup(); + model.replies.push({ content: "fix/login-timeout" }, { content: "Here is how." }); + assert.equal((await bridge.runTask("r", "branchName", { description: "login times out" })).text, "fix/login-timeout"); + assert.match(promptText(model.chats[0]), /login times out/); + assert.equal((await bridge.runTask("r", "assist", { description: "how do I squash?" })).text, "Here is how."); + assert.match(promptText(model.chats[1]), /how do I squash\?/); +}); + +test("a task the bridge does not know is refused by name", async () => { + const { bridge } = await setup(); + const r = await bridge.runTask("r", "noSuchTask" as never, {}); + assert.deepEqual(r, { requestId: "r", ok: false, message: "Unknown task: noSuchTask" }); +}); + +test("an empty answer is reported, not shown as a blank result", async () => { + const { bridge } = await setup(); + model.streams.push(sse([" ", " \n"])); + assert.deepEqual(await bridge.runTask("r", "assist", { description: "q" }), { + requestId: "r", + ok: false, + message: "The model returned nothing.", + }); +}); + +test("a failing model request reaches the user as the provider's message", async () => { + const { bridge } = await setup(); + model.replies.push(new Response("boom", { status: 500 })); + const r = await bridge.runTask("r", "assist", { description: "q" }); + assert.equal(r.ok, false); + assert.match(r.message ?? "", /localhost:11434 request failed \(HTTP 500\)/); +}); + +test("with no usable model the task says where to add one", async () => { + const repo = makeRepo(); + const { bridge } = await makeBridge(repo.root); + const none = await bridge.runTask("r", "assist", { description: "q" }); + assert.deepEqual(none, { requestId: "r", ok: false, expected: true, message: "No AI model is connected. Add one in Settings ▸ AI." }); + + await bridge.addConnection("openai"); // needs a key it does not have + assert.equal((await bridge.runTask("r", "assist", { description: "q" })).message, none.message); + assert.equal(model.all.length, 0); +}); + +test("with no repository open the task says so", async () => { + const { bridge } = await makeBridge(); + await bridge.addConnection("ollama"); + assert.deepEqual(await bridge.runTask("r", "assist", { description: "q" }), { + requestId: "r", + ok: false, + expected: true, + message: "No repository is open.", + }); +}); + +test("a task can be pointed at a specific connection", async () => { + const { bridge } = await setup(); + const lm = (await bridge.addConnection("lmstudio")).connections[1].id; + model.replies.push({ content: "from lm studio" }); + const r = await bridge.runTask("r", "assist", { description: "q", connectionId: lm }); + assert.equal(r.text, "from lm studio"); + assert.equal(model.chats[0].url, "http://localhost:1234/v1/chat/completions"); +}); + +test("cancelling a task aborts the model request", async () => { + const { bridge } = await setup(); + model.replies.push( + (req) => + new Promise((_, reject) => { + req.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + }), + ); + const pending = bridge.runTask("r-cancel", "assist", { description: "slow" }); + await (await import("./aiBridge.fakes")).until(() => model.chats.length === 1, "the request to be in flight"); + bridge.cancel("r-cancel"); + const r = await pending; + assert.equal(model.chats[0].signal?.aborted, true); + assert.deepEqual(r, { requestId: "r-cancel", ok: false, message: "Request cancelled." }); + bridge.cancel("r-cancel"); // a second cancel of a finished request is harmless +}); + +test("a local CLI connection runs the task through the CLI, inside the open repository", async () => { + const repo = makeRepo(); + const { bridge, sent } = await makeBridge(repo.root); + await bridge.addConnection("claude-code"); + onNextSpawn((c) => { + c.json({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "cli says hi" } } }); + void c.exit(0); + }); + const r = await bridge.runTask("r", "assist", { description: "hello cli" }); + assert.deepEqual(r, { requestId: "r", ok: true, text: "cli says hi" }); + const c = spawned[0]; + assert.equal(c.command, "claude"); + assert.ok(c.options?.cwd && /gs-ai-repo-/.test(c.options.cwd), "the repository is the CLI's working directory"); + assert.ok(c.args.includes("--model") && c.args[c.args.indexOf("--model") + 1] === "sonnet", "the mid tier"); + assert.match(c.args.at(-1)!, /hello cli/); + assert.deepEqual(sent.filter((s) => s.event === "ai:delta").map((s) => s.data.delta), ["cli says hi"]); + assert.equal(model.all.length, 0, "no HTTP at all"); +}); diff --git a/apps/desktop/test/assistantSessions.test.ts b/apps/desktop/test/assistantSessions.test.ts new file mode 100644 index 00000000..d6ae9032 --- /dev/null +++ b/apps/desktop/test/assistantSessions.test.ts @@ -0,0 +1,246 @@ +// The Assistant's chats live in the main process and on disk, so a renderer +// refresh — or a full restart — never loses a conversation. These pin what a +// user relies on: the chat list per repository, which chat reopens, the title +// a chat earns from its first message, the cap on what is kept, and that a +// local CLI chat keeps (or resumes) ONE warm `claude` process. +// +// Electron's userData is a temp folder and `claude` a scripted FakeChild +// (aiBridge.fakes.ts). + +import { onEverySpawn, resetSpawns, setUserData, spawned, until, type FakeChild } from "./aiBridge.fakes"; +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ConversationStore } from "../src/main/assistantSessions"; +import { removeTempRepo } from "./tmpRepo"; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "gs-ai-sessions-")); + setUserData(dir); + resetSpawns(); +}); +afterEach(() => removeTempRepo(dir)); + +const onDisk = () => + JSON.parse(readFileSync(join(dir, "ai-sessions.json"), "utf8")) as { + sessions: { id: string; title: string; turns: unknown[]; cliSessionId?: string }[]; + currentByRepo: Record; + }; + +test("a new chat is persisted, listed for its repository only, and becomes current", async () => { + const store = new ConversationStore(); + const a = await store.create("/r1", "conn-1", "chat-a"); + assert.equal(a.title, "New chat"); + assert.deepEqual(a.turns, []); + await store.create("/r2", "conn-1", "chat-b"); + + assert.deepEqual((await store.list("/r1")).map((s) => s.id), ["chat-a"]); + assert.equal((await store.current("/r1"))?.id, "chat-a"); + assert.equal((await store.get("chat-b"))?.repoRoot, "/r2"); + assert.equal(await store.get("nope"), undefined); + + const disk = onDisk(); + assert.deepEqual(disk.sessions.map((s) => s.id).sort(), ["chat-a", "chat-b"]); + assert.deepEqual(disk.currentByRepo, { "/r1": "chat-a", "/r2": "chat-b" }); +}); + +test("a footer tab's chat does not steal the Assistant's current chat", async () => { + const store = new ConversationStore(); + await store.create("/r", "c", "main-chat"); + await store.create("/r", "c", "footer-chat", false); + assert.equal((await store.current("/r"))?.id, "main-chat"); + await store.setCurrent("/r", "footer-chat"); + assert.equal((await store.current("/r"))?.id, "footer-chat"); +}); + +test("with no remembered chat, the most recently updated one reopens", async () => { + const store = new ConversationStore(); + await store.create("/r", "c", "old", false); + await store.create("/r", "c", "new", false); + await store.appendTurn("old", { role: "user", text: "bump", at: Date.now() + 10_000 }); + assert.equal((await store.current("/r"))?.id, "old", "updatedAt decides, not creation order"); + assert.deepEqual((await store.list("/r")).map((s) => s.id), ["old", "new"]); + assert.equal(await store.current("/elsewhere"), undefined); +}); + +test("a current chat remembered for another repository is not reopened here", async () => { + writeFileSync( + join(dir, "ai-sessions.json"), + JSON.stringify({ + sessions: [ + { id: "x", repoRoot: "/other", connectionId: "c", title: "t", turns: [], createdAt: 1, updatedAt: 1 }, + { id: "y", repoRoot: "/r", connectionId: "c", title: "t", turns: [], createdAt: 1, updatedAt: 2 }, + ], + currentByRepo: { "/r": "x" }, + }), + ); + const store = new ConversationStore(); + assert.equal((await store.current("/r"))?.id, "y"); +}); + +test("a chat's first user message becomes its title, collapsed and cut to 60 characters", async () => { + const store = new ConversationStore(); + await store.create("/r", "c", "t1"); + await store.appendTurn("t1", { role: "assistant", text: "Hi there", at: 1 }); + assert.equal((await store.get("t1"))?.title, "New chat", "an assistant turn never names the chat"); + const long = "Why does\nthe build " + "x".repeat(80); + await store.appendTurn("t1", { role: "user", text: long, at: 2 }); + const title = (await store.get("t1"))!.title; + assert.equal(title, long.slice(0, 60).replace(/\s+/g, " ").trim()); + assert.ok(!/\s{2}|\n/.test(title)); + await store.appendTurn("t1", { role: "user", text: "second question", at: 3 }); + assert.equal((await store.get("t1"))!.title, title, "only the first message names it"); + assert.equal((await store.get("t1"))!.turns.length, 3); + assert.equal((await store.get("t1"))!.updatedAt, 3); + + await store.create("/r", "c", "blank"); + await store.appendTurn("blank", { role: "user", text: " ", at: 4 }); + assert.equal((await store.get("blank"))!.title, "New chat", "a blank message leaves the default title"); +}); + +test("appending to or naming a session for a chat that is gone does nothing", async () => { + const store = new ConversationStore(); + await store.appendTurn("ghost", { role: "user", text: "x", at: 1 }); + await store.setCliSessionId("ghost", "s"); + assert.equal(await store.get("ghost"), undefined); +}); + +test("the CLI session id is persisted so the conversation resumes after a restart", async () => { + const store = new ConversationStore(); + await store.create("/r", "c", "chat"); + await store.setCliSessionId("chat", "claude-sess-1"); + assert.equal(onDisk().sessions[0].cliSessionId, "claude-sess-1"); + await store.setCliSessionId("chat", undefined); + assert.equal(onDisk().sessions[0].cliSessionId, "claude-sess-1", "an unknown id never erases the known one"); + + const reopened = new ConversationStore(); + assert.equal((await reopened.get("chat"))?.cliSessionId, "claude-sess-1"); + assert.equal((await reopened.current("/r"))?.id, "chat", "and the current chat is remembered"); +}); + +test("deleting a chat forgets it everywhere, including as the current chat", async () => { + const store = new ConversationStore(); + await store.create("/r", "c", "gone"); + await store.create("/r", "c", "kept", false); + await store.delete("gone"); + assert.equal(await store.get("gone"), undefined); + assert.equal((await store.current("/r"))?.id, "kept", "falls back to what is left"); + assert.deepEqual(onDisk().currentByRepo, {}); + assert.deepEqual(onDisk().sessions.map((s) => s.id), ["kept"]); + await store.delete("never-existed"); + assert.deepEqual(onDisk().sessions.map((s) => s.id), ["kept"]); +}); + +test("only the 60 most recent chats are kept", async () => { + const store = new ConversationStore(); + for (let i = 0; i < 62; i++) { + await store.create("/r", "c", `c${i}`, false); + await store.appendTurn(`c${i}`, { role: "user", text: `m${i}`, at: 1000 + i }); + } + const kept = await store.list("/r"); + assert.equal(kept.length, 60); + assert.ok(!kept.some((s) => s.id === "c0" || s.id === "c1"), "the two oldest are dropped"); + assert.equal(kept[0].id, "c61"); + assert.equal(onDisk().sessions.length, 60); +}); + +test("an unreadable sessions file starts empty rather than failing", async () => { + writeFileSync(join(dir, "ai-sessions.json"), "{ not json"); + const store = new ConversationStore(); + assert.deepEqual(await store.list("/r"), []); + writeFileSync(join(dir, "ai-sessions.json"), JSON.stringify({ sessions: [null, { title: "no id" }] })); + assert.deepEqual(await new ConversationStore().list("/r"), [], "entries without an id are skipped"); +}); + +test("a failed write never fails the action", async () => { + // userData is a FILE, so mkdir/writeFile fail underneath. + const file = join(dir, "not-a-dir"); + writeFileSync(file, ""); + setUserData(file); + const store = new ConversationStore(); + const s = await store.create("/r", "c", "chat"); + assert.equal(s.id, "chat"); + assert.equal((await store.get("chat"))?.id, "chat", "kept in memory"); +}); + +// ── warm CLI processes ──────────────────────────────────────────────────────── + +/** Every spawned `claude` answers each message and reports session `sid`. */ +function claudeAnswering(sid: string) { + onEverySpawn((c: FakeChild) => { + c.json({ type: "system", session_id: sid }); + c.onStdinLine(() => { + c.json({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "ok" } } }); + c.json({ type: "result" }); + }); + }); +} + +test("a chat's warm process is reused while alive and the model is unchanged", async () => { + claudeAnswering("s1"); + const store = new ConversationStore(); + const w1 = store.warmFor("chat", { cwd: "/r", model: "sonnet" }); + await w1.send("hi", { onDelta: () => {} }); + const w2 = store.warmFor("chat", { cwd: "/r", model: "sonnet" }); + assert.equal(w2, w1, "same session object"); + assert.equal(spawned.length, 1); + store.disposeAll(); + assert.equal(spawned[0].killed, true, "quitting kills every warm process"); +}); + +test("switching model mid-chat replaces the process and carries the conversation over", async () => { + claudeAnswering("s-carry"); + const store = new ConversationStore(); + const w1 = store.warmFor("chat", { cwd: "/r", model: "sonnet", resumeId: "older" }); + await w1.send("hi", { onDelta: () => {} }); + const w2 = store.warmFor("chat", { cwd: "/r", model: "opus" }); + assert.notEqual(w2, w1); + assert.equal(spawned[0].killed, true, "the old model's process is stopped"); + await w2.send("again", { onDelta: () => {} }); + const args = spawned[1].args; + assert.equal(args[args.indexOf("--model") + 1], "opus"); + assert.equal(args[args.indexOf("--resume") + 1], "s-carry", "resumes the session the old process had"); + store.disposeAll(); +}); + +test("a process that went away is replaced, resuming the persisted id when it never learned one", async () => { + onEverySpawn(undefined); + const store = new ConversationStore(); + const w1 = store.warmFor("chat", { cwd: "/r", model: "m" }); + assert.equal(w1.warm, false, "never started"); + claudeAnswering("fresh"); + const w2 = store.warmFor("chat", { cwd: "/r", model: "m", resumeId: "persisted" }); + assert.notEqual(w2, w1); + await w2.send("q", { onDelta: () => {} }); + const args = spawned[0].args; + assert.equal(args[args.indexOf("--resume") + 1], "persisted"); + store.disposeAll(); +}); + +test("when a warm process exits the store drops it, and the next ask builds a new one", async () => { + onEverySpawn((c) => { + c.onStdinLine(() => { + c.json({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "bye" } } }); + void c.exit(0); + }); + }); + const store = new ConversationStore(); + const w1 = store.warmFor("chat", { cwd: "/r" }); + assert.equal(await w1.send("q", { onDelta: () => {} }), "bye"); + await until(() => !w1.warm, "the process to be gone"); + const w2 = store.warmFor("chat", { cwd: "/r" }); + assert.notEqual(w2, w1); + store.disposeAll(); +}); + +test("deleting a chat kills its warm process", async () => { + claudeAnswering("s"); + const store = new ConversationStore(); + await store.create("/r", "c", "chat"); + await store.warmFor("chat", { cwd: "/r" }).send("q", { onDelta: () => {} }); + await store.delete("chat"); + assert.equal(spawned[0].killed, true); +}); diff --git a/apps/desktop/test/autoUpdate.fakeUpdater.ts b/apps/desktop/test/autoUpdate.fakeUpdater.ts new file mode 100644 index 00000000..05c1d7b9 --- /dev/null +++ b/apps/desktop/test/autoUpdate.fakeUpdater.ts @@ -0,0 +1,89 @@ +// A stand-in for `electron-updater`, for the Windows/Linux half of +// main/autoUpdate.ts. The updater imports it lazily with a dynamic +// `import("electron-updater")`, which goes through Node's ESM loader rather +// than the require cache — so this registers a resolve hook that answers that +// one specifier with a tiny module exporting the fake below. +// +// IMPORT THIS FIRST in the test file. Each test file runs in its own process, +// so the hook never reaches another file. + +import { register } from "node:module"; + +const KEY = "gitstudio.test.fakeAutoUpdater"; + +type Listener = (arg: unknown) => void; + +export interface FakeUpdater { + autoDownload: boolean; + autoInstallOnAppQuit: boolean; + /** What checkForUpdates resolves to, or the Error it rejects with. */ + checkAnswer: { updateInfo?: { version?: string } } | null | Error; + /** What downloadUpdate does. */ + download: () => Promise; + checks: number; + downloads: number; + installs: number; + on(event: string, fn: Listener): FakeUpdater; + emit(event: string, arg: unknown): void; + checkForUpdates(): Promise; + downloadUpdate(): Promise; + quitAndInstall(): void; + /** Back to a fresh updater (listeners too). */ + reset(): void; +} + +const listeners = new Map(); + +export const updater: FakeUpdater = { + autoDownload: true, + autoInstallOnAppQuit: false, + checkAnswer: null, + download: async () => [], + checks: 0, + downloads: 0, + installs: 0, + on(event, fn) { + listeners.set(event, [...(listeners.get(event) ?? []), fn]); + return updater; + }, + emit(event, arg) { + for (const fn of listeners.get(event) ?? []) fn(arg); + }, + async checkForUpdates() { + updater.checks++; + const a = updater.checkAnswer; + if (a instanceof Error) throw a; + return a; + }, + async downloadUpdate() { + updater.downloads++; + return updater.download(); + }, + quitAndInstall() { + updater.installs++; + }, + reset() { + listeners.clear(); + Object.assign(updater, { + autoDownload: true, + autoInstallOnAppQuit: false, + checkAnswer: null, + download: async () => [], + checks: 0, + downloads: 0, + installs: 0, + }); + }, +}; + +(globalThis as Record)[Symbol.for(KEY)] = updater; + +const fakeModule = `export const autoUpdater = globalThis[Symbol.for(${JSON.stringify(KEY)})];`; +const hooks = ` +export async function resolve(specifier, context, next) { + if (specifier === "electron-updater") { + return { url: ${JSON.stringify("data:text/javascript," + encodeURIComponent(fakeModule))}, shortCircuit: true }; + } + return next(specifier, context); +}`; +register("data:text/javascript," + encodeURIComponent(hooks)); diff --git a/apps/desktop/test/autoUpdate.flows.test.ts b/apps/desktop/test/autoUpdate.flows.test.ts new file mode 100644 index 00000000..16d98eb1 --- /dev/null +++ b/apps/desktop/test/autoUpdate.flows.test.ts @@ -0,0 +1,367 @@ +// The updater's confirm → pull → apply flows (main/autoUpdate.ts), past the +// check the older autoUpdate.test.ts covers: +// +// macOS — the installer is downloaded from the GitHub release into +// ~/Downloads with progress, announced ready, and opened on install; a +// release without a mac asset sends the user to the releases page. +// Windows / Linux — electron-updater (faked) is checked, downloads only +// once the user says so, and restarts into the update on install. +// +// Every refusal (nothing waiting, nothing downloaded, a dev build) is an +// `expected` result, never a throw. Timers are mocked: initAutoUpdate +// schedules a background check 20 s out, and no test may wait for it. + +import { electron } from "./ghFakeElectron"; // first: seeds `electron` (shell, app.getPath) +import { updater } from "./autoUpdate.fakeUpdater"; // first: answers import("electron-updater") +import { mock, test } from "node:test"; +import type { TestContext } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { initAutoUpdate, type UpdateManager } from "../src/main/autoUpdate"; + +const RELEASES_API = "https://api.github.com/repos/GitStudioHQ/gitstudio/releases"; +const RELEASES_PAGE = "https://github.com/GitStudioHQ/gitstudio/releases/latest"; +const arch = process.arch === "arm64" ? "arm64" : "x64"; +const ASSET = `GitStudio-2.3.0-${arch}.dmg`; +const ASSET_URL = `https://github.com/GitStudioHQ/gitstudio/releases/download/app-v2.3.0/${ASSET}`; + +type Sent = { event: string; data: unknown }; + +interface World { + sent: Sent[]; + notified: string[]; + fetched: string[]; + downloads: string; + updates: UpdateManager; +} + +/** + * A manager on a mocked clock with a fake network: the releases API answers + * `releases` (or `status`), and the asset URL answers `asset()`. + */ +function world( + t: TestContext, + o: { mac: boolean; releases?: unknown; status?: number; asset?: () => Response | Promise; isDev?: boolean }, +): World { + mock.timers.enable({ apis: ["setTimeout", "setInterval"] }); + const downloads = mkdtempSync(join(tmpdir(), "gs-update-")); + electron.paths.downloads = downloads; + electron.externals.length = 0; + electron.opened.length = 0; + electron.shown.length = 0; + electron.openPathAnswer = ""; + updater.reset(); + const fetched: string[] = []; + const real = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input); + fetched.push(url); + if (url === RELEASES_API) { + return new Response(JSON.stringify(o.releases ?? []), { status: o.status ?? 200 }); + } + if (url === ASSET_URL && o.asset) return o.asset(); + return new Response("no fake for " + url, { status: 404 }); + }) as typeof fetch; + t.after(() => { + globalThis.fetch = real; + mock.timers.reset(); + rmSync(downloads, { recursive: true, force: true }); + }); + const sent: Sent[] = []; + const notified: string[] = []; + const updates = initAutoUpdate({ + isDev: o.isDev ?? false, + current: "2.2.0", + mac: o.mac, + send: (event, data) => sent.push({ event, data }), + notify: (v) => notified.push(v), + }); + return { sent, notified, fetched, downloads, updates }; +} + +const withMacAsset = [ + { + tag_name: "app-v2.3.0", + assets: [ + { name: ASSET, browser_download_url: ASSET_URL, size: 10 }, + { name: "GitStudio-Setup-2.3.0.exe", browser_download_url: "https://example.invalid/win.exe", size: 1 }, + ], + }, +]; + +/** A response whose body arrives in these chunks. */ +function chunked(chunks: string[], headers: Record = {}): Response { + const enc = new TextEncoder(); + return new Response( + new ReadableStream({ + start(c) { + for (const s of chunks) c.enqueue(enc.encode(s)); + c.close(); + }, + }), + { status: 200, headers }, + ); +} + +const events = (sent: Sent[], name: string) => sent.filter((s) => s.event === name).map((s) => s.data); + +// ── Development builds ── + +test("a development build never checks, downloads or installs, and says why", async (t) => { + const w = world(t, { mac: true, isDev: true }); + assert.deepEqual(await w.updates.check(true), { + status: "disabled", + current: "2.2.0", + message: "Updates are disabled in development builds.", + }); + const refusal = { ok: false, expected: true, message: "Updates are disabled in development builds." }; + assert.deepEqual(await w.updates.download(), refusal); + assert.deepEqual(await w.updates.install(), refusal); + w.updates.windowReady(); + mock.timers.tick(60_000); + assert.deepEqual(w.fetched, [], "not even the background check runs"); + assert.deepEqual(w.sent, []); +}); + +// ── macOS ── + +test("mac: a newer release is announced to the window, without a system notification when asked", async (t) => { + const w = world(t, { mac: true, releases: withMacAsset }); + assert.deepEqual(await w.updates.check(true), { status: "available", current: "2.2.0", version: "2.3.0" }); + assert.deepEqual(events(w.sent, "update:available"), [{ version: "2.3.0", current: "2.2.0" }]); + assert.deepEqual(w.notified, []); +}); + +test("mac: the same release or an older one is up to date", async (t) => { + const w = world(t, { mac: true, releases: [{ tag_name: "app-v2.2.0" }, { tag_name: "app-v2.1.9" }] }); + assert.deepEqual(await w.updates.check(true), { status: "uptodate", current: "2.2.0" }); + assert.deepEqual(w.sent, []); +}); + +test("mac: GitHub refusing the releases read is an error status with its code", async (t) => { + const w = world(t, { mac: true, status: 503 }); + assert.deepEqual(await w.updates.check(true), { status: "error", current: "2.2.0", message: "GitHub responded 503." }); +}); + +test("mac: nothing downloads or installs before an update is waiting", async (t) => { + const w = world(t, { mac: true, releases: [] }); + assert.deepEqual(await w.updates.download(), { ok: false, expected: true, message: "No update is waiting to download." }); + assert.deepEqual(await w.updates.install(), { ok: false, expected: true, message: "No downloaded update to open." }); +}); + +test("mac: a release with no installer for this machine opens the releases page instead", async (t) => { + const w = world(t, { mac: true, releases: [{ tag_name: "app-v2.3.0", assets: [{ name: "notes.txt", browser_download_url: "https://x/n" }] }] }); + await w.updates.check(true); + assert.deepEqual(await w.updates.download(), { + ok: false, + message: "Couldn't find a macOS download — opened the releases page.", + }); + assert.deepEqual(electron.externals, [RELEASES_PAGE]); +}); + +test("mac: the confirmed download lands in Downloads with progress, is announced, and install opens it", async (t) => { + const w = world(t, { + mac: true, + releases: withMacAsset, + asset: () => chunked(["01234", "56789"], { "content-length": "10" }), + }); + await w.updates.check(true); + assert.deepEqual(await w.updates.download(), { ok: true }); + + const dest = join(w.downloads, ASSET); + assert.equal(readFileSync(dest, "utf8"), "0123456789"); + assert.deepEqual(readdirSync(w.downloads), [ASSET], "no .part file left behind"); + assert.deepEqual(events(w.sent, "update:progress"), [{ percent: 50 }, { percent: 100 }], "each percent once, ending at 100"); + assert.deepEqual(events(w.sent, "update:ready"), [{ version: "2.3.0", kind: "installer", path: dest }]); + + assert.deepEqual(await w.updates.check(true), { status: "ready", current: "2.2.0", version: "2.3.0" }); + // Pressing Download again re-announces the installer rather than fetching it twice. + assert.deepEqual(await w.updates.download(), { ok: true }); + assert.equal(w.fetched.filter((u) => u === ASSET_URL).length, 1); + assert.equal(events(w.sent, "update:ready").length, 2); + + assert.deepEqual(await w.updates.install(), { ok: true }); + assert.deepEqual(electron.opened, [dest]); +}); + +test("mac: a window opened after the download is told the installer is ready", async (t) => { + const w = world(t, { mac: true, releases: withMacAsset, asset: () => chunked(["x"]) }); + await w.updates.check(true); + await w.updates.download(); + w.sent.length = 0; + w.updates.windowReady(); + mock.timers.tick(3_000); + assert.deepEqual(w.sent, [ + { event: "update:ready", data: { version: "2.3.0", kind: "installer", path: join(w.downloads, ASSET) } }, + ]); +}); + +test("mac: progress falls back to the release's stated size when there is no content-length", async (t) => { + const w = world(t, { mac: true, releases: withMacAsset, asset: () => chunked(["abcde", "fghij"]) }); + await w.updates.check(true); + await w.updates.download(); + assert.deepEqual(events(w.sent, "update:progress"), [{ percent: 50 }, { percent: 100 }]); +}); + +test("mac: an installer the OS won't open is shown in its folder and the error returned", async (t) => { + const w = world(t, { mac: true, releases: withMacAsset, asset: () => chunked(["x"]) }); + await w.updates.check(true); + await w.updates.download(); + electron.openPathAnswer = "No application knows how to open this file."; + const dest = join(w.downloads, ASSET); + assert.deepEqual(await w.updates.install(), { ok: false, message: "No application knows how to open this file." }); + assert.deepEqual(electron.shown, [dest]); +}); + +test("mac: a failed download says so, leaves nothing behind, and can be tried again", async (t) => { + let fail = true; + const w = world(t, { + mac: true, + releases: withMacAsset, + asset: () => (fail ? new Response("gone", { status: 404 }) : chunked(["ok"])), + }); + await w.updates.check(true); + assert.deepEqual(await w.updates.download(), { ok: false, message: "Download failed (404)." }); + assert.deepEqual(readdirSync(w.downloads), []); + assert.deepEqual(await w.updates.check(true), { status: "available", current: "2.2.0", version: "2.3.0" }); + fail = false; + assert.deepEqual(await w.updates.download(), { ok: true }); + assert.equal(existsSync(join(w.downloads, ASSET)), true); +}); + +test("mac: while the installer is downloading, a check says so instead of asking GitHub again", async (t) => { + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const w = world(t, { + mac: true, + releases: withMacAsset, + asset: () => + new Response( + new ReadableStream({ + async pull(c) { + await gate; + c.enqueue(new TextEncoder().encode("done")); + c.close(); + }, + }), + { status: 200 }, + ), + }); + await w.updates.check(true); + const downloading = w.updates.download(); + for (let i = 0; i < 50 && !w.fetched.includes(ASSET_URL); i++) await new Promise((r) => setImmediate(r)); + const apiReads = w.fetched.filter((u) => u === RELEASES_API).length; + assert.deepEqual(await w.updates.check(true), { status: "downloading", current: "2.2.0", version: "2.3.0" }); + assert.equal(w.fetched.filter((u) => u === RELEASES_API).length, apiReads); + release(); + assert.deepEqual(await downloading, { ok: true }); +}); + +// ── Windows / Linux (electron-updater) ── + +test("el: a newer version is announced, and the updater is told never to download on its own", async (t) => { + const w = world(t, { mac: false }); + updater.checkAnswer = { updateInfo: { version: "2.3.0" } }; + assert.deepEqual(await w.updates.check(true), { status: "available", current: "2.2.0", version: "2.3.0" }); + assert.equal(updater.autoDownload, false); + assert.equal(updater.autoInstallOnAppQuit, true); + assert.deepEqual(events(w.sent, "update:available"), [{ version: "2.3.0", current: "2.2.0" }]); + assert.equal(updater.downloads, 0, "found is not downloaded"); +}); + +test("el: no newer version is up to date; a failed check is an error status", async (t) => { + const w = world(t, { mac: false }); + updater.checkAnswer = { updateInfo: { version: "2.2.0" } }; + assert.deepEqual(await w.updates.check(true), { status: "uptodate", current: "2.2.0" }); + updater.checkAnswer = null; + assert.deepEqual(await w.updates.check(true), { status: "uptodate", current: "2.2.0" }); + updater.checkAnswer = new Error("net::ERR_INTERNET_DISCONNECTED"); + assert.deepEqual(await w.updates.check(true), { + status: "error", + current: "2.2.0", + message: "net::ERR_INTERNET_DISCONNECTED", + }); +}); + +test("el: nothing downloads or installs before an update is waiting", async (t) => { + const w = world(t, { mac: false }); + assert.deepEqual(await w.updates.download(), { ok: false, expected: true, message: "No update is waiting to download." }); + assert.deepEqual(await w.updates.install(), { ok: false, expected: true, message: "No downloaded update to install." }); + assert.equal(updater.downloads, 0); +}); + +test("el: the confirmed download reports progress, then ready, and install restarts into it", async (t) => { + const w = world(t, { mac: false }); + updater.checkAnswer = { updateInfo: { version: "2.3.0" } }; + updater.download = async () => { + updater.emit("download-progress", { percent: 12.9 }); + updater.emit("download-progress", { percent: 12.2 }); + updater.emit("download-progress", { percent: 150 }); + updater.emit("update-downloaded", { version: "2.3.0" }); + return []; + }; + await w.updates.check(true); + assert.deepEqual(await w.updates.download(), { ok: true }); + assert.equal(updater.downloads, 1); + assert.deepEqual(events(w.sent, "update:progress"), [{ percent: 12 }, { percent: 100 }], "floored, clamped, deduped"); + assert.deepEqual(events(w.sent, "update:ready"), [{ version: "2.3.0", kind: "restart" }]); + assert.deepEqual(await w.updates.check(true), { status: "ready", current: "2.2.0", version: "2.3.0" }); + assert.equal(updater.checks, 1, "a ready update is not checked for again"); + + assert.deepEqual(await w.updates.download(), { ok: true }, "Download again just re-announces"); + assert.equal(updater.downloads, 1); + + assert.deepEqual(await w.updates.install(), { ok: true }); + for (let i = 0; i < 10 && updater.installs === 0; i++) await new Promise((r) => setImmediate(r)); + assert.equal(updater.installs, 1, "restarted into the new version"); +}); + +test("el: a window opened after the download is told to restart", async (t) => { + const w = world(t, { mac: false }); + updater.checkAnswer = { updateInfo: { version: "2.3.0" } }; + updater.download = async () => updater.emit("update-downloaded", {}); + await w.updates.check(true); + await w.updates.download(); + w.sent.length = 0; + w.updates.windowReady(); + mock.timers.tick(3_000); + assert.deepEqual(w.sent, [{ event: "update:ready", data: { version: "2.3.0", kind: "restart" } }]); +}); + +test("el: while downloading, a check says so", async (t) => { + const w = world(t, { mac: false }); + updater.checkAnswer = { updateInfo: { version: "2.3.0" } }; + let finish!: () => void; + updater.download = () => new Promise((r) => (finish = r)); + await w.updates.check(true); + const downloading = w.updates.download(); + for (let i = 0; i < 50 && updater.downloads === 0; i++) await new Promise((r) => setImmediate(r)); + assert.deepEqual(await w.updates.check(true), { status: "downloading", current: "2.2.0", version: "2.3.0" }); + assert.equal(updater.checks, 1, "not checked again mid-download"); + finish(); + assert.deepEqual(await downloading, { ok: true }); +}); + +test("el: a failed download returns its message and can be tried again", async (t) => { + const w = world(t, { mac: false }); + updater.checkAnswer = { updateInfo: { version: "2.3.0" } }; + updater.download = async () => { + throw new Error("sha512 checksum mismatch"); + }; + await w.updates.check(true); + assert.deepEqual(await w.updates.download(), { ok: false, message: "sha512 checksum mismatch" }); + updater.download = async () => []; + assert.deepEqual(await w.updates.download(), { ok: true }, "back to available, so a retry is allowed"); + assert.equal(updater.downloads, 2); +}); + +test("el: the background check announces once and raises a system notification", async (t) => { + const w = world(t, { mac: false }); + updater.checkAnswer = { updateInfo: { version: "2.3.0" } }; + mock.timers.tick(20_000); + for (let i = 0; i < 20 && w.notified.length === 0; i++) await new Promise((r) => setImmediate(r)); + assert.deepEqual(w.notified, ["2.3.0"]); + assert.deepEqual(events(w.sent, "update:available"), [{ version: "2.3.0", current: "2.2.0" }]); +}); diff --git a/apps/desktop/test/branchReset.edges.test.ts b/apps/desktop/test/branchReset.edges.test.ts new file mode 100644 index 00000000..316ae50f --- /dev/null +++ b/apps/desktop/test/branchReset.edges.test.ts @@ -0,0 +1,267 @@ +import "./hermeticGit"; +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { GitContext } from "@gitstudio/git-service/index"; +import { GitBridge } from "../src/main/gitBridge"; +import type { RepoStore } from "../src/main/repoStore"; +import type { BranchResetPlan } from "../src/shared/ipc"; +import { removeTempRepo } from "./tmpRepo"; + +// "Reset to 'origin/feature'…" (#32), the edges branchReset.test.ts's state +// table leaves: an upstream deleted on the remote, a stopped operation of each +// kind named in its own words, an untracked file that appears between the +// question and the answer, a request whose shas are not shas, and the undo +// when you have switched away, when git refuses to move back, and when the +// kept copy of your changes will not apply. Same fixture as that file: a bare +// "remote" on disk and a clone of it. + +let base: string; +let remote: string; +let repo: string; +let bridge: GitBridge; +let ctx: GitContext; + +const run = (cwd: string, ...a: string[]): string => + execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +const git = (...a: string[]): string => run(repo, ...a); +const gitTry = (...a: string[]): number => { + try { + git(...a); + return 0; + } catch (e) { + return (e as { status?: number }).status ?? 1; + } +}; +const sha = (ref: string, cwd = repo): string => run(cwd, "rev-parse", ref).trim(); +const write = (rel: string, text: string): void => writeFileSync(join(repo, rel), text); +const read = (rel: string): string => readFileSync(join(repo, rel), "utf8"); + +beforeEach(() => { + base = mkdtempSync(join(tmpdir(), "gitstudio-reset-edges-")); + remote = join(base, "remote.git"); + repo = join(base, "repo"); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", "-q", "--bare", remote]); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", "-q", repo]); + for (const [k, v] of [ + ["user.email", "dev@example.com"], + ["user.name", "Dev"], + ["gc.auto", "0"], + ["core.autocrlf", "false"], + ["commit.gpgsign", "false"], + ]) git("config", k, v); + git("remote", "add", "origin", remote); + write("a.txt", "one\n"); + git("add", "."); + git("commit", "-qm", "first"); + git("push", "-qu", "origin", "main"); + git("checkout", "-qb", "feature/x"); + write("a.txt", "two\n"); + git("commit", "-qam", "on the remote"); + git("push", "-qu", "origin", "feature/x"); + ctx = new GitContext({ root: repo }); + bridge = new GitBridge({ getContext: () => ctx } as unknown as RepoStore); +}); + +afterEach(() => removeTempRepo(base)); + +const FULL = "refs/heads/feature/x"; +const plan = (fullName = FULL): Promise => bridge.branchResetPlan({ fullName }); + +test("an upstream deleted on the remote is said as that, not as a failed fetch", async () => { + run(remote, "update-ref", "-d", "refs/heads/feature/x"); + const p = await plan(); + assert.equal(p.ok, false); + assert.equal(p.expected, true); + assert.equal(p.message, "origin/feature/x no longer exists on origin, so there is nothing to reset 'feature/x' to."); +}); + +test("each stopped operation on the branch is named in its own words and blocks the plan", async () => { + // A second line of history that conflicts with feature/x on a.txt. + git("checkout", "-qb", "clash", "main"); + write("a.txt", "clash\n"); + git("commit", "-qam", "clash"); + const clash = sha("HEAD"); + git("checkout", "-q", "feature/x"); + // The commit that set a.txt to "two"; reverting it under "local" conflicts. + const onRemote = sha("HEAD"); + write("a.txt", "local\n"); + git("commit", "-qam", "local"); + const local = sha("HEAD"); + + const cases: Array<[string, () => void, string[]]> = [ + ["A cherry-pick is in progress.", () => void gitTry("cherry-pick", clash), ["cherry-pick", "--abort"]], + ["A revert is in progress.", () => void gitTry("revert", "--no-edit", onRemote), ["revert", "--abort"]], + [ + "A stash with conflicts is in progress.", + () => { + write("a.txt", "stashed edit\n"); + git("stash", "push", "-q"); + write("a.txt", "committed edit\n"); + git("commit", "-qam", "committed edit"); + gitTry("stash", "apply"); + }, + ["reset", "-q", "--hard", local], + ], + ]; + for (const [said, stop, abort] of cases) { + stop(); + const p = await plan(); + assert.equal(p.ok, false, said); + assert.equal(p.expected, true); + assert.equal(p.message, `${said} Finish or abort it in Changes before resetting 'feature/x'.`); + git(...abort); + assert.equal(sha("HEAD"), local, "aborted cleanly before the next case"); + } +}); + +test("a reset whose from or to is not a sha is refused before anything is read", async () => { + const before = sha("HEAD"); + const r = await bridge.branchResetToUpstream({ root: repo, fullName: FULL, from: "HEAD", to: sha("origin/feature/x") }); + assert.deepEqual(r, { ok: false, changed: false, message: "That value isn't a valid git reference." }); + assert.equal(sha("HEAD"), before); +}); + +test("an untracked file the upstream also has, created after the question, stops the reset", async () => { + // Somebody else adds new.txt on the remote; the plan fetches it. + const other = join(base, "other"); + execFileSync("git", ["clone", "-q", "-b", "feature/x", remote, other]); + run(other, "config", "user.email", "o@example.com"); + run(other, "config", "user.name", "Other"); + writeFileSync(join(other, "new.txt"), "theirs\n"); + run(other, "add", "new.txt"); + run(other, "commit", "-qm", "add new.txt"); + run(other, "push", "-q", "origin", "feature/x"); + + const p = await plan(); + assert.equal(p.ok, true, p.message); + assert.equal(p.gained, 1); + // Between the question and the answer, an untracked file of the same name. + write("new.txt", "mine, not tracked\n"); + const r = await bridge.branchResetToUpstream({ root: repo, fullName: FULL, from: p.from!, to: p.to! }); + assert.equal(r.ok, false); + assert.equal(r.expected, true); + assert.match(r.message ?? "", /^Resetting would overwrite a file git isn't tracking, which origin\/feature\/x has too: new\.txt\./); + assert.equal(read("new.txt"), "mine, not tracked\n", "the file nobody could bring back is untouched"); + assert.equal(sha("HEAD"), p.from); +}); + +async function resetWithLocalWork(): Promise<{ p: BranchResetPlan; was: string; snapshot?: string }> { + write("local.txt", "committed only here\n"); + git("add", "local.txt"); + git("commit", "-qm", "local work"); + const p = await plan(); + assert.equal(p.ok, true, p.message); + const r = await bridge.branchResetToUpstream({ root: repo, fullName: FULL, from: p.from!, to: p.to! }); + assert.equal(r.ok, true, r.message); + return { p, was: r.was!, snapshot: r.snapshot }; +} + +test("undoing a reset refuses shas that are not shas, and a branch you have since left", async () => { + const { p, was } = await resetWithLocalWork(); + const bad = await bridge.branchResetUndo({ root: repo, fullName: FULL, was: "HEAD@{1}", now: p.to!, current: true }); + assert.deepEqual(bad, { ok: false, changed: false, message: "That value isn't a valid git reference." }); + + git("checkout", "-q", "main"); + const away = await bridge.branchResetUndo({ root: repo, fullName: FULL, was, now: p.to!, current: true }); + assert.equal(away.ok, false); + assert.equal(away.expected, true); + assert.equal(away.message, "You're no longer on 'feature/x'. Switch back to it to undo the reset."); + assert.equal(sha(FULL), p.to, "the branch was not moved from somewhere else"); +}); + +test("undoing a reset that git refuses to move back over an untracked file changes nothing", async () => { + const { p, was } = await resetWithLocalWork(); + // local.txt left with the reset; a new untracked local.txt is now in the way + // of bringing the old commit back. + assert.equal(existsSync(join(repo, "local.txt")), false); + write("local.txt", "a different, untracked file\n"); + const res = await bridge.branchResetUndo({ root: repo, fullName: FULL, was, now: p.to!, current: true }); + assert.equal(res.ok, false); + assert.equal(res.expected, true); + assert.match(res.message ?? "", /^Couldn't put 'feature\/x' back: .+\. Nothing was changed\.$/); + assert.equal(sha("HEAD"), p.to); + assert.equal(read("local.txt"), "a different, untracked file\n"); +}); + +test("undoing a reset whose kept changes will not apply puts the branch back and says where they are", async () => { + const { p, was } = await resetWithLocalWork(); + // A restore point that is a sha but not a stash: git refuses to apply it. + const notAStash = sha("main"); + const res = await bridge.branchResetUndo({ + root: repo, + fullName: FULL, + was, + now: p.to!, + current: true, + snapshot: notAStash, + }); + assert.equal(res.ok, false); + assert.equal(res.changed, true, "the branch DID move back"); + assert.match(res.message ?? "", /^'feature\/x' is back where it was, but your uncommitted changes couldn't be put back/); + assert.match(res.message ?? "", new RegExp(`git stash apply ${notAStash}`)); + assert.equal(sha("HEAD"), was); + assert.equal(read("local.txt"), "committed only here\n"); +}); + +test("a branch whose upstream stopped being a remote branch after the question is left alone", async () => { + write("a.txt", "local\n"); + git("commit", "-qam", "local"); + const p = await plan(); + assert.equal(p.ok, true, p.message); + git("branch", "-q", "--set-upstream-to=main"); + const r = await bridge.branchResetToUpstream({ root: repo, fullName: FULL, from: p.from!, to: p.to! }); + assert.equal(r.ok, false); + assert.equal(r.expected, true); + assert.equal(r.message, "'feature/x' doesn't track a branch on a remote any more — nothing was changed."); + assert.equal(sha("HEAD"), p.from); +}); + +/** Make one git command fail as git would, leaving every other one real. */ +function failing(match: (args: string[]) => boolean, stderr: string): () => void { + const real = ctx.process.run.bind(ctx.process); + ctx.process.run = async (args, opts) => + match(args) ? { code: 128, stdout: "", stderr } : real(args, opts); + return () => { + ctx.process.run = real; + }; +} + +test("when git cannot keep a copy of the uncommitted changes, nothing is reset", async () => { + write("a.txt", "local\n"); + git("commit", "-qam", "local"); + write("a.txt", "uncommitted\n"); + const p = await plan(); + assert.equal(p.ok, true, p.message); + // git's "fatal: " prefix and any lines after the first are not the reason. + const restore = failing((a) => a[0] === "stash" && a[1] === "create", "fatal: unable to write new index file\nmore"); + try { + const r = await bridge.branchResetToUpstream({ root: repo, fullName: FULL, from: p.from!, to: p.to! }); + assert.deepEqual(r, { + ok: false, + changed: false, + message: "Couldn't keep a copy of your uncommitted changes, so nothing was reset: unable to write new index file", + }); + } finally { + restore(); + } + assert.equal(sha("HEAD"), p.from, "the branch did not move"); + assert.equal(read("a.txt"), "uncommitted\n", "and the work is still there"); +}); + +test("a reset git refuses part-way says so, and that something may have changed", async () => { + write("a.txt", "local\n"); + git("commit", "-qam", "local"); + const p = await plan(); + assert.equal(p.ok, true, p.message); + const restore = failing((a) => a[0] === "reset" && a[1] === "--hard", ""); + try { + const r = await bridge.branchResetToUpstream({ root: repo, fullName: FULL, from: p.from!, to: p.to! }); + assert.deepEqual(r, { ok: false, changed: true, message: "Couldn't reset 'feature/x'." }); + } finally { + restore(); + } +}); diff --git a/apps/desktop/test/cliProvider.spawn.test.ts b/apps/desktop/test/cliProvider.spawn.test.ts new file mode 100644 index 00000000..8a14b04a --- /dev/null +++ b/apps/desktop/test/cliProvider.spawn.test.ts @@ -0,0 +1,280 @@ +// The local-CLI provider: how GitStudio talks to an installed `claude`, `codex` +// or `gemini` in print mode, using the CLI's own login instead of an API key. +// +// Every CLI here is a scripted FakeChild (aiBridge.fakes.ts) — never a real +// binary. What is pinned is what the user sees: the text that streams into the +// panel, the error that explains why nothing did, and that Cancel stops the +// process rather than leaving it running. + +import { onNextSpawn, resetSpawns, spawned, throwOnNextSpawn, until } from "./aiBridge.fakes"; +import { test, beforeEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { CliProvider, CLI_SPECS, cliSpecFor, detectCli, withThinking } from "../src/main/cliProvider"; + +beforeEach(() => resetSpawns()); + +function provider(preset: string, resolve: (tier?: string) => string | undefined = () => undefined, cwd = "/repo") { + return new CliProvider({ preset, cwd, resolveModel: resolve as never, label: `L ${preset}` }); +} + +const delta = (text: string) => ({ + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text } }, +}); + +// ── Claude Code: streamed JSON events ───────────────────────────────────────── + +test("Claude Code streams token deltas live and the answer is their concatenation", async () => { + onNextSpawn((c) => { + c.json({ type: "system", subtype: "init", session_id: "s1" }); + c.json(delta("Hello")); + // A line split across two chunks is reassembled before it is parsed. + const line = JSON.stringify(delta(", world")) + "\n"; + c.out(line.slice(0, 10)); + c.out(line.slice(10)); + c.json({ type: "result", result: "Hello, world (final)" }); + void c.exit(0); + }); + const seen: string[] = []; + const out = await provider("claude-code", () => "sonnet").streamText( + [{ role: "user", content: "hi" }], + (d) => seen.push(d), + ); + assert.deepEqual(seen, ["Hello", ", world"], "deltas arrive as they stream"); + assert.equal(out, "Hello, world", "token deltas win over the final result"); + + const c = spawned[0]; + assert.equal(c.command, "claude"); + assert.equal(c.options?.cwd, "/repo", "runs inside the open repository"); + assert.deepEqual(c.args.slice(0, 4), ["-p", "--strict-mcp-config", "--model", "sonnet"]); + assert.ok(c.args.includes("stream-json") && c.args.includes("--include-partial-messages")); + assert.equal(c.args.at(-1), "hi", "the prompt is the last argument"); +}); + +test("with no token deltas, Claude Code's final result is the answer", async () => { + onNextSpawn((c) => { + c.out("not json at all\n\n"); + c.json({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "input_json_delta" } } }); + c.json({ type: "result", result: "\u001b[1mDone.\u001b[0m" }); + void c.exit(0); + }); + const r = await provider("claude-code").chat([{ role: "user", content: "x" }]); + assert.equal(r.text, "Done.", "ANSI escapes are stripped from the fallback"); + assert.equal(r.stopReason, "stop"); + assert.deepEqual(r.toolCalls, [], "a CLI exposes no tool calls"); + assert.ok(!spawned[0].args.includes("--model"), "no model flag when none resolves"); +}); + +test("a completed assistant message is the fallback answer when no result line comes", async () => { + onNextSpawn((c) => { + c.json({ + type: "assistant", + message: { content: [{ type: "text", text: "Part A. " }, { type: "tool_use" }, { type: "text", text: "Part B." }] }, + }); + c.json({ type: "assistant", message: { content: [{ type: "tool_use" }] } }); + void c.exit(1); + }); + const r = await provider("claude-code").chat([{ role: "user", content: "x" }]); + assert.equal(r.text, "Part A. Part B.", "a non-zero exit after a real answer still answers"); +}); + +test("a failed Claude Code run names the command, the exit code and the last stderr lines", async () => { + onNextSpawn((c) => { + c.err("warming up\nline 2\nline 3\nError: not logged in\n"); + void c.exit(2); + }); + await assert.rejects(provider("claude-code").chat([{ role: "user", content: "x" }]), (e: Error) => { + assert.match(e.message, /`claude` exited with code 2: line 2 line 3 Error: not logged in/); + assert.ok(!/warming up/.test(e.message), "only the last three stderr lines"); + return true; + }); +}); + +test("a failed run with a silent stderr ends its message with a full stop", async () => { + onNextSpawn((c) => void c.exit(3)); + await assert.rejects(provider("claude-code").chat([{ role: "user", content: "x" }]), /`claude` exited with code 3\.$/); +}); + +// ── plain stdout CLIs ───────────────────────────────────────────────────────── + +test("Codex's stdout is the answer, stripped of colour codes", async () => { + onNextSpawn((c) => { + c.out("\u001b[32mfeat:\u001b[0m add "); + c.out("the thing\n"); + void c.exit(0); + }); + const seen: string[] = []; + const out = await provider("codex", () => "gpt-5").streamText([{ role: "user", content: "q" }], (d) => seen.push(d)); + assert.equal(out, "feat: add the thing"); + assert.deepEqual(seen, ["feat: add ", "the thing\n"]); + assert.deepEqual(spawned[0].args, ["exec", "--model", "gpt-5", "q"]); +}); + +test("an empty answer streams as null, not as an empty string", async () => { + onNextSpawn((c) => { + c.out(" \n"); + void c.exit(0); + }); + assert.equal(await provider("gemini-cli").streamText([{ role: "user", content: "q" }], () => {}), null); + assert.deepEqual(spawned[0].args, ["-p", "q"]); +}); + +test("a plain CLI that exits non-zero rejects with its stderr", async () => { + onNextSpawn((c) => { + c.out("partial"); + c.err("quota exceeded\n"); + void c.exit(1); + }); + await assert.rejects(provider("gemini-cli").chat([{ role: "user", content: "q" }]), /`gemini` exited with code 1: quota exceeded/); +}); + +test("an explicit model id beats the tier, and the thinking directive rides on the prompt", async () => { + const tiers: (string | undefined)[] = []; + onNextSpawn((c) => void c.exit(0)); + await provider("gemini-cli", (t) => { + tiers.push(t); + return "tier-model"; + }).chat([{ role: "user", content: "q" }], { modelId: "exact-model", thinking: "extended" }); + assert.deepEqual(tiers, [], "the tier resolver is not consulted when an id is given"); + assert.deepEqual(spawned[0].args.slice(0, 3), ["-p", "--model", "exact-model"]); + assert.match(spawned[0].args[3], /^q\n\nThink hard/); +}); + +test("the conversation is flattened: system first, assistant turns labelled", async () => { + onNextSpawn((c) => void c.exit(0)); + await provider("codex").chat([ + { role: "system", content: "Be brief." }, + { role: "user", content: "What changed?" }, + { role: "assistant", content: "Two files." }, + { role: "tool", content: "ignored", toolCallId: "t", name: "n" }, + { role: "user", content: "Which?" }, + ] as never); + assert.equal(spawned[0].args.at(-1), "Be brief.\n\nWhat changed?\n\nAssistant: Two files.\n\nWhich?"); +}); + +test("without a system message the prompt is just the conversation", async () => { + onNextSpawn((c) => void c.exit(0)); + await provider("codex").chat([{ role: "user", content: "Only this" }]); + assert.equal(spawned[0].args.at(-1), "Only this"); +}); + +// ── failures to start, and cancelling ───────────────────────────────────────── + +test("an unknown preset is refused before anything is spawned", async () => { + await assert.rejects(provider("nope").chat([{ role: "user", content: "q" }]), /Unknown local CLI: nope\./); + assert.equal(spawned.length, 0); +}); + +test("a CLI that is not installed says so, with how to install it", async () => { + onNextSpawn((c) => c.fail("ENOENT")); + await assert.rejects(provider("codex").chat([{ role: "user", content: "q" }]), (e: Error) => { + assert.match(e.message, /The `codex` CLI isn't installed or not on PATH/); + assert.ok(e.message.includes(CLI_SPECS.codex.install)); + return true; + }); +}); + +test("any other start failure carries the system's reason", async () => { + onNextSpawn((c) => c.fail("EACCES", "permission denied")); + await assert.rejects(provider("claude-code").chat([{ role: "user", content: "q" }]), /`claude` failed to start: permission denied/); +}); + +test("a spawn that throws is reported as a launch failure", async () => { + throwOnNextSpawn(new Error("bad cwd")); + await assert.rejects(provider("codex").chat([{ role: "user", content: "q" }]), /Couldn't launch `codex`\./); +}); + +test("cancelling mid-answer kills the CLI and resolves with what streamed so far", async () => { + const ctrl = new AbortController(); + onNextSpawn((c) => { + c.json(delta("partial")); + setImmediate(() => ctrl.abort()); + }); + const seen: string[] = []; + const out = await provider("claude-code").streamText([{ role: "user", content: "q" }], (d) => seen.push(d), { + signal: ctrl.signal, + }); + assert.equal(spawned[0].killSignal, "SIGTERM", "the process is terminated, not left running"); + assert.deepEqual(seen, ["partial"]); + assert.equal(out, "partial"); +}); + +test("a request that is already cancelled kills the CLI at once and does not fail", async () => { + const ctrl = new AbortController(); + ctrl.abort(); + const r = await provider("codex").chat([{ role: "user", content: "q" }], { signal: ctrl.signal }); + assert.equal(spawned[0].killed, true); + assert.equal(r.text, "", "a cancelled plain CLI resolves empty rather than as an error"); +}); + +test("a cancelled streaming run that produced nothing resolves empty rather than failing", async () => { + const ctrl = new AbortController(); + ctrl.abort(); + const out = await provider("claude-code").streamText([{ role: "user", content: "q" }], () => {}, { signal: ctrl.signal }); + assert.equal(out, null); +}); + +// ── helpers ─────────────────────────────────────────────────────────────────── + +test("withThinking asks for depth, for brevity, or leaves the prompt alone", () => { + assert.equal(withThinking("P", "extended"), "P\n\nThink hard and reason carefully before you answer."); + assert.equal(withThinking("P", "off"), "P\n\nAnswer directly and concisely, without extended reasoning."); + assert.equal(withThinking("P", "auto"), "P"); + assert.equal(withThinking("P"), "P"); +}); + +test("cliSpecFor knows the three CLIs and nothing else", () => { + assert.equal(cliSpecFor("claude-code")?.command, "claude"); + assert.equal(cliSpecFor("codex")?.command, "codex"); + assert.equal(cliSpecFor("gemini-cli")?.command, "gemini"); + assert.equal(cliSpecFor("openai"), undefined); +}); + +test("the provider reports its label and that it cannot call tools", () => { + const p = provider("codex"); + assert.equal(p.label, "L codex"); + assert.equal(p.supportsTools, false); + assert.equal(p.id, "cli"); +}); + +// ── detectCli ──────────────────────────────────────────────────────────────── + +test("detectCli reports an installed CLI and the first line of its version", async () => { + onNextSpawn((c) => { + c.out("1.2.3 (Claude Code)\nextra\n"); + void c.exit(0); + }); + assert.deepEqual(await detectCli("claude"), { ok: true, version: "1.2.3 (Claude Code)" }); + assert.deepEqual(spawned[0].args, ["--version"]); +}); + +test("detectCli reports a CLI whose --version fails as not ok", async () => { + onNextSpawn((c) => void c.exit(127)); + assert.deepEqual(await detectCli("codex"), { ok: false, version: undefined }); +}); + +test("detectCli reports a missing CLI as not ok", async () => { + onNextSpawn((c) => c.fail("ENOENT")); + assert.deepEqual(await detectCli("gemini"), { ok: false }); +}); + +test("detectCli reports a spawn that throws as not ok", async () => { + throwOnNextSpawn(new Error("EINVAL")); + assert.deepEqual(await detectCli("claude"), { ok: false }); +}); + +test("detectCli gives up on a CLI that hangs, after four seconds, and kills it", async () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + onNextSpawn(() => {}); // never answers --version + const result = detectCli("claude"); + await until(() => spawned.length === 1, "the probe to start"); + mock.timers.tick(3999); + assert.equal(spawned[0].killed, false, "not yet"); + mock.timers.tick(1); + assert.deepEqual(await result, { ok: false }); + assert.equal(spawned[0].killSignal, "SIGKILL"); + } finally { + mock.timers.reset(); + } +}); diff --git a/apps/desktop/test/cloneBridge.flow.test.ts b/apps/desktop/test/cloneBridge.flow.test.ts new file mode 100644 index 00000000..a9928860 --- /dev/null +++ b/apps/desktop/test/cloneBridge.flow.test.ts @@ -0,0 +1,299 @@ +import { electron } from "./desktopMainFakeElectron"; +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { killActiveClones, listGhRepos, pickCloneDir, startClone, targetName } from "../src/main/cloneBridge"; +import type { GitHubClient } from "../src/main/githubClient"; +import type { CloneProgress } from "../src/shared/ipc"; +import { serveOwnSsh } from "../../../scripts/test/no-network-git.mjs"; +import { removeTempRepo } from "./tmpRepo"; + +// Clone, end to end against local repositories (a file:// URL, so git runs its +// real transport and prints real progress), and the checks on what the user +// typed into the clone sheet — each of which the sheet shows inline, so each +// is `expected`. Nothing here reaches a network: the suite's hermetic git +// refuses one, and the one "remote" that hangs is this test's own ssh stand-in. + +const made: string[] = []; +afterEach(() => { + for (const d of made.splice(0)) removeTempRepo(d); +}); + +function temp(prefix: string): string { + const d = mkdtempSync(join(tmpdir(), `gitstudio-${prefix}-`)); + made.push(d); + return d; +} + +function sourceRepo(): string { + const src = temp("clonesrc"); + const git = (...a: string[]): string => execFileSync("git", ["-C", src, ...a], { encoding: "utf8" }); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", "-q", src]); + git("config", "user.email", "dev@example.com"); + git("config", "user.name", "Dev"); + writeFileSync(join(src, "a.txt"), "one\n"); + git("add", "."); + git("commit", "-q", "-m", "first"); + return src; +} + +// ── the clone sheet's checks ──────────────────────────────────────────────── + +test("a blank URL, a URL that isn't one, and no destination are refused as the sheet's own states", async () => { + const progress: CloneProgress[] = []; + const on = (p: CloneProgress): void => void progress.push(p); + assert.deepEqual(await startClone({ url: " ", parentDir: "/x" }, on), { + ok: false, + expected: true, + message: "No repository URL was provided.", + }); + const ext = await startClone({ url: "ext::sh -c touch% /tmp/pwned", parentDir: "/x" }, on); + assert.equal(ext.ok, false); + assert.equal(ext.expected, true); + assert.match(ext.message ?? "", /unsupported transport/); + const flag = await startClone({ url: "--upload-pack=touch /tmp/pwned", parentDir: "/x" }, on); + assert.equal(flag.ok, false); + assert.equal(flag.expected, true); + assert.deepEqual(await startClone({ url: "https://example.com/acme/repo.git", parentDir: "" }, on), { + ok: false, + expected: true, + message: "No destination folder was chosen.", + }); + assert.deepEqual(progress, [], "nothing was started"); +}); + +test("a folder name that cannot be derived or used is refused with the bad-name code", async () => { + const parent = temp("clonedest"); + const none = await startClone({ url: "/", parentDir: parent }, () => undefined); + assert.deepEqual(none, { ok: false, code: "bad-name", expected: true, message: "Couldn't derive a folder name from the URL." }); + const dash = await startClone({ url: "https://example.com/a/b.git", parentDir: parent, name: "-rf" }, () => undefined); + assert.equal(dash.code, "bad-name"); + assert.match(dash.message ?? "", /can't start with a dash/); + const dots = await startClone({ url: "https://example.com/a/b.git", parentDir: parent, name: ".." }, () => undefined); + assert.equal(dots.code, "bad-name"); +}); + +test("the folder name is the explicit one, else the URL's last segment without .git", () => { + assert.equal(targetName({ url: "https://github.com/acme/widgets.git/", parentDir: "/p" }), "widgets"); + assert.equal(targetName({ url: "C:\\src\\Tools.GIT", parentDir: "/p" }), "Tools"); + assert.equal(targetName({ url: "https://github.com/acme/widgets", parentDir: "/p", name: " mine " }), "mine"); +}); + +test("a destination that is already there is refused before git runs", async () => { + const parent = temp("clonedest"); + writeFileSync(join(parent, "taken"), "someone's file\n"); + const res = await startClone({ url: "https://example.com/acme/taken.git", parentDir: parent }, () => undefined); + assert.equal(res.ok, false); + assert.equal(res.code, "dest-exists"); + assert.equal(res.expected, true); + assert.match(res.message ?? "", /already exists/); + assert.equal(readFileSync(join(parent, "taken"), "utf8"), "someone's file\n"); +}); + +test("a destination folder that cannot be created is reported with the reason", async () => { + const parent = temp("clonedest"); + const file = join(parent, "a-file"); + writeFileSync(file, "not a folder\n"); + const res = await startClone({ url: "https://example.com/acme/x.git", parentDir: join(file, "under") }, () => undefined); + assert.equal(res.ok, false); + assert.equal(res.expected, undefined, "mkdir refusing is a real failure"); + assert.match(res.message ?? "", /^Couldn't create /); +}); + +// ── the clone itself ───────────────────────────────────────────────────────── + +test("a clone streams git's progress and resolves with the new repository's path", async () => { + const src = sourceRepo(); + const parent = temp("clonedest"); + const progress: CloneProgress[] = []; + const res = await startClone({ url: pathToFileURL(src).href, parentDir: parent, name: "copy" }, (p) => void progress.push(p)); + assert.deepEqual(res, { ok: true, root: join(parent, "copy") }); + assert.equal(readFileSync(join(parent, "copy", "a.txt"), "utf8").replace(/\r\n/g, "\n"), "one\n"); + assert.ok( + progress.some((p) => /^Cloning into/.test(p.phase)), + `the opening line is forwarded: ${JSON.stringify(progress)}`, + ); + const pct = progress.filter((p) => typeof p.percent === "number"); + assert.ok(pct.length > 0, "percent lines are parsed"); + for (const p of pct) { + assert.ok(p.percent! >= 0 && p.percent! <= 100); + assert.match(p.phase, /^[A-Za-z ]+$/, "the phase is the words before the colon"); + } +}); + +test("a clone git refuses fails, leaves no half-made folder, and names no success", async () => { + const parent = temp("clonedest"); + const missing = join(temp("clonesrc"), "no-repo-here"); + const res = await startClone({ url: pathToFileURL(missing).href, parentDir: parent, name: "copy" }, () => undefined); + assert.equal(res.ok, false); + assert.equal(res.root, undefined); + assert.ok((res.message ?? "").length > 0, "it says something"); + assert.equal(existsSync(join(parent, "copy")), false, "git leaves no half-made folder behind"); +}); + +// BUG (reported, not fixed here): the failure message is only the LAST stderr +// line. git explains a refused clone over several lines — +// fatal: '/…/no-repo-here' does not appear to be a git repository +// fatal: Could not read from remote repository. +// +// Please make sure you have the correct access rights +// and the repository exists. +// — so the clone sheet shows "and the repository exists." and nothing else: a +// sentence fragment with the reason cut off. Unskip when the message keeps the +// fatal line(s). +test.skip("a clone git refuses resolves with git's reason, not the tail of its advice", async () => { + const parent = temp("clonedest"); + const missing = join(temp("clonesrc"), "no-repo-here"); + const res = await startClone({ url: pathToFileURL(missing).href, parentDir: parent, name: "copy" }, () => undefined); + assert.equal(res.ok, false); + assert.match(res.message ?? "", /does not appear to be a git repository|Could not read from remote/); +}); + +test("with no git on PATH the clone says so in words, not ENOENT", async () => { + const parent = temp("clonedest"); + const saved = { PATH: process.env.PATH, Path: process.env.Path }; + try { + process.env.PATH = temp("empty-path"); + if (saved.Path !== undefined) process.env.Path = process.env.PATH; + const res = await startClone({ url: "https://example.com/acme/x.git", parentDir: parent }, () => undefined); + assert.deepEqual(res, { ok: false, message: "git was not found on your PATH." }); + } finally { + process.env.PATH = saved.PATH; + if (saved.Path !== undefined) process.env.Path = saved.Path; + } +}); + +test("a clone the OS refuses to start is answered with the reason, not thrown", async () => { + const parent = temp("clonedest"); + // A NUL cannot be in an argument: spawn throws before any process exists. + const res = await startClone({ url: "https://example.com/acme/x.git", parentDir: parent, name: "bad\0name" }, () => undefined); + assert.equal(res.ok, false); + assert.match(res.message ?? "", /null bytes|string without null/i); +}); + +test("closing the window kills a clone still in flight instead of orphaning it", async () => { + const parent = temp("clonedest"); + // An ssh that connects and then waits forever — until git, its parent, goes + // away and its stdin closes. Forward slashes: git runs it through `sh -c`. + const node = process.execPath.split("\\").join("/"); + const undo = serveOwnSsh(`"${node}" -e "process.stdin.on('end',()=>process.exit(0));process.stdin.resume()"`); + try { + let killed = false; + const res = await startClone({ url: "ssh://git@example.invalid/acme/slow.git", parentDir: parent }, (p) => { + // The first line git prints is "Cloning into …": the child is alive. + if (!killed) { + killed = true; + killActiveClones(); + } + }); + assert.equal(killed, true); + assert.equal(res.ok, false, "a killed clone is not a finished one"); + assert.ok(res.message, "and it says something"); + // A second teardown has nothing left to kill. + killActiveClones(); + } finally { + undo(); + } +}); + +// ── pickers and lists ──────────────────────────────────────────────────────── + +test("the clone-folder picker opens at the configured folder and answers the chosen path", async () => { + electron.dialogAnswer = { canceled: false, filePaths: ["/Users/dev/code"] }; + assert.equal(await pickCloneDir("/Users/dev/GitStudio"), "/Users/dev/code"); + assert.equal(electron.dialogs.at(-1)?.defaultPath, "/Users/dev/GitStudio"); + assert.deepEqual(electron.dialogs.at(-1)?.properties, ["openDirectory", "createDirectory"]); + + electron.dialogAnswer = { canceled: true, filePaths: [] }; + assert.equal(await pickCloneDir(), undefined, "cancel is no folder"); + assert.equal("defaultPath" in (electron.dialogs.at(-1) ?? {}), false); + electron.dialogAnswer = { canceled: false, filePaths: [] }; + assert.equal(await pickCloneDir(), undefined); +}); + +function client(repos: unknown[] | undefined, login: string | Error): GitHubClient & { paths: string[] } { + const paths: string[] = []; + return { + paths, + requestPaged: async (path: string) => { + paths.push(path); + return repos; + }, + request: async (_method: string, path: string) => { + paths.push(path); + if (login instanceof Error) throw login; + return { login }; + }, + } as unknown as GitHubClient & { paths: string[] }; +} + +const RAW = [ + { + full_name: "dev/old-tool", + name: "old-tool", + owner: { login: "dev", type: "User" }, + description: "A CLI for widgets", + private: true, + clone_url: "https://github.com/dev/old-tool.git", + ssh_url: "git@github.com:dev/old-tool.git", + default_branch: "trunk", + stargazers_count: 3, + language: "Go", + pushed_at: "2024-01-01T00:00:00Z", + }, + { + full_name: "acme/app", + name: "app", + owner: { login: "acme", type: "Organization" }, + description: null, + fork: true, + updated_at: "2025-06-01T00:00:00Z", + }, + {}, +]; + +test("the repository list maps GitHub's shape, marks yours, and sorts newest first", async () => { + const c = client(RAW, "dev"); + const list = await listGhRepos(c); + assert.match(c.paths[0], /^\/user\/repos\?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member$/); + assert.deepEqual(list.map((r) => r.fullName), ["acme/app", "dev/old-tool", ""]); + const mine = list[1]; + assert.deepEqual(mine, { + fullName: "dev/old-tool", + name: "old-tool", + owner: "dev", + ownerType: "User", + mine: true, + description: "A CLI for widgets", + private: true, + fork: false, + cloneUrl: "https://github.com/dev/old-tool.git", + sshUrl: "git@github.com:dev/old-tool.git", + defaultBranch: "trunk", + stars: 3, + language: "Go", + updatedAt: "2024-01-01T00:00:00Z", + }); + const org = list[0]; + assert.equal(org.ownerType, "Organization"); + assert.equal(org.mine, false); + assert.equal(org.fork, true); + assert.equal(org.defaultBranch, "main", "a missing default branch reads as main"); + assert.equal(org.updatedAt, "2025-06-01T00:00:00Z", "updated_at stands in for pushed_at"); +}); + +test("the repository search matches the name or the description, case-insensitively", async () => { + assert.deepEqual((await listGhRepos(client(RAW, "dev"), " WIDGETS ")).map((r) => r.fullName), ["dev/old-tool"]); + assert.deepEqual((await listGhRepos(client(RAW, "dev"), "ACME/")).map((r) => r.fullName), ["acme/app"]); +}); + +test("when who-am-I fails, nothing is claimed as yours and the list still loads", async () => { + const list = await listGhRepos(client(RAW, new Error("401"))); + assert.equal(list.length, 3); + assert.equal(list.some((r) => r.mine), false); + assert.deepEqual(await listGhRepos(client(undefined, "dev")), [], "no pages is an empty list"); +}); diff --git a/apps/desktop/test/contextMenu.dom.test.ts b/apps/desktop/test/contextMenu.dom.test.ts new file mode 100644 index 00000000..03cfeeec --- /dev/null +++ b/apps/desktop/test/contextMenu.dom.test.ts @@ -0,0 +1,396 @@ +// The graph's right-click menu (contextMenu.ts), rendered and driven: the rows +// it offers for one commit and for several, where it lands on screen, the +// keyboard, what each click sends to the host — including the question it has +// to ask first — and where focus goes when it closes. +// +// Built in helpers/miniDom.ts; the dialogs it opens are the real ones. + +import { test, before, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { installMiniDom, fire, press, settle, text, type MiniElement } from "./helpers/miniDom"; +import type { CommitActionRequest } from "../src/shared/ipc"; + +const dom = installMiniDom(); +type CM = typeof import("../src/renderer/contextMenu"); +let cm!: CM; +let overlays!: typeof import("../src/renderer/overlays"); +before(async () => { + cm = (await import("../src/renderer/contextMenu")) as CM; + overlays = await import("../src/renderer/overlays"); +}); + +const doc = dom.document; +const SHA = "0123456789abcdef0123456789abcdef01234567"; +let graphRow!: MiniElement; +beforeEach(() => { + mock.timers.enable({ apis: ["setTimeout"] }); + doc.body.replaceChildren(); + dom.window.innerWidth = 1200; + dom.window.innerHeight = 800; + doc.sizer = (el) => (el.classList.contains("ctx-menu") ? { width: 220, height: 300 } : undefined); + graphRow = doc.createElement("div"); + graphRow.tabIndex = 0; + graphRow.dataset.num = SHA; + doc.body.appendChild(graphRow); + graphRow.focus(); +}); +afterEach(() => { + for (const o of doc.querySelectorAll(".modal-overlay")) o.querySelector(".mini-btn")?.click(); + overlays.dismissLayers(); + mock.timers.reset(); +}); + +function harness() { + const sent: CommitActionRequest[] = []; + const dropped: string[] = []; + const many: Array<[string, string[]]> = []; + const menu = new cm.CommitContextMenu( + (r) => sent.push(r), + (sha) => dropped.push(sha), + (action, shas) => many.push([action, shas]), + ); + return { menu, sent, dropped, many }; +} +const menuEl = (): MiniElement | null => doc.querySelector(".ctx-menu"); +const rows = (): MiniElement[] => menuEl()?.querySelectorAll(".ctx-menu-item") ?? []; +const row = (label: string): MiniElement => { + const r = rows().find((x) => x.textContent === label); + assert.ok(r, `no row "${label}" in ${rows().map((x) => x.textContent).join(", ")}`); + return r; +}; +const px = (v: unknown): number => Number(String(v).replace("px", "")); +const dialog = (): MiniElement | null => doc.querySelector(".modal-overlay"); + +// ── what one commit's menu offers ──────────────────────────────────────────── + +test("one commit: a short-sha heading, Checkout rows for its refs, then the commit actions", () => { + const { menu } = harness(); + menu.open(SHA, 100, 100, [ + { name: "main", kind: "head", current: true }, + { name: "feat/x", kind: "head", fullName: "refs/heads/feat/x" }, + { name: "v1.0", kind: "tag" }, + ]); + mock.timers.tick(0); + const m = menuEl()!; + assert.equal(m.getAttribute("role"), "menu"); + assert.equal(text(m.querySelector(".ctx-menu-header")), "0123456", "seven characters, like every short sha"); + assert.deepEqual( + rows().map((r) => r.textContent), + [ + "Checkout feat/x", + "Checkout v1.0…", + "Checkout", + "Create branch here…", + "Create tag here…", + "Cherry-pick", + "Revert", + "Reset (soft)", + "Reset (mixed)", + "Reset (hard)", + "Copy SHA", + ], + "the branch you are on is not offered as a checkout", + ); + for (const r of rows()) { + assert.equal(r.getAttribute("role"), "menuitem"); + assert.equal(r.tabIndex, -1); + } + assert.equal(row("Checkout feat/x").dataset.action, "checkout-ref"); + assert.ok(row("Reset (hard)").classList.contains("ctx-danger")); + assert.equal(row("Revert").classList.contains("ctx-danger"), false); + assert.equal(doc.activeElement, rows()[0], "the first row has the keyboard"); +}); + +test("Drop commit… is offered beside Revert only when main said it can work", () => { + const { menu } = harness(); + menu.open(SHA, 10, 10, [], { drop: true }); + const labels = rows().map((r) => r.textContent); + assert.equal(labels[labels.indexOf("Revert") + 1], "Drop commit…"); + assert.ok(row("Drop commit…").classList.contains("ctx-danger")); + menu.open(SHA, 10, 10); + assert.equal( + rows().some((r) => r.textContent === "Drop commit…"), + false, + ); + assert.equal(doc.querySelectorAll(".ctx-menu").length, 1, "re-opening replaces, never stacks"); +}); + +test("the menu stays on screen with an 8px margin", () => { + const { menu } = harness(); + menu.open(SHA, 300, 200); + assert.deepEqual([px(menuEl()!.style.left), px(menuEl()!.style.top)], [300, 200]); + menu.open(SHA, 1150, 780); + assert.deepEqual([px(menuEl()!.style.left), px(menuEl()!.style.top)], [1200 - 220 - 8, 800 - 300 - 8]); + dom.window.innerWidth = 100; + dom.window.innerHeight = 100; + menu.open(SHA, 50, 50); + assert.deepEqual([px(menuEl()!.style.left), px(menuEl()!.style.top)], [8, 8]); +}); + +// ── the keyboard ───────────────────────────────────────────────────────────── + +test("arrows wrap, Home/End jump, Enter runs the focused row", () => { + const { menu, sent } = harness(); + menu.open(SHA, 10, 10); + mock.timers.tick(0); + const all = rows(); + press("ArrowUp"); + assert.equal(doc.activeElement, all.at(-1)); + press("ArrowDown"); + assert.equal(doc.activeElement, all[0]); + press("End"); + assert.equal(doc.activeElement.textContent, "Copy SHA"); + press("Home"); + assert.equal(doc.activeElement.textContent, "Checkout"); + press("ArrowDown"); + press("ArrowDown"); + press("ArrowDown"); + assert.equal(doc.activeElement.textContent, "Cherry-pick"); + press("Enter"); + assert.deepEqual(sent, [{ action: "cherry-pick", sha: SHA, name: undefined }]); + assert.equal(menuEl(), null); + assert.equal(doc.activeElement, graphRow, "focus is back on the graph row"); +}); + +test("Space runs the focused row; Enter with nothing focused in the menu does nothing", () => { + const { menu, sent } = harness(); + menu.open(SHA, 10, 10); + // Before the deferred focus: the keyboard is still on the graph row. + press("Enter"); + assert.deepEqual(sent, []); + press("ArrowDown"); + assert.equal(doc.activeElement.textContent, "Checkout", "Down from outside lands on the first row"); + press("End"); + press(" "); + assert.deepEqual(sent, [{ action: "copy-sha", sha: SHA, name: undefined }]); +}); + +test("ArrowUp from outside the rows lands on the last one", () => { + const { menu } = harness(); + menu.open(SHA, 10, 10); + press("ArrowUp"); + assert.equal(doc.activeElement.textContent, "Copy SHA"); +}); + +test("Escape closes and returns focus into the graph's shadow root; the page never hears it", () => { + // The graph's rows live inside 's shadow root: the document + // sees only the host as focused. + const host = doc.createElement("gitstudio-graph"); + const inner = doc.createElement("div"); + inner.tabIndex = 0; + doc.body.append(host, inner); + host.tabIndex = 0; + host.focus(); + host.shadowRoot = { activeElement: inner }; + let pageEscapes = 0; + const onPage = (e: { key: string }): void => void (e.key === "Escape" && pageEscapes++); + doc.addEventListener("keydown", onPage as never); + const { menu } = harness(); + menu.open(SHA, 10, 10); + mock.timers.tick(0); + const e = press("Escape"); + doc.removeEventListener("keydown", onPage as never); + assert.equal(e.defaultPrevented, true); + assert.equal(pageEscapes, 0); + assert.equal(menuEl(), null); + assert.equal(doc.activeElement, inner, "not the host, which has nowhere to put the arrows"); +}); + +test("Tab closes without dragging focus back; a click anywhere closes too", () => { + const { menu } = harness(); + menu.open(SHA, 10, 10); + mock.timers.tick(0); + press("Tab"); + assert.equal(menuEl(), null); + assert.notEqual(doc.activeElement, graphRow); + + menu.open(SHA, 10, 10); + mock.timers.tick(0); + const baseKeys = doc.listenerCount("keydown"); + fire(doc.body, "click"); + assert.equal(menuEl(), null); + assert.equal(doc.listenerCount("keydown"), baseKeys - 1, "its key listener went with it"); + assert.equal(doc.listenerCount("click"), 0); + graphRow.focus(); + const after = press("ArrowDown"); + assert.equal(after.defaultPrevented, false, "a closed menu answers no keys"); + assert.equal(doc.activeElement, graphRow); +}); + +test("a route change closes the menu without moving focus", () => { + const { menu } = harness(); + menu.open(SHA, 10, 10); + mock.timers.tick(0); + assert.equal(overlays.isMenuOpen(), true); + overlays.dismissLayers(); + assert.equal(menuEl(), null); + assert.equal(overlays.isMenuOpen(), false); + assert.notEqual(doc.activeElement, graphRow); +}); + +// ── what a click sends ─────────────────────────────────────────────────────── + +test("an item with no question dispatches at once, and the click does not reach the page", () => { + const { menu, sent } = harness(); + let pageClicks = 0; + const onPage = (): void => void pageClicks++; + doc.body.addEventListener("click", onPage as never); + menu.open(SHA, 10, 10); + row("Copy SHA").click(); + doc.body.removeEventListener("click", onPage as never); + assert.deepEqual(sent, [{ action: "copy-sha", sha: SHA, name: undefined }]); + assert.equal(pageClicks, 0); +}); + +test("a branch checkout from the menu carries the ref, its kind and its full name", () => { + const { menu, sent } = harness(); + menu.open(SHA, 10, 10, [{ name: "feat/x", kind: "head", fullName: "refs/heads/feat/x" }]); + row("Checkout feat/x").click(); + assert.deepEqual(sent, [{ action: "checkout-ref", sha: SHA, name: "feat/x", refKind: "head", fullName: "refs/heads/feat/x" }]); +}); + +test("a tag checkout asks first: it will detach HEAD", async () => { + const { menu, sent } = harness(); + menu.open(SHA, 10, 10, [{ name: "v1.0", kind: "tag" }]); + row("Checkout v1.0…").click(); + mock.timers.tick(0); + assert.match(text(dialog()!.querySelector(".modal-message")), /detached HEAD/); + assert.equal(text(dialog()!.querySelector(".modal-ok")), "Checkout v1.0"); + dialog()!.querySelector(".modal-ok")!.click(); + await settle(); + assert.deepEqual(sent, [{ action: "checkout-ref", sha: SHA, name: "v1.0", refKind: "tag" }]); +}); + +test("Revert is confirm-gated: Cancel sends nothing, the confirm sends the revert", async () => { + const { menu, sent } = harness(); + menu.open(SHA, 10, 10); + row("Revert").click(); + mock.timers.tick(0); + assert.equal(text(dialog()!.querySelector(".modal-title")), "Revert"); + assert.equal(text(dialog()!.querySelector(".modal-message")), "Create a revert commit for this commit?"); + dialog()!.querySelector(".mini-btn")!.click(); + await settle(); + assert.deepEqual(sent, []); + assert.equal(doc.activeElement, graphRow, "the dialog hands the keyboard back to the graph"); + + menu.open(SHA, 10, 10); + row("Revert").click(); + mock.timers.tick(0); + dialog()!.querySelector(".modal-ok")!.click(); + await settle(); + assert.deepEqual(sent, [{ action: "revert", sha: SHA, name: undefined }]); +}); + +test("Reset (hard) is a danger confirm whose button says Reset and which starts on Cancel", async () => { + const { menu, sent } = harness(); + menu.open(SHA, 10, 10); + row("Reset (hard)").click(); + mock.timers.tick(0); + const ok = dialog()!.querySelector(".modal-ok")!; + assert.equal(text(ok), "Reset"); + assert.ok(ok.classList.contains("btn-danger")); + assert.equal(doc.activeElement, dialog()!.querySelector(".mini-btn")); + ok.click(); + await settle(); + assert.deepEqual(sent, [{ action: "reset-hard", sha: SHA, name: undefined }]); +}); + +test("Create branch here… asks for the name and sends it trimmed; an empty name sends nothing", async () => { + const { menu, sent } = harness(); + menu.open(SHA, 10, 10); + row("Create branch here…").click(); + mock.timers.tick(0); + const field = dialog()!.querySelector(".modal-input")!; + assert.equal(text(dialog()!.querySelector(".modal-title")), "Create branch here"); + assert.equal(field.placeholder, "feature/my-branch"); + field.value = " feat/login "; + press("Enter"); + await settle(); + assert.deepEqual(sent, [{ action: "branch", sha: SHA, name: "feat/login" }]); + + menu.open(SHA, 10, 10); + row("Create tag here…").click(); + mock.timers.tick(0); + press("Enter"); + await settle(); + assert.equal(sent.length, 1, "no nameless tag request"); +}); + +test("Drop commit… runs its own flow, not a commit:action", () => { + const { menu, sent, dropped } = harness(); + menu.open(SHA, 10, 10, [], { drop: true }); + row("Drop commit…").click(); + assert.deepEqual(dropped, [SHA]); + assert.deepEqual(sent, []); +}); + +test("a menu built without a drop handler ignores Drop rather than throwing", () => { + const sent: CommitActionRequest[] = []; + const menu = new cm.CommitContextMenu((r) => sent.push(r)); + menu.open(SHA, 10, 10, [], { drop: true }); + row("Drop commit…").click(); + assert.deepEqual(sent, []); + menu.openMany([SHA, SHA], 10, 10, { apply: true, drop: false, squash: false }); + row("Copy SHAs").click(); + assert.equal(menuEl(), null); +}); + +// ── several commits ────────────────────────────────────────────────────────── + +test("several commits: headed in words, only what applies, and a pick hands over every sha", () => { + const { menu, many } = harness(); + const shas = ["a".repeat(40), "b".repeat(40), "c".repeat(40)]; + menu.openMany(shas, 40, 40, { apply: true, drop: true, squash: false }); + mock.timers.tick(0); + const m = menuEl()!; + assert.equal(m.getAttribute("aria-label"), "Actions for 3 commits"); + const head = m.querySelector(".ctx-menu-header")!; + assert.equal(head.textContent, "3 commits selected"); + assert.ok(head.classList.contains("is-words")); + assert.deepEqual(rows().map((r) => r.textContent), ["Cherry-pick 3 commits", "Revert 3 commits", "Drop 3 commits…", "Copy SHAs"]); + assert.ok(row("Drop 3 commits…").classList.contains("ctx-danger")); + assert.equal(row("Revert 3 commits").dataset.action, "revert-many"); + assert.equal(doc.activeElement, rows()[0]); + row("Drop 3 commits…").click(); + assert.deepEqual(many, [["drop-many", shas]]); + assert.equal(menuEl(), null); + assert.equal(doc.activeElement, graphRow, "closed BEFORE the flow's dialog captures focus"); +}); + +// ── from outside the menu ──────────────────────────────────────────────────── + +test("checkoutRef from a chip asks the same question as the menu, and declines what the menu declines", async () => { + const { menu, sent } = harness(); + menu.checkoutRef(SHA, { name: "origin/fix", kind: "remote", fullName: "refs/remotes/origin/fix" }); + assert.deepEqual(sent, [ + { action: "checkout-ref", sha: SHA, name: "origin/fix", refKind: "remote", fullName: "refs/remotes/origin/fix" }, + ]); + menu.checkoutRef(SHA, { name: "main", kind: "head", current: true }); + assert.equal(sent.length, 1, "the branch you are on is not checked out again"); + menu.checkoutRef(SHA, { name: "v2", kind: "tag" }); + mock.timers.tick(0); + assert.ok(dialog(), "a tag asks first here too"); + dialog()!.querySelector(".mini-btn")!.click(); + await settle(); + assert.equal(sent.length, 1); +}); + +test("askForCommitAction and commitActionItem: the one table the toolbar shares", async () => { + assert.equal(cm.commitActionItem("revert")?.confirm, "Create a revert commit for this commit?"); + assert.equal(cm.commitActionItem("nope"), undefined); + assert.deepEqual(await cm.askForCommitAction(cm.commitActionItem("cherry-pick")!), { ok: true, name: undefined }); + + const tag = cm.askForCommitAction(cm.commitActionItem("tag")!); + mock.timers.tick(0); + assert.equal(text(dialog()!.querySelector(".modal-title")), "Create tag here"); + assert.equal(dialog()!.querySelector(".modal-input")!.placeholder, "v1.0.0"); + dialog()!.querySelector(".modal-input")!.value = "v3.1.0"; + dialog()!.querySelector(".modal-ok")!.click(); + assert.deepEqual(await tag, { ok: true, name: "v3.1.0" }); + + const soft = cm.askForCommitAction(cm.commitActionItem("reset-soft")!); + mock.timers.tick(0); + assert.equal(text(dialog()!.querySelector(".modal-ok")), "Reset (soft)", "a non-danger confirm uses the item's own words"); + press("Escape"); + assert.deepEqual(await soft, { ok: false }); +}); diff --git a/apps/desktop/test/desktopMainFakeElectron.ts b/apps/desktop/test/desktopMainFakeElectron.ts new file mode 100644 index 00000000..c3d80292 --- /dev/null +++ b/apps/desktop/test/desktopMainFakeElectron.ts @@ -0,0 +1,82 @@ +// A stand-in for the parts of `electron` that main/editors.ts and +// main/cloneBridge.ts touch: app.getPath, app.getFileIcon, nativeImage, +// shell.showItemInFolder and dialog.showOpenDialog. Outside Electron +// `require("electron")` is the binary's path (a string), so every one of those +// would be undefined. +// +// IMPORT THIS FIRST, before the module under test: it seeds the require cache +// entry `electron` resolves to. Each test file runs in its own process, so the +// stub never leaks into another file. (Same technique as ghFakeElectron.ts, +// with the image APIs that one does not carry.) + +import { createRequire } from "node:module"; + +export interface FakeImage { + isEmpty(): boolean; + toDataURL(): string; + resize(opts: { width: number; height: number; quality?: string }): FakeImage; +} + +function image(tag: string | undefined): FakeImage { + return { + isEmpty: () => tag === undefined, + toDataURL: () => `data:image/png;base64,${Buffer.from(tag ?? "").toString("base64")}`, + resize: (o) => image(tag === undefined ? undefined : `${tag}@${o.width}x${o.height}`), + }; +} + +export const electron = { + paths: {} as Record, + /** Every app.getFileIcon path asked for, in order. */ + iconAsks: [] as string[], + /** What getFileIcon answers per path: a tag (non-empty image), "" (empty image) or an Error. */ + icons: {} as Record, + /** shell.showItemInFolder paths. */ + shown: [] as string[], + /** createFromBuffer inputs' first bytes, hex, for asserting what was decoded. */ + decoded: [] as string[], + /** Every dialog.showOpenDialog options object, and what the next one answers. */ + dialogs: [] as Array>, + dialogAnswer: { canceled: true, filePaths: [] as string[] }, +}; + +const fake = { + app: { + getPath(name: string): string { + const p = electron.paths[name]; + if (!p) throw new Error(`test electron stub: no path for "${name}"`); + return p; + }, + async getFileIcon(path: string): Promise { + electron.iconAsks.push(path); + const answer = electron.icons[path]; + if (answer instanceof Error) throw answer; + return image(answer === undefined || answer === "" ? undefined : answer); + }, + }, + nativeImage: { + createFromBuffer(buf: Buffer): FakeImage { + electron.decoded.push(buf.subarray(0, 8).toString("hex")); + return image(`png:${buf.length}`); + }, + /** Nothing is decoded from disk here: an icon read by path is empty. */ + createFromPath(): FakeImage { + return image(undefined); + }, + }, + shell: { + showItemInFolder(path: string): void { + electron.shown.push(path); + }, + }, + dialog: { + async showOpenDialog(opts: Record): Promise<{ canceled: boolean; filePaths: string[] }> { + electron.dialogs.push(opts); + return electron.dialogAnswer; + }, + }, +}; + +const req = createRequire(__filename); +const id = req.resolve("electron"); +req.cache[id] = { id, filename: id, loaded: true, exports: fake } as unknown as NodeJS.Module; diff --git a/apps/desktop/test/dialogs.modals.test.ts b/apps/desktop/test/dialogs.modals.test.ts new file mode 100644 index 00000000..d0fc26f2 --- /dev/null +++ b/apps/desktop/test/dialogs.modals.test.ts @@ -0,0 +1,666 @@ +// The in-app dialogs (dialogs.ts) that replace alert/confirm/prompt: what each +// shows, what each resolves for every way it can be answered (button, Enter, +// Escape, backdrop, a route change, an abort), where the keyboard goes while it +// is open and after it closes, and the toasts that report outcomes. +// +// Rendered into helpers/miniDom.ts and driven the way a person would — clicks +// on the buttons, keys on the focused element. + +import { test, before, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { installMiniDom, fire, press, settle, text, type MiniElement } from "./helpers/miniDom"; + +const dom = installMiniDom(); +type D = typeof import("../src/renderer/dialogs"); +let d!: D; +let overlays!: typeof import("../src/renderer/overlays"); +before(async () => { + d = (await import("../src/renderer/dialogs")) as D; + overlays = await import("../src/renderer/overlays"); +}); + +const doc = dom.document; +let shell!: MiniElement; +let opener!: MiniElement; +beforeEach(() => { + mock.timers.enable({ apis: ["setTimeout"] }); + doc.body.replaceChildren(); + doc.body.classList.remove("cmdk-open"); + shell = doc.createElement("div"); + shell.id = "app-shell"; + opener = doc.createElement("button"); + opener.textContent = "Delete branch"; + shell.appendChild(opener); + doc.body.appendChild(shell); + opener.focus(); +}); +afterEach(() => { + // Nothing may leak into the next test: close whatever is still up. + for (const o of doc.querySelectorAll(".modal-overlay")) { + const cancel = o.querySelector(".mini-btn"); + cancel?.click(); + } + overlays.dismissLayers(); + mock.timers.reset(); +}); + +const overlay = (): MiniElement | null => doc.querySelectorAll(".modal-overlay").at(-1) ?? null; +const okBtn = (): MiniElement => overlay()!.querySelector(".modal-ok")!; +const cancelBtn = (): MiniElement => overlay()!.querySelector(".modal-actions .mini-btn")!; +const input = (): MiniElement => overlay()!.querySelector(".modal-input")!; +/** Let the modal's deferred focus run. */ +const mounted = (): void => mock.timers.tick(0); + +// ── toasts ─────────────────────────────────────────────────────────────────── + +test("a toast goes into one polite live region, with its kind's icon, and slides in on the next frame", () => { + d.toast("Pushed main to origin", "success"); + d.toast("Something failed", "error"); + d.toast("FYI"); + const stacks = doc.querySelectorAll("#toast-stack"); + assert.equal(stacks.length, 1, "one stack, reused"); + const stack = stacks[0]; + assert.equal(stack.getAttribute("role"), "status"); + assert.equal(stack.getAttribute("aria-live"), "polite"); + const [ok, err, info] = stack.querySelectorAll(".toast"); + assert.ok(ok.classList.contains("toast-success") && ok.querySelector(".codicon-pass-filled")); + assert.ok(err.classList.contains("toast-error") && err.querySelector(".codicon-error")); + assert.ok(info.classList.contains("toast-info") && info.querySelector(".codicon-info")); + assert.equal(text(ok.querySelector(".toast-msg")), "Pushed main to origin"); + assert.equal(ok.querySelector(".toast-close")!.getAttribute("aria-label"), "Dismiss"); + assert.equal(ok.classList.contains("in"), false); + mock.timers.tick(0); + assert.ok(ok.classList.contains("in")); +}); + +test("toasts time out by kind: 4s info, 7s error, 10s when there is something to undo", () => { + d.toast("info"); + d.toast("error", "error"); + d.toast("undoable", "success", undefined, { label: "Undo", onClick: () => {} }); + d.toast("custom", "info", 1000); + const alive = (): string[] => doc.querySelectorAll(".toast:not(.out)").map((t) => text(t.querySelector(".toast-msg"))); + mock.timers.tick(999); + assert.deepEqual(alive(), ["info", "error", "undoable", "custom"]); + mock.timers.tick(1); + assert.deepEqual(alive(), ["info", "error", "undoable"]); + mock.timers.tick(3000); + assert.deepEqual(alive(), ["error", "undoable"]); + mock.timers.tick(3000); + assert.deepEqual(alive(), ["undoable"]); + mock.timers.tick(3000); + assert.deepEqual(alive(), []); + mock.timers.tick(280); + assert.equal(doc.querySelectorAll(".toast").length, 0, "gone from the DOM once the exit has played"); +}); + +test("a toast's action dismisses it FIRST, then runs; actions keep the order offered", () => { + const seen: string[] = []; + d.toast("Commit hidden by the filter", "info", undefined, [ + { label: "Add feat/x to the filter", onClick: () => seen.push(`add:${doc.querySelector(".toast")!.classList.contains("out")}`) }, + { label: "Show all branches", onClick: () => seen.push("all") }, + ]); + const t = doc.querySelector(".toast")!; + const acts = t.querySelectorAll(".toast-action"); + assert.deepEqual(acts.map((a) => a.textContent), ["Add feat/x to the filter", "Show all branches"]); + assert.equal(t.children.at(-1), t.querySelector(".toast-close"), "Dismiss stays last"); + acts[0].click(); + assert.deepEqual(seen, ["add:true"], "the toast was already leaving when the handler re-rendered"); + fire(t, "transitionend"); + assert.equal(t.isConnected, false); + acts[1].click(); + assert.deepEqual(seen, ["add:true", "all"]); +}); + +test("Dismiss takes a toast down; clearToasts takes every one down", () => { + d.toast("one"); + d.toast("two"); + const [one] = doc.querySelectorAll(".toast"); + one.querySelector(".toast-close")!.click(); + assert.ok(one.classList.contains("out")); + mock.timers.tick(280); + assert.equal(one.isConnected, false); + one.querySelector(".toast-close")!.click(); // a second dismiss of a removed toast is a no-op + d.clearToasts(); + assert.equal(doc.querySelectorAll(".toast").length, 0); + assert.ok(doc.getElementById("toast-stack"), "the live region itself stays"); + d.clearToasts(); +}); + +// ── the modal scaffold ─────────────────────────────────────────────────────── + +function openBare(spec: Partial = {}) { + const log: string[] = []; + let close!: () => void; + const card = doc.createElement("div"); + card.className = "modal-card"; + const a = doc.createElement("button"); + a.textContent = "A"; + const hidden = doc.createElement("button"); + hidden.hidden = true; + const b = doc.createElement("input"); + const dis = doc.createElement("button"); + dis.disabled = true; + card.append(a, hidden, b, dis); + d.openModal((c) => { + close = c; + return { card: card as never, focusEl: a as never, label: "Bare", onClose: () => log.push("closed"), ...spec }; + }); + mounted(); + return { log, close: () => close(), card, a, b }; +} + +test("a modal is a labelled dialog that holds the page behind it and focuses its field", () => { + d.toast("still readable"); + const { a } = openBare(); + const o = overlay()!; + assert.equal(o.getAttribute("role"), "dialog"); + assert.equal(o.getAttribute("aria-modal"), "true"); + assert.equal(o.getAttribute("aria-label"), "Bare"); + assert.equal(shell.hasAttribute("inert"), true, "the app behind cannot be tabbed into"); + assert.equal(doc.getElementById("toast-stack")!.hasAttribute("inert"), false, "toasts stay live"); + assert.equal(doc.activeElement, a); + assert.equal(overlays.isModalOpen(), true); +}); + +test("closing gives the keyboard back to what had it, after releasing the page", () => { + const { close, log } = openBare(); + close(); + close(); + assert.deepEqual(log, ["closed"], "onClose runs once however many times close is called"); + assert.equal(overlay(), null); + assert.equal(shell.hasAttribute("inert"), false); + assert.equal(doc.activeElement, opener); + assert.equal(overlays.isModalOpen(), false); +}); + +test("Escape and the backdrop dismiss; canDismiss can veto both, but not the modal's own close", () => { + let allow = false; + const { log, card, close } = openBare({ canDismiss: () => allow }); + press("Escape"); + fire(overlay()!, "mousedown"); + assert.ok(overlay(), "a clone mid-flight keeps its dialog"); + allow = true; + fire(card, "mousedown"); + assert.ok(overlay(), "a press INSIDE the card is not the backdrop"); + fire(overlay()!, "mousedown"); + assert.equal(overlay(), null); + assert.deepEqual(log, ["closed"]); + + allow = false; + const second = openBare({ canDismiss: () => allow }); + second.close(); + assert.equal(overlay(), null, "the explicit close always works"); + void close; +}); + +test("Escape dismisses only the topmost modal", () => { + const first = openBare(); + const second = openBare(); + press("Escape"); + assert.deepEqual(second.log, ["closed"]); + assert.deepEqual(first.log, [], "the dialog underneath survives"); + assert.ok(overlay()); + assert.equal(doc.activeElement, first.a, "the keyboard returns into the first dialog"); + press("Escape"); + assert.deepEqual(first.log, ["closed"]); +}); + +test("Escape belongs to the command palette or a menu when one is open over the dialog", () => { + const { log } = openBare(); + doc.body.classList.add("cmdk-open"); + press("Escape"); + assert.deepEqual(log, []); + doc.body.classList.remove("cmdk-open"); + const menu = overlays.registerLayer(() => {}, "menu"); + press("Escape"); + assert.deepEqual(log, [], "a menu opened from inside the dialog closes first"); + menu.release(); + press("Escape"); + assert.deepEqual(log, ["closed"]); +}); + +test("Tab wraps inside the card, skipping disabled and hidden controls", () => { + const { a, b } = openBare(); + b.focus(); + const e = press("Tab"); + assert.equal(e.defaultPrevented, true); + assert.equal(doc.activeElement, a, "from the last reachable control back to the first"); + const back = press("Tab", { shiftKey: true }); + assert.equal(back.defaultPrevented, true); + assert.equal(doc.activeElement, b); + a.focus(); + assert.equal(press("Tab").defaultPrevented, false, "in the middle, Tab moves on its own"); + assert.equal(press("x").defaultPrevented, false); +}); + +test("a card with nothing focusable leaves Tab alone", () => { + const card = doc.createElement("div"); + const title = doc.createElement("div"); + card.appendChild(title); + d.openModal(() => ({ card: card as never, focusEl: title as never, onClose: () => {} })); + mounted(); + assert.equal(press("Tab").defaultPrevented, false); + assert.equal(overlay()!.hasAttribute("aria-label"), false, "no label given, none invented"); +}); + +test("a route change closes a modal — unless it holds unsaved work, and then it stays registered", () => { + let dirty = true; + const { log } = openBare({ hasUnsavedWork: () => dirty }); + overlays.dismissLayers(); + assert.ok(overlay(), "the half-written form survives a background refresh"); + assert.equal(overlays.openLayerCount(), 1, "…and the registry still knows it is open"); + dirty = false; + overlays.dismissLayers(); + assert.equal(overlay(), null); + assert.deepEqual(log, ["closed"]); +}); + +// ── confirmDialog ──────────────────────────────────────────────────────────── + +test("confirm: title, message, the action's own word, and Confirm resolves true", async () => { + const p = d.confirmDialog({ title: "Revert", message: "Create a revert commit?", confirmLabel: "Revert" }); + mounted(); + const o = overlay()!; + assert.equal(text(o.querySelector(".modal-title")), "Revert"); + assert.equal(text(o.querySelector(".modal-message")), "Create a revert commit?"); + assert.equal(text(okBtn()), "Revert"); + assert.ok(okBtn().classList.contains("btn-primary")); + assert.equal(doc.activeElement, okBtn(), "a safe confirm starts on its button"); + okBtn().click(); + assert.equal(await p, true); + assert.equal(doc.activeElement, opener); +}); + +test("a danger confirm starts on Cancel, so a reflexive Return does not destroy anything", async () => { + const p = d.confirmDialog({ title: "Reset", message: "Discard all changes?", danger: true }); + mounted(); + assert.equal(text(okBtn()), "Confirm"); + assert.ok(okBtn().classList.contains("btn-danger")); + assert.equal(doc.activeElement, cancelBtn()); + cancelBtn().click(); + assert.equal(await p, false); +}); + +test("confirm: Escape and a route change answer false", async () => { + const p1 = d.confirmDialog({ title: "t", message: "m" }); + mounted(); + press("Escape"); + assert.equal(await p1, false); + const p2 = d.confirmDialog({ title: "t", message: "m" }); + mounted(); + overlays.dismissLayers(); + assert.equal(await p2, false); +}); + +test("confirm with requireTyped: Confirm stays off until the exact name is typed", async () => { + const p = d.confirmDialog({ title: "Delete repository", message: "This deletes files.", danger: true, requireTyped: "gitstudio" }); + mounted(); + const field = overlay()!.querySelector(".confirm-typed-input")!; + assert.equal(text(overlay()!.querySelector(".confirm-typed-hint")), "Type gitstudio to confirm."); + assert.notEqual(field.placeholder, "gitstudio", "the answer is not printed inside the box"); + assert.equal(field.getAttribute("aria-label"), "Type gitstudio to confirm"); + assert.equal(doc.activeElement, field, "the dialog starts in the field you must fill in"); + assert.equal(okBtn().hasAttribute("disabled"), true); + okBtn().click(); + press("Enter"); + field.value = "gitstudi"; + fire(field, "input"); + press("Enter"); + assert.ok(overlay(), "neither a click nor Enter gets through before the name matches"); + field.value = " gitstudio "; + fire(field, "input"); + assert.equal(okBtn().hasAttribute("disabled"), false); + const enter = press("Enter"); + assert.equal(enter.defaultPrevented, true); + assert.equal(await p, true); +}); + +test("confirm: an abort from outside closes it and answers false, once", async () => { + const ctl = new AbortController(); + const p = d.confirmDialog({ title: "Run tool?", message: "shell", signal: ctl.signal }); + mounted(); + ctl.abort(); + assert.equal(await p, false); + assert.equal(overlay(), null, "no Approve button left pointing at a finished run"); + + const ctl2 = new AbortController(); + const p2 = d.confirmDialog({ title: "Run tool?", message: "shell", signal: ctl2.signal }); + mounted(); + okBtn().click(); + ctl2.abort(); + assert.equal(await p2, true, "an abort after the answer changes nothing"); +}); + +test("confirm with holdWhile stays up through a background re-route until the user answers", async () => { + let sameRepo = true; + const p = d.confirmDialog({ title: "Abort the rebase?", message: "m", holdWhile: () => sameRepo }); + mounted(); + overlays.dismissLayers(); + assert.ok(overlay()); + sameRepo = false; + overlays.dismissLayers(); + assert.equal(await p, false); +}); + +// ── promptInline ───────────────────────────────────────────────────────────── + +test("prompt: what it shows, and Create resolves the trimmed text", async () => { + const p = d.promptInline("Create branch", "feature/my-branch", " seed "); + mounted(); + const o = overlay()!; + assert.equal(o.getAttribute("aria-label"), "Create branch"); + assert.equal(text(o.querySelector(".modal-title")), "Create branch"); + assert.equal(input().placeholder, "feature/my-branch"); + assert.equal(input().value, " seed "); + assert.equal(text(okBtn()), "Create"); + assert.equal(doc.activeElement, input()); + assert.equal(o.querySelector(".prompt-error"), null, "no validation asked for, none shown"); + input().value = " feat/login "; + okBtn().click(); + assert.equal(await p, "feat/login"); +}); + +test("prompt: Enter submits, an empty answer is a cancel, Escape and Cancel resolve null", async () => { + const p1 = d.promptInline("Tag", "v1.0.0", "", "Tag"); + mounted(); + input().value = "v2.0.0"; + assert.equal(press("Enter").defaultPrevented, true); + assert.equal(await p1, "v2.0.0"); + + const p2 = d.promptInline("Tag", "v1.0.0"); + mounted(); + input().value = " "; + press("Enter"); + assert.equal(await p2, null); + + const p3 = d.promptInline("Tag", "v1.0.0"); + mounted(); + press("Escape"); + assert.equal(await p3, null); + + const p4 = d.promptInline("Tag", "v1.0.0"); + mounted(); + press("a"); + cancelBtn().click(); + assert.equal(await p4, null); +}); + +test("prompt with allowEmpty tells 'cleared' from 'cancelled'", async () => { + const p = d.promptInline("Description", "none", "old", "Save", true); + mounted(); + input().value = " "; + okBtn().click(); + assert.equal(await p, "", "cleared is an empty string"); + const p2 = d.promptInline("Description", "none", "old", "Save", true); + mounted(); + cancelBtn().click(); + assert.equal(await p2, null, "cancelled is null"); +}); + +test("prompt with a hint describes the field with it", async () => { + const p = d.promptInline("Rename", "name", "", "Rename", false, { hint: "Only the local branch is renamed." }); + mounted(); + const hint = overlay()!.querySelector("#gs-prompt-hint")!; + assert.equal(hint.textContent, "Only the local branch is renamed."); + assert.equal(input().getAttribute("aria-describedby"), "gs-prompt-hint"); + cancelBtn().click(); + await p; +}); + +test("prompt with refName validation: says why, disables Create, offers a repaired name", async () => { + const p = d.promptInline("Create branch", "name", "", "Create", false, { validate: "refName", hint: "h" }); + mounted(); + const err = overlay()!.querySelector(".prompt-error")!; + const sug = overlay()!.querySelector(".prompt-suggest")!; + assert.equal(input().getAttribute("aria-describedby"), "gs-prompt-hint gs-prompt-error"); + assert.equal(input().spellcheck, false); + assert.equal(err.hidden, true, "an empty field is not scolded"); + assert.equal(okBtn().hasAttribute("disabled"), true, "…but cannot be submitted either"); + + input().value = "my feature"; + fire(input(), "input"); + assert.equal(err.hidden, false); + assert.equal(err.getAttribute("role"), "alert"); + assert.equal(err.textContent, "Cannot contain spaces."); + assert.equal(input().getAttribute("aria-invalid"), "true"); + assert.ok(input().classList.contains("is-invalid")); + assert.equal(okBtn().hasAttribute("disabled"), true); + assert.equal(sug.hidden, false); + assert.equal(sug.querySelector("code")!.textContent, "my-feature"); + press("Enter"); + assert.ok(overlay(), "Enter with a refused name does nothing"); + + sug.querySelector(".prompt-suggest-use")!.click(); + assert.equal(input().value, "my-feature"); + assert.equal(doc.activeElement, input()); + assert.equal(input().selectionStart, "my-feature".length, "the caret goes to the end"); + assert.equal(err.hidden, true); + assert.equal(sug.hidden, true); + assert.equal(okBtn().hasAttribute("disabled"), false); + okBtn().click(); + assert.equal(await p, "my-feature"); +}); + +test("prompt: the caller's extra rule blocks too, and suggest:false offers no repair", async () => { + const p = d.promptInline("Create branch", "name", "", "Create", false, { + validate: "refName", + suggest: false, + extra: (v) => (v === "main" ? "A branch named main already exists." : null), + }); + mounted(); + input().value = "main"; + fire(input(), "input"); + assert.equal(overlay()!.querySelector(".prompt-error")!.textContent, "A branch named main already exists."); + assert.equal(okBtn().hasAttribute("disabled"), true); + input().value = "a b"; + fire(input(), "input"); + assert.equal(overlay()!.querySelector(".prompt-suggest")!.hidden, true); + input().value = "main2"; + fire(input(), "input"); + assert.equal(okBtn().hasAttribute("disabled"), false); + okBtn().click(); + assert.equal(await p, "main2"); +}); + +test("prompt with a checkbox: the button says what a ticked box will do, and the tick is read back", async () => { + const check = { label: "Check out the new branch", checked: false, okLabelChecked: "Create & checkout" }; + const p = d.promptInline("Create branch", "name", "", "Create", false, { check }); + mounted(); + const box = overlay()!.querySelector(".modal-check input")!; + assert.equal(text(overlay()!.querySelector(".modal-check")), "Check out the new branch"); + assert.equal(box.type, "checkbox"); + assert.equal(text(okBtn()), "Create"); + box.checked = true; + fire(box, "change"); + assert.equal(text(okBtn()), "Create & checkout"); + input().value = "feat/x"; + okBtn().click(); + assert.equal(await p, "feat/x"); + assert.equal(check.checked, true, "the caller reads the box's state back"); +}); + +test("a half-typed prompt survives a background re-route", async () => { + const p = d.promptInline("Create branch", "name", "seed"); + mounted(); + overlays.dismissLayers(); + assert.equal(overlay(), null, "untouched, it closes like any layer"); + assert.equal(await p, null); + + const p2 = d.promptInline("Create branch", "name", "seed"); + mounted(); + input().value = "seed-and-more"; + overlays.dismissLayers(); + assert.ok(overlay(), "typed into, it stays"); + cancelBtn().click(); + assert.equal(await p2, null); +}); + +// ── promptMessage ──────────────────────────────────────────────────────────── + +test("message editor: pre-filled, caret at the start, ⌘/Ctrl+Enter confirms, Enter is a new line", async () => { + const p = d.promptMessage({ title: "Squash 3 commits", hint: "They become one.", value: "first\n\nsecond", okLabel: "Squash" }); + mounted(); + const area = overlay()!.querySelector("textarea")!; + assert.equal(area.value, "first\n\nsecond"); + assert.equal(area.getAttribute("aria-label"), "Commit message"); + assert.equal(area.getAttribute("aria-describedby"), "gs-msg-hint"); + assert.equal(text(overlay()!.querySelector("#gs-msg-hint")), "They become one."); + assert.equal(doc.activeElement, area); + assert.equal(area.selectionStart, 0, "the first keystroke edits, it does not replace"); + assert.equal(area.selectionEnd, 0); + assert.equal(text(okBtn()), "Squash"); + assert.equal(press("Enter").defaultPrevented, false); + assert.ok(overlay()); + area.value = " squashed "; + press("Enter", { metaKey: true }); + assert.equal(await p, "squashed"); + + const p2 = d.promptMessage({ title: "t", hint: "h", value: "x", okLabel: "OK", label: "Tag message" }); + mounted(); + assert.equal(overlay()!.querySelector("textarea")!.getAttribute("aria-label"), "Tag message"); + press("Enter", { ctrlKey: true }); + assert.equal(await p2, "x"); +}); + +test("message editor: an empty message cannot be confirmed; Cancel resolves null", async () => { + const p = d.promptMessage({ title: "t", hint: "h", value: "msg", okLabel: "OK" }); + mounted(); + const area = overlay()!.querySelector("textarea")!; + area.value = " "; + fire(area, "input"); + assert.equal(okBtn().hasAttribute("disabled"), true); + press("Enter", { metaKey: true }); + okBtn().click(); + assert.ok(overlay()); + area.value = "back"; + fire(area, "input"); + assert.equal(okBtn().hasAttribute("disabled"), false); + cancelBtn().click(); + assert.equal(await p, null); +}); + +test("message editor: an edited message, or holdWhile, keeps it through a re-route", async () => { + let hold = true; + const p = d.promptMessage({ title: "t", hint: "h", value: "msg", okLabel: "OK", holdWhile: () => hold }); + mounted(); + overlays.dismissLayers(); + assert.ok(overlay()); + hold = false; + overlay()!.querySelector("textarea")!.value = "edited"; + overlays.dismissLayers(); + assert.ok(overlay(), "edited text is work in progress"); + overlay()!.querySelector("textarea")!.value = "msg"; + overlays.dismissLayers(); + assert.equal(await p, null); +}); + +// ── promptChoice ───────────────────────────────────────────────────────────── + +const choices = [ + { id: "merge", label: "Merge", sub: "Merge origin/main into main", icon: "git-merge" }, + { id: "rebase", label: "Rebase", sub: "Replay your 2 commits on top of origin/main" }, + { id: "ff", label: "Fast-forward only", sub: "Refuse if it cannot" }, +]; + +test("choice: each option shows its label and its full consequence; a pick is the answer", async () => { + const p = d.promptChoice({ title: "Branches have diverged", hint: "Pick one.", choices, cancelId: "cancel" }); + mounted(); + const o = overlay()!; + assert.equal(text(o.querySelector(".modal-message")), "Pick one."); + const rows = o.querySelectorAll(".modal-choice"); + assert.deepEqual(rows.map((r) => text(r.querySelector(".modal-choice-label"))), ["Merge", "Rebase", "Fast-forward only"]); + assert.equal(text(rows[1].querySelector(".modal-choice-sub")), "Replay your 2 commits on top of origin/main"); + assert.ok(rows[0].querySelector(".codicon-git-merge")); + assert.equal(rows[1].querySelector(".glyph"), null); + assert.equal(rows[0].type, "button"); + assert.equal(doc.activeElement, rows[0]); + rows[1].click(); + assert.equal(await p, "rebase"); +}); + +test("choice: arrows wrap, Home/End jump, Enter picks via the focused button", async () => { + const p = d.promptChoice({ title: "t", choices, cancelId: "cancel" }); + mounted(); + const rows = overlay()!.querySelectorAll(".modal-choice"); + assert.equal(overlay()!.querySelector(".modal-message"), null, "no hint, no empty line"); + press("ArrowUp"); + assert.equal(doc.activeElement, rows[2]); + press("ArrowDown"); + assert.equal(doc.activeElement, rows[0]); + press("End"); + assert.equal(doc.activeElement, rows[2]); + press("Home"); + assert.equal(doc.activeElement, rows[0]); + assert.equal(press("x").defaultPrevented, false); + cancelBtn().focus(); + assert.equal(press("ArrowDown").defaultPrevented, false, "keys off the rows are not the list's"); + (doc.activeElement as MiniElement).click(); + assert.equal(await p, "cancel"); +}); + +test("choice: Escape, the backdrop and a re-route all resolve the cancel id — unless held", async () => { + const p1 = d.promptChoice({ title: "t", choices, cancelId: "keep" }); + mounted(); + press("Escape"); + assert.equal(await p1, "keep"); + + const p2 = d.promptChoice({ title: "t", choices, cancelId: "keep" }); + mounted(); + fire(overlay()!, "mousedown"); + assert.equal(await p2, "keep"); + + let hold = true; + const p3 = d.promptChoice({ title: "Rename on origin too?", choices, cancelId: "no", holdWhile: () => hold }); + mounted(); + overlays.dismissLayers(); + assert.ok(overlay(), "the watcher's refresh does not answer for the user"); + hold = false; + overlays.dismissLayers(); + assert.equal(await p3, "no"); +}); + +// ── formWithRetry ──────────────────────────────────────────────────────────── + +test("formWithRetry: a failed submit re-opens the form with what was typed and why", async () => { + const opens: Array<[unknown, unknown]> = []; + const answers = ["draft 1", "draft 2"]; + const out = await d.formWithRetry( + async (seed, error) => { + opens.push([seed, error]); + return answers.shift() ?? null; + }, + async (v) => (v === "draft 1" ? "Validation failed: title too long" : undefined), + ); + assert.equal(out, "draft 2"); + assert.deepEqual(opens, [ + [undefined, undefined], + ["draft 1", "Validation failed: title too long"], + ]); +}); + +test("formWithRetry: cancelling abandons it, and a submit that always fails stops after 20 tries", async () => { + let submits = 0; + assert.equal( + await d.formWithRetry( + async () => null, + async () => { + submits++; + return undefined; + }, + ), + undefined, + ); + assert.equal(submits, 0, "a cancel sends nothing"); + + let opened = 0; + const out = await d.formWithRetry( + async () => { + opened++; + return "x"; + }, + async () => "offline", + ); + assert.equal(out, undefined); + assert.equal(opened, 20); + await settle(); +}); diff --git a/apps/desktop/test/editors.icons.test.ts b/apps/desktop/test/editors.icons.test.ts new file mode 100644 index 00000000..3fa10c83 --- /dev/null +++ b/apps/desktop/test/editors.icons.test.ts @@ -0,0 +1,246 @@ +import { electron } from "./desktopMainFakeElectron"; +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pngFromIcns, withIcons, openEditor, revealRoot, editorsView } from "../src/main/editors"; +import type { EditorView } from "../src/shared/ipc"; +import { removeTempRepo } from "./tmpRepo"; + +// The OS-touching half of "Open in ": reading an icon out of a macOS +// .icns, decorating the editor list with each app's real icon (and never a +// custom command's), launching the pick, and revealing the folder. Electron is +// the stub in desktopMainFakeElectron.ts; the launches are real spawns of this +// test's own node binary, so nothing but node is ever started. + +const dir = mkdtempSync(join(tmpdir(), "gitstudio-editors-")); +after(() => removeTempRepo(dir)); +electron.paths.temp = dir; + +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const png = (tag: string): Buffer => Buffer.concat([PNG_MAGIC, Buffer.from(tag)]); + +/** An .icns container: "icns" + total length, then type(4) length(4) data chunks. */ +function icns(chunks: Array<[string, Buffer]>): Buffer { + const parts = chunks.map(([type, data]) => { + const head = Buffer.alloc(8); + head.write(type, 0, "ascii"); + head.writeUInt32BE(data.length + 8, 4); + return Buffer.concat([head, data]); + }); + const body = Buffer.concat(parts); + const head = Buffer.alloc(8); + head.write("icns", 0, "ascii"); + head.writeUInt32BE(body.length + 8, 4); + return Buffer.concat([head, body]); +} + +let n = 0; +function file(bytes: Buffer): string { + const p = join(dir, `icon-${n++}.icns`); + writeFileSync(p, bytes); + return p; +} + +// ── .icns ────────────────────────────────────────────────────────────────── + +test("the icns reader takes the smallest PNG rep that still covers a retina slot", () => { + const p = file( + icns([ + ["ic10", png("1024")], + ["ic07", png("128")], + ["ic12", png("64@2x")], + ]), + ); + assert.deepEqual(pngFromIcns(p), png("64@2x")); +}); + +test("the icns reader skips non-PNG reps and falls back to any PNG it found", () => { + const argb = Buffer.from("ARGB-not-a-png-at-all"); + const p = file( + icns([ + ["ic12", argb], + ["zzzz", png("odd type")], + ]), + ); + assert.deepEqual(pngFromIcns(p), png("odd type"), "the legacy rep is not served as a PNG"); + assert.equal(pngFromIcns(file(icns([["it32", argb]]))), undefined, "no PNG at all is no icon"); +}); + +test("the icns reader refuses what is not an icns, a missing file and a chunk that overruns", () => { + assert.equal(pngFromIcns(join(dir, "not-there.icns")), undefined); + assert.equal(pngFromIcns(file(Buffer.from("PNG? no, a text file"))), undefined); + assert.equal(pngFromIcns(file(Buffer.from("icn"))), undefined, "shorter than a header"); + + // A chunk whose length runs past the end stops the walk — it is not read + // out of bounds, and the good chunk before it still counts. + const good = icns([["ic07", png("ok")]]); + const bad = Buffer.alloc(8); + bad.write("ic12", 0, "ascii"); + bad.writeUInt32BE(10_000, 4); + assert.deepEqual(pngFromIcns(file(Buffer.concat([good, bad]))), png("ok")); +}); + +// ── icons on the list ────────────────────────────────────────────────────── + +const view = (editors: Array & { id: string }>): { editors: EditorView[]; defaultId?: string } => ({ + editors: editors.map((e) => ({ name: e.id, via: "cli", shown: true, isDefault: false, location: "", ...e }) as EditorView), +}); + +test("each editor wears the OS's icon for its executable; a custom command wears none", async () => { + const code = join(dir, "bin", "code"); + const broken = join(dir, "bin", "vanished"); + const blank = join(dir, "bin", "blank"); + electron.icons[code] = "vscode-mark"; + electron.icons[broken] = new Error("ENOENT"); + electron.icons[blank] = ""; + const v = await withIcons( + view([ + { id: "vscode", location: code }, + { id: "gone", location: broken }, + { id: "blank", location: blank }, + { id: "mine", via: "custom", location: "my-editor --wait {path}" }, + { id: "nowhere", location: "" }, + ]), + ); + const byId = new Map(v.editors.map((e) => [e.id, e])); + assert.equal(byId.get("vscode")?.icon, `data:image/png;base64,${Buffer.from("vscode-mark").toString("base64")}`); + assert.equal(byId.get("gone")?.icon, undefined, "a path that vanished falls back to the glyph"); + assert.equal(byId.get("blank")?.icon, undefined, "an empty image is no icon"); + assert.equal("icon" in (byId.get("mine") ?? {}), false, "a command is not an application"); + assert.equal("icon" in (byId.get("nowhere") ?? {}), false); + assert.equal(electron.iconAsks.includes("my-editor --wait {path}"), false, "never asked the OS about a command line"); + + // Cached by path: the list repaints without asking the OS again. + const asked = electron.iconAsks.length; + electron.icons[code] = "changed"; + const again = await withIcons(view([{ id: "vscode", location: code }])); + assert.equal(electron.iconAsks.length, asked); + assert.equal(again.editors[0].icon, byId.get("vscode")?.icon); +}); + +function bundle(name: string, plist: string | undefined, icon?: Buffer, iconFile = "AppIcon.icns"): string { + const b = join(dir, `${name}.app`); + mkdirSync(join(b, "Contents", "Resources"), { recursive: true }); + if (plist !== undefined) writeFileSync(join(b, "Contents", "Info.plist"), plist); + if (icon) writeFileSync(join(b, "Contents", "Resources", iconFile), icon); + return b; +} + +const plistNaming = (icon: string): string => + `\n\n` + + `CFBundleIconFile${icon}\n`; + +test( + "a macOS bundle's icon is the icns its Info.plist names, decoded from its PNG rep", + { skip: process.platform !== "darwin" && "reads bundles with macOS's own plutil" }, + async () => { + const named = bundle("Named", plistNaming("AppIcon"), icns([["ic12", png("the real mark")]])); + // The name may already carry the extension. + const withExt = bundle("WithExt", plistNaming("Other.icns"), icns([["ic07", png("other mark")]]), "Other.icns"); + const v = await withIcons(view([{ id: "named", via: "app", location: named }, { id: "ext", via: "app", location: withExt }])); + assert.equal(v.editors[0].icon, `data:image/png;base64,${Buffer.from(`png:${png("the real mark").length}@64x64`).toString("base64")}`); + assert.match(v.editors[1].icon ?? "", /^data:image\/png;base64,/); + assert.ok(electron.decoded.includes(PNG_MAGIC.toString("hex")), "the PNG rep was what got decoded"); + assert.equal(electron.iconAsks.includes(named), false, "the generic OS placeholder was never needed"); + }, +); + +test( + "a bundle with no readable icon falls back to the OS's icon for it", + { skip: process.platform !== "darwin" && "reads bundles with macOS's own plutil and sips" }, + async () => { + const noPlist = bundle("NoPlist", undefined); + const noPng = bundle("NoPng", plistNaming("AppIcon"), icns([["it32", Buffer.from("legacy rep only")]])); + const emptyName = bundle("EmptyName", plistNaming("")); + electron.icons[noPlist] = "generic"; + electron.icons[noPng] = "generic-too"; + const v = await withIcons( + view([ + { id: "a", via: "app", location: noPlist }, + { id: "b", via: "app", location: noPng }, + { id: "c", via: "app", location: emptyName }, + ]), + ); + assert.equal(v.editors[0].icon, `data:image/png;base64,${Buffer.from("generic").toString("base64")}`); + assert.equal(v.editors[1].icon, `data:image/png;base64,${Buffer.from("generic-too").toString("base64")}`); + assert.equal(v.editors[2].icon, undefined, "no icon anywhere: the glyph stands in"); + assert.ok(electron.iconAsks.includes(noPng), "sips could not decode it either, so the OS was asked"); + }, +); + +// ── launching ────────────────────────────────────────────────────────────── + +const node = `"${process.execPath}"`; + +/** Wait for a condition, with a generous cap — never a fixed sleep. */ +async function until(cond: () => boolean, what: string, capMs = 15_000): Promise { + const start = Date.now(); + while (!cond()) { + if (Date.now() - start > capMs) assert.fail(`timed out waiting for ${what}`); + await new Promise((r) => setTimeout(r, 25)); + } +} + +test("a custom editor command is launched with the folder substituted for {path}", async () => { + const marker = join(dir, "launched.txt"); + const script = `require('fs').writeFileSync(${JSON.stringify(marker)}, process.argv[1])`; + const prefs = { + hidden: [], + custom: [{ id: "custom-1", name: "Mine", command: `${node} -e "${script.replace(/"/g, "'")}" {path}` }], + }; + const res = await openEditor("custom-1", dir, prefs); + assert.deepEqual(res, { ok: true }); + await until(() => existsSync(marker) && readFileSync(marker, "utf8").length > 0, "the editor to start"); + assert.equal(readFileSync(marker, "utf8"), dir, "it was handed the repository folder"); +}); + +test("a custom editor whose program is not there reports that it could not launch", async () => { + const prefs = { hidden: [], custom: [{ id: "c", name: "Ghost", command: join(dir, "no-such-editor-binary") }] }; + const res = await openEditor("c", dir, prefs); + assert.equal(res.ok, false); + assert.equal(res.expected, undefined, "we found it in Settings and could not start it: that is news"); + assert.match(res.message ?? "", /^Couldn't launch it: /); +}); + +test("a launch the OS refuses outright is answered, not thrown", async () => { + // A NUL cannot be in an argument: spawn throws before any process exists. + const prefs = { hidden: [], custom: [{ id: "c", name: "Mine", command: `${node} {path}` }] }; + const res = await openEditor("c", "bad\0folder", prefs); + assert.equal(res.ok, false); + assert.match(res.message ?? "", /null bytes|\\0|string without null/i); +}); + +test("an editor that is no longer installed is an expected state with a pointer to Settings", async () => { + const res = await openEditor("an-editor-no-machine-has", dir, { hidden: [], custom: [] }); + assert.deepEqual(res, { + ok: false, + expected: true, + message: "That editor isn't installed any more — check Settings ▸ Editors.", + }); +}); + +test("the editor view lists a custom editor, hidden or not, and defaults to the first shown", () => { + const v = editorsView({ + hidden: ["mine-hidden"], + custom: [ + { id: "mine-hidden", name: "Hidden", command: "hidden {path}" }, + { id: "mine", name: "Mine", command: "mine" }, + ], + defaultId: "mine-hidden", + }); + const hidden = v.editors.find((e) => e.id === "mine-hidden"); + const mine = v.editors.find((e) => e.id === "mine"); + assert.equal(hidden?.shown, false); + assert.equal(hidden?.isDefault, false, "a hidden editor cannot be the default"); + assert.equal(mine?.via, "custom"); + assert.equal(mine?.location, "mine"); + assert.ok(v.defaultId, "something shown is the default"); + assert.notEqual(v.defaultId, "mine-hidden"); +}); + +test("Reveal shows the repository folder in the file manager", () => { + revealRoot(dir); + assert.deepEqual(electron.shown, [dir]); +}); diff --git a/apps/desktop/test/focusReturn.dom.test.ts b/apps/desktop/test/focusReturn.dom.test.ts new file mode 100644 index 00000000..0468f82e --- /dev/null +++ b/apps/desktop/test/focusReturn.dom.test.ts @@ -0,0 +1,393 @@ +// Where the keyboard goes when a page changes (focusReturn.ts): +// +// · leaving a list remembers the row you were on, and coming back puts focus +// on it again — once its rows have arrived, per view and per repository tab; +// · a page that replaced another gets focus on its heading (or a fallback); +// · a control destroyed by a rebuild hands focus to its replacement. +// +// Rendered into helpers/miniDom.ts with node:test's fake setTimeout and Date, +// so "the rows arrive 100 ms later" and "the 2.5 s arm window runs out" are +// stated, not slept. + +import { test, before, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { installMiniDom, fire, type MiniElement } from "./helpers/miniDom"; + +const dom = installMiniDom(); +type FR = typeof import("../src/renderer/focusReturn"); +let fr!: FR; +before(async () => { + fr = (await import("../src/renderer/focusReturn")) as FR; + // The listeners are wired by the first navigation, as at boot. + fr.setFocusScope("boot"); +}); + +const doc = dom.document; +let tab = 100; +beforeEach(() => { + mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1_000_000 }); + doc.body.replaceChildren(); + // A fresh tab per test, so no remembered row leaks between them. + fr.setFocusTab(++tab); +}); +afterEach(() => mock.timers.reset()); + +/** + * Let `ms` of fake time pass in small steps. One `mock.timers.tick(n)` moves the + * clock to its end before running what is due, so a poll that re-arms itself + * from inside would be scheduled past the window and run once; stepping runs + * each poll at its own time, as a real clock would. + */ +function advance(ms: number): void { + mock.timers.tick(0); + for (let t = 0; t < ms; t += 8) mock.timers.tick(Math.min(8, ms - t)); +} + +/** A list view: rows with `data-num`, each holding a focusable button. */ +function renderList(nums: string[]): { view: MiniElement; rows: Map } { + const view = doc.createElement("div"); + view.className = "view"; + const rows = new Map(); + for (const n of nums) { + const row = doc.createElement("div"); + row.dataset.num = n; + row.tabIndex = 0; + const open = doc.createElement("button"); + open.textContent = `Open #${n}`; + row.appendChild(open); + view.appendChild(row); + rows.set(n, row); + } + doc.body.replaceChildren(view); + return { view, rows }; +} + +// ── returning to the row ───────────────────────────────────────────────────── + +test("back in a list, focus returns to the row you left — once its rows have arrived", () => { + fr.setFocusScope("issues"); + const first = renderList(["30", "31", "32"]); + // Focus lands on a control INSIDE the row; the row is what is remembered. + first.rows.get("31")!.querySelector("button")!.focus(); + + fr.setFocusScope("issue:31"); + doc.body.replaceChildren(doc.createElement("h1")); + + fr.setFocusScope("issues"); + doc.body.replaceChildren(); // the view is built; its data has not resolved + advance(100); + assert.equal(doc.activeElement, doc.body, "nothing to focus yet"); + const again = renderList(["30", "31", "32"]); + advance(32); + const row = again.rows.get("31")!; + assert.equal(doc.activeElement, row, "the same row, in the rebuilt list"); + assert.equal(row.scrolledIntoView, 1, "and scrolled into view"); +}); + +test("a remembered row that is still hidden waits until it is shown", () => { + fr.setFocusScope("prs"); + renderList(["7"]).rows.get("7")!.focus(); + fr.setFocusScope("pr:7"); + fr.setFocusScope("prs"); + const { view, rows } = renderList(["7"]); + view.hidden = true; + advance(0); + advance(64); + assert.equal(doc.activeElement, doc.body); + view.hidden = false; + advance(32); + assert.equal(doc.activeElement, rows.get("7")); +}); + +test("the restore gives up quietly when the row never comes back", () => { + fr.setFocusScope("issues"); + renderList(["5"]).rows.get("5")!.focus(); + fr.setFocusScope("issue:5"); + fr.setFocusScope("issues"); + renderList(["6"]); // #5 was deleted + advance(2600); + assert.equal(doc.activeElement, doc.body); + const late = renderList(["5"]); + advance(100); + assert.notEqual(doc.activeElement, late.rows.get("5"), "a row arriving after the window does not grab focus"); +}); + +test("rows are remembered per view: returning to another list does not reach for this one's row", () => { + fr.setFocusScope("issues"); + renderList(["12"]).rows.get("12")!.focus(); + fr.setFocusScope("prs"); + const prs = renderList(["12"]); + advance(100); + assert.notEqual(doc.activeElement, prs.rows.get("12"), "PR #12 is not issue #12"); +}); + +test("each repository tab keeps its own rows; a closed tab's are forgotten", () => { + const a = ++tab; + const b = ++tab; + fr.setFocusTab(a); + fr.setFocusScope("issues"); + renderList(["31"]).rows.get("31")!.focus(); + + fr.setFocusTab(b); + fr.setFocusScope("issues"); + const inB = renderList(["31"]); + advance(100); + assert.notEqual(doc.activeElement, inB.rows.get("31"), "issue #31 in one repository is not #31 in another"); + + fr.setFocusTab(a); + fr.setFocusScope("issues"); + const inA = renderList(["31"]); + advance(0); + assert.equal(doc.activeElement, inA.rows.get("31")); + + fr.setFocusScope("issue:31"); + fr.dropFocusTab(a); + fr.setFocusTab(a); // reopened: a fresh tab + fr.setFocusScope("issues"); + const reopened = renderList(["31"]); + advance(100); + assert.notEqual(doc.activeElement, reopened.rows.get("31")); +}); + +test("clearFocusReturn forgets every remembered row (a repository switch)", () => { + fr.setFocusScope("branches"); + renderList(["main"]).rows.get("main")!.focus(); + fr.setFocusScope("branch:main"); + fr.clearFocusReturn(); + fr.setFocusScope("branches"); + const list = renderList(["main"]); + advance(100); + assert.notEqual(doc.activeElement, list.rows.get("main")); +}); + +test("a re-navigation cancels a pending restore before it lands", () => { + fr.setFocusScope("issues"); + renderList(["9"]).rows.get("9")!.focus(); + fr.setFocusScope("issue:9"); + fr.setFocusScope("issues"); + fr.setFocusScope("settings"); // left again before the rows arrived + const list = renderList(["9"]); + advance(100); + assert.notEqual(doc.activeElement, list.rows.get("9")); +}); + +test("focus on something that is not a row records nothing", () => { + fr.setFocusScope("issues"); + const { view } = renderList(["1"]); + const search = doc.createElement("input"); + view.prepend(search); + search.focus(); + fr.setFocusScope("issue:1"); + fr.setFocusScope("issues"); + renderList(["1"]); + advance(100); + assert.equal(doc.activeElement, doc.body); +}); + +// ── the element that really has focus ──────────────────────────────────────── + +test("deepActiveElement walks through open shadow roots to the real focus", () => { + const host = doc.createElement("gitstudio-graph"); + host.tabIndex = 0; + const innerHost = doc.createElement("x-row-list"); + const row = doc.createElement("div"); + doc.body.append(host, innerHost, row); + host.focus(); + assert.equal(fr.deepActiveElement(doc as never), host, "no shadow root: the element itself"); + host.shadowRoot = { activeElement: innerHost }; + innerHost.shadowRoot = { activeElement: row }; + assert.equal(fr.deepActiveElement(doc as never), row); + assert.equal(fr.deepActiveElement(), row, "the global document by default"); + innerHost.shadowRoot = { activeElement: null }; + assert.equal(fr.deepActiveElement(doc as never), innerHost); +}); + +// ── focus on a new page ────────────────────────────────────────────────────── + +test("a new page's heading gets focus, made focusable without joining the Tab order", () => { + const view = doc.createElement("div"); + const h = doc.createElement("h1"); + h.textContent = "Fix the graph #12"; + view.appendChild(h); + doc.body.appendChild(view); + fr.focusNewPage(view as never); + advance(0); + assert.equal(doc.activeElement, h); + assert.equal(h.getAttribute("tabindex"), "-1"); +}); + +test("a page attached and titled late still gets focus when its heading arrives", () => { + const view = doc.createElement("div"); + fr.focusNewPage(view as never); + advance(0); + advance(32 * 3); + doc.body.appendChild(view); + advance(32 * 2); + const title = doc.createElement("div"); + title.className = "det-title"; + title.tabIndex = 0; + view.appendChild(title); + advance(32); + assert.equal(doc.activeElement, title); + assert.equal(title.getAttribute("tabindex"), "0", "an existing tabindex is left alone"); +}); + +test("with no heading, the fallback control gets focus — a button needs no tabindex", () => { + const view = doc.createElement("div"); + const back = doc.createElement("button"); + view.appendChild(back); + doc.body.appendChild(view); + fr.focusNewPage(view as never, back as never); + advance(0); + assert.equal(doc.activeElement, back); + assert.equal(back.hasAttribute("tabindex"), false); +}); + +test("a page never steals focus from something the user is already typing in", () => { + const view = doc.createElement("div"); + const box = doc.createElement("textarea"); + const h = doc.createElement("h1"); + view.append(box, h); + doc.body.appendChild(view); + fr.focusNewPage(view as never); + box.focus(); + advance(0); + assert.equal(doc.activeElement, box); +}); + +test("a page that never attaches is given up on after about 60 tries", () => { + const view = doc.createElement("div"); + view.appendChild(doc.createElement("h1")); + fr.focusNewPage(view as never); + advance(0); + for (let i = 0; i < 70; i++) advance(32); + doc.body.appendChild(view); + advance(32 * 5); + assert.equal(doc.activeElement, doc.body, "a view replaced long ago does not pull focus back"); +}); + +// ── the rebuilt control ────────────────────────────────────────────────────── + +/** Focus `lost`, then do what a rebuild does: it blurs as it is torn down. */ +function tearDown(lost: MiniElement): void { + lost.focus(); + fire(lost, "focusout"); + lost.remove(); +} + +function button(opts: { cls?: string; text?: string; title?: string; label?: string; num?: string; tag?: string } = {}): MiniElement { + const b = doc.createElement(opts.tag ?? "button"); + if (opts.cls) b.className = opts.cls; + if (opts.text) b.textContent = opts.text; + if (opts.title) b.title = opts.title; + if (opts.label) b.setAttribute("aria-label", opts.label); + if (opts.num) b.dataset.num = opts.num; + return b; +} + +test("a control destroyed by a rebuild hands focus to its replacement — matched by what it acts on", () => { + fr.setFocusScope("changes"); + const list = doc.createElement("div"); + doc.body.appendChild(list); + const oldA = button({ text: "Stage", title: "Stage", num: "src/a.ts" }); + const oldB = button({ text: "Stage", title: "Stage", num: "src/b.ts" }); + list.append(oldA, oldB); + tearDown(oldB); + oldA.remove(); + // The list comes back a poll or two later. + advance(32); + assert.equal(doc.activeElement, doc.body); + const newA = button({ text: "Stage", title: "Stage", num: "src/a.ts" }); + const newB = button({ text: "Stage", title: "Stage", num: "src/b.ts" }); + list.append(newA, newB); + advance(32); + assert.equal(doc.activeElement, newB, "the SAME file's button, not the first Stage in the list"); +}); + +test("matching falls back to title, then aria-label, then base class + text", () => { + const cases: Array<[MiniElement, MiniElement, MiniElement]> = [ + [button({ title: "Refresh" }), button({ title: "Other" }), button({ title: "Refresh" })], + [button({ label: "Close tab" }), button({ label: "Close" }), button({ label: "Close tab" })], + // Flipping Releases → Tags rebuilds the segment and moves `active`: the + // base class and the words still say it is the same control. + [button({ cls: "seg active", text: "Tags" }), button({ cls: "chip", text: "Tags" }), button({ cls: "seg", text: " Tags " })], + ]; + for (const [i, [lost, decoy, twin]] of cases.entries()) { + doc.body.replaceChildren(); + doc.body.appendChild(lost); + tearDown(lost); + doc.body.append(decoy, twin); + advance(32); + assert.equal(doc.activeElement, twin, `case ${i}`); + } +}); + +test("no equivalent — different tag, or nothing to identify it by — means no rescue", () => { + const lost = button({ text: "Open" }); + doc.body.appendChild(lost); + tearDown(lost); + doc.body.append(button({ tag: "a", text: "Open" })); + advance(32 * 30); + assert.equal(doc.activeElement, doc.body); + + const blank = button({}); + doc.body.appendChild(blank); + tearDown(blank); + doc.body.append(button({})); + advance(32 * 30); + assert.equal(doc.activeElement, doc.body, "an empty button is not 'the same' as another empty one"); +}); + +test("a twin with no layout box is skipped — except a text field, which may be fixed-position", () => { + const lost = button({ title: "Filter" }); + doc.body.appendChild(lost); + tearDown(lost); + const hiddenTwin = button({ title: "Filter" }); + hiddenTwin.hidden = true; + doc.body.appendChild(hiddenTwin); + advance(32); + assert.equal(doc.activeElement, doc.body, "a button with no offsetParent is not on screen"); + + // An answers offsetParent === null when it is position: fixed (the + // palette's field); the fake models "no offsetParent" with a hidden wrapper. + const field = button({ tag: "input", title: "Search" }); + doc.body.appendChild(field); + tearDown(field); + const wrap = doc.createElement("div"); + wrap.hidden = true; + const twinField = button({ tag: "input", title: "Search" }); + wrap.appendChild(twinField); + doc.body.appendChild(wrap); + assert.equal(twinField.offsetParent, null); + advance(32); + assert.equal(doc.activeElement, twinField); +}); + +test("no rescue when the user simply moved on, or the control still exists", () => { + const lost = button({ title: "Stage all" }); + const elsewhere = button({ title: "Commit" }); + doc.body.append(lost, elsewhere); + lost.focus(); + fire(lost, "focusout"); + advance(32); + assert.equal(doc.activeElement, lost, "still in the document: the user clicked away, nothing to do"); + + lost.focus(); + fire(lost, "focusout"); + lost.remove(); + elsewhere.focus(); + doc.body.append(button({ title: "Stage all" })); + advance(32); + assert.equal(doc.activeElement, elsewhere, "focus already went somewhere real"); +}); + +test("the rescue stops looking after 25 polls", () => { + const lost = button({ title: "Push" }); + doc.body.appendChild(lost); + tearDown(lost); + advance(32 * 26); + const twin = button({ title: "Push" }); + doc.body.appendChild(twin); + advance(32 * 5); + assert.equal(doc.activeElement, doc.body); +}); diff --git a/apps/desktop/test/ghFakeApi.ts b/apps/desktop/test/ghFakeApi.ts new file mode 100644 index 00000000..5dee2900 --- /dev/null +++ b/apps/desktop/test/ghFakeApi.ts @@ -0,0 +1,105 @@ +// A fake api.github.com for the desktop's GitHub tests: it replaces the global +// fetch, answers each request from a route table keyed "METHOD /path?query", +// and records every request so a test can assert what was SENT — method, path, +// JSON body, headers — as well as what the code made of the answer. +// +// A request with no route answers 404 with a message naming it (and is listed +// in `unmatched`), so a path built wrong fails loudly instead of quietly +// reading as "not found". + +import type { TestContext } from "node:test"; +import { GitHubClient } from "../src/main/githubClient"; + +const API = "https://api.github.com"; + +export interface FakeCall { + method: string; + /** The full URL fetched. */ + url: string; + /** The URL relative to api.github.com (the full URL for any other host). */ + path: string; + /** The JSON-decoded body, the raw body when it is not a string, or undefined. */ + body: unknown; + headers: Record; +} + +/** A route's answer: a Response as-is, an Error thrown (a network failure), + * anything else served as JSON at HTTP 200. A function is called per request. */ +export type FakeReply = unknown | ((call: FakeCall) => unknown | Promise); + +export interface FakeGitHub { + calls: FakeCall[]; + unmatched: string[]; + client: GitHubClient; + /** The calls made with this method + path (exact). */ + sent(method: string, path: string): FakeCall[]; + /** Add or replace a route. */ + route(key: string, reply: FakeReply): void; +} + +/** A JSON response at `status`, with optional extra headers (e.g. `link`). */ +export function reply(status: number, json?: unknown, headers: Record = {}): Response { + if (status === 204) return new Response(null, { status, headers }); + return new Response(json === undefined ? "" : JSON.stringify(json), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +} + +/** A 200 page of a paged list with a rel="next" link to `next` (an API path). */ +export function page(items: unknown, next?: string): Response { + return reply(200, items, next ? { link: `<${API}${next}>; rel="next", <${API}/last>; rel="last"` } : {}); +} + +/** Base64, as the contents API inlines file bodies. */ +export function b64(text: string | Uint8Array): string { + return Buffer.from(text).toString("base64"); +} + +export function fakeGitHub( + t: TestContext, + routes: Record = {}, + /** null: a client with no token (signed out). */ + token: string | null = "ghp_test", +): FakeGitHub { + const table = new Map(Object.entries(routes)); + const calls: FakeCall[] = []; + const unmatched: string[] = []; + const real = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + const method = (init?.method ?? "GET").toUpperCase(); + const path = url.startsWith(API) ? url.slice(API.length) : url; + let body: unknown = init?.body ?? undefined; + if (typeof body === "string") { + try { + body = JSON.parse(body); + } catch { + /* keep the raw string */ + } + } + const headers = { ...((init?.headers as Record | undefined) ?? {}) }; + const call: FakeCall = { method, url, path, body, headers }; + calls.push(call); + const key = `${method} ${path}`; + if (!table.has(key)) { + unmatched.push(key); + return reply(404, { message: `no fake route: ${key}` }); + } + const r = table.get(key); + const v = typeof r === "function" ? await (r as (c: FakeCall) => unknown)(call) : r; + if (v instanceof Error) throw v; + if (v instanceof Response) return v; + return reply(200, v); + }) as typeof fetch; + t.after(() => { + globalThis.fetch = real; + }); + return { + calls, + unmatched, + client: new GitHubClient(() => token ?? undefined), + sent: (method, path) => calls.filter((c) => c.method === method && c.path === path), + route: (key, r) => void table.set(key, r), + }; +} diff --git a/apps/desktop/test/ghFakeElectron.ts b/apps/desktop/test/ghFakeElectron.ts new file mode 100644 index 00000000..94cd10c1 --- /dev/null +++ b/apps/desktop/test/ghFakeElectron.ts @@ -0,0 +1,50 @@ +// A stand-in for the `electron` module, for main-process code that reads +// `app.getPath` or calls `shell` — the GitHub bridge's token store, the +// updater's installer download. Outside Electron, `require("electron")` is the +// path of the binary (a string), so `app` is undefined and any such call throws. +// +// IMPORT THIS FIRST, before the module under test: it seeds the require cache +// entry `electron` resolves to, so the source's `import { app } from +// "electron"` gets this object. Each test file runs in its own process, so the +// stub never leaks into another file. + +import { createRequire } from "node:module"; + +export const electron = { + /** app.getPath answers from here; an unknown name throws, as a guard. */ + paths: {} as Record, + version: "0.0.0-test", + /** shell.openExternal URLs, shell.openPath paths, shell.showItemInFolder paths. */ + externals: [] as string[], + opened: [] as string[], + shown: [] as string[], + /** What shell.openPath answers: "" is success, anything else the OS's error. */ + openPathAnswer: "", +}; + +const fake = { + app: { + getPath(name: string): string { + const p = electron.paths[name]; + if (!p) throw new Error(`test electron stub: no path for "${name}"`); + return p; + }, + getVersion: (): string => electron.version, + }, + shell: { + openExternal: async (url: string): Promise => { + electron.externals.push(url); + }, + openPath: async (path: string): Promise => { + electron.opened.push(path); + return electron.openPathAnswer; + }, + showItemInFolder: (path: string): void => { + electron.shown.push(path); + }, + }, +}; + +const req = createRequire(__filename); +const id = req.resolve("electron"); +req.cache[id] = { id, filename: id, loaded: true, exports: fake } as unknown as NodeJS.Module; diff --git a/apps/desktop/test/gitBridge.noRepo.test.ts b/apps/desktop/test/gitBridge.noRepo.test.ts new file mode 100644 index 00000000..951aa24e --- /dev/null +++ b/apps/desktop/test/gitBridge.noRepo.test.ts @@ -0,0 +1,156 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { GitBridge } from "../src/main/gitBridge"; +import type { RepoStore } from "../src/main/repoStore"; +import type { CommitActionResult } from "../src/shared/ipc"; + +// The app starts with no repository open, and a tab can close under a request +// already in flight. Every channel must then answer as a STATE: reads come back +// empty (never a throw the renderer paints as "Couldn't load"), and writes are +// refused with `expected: true` — the flag that keeps "no repository open" out +// of the crash reporter (main/expectedError.ts). A write that answered ok, or +// a refusal without `expected`, would file a report every time someone clicked +// before opening a folder. + +const noRepo = new GitBridge({ + getContext: () => undefined, + current: () => undefined, + state: () => ({ tabs: [], active: undefined }), + runnerOptions: () => ({ gitPath: "git", onRun: () => undefined }), +} as unknown as RepoStore); + +function refusedAsState(r: CommitActionResult | { ok: boolean; expected?: boolean; message?: string }, what: string): void { + assert.equal(r.ok, false, `${what} must not claim success with no repository`); + assert.equal(r.expected, true, `${what}: no repository open is a state, not a crash`); + assert.match(r.message ?? "", /no repository/i, `${what} says why`); + if ("changed" in r) assert.equal(r.changed, false, `${what} changed nothing`); +} + +test("with no repository open, the graph answers an empty page with an empty ref list", async () => { + const page = await noRepo.graphLoad({ skip: 0 }); + assert.deepEqual(page.rows, []); + assert.equal(page.head, ""); + assert.equal(page.hasMore, false); + assert.equal(page.nextSkip, 0); + assert.equal(page.refFilter, null); + assert.deepEqual(page.refList, [], "a renderer holding no list is sent the (empty) list"); + + // A renderer that already holds the empty list is not sent it again. + const again = await noRepo.graphLoad({ skip: 0, refListSig: page.refListSig }); + assert.equal("refList" in again, false); + assert.equal(again.refListSig, page.refListSig); +}); + +test("with no repository open, every read answers empty rather than throwing", async () => { + assert.deepEqual(await noRepo.refsContains("abc1234"), { branches: [], refs: [], truncated: false }); + assert.deepEqual(await noRepo.refsList(), []); + assert.equal(await noRepo.head(), undefined); + assert.deepEqual(await noRepo.commitBranches("abc1234"), { branches: [], onCurrent: false }); + assert.equal(await noRepo.commitDetails("abc1234"), undefined); + assert.deepEqual(await noRepo.rowStats(["abc1234"]), []); + assert.deepEqual(await noRepo.status(), []); + assert.deepEqual(await noRepo.diffFiles(), []); + assert.equal(await noRepo.fileDiff({ path: "a.txt" }), undefined); + assert.equal(await noRepo.conflictModel("a.txt"), undefined); + assert.equal(await noRepo.blameFile("a.txt"), undefined); + assert.deepEqual(await noRepo.discardSnapshot(), {}); + assert.deepEqual(await noRepo.stashList(), []); + assert.deepEqual(await noRepo.hunksList("a.txt"), []); + assert.deepEqual(await noRepo.worktreeList(), []); + assert.deepEqual(await noRepo.worktreeRemoval({ path: "/somewhere" }), { kind: "notListed" }); + assert.equal(await noRepo.compareRefs({ base: "main", head: "topic" }), undefined); + assert.equal(await noRepo.compareFileDiff({ base: "main", head: "topic", path: "a.txt" }), undefined); + assert.equal(await noRepo.headCommit({ count: true }), undefined); + assert.deepEqual(await noRepo.treeList({ path: "" }), []); + assert.equal(await noRepo.fileText({ path: "README.md" }), undefined); + assert.deepEqual(await noRepo.syncStatus(), { ahead: 0, behind: 0, noUpstream: true }); + assert.deepEqual(await noRepo.branchesList(), []); + assert.deepEqual(await noRepo.refLog({ ref: "main" }), []); + assert.deepEqual(await noRepo.branchesPeople(), {}); + assert.deepEqual(await noRepo.conflictList(), []); + assert.deepEqual(await noRepo.graphReaches("abc1234"), { reached: true }); + + const conflicts = await noRepo.conflictState(); + assert.deepEqual(conflicts.files, []); + assert.equal(conflicts.total, 0); + assert.equal(conflicts.op.kind, "none"); + + const op = await noRepo.opState(); + assert.equal(op.kind, null); + assert.equal(op.canContinue, false); + assert.equal(op.canSkip, false); + assert.equal(op.conflicts, 0); + assert.equal(op.merging || op.rebasing || op.cherryPicking || op.reverting || op.amApplying, false); +}); + +test("with no repository open, every write is refused as an expected state", async () => { + const writes: Array<[string, () => Promise]> = [ + ["stage", () => noRepo.stage("a.txt")], + ["unstage", () => noRepo.unstage("a.txt")], + ["discard", () => noRepo.discard("a.txt")], + ["stageAll", () => noRepo.stageAll()], + ["unstageAll", () => noRepo.unstageAll()], + ["discardUndo", () => noRepo.discardUndo({ sha: "abc1234", paths: ["a.txt"] })], + ["commit", () => noRepo.commit({ message: "hello" })], + ["blocksSet", () => noRepo.blocksSet({ path: "a.txt", block: { index: 0 } as never, staged: true })], + ["stashApply", () => noRepo.stashApply("abc1234")], + ["stashPop", () => noRepo.stashPop({ ref: "abc1234" })], + ["stashDrop", () => noRepo.stashDrop("abc1234")], + ["stashRestore", () => noRepo.stashRestore({ sha: "abc1234" })], + ["stashSave", () => noRepo.stashSave({ message: "wip" })], + ["hunksStage", () => noRepo.hunksStage({ path: "a.txt", index: 0 })], + ["worktreeAdd", () => noRepo.worktreeAdd("/tmp/wt", "main")], + ["syncFetch", () => noRepo.syncFetch()], + ["syncPull", () => noRepo.syncPull()], + ["syncPush", () => noRepo.syncPush(undefined)], + ["branchPullFf", () => noRepo.branchPullFf("refs/heads/main")], + ["branchResetPlan", () => noRepo.branchResetPlan({ fullName: "refs/heads/main" })], + ["branchResetToUpstream", () => noRepo.branchResetToUpstream({ fullName: "refs/heads/main" } as never)], + ["branchResetUndo", () => noRepo.branchResetUndo({ fullName: "refs/heads/main" } as never)], + ["commitAction", () => noRepo.commitAction({ action: "checkout", sha: "abc1234" })], + ["amAbort", () => noRepo.amAbort()], + ["amContinue", () => noRepo.amContinue()], + ["mergeAbort", () => noRepo.mergeAbort()], + ["rebaseContinue", () => noRepo.rebaseContinue()], + ["rebaseSkip", () => noRepo.rebaseSkip()], + ["tagCreate", () => noRepo.tagCreate({ name: "v1" })], + ["tagRestore", () => noRepo.tagRestore({ name: "v1", sha: "abc1234" })], + ["tagPush", () => noRepo.tagPush({ name: "v1" })], + ["branchRestoreRemote", () => noRepo.branchRestoreRemote({ remote: "origin", name: "x", sha: "abc1234" })], + ["stageLines", () => noRepo.stageLines({ path: "a.txt", lines: [1] })], + ["conflictResolve", () => noRepo.conflictResolve({ path: "a.txt", content: "x" })], + ["conflictTakeSide", () => noRepo.conflictTakeSide({ path: "a.txt", side: "ours" })], + ["conflictTakeRole", () => noRepo.conflictTakeRole({ path: "a.txt", role: "yours" })], + ["conflictRestore", () => noRepo.conflictRestore({ path: "a.txt" })], + ["conflictDelete", () => noRepo.conflictDelete({ path: "a.txt" })], + ]; + for (const [what, run] of writes) refusedAsState(await run(), what); +}); + +test("with no repository open, the operation verbs refuse as not-allowed and say why", async () => { + for (const verb of [() => noRepo.opContinue({ confirmDrop: true }), () => noRepo.opSkip(), () => noRepo.opAbort()]) { + const r = await verb(); + assert.equal(r.ok, false); + assert.equal(r.expected, true); + assert.equal((r as { refused?: string }).refused, "not-allowed"); + assert.equal(r.view.kind, "none"); + assert.equal(r.remainingConflicts, 0); + } +}); + +test("with no repository open, deleting a branch, tag or remote branch refuses without recording an undo", async () => { + const branch = await noRepo.branchDelete({ fullName: "refs/heads/topic" }); + refusedAsState(branch, "branchDelete"); + assert.equal(branch.was, undefined, "no tip was read, so there is nothing to put back"); + + const tag = await noRepo.tagDelete("v1"); + refusedAsState(tag, "tagDelete"); + assert.equal(tag.was, undefined); + + const remote = await noRepo.branchDeleteRemote({ remote: "origin", name: "topic" }); + refusedAsState(remote, "branchDeleteRemote"); + assert.equal(remote.was, undefined); + + const made = await noRepo.branchCreate({ name: "x", upstream: "origin/x" }); + refusedAsState(made, "branchCreate"); +}); diff --git a/apps/desktop/test/gitBridge.ops.test.ts b/apps/desktop/test/gitBridge.ops.test.ts new file mode 100644 index 00000000..d60fd21f --- /dev/null +++ b/apps/desktop/test/gitBridge.ops.test.ts @@ -0,0 +1,544 @@ +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readdirSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { initRepo, bareRemote, gitIn, type BridgeRepo } from "./gitBridgeFixture"; + +// The graph's commit menu, the Branches view's tag and push verbs, the +// operation banner's verbs, and the conflict dashboard's writes — each checked +// by what the repository looks like afterwards, and by which failures come +// back `expected` (a state the user is in) versus reported (something broke). + +let r: BridgeRepo | undefined; +afterEach(() => { + r?.cleanup(); + r = undefined; +}); + +function repo(prefix = "ops"): BridgeRepo { + r = initRepo(prefix); + return r; +} + +function threeCommits(t: BridgeRepo): string[] { + const shas: string[] = []; + for (const [i, v] of ["one", "two", "three"].entries()) { + t.write("a.txt", `${v}\n`); + t.write(`f${i}.txt`, `${v}\n`); + shas.push(t.commitAll(v)); + } + return shas; +} + +/** Three commits that each add their own file, so any one reverts cleanly. */ +function separateCommits(t: BridgeRepo): string[] { + return ["one", "two", "three"].map((v) => { + t.write(`${v}.txt`, `${v}\n`); + return t.commitAll(v); + }); +} + +const head = (t: BridgeRepo): string => t.git("rev-parse", "HEAD").trim(); + +// ── commit menu ───────────────────────────────────────────────────────────── + +test("the three resets move the branch and treat the index and working tree as named", async () => { + const t = repo(); + const [first, , third] = threeCommits(t); + void third; + + t.write("a.txt", "dirty\n"); + const soft = await t.bridge.commitAction({ action: "reset-soft", sha: first }); + assert.deepEqual(soft, { ok: true, changed: true }); + assert.equal(head(t), first); + assert.match(t.git("diff", "--cached", "--name-only"), /f1\.txt/, "soft keeps the undone commits staged"); + assert.equal(readFileSync(join(t.repo, "a.txt"), "utf8"), "dirty\n"); + + const mixed = await t.bridge.commitAction({ action: "reset-mixed", sha: first }); + assert.equal(mixed.ok, true); + assert.equal(t.git("diff", "--cached", "--name-only").trim(), "", "mixed empties the index"); + assert.equal(readFileSync(join(t.repo, "a.txt"), "utf8"), "dirty\n", "and keeps the working tree"); + + const hard = await t.bridge.commitAction({ action: "reset-hard", sha: first }); + assert.equal(hard.ok, true); + assert.equal(readFileSync(join(t.repo, "a.txt"), "utf8"), "one\n", "hard puts the files back"); +}); + +test("branch and tag from the commit menu are made at that commit", async () => { + const t = repo(); + const [first] = threeCommits(t); + assert.deepEqual(await t.bridge.commitAction({ action: "branch", sha: first, name: "from-first" }), { ok: true, changed: true }); + assert.equal(t.git("rev-parse", "refs/heads/from-first").trim(), first); + assert.deepEqual(await t.bridge.commitAction({ action: "tag", sha: first, name: "at-first" }), { ok: true, changed: true }); + assert.equal(t.git("rev-parse", "refs/tags/at-first").trim(), first); + + // A name git rejects is git's refusal, said. + const bad = await t.bridge.commitAction({ action: "branch", sha: first, name: "has space" }); + assert.equal(bad.ok, false); + assert.match(bad.message ?? "", /not a valid branch name/); +}); + +test("the commit menu refuses option-shaped values and runs nothing for copy-sha", async () => { + const t = repo(); + const [first] = threeCommits(t); + const before = head(t); + assert.equal((await t.bridge.commitAction({ action: "reset-hard", sha: "--merge" })).ok, false); + assert.equal((await t.bridge.commitAction({ action: "branch", sha: first, name: "-D" })).ok, false); + assert.equal((await t.bridge.commitAction({ action: "tag", sha: first })).ok, false, "a tag needs a name"); + assert.deepEqual(await t.bridge.commitAction({ action: "copy-sha", sha: "" }), { ok: true, changed: false }); + assert.equal(head(t), before); +}); + +test("reverting a commit whose change is already undone is git declining, not a crash", async () => { + const t = repo(); + const [, second] = separateCommits(t); + t.git("revert", "--no-edit", second); + const res = await t.bridge.commitAction({ action: "revert", sha: second }); + assert.equal(res.ok, false); + assert.equal(res.expected, true); + assert.ok((res.message ?? "").length > 0, "git's explanation, not a blank toast"); +}); + +test("a cherry-pick that stops on a conflict says so as a state, with the conflict left to resolve", async () => { + const t = repo(); + t.write("a.txt", "base\n"); + t.commitAll("base"); + t.git("checkout", "-q", "-b", "topic"); + t.write("a.txt", "topic\n"); + const pick = t.commitAll("topic edit"); + t.git("checkout", "-q", "main"); + t.write("a.txt", "main\n"); + t.commitAll("main edit"); + const res = await t.bridge.commitAction({ action: "cherry-pick", sha: pick }); + assert.equal(res.ok, false); + assert.equal(res.expected, true); + assert.match(res.message ?? "", /conflict/i); + const state = await t.bridge.opState(); + assert.equal(state.cherryPicking, true); + assert.equal(state.kind, "cherry-pick"); + assert.equal(state.conflicts, 1); + + const abort = await t.bridge.cherryPickAbort(); + assert.equal(abort.ok, true, abort.message); + assert.equal((await t.bridge.opState()).kind, null); + assert.equal(readFileSync(join(t.repo, "a.txt"), "utf8"), "main\n"); +}); + +test("checkout of a ref refuses an option-shaped name and a request without its full name", async () => { + const t = repo(); + const [first] = threeCommits(t); + const unsafe = await t.bridge.commitAction({ action: "checkout-ref", sha: first, name: "-f", fullName: "refs/heads/-f" }); + assert.equal(unsafe.ok, false); + const noName = await t.bridge.commitAction({ action: "checkout-ref", sha: first, name: "" }); + assert.match(noName.message ?? "", /valid git reference/); + const flagFull = await t.bridge.commitAction({ action: "checkout-ref", sha: first, name: "main", fullName: "--orphan" }); + assert.match(flagFull.message ?? "", /valid git reference/); + const noFull = await t.bridge.commitAction({ action: "checkout-ref", sha: first, name: "main" }); + assert.equal(noFull.ok, false); + assert.match(noFull.message ?? "", /Couldn't tell which main to check out/); +}); + +test("checking out a branch while a conflict is unresolved answers in the operation's words", async () => { + const t = repo(); + t.write("a.txt", "base\n"); + t.commitAll("base"); + t.git("branch", "other"); + t.git("checkout", "-q", "-b", "topic"); + t.write("a.txt", "topic\n"); + t.commitAll("topic"); + t.git("checkout", "-q", "main"); + t.write("a.txt", "main\n"); + t.commitAll("main"); + t.gitTry("merge", "topic"); + const sha = t.git("rev-parse", "other").trim(); + const res = await t.bridge.commitAction({ action: "checkout-ref", sha, name: "other", fullName: "refs/heads/other" }); + assert.equal(res.ok, false); + assert.equal(res.expected, true); + assert.match(res.message ?? "", /conflict/i); + assert.equal(t.git("symbolic-ref", "--short", "HEAD").trim(), "main", "still on main, mid-merge"); +}); + +test("picking several commits refuses unreadable shas and a merge among them", async () => { + const t = repo(); + const [first, second] = separateCommits(t); + const ghost = "f".repeat(40); + const unreadable = await t.bridge.commitAction({ action: "cherry-pick", sha: first, shas: [first, ghost] }); + assert.equal(unreadable.ok, false); + assert.equal(unreadable.expected, true); + assert.match(unreadable.message ?? "", /could not be read any more/); + + t.git("checkout", "-q", "-b", "side", first); + t.write("side.txt", "side\n"); + t.commitAll("side"); + t.git("checkout", "-q", "main"); + t.git("merge", "-q", "--no-ff", "--no-edit", "side"); + const merge = head(t); + t.git("checkout", "-q", "-b", "target", first); + const before = head(t); + const withMerge = await t.bridge.commitAction({ action: "cherry-pick", sha: second, shas: [second, merge] }); + assert.equal(withMerge.ok, false); + assert.equal(withMerge.expected, true); + assert.match(withMerge.message ?? "", new RegExp(`${merge.slice(0, 7)} is a merge commit`)); + assert.equal(head(t), before, "nothing was picked"); + + // Duplicates collapse to one sha: not "several", and not guessed at either. + const one = await t.bridge.commitAction({ action: "revert", sha: second, shas: [second, second] }); + assert.equal(one.ok, false); + assert.match(one.message ?? "", /valid git reference/); + assert.equal(head(t), before); +}); + +test("reverting several commits that git declines is reported with its reason", async () => { + const t = repo(); + const [, second, third] = separateCommits(t); + // Both already reverted: the revert run has nothing to do on the first one. + t.git("revert", "--no-edit", third); + t.git("revert", "--no-edit", second); + const res = await t.bridge.commitAction({ action: "revert", sha: second, shas: [second, third] }); + assert.equal(res.ok, false); + assert.ok((res.message ?? "").length > 0); +}); + +// ── branches: push / delete / default ────────────────────────────────────── + +test("pushing an unpublished branch with no remote, or with several, says why instead of guessing", async () => { + const t = repo(); + threeCommits(t); + const none = await t.bridge.branchPush("refs/heads/main"); + assert.equal(none.ok, false); + assert.match(none.message ?? "", /No remote is configured, so 'main' can't be published/); + + bareRemote(t, "alpha"); + bareRemote(t, "beta"); + const several = await t.bridge.branchPush("refs/heads/main"); + assert.equal(several.ok, false); + assert.match(several.message ?? "", /Several remotes are configured/); + + // The only remote is taken when there is exactly one. + t.git("remote", "remove", "beta"); + const one = await t.bridge.branchPush("refs/heads/main"); + assert.equal(one.ok, true, one.message); + assert.equal(t.git("rev-parse", "--abbrev-ref", "main@{upstream}").trim(), "alpha/main"); +}); + +test("the branch list measures merged against the default branch another remote names", async () => { + const t = repo(); + const [first] = threeCommits(t); + t.git("branch", "merged-one", first); + t.git("checkout", "-q", "-b", "ahead", first); + t.write("x.txt", "x\n"); + t.commitAll("ahead work"); + t.git("checkout", "-q", "main"); + // Only an "upstream" remote, whose HEAD points at main. + bareRemote(t, "upstream"); + t.git("push", "-q", "upstream", "main"); + t.git("symbolic-ref", "refs/remotes/upstream/HEAD", "refs/remotes/upstream/main"); + + const list = await t.bridge.branchesList(); + const byName = new Map(list.map((b) => [b.name, b])); + assert.equal(byName.get("main")?.isDefault, true); + assert.equal(byName.get("merged-one")?.merged, true); + assert.equal(byName.get("merged-one")?.behindDefault, 2); + assert.equal(byName.get("ahead")?.merged, false); + assert.equal(byName.get("ahead")?.aheadDefault, 1); + assert.deepEqual(byName.get("ahead")?.tipAuthor, { name: "Dev", email: "dev@example.com" }); +}); + +test("with the default branch unreadable the list still loads, with no divergence bars", async () => { + const t = repo(); + threeCommits(t); + t.git("branch", "topic"); + const run = t.ctx.process.run.bind(t.ctx.process); + // Every read of the default branch fails; the list itself still works. + t.ctx.process.run = async (args, opts) => { + if (args[0] === "for-each-ref" && args.includes("refs/remotes/*/HEAD")) throw new Error("spawn failed"); + return run(args, opts); + }; + t.ctx.refs.getHead = async () => { + throw new Error("HEAD unreadable"); + }; + const list = await t.bridge.branchesList(); + assert.deepEqual(list.map((b) => b.name).sort(), ["main", "topic"]); + assert.equal(list.some((b) => b.merged !== undefined || b.isDefault), false, "nothing to measure against"); + assert.deepEqual(await t.bridge.branchesPeople(), {}, "and no base to find creators from"); +}); + +test("an older git without ahead-behind leaves the bars off rather than failing the list", async () => { + const t = repo(); + threeCommits(t); + const run = t.ctx.process.run.bind(t.ctx.process); + t.ctx.process.run = async (args, opts) => { + if (args[0] === "for-each-ref" && args.some((a) => a.includes("%(ahead-behind:"))) throw new Error("unknown field name"); + return run(args, opts); + }; + const list = await t.bridge.branchesList(); + assert.equal(list.length, 1); + assert.equal(list[0].isDefault, true); + assert.equal(list[0].merged, undefined); +}); + +test("branch people names each branch's first author and counts contributors, skipping failures", async () => { + const t = repo(); + threeCommits(t); + t.git("checkout", "-q", "-b", "feature"); + t.write("feat.txt", "1\n"); + t.git("add", "-A"); + t.git("-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-q", "-m", "start"); + t.write("feat.txt", "2\n"); + t.commitAll("dev follows"); + t.write("feat.txt", "3\n"); + t.git("add", "-A"); + t.git("-c", "user.name=Ada", "-c", "user.email=ADA@example.com", "commit", "-q", "-m", "ada again"); + t.git("checkout", "-q", "main"); + t.git("branch", "broken"); + + const run = t.ctx.process.run.bind(t.ctx.process); + t.ctx.process.run = async (args, opts) => { + if (args[0] === "log" && args.some((a) => a.endsWith("..broken"))) throw new Error("one odd ref"); + return run(args, opts); + }; + const people = await t.bridge.branchesPeople(); + assert.deepEqual(people.feature.creator, { name: "Ada", email: "ada@example.com" }); + assert.deepEqual( + people.feature.contributors.map((c) => [c.email.toLowerCase(), c.count]), + [ + ["ada@example.com", 2], + ["dev@example.com", 1], + ], + ); + assert.equal("main" in people, false, "the base itself has no unique commits"); + assert.equal("broken" in people, false, "one branch's failure is its own"); +}); + +test("deleting a remote branch remembers where it was, and restoring pushes it back only if absent", async () => { + const t = repo(); + const [first, , tip] = threeCommits(t); + const bare = bareRemote(t); + t.git("push", "-q", "origin", "main:feature"); + t.git("fetch", "-q", "origin"); + + const del = await t.bridge.branchDeleteRemote({ remote: "origin", name: "feature" }); + assert.equal(del.ok, true, del.message); + assert.equal(del.was, tip); + assert.equal(gitIn(bare, "for-each-ref", "refs/heads/feature").trim(), "", "gone on the remote"); + + const back = await t.bridge.branchRestoreRemote({ remote: "origin", name: "feature", sha: tip }); + assert.deepEqual(back, { ok: true, changed: true }); + assert.equal(gitIn(bare, "rev-parse", "refs/heads/feature").trim(), tip); + + // Somebody re-made it meanwhile, at an ANCESTOR of the old tip — an ordinary + // push would fast-forward their branch. The restore is refused instead. + gitIn(bare, "update-ref", "refs/heads/feature", first); + const again = await t.bridge.branchRestoreRemote({ remote: "origin", name: "feature", sha: tip }); + assert.equal(again.ok, false); + assert.equal(again.expected, true); + assert.equal(gitIn(bare, "rev-parse", "refs/heads/feature").trim(), first, "their branch is untouched"); +}); + +// ── tags ─────────────────────────────────────────────────────────────────── + +test("a tag with a message is annotated, one without is lightweight, and option-shaped names are refused", async () => { + const t = repo(); + const [first] = threeCommits(t); + assert.deepEqual(await t.bridge.tagCreate({ name: "v1", message: "release one" }), { ok: true, changed: true }); + assert.equal(t.git("cat-file", "-t", "refs/tags/v1").trim(), "tag"); + assert.deepEqual(await t.bridge.tagCreate({ name: "v0", ref: first }), { ok: true, changed: true }); + assert.equal(t.git("cat-file", "-t", "refs/tags/v0").trim(), "commit"); + assert.equal(t.git("rev-parse", "refs/tags/v0").trim(), first); + + assert.equal((await t.bridge.tagCreate({ name: "--delete" })).ok, false); + assert.equal((await t.bridge.tagCreate({ name: "ok", ref: "--all" })).ok, false); + assert.equal(t.gitTry("rev-parse", "--verify", "refs/tags/ok").code === 0, false); +}); + +test("a deleted annotated tag comes back annotated, and not over a tag made since", async () => { + const t = repo(); + threeCommits(t); + t.git("tag", "-a", "v1", "-m", "the message"); + const obj = t.git("rev-parse", "refs/tags/v1").trim(); + const del = await t.bridge.tagDelete("v1"); + assert.equal(del.ok, true); + assert.equal(del.was, obj, "the tag object, not the commit it names"); + + const back = await t.bridge.tagRestore({ name: "v1", sha: obj }); + assert.deepEqual(back, { ok: true, changed: true }); + assert.equal(t.git("cat-file", "-t", "refs/tags/v1").trim(), "tag"); + assert.match(t.git("cat-file", "-p", "refs/tags/v1"), /the message/); + + const twice = await t.bridge.tagRestore({ name: "v1", sha: obj }); + assert.equal(twice.ok, false); + assert.match(twice.message ?? "", /is there again/); + + const bogus = await t.bridge.tagRestore({ name: "v2", sha: "1234567".repeat(5) + "89abc" }); + assert.equal(bogus.ok, false); + assert.equal(bogus.expected, true); + assert.equal(t.gitTry("rev-parse", "--verify", "refs/tags/v2").code === 0, false); +}); + +test("pushing a tag picks origin, else the only remote, and refuses to guess between several", async () => { + const t = repo(); + threeCommits(t); + t.git("tag", "v1"); + const none = await t.bridge.tagPush({ name: "v1" }); + assert.equal(none.ok, false); + assert.match(none.message ?? "", /No remote is configured, so 'v1' can't be pushed/); + + const only = bareRemote(t, "fork"); + const one = await t.bridge.tagPush({ name: "v1" }); + assert.equal(one.ok, true, one.message); + assert.equal(gitIn(only, "rev-parse", "refs/tags/v1").trim(), t.git("rev-parse", "v1").trim()); + + bareRemote(t, "mirror"); + t.git("tag", "v2"); + const several = await t.bridge.tagPush({ name: "v2" }); + assert.equal(several.ok, false); + assert.match(several.message ?? "", /Several remotes are configured/); + + const named = await t.bridge.tagPush({ name: "v2", remote: "mirror" }); + assert.equal(named.ok, true, named.message); + assert.equal((await t.bridge.tagPush({ name: "v2", remote: "--mirror" })).ok, false); + assert.equal((await t.bridge.tagPush({ name: "-v2" })).ok, false); +}); + +// ── the operation banner ─────────────────────────────────────────────────── + +test("abandoning a patch series after HEAD moved warns that git did not rewind", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("base"); + t.git("checkout", "-q", "-b", "patch"); + t.write("a.txt", "from the patch\n"); + t.commitAll("the patch"); + const out = mkdtempSync(join(tmpdir(), "gitstudio-patches-")); + t.alsoRemove.push(out); + t.git("format-patch", "-q", "-1", "-o", out); + t.git("checkout", "-q", "main"); + t.write("a.txt", "conflicting line\n"); + t.commitAll("main moves"); + assert.notEqual(t.gitTry("am", join(out, readdirSync(out)[0])).code, 0); + // HEAD moves while the series is stopped. + t.git("commit", "-q", "--allow-empty", "-m", "moved meanwhile"); + const moved = head(t); + + const res = await t.bridge.amAbort(); + assert.equal(res.ok, true); + assert.match(res.message ?? "", /HEAD had moved since it started/); + assert.equal(head(t), moved, "git left HEAD where it is"); +}); + +test("skipping a revert when none is stopped fails as an expected state", async () => { + const t = repo(); + threeCommits(t); + const res = await t.bridge.revertSkip(); + assert.equal(res.ok, false); + assert.equal(res.expected, true); + assert.ok(res.message); +}); + +test("continuing a rebase when none is running is a stated failure, not a crash", async () => { + const t = repo(); + threeCommits(t); + const res = await t.bridge.rebaseContinue(); + assert.equal(res.ok, false); + assert.ok((res.message ?? "").length > 0); + assert.equal(res.expected, false, "not stopped — nothing is waiting on the user"); +}); + +test("a rebase runner that throws is reported with its message", async () => { + const t = repo(); + threeCommits(t); + const broken = new (t.bridge.constructor as typeof import("../src/main/gitBridge").GitBridge)({ + getContext: () => t.ctx, + current: () => ({ root: t.repo }), + state: () => ({ tabs: [] }), + runnerOptions: () => { + throw new Error("no git binary configured"); + }, + } as never); + const res = await broken.rebaseSkip(); + assert.deepEqual(res, { ok: false, changed: false, message: "no git binary configured" }); +}); + +test("an operation state that cannot be inspected reads as nothing in progress", async () => { + const t = repo(); + threeCommits(t); + t.ctx.operation.inspect = async () => { + throw new Error("unreadable .git"); + }; + const s = await t.bridge.opState(); + assert.equal(s.kind, null); + assert.equal(s.canContinue, false); +}); + +// ── the conflict dashboard ───────────────────────────────────────────────── + +test("conflict writes that throw come back as reported failures, not rejections", async () => { + const t = repo(); + threeCommits(t); + const boom = async (): Promise => { + throw new Error("disk full"); + }; + t.ctx.conflictOps.writeResolution = boom; + t.ctx.conflictOps.takeStage = boom; + t.ctx.conflictOps.takeRole = boom; + t.ctx.conflictOps.restore = boom; + t.ctx.conflictOps.deleteFile = boom; + t.ctx.conflict.listConflicts = boom; + const results = [ + await t.bridge.conflictResolve({ path: "a.txt", content: "x" }), + await t.bridge.conflictTakeSide({ path: "a.txt", side: "theirs" }), + await t.bridge.conflictTakeRole({ path: "a.txt", role: "theirs" }), + await t.bridge.conflictRestore({ path: "a.txt" }), + await t.bridge.conflictDelete({ path: "a.txt" }), + ]; + for (const res of results) { + assert.equal(res.ok, false); + assert.equal(res.expected, undefined, "a throw is ours to hear about"); + assert.match(res.message ?? "", /disk full/); + } + assert.deepEqual(await t.bridge.conflictList(), []); +}); + +test("conflict writes refuse unusable paths, sides and roles before touching anything", async () => { + const t = repo(); + threeCommits(t); + assert.match((await t.bridge.conflictResolve({ path: "", content: "x" })).message ?? "", /usable file path/); + assert.match( + (await t.bridge.conflictResolve({ path: "a.txt", content: 5 as unknown as string })).message ?? "", + /Nothing to save/, + ); + assert.match((await t.bridge.conflictTakeSide({ path: "a\0b", side: "ours" })).message ?? "", /usable file path/); + assert.match( + (await t.bridge.conflictTakeSide({ path: "a.txt", side: "base" as unknown as "ours" })).message ?? "", + /usable file path/, + ); + assert.match( + (await t.bridge.conflictTakeRole({ path: "a.txt", role: "base" as unknown as "yours" })).message ?? "", + /Choose Yours or Theirs/, + ); + assert.match((await t.bridge.conflictRestore({ path: "" })).message ?? "", /usable file path/); + assert.match((await t.bridge.conflictDelete({ path: "" })).message ?? "", /usable file path/); + assert.equal(readFileSync(join(t.repo, "a.txt"), "utf8"), "three\n"); +}); + +test("a conflict state read that fails is not served again from the cache", async () => { + const t = repo(); + threeCommits(t); + const real = t.ctx.conflictOps.snapshot.bind(t.ctx.conflictOps); + let calls = 0; + t.ctx.conflictOps.snapshot = async () => { + calls++; + if (calls === 1) throw new Error("transient"); + return real(); + }; + await assert.rejects(t.bridge.conflictState(), /transient/); + // Give the eviction (a .catch on the cached promise) its turn. + await new Promise((resolve) => setImmediate(resolve)); + const snap = await t.bridge.conflictState(); + assert.equal(calls, 2, "the failed read was dropped, so this one asked git again"); + assert.equal(snap.total, 0); + // And a good read IS cached: the next call does not ask again. + await t.bridge.conflictState(); + assert.equal(calls, 2); +}); diff --git a/apps/desktop/test/gitBridge.reads.test.ts b/apps/desktop/test/gitBridge.reads.test.ts new file mode 100644 index 00000000..69964663 --- /dev/null +++ b/apps/desktop/test/gitBridge.reads.test.ts @@ -0,0 +1,520 @@ +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { initRepo, bareRemote, type BridgeRepo } from "./gitBridgeFixture"; +import { removeTempRepo } from "./tmpRepo"; + +// The bridge's READ channels — what the Code browser, Compare, the ref pickers, +// the sync widget and Settings show. Each is checked against a real repository +// for the facts it reports, and against a provider that throws for the promise +// every one of them makes: a read that fails answers empty, it never rejects +// into the renderer. + +let r: BridgeRepo | undefined; +afterEach(() => { + r?.cleanup(); + r = undefined; +}); + +function repo(prefix = "reads"): BridgeRepo { + r = initRepo(prefix); + return r; +} + +// ── refs / HEAD ───────────────────────────────────────────────────────────── + +test("the ref list carries each ref's subject, date, object kind and upstream", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + const first = t.commitAll("first commit"); + t.git("tag", "-a", "v1", "-m", "release one"); + t.git("tag", "light"); + const bare = bareRemote(t); + void bare; + t.git("push", "-q", "-u", "origin", "main"); + + const refs = await t.bridge.refsList(); + const main = refs.find((x) => x.fullName === "refs/heads/main"); + assert.ok(main, "the local branch is listed"); + assert.equal(main.sha, first); + assert.equal(main.isCurrent, true); + assert.equal(main.subject, "first commit"); + assert.ok(main.date && main.date > 0, "a date to sort by"); + assert.match(main.upstream ?? "", /origin\/main/); + + const annotated = refs.find((x) => x.fullName === "refs/tags/v1"); + const lightweight = refs.find((x) => x.fullName === "refs/tags/light"); + assert.equal(annotated?.objectType, "tag", "an annotated tag is its own object"); + assert.equal(lightweight?.objectType, "commit", "a lightweight tag points straight at the commit"); + assert.ok(refs.some((x) => x.type === "remote" && x.fullName === "refs/remotes/origin/main")); +}); + +test("a ref listing that fails answers an empty list instead of rejecting", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("first"); + t.ctx.refs.listRefs = async () => { + throw new Error("packed-refs is corrupt"); + }; + assert.deepEqual(await t.bridge.refsList(), []); +}); + +test("which branches contain a commit, and an empty answer when git cannot say", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + const first = t.commitAll("first"); + t.git("branch", "topic"); + t.write("a.txt", "two\n"); + const second = t.commitAll("second"); + + const both = await t.bridge.refsContains(first); + assert.deepEqual([...both.branches].sort(), ["main", "topic"]); + const onlyMain = await t.bridge.refsContains(second); + assert.deepEqual(onlyMain.branches, ["main"]); + + t.ctx.refs.containingBranches = async () => { + throw new Error("boom"); + }; + assert.deepEqual(await t.bridge.refsContains(first), { branches: [], refs: [], truncated: false }); +}); + +test("HEAD reads as the plain branch name, as a detached sha, and as nothing when unreadable", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + const sha = t.commitAll("first"); + assert.deepEqual(await t.bridge.head(), { detached: false, branch: "main", sha }); + + t.git("checkout", "-q", "--detach"); + assert.deepEqual(await t.bridge.head(), { detached: true, sha }); + + t.ctx.refs.getHead = async () => { + throw new Error("HEAD is corrupt"); + }; + assert.equal(await t.bridge.head(), undefined); +}); + +// ── graph ─────────────────────────────────────────────────────────────────── + +// BUG (reported, not fixed here): in a freshly `git init`ed repository the +// graph's walk passes a literal `HEAD` to `git log`, which has no commit to +// resolve — git exits 128 ("ambiguous argument 'HEAD'") and graph:load +// REJECTS. The renderer then paints "Couldn't load history" + Retry over a +// repository that simply has no commits yet, instead of its crafted "No +// commits yet" tile (graphMount.renderEmpty), which it only shows for an empty +// page. Unskip when the walk tolerates an unborn HEAD. +test.skip("an unborn repository loads an empty graph with no head, rather than failing", async () => { + const t = repo(); + const page = await t.bridge.graphLoad({ skip: 0 }); + assert.deepEqual(page.rows, []); + assert.equal(page.head, "", "no commit to be 'you are here'"); + assert.equal(page.hasMore, false); +}); + +test("a ref listing that throws still loads the history, with HEAD found from the commit", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + const sha = t.commitAll("first"); + t.ctx.refs.listRefs = async () => { + throw new Error("for-each-ref failed"); + }; + const page = await t.bridge.graphLoad({ skip: 0 }); + assert.equal(page.rows.length, 1, "the commit is still drawn"); + assert.equal(page.head, sha, "HEAD comes from the commit when no branch says so"); +}); + +test("forgetting a tab's graph makes its next page a fresh load from the top", async () => { + const t = repo(); + for (let i = 0; i < 3; i++) { + t.write("a.txt", `v${i}\n`); + t.commitAll(`c${i}`); + } + const first = await t.bridge.graphLoad({ skip: 0, maxCount: 2 }); + assert.equal(first.rows.length, 2); + assert.equal(first.hasMore, true); + const tip = first.rows[0].sha; + + t.bridge.forgetGraph(t.repo); + // A page asked for past a forgotten accumulator is not appended to nothing: + // it restarts at the top, so the renderer never draws a gapped history. + const after = await t.bridge.graphLoad({ skip: 2, maxCount: 2 }); + assert.equal(after.rows[0].sha, tip); + assert.equal(after.nextSkip, 2); +}); + +// ── commit details ────────────────────────────────────────────────────────── + +test("details of a revision that names no commit are absent", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("first"); + // An empty range walks nothing: there is no commit to describe. + assert.equal(await t.bridge.commitDetails("HEAD..HEAD"), undefined); +}); + +test("a stash made with -u lists its new files, which the first-parent diff leaves out", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("first"); + t.write("a.txt", "edited\n"); + t.write("brand-new.txt", "new file\n"); + t.git("stash", "push", "-u", "-m", "with new files"); + const sha = t.git("rev-parse", "stash@{0}").trim(); + + const d = await t.bridge.commitDetails(sha); + assert.ok(d); + const paths = d.files.map((f) => f.path).sort(); + assert.deepEqual(paths, ["a.txt", "brand-new.txt"]); + assert.equal(d.files.find((f) => f.path === "brand-new.txt")?.status, "A"); + + // And the diff of that new file reads from the stash's third parent. + const diff = await t.bridge.fileDiff({ path: "brand-new.txt", sha }); + assert.equal(diff?.rightText, "new file\n"); +}); + +test("commit details whose file list cannot be read still describe the commit", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + const sha = t.commitAll("described"); + t.ctx.commitDetails.getCommitFiles = async () => { + throw new Error("diff failed"); + }; + const d = await t.bridge.commitDetails(sha); + assert.equal(d?.subject, "described"); + assert.deepEqual(d?.files, []); + assert.equal(d?.hasRemote, false); +}); + +test("row stats count each commit's files and lines in one read", async () => { + const t = repo(); + t.write("a.txt", "one\ntwo\n"); + const first = t.commitAll("first"); + t.write("a.txt", "one\nTWO\nthree\n"); + t.write("b.txt", "b\n"); + const second = t.commitAll("second"); + const stats = await t.bridge.rowStats([first, second]); + const s2 = stats.find((s) => s.sha === second); + assert.ok(s2, JSON.stringify(stats)); + assert.equal(s2.files, 2); + assert.equal(s2.additions, 3); + assert.equal(s2.deletions, 1); +}); + +test("blame answers per-line authorship, and nothing for a file git cannot blame", async () => { + const t = repo(); + t.write("a.txt", "one\ntwo\n"); + const sha = t.commitAll("first"); + const blame = (await t.bridge.blameFile("a.txt")) as Array<{ sha?: string; commit?: { sha?: string } }> | { lines?: unknown[] }; + assert.ok(blame, "a tracked file has blame"); + assert.ok(JSON.stringify(blame).includes(sha), "the lines are attributed to the commit that wrote them"); + assert.equal(await t.bridge.blameFile("not-there.txt"), undefined); +}); + +// ── compare ───────────────────────────────────────────────────────────────── + +function forked(t: BridgeRepo): { base: string } { + t.write("keep.txt", "keep\n"); + t.write("old-name.txt", "line1\nline2\nline3\nline4\nline5\nline6\n"); + const base = t.commitAll("base"); + t.git("checkout", "-q", "-b", "topic"); + t.git("mv", "old-name.txt", "new-name.txt"); + t.write("new-name.txt", "line1\nline2\nline3\nline4\nline5\nline6 changed\n"); + t.commitAll("rename it\n\nbecause the name was wrong"); + t.write("added.txt", "added\n"); + t.commitAll("add a file"); + t.git("checkout", "-q", "main"); + t.write("keep.txt", "main moved\n"); + t.commitAll("main moves on"); + return { base }; +} + +test("compare counts ahead and behind, keeps each commit's body, and pairs a rename with its old name", async () => { + const t = repo(); + forked(t); + const c = await t.bridge.compareRefs({ base: "main", head: "topic" }); + assert.ok(c); + assert.equal(c.ahead, 2); + assert.equal(c.behind, 1); + assert.equal(c.commitsTruncated, false); + assert.deepEqual(c.commits.map((x) => x.subject), ["add a file", "rename it"]); + assert.match(c.commits[1].body ?? "", /because the name was wrong/); + assert.equal(c.commits[0].isMerge, false); + // Three-dot: only what topic introduced — main's own edit to keep.txt is not in it. + const rename = c.files.find((f) => f.status === "R"); + assert.equal(rename?.path, "new-name.txt"); + assert.equal(rename?.oldPath, "old-name.txt"); + assert.equal(c.files.some((f) => f.path === "keep.txt"), false); + + // Two-dot: the literal difference between the tips, main's edit included. + const direct = await t.bridge.compareRefs({ base: "main", head: "topic", mode: "two-dot" }); + assert.ok(direct?.files.some((f) => f.path === "keep.txt")); +}); + +test("compare refuses an option-shaped ref instead of handing it to git", async () => { + const t = repo(); + forked(t); + assert.equal(await t.bridge.compareRefs({ base: "--output=/tmp/x", head: "topic" }), undefined); + assert.equal(await t.bridge.compareFileDiff({ base: "main", head: "-p", path: "a" }), undefined); +}); + +test("a compared rename diffs against the old name at the merge-base", async () => { + const t = repo(); + const { base } = forked(t); + const d = await t.bridge.compareFileDiff({ base: "main", head: "topic", path: "new-name.txt", leftPath: "old-name.txt" }); + assert.ok(d); + assert.match(d.leftText, /line6\n$/, "the left side is the old file, not an empty 'new file'"); + assert.match(d.rightText, /line6 changed/); + assert.equal(d.leftLabel, "main (merge-base) old-name.txt"); + assert.equal(d.rightLabel, "topic new-name.txt"); + + // Two-dot compares the tips; a side named by sha is labelled by its short sha. + const tipSha = t.git("rev-parse", "topic").trim(); + const two = await t.bridge.compareFileDiff({ base, head: tipSha, path: "added.txt", mode: "two-dot" }); + assert.equal(two?.leftLabel, `${base.slice(0, 7)} added.txt`); + assert.equal(two?.rightLabel, `${tipSha.slice(0, 7)} added.txt`); + assert.equal(two?.leftText, "", "absent at the base"); + assert.equal(two?.rightText, "added\n"); +}); + +// ── the code browser ──────────────────────────────────────────────────────── + +test("the head commit carries the whole message and, when asked, the commit count", async () => { + const t = repo(); + assert.equal(await t.bridge.headCommit(), undefined, "an unborn HEAD has no tip"); + t.write("a.txt", "one\n"); + t.commitAll("first"); + t.write("a.txt", "two\n"); + t.git("add", "-A"); + t.git("commit", "-q", "-m", "subject line", "-m", "body text", "-m", "Signed-off-by: Dev "); + const sha = t.git("rev-parse", "HEAD").trim(); + + const plain = await t.bridge.headCommit(); + assert.equal(plain?.sha, sha); + assert.equal(plain?.shortSha, t.git("rev-parse", "--short", "HEAD").trim()); + assert.equal(plain?.subject, "subject line"); + assert.equal(plain?.message, "subject line\n\nbody text\n\nSigned-off-by: Dev "); + assert.equal(plain?.author, "Dev"); + assert.equal(plain?.authorEmail, "dev@example.com"); + assert.ok((plain?.date ?? 0) > 0); + assert.equal("total" in (plain ?? {}), false, "the count is opt-in"); + + const counted = await t.bridge.headCommit({ count: true }); + assert.equal(counted?.total, 2); +}); + +test("the head commit is absent, not a throw, when git cannot run", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("first"); + t.ctx.process.run = async () => { + throw new Error("spawn failed"); + }; + assert.equal(await t.bridge.headCommit(), undefined); + assert.deepEqual(await t.bridge.treeList({ path: "" }), []); + assert.equal(await t.bridge.fileText({ path: "a.txt" }), undefined); +}); + +test("the tree lists one level, folders first then by name, with blob sizes and no submodules", async () => { + const t = repo(); + t.write("zeta.txt", "12345"); + t.write("alpha.txt", "1"); + t.write("src/main.ts", "export {};\n"); + t.write("src/deep/x.ts", "x\n"); + t.write("docs/readme.md", "# docs\n"); + const sha = t.commitAll("tree"); + // A gitlink (submodule) entry: listed by git as type "commit", never a file. + t.git("update-index", "--add", "--cacheinfo", `160000,${sha},vendored`); + t.git("commit", "-q", "-m", "gitlink"); + + const root = await t.bridge.treeList({ path: "" }); + assert.deepEqual( + root.map((e) => [e.name, e.type]), + [ + ["docs", "tree"], + ["src", "tree"], + ["alpha.txt", "blob"], + ["zeta.txt", "blob"], + ], + ); + assert.equal(root.find((e) => e.name === "zeta.txt")?.size, 5); + assert.equal("size" in (root.find((e) => e.name === "src") ?? {}), false, "a folder has no size"); + + const src = await t.bridge.treeList({ path: "/src/" }); + assert.deepEqual(src.map((e) => e.path), ["src/deep", "src/main.ts"]); + assert.deepEqual(await t.bridge.treeList({ path: "no-such-dir" }), []); +}); + +test("the file viewer reads text, and refuses folders, binaries and oversized blobs", async () => { + const t = repo(); + t.write("docs/readme.md", "# hello\n"); + t.write("image.bin", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02])); + t.write("huge.txt", "x".repeat(600 * 1024)); + t.commitAll("files"); + + assert.deepEqual(await t.bridge.fileText({ path: "/docs/readme.md" }), { path: "docs/readme.md", text: "# hello\n" }); + assert.equal(await t.bridge.fileText({ path: "docs" }), undefined, "a folder is not a file"); + assert.equal(await t.bridge.fileText({ path: "missing.txt" }), undefined, "not tracked at HEAD"); + assert.equal(await t.bridge.fileText({ path: "/" }), undefined, "no path at all"); + assert.deepEqual(await t.bridge.fileText({ path: "image.bin" }), { path: "image.bin", text: "", binary: true }); + assert.deepEqual(await t.bridge.fileText({ path: "huge.txt" }), { path: "huge.txt", text: "", truncated: true }); +}); + +// ── settings ──────────────────────────────────────────────────────────────── + +test("SSH keys are the .pub files in ~/.ssh, sorted, with type and comment", async () => { + const home = mkdtempSync(join(tmpdir(), "gitstudio-home-")); + const saved = process.env.HOME; + try { + process.env.HOME = home; + const t = repo(); + assert.deepEqual(await t.bridge.sshKeys(), [], "no .ssh folder is no keys, not an error"); + + mkdirSync(join(home, ".ssh")); + writeFileSync(join(home, ".ssh", "id_rsa.pub"), "ssh-rsa AAAAB3Nza me@laptop work\n"); + writeFileSync(join(home, ".ssh", "id_ed25519.pub"), "ssh-ed25519 AAAAC3Nz dev@example.com\n"); + writeFileSync(join(home, ".ssh", "id_ed25519"), "PRIVATE KEY — never listed\n"); + writeFileSync(join(home, ".ssh", "config"), "Host *\n"); + // A directory named like a key cannot be read as one: skipped, not fatal. + mkdirSync(join(home, ".ssh", "odd.pub")); + + assert.deepEqual(await t.bridge.sshKeys(), [ + { file: "id_ed25519.pub", type: "ssh-ed25519", comment: "dev@example.com" }, + { file: "id_rsa.pub", type: "ssh-rsa", comment: "me@laptop work" }, + ]); + } finally { + if (saved === undefined) delete process.env.HOME; + else process.env.HOME = saved; + rmSync(home, { recursive: true, force: true }); + } +}); + +test("reading the identity when git cannot run answers blanks rather than failing Settings", async () => { + const t = repo(); + t.ctx.process.run = async () => { + throw new Error("spawn failed"); + }; + assert.deepEqual(await t.bridge.gitIdentity(), { name: "", email: "" }); +}); + +test("an identity whose name starts with a dash is refused before git reads it as an option", async () => { + const t = repo(); + const r = await t.bridge.setGitIdentity({ name: "--global", email: "a@b.c" }); + assert.equal(r.ok, false); + assert.equal(r.expected, true); + assert.match(r.message ?? "", /can't start with/); + t.ctx.process.run = async () => { + throw new Error("spawn failed"); + }; + const thrown = await t.bridge.setGitIdentity({ name: "Dev", email: "dev@example.com" }); + assert.equal(thrown.ok, false); + assert.equal(thrown.expected, undefined, "git failing to run is reported, not hidden"); + assert.match(thrown.message ?? "", /spawn failed/); +}); + +// ── sync widget ───────────────────────────────────────────────────────────── + +test("sync status names the branch and counts against its upstream", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("first"); + assert.deepEqual(await t.bridge.syncStatus(), { branch: "main", ahead: 0, behind: 0, noUpstream: true }); + + bareRemote(t); + t.git("push", "-q", "-u", "origin", "main"); + t.write("a.txt", "two\n"); + t.commitAll("second"); + const s = await t.bridge.syncStatus(); + assert.equal(s.branch, "main"); + assert.equal(s.upstream, "origin/main"); + assert.equal(s.ahead, 1); + assert.equal(s.behind, 0); + assert.equal(s.noUpstream, false); + + // An unreadable HEAD still answers the counts, just without a name. + t.ctx.refs.getHead = async () => { + throw new Error("HEAD unreadable"); + }; + const nameless = await t.bridge.syncStatus(); + assert.equal(nameless.branch, undefined); + assert.equal(nameless.ahead, 1); +}); + +test("fetching from a local remote brings its new branch in", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("first"); + const bare = bareRemote(t); + t.git("push", "-q", "origin", "main:elsewhere"); + // The remote has a branch the local repo has not fetched yet. + t.git("update-ref", "-d", "refs/remotes/origin/elsewhere"); + const r0 = await t.bridge.syncFetch(); + assert.equal(r0.ok, true, r0.message); + assert.equal(t.gitTry("rev-parse", "--verify", "refs/remotes/origin/elsewhere").code, 0); + void bare; +}); + +// ── lists ─────────────────────────────────────────────────────────────────── + +test("a ref's recent history is capped between 1 and 100 and empty for an unknown ref", async () => { + const t = repo(); + for (let i = 0; i < 3; i++) { + t.write("a.txt", `v${i}\n`); + t.commitAll(`c${i}`); + } + const one = await t.bridge.refLog({ ref: "main", maxCount: 0 }); + assert.deepEqual(one.map((c) => c.subject), ["c2"], "a count below 1 still shows the tip"); + const all = await t.bridge.refLog({ ref: "main", maxCount: 1000 }); + assert.deepEqual(all.map((c) => c.subject), ["c2", "c1", "c0"]); + assert.equal(all[0].shortSha.length, 7); + assert.deepEqual(await t.bridge.refLog({ ref: "no-such-branch" }), []); + assert.deepEqual(await t.bridge.refLog({ ref: "--all" }), [], "an option-shaped ref is refused"); +}); + +test("the stash list carries each stash's sha and message, and is empty when unreadable", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("first"); + t.write("a.txt", "wip\n"); + t.git("stash", "push", "-m", "my wip"); + const list = await t.bridge.stashList(); + assert.equal(list.length, 1); + assert.equal(list[0].sha, t.git("rev-parse", "stash@{0}").trim()); + assert.match(list[0].message, /my wip/); + assert.equal(list[0].ref, "stash@{0}"); + + t.ctx.stashes.list = async () => { + throw new Error("reflog unreadable"); + }; + assert.deepEqual(await t.bridge.stashList(), []); +}); + +test("the worktree list marks the main and current worktrees and one whose folder is gone", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("first"); + const wtParent = mkdtempSync(join(tmpdir(), "gitstudio-wt-")); + t.alsoRemove.push(wtParent); + const wt = join(wtParent, "linked"); + const gone = join(wtParent, "gone"); + t.git("worktree", "add", "-q", "-b", "linked", wt); + t.git("worktree", "add", "-q", "-b", "gone", gone); + removeTempRepo(gone); + + const list = await t.bridge.worktreeList(); + assert.equal(list.length, 3); + assert.equal(list[0].main, true); + assert.equal(list[0].current, true); + const linked = list.find((w) => w.branch === "linked"); + assert.equal(linked?.main, false); + assert.equal(linked?.current, false); + assert.equal(linked?.missing, false); + const missing = list.find((w) => w.branch === "gone"); + assert.equal(missing?.missing, true); + assert.equal(missing?.openInTab, false, "a gone folder is in no tab"); + + t.ctx.worktrees.list = async () => { + throw new Error("worktree list failed"); + }; + assert.deepEqual(await t.bridge.worktreeList(), []); +}); diff --git a/apps/desktop/test/gitBridge.staging.test.ts b/apps/desktop/test/gitBridge.staging.test.ts new file mode 100644 index 00000000..e0ea2d49 --- /dev/null +++ b/apps/desktop/test/gitBridge.staging.test.ts @@ -0,0 +1,500 @@ +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { computeChangeBlocks } from "@gitstudio/engine/staging/blockStaging"; +import { initRepo, type BridgeRepo } from "./gitBridgeFixture"; + +// The Changes view's writes: ticks per change block and per hunk, Stage / Stage +// all over a conflicted merge, Discard and its Undo, Stash, and Commit. Every +// case reads the index or the repository back after the call — the answer +// the bridge gives is only half of it. + +let r: BridgeRepo | undefined; +afterEach(() => { + r?.cleanup(); + r = undefined; +}); + +function repo(prefix = "staging"): BridgeRepo { + r = initRepo(prefix); + return r; +} + +const LINES = (n: number, edit: Record = {}): string => + Array.from({ length: n }, (_, i) => edit[i + 1] ?? `line ${i + 1}`).join("\n") + "\n"; + +const indexOf = (t: BridgeRepo, rel: string): string => t.git("show", `:${rel}`); + +// ── change-block ticks ────────────────────────────────────────────────────── + +test("ticking one change block stages that block alone and hands back the new index", async () => { + const t = repo(); + t.write("f.txt", LINES(20)); + t.commitAll("base"); + const working = LINES(20, { 2: "EDIT TWO", 18: "EDIT EIGHTEEN" }); + t.write("f.txt", working); + + const head = LINES(20); + const blocks = computeChangeBlocks(head, head, working); + assert.equal(blocks.length, 2, "two separate changes"); + const second = blocks[1]; + + const on = await t.bridge.blocksSet({ path: "f.txt", block: second, staged: true }); + assert.equal(on.ok, true, on.message); + assert.equal(on.changed, true); + const idx = indexOf(t, "f.txt"); + assert.match(idx, /EDIT EIGHTEEN/, "the ticked block is staged"); + assert.doesNotMatch(idx, /EDIT TWO/, "the other one is not"); + assert.equal(on.indexText, idx, "the ticks repaint from the index git now holds"); + + // And the tick comes off again: the block goes back to HEAD in the index. + const now = computeChangeBlocks(head, idx, working)[1]; + const off = await t.bridge.blocksSet({ path: "f.txt", block: now, staged: false }); + assert.equal(off.ok, true, off.message); + assert.equal(indexOf(t, "f.txt"), head); +}); + +test("a block that moved under the tick is a stated refusal, and the index is untouched", async () => { + const t = repo(); + t.write("f.txt", LINES(10)); + t.commitAll("base"); + t.write("f.txt", LINES(10, { 5: "changed" })); + const stale = { head: { start: 0, end: 0 }, working: { start: 0, end: 0 }, state: "unstaged" as const }; + const res = await t.bridge.blocksSet({ path: "f.txt", block: stale, staged: true }); + assert.equal(res.ok, false); + assert.equal(res.expected, true, "the file moved — a user state, not a crash report"); + assert.equal(indexOf(t, "f.txt"), LINES(10)); +}); + +test("a block tick on a path outside the repository or a missing file changes nothing", async () => { + const t = repo(); + t.write("f.txt", "x\n"); + t.commitAll("base"); + const block = { head: { start: 0, end: 0 }, working: { start: 0, end: 0 }, state: "unstaged" as const }; + const outside = await t.bridge.blocksSet({ path: "../escape.txt", block, staged: true }); + assert.equal(outside.ok, false); + assert.match(outside.message ?? "", /valid git reference/); + + const missing = await t.bridge.blocksSet({ path: "gone.txt", block, staged: true }); + assert.equal(missing.ok, false); + assert.equal(missing.expected, undefined, "an unreadable file we were asked to tick is news"); + assert.match(missing.message ?? "", /ENOENT/); +}); + +// ── per-file stage / unstage over a merge ─────────────────────────────────── + +function mergeConflict(t: BridgeRepo): void { + t.write("clash.txt", "base\n"); + t.write("resolved.txt", "base\n"); + t.write("gone-on-topic.txt", "base\n"); + t.write("pic.bin", Buffer.from([1, 0, 2, 0, 3])); + t.write("plain.txt", "plain\n"); + t.commitAll("base"); + t.git("checkout", "-q", "-b", "topic"); + t.write("clash.txt", "theirs\n"); + t.write("resolved.txt", "theirs\n"); + t.git("rm", "-q", "gone-on-topic.txt"); + t.write("pic.bin", Buffer.from([9, 0, 9, 0, 9])); + t.commitAll("topic"); + t.git("checkout", "-q", "main"); + t.write("clash.txt", "ours\n"); + t.write("resolved.txt", "ours\n"); + t.write("gone-on-topic.txt", "edited on main\n"); + t.write("pic.bin", Buffer.from([7, 0, 7, 0, 7])); + t.commitAll("main"); + t.gitTry("merge", "topic"); +} + +const unmerged = (t: BridgeRepo): string[] => + t.git("diff", "--name-only", "--diff-filter=U").split("\n").filter(Boolean).sort(); + +test("Stage on a conflicted file that still has markers is refused; once resolved it stages", async () => { + const t = repo(); + mergeConflict(t); + const refused = await t.bridge.stage("clash.txt"); + assert.equal(refused.ok, false); + assert.equal(refused.expected, true); + assert.match(refused.message ?? "", /still contains conflict markers/); + assert.ok(unmerged(t).includes("clash.txt"), "the conflict is not marked resolved"); + + t.write("clash.txt", "resolved by hand\n"); + const ok = await t.bridge.stage("clash.txt"); + assert.equal(ok.ok, true, ok.message); + assert.equal(unmerged(t).includes("clash.txt"), false); + assert.equal(indexOf(t, "clash.txt"), "resolved by hand\n"); +}); + +test("a conflicted file removed from disk stages as its deletion", async () => { + const t = repo(); + mergeConflict(t); + unlinkSync(join(t.repo, "clash.txt")); + const res = await t.bridge.stage("clash.txt"); + assert.equal(res.ok, true, res.message); + assert.equal(unmerged(t).includes("clash.txt"), false); + assert.equal(t.gitTry("cat-file", "-e", ":clash.txt").code === 0, false, "gone from the index"); +}); + +test("Stage all holds back marker-bearing, modify/delete and binary conflicts and stages the rest", async () => { + const t = repo(); + mergeConflict(t); + t.write("resolved.txt", "resolved\n"); + t.write("plain.txt", "plain, edited during the merge\n"); + + const res = await t.bridge.stageAll(); + assert.equal(res.ok, false); + assert.equal(res.expected, true); + assert.equal(res.changed, true, "everything else WAS staged"); + assert.match(res.message ?? "", /^Staged everything else\./); + assert.match(res.message ?? "", /1 still contains conflict markers \(clash\.txt\)/); + assert.match(res.message ?? "", /2 are modify\/delete conflicts \(gone-on-topic\.txt, pic\.bin\)|2 are modify\/delete conflicts \(pic\.bin, gone-on-topic\.txt\)/); + + // What the message says is what the index holds. + assert.deepEqual(unmerged(t), ["clash.txt", "gone-on-topic.txt", "pic.bin"]); + assert.equal(indexOf(t, "resolved.txt"), "resolved\n"); + assert.equal(indexOf(t, "plain.txt"), "plain, edited during the merge\n"); +}); + +test("Stage all names the first three held files and counts the rest", async () => { + const t = repo(); + const files = ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"]; + for (const f of files) t.write(f, "base\n"); + t.commitAll("base"); + t.git("checkout", "-q", "-b", "topic"); + for (const f of files) t.write(f, "theirs\n"); + t.commitAll("topic"); + t.git("checkout", "-q", "main"); + for (const f of files) t.write(f, "ours\n"); + t.commitAll("main"); + t.gitTry("merge", "topic"); + + const res = await t.bridge.stageAll(); + assert.equal(res.ok, false); + assert.match(res.message ?? "", /5 still contain conflict markers \(a\.txt, b\.txt, c\.txt and 2 more\)/); + assert.equal(unmerged(t).length, 5, "nothing was marked resolved"); +}); + +test("Unstage takes one file out of the index and leaves the working copy", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("base"); + t.write("a.txt", "two\n"); + t.git("add", "a.txt"); + const res = await t.bridge.unstage("a.txt"); + assert.equal(res.ok, true, res.message); + assert.equal(indexOf(t, "a.txt"), "one\n"); + assert.equal(readFileSync(join(t.repo, "a.txt"), "utf8"), "two\n"); +}); + +// ── discard ──────────────────────────────────────────────────────────────── + +test("Discard on a modify/delete conflict says git's reason and changes nothing", async () => { + const t = repo(); + mergeConflict(t); + const res = await t.bridge.discard("gone-on-topic.txt"); + assert.equal(res.ok, false); + assert.equal(res.expected, true); + assert.match(res.message ?? "", /does not have all necessary versions/); + assert.equal(readFileSync(join(t.repo, "gone-on-topic.txt"), "utf8"), "edited on main\n"); +}); + +test("undoing a discard over several files edited again since names them all and restores none", async () => { + const t = repo(); + t.write("a.txt", "a\n"); + t.write("b.txt", "b\n"); + t.commitAll("base"); + t.write("a.txt", "a work\n"); + t.write("b.txt", "b work\n"); + const snap = await t.bridge.discardSnapshot(); + assert.ok(snap.sha); + t.git("checkout", "--", "a.txt", "b.txt"); + t.write("a.txt", "a newer\n"); + t.write("b.txt", "b newer\n"); + + const res = await t.bridge.discardUndo({ sha: snap.sha, paths: ["a.txt", "b.txt"] }); + assert.equal(res.ok, false); + assert.equal(res.expected, true); + assert.match(res.message ?? "", /^2 files have changed since their changes were discarded \(a\.txt, b\.txt\)/); + assert.equal(readFileSync(join(t.repo, "a.txt"), "utf8"), "a newer\n"); + assert.equal(readFileSync(join(t.repo, "b.txt"), "utf8"), "b newer\n"); +}); + +test("undoing a discard against a restore point git cannot read changes nothing and is reported", async () => { + const t = repo(); + t.write("a.txt", "a\n"); + const plain = t.commitAll("base — a commit, not a snapshot: it has no second parent"); + t.write("a.txt", "work\n"); + const res = await t.bridge.discardUndo({ sha: plain, paths: ["a.txt"] }); + assert.equal(res.ok, false); + assert.equal(res.expected, undefined, "our own restore point failing is our bug"); + assert.match(res.message ?? "", /Couldn't tell whether those files have changed since/); + assert.equal(readFileSync(join(t.repo, "a.txt"), "utf8"), "work\n"); + + const empty = await t.bridge.discardUndo({ sha: plain, paths: [""] }); + assert.deepEqual(empty, { ok: false, expected: true, message: "Nothing to restore." }); + const flag = await t.bridge.discardUndo({ sha: "--hard", paths: ["a.txt"] }); + assert.equal(flag.ok, false); +}); + +// ── commit ───────────────────────────────────────────────────────────────── + +test("Commit records the staged change and nothing else", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("base"); + t.write("a.txt", "two\n"); + t.write("b.txt", "not staged\n"); + t.git("add", "a.txt"); + const res = await t.bridge.commit({ message: "change a" }); + assert.deepEqual(res, { ok: true, changed: true }); + assert.equal(t.git("log", "-1", "--format=%s").trim(), "change a"); + assert.equal(t.git("show", "--name-only", "--format=", "HEAD").trim(), "a.txt"); +}); + +test("Commit with no message is refused before git runs", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + const head = t.commitAll("base"); + t.write("a.txt", "two\n"); + t.git("add", "a.txt"); + const res = await t.bridge.commit({ message: " " }); + assert.equal(res.ok, false); + assert.equal(res.expected, true); + assert.match(res.message ?? "", /message is required/); + assert.equal(t.git("rev-parse", "HEAD").trim(), head); +}); + +test("Commit with nothing staged says why in the app's words, not an empty toast", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("base"); + t.write("a.txt", "edited but not staged\n"); + const res = await t.bridge.commit({ message: "too early" }); + assert.equal(res.ok, false); + assert.equal(res.expected, true); + assert.match(res.message ?? "", /Nothing is staged/); +}); + +test("Commit refused by a silent pre-commit hook says to check the hook, as a state", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("base"); + const hooks = mkdtempSync(join(tmpdir(), "gitstudio-hooks-")); + t.alsoRemove.push(hooks); + writeFileSync(join(hooks, "pre-commit"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + t.git("config", "core.hooksPath", hooks.split("\\").join("/")); + t.write("a.txt", "two\n"); + t.git("add", "a.txt"); + const res = await t.bridge.commit({ message: "blocked" }); + assert.equal(res.ok, false); + assert.equal(res.expected, true, "a hook doing its job is not a GitStudio crash"); + assert.match(res.message ?? "", /pre-commit hook/); + assert.equal(t.git("log", "-1", "--format=%s").trim(), "base"); +}); + +test("Commit during a part-applied patch series is refused in favour of Continue", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("base"); + t.git("checkout", "-q", "-b", "patch"); + t.write("a.txt", "from the patch\n"); + t.commitAll("the patch"); + const out = mkdtempSync(join(tmpdir(), "gitstudio-patches-")); + t.alsoRemove.push(out); + t.git("format-patch", "-q", "-1", "-o", out); + t.git("checkout", "-q", "main"); + t.write("a.txt", "conflicting line\n"); + t.commitAll("main moves"); + const patch = join(out, readdirSync(out)[0]); + assert.notEqual(t.gitTry("am", patch).code, 0, "the patch stops"); + + const state = await t.bridge.opState(); + assert.equal(state.amApplying, true); + assert.equal(state.kind, "am"); + + t.write("b.txt", "unrelated\n"); + t.git("add", "b.txt"); + const res = await t.bridge.commit({ message: "a plain commit" }); + assert.equal(res.ok, false); + assert.equal(res.expected, true); + assert.match(res.message ?? "", /patch series is part-applied/); + assert.equal(t.git("log", "-1", "--format=%s").trim(), "main moves", "nothing was committed"); + + // Abandoning the series ends it and says nothing of rewinding when HEAD had not moved. + const aborted = await t.bridge.amAbort(); + assert.equal(aborted.ok, true, aborted.message); + assert.equal(aborted.message, undefined); + assert.equal((await t.bridge.opState()).amApplying, false); +}); + +test("abandoning a patch series when none is running reports git's refusal", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("base"); + const res = await t.bridge.amAbort(); + assert.equal(res.ok, false); + assert.equal(res.changed, false); + assert.match(res.message ?? "", /not in progress/i, "git's own reason, not a blank toast"); + assert.equal(t.git("log", "-1", "--format=%s").trim(), "base"); +}); + +// ── stash save ───────────────────────────────────────────────────────────── + +test("Stash puts the working tree away and says so; a clean tree or selection is a stated no-op", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.write("b.txt", "b\n"); + t.commitAll("base"); + + const clean = await t.bridge.stashSave({ message: "nothing" }); + assert.equal(clean.ok, false); + assert.equal(clean.expected, true); + assert.equal(clean.message, "Nothing to stash — the working tree is clean."); + + t.write("a.txt", "work\n"); + const cleanSelection = await t.bridge.stashSave({ paths: ["b.txt"] }); + assert.equal(cleanSelection.message, "Nothing to stash — the files you selected have no changes."); + const nothingStaged = await t.bridge.stashSave({ stagedOnly: true }); + assert.equal(nothingStaged.message, "Nothing to stash — nothing is staged."); + + const saved = await t.bridge.stashSave({ message: "my work", paths: ["a.txt", ""] }); + assert.deepEqual(saved, { ok: true, changed: true }); + assert.equal(readFileSync(join(t.repo, "a.txt"), "utf8"), "one\n"); + assert.match(t.git("stash", "list"), /my work/); +}); + +test("Stash refuses a path outside the repository and git's unsupported staged-plus-paths", async () => { + const t = repo(); + t.write("a.txt", "one\n"); + t.commitAll("base"); + t.write("a.txt", "work\n"); + t.git("add", "a.txt"); + const outside = await t.bridge.stashSave({ paths: ["../../etc/passwd"] }); + assert.equal(outside.ok, false); + assert.match(outside.message ?? "", /valid git reference/); + + const mixed = await t.bridge.stashSave({ stagedOnly: true, paths: ["a.txt"] }); + assert.equal(mixed.ok, false); + assert.match(mixed.message ?? "", /not supported by git/); + assert.equal(t.git("stash", "list").trim(), "", "nothing was stashed"); + assert.equal(indexOf(t, "a.txt"), "work\n"); +}); + +// ── hunks ────────────────────────────────────────────────────────────────── + +test("hunks are listed for a text file and staged one at a time", async () => { + const t = repo(); + t.write("f.txt", LINES(30)); + t.commitAll("base"); + t.write("f.txt", LINES(30, { 3: "top edit", 27: "bottom edit" })); + + const hunks = await t.bridge.hunksList("f.txt"); + assert.equal(hunks.length, 2); + const res = await t.bridge.hunksStage({ path: "f.txt", index: 1 }); + assert.equal(res.ok, true, res.message); + const idx = indexOf(t, "f.txt"); + assert.match(idx, /bottom edit/); + assert.doesNotMatch(idx, /top edit/); + assert.equal((await t.bridge.hunksList("f.txt")).length, 1, "one change left to stage"); + + const stale = await t.bridge.hunksStage({ path: "f.txt", index: 7 }); + assert.equal(stale.ok, false); + assert.equal(stale.expected, true, "the list moved under the tick"); +}); + +test("no hunks are offered for binaries, paths outside the repo or a missing file", async () => { + const t = repo(); + t.write("pic.bin", Buffer.from([0x89, 0x50, 0x00, 0x01])); + t.commitAll("base"); + t.write("pic.bin", Buffer.from([0x89, 0x50, 0x00, 0x02])); + assert.deepEqual(await t.bridge.hunksList("pic.bin"), []); + assert.deepEqual(await t.bridge.hunksList("../outside.txt"), []); + assert.deepEqual(await t.bridge.hunksList(""), []); + assert.deepEqual(await t.bridge.hunksList("never-existed.txt"), []); + + const bin = await t.bridge.hunksStage({ path: "pic.bin", index: 0 }); + assert.equal(bin.ok, false); + assert.equal(bin.expected, true); + assert.match(bin.message ?? "", /isn't UTF-8 text/); + const outside = await t.bridge.hunksStage({ path: "../x", index: 0 }); + assert.equal(outside.ok, false); +}); + +// ── line staging ──────────────────────────────────────────────────────────── + +test("unstaging lines of a newly added file unstages the whole add and keeps the file", async () => { + const t = repo(); + t.write("a.txt", "a\n"); + t.commitAll("base"); + t.write("new.txt", "first\nsecond\n"); + t.git("add", "new.txt"); + const res = await t.bridge.stageLines({ path: "new.txt", lines: [1], reverse: true }); + assert.deepEqual(res, { ok: true, changed: true }); + assert.equal(t.gitTry("cat-file", "-e", ":new.txt").code === 0, false, "no longer in the index"); + assert.equal(readFileSync(join(t.repo, "new.txt"), "utf8"), "first\nsecond\n", "the file is untouched"); +}); + +test("unstaging a run of lines inside one staged change rolls back that change", async () => { + const t = repo(); + t.write("f.txt", LINES(10)); + t.commitAll("base"); + const edited = LINES(10, { 4: "four A", 5: "five A" }); + t.write("f.txt", edited); + t.git("add", "f.txt"); + // Lines 4 and 5, in working-tree numbering, both inside the one staged change. + const res = await t.bridge.stageLines({ path: "f.txt", lines: [5, 4], reverse: true }); + assert.equal(res.ok, true, res.message); + assert.equal(indexOf(t, "f.txt"), LINES(10)); + assert.equal(readFileSync(join(t.repo, "f.txt"), "utf8"), edited); +}); + +test("line staging refuses an empty selection, a selection with no change under it, and a binary index side", async () => { + const t = repo(); + t.write("f.txt", LINES(5)); + t.write("blob.txt", "text\n"); + t.commitAll("base"); + t.write("f.txt", LINES(5, { 5: "edited" })); + + const none = await t.bridge.stageLines({ path: "f.txt", lines: [0, -1, 1.5] }); + assert.deepEqual(none, { ok: false, changed: false, expected: true, message: "No lines selected." }); + + const nothingThere = await t.bridge.stageLines({ path: "f.txt", lines: [1] }); + assert.equal(nothingThere.ok, false); + assert.equal(nothingThere.message, "Nothing to apply in the selection."); + + const nothingStaged = await t.bridge.stageLines({ path: "f.txt", lines: [1], reverse: true }); + assert.equal(nothingStaged.ok, false); + assert.equal(nothingStaged.message, "Nothing to apply in the selection."); + + // The index side is binary even though the working file is text again. + t.write("blob.txt", Buffer.from([0, 1, 2, 3])); + t.git("add", "blob.txt"); + t.write("blob.txt", "text\nmore\n"); + const bin = await t.bridge.stageLines({ path: "blob.txt", lines: [2] }); + assert.equal(bin.ok, false); + assert.equal(bin.expected, true); + assert.match(bin.message ?? "", /staged as a binary file/); + + const outside = await t.bridge.stageLines({ path: "../f.txt", lines: [1] }); + assert.equal(outside.ok, false); + assert.match(outside.message ?? "", /valid git reference/); +}); + +test("a held index.lock surfaces git's own advice when staging lines", async () => { + const t = repo(); + t.write("f.txt", LINES(5)); + t.commitAll("base"); + t.write("f.txt", LINES(5, { 2: "edited" })); + const lock = join(t.repo, ".git", "index.lock"); + writeFileSync(lock, ""); + try { + const res = await t.bridge.stageLines({ path: "f.txt", lines: [2] }); + assert.equal(res.ok, false); + assert.match(res.message ?? "", /index\.lock|Another git process/i, "git's words, not a generic failure"); + } finally { + rmSync(lock, { force: true }); + } + assert.equal(existsSync(lock), false); + assert.equal(indexOf(t, "f.txt"), LINES(5), "nothing was staged"); +}); diff --git a/apps/desktop/test/gitBridgeFixture.ts b/apps/desktop/test/gitBridgeFixture.ts new file mode 100644 index 00000000..4c02734d --- /dev/null +++ b/apps/desktop/test/gitBridgeFixture.ts @@ -0,0 +1,106 @@ +// A throwaway repository with a GitBridge over it, for the gitBridge.*.test.ts +// files. Same recipe as the neighbouring bridge tests (stashRestore.test.ts): +// a hermetic git (the test script's hermetic-git.mjs), gc off, autocrlf off, +// and removal through removeTempRepo. +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { GitContext } from "@gitstudio/git-service/index"; +import { GitBridge } from "../src/main/gitBridge"; +import type { RepoStore } from "../src/main/repoStore"; +import { removeTempRepo } from "./tmpRepo"; + +export interface BridgeRepo { + repo: string; + ctx: GitContext; + bridge: GitBridge; + git: (...args: string[]) => string; + /** git, answering the exit code instead of throwing. */ + gitTry: (...args: string[]) => { code: number; stdout: string; stderr: string }; + write: (rel: string, content: string | Buffer) => void; + commitAll: (message: string) => string; + cleanup: () => void; + /** Extra folders (bare remotes, worktrees) to remove with the repo. */ + alsoRemove: string[]; +} + +const ENV = { ...process.env, GIT_OPTIONAL_LOCKS: "0" }; + +export function gitIn(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", env: ENV, stdio: ["ignore", "pipe", "pipe"] }); +} + +export function initRepo(prefix: string, opts: { tabs?: () => string[] } = {}): BridgeRepo { + const repo = mkdtempSync(join(tmpdir(), `gitstudio-${prefix}-`)); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", "-q", repo], { env: ENV }); + const git = (...a: string[]): string => gitIn(repo, ...a); + git("config", "user.email", "dev@example.com"); + git("config", "user.name", "Dev"); + git("config", "gc.auto", "0"); + git("config", "core.autocrlf", "false"); + git("config", "commit.gpgsign", "false"); + const ctx = new GitContext({ root: repo }); + const store = { + getContext: () => ctx, + current: () => ({ root: repo }), + state: () => ({ tabs: (opts.tabs?.() ?? [repo]).map((root) => ({ root })) }), + runnerOptions: () => ({ gitPath: "git", onRun: () => undefined }), + } as unknown as RepoStore; + const bridge = new GitBridge(store); + const alsoRemove: string[] = []; + const write = (rel: string, content: string | Buffer): void => { + const abs = join(repo, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + }; + const gitTry = (...a: string[]): { code: number; stdout: string; stderr: string } => { + try { + return { code: 0, stdout: git(...a), stderr: "" }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? 1, stdout: String(err.stdout ?? ""), stderr: String(err.stderr ?? "") }; + } + }; + const commitAll = (message: string): string => { + git("add", "-A"); + git("commit", "-q", "-m", message); + return git("rev-parse", "HEAD").trim(); + }; + return { + repo, + ctx, + bridge, + git, + gitTry, + write, + commitAll, + alsoRemove, + cleanup: () => { + removeTempRepo(repo); + for (const d of alsoRemove) removeTempRepo(d); + }, + }; +} + +/** A bare repository to push to — a local path, so nothing leaves the machine. */ +export function bareRemote(r: BridgeRepo, name = "origin"): string { + const bare = mkdtempSync(join(tmpdir(), "gitstudio-bare-")); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", "-q", "--bare", bare], { env: ENV }); + r.alsoRemove.push(bare); + r.git("remote", "add", name, bare); + return bare; +} + +/** A conflicted merge of `topic` into main on `file`: returns after git stopped. */ +export function conflictOn(r: BridgeRepo, file: string, base: string, ours: string, theirs: string): void { + r.write(file, base); + r.commitAll(`base ${file}`); + r.git("checkout", "-q", "-b", "topic"); + r.write(file, theirs); + r.commitAll(`theirs ${file}`); + r.git("checkout", "-q", "main"); + r.write(file, ours); + r.commitAll(`ours ${file}`); + r.gitTry("merge", "topic"); +} diff --git a/apps/desktop/test/githubBridge.api.test.ts b/apps/desktop/test/githubBridge.api.test.ts new file mode 100644 index 00000000..c2d2822c --- /dev/null +++ b/apps/desktop/test/githubBridge.api.test.ts @@ -0,0 +1,504 @@ +// The desktop's GitHub layer (main/githubBridge.ts) end to end, over a fake +// api.github.com and a stubbed Electron `app.getPath`: signing in (a pasted +// token, the device flow) and out, where the token lives and what a fresh +// launch makes of it, which repository the calls are about (origin, then +// upstream, then any other remote), and the thin data calls — including the +// rule that "not signed in" is empty while a FAILED read is not. + +import { electron } from "./ghFakeElectron"; // first: seeds `electron` for the bridge +import { test } from "node:test"; +import type { TestContext } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fakeGitHub, page, reply, type FakeReply } from "./ghFakeApi"; +import { GitHubBridge } from "../src/main/githubBridge"; +import { isExpectedError } from "../src/main/expectedError"; +import type { RepoStore } from "../src/main/repoStore"; +import { SecretStore } from "@gitstudio/secret-store/secretStore"; + +const DEVICE = "POST https://github.com/login/device/code"; +const TOKEN = "POST https://github.com/login/oauth/access_token"; + +type Remotes = Record; + +/** A RepoStore whose one repository has these remotes (name → URL). */ +/** `null`: no repository open. */ +function repoWith(remotes: Remotes | null, root = "/work/repo"): { store: RepoStore; runs: string[][]; remotes: Remotes } { + const runs: string[][] = []; + const live: Remotes = { ...(remotes ?? {}) }; + const ctx = { + root, + process: { + run: async (args: string[]) => { + runs.push(args); + if (args[0] === "remote" && args[1] === "get-url") { + const url = live[args[2]]; + return url === undefined + ? { code: 2, stdout: "", stderr: `error: No such remote '${args[2]}'` } + : { code: 0, stdout: `${url}\n`, stderr: "" }; + } + if (args.length === 1 && args[0] === "remote") { + return { code: 0, stdout: Object.keys(live).map((n) => `${n}\n`).join(""), stderr: "" }; + } + return { code: 1, stdout: "", stderr: "unexpected" }; + }, + }, + }; + const store = { getContext: () => (remotes === null ? undefined : ctx) } as unknown as RepoStore; + return { store, runs, remotes: live }; +} + +/** A fresh userData folder for this test (the token store lives under it). */ +function userData(t: TestContext): string { + const dir = mkdtempSync(join(tmpdir(), "gs-ghbridge-")); + electron.paths.userData = dir; + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function setup(t: TestContext, routes: Record = {}, remotes: Remotes | null = { origin: "https://github.com/acme/app.git" }) { + const dir = userData(t); + const gh = fakeGitHub(t, routes); + const repo = repoWith(remotes); + return { dir, gh, repo, bridge: new GitHubBridge(repo.store) }; +} + +/** Sign `bridge` in as `login` with a pasted token. */ +async function signIn(bridge: GitHubBridge, gh: ReturnType, login = "ann"): Promise { + gh.route("GET /user", { login }); + const r = await bridge.connect("ghp_pasted"); + assert.equal(r.ok, true, r.message); +} + +// ── Status and which repository ── + +test("with no repository open and no token, the status is signed out with no repo", async (t) => { + const { bridge, gh } = setup(t, {}, null); + assert.deepEqual(await bridge.status(), { connected: false, repo: undefined }); + assert.equal(gh.calls.length, 0, "nothing asked of GitHub without a token"); +}); + +test("the repository is read from origin", async (t) => { + const { bridge } = setup(t); + assert.deepEqual(await bridge.status(), { connected: false, repo: { owner: "acme", repo: "app" } }); +}); + +test("a fork with only an upstream remote is still recognised", async (t) => { + const { bridge, repo } = setup(t, {}, { upstream: "git@github.com:acme/app.git" }); + assert.deepEqual((await bridge.status()).repo, { owner: "acme", repo: "app" }); + assert.deepEqual(repo.runs.map((a) => a.join(" ")), ["remote get-url origin", "remote get-url upstream"]); +}); + +test("with neither origin nor upstream, any other GitHub remote is used", async (t) => { + const { bridge } = setup(t, {}, { mirror: "https://github.com/acme/mirror.git" }); + assert.deepEqual((await bridge.status()).repo, { owner: "acme", repo: "mirror" }); +}); + +test("a repository found once is not asked about again; a miss is asked again next time", async (t) => { + const { bridge, repo } = setup(t, {}, {}); + assert.equal((await bridge.status()).repo, undefined); + repo.remotes.origin = "https://github.com/acme/late.git"; + assert.deepEqual((await bridge.status()).repo, { owner: "acme", repo: "late" }, "a remote added later works at once"); + const before = repo.runs.length; + await bridge.status(); + assert.equal(repo.runs.length, before, "the hit is cached for this root"); +}); + +test("a pre-1.4 keyring token file is deleted unread when no token is stored", async (t) => { + const { bridge, dir } = setup(t); + const legacy = join(dir, "github-token.bin"); + writeFileSync(legacy, "opaque keyring blob"); + const s = await bridge.status(); + assert.equal(s.connected, false, "the blob is not a connection"); + assert.equal(existsSync(legacy), false); +}); + +// ── Signing in with a token ── + +test("a pasted token GitHub accepts signs in, is stored, and is used for every call", async (t) => { + const { bridge, gh, dir } = setup(t, { "GET /user": { login: "ann" } }); + assert.deepEqual(await bridge.connect(" ghp_pasted \n"), { ok: true, login: "ann" }); + assert.equal(gh.calls[0].headers.Authorization, "Bearer ghp_pasted", "trimmed before use"); + assert.equal(bridge.peekToken(), "ghp_pasted"); + assert.deepEqual(await bridge.status(), { connected: true, login: "ann", repo: { owner: "acme", repo: "app" } }); + + const store = new SecretStore(join(dir, "secrets")); + assert.equal(await store.get("github.token"), "ghp_pasted", "encrypted at rest under userData"); + assert.equal(await store.get("github.login"), "ann", "the name is kept beside it"); +}); + +test("a token GitHub rejects is refused as expected and nothing is stored", async (t) => { + const { bridge, dir } = setup(t, { "GET /user": () => reply(401, { message: "Bad credentials" }) }); + const r = await bridge.connect("ghp_typo"); + assert.deepEqual(r, { ok: false, expected: true, message: "That token didn't work — make sure it has 'repo' scope." }); + assert.equal(bridge.peekToken(), undefined); + assert.equal(new SecretStore(join(dir, "secrets")).has("github.token"), false); +}); + +test("a later launch reads the stored token and remembered name, then confirms with GitHub", async (t) => { + const { bridge, gh, repo } = setup(t, { "GET /user": { login: "ann" } }); + await bridge.connect("ghp_pasted"); + + gh.route("GET /user", { login: "ann-renamed" }); + const relaunch = new GitHubBridge(repo.store); + const s = await relaunch.status(); + assert.equal(s.connected, true); + assert.equal(s.login, "ann", "the remembered name answers at once"); + assert.equal(relaunch.peekToken(), "ghp_pasted"); + // GitHub's answer lands behind it; the next status carries the rename. + for (let i = 0; i < 50 && (await relaunch.status()).login !== "ann-renamed"; i++) { + await new Promise((r) => setImmediate(r)); + } + assert.equal((await relaunch.status()).login, "ann-renamed"); +}); + +test("a stored token with no remembered name asks GitHub for it and remembers the answer", async (t) => { + const { dir, repo } = setup(t, { "GET /user": { login: "bob" } }); + await new SecretStore(join(dir, "secrets")).set("github.token", "ghp_stored"); + const s = await new GitHubBridge(repo.store).status(); + assert.deepEqual(s, { connected: true, login: "bob", repo: { owner: "acme", repo: "app" } }); + assert.equal(await new SecretStore(join(dir, "secrets")).get("github.login"), "bob"); +}); + +test("a failed ask for the name leaves a stored token signed in, just nameless", async (t) => { + const { dir, repo } = setup(t, { "GET /user": () => reply(502, {}) }); + await new SecretStore(join(dir, "secrets")).set("github.token", "ghp_stored"); + const s = await new GitHubBridge(repo.store).status(); + assert.equal(s.connected, true); + assert.equal(s.login, undefined); +}); + +test("signing out forgets the token everywhere", async (t) => { + const { bridge, gh, dir } = setup(t); + await signIn(bridge, gh); + writeFileSync(join(dir, "github-token.bin"), "legacy"); + await bridge.disconnect(); + assert.equal(bridge.peekToken(), undefined); + assert.equal((await bridge.status()).connected, false); + const store = new SecretStore(join(dir, "secrets")); + assert.equal(store.has("github.token"), false); + assert.equal(store.has("github.login"), false); + assert.equal(existsSync(join(dir, "github-token.bin")), false); + assert.equal(await bridge.withClientIfUnlocked(async () => "ran"), undefined); +}); + +// ── The device flow ── + +test("device sign-in step 1 hands the renderer GitHub's code", async (t) => { + const { bridge, gh } = setup(t, { + [DEVICE]: { + device_code: "dev-123", + user_code: "ABCD-1234", + verification_uri: "https://github.com/login/device", + verification_uri_complete: "https://github.com/login/device?user_code=ABCD-1234", + expires_in: 600, + interval: 7, + }, + }); + assert.deepEqual(await bridge.deviceStart(), { + ok: true, + userCode: "ABCD-1234", + verificationUri: "https://github.com/login/device", + verificationUriComplete: "https://github.com/login/device?user_code=ABCD-1234", + deviceCode: "dev-123", + interval: 7, + expiresIn: 600, + }); + assert.equal((gh.calls[0].body as { client_id?: string }).client_id !== undefined, true); +}); + +test("a device code GitHub won't issue is an expected failure with its reason", async (t) => { + const { bridge } = setup(t, { [DEVICE]: () => reply(400, { error: "unauthorized_client", error_description: "Device flow disabled" }) }); + assert.deepEqual(await bridge.deviceStart(), { ok: false, expected: true, message: "Device flow disabled" }); +}); + +test("polling before the user has authorized says pending, and a cancel says so", async (t) => { + const { bridge, gh } = setup(t, { [TOKEN]: { error: "authorization_pending" } }); + assert.deepEqual(await bridge.devicePoll({ deviceCode: "dev-123" }), { state: "pending", message: undefined }); + assert.equal((gh.calls[0].body as { device_code: string }).device_code, "dev-123"); + gh.route(TOKEN, { error: "access_denied" }); + assert.deepEqual(await bridge.devicePoll({ deviceCode: "dev-123" }), { state: "denied", message: "Sign-in was cancelled." }); + assert.equal(bridge.peekToken(), undefined); +}); + +test("an authorized poll signs in with the issued token and stores it", async (t) => { + const { bridge, gh, dir } = setup(t, { + [TOKEN]: { access_token: "gho_device", scope: "repo" }, + "GET /user": { login: "dee" }, + }); + assert.deepEqual(await bridge.devicePoll({ deviceCode: "dev-123" }), { state: "authorized", login: "dee" }); + assert.equal(gh.sent("GET", "/user")[0].headers.Authorization, "Bearer gho_device"); + assert.equal(await new SecretStore(join(dir, "secrets")).get("github.token"), "gho_device"); + assert.equal((await bridge.status()).login, "dee"); +}); + +test("an authorized poll whose user read fails is an error and leaves you signed out", async (t) => { + const { bridge } = setup(t, { + [TOKEN]: { access_token: "gho_device" }, + "GET /user": () => reply(500, {}), + }); + assert.deepEqual(await bridge.devicePoll({ deviceCode: "d" }), { + state: "error", + message: "Signed in, but GitHub didn't return a user.", + }); + assert.equal(bridge.peekToken(), undefined); +}); + +test("a poll that can't reach GitHub is an error result, not a throw", async (t) => { + const { bridge } = setup(t, { [TOKEN]: new TypeError("fetch failed") }); + const r = await bridge.devicePoll({ deviceCode: "d" }); + assert.equal(r.state, "error"); + assert.match(r.message ?? "", /Couldn't reach GitHub/); +}); + +// ── Gates for the per-section modules ── + +test("withRepo refuses as expected when signed out, and when the repo isn't on GitHub", async (t) => { + const { bridge, gh } = setup(t, {}, { origin: "" }); + await assert.rejects(bridge.withRepo(async () => 1), (e: Error) => e.message === "Not connected to GitHub." && isExpectedError(e)); + await assert.rejects(bridge.withClient(async () => 1), /Not connected to GitHub/); + await signIn(bridge, gh); + await assert.rejects( + bridge.withRepo(async () => 1), + (e: Error) => e.message === "This repository isn't on github.com." && isExpectedError(e), + ); +}); + +test("withRepo hands the section the signed-in client and the resolved owner/repo", async (t) => { + const { bridge, gh } = setup(t, { "GET /repos/acme/app": { default_branch: "main" } }); + await signIn(bridge, gh); + const got = await bridge.withRepo(async (client, owner, repo) => { + const meta = await client.request<{ default_branch: string }>("GET", `/repos/${owner}/${repo}`); + return `${owner}/${repo}@${meta.default_branch}`; + }); + assert.equal(got, "acme/app@main"); + assert.equal(await bridge.withClient(async (c) => c.currentLogin()), "ann"); + assert.equal(await bridge.withClientIfUnlocked(async (c) => c.currentLogin()), "ann"); +}); + +test("an ambient read never unlocks the token: before sign-in it yields undefined", async (t) => { + const { bridge, gh } = setup(t); + let ran = false; + assert.equal(await bridge.withClientIfUnlocked(async () => (ran = true)), undefined); + assert.equal(ran, false); + assert.equal(gh.calls.length, 0); +}); + +// ── Data calls ── + +const rawPull = (n: number, extra: Record = {}) => ({ + number: n, + title: `PR ${n}`, + body: "desc", + state: "open", + html_url: `https://github.com/acme/app/pull/${n}`, + user: { login: "ann" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + head: { ref: "feat", sha: "h1" }, + base: { ref: "main", sha: "b1" }, + ...extra, +}); +const rawIssue = (n: number) => ({ + number: n, + title: `Issue ${n}`, + body: "ib", + state: "open", + html_url: `https://github.com/acme/app/issues/${n}`, + user: null, + created_at: "2026-01-02T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + comments: 1, +}); + +test("signed out, every list is empty and every detail absent — nothing went wrong", async (t) => { + const { bridge, gh } = setup(t); + assert.deepEqual(await bridge.prList(), []); + assert.deepEqual(await bridge.prCommits(1), []); + assert.deepEqual(await bridge.prConversation(1), []); + assert.deepEqual(await bridge.prChecks(1), []); + assert.deepEqual(await bridge.issueList(), []); + assert.equal(await bridge.issueDetail(1), undefined); + assert.equal(await bridge.prDetail(1), undefined); + assert.equal(await bridge.externalItem({ owner: "x", repo: "y", number: 1, kind: "issue" }), undefined); + assert.deepEqual(await bridge.prMerge({ number: 1, method: "merge" }), { + ok: false, + changed: false, + message: "Not connected to GitHub.", + expected: true, + }); + assert.deepEqual(await bridge.prApprove(1), { ok: false, changed: false, message: "Not connected to GitHub.", expected: true }); + assert.equal(gh.calls.length, 0); +}); + +test("the PR list is the resolved repo's, in the state asked for", async (t) => { + const { bridge, gh } = setup(t, { + "GET /repos/acme/app/pulls?state=all&sort=updated&direction=desc&per_page=100": () => page([rawPull(4), rawPull(3)]), + }); + await signIn(bridge, gh); + assert.deepEqual((await bridge.prList("all")).map((p) => p.number), [4, 3]); +}); + +test("a failed PR list read propagates instead of reading as 'no pull requests'", async (t) => { + const { bridge, gh } = setup(t, { + "GET /repos/acme/app/pulls?state=open&sort=updated&direction=desc&per_page=100": () => reply(403, { message: "API rate limit exceeded" }), + }); + await signIn(bridge, gh); + await assert.rejects(bridge.prList(), /rate limit/); +}); + +test("a PR's detail carries its files and the combined check state", async (t) => { + const { bridge, gh } = setup(t, { + "GET /repos/acme/app/pulls/5": rawPull(5), + "GET /repos/acme/app/pulls/5/files?per_page=100": () => page([{ filename: "a.ts", status: "added", additions: 2, deletions: 0 }]), + "GET /repos/acme/app/commits/h1/status": { state: "failure", total_count: 2 }, + }); + await signIn(bridge, gh); + const d = await bridge.prDetail(5); + assert.equal(d?.pr.number, 5); + assert.deepEqual(d?.files, [{ filename: "a.ts", status: "added", additions: 2, deletions: 0 }]); + assert.equal(d?.checks, "failure"); +}); + +test("a PR detail whose files can't be read is absent, not an empty Files tab", async (t) => { + const { bridge, gh } = setup(t, { + "GET /repos/acme/app/pulls/5": rawPull(5), + "GET /repos/acme/app/pulls/5/files?per_page=100": () => reply(500, {}), + "GET /repos/acme/app/commits/h1/status": { state: "success" }, + }); + await signIn(bridge, gh); + assert.equal(await bridge.prDetail(5), undefined); +}); + +test("commits, conversation and checks read the resolved repo's PR", async (t) => { + const { bridge, gh } = setup(t, { + "GET /repos/acme/app/pulls/6/commits?per_page=100": () => + page([{ sha: "abcdef1234", commit: { message: "one\n\nbody", author: { name: "A", date: "d" } } }]), + "GET /repos/acme/app/issues/6/comments?per_page=100": () => + page([{ id: 1, user: { login: "ann" }, body: "hi", created_at: "2026-01-01" }]), + "GET /repos/acme/app/pulls/6/reviews?per_page=100": () => page([]), + "GET /repos/acme/app/pulls/6": rawPull(6), + "GET /repos/acme/app/commits/h1/check-runs?per_page=100": { check_runs: [{ name: "ci", status: "completed", conclusion: "success" }] }, + }); + await signIn(bridge, gh); + const commits = await bridge.prCommits(6); + assert.deepEqual([commits[0].shortSha, commits[0].message, commits[0].body], ["abcdef1", "one", "body"]); + assert.deepEqual((await bridge.prConversation(6)).map((c) => c.body), ["hi"]); + assert.deepEqual(await bridge.prChecks(6), [{ name: "ci", status: "completed", conclusion: "success", detailsUrl: undefined }]); +}); + +test("a failed commits read propagates; a PR that can't be read has no checks", async (t) => { + const { bridge, gh } = setup(t, { + "GET /repos/acme/app/pulls/6/commits?per_page=100": () => reply(502, {}), + "GET /repos/acme/app/pulls/6": () => reply(404, { message: "Not Found" }), + }); + await signIn(bridge, gh); + await assert.rejects(bridge.prCommits(6), /HTTP 502/); + assert.deepEqual(await bridge.prChecks(6), []); +}); + +test("merging sends the method and reports the working tree may have changed", async (t) => { + const { bridge, gh } = setup(t, { "PUT /repos/acme/app/pulls/7/merge": { merged: true } }); + await signIn(bridge, gh); + assert.deepEqual(await bridge.prMerge({ number: 7, method: "rebase" }), { ok: true, changed: true }); + assert.deepEqual(gh.sent("PUT", "/repos/acme/app/pulls/7/merge")[0].body, { merge_method: "rebase" }); + gh.route("PUT /repos/acme/app/pulls/7/merge", () => reply(405, { message: "Pull Request is not mergeable" })); + assert.deepEqual(await bridge.prMerge({ number: 7, method: "merge" }), { + ok: false, + changed: false, + message: "Pull Request is not mergeable", + }); +}); + +test("approving posts an APPROVE review; a refusal is a result", async (t) => { + const { bridge, gh } = setup(t, { "POST /repos/acme/app/pulls/7/reviews": { id: 1 } }); + await signIn(bridge, gh); + assert.deepEqual(await bridge.prApprove(7), { ok: true, changed: false }); + gh.route("POST /repos/acme/app/pulls/7/reviews", () => reply(422, { message: "Can not approve your own pull request" })); + assert.deepEqual(await bridge.prApprove(7), { + ok: false, + changed: false, + message: "Can not approve your own pull request", + }); +}); + +test("the issue list and an issue's detail are the resolved repo's", async (t) => { + const { bridge, gh } = setup(t, { + "GET /repos/acme/app/issues?state=open&sort=updated&direction=desc&per_page=100": () => page([rawIssue(1), { ...rawIssue(2), pull_request: {} }]), + "GET /repos/acme/app/issues/1": rawIssue(1), + "GET /repos/acme/app/issues/404": () => reply(404, { message: "Not Found" }), + }); + await signIn(bridge, gh); + assert.deepEqual((await bridge.issueList()).map((i) => i.number), [1]); + assert.equal((await bridge.issueDetail(1))?.title, "Issue 1"); + assert.equal(await bridge.issueDetail(404), undefined); +}); + +test("an item from ANY repo is read for the assistant, with its comments", async (t) => { + const { bridge, gh } = setup(t, { + "GET /repos/other/lib/issues/12/comments?per_page=100": () => + page([{ id: 1, user: { login: "zed" }, body: "same here", created_at: "2026-01-03" }]), + "GET /repos/other/lib/pulls/12/reviews?per_page=100": () => + page([{ user: { login: "rev" }, body: "lgtm", state: "APPROVED", submitted_at: "2026-01-04" }]), + "GET /repos/other/lib/pulls/12": rawPull(12, { draft: true, html_url: "https://github.com/other/lib/pull/12" }), + "GET /repos/other/lib/issues/13/comments?per_page=100": () => reply(500, {}), + "GET /repos/other/lib/pulls/13/reviews?per_page=100": () => page([]), + "GET /repos/other/lib/issues/13": rawIssue(13), + }); + await signIn(bridge, gh); + assert.deepEqual(await bridge.externalItem({ owner: "other", repo: "lib", number: 12, kind: "pull" }), { + kind: "pull", + number: 12, + repo: "other/lib", + title: "PR 12", + state: "draft", + body: "desc", + htmlUrl: "https://github.com/other/lib/pull/12", + author: "ann", + createdAt: "2026-01-01T00:00:00Z", + comments: [{ author: "zed", body: "same here", createdAt: "2026-01-03" }], + }); + const issue = await bridge.externalItem({ owner: "other", repo: "lib", number: 13, kind: "issue" }); + assert.equal(issue?.kind, "issue"); + assert.equal(issue?.state, "open"); + assert.equal(issue?.author, null); + assert.deepEqual(issue?.comments, [], "a failed comment read thins the summary, it does not drop the item"); +}); + +test("an external item that can't be read is absent", async (t) => { + const { bridge, gh } = setup(t, { + "GET /repos/other/lib/issues/9/comments?per_page=100": () => page([]), + "GET /repos/other/lib/pulls/9/reviews?per_page=100": () => page([]), + "GET /repos/other/lib/pulls/9": () => reply(404, { message: "Not Found" }), + }); + await signIn(bridge, gh); + assert.equal(await bridge.externalItem({ owner: "other", repo: "lib", number: 9, kind: "pull" }), undefined); +}); + +// ── PR checkout's refusals before git runs ── + +test("checking out a PR with no repository open, or with a bad number, runs nothing", async (t) => { + const closed = setup(t, {}, null); + assert.deepEqual(await closed.bridge.prCheckout(3), { + ok: false, + changed: false, + expected: true, + message: "No repository open.", + }); + const open = setup(t); + for (const bad of [0, -2, 1.5, Number.NaN]) { + assert.deepEqual(await open.bridge.prCheckout(bad), { + ok: false, + changed: false, + message: "That isn't a pull request number.", + }); + } + assert.deepEqual(await open.bridge.prCheckout({ number: 0, stashFirst: "x" }), { + ok: false, + changed: false, + message: "That isn't a pull request number.", + }); + assert.deepEqual(open.repo.runs, [], "no git ran"); +}); diff --git a/apps/desktop/test/githubClient.api.test.ts b/apps/desktop/test/githubClient.api.test.ts new file mode 100644 index 00000000..b047d975 --- /dev/null +++ b/apps/desktop/test/githubClient.api.test.ts @@ -0,0 +1,440 @@ +// GitHubClient against a fake api.github.com: what each call SENDS (method, +// path, headers, body) and what it makes of GitHub's answer — the paging +// rules, the failure policy (network / HTTP / GraphQL), and the PR + issue +// mappers the Pull Requests and Issues views render. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fakeGitHub, page, reply } from "./ghFakeApi"; +import { GitHubClient } from "../src/main/githubClient"; +import { isExpectedError } from "../src/main/expectedError"; + +const pull = (n: number, extra: Record = {}) => ({ + number: n, + title: `PR ${n}`, + body: null, + state: "open", + html_url: `https://github.com/o/r/pull/${n}`, + user: { login: "ann", avatar_url: "https://a/ann" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + head: { ref: "feat", sha: "h".repeat(40) }, + base: { ref: "main", sha: "b".repeat(40) }, + ...extra, +}); + +const issue = (n: number, extra: Record = {}) => ({ + number: n, + title: `Issue ${n}`, + body: "text", + state: "open", + html_url: `https://github.com/o/r/issues/${n}`, + user: { login: "bob" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-03T00:00:00Z", + comments: 2, + ...extra, +}); + +// ── The request primitives ── + +test("every REST call carries the token, the API version and the JSON media type", async (t) => { + const gh = fakeGitHub(t, { "GET /user": { login: "ann" } }); + assert.equal(await gh.client.currentLogin(), "ann"); + const h = gh.calls[0].headers; + assert.equal(h.Authorization, "Bearer ghp_test"); + assert.equal(h.Accept, "application/vnd.github+json"); + assert.equal(h["X-GitHub-Api-Version"], "2022-11-28"); + assert.equal(h["User-Agent"], "GitStudio"); + assert.equal("Content-Type" in h, false, "a GET with no body declares no content type"); +}); + +test("a body is sent as JSON with a content type, and a caller can ask for another media type", async (t) => { + const gh = fakeGitHub(t, { "POST /x": { ok: 1 } }); + const out = await gh.client.request<{ ok: number }>("POST", "/x", { a: 1 }, { accept: "application/vnd.github.text-match+json" }); + assert.deepEqual(out, { ok: 1 }); + assert.deepEqual(gh.calls[0].body, { a: 1 }); + assert.equal(gh.calls[0].headers["Content-Type"], "application/json"); + assert.equal(gh.calls[0].headers.Accept, "application/vnd.github.text-match+json"); +}); + +test("a 204 and an empty 200 both answer undefined rather than failing to parse", async (t) => { + const gh = fakeGitHub(t, { + "DELETE /a": () => reply(204), + "GET /b": () => new Response("", { status: 200 }), + }); + assert.equal(await gh.client.request("DELETE", "/a"), undefined); + assert.equal(await gh.client.request("GET", "/b"), undefined); +}); + +test("with no token every primitive refuses as an expected 'not connected' before any request", async (t) => { + const gh = fakeGitHub(t, {}, null); + const attempts: Array<() => Promise> = [ + () => gh.client.request("GET", "/user"), + () => gh.client.requestBody("POST", "/x", {}), + () => gh.client.graphql("query{viewer{login}}", {}), + () => gh.client.uploadReleaseAsset("o", "r", 1, "a.zip", new Uint8Array([1]), "application/zip"), + ]; + for (const attempt of attempts) { + await assert.rejects(attempt(), (e: Error) => { + assert.equal(e.message, "Not connected to GitHub."); + assert.equal(isExpectedError(e), true); + return true; + }); + } + assert.equal(gh.calls.length, 0, "nothing was sent without a token"); +}); + +test("a request that never reaches GitHub is a network condition, not a TypeError", async (t) => { + const gh = fakeGitHub(t, { + "GET /a": new TypeError("fetch failed"), + "POST /b": new TypeError("fetch failed"), + "POST /graphql": new TypeError("fetch failed"), + "POST https://uploads.github.com/repos/o/r/releases/9/assets?name=a.zip": new TypeError("fetch failed"), + }); + const attempts: Array<() => Promise> = [ + () => gh.client.request("GET", "/a"), + () => gh.client.requestBody("POST", "/b", {}), + () => gh.client.graphql("q", {}), + () => gh.client.uploadReleaseAsset("o", "r", 9, "a.zip", new Uint8Array([1]), "application/zip"), + ]; + for (const attempt of attempts) { + await assert.rejects(attempt(), (e: Error) => { + assert.match(e.message, /Couldn't reach GitHub/); + assert.equal(isExpectedError(e), true); + return true; + }); + } +}); + +test("a non-2xx answer becomes GitHub's own message, on every primitive", async (t) => { + const gh = fakeGitHub(t, { + "GET /a": () => reply(422, { message: "Validation Failed" }), + "PUT /b": () => reply(409, { message: "Merge conflict" }), + "POST /graphql": () => reply(401, { message: "Bad credentials" }), + "POST https://uploads.github.com/repos/o/r/releases/9/assets?name=a.zip": () => reply(422, { message: "already_exists" }), + }); + await assert.rejects(gh.client.request("GET", "/a"), /Validation Failed/); + await assert.rejects(gh.client.requestBody("PUT", "/b", {}), (e: Error) => e.message === "Merge conflict" && isExpectedError(e)); + await assert.rejects(gh.client.graphql("q", {}), /token is invalid or expired/); + await assert.rejects( + gh.client.uploadReleaseAsset("o", "r", 9, "a.zip", new Uint8Array([1]), "application/zip"), + /already_exists/, + ); +}); + +test("requestBody sends the JSON body and ignores whatever comes back", async (t) => { + const gh = fakeGitHub(t, { "PATCH /repos/o/r/issues/1": { number: 1 } }); + assert.equal(await gh.client.requestBody("PATCH", "/repos/o/r/issues/1", { state: "closed" }), undefined); + assert.deepEqual(gh.calls[0].body, { state: "closed" }); + assert.equal(gh.calls[0].headers["Content-Type"], "application/json"); +}); + +test("a release asset goes to uploads.github.com as raw bytes under its own content type", async (t) => { + const key = "POST https://uploads.github.com/repos/my%20org/r/releases/77/assets?name=GitStudio%201.0.dmg"; + const gh = fakeGitHub(t, { [key]: () => reply(201, { id: 5 }) }); + const bytes = new Uint8Array([1, 2, 3]); + await gh.client.uploadReleaseAsset("my org", "r", 77, "GitStudio 1.0.dmg", bytes, "application/x-apple-diskimage"); + assert.deepEqual(gh.unmatched, [], "owner and asset name are URL-encoded"); + assert.equal(gh.calls[0].body, bytes, "the bytes are sent as they are, not JSON-encoded"); + assert.equal(gh.calls[0].headers["Content-Type"], "application/x-apple-diskimage"); + assert.equal(gh.calls[0].headers.Authorization, "Bearer ghp_test"); +}); + +// ── Paging ── + +test("requestPaged follows rel=next and concatenates the pages", async (t) => { + const gh = fakeGitHub(t, { + "GET /l?per_page=2": () => page([1, 2], "/l?per_page=2&page=2"), + "GET /l?per_page=2&page=2": () => page([3, 4], "/l?per_page=2&page=3"), + "GET /l?per_page=2&page=3": () => page([5]), + }); + assert.deepEqual(await gh.client.requestPaged("/l?per_page=2", 5), [1, 2, 3, 4, 5]); + assert.equal(gh.calls.length, 3, "and stops where the link chain ends"); +}); + +test("requestPaged stops at the page cap even when GitHub offers more", async (t) => { + const gh = fakeGitHub(t, { + "GET /l": () => page([1], "/l2"), + "GET /l2": () => page([2], "/l3"), + "GET /l3": () => page([3]), + }); + assert.deepEqual(await gh.client.requestPaged("/l", 2), [1, 2]); + assert.equal(gh.calls.length, 2); +}); + +test("a failed follow-up page keeps what was already read; a failed first page throws", async (t) => { + const gh = fakeGitHub(t, { + "GET /l": () => page(["a", "b"], "/l2"), + "GET /l2": () => reply(502, { message: "Bad gateway" }), + "GET /bad": () => reply(500, { message: "boom" }), + }); + assert.deepEqual(await gh.client.requestPaged("/l", 3), ["a", "b"]); + await assert.rejects(gh.client.requestPaged("/bad", 3), /boom/); +}); + +test("an empty page body reads as no items", async (t) => { + const gh = fakeGitHub(t, { "GET /l": () => new Response("", { status: 200 }) }); + assert.deepEqual(await gh.client.requestPaged("/l", 3), []); +}); + +test("requestPagedKey gathers the named array out of object-bodied pages", async (t) => { + const gh = fakeGitHub(t, { + "GET /runs": () => page({ total_count: 3, workflow_runs: [{ id: 1 }, { id: 2 }] }, "/runs?page=2"), + "GET /runs?page=2": () => page({ total_count: 3, workflow_runs: [{ id: 3 }] }, "/runs?page=3"), + "GET /runs?page=3": () => reply(503, {}), + "GET /none": () => page({ total_count: 0 }), + "GET /empty": () => new Response("", { status: 200 }), + "GET /bad": () => reply(500, {}), + }); + assert.deepEqual(await gh.client.requestPagedKey("/runs", "workflow_runs", 5), [{ id: 1 }, { id: 2 }, { id: 3 }]); + assert.deepEqual(await gh.client.requestPagedKey("/none", "workflow_runs", 5), [], "a missing key is no items"); + assert.deepEqual(await gh.client.requestPagedKey("/empty", "workflow_runs", 5), []); + await assert.rejects(gh.client.requestPagedKey("/bad", "workflow_runs", 5), /HTTP 500/); +}); + +// ── GraphQL ── + +test("graphql posts the query and variables and returns data", async (t) => { + const gh = fakeGitHub(t, { "POST /graphql": { data: { viewer: { login: "ann" } } } }); + const data = await gh.client.graphql<{ viewer: { login: string } }>("query($a:Int){viewer{login}}", { a: 1 }); + assert.equal(data.viewer.login, "ann"); + assert.deepEqual(gh.calls[0].body, { query: "query($a:Int){viewer{login}}", variables: { a: 1 } }); + assert.equal(gh.calls[0].headers.Authorization, "Bearer ghp_test"); +}); + +test("a GraphQL error in a 200 body throws, and a rate limit is an expected condition", async (t) => { + const gh = fakeGitHub(t, { + "POST /graphql": { data: null, errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }] }, + }); + await assert.rejects(gh.client.graphql("q", {}), (e: Error) => { + assert.equal(e.message, "API rate limit exceeded"); + assert.equal(isExpectedError(e), true); + return true; + }); +}); + +// ── User ── + +test("currentLogin answers undefined rather than throwing when GitHub refuses", async (t) => { + const gh = fakeGitHub(t, { "GET /user": () => reply(401, { message: "Bad credentials" }) }); + assert.equal(await gh.client.currentLogin(), undefined); +}); + +// ── Pull requests ── + +test("listPulls asks for the state newest-updated first and maps every page", async (t) => { + const gh = fakeGitHub(t, { + "GET /repos/o/r/pulls?state=closed&sort=updated&direction=desc&per_page=100": () => + page([pull(3, { merged_at: "2026-02-01T00:00:00Z" })], "/repos/o/r/pulls?page=2"), + "GET /repos/o/r/pulls?page=2": () => page([pull(2, { draft: true })]), + }); + const prs = await gh.client.listPulls("o", "r", "closed"); + assert.deepEqual(prs.map((p) => p.number), [3, 2]); + assert.equal(prs[0].mergedAt, "2026-02-01T00:00:00Z"); + assert.equal(prs[1].draft, true); + assert.equal(prs[0].user?.login, "ann"); +}); + +test("listPulls defaults to open pulls", async (t) => { + const gh = fakeGitHub(t, { + "GET /repos/o/r/pulls?state=open&sort=updated&direction=desc&per_page=100": () => page([pull(1)]), + }); + assert.equal((await gh.client.listPulls("o", "r"))[0].title, "PR 1"); +}); + +test("getPull maps one pull request, including a fork's head repository", async (t) => { + const gh = fakeGitHub(t, { + "GET /repos/o/r/pulls/9": pull(9, { + head: { ref: "fix", sha: "1".repeat(40), repo: { full_name: "fork/r" } }, + base: { ref: "main", sha: "2".repeat(40), repo: { full_name: "o/r" } }, + }), + }); + const p = await gh.client.getPull("o", "r", 9); + assert.equal(p.number, 9); + assert.equal(p.headRepoFullName, "fork/r"); + assert.deepEqual(p.head, { ref: "fix", sha: "1".repeat(40) }); +}); + +test("getPullFiles keeps where a renamed file came from, and nothing extra on the rest", async (t) => { + const gh = fakeGitHub(t, { + "GET /repos/o/r/pulls/4/files?per_page=100": () => + page([ + { filename: "new.ts", status: "renamed", additions: 1, deletions: 0, previous_filename: "old.ts" }, + { filename: "a.ts", status: "modified", additions: 3, deletions: 2 }, + ]), + }); + const files = await gh.client.getPullFiles("o", "r", 4); + assert.deepEqual(files, [ + { filename: "new.ts", status: "renamed", additions: 1, deletions: 0, previousFilename: "old.ts" }, + { filename: "a.ts", status: "modified", additions: 3, deletions: 2 }, + ]); +}); + +test("mergePull PUTs the chosen method and approvePull posts an APPROVE review", async (t) => { + const gh = fakeGitHub(t, { + "PUT /repos/o/r/pulls/5/merge": { merged: true }, + "POST /repos/o/r/pulls/5/reviews": { id: 1 }, + }); + await gh.client.mergePull("o", "r", 5, "squash"); + await gh.client.approvePull("o", "r", 5); + assert.deepEqual(gh.sent("PUT", "/repos/o/r/pulls/5/merge")[0].body, { merge_method: "squash" }); + assert.deepEqual(gh.sent("POST", "/repos/o/r/pulls/5/reviews")[0].body, { event: "APPROVE" }); +}); + +test("listPrCommits splits subject from body and flags signed and merge commits", async (t) => { + const sha1 = "a".repeat(40); + const sha2 = "b".repeat(40); + const gh = fakeGitHub(t, { + "GET /repos/o/r/pulls/6/commits?per_page=100": () => + page([ + { + sha: sha1, + commit: { + message: "Fix the parser\n\nIt re-read the block.\n", + author: { name: "Ann A", date: "2026-03-01T00:00:00Z" }, + verification: { verified: true }, + }, + author: { login: "ann", avatar_url: "https://a/ann" }, + parents: [{ sha: "p" }], + }, + { sha: sha2, commit: { message: "Merge main" }, author: null, parents: [{ sha: "p" }, { sha: "q" }] }, + { sha: "c".repeat(40), author: { login: "cat" } }, + ]), + }); + const [a, b, c] = await gh.client.listPrCommits("o", "r", 6); + assert.deepEqual(a, { + sha: sha1, + shortSha: "aaaaaaa", + message: "Fix the parser", + body: "It re-read the block.", + author: "Ann A", + login: "ann", + avatarUrl: "https://a/ann", + date: "2026-03-01T00:00:00Z", + verified: true, + isMerge: false, + }); + assert.equal(b.message, "Merge main"); + assert.equal(b.body, "", "a one-line message has no body"); + assert.equal(b.author, "unknown", "no git author and no account"); + assert.equal(b.isMerge, true, "two parents is a merge"); + assert.equal(b.verified, false); + assert.equal(c.author, "cat", "the account's login stands in for a missing git author"); + assert.equal(c.message, ""); + assert.equal(c.date, ""); +}); + +test("the conversation interleaves comments and submitted reviews by time, dropping pending ones", async (t) => { + const gh = fakeGitHub(t, { + "GET /repos/o/r/issues/8/comments?per_page=100": () => + page([ + { + id: 11, + user: { login: "ann" }, + body: "second", + created_at: "2026-01-02T00:00:00Z", + updated_at: "2026-01-02T01:00:00Z", + author_association: "OWNER", + reactions: { total_count: 1, heart: 1 }, + html_url: "https://github.com/o/r/pull/8#c11", + }, + { id: 12, user: null, created_at: "2026-01-04T00:00:00Z" }, + ]), + "GET /repos/o/r/pulls/8/reviews?per_page=100": () => + page([ + { user: { login: "rev" }, body: "lgtm", state: "APPROVED", submitted_at: "2026-01-01T00:00:00Z" }, + { user: { login: "rev" }, body: "draft", state: "PENDING" }, + { user: null, state: "COMMENTED", submitted_at: "2026-01-03T00:00:00Z" }, + ]), + }); + const conv = await gh.client.listConversation("o", "r", 8); + assert.deepEqual( + conv.map((c) => [c.kind, c.author, c.body]), + [ + ["review", "rev", "lgtm"], + ["comment", "ann", "second"], + ["review", "unknown", ""], + ["comment", "unknown", ""], + ], + ); + assert.equal(conv[0].state, "APPROVED"); + assert.equal(conv[1].id, 11); + assert.equal(conv[1].authorAssociation, "OWNER"); + assert.equal(conv[1].reactions?.heart, 1); + assert.equal(conv[1].htmlUrl, "https://github.com/o/r/pull/8#c11"); + assert.equal(conv[1].updatedAt, "2026-01-02T01:00:00Z"); +}); + +test("the conversation still shows reviews when the comments read fails, and vice versa", async (t) => { + const gh = fakeGitHub(t, { + "GET /repos/o/r/issues/8/comments?per_page=100": () => reply(500, {}), + "GET /repos/o/r/pulls/8/reviews?per_page=100": () => + page([{ user: { login: "rev" }, body: "ok", state: "APPROVED", submitted_at: "2026-01-01T00:00:00Z" }]), + }); + assert.deepEqual((await gh.client.listConversation("o", "r", 8)).map((c) => c.kind), ["review"]); +}); + +test("listCheckRuns maps runs at the ref, and degrades to none when the read fails", async (t) => { + const gh = fakeGitHub(t, { + "GET /repos/o/r/commits/feat%2Fx/check-runs?per_page=100": { + check_runs: [ + { name: "build", status: "completed", conclusion: "success", details_url: "https://ci/1" }, + { name: "lint" }, + ], + }, + "GET /repos/o/r/commits/gone/check-runs?per_page=100": () => reply(404, { message: "No commit found" }), + "GET /repos/o/r/commits/empty/check-runs?per_page=100": {}, + }); + assert.deepEqual(await gh.client.listCheckRuns("o", "r", "feat/x"), [ + { name: "build", status: "completed", conclusion: "success", detailsUrl: "https://ci/1" }, + { name: "lint", status: "", conclusion: "", detailsUrl: undefined }, + ]); + assert.deepEqual(await gh.client.listCheckRuns("o", "r", "gone"), []); + assert.deepEqual(await gh.client.listCheckRuns("o", "r", "empty"), []); +}); + +test("getCombinedStatus reads state and count, and a failure reads as no status", async (t) => { + const gh = fakeGitHub(t, { + "GET /repos/o/r/commits/abc/status": { state: "pending", total_count: 3 }, + "GET /repos/o/r/commits/blank/status": {}, + "GET /repos/o/r/commits/bad/status": () => reply(500, {}), + }); + assert.deepEqual(await gh.client.getCombinedStatus("o", "r", "abc"), { state: "pending", totalCount: 3 }); + assert.deepEqual(await gh.client.getCombinedStatus("o", "r", "blank"), { state: "", totalCount: 0 }); + assert.deepEqual(await gh.client.getCombinedStatus("o", "r", "bad"), { state: "", totalCount: 0 }); +}); + +// ── Issues ── + +test("listOpenIssues drops the pull requests the issues endpoint also returns", async (t) => { + const gh = fakeGitHub(t, { + "GET /repos/o/r/issues?state=open&sort=updated&direction=desc&per_page=100": () => + page([issue(1), issue(2, { pull_request: { url: "x" } }), issue(3, { labels: ["bug", { name: "ui", color: "00ff00" }] })]), + }); + const list = await gh.client.listOpenIssues("o", "r"); + assert.deepEqual(list.map((i) => i.number), [1, 3]); + assert.deepEqual(list[1].labels, [ + { name: "bug", color: "888888" }, + { name: "ui", color: "00ff00" }, + ]); +}); + +test("getIssue maps one issue", async (t) => { + const gh = fakeGitHub(t, { "GET /repos/o/r/issues/7": issue(7, { state: "closed", state_reason: "not_planned" }) }); + const i = await gh.client.getIssue("o", "r", 7); + assert.equal(i.number, 7); + assert.equal(i.stateReason, "not_planned"); + assert.equal(i.user?.login, "bob"); +}); + +test("owner and repo are URL-encoded into every path", async (t) => { + const gh = fakeGitHub(t, { "GET /repos/a%20b/c%23d/issues/1": issue(1) }); + await gh.client.getIssue("a b", "c#d", 1); + assert.deepEqual(gh.unmatched, []); +}); + +test("an empty token is not a connection", async () => { + const c = new GitHubClient(() => ""); + await assert.rejects(c.request("GET", "/user"), /Not connected/); +}); diff --git a/apps/desktop/test/helpers/miniDom.ts b/apps/desktop/test/helpers/miniDom.ts new file mode 100644 index 00000000..11b21d1c --- /dev/null +++ b/apps/desktop/test/helpers/miniDom.ts @@ -0,0 +1,820 @@ +// A deliberately small, honest stand-in for the browser DOM — just enough of it +// for the renderer's framework-free modules (ui.ts, dialogs.ts, contextMenu.ts, +// focusReturn.ts) to build their elements, wire their listeners and move focus +// the way they do in the app, so a test can read what was rendered and drive +// it with clicks and keys. +// +// There is no DOM library in this repository and none is to be added, so this +// implements the subset those modules actually touch and nothing else: +// +// · a node tree with parentNode / childNodes / children, append, insertBefore, +// replaceChildren, replaceWith, remove, contains, isConnected; +// · attributes, with id / className / title / hidden / disabled / tabIndex / +// htmlFor … reflected onto them, a live `classList` and `dataset`; +// · textContent (get = the descendants' text, set = one text node); +// · querySelector(All) / matches / closest for simple selectors: tag, .class, +// #id, [attr], [attr="v"], :not(…), comma lists and the descendant space; +// · events with a real capture → target → bubble path up through document to +// window, stopPropagation, stopImmediatePropagation, once, preventDefault; +// · focus: `document.activeElement`, focus()/blur() firing focus, focusin and +// focusout, and falling back to when the focused node leaves the tree; +// · a disabled