diff --git a/src/main/ipc/worktrees-create-label-only-first-work.test.ts b/src/main/ipc/worktrees-create-label-only-first-work.test.ts new file mode 100644 index 00000000000..a92e2249cce --- /dev/null +++ b/src/main/ipc/worktrees-create-label-only-first-work.test.ts @@ -0,0 +1,205 @@ +import { + labelOnlyCreateRequests, + renameCreatedWorktreeOnFirstWork +} from '../worktree-create-first-work-test-harness' +import { isAutoGeneratedCreatureBranchName } from '../../shared/branch-name-from-work' +import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describeCreatedWorktreeMock, gitExecFileAsyncMock } from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' + +vi.mock('../text-generation/commit-message-text-generation', () => ({ + resolveTextGenerationParams: () => ({ + ok: true, + params: { agentId: 'claude', model: 'fixture' } + }), + generateBranchNameFromContext: async () => ({ success: true, slug: 'fix-auth' }) +})) +vi.mock('../text-generation/commit-message-agent-environment', () => ({ + prepareLocalCommitMessageAgentEnv: async () => ({ ok: true }) +})) + +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + await importOriginal>() + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + await importOriginal>() + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + await importOriginal>() + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + await importOriginal>() + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + await importOriginal>() + ) +) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty/provider/registry', async () => + (await import('./worktrees-test-module-mocks')).ptyRegistryModuleMock() +) +vi.mock('./pty/provider/state-cleanup', async () => + (await import('./worktrees-test-module-mocks')).ptyStateCleanupModuleMock() +) + +describe('desktop label-only creation through first work', () => { + function expectCreatedWorktree(result: unknown): asserts result is CreateWorktreeResult { + expect(result).toEqual( + expect.objectContaining({ + worktree: expect.objectContaining({ + id: expect.any(String), + repoId: 'repo-1', + path: expect.any(String), + head: expect.any(String), + branch: expect.stringMatching(/^refs\/heads\//), + isBare: false, + isMainWorktree: false, + displayName: expect.any(String), + displayNameMode: expect.stringMatching(/^(fixed|automatic)$/), + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: expect.any(Number), + lastActivityAt: expect.any(Number) + }) + }) + ) + } + + let savedMeta: Partial + beforeEach(() => { + setupWorktreeHandlers() + savedMeta = {} + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + workspaceDir: '/workspace', + autoRenameBranchFromWork: true + }) + store.setWorktreeMeta.mockImplementation((_target, updates) => { + savedMeta = { ...savedMeta, ...updates } + return savedMeta + }) + describeCreatedWorktreeMock.mockImplementation(async (_repoPath, path, branch) => ({ + path, + branch: `refs/heads/${branch}`, + head: 'abc123', + isBare: false, + isMainWorktree: false + })) + }) + + it.each(labelOnlyCreateRequests)( + 'creates and renames a creature branch for $name while projecting its label', + async (request) => { + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + ...request + }) + expectCreatedWorktree(result) + const { worktree } = result + const branch = worktree.branch.replace(/^refs\/heads\//, '') + expect(isAutoGeneratedCreatureBranchName(branch)).toBe(true) + expect(worktree.displayName).toBe(request.name) + expect(worktree.displayNameMode).toBe(request.nameWasGenerated ? 'automatic' : 'fixed') + const renamed = await renameCreatedWorktreeOnFirstWork( + worktree, + savedMeta, + gitExecFileAsyncMock + ) + expect(renamed).toMatchObject({ + branch: 'refs/heads/fix-auth', + displayName: request.nameWasGenerated ? 'Fix auth' : request.name, + displayNameMode: 'fixed' + }) + } + ) + + it.each([ + { branchNameOverride: 'feature/exact', enabled: true, branch: 'feature/exact' }, + { branchNameOverride: undefined, enabled: false, branch: 'typed-label' } + ])( + 'preserves branch choice $branch (automatic=$enabled)', + async ({ branchNameOverride, enabled, branch }) => { + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + workspaceDir: '/workspace', + autoRenameBranchFromWork: enabled + }) + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'typed-label', + branchNameOverride + }) + expectCreatedWorktree(result) + const { worktree } = result + expect(worktree).toMatchObject({ + branch: `refs/heads/${branch}`, + displayName: 'typed-label', + displayNameMode: 'fixed' + }) + } + ) +}) diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 740bb078fac..c23371569fd 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -1,16 +1,9 @@ -import { - labelOnlyCreateRequests, - renameCreatedWorktreeOnFirstWork -} from '../worktree-create-first-work-test-harness' -import { isAutoGeneratedCreatureBranchName } from '../../shared/branch-name-from-work' -import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { beforeEach, describe, expect, it, vi } from 'vitest' import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref' import { handleMock, removeHandlerMock, listWorktreesMock, - describeCreatedWorktreeMock, getPullRequestPushTargetMock, gitExecFileAsyncMock } from './worktrees-test-module-mocks' @@ -794,110 +787,3 @@ describe('registerWorktreeHandlers', () => { }) }) }) - -describe('desktop label-only creation through first work', () => { - function expectCreatedWorktree(result: unknown): asserts result is CreateWorktreeResult { - expect(result).toEqual( - expect.objectContaining({ - worktree: expect.objectContaining({ - id: expect.any(String), - repoId: 'repo-1', - path: expect.any(String), - head: expect.any(String), - branch: expect.stringMatching(/^refs\/heads\//), - isBare: false, - isMainWorktree: false, - displayName: expect.any(String), - displayNameMode: expect.stringMatching(/^(fixed|automatic)$/), - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: expect.any(Number), - lastActivityAt: expect.any(Number) - }) - }) - ) - } - - let savedMeta: Partial - beforeEach(() => { - setupWorktreeHandlers() - savedMeta = {} - store.getSettings.mockReturnValue({ - branchPrefix: 'none', - nestWorkspaces: false, - refreshLocalBaseRefOnWorktreeCreate: false, - workspaceDir: '/workspace', - autoRenameBranchFromWork: true - }) - store.setWorktreeMeta.mockImplementation((_target, updates) => { - savedMeta = { ...savedMeta, ...updates } - return savedMeta - }) - describeCreatedWorktreeMock.mockImplementation(async (_repoPath, path, branch) => ({ - path, - branch: `refs/heads/${branch}`, - head: 'abc123', - isBare: false, - isMainWorktree: false - })) - }) - - it.each(labelOnlyCreateRequests)( - 'creates and renames a creature branch for $name while projecting its label', - async (request) => { - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-1', - ...request - }) - expectCreatedWorktree(result) - const { worktree } = result - const branch = worktree.branch.replace(/^refs\/heads\//, '') - expect(isAutoGeneratedCreatureBranchName(branch)).toBe(true) - expect(worktree.displayName).toBe(request.name) - expect(worktree.displayNameMode).toBe(request.nameWasGenerated ? 'automatic' : 'fixed') - const renamed = await renameCreatedWorktreeOnFirstWork( - worktree, - savedMeta, - gitExecFileAsyncMock - ) - expect(renamed).toMatchObject({ - branch: 'refs/heads/fix-auth', - displayName: request.nameWasGenerated ? 'Fix auth' : request.name, - displayNameMode: 'fixed' - }) - } - ) - - it.each([ - { branchNameOverride: 'feature/exact', enabled: true, branch: 'feature/exact' }, - { branchNameOverride: undefined, enabled: false, branch: 'typed-label' } - ])( - 'preserves branch choice $branch (automatic=$enabled)', - async ({ branchNameOverride, enabled, branch }) => { - store.getSettings.mockReturnValue({ - branchPrefix: 'none', - nestWorkspaces: false, - refreshLocalBaseRefOnWorktreeCreate: false, - workspaceDir: '/workspace', - autoRenameBranchFromWork: enabled - }) - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-1', - name: 'typed-label', - branchNameOverride - }) - expectCreatedWorktree(result) - const { worktree } = result - expect(worktree).toMatchObject({ - branch: `refs/heads/${branch}`, - displayName: 'typed-label', - displayNameMode: 'fixed' - }) - } - ) -}) diff --git a/src/main/runtime/runtime-local-worktree-create-pr-conflicts.test.ts b/src/main/runtime/runtime-local-worktree-create-pr-conflicts.test.ts new file mode 100644 index 00000000000..13232794c30 --- /dev/null +++ b/src/main/runtime/runtime-local-worktree-create-pr-conflicts.test.ts @@ -0,0 +1,404 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolve } from 'node:path' +import type { Store } from '../persistence' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' +import type { Repo } from '../../shared/repo-types' +import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' +import { resolveGitAdmissionTier } from '../git/command-runner/git-operation-executor' + +const mocks = vi.hoisted(() => ({ + git: vi.fn(), + rearm: vi.fn(), + routing: vi.fn<() => { wslDistro?: string }>(), + defaultBase: vi.fn(), + hasBase: vi.fn(), + branchName: vi.fn(), + canCheckout: vi.fn(), + pathExists: vi.fn(), + branchConflict: vi.fn(), + githubPr: vi.fn(), + consume: vi.fn(), + add: vi.fn(), + addSparse: vi.fn(), + pushTarget: vi.fn(), + listing: vi.fn(), + remoteBase: vi.fn(), + hasRemoteRef: vi.fn(), + refresh: vi.fn(), + fetch: vi.fn(), + resolveShared: vi.fn<() => Promise>(), + resolveInclude: vi.fn<() => Promise>(), + copyPaths: vi.fn<() => Promise>(), + effectiveHooks: vi.fn(), + created: { + path: '', + head: 'abc123', + branch: 'app', + isBare: false, + isMainWorktree: false + } +})) + +vi.mock('../text-generation/commit-message-text-generation', () => ({ + resolveTextGenerationParams: () => ({ + ok: true, + params: { agentId: 'claude', model: 'fixture' } + }), + generateBranchNameFromContext: async () => ({ success: true, slug: 'fix-auth' }) +})) +vi.mock('../text-generation/commit-message-agent-environment', () => ({ + prepareLocalCommitMessageAgentEnv: async () => ({ ok: true }) +})) + +vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.git })) +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectGitExecOptions: () => ({ cwd: '/repo', ...mocks.routing() }), + getLocalProjectWorktreeGitOptions: mocks.routing, + getWorktreeMirrorDistro: () => undefined +})) +vi.mock('../git/repo', () => ({ + getBaseRefDefault: mocks.defaultBase, + resolveDefaultBaseRefWithLocalGit: mocks.defaultBase, + getBranchConflictKind: mocks.branchConflict +})) +vi.mock('../git/git-username', () => ({ resolveLocalGitUsername: async () => '' })) +vi.mock('../hooks', () => ({ getEffectiveHooks: mocks.effectiveHooks })) +vi.mock('../git/worktree-base-ref-probe', () => ({ hasLocalWorktreeBaseRef: mocks.hasBase })) +vi.mock('./runtime-worktree-create-git', () => ({ + resolveCreateBranchName: mocks.branchName, + canCheckoutExistingLocalBranch: mocks.canCheckout, + getLocalGitHubPrForBranch: mocks.githubPr, + getSelectedHostedReviewForBranch: vi.fn() +})) +vi.mock('./runtime-worktree-filesystem', () => ({ runtimePathExists: mocks.pathExists })) +vi.mock('../worktree-create-preparation', () => ({ consumePreparedWorktreeCreate: mocks.consume })) +vi.mock('../git/worktree', () => ({ addWorktree: mocks.add, addSparseWorktree: mocks.addSparse })) +vi.mock('../ipc/worktree-remote', () => ({ configureCreatedWorktreePushTarget: mocks.pushTarget })) +vi.mock('../ipc/created-worktree-reconciliation', () => ({ resolveCreatedWorktree: mocks.listing })) +vi.mock('../worktree-name-retirement', () => ({ + failedWorktreeCreationNeedsRetirement: vi.fn(), + retireGeneratedWorktreeName: vi.fn() +})) +vi.mock('../git/worktree-shared-directories', () => ({ + resolveWorktreeSharedDirectories: mocks.resolveShared +})) +vi.mock('../git/worktree-include-file', () => ({ + resolveWorktreeIncludePaths: mocks.resolveInclude +})) +vi.mock('../ipc/worktree-symlinks', () => ({ + createWorktreeCopiedPaths: mocks.copyPaths, + createWorktreeLinkedPaths: vi.fn(), + createWorktreeSharedPaths: vi.fn() +})) + +import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' +import type { PreparationRearmHolder } from '../worktree-create-preparation' +import { + createWorktreeCreateTimingRecorder, + type WorktreeCreateTimingRecorder +} from '../worktree-create-timing' +import type { SparsePreset } from '../../shared/worktree/create-types' + +const worktreePath = resolve('/worktrees', 'app') + +const pickedBranch = { + baseBranch: 'origin/main', + branchNameOverride: 'main', + branchIntent: 'open-existing' +} as const + +const writtenMeta: Partial[] = [] +const WEB_PRESET: SparsePreset = { + id: 'preset-web', + repoId: 'repo-1', + name: 'web', + directories: ['apps/web', 'packages/ui'], + createdAt: 0, + updatedAt: 0 +} +const readSparsePresets = vi.fn<(repoId: string) => SparsePreset[]>() + +function createWorktree( + request: Partial = {}, + rearm: PreparationRearmHolder = { fire: () => {} }, + timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder(), + repoOverrides: Partial = {}, + autoRenameBranchFromWork = false +) { + const store = { + getSettings: () => ({ + workspaceDir: '/worktrees', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: '', + autoRenameBranchFromWork + }), + setWorktreeMeta: (_target: HostWorktreeTarget, updates: Partial) => { + writtenMeta.push(updates) + return updates + }, + getSparsePresets: (repoId: string): SparsePreset[] => readSparsePresets(repoId) + } + return createRuntimeLocalManagedWorktree({ + request: { repoSelector: 'repo-1', name: 'app', baseBranch: 'main', ...request }, + repo: { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 0, + ...repoOverrides + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All store methods reached by this isolated create path are supplied above. + store: store as Store, + createdWithAgent: undefined, + resolveRemoteTrackingBase: mocks.remoteBase, + hasRemoteTrackingRef: mocks.hasRemoteRef, + // vi.fn marks returned promises handled; a fresh promise exposes unawaited fetch rejection. + refreshRemoteTrackingBase: async (...refreshArgs) => await mocks.refresh(...refreshArgs), + fetchRemote: async (...fetchArgs) => await mocks.fetch(...fetchArgs), + onWorktreeMetadataPersisted: () => undefined, + rearm, + timing + }) +} + +beforeEach(() => { + vi.resetAllMocks() + writtenMeta.length = 0 + mocks.created.branch = 'app' + mocks.git.mockResolvedValue({ stdout: '', stderr: '' }) + mocks.created.path = worktreePath + mocks.routing.mockReturnValue({}) + mocks.defaultBase.mockImplementation(async () => { + expect(resolveGitAdmissionTier()).toBe('interactive') + return 'main' + }) + mocks.hasBase.mockResolvedValue(true) + mocks.branchName.mockResolvedValue('app') + mocks.canCheckout.mockResolvedValue(false) + mocks.pathExists.mockResolvedValue(false) + mocks.branchConflict.mockResolvedValue(null) + mocks.githubPr.mockResolvedValue(null) + mocks.consume.mockResolvedValue({ status: 'hit', result: {}, rearm: mocks.rearm }) + mocks.add.mockResolvedValue({}) + mocks.addSparse.mockResolvedValue({}) + mocks.listing.mockImplementation(async () => { + expect(resolveGitAdmissionTier()).toBe('interactive') + return { created: mocks.created } + }) + mocks.remoteBase.mockImplementation(async () => { + expect(resolveGitAdmissionTier()).toBe('interactive') + return null + }) + mocks.hasRemoteRef.mockResolvedValue(true) + mocks.refresh.mockResolvedValue({ ok: true }) + mocks.fetch.mockResolvedValue(undefined) + mocks.resolveShared.mockResolvedValue([]) + mocks.resolveInclude.mockResolvedValue(['.env']) + mocks.copyPaths.mockResolvedValue([]) + mocks.effectiveHooks.mockReturnValue(null) + readSparsePresets.mockReturnValue([WEB_PRESET]) +}) + +describe('runtime create PR conflict probe', () => { + const remoteTrackingBase = { + remote: 'origin', + branch: 'main', + base: 'origin/main', + ref: 'refs/remotes/origin/main' + } + + beforeEach(() => { + mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name) + }) + + it.each([1, 10])( + 'keeps the selected name and source through %i occupied paths, without PR eligibility', + async (occupied) => { + const options = { wslDistro: 'Ubuntu' } + mocks.routing.mockReturnValue(options) + mocks.remoteBase.mockResolvedValue(remoteTrackingBase) + mocks.githubPr.mockResolvedValue({ number: 42 }) + let pathChecks = 0 + mocks.pathExists.mockImplementation(async () => pathChecks++ < occupied) + + await createWorktree({ ...pickedBranch, sparseCheckout: { directories: ['src'] } }) + + expect(mocks.remoteBase.mock.calls).toEqual([['/repo', 'origin/main', options]]) + expect(mocks.branchName).toHaveBeenCalledOnce() + expect(mocks.branchConflict.mock.calls).toEqual([['/repo', 'main', 'origin/main', options]]) + expect(mocks.pathExists).toHaveBeenCalledTimes(occupied + 1) + expect(mocks.githubPr).not.toHaveBeenCalled() + expect(mocks.consume).not.toHaveBeenCalled() + expect(mocks.addSparse).toHaveBeenCalledExactlyOnceWith( + '/repo', + resolve('/worktrees', `app-${occupied + 1}`), + 'main', + ['src'], + remoteTrackingBase.base, + false, + expect.objectContaining({ remoteTrackingBase, ...options }) + ) + } + ) + + it.each([true, false])( + 'keeps local eligibility with picker intent (reusable: %s)', + async (reusable) => { + mocks.branchConflict.mockImplementation(async (_path, branch: string) => + branch === 'main' ? 'local' : null + ) + mocks.canCheckout.mockImplementation( + async (_path, branch: string) => reusable && branch === 'main' + ) + mocks.pathExists.mockImplementation(async (path) => path === worktreePath) + mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' }) + + await createWorktree(pickedBranch) + + expect(mocks.add).toHaveBeenCalledWith( + '/repo', + resolve('/worktrees', 'app-2'), + reusable ? 'main' : 'main-2', + 'origin/main', + false, + false, + expect.objectContaining(reusable ? { checkoutExistingBranch: true } : {}) + ) + expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual( + reusable ? [] : ['main-2'] + ) + expect(mocks.add.mock.calls[0][6].checkoutExistingBranch ?? false).toBe(reusable) + } + ) + + it('keeps unflagged legacy, mobile New branch and Advanced collisions on baseline allocation', async () => { + mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' }) + mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name) + mocks.branchConflict.mockImplementation(async (_path, branch: string) => + branch === 'feature/test' ? 'remote' : null + ) + + await createWorktree({ branchNameOverride: 'feature/test', baseBranch: 'origin/main' }) + + expect(mocks.add.mock.calls[0][2]).toBe('feature/test-2') + }) + + it('runs the first name PR lookup while the base fetch is still in flight', async () => { + mocks.remoteBase.mockResolvedValue(remoteTrackingBase) + let finishFetch!: () => void + mocks.refresh.mockReturnValue( + new Promise((resolve) => { + finishFetch = () => resolve({ ok: true }) + }) + ) + let fetchPendingAtLookup = false + mocks.githubPr.mockImplementation(async () => { + fetchPendingAtLookup = mocks.refresh.mock.calls.length === 1 + finishFetch?.() + return null + }) + + await createWorktree() + + expect(fetchPendingAtLookup).toBe(true) + expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app', {}) + expect(mocks.consume).toHaveBeenCalledWith( + expect.objectContaining({ + branch: 'app', + options: expect.objectContaining({ remoteTrackingBase }) + }) + ) + }) + + it('suffixes past a PR on the first name', async () => { + mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null) + + await createWorktree() + + expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app', 'app-2']) + expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-2' })) + }) + + it.each([undefined, 'Ubuntu'])( + 'probes the next name for a PR once the first name collides, routed for %s', + async (wslDistro) => { + const routing = wslDistro ? { wslDistro } : {} + mocks.routing.mockReturnValue(routing) + mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null) + + await createWorktree() + + expect(mocks.githubPr).toHaveBeenCalledOnce() + expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app-2', routing) + } + ) + + it('skips a later name that already has a PR', async () => { + mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null) + mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null) + + await createWorktree() + + expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app-2', 'app-3']) + expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-3' })) + }) + + it('reports the naming failure when the started base fetch also fails', async () => { + mocks.remoteBase.mockResolvedValue(remoteTrackingBase) + mocks.refresh.mockRejectedValue(new Error('offline')) + mocks.branchConflict.mockResolvedValue('local') + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + try { + await expect(createWorktree()).rejects.toThrow('Branch "app-') + await new Promise((resolve) => setImmediate(resolve)) + expect(mocks.refresh).toHaveBeenCalledOnce() + expect(unhandled).not.toHaveBeenCalled() + expect(mocks.consume).not.toHaveBeenCalled() + } finally { + process.off('unhandledRejection', unhandled) + } + }) + + it('runs the first name PR lookup while the origin fetch is still in flight', async () => { + mocks.hasBase.mockResolvedValue(false) + let finishFetch!: () => void + mocks.fetch.mockReturnValue( + new Promise((resolve) => { + finishFetch = resolve + }) + ) + let fetchPendingAtLookup = false + mocks.githubPr.mockImplementation(async () => { + fetchPendingAtLookup = mocks.fetch.mock.calls.length === 1 + finishFetch?.() + return null + }) + + await createWorktree() + + expect(fetchPendingAtLookup).toBe(true) + expect(mocks.fetch).toHaveBeenCalledWith('/repo', 'origin', {}) + expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app' })) + }) + + it('reports the naming failure when the started origin fetch also fails', async () => { + mocks.hasBase.mockResolvedValue(false) + mocks.fetch.mockRejectedValue(new Error('offline')) + mocks.branchConflict.mockResolvedValue('local') + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + try { + await expect(createWorktree()).rejects.toThrow('Branch "app-') + await new Promise((resolve) => setImmediate(resolve)) + expect(mocks.fetch).toHaveBeenCalledOnce() + expect(unhandled).not.toHaveBeenCalled() + expect(mocks.consume).not.toHaveBeenCalled() + } finally { + process.off('unhandledRejection', unhandled) + } + }) +}) diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index 65209c2ffa8..d10d57e3774 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -518,207 +518,6 @@ describe('runtime create Git priority', () => { }) }) -describe('runtime create PR conflict probe', () => { - const remoteTrackingBase = { - remote: 'origin', - branch: 'main', - base: 'origin/main', - ref: 'refs/remotes/origin/main' - } - - beforeEach(() => { - mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name) - }) - - it.each([1, 10])( - 'keeps the selected name and source through %i occupied paths, without PR eligibility', - async (occupied) => { - const options = { wslDistro: 'Ubuntu' } - mocks.routing.mockReturnValue(options) - mocks.remoteBase.mockResolvedValue(remoteTrackingBase) - mocks.githubPr.mockResolvedValue({ number: 42 }) - let pathChecks = 0 - mocks.pathExists.mockImplementation(async () => pathChecks++ < occupied) - - await createWorktree({ ...pickedBranch, sparseCheckout: { directories: ['src'] } }) - - expect(mocks.remoteBase.mock.calls).toEqual([['/repo', 'origin/main', options]]) - expect(mocks.branchName).toHaveBeenCalledOnce() - expect(mocks.branchConflict.mock.calls).toEqual([['/repo', 'main', 'origin/main', options]]) - expect(mocks.pathExists).toHaveBeenCalledTimes(occupied + 1) - expect(mocks.githubPr).not.toHaveBeenCalled() - expect(mocks.consume).not.toHaveBeenCalled() - expect(mocks.addSparse).toHaveBeenCalledExactlyOnceWith( - '/repo', - resolve('/worktrees', `app-${occupied + 1}`), - 'main', - ['src'], - remoteTrackingBase.base, - false, - expect.objectContaining({ remoteTrackingBase, ...options }) - ) - } - ) - - it.each([true, false])( - 'keeps local eligibility with picker intent (reusable: %s)', - async (reusable) => { - mocks.branchConflict.mockImplementation(async (_path, branch: string) => - branch === 'main' ? 'local' : null - ) - mocks.canCheckout.mockImplementation( - async (_path, branch: string) => reusable && branch === 'main' - ) - mocks.pathExists.mockImplementation(async (path) => path === worktreePath) - mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' }) - - await createWorktree(pickedBranch) - - expect(mocks.add).toHaveBeenCalledWith( - '/repo', - resolve('/worktrees', 'app-2'), - reusable ? 'main' : 'main-2', - 'origin/main', - false, - false, - expect.objectContaining(reusable ? { checkoutExistingBranch: true } : {}) - ) - expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual( - reusable ? [] : ['main-2'] - ) - expect(mocks.add.mock.calls[0][6].checkoutExistingBranch ?? false).toBe(reusable) - } - ) - - it('keeps unflagged legacy, mobile New branch and Advanced collisions on baseline allocation', async () => { - mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' }) - mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name) - mocks.branchConflict.mockImplementation(async (_path, branch: string) => - branch === 'feature/test' ? 'remote' : null - ) - - await createWorktree({ branchNameOverride: 'feature/test', baseBranch: 'origin/main' }) - - expect(mocks.add.mock.calls[0][2]).toBe('feature/test-2') - }) - - it('runs the first name PR lookup while the base fetch is still in flight', async () => { - mocks.remoteBase.mockResolvedValue(remoteTrackingBase) - let finishFetch!: () => void - mocks.refresh.mockReturnValue( - new Promise((resolve) => { - finishFetch = () => resolve({ ok: true }) - }) - ) - let fetchPendingAtLookup = false - mocks.githubPr.mockImplementation(async () => { - fetchPendingAtLookup = mocks.refresh.mock.calls.length === 1 - finishFetch?.() - return null - }) - - await createWorktree() - - expect(fetchPendingAtLookup).toBe(true) - expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app', {}) - expect(mocks.consume).toHaveBeenCalledWith( - expect.objectContaining({ - branch: 'app', - options: expect.objectContaining({ remoteTrackingBase }) - }) - ) - }) - - it('suffixes past a PR on the first name', async () => { - mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null) - - await createWorktree() - - expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app', 'app-2']) - expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-2' })) - }) - - it.each([undefined, 'Ubuntu'])( - 'probes the next name for a PR once the first name collides, routed for %s', - async (wslDistro) => { - const routing = wslDistro ? { wslDistro } : {} - mocks.routing.mockReturnValue(routing) - mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null) - - await createWorktree() - - expect(mocks.githubPr).toHaveBeenCalledOnce() - expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app-2', routing) - } - ) - - it('skips a later name that already has a PR', async () => { - mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null) - mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null) - - await createWorktree() - - expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app-2', 'app-3']) - expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-3' })) - }) - - it('reports the naming failure when the started base fetch also fails', async () => { - mocks.remoteBase.mockResolvedValue(remoteTrackingBase) - mocks.refresh.mockRejectedValue(new Error('offline')) - mocks.branchConflict.mockResolvedValue('local') - const unhandled = vi.fn() - process.on('unhandledRejection', unhandled) - try { - await expect(createWorktree()).rejects.toThrow('Branch "app-') - await new Promise((resolve) => setImmediate(resolve)) - expect(mocks.refresh).toHaveBeenCalledOnce() - expect(unhandled).not.toHaveBeenCalled() - expect(mocks.consume).not.toHaveBeenCalled() - } finally { - process.off('unhandledRejection', unhandled) - } - }) - - it('runs the first name PR lookup while the origin fetch is still in flight', async () => { - mocks.hasBase.mockResolvedValue(false) - let finishFetch!: () => void - mocks.fetch.mockReturnValue( - new Promise((resolve) => { - finishFetch = resolve - }) - ) - let fetchPendingAtLookup = false - mocks.githubPr.mockImplementation(async () => { - fetchPendingAtLookup = mocks.fetch.mock.calls.length === 1 - finishFetch?.() - return null - }) - - await createWorktree() - - expect(fetchPendingAtLookup).toBe(true) - expect(mocks.fetch).toHaveBeenCalledWith('/repo', 'origin', {}) - expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app' })) - }) - - it('reports the naming failure when the started origin fetch also fails', async () => { - mocks.hasBase.mockResolvedValue(false) - mocks.fetch.mockRejectedValue(new Error('offline')) - mocks.branchConflict.mockResolvedValue('local') - const unhandled = vi.fn() - process.on('unhandledRejection', unhandled) - try { - await expect(createWorktree()).rejects.toThrow('Branch "app-') - await new Promise((resolve) => setImmediate(resolve)) - expect(mocks.fetch).toHaveBeenCalledOnce() - expect(unhandled).not.toHaveBeenCalled() - expect(mocks.consume).not.toHaveBeenCalled() - } finally { - process.off('unhandledRejection', unhandled) - } - }) -}) - describe('runtime create base without a tracking ref', () => { const originMain = { remote: 'origin', diff --git a/src/main/updater-latest-stable-feed.test.ts b/src/main/updater-latest-stable-feed.test.ts new file mode 100644 index 00000000000..960af361324 --- /dev/null +++ b/src/main/updater-latest-stable-feed.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installNetRequestFetchAdapter } from './updater-net-request.fixture' + +const originalPlatform = process.platform +const originalArch = process.arch +const { netFetchMock, netRequestMock } = vi.hoisted(() => ({ + netFetchMock: vi.fn(), + netRequestMock: vi.fn() +})) +vi.mock('electron', () => ({ net: { fetch: netFetchMock, request: netRequestMock } })) + +const latestUrl = 'https://github.com/stablyai/orca/releases/latest' +const releaseUrl = 'https://github.com/stablyai/orca/releases/download/v1.4.241' + +function serveLatestRelease(location = '/stablyai/orca/releases/tag/v1.4.241'): void { + netFetchMock.mockImplementation(async (url: string) => { + if (url === latestUrl) { + return new Response(null, { status: 302, headers: { location } }) + } + if (url.endsWith('.yml')) { + return new Response( + 'version: 1.4.241\nfiles:\n - url: Orca-1.4.241-arm64-mac.zip\n sha512: test\n' + ) + } + return new Response(null, { status: 200 }) + }) +} + +describe('latest stable release fallback', () => { + beforeEach(() => { + netFetchMock.mockReset() + netRequestMock.mockReset() + installNetRequestFetchAdapter(netRequestMock, netFetchMock) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + Object.defineProperty(process, 'arch', { value: originalArch }) + }) + + it.each([ + { platform: 'darwin', arch: 'arm64', manifest: 'latest-mac.yml' }, + { platform: 'win32', arch: 'x64', manifest: 'latest.yml' }, + { platform: 'linux', arch: 'x64', manifest: 'latest-linux.yml' }, + { platform: 'linux', arch: 'arm64', manifest: 'latest-linux-arm64.yml' } + ])( + 'probes the concrete $platform/$arch release before returning its tag', + async ({ platform, arch, manifest }) => { + Object.defineProperty(process, 'platform', { value: platform }) + Object.defineProperty(process, 'arch', { value: arch }) + serveLatestRelease() + const { fetchLatestStableReleaseTag } = await import('./updater-prerelease-feed') + + await expect(fetchLatestStableReleaseTag()).resolves.toBe('v1.4.241') + expect(netFetchMock).toHaveBeenCalledWith( + latestUrl, + expect.objectContaining({ method: 'HEAD', redirect: 'manual' }) + ) + expect(netFetchMock).toHaveBeenCalledWith(`${releaseUrl}/${manifest}`, expect.anything()) + expect(netFetchMock).toHaveBeenCalledWith( + `${releaseUrl}/Orca-1.4.241-arm64-mac.zip`, + expect.objectContaining({ method: 'HEAD' }) + ) + } + ) + + it.each([ + 'https://other.example/stablyai/orca/releases/tag/v1.4.241', + '/other/repo/releases/tag/v1.4.241', + '/stablyai/orca/releases/tag/v1.4.242-rc.1', + '/stablyai/orca/releases/tag/mobile-android-v0.0.59', + '/stablyai/orca/releases/tag/%', + '/stablyai/orca/releases/latest' + ])('rejects an invalid fallback redirect %s before probing assets', async (location) => { + serveLatestRelease(location) + const { fetchLatestStableReleaseTag } = await import('./updater-prerelease-feed') + + await expect(fetchLatestStableReleaseTag()).resolves.toBeNull() + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) + + it.each(['manifest', 'installer'])('rejects a release with a missing %s', async (missing) => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + serveLatestRelease() + const respond = netFetchMock.getMockImplementation() + netFetchMock.mockImplementation(async (url: string) => { + if (url.endsWith(missing === 'manifest' ? '.yml' : '.zip')) { + return new Response(null, { status: 404 }) + } + return respond?.(url) + }) + const { fetchLatestStableReleaseTag } = await import('./updater-prerelease-feed') + + await expect(fetchLatestStableReleaseTag()).resolves.toBeNull() + }) + + it('keeps a network failure retryable instead of returning a moving URL', async () => { + netFetchMock.mockRejectedValueOnce(new Error('network unavailable')) + const { fetchLatestStableReleaseTag } = await import('./updater-prerelease-feed') + await expect(fetchLatestStableReleaseTag()).resolves.toBeNull() + + serveLatestRelease('https://github.com/stablyai/orca/releases/tag/v1.4.241') + await expect(fetchLatestStableReleaseTag()).resolves.toBe('v1.4.241') + }) +}) diff --git a/src/main/updater-net-request.fixture.ts b/src/main/updater-net-request.fixture.ts index a4c06739d2e..966e05387e5 100644 --- a/src/main/updater-net-request.fixture.ts +++ b/src/main/updater-net-request.fixture.ts @@ -4,6 +4,7 @@ import { vi, type Mock } from 'vitest' type NetFetchResponse = { ok?: boolean status?: number + headers?: { get: (name: string) => string | null } } export function installNetRequestFetchAdapter(netRequestMock: Mock, netFetchMock: Mock): void { @@ -21,7 +22,13 @@ export function installNetRequestFetchAdapter(netRequestMock: Mock, netFetchMock (response: NetFetchResponse) => { const status = response.status ?? (response.ok ? 200 : 503) if (options.redirect === 'manual' && status >= 300 && status < 400) { - request.emit('redirect', status, options.method ?? 'GET', 'https://redirect.test', {}) + request.emit( + 'redirect', + status, + options.method ?? 'GET', + response.headers?.get('location') ?? 'https://redirect.test', + {} + ) // Electron emits an error after cancelling an unfollowed manual redirect. request.emit('error', new Error('Redirect was cancelled')) } else { diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts index de9ad309316..a0fb415b3e3 100644 --- a/src/main/updater-prerelease-feed.ts +++ b/src/main/updater-prerelease-feed.ts @@ -104,17 +104,19 @@ function getManifestAssetNames(manifestText: string): string[] { type ReleaseReadiness = 'ready' | 'not-ready' | 'unavailable' -function getGitHubReleaseAssetReadiness(assetUrl: string): Promise { +type ReleaseHeadResponse = { statusCode: number; redirectUrl?: string } + +function requestGitHubReleaseHead(assetUrl: string): Promise { return new Promise((resolve) => { const request = net.request({ method: 'HEAD', url: assetUrl, redirect: 'manual' }) let settled = false - const settle = (readiness: ReleaseReadiness): void => { + const settle = (response: ReleaseHeadResponse | null): void => { if (settled) { return } settled = true clearTimeout(timeout) - resolve(readiness) + resolve(response) } const timeout = setTimeout(() => { try { @@ -122,31 +124,35 @@ function getGitHubReleaseAssetReadiness(assetUrl: string): Promise { + request.on('redirect', (statusCode, _method, redirectUrl) => { // GitHub's redirect proves existence without fetching the signed storage URL. - settle(statusCode >= 300 && statusCode < 400 ? 'ready' : 'unavailable') + settle({ statusCode, redirectUrl }) }) - request.on('response', (response) => { - settle( - response.statusCode === 404 - ? 'not-ready' - : response.statusCode >= 200 && response.statusCode < 300 - ? 'ready' - : 'unavailable' - ) - }) - request.on('error', () => settle('unavailable')) + request.on('response', (response) => settle({ statusCode: response.statusCode })) + request.on('error', () => settle(null)) try { request.end() } catch { - settle('unavailable') + settle(null) } }) } +async function getGitHubReleaseAssetReadiness(assetUrl: string): Promise { + const response = await requestGitHubReleaseHead(assetUrl) + if (!response) { + return 'unavailable' + } + const ready = + response.redirectUrl !== undefined + ? response.statusCode >= 300 && response.statusCode < 400 + : response.statusCode >= 200 && response.statusCode < 300 + return response.statusCode === 404 ? 'not-ready' : ready ? 'ready' : 'unavailable' +} + async function getReleaseAssetReadiness(tag: string, assetName: string): Promise { const isRelativeAsset = !/^https?:\/\//i.test(assetName) const isGitHubReleaseAsset = @@ -208,6 +214,30 @@ async function getPlatformManifestReadiness(tag: string): Promise { + try { + // Resolve only the release-page redirect; installer redirects continue on to signed storage URLs. + const latestUrl = 'https://github.com/stablyai/orca/releases/latest' + const response = await requestGitHubReleaseHead(latestUrl) + if (!response?.redirectUrl || response.statusCode < 300 || response.statusCode >= 400) { + return null + } + const target = new URL(response.redirectUrl, latestUrl) + const match = target.pathname.match(/^\/stablyai\/orca\/releases\/tag\/([^/]+)$/) + if (target.origin !== 'https://github.com' || !match) { + return null + } + const tag = decodeURIComponent(match[1]) + const version = normalizeTagToVersion(tag) + if (!isValidVersion(version) || isPrereleaseVersion(version)) { + return null + } + return (await getPlatformManifestReadiness(tag)) === 'ready' ? tag : null + } catch { + return null + } +} + // Concrete tags keep manifests and downloaded assets on the same release. type FetchNewerReleaseTagOptions = { includePrerelease?: boolean diff --git a/src/main/updater-test-harness.ts b/src/main/updater-test-harness.ts index ce4a9478e0e..bb3ec9d4299 100644 --- a/src/main/updater-test-harness.ts +++ b/src/main/updater-test-harness.ts @@ -59,6 +59,7 @@ type UpdaterModuleFactories = { } updaterPrereleaseFeed: () => { fetchNewerReleaseTagsWithReadiness: (...args: unknown[]) => Promise + fetchLatestStableReleaseTag: () => Promise getReleaseDownloadUrl: (tag: string) => string } localBuildSwitch: () => { chooseLocalBuild: UpdaterSpy } @@ -82,6 +83,7 @@ export type UpdaterMocks = { armExitWatchdogMock: UpdaterSpy disarmExitWatchdogMock: UpdaterSpy fetchNewerReleaseTagsMock: UpdaterSpy + fetchLatestStableReleaseTagMock: Mock<() => Promise> chooseLocalBuildMock: UpdaterSpy startLocalBuildFeedMock: UpdaterSpy closeLocalBuildFeedMock: UpdaterSpy @@ -204,6 +206,7 @@ export function createUpdaterMocks(): UpdaterMocks { const armExitWatchdogMock = vi.fn() const disarmExitWatchdogMock = vi.fn() const fetchNewerReleaseTagsMock = vi.fn() + const fetchLatestStableReleaseTagMock = vi.fn<() => Promise>() const chooseLocalBuildMock = vi.fn() const startLocalBuildFeedMock = vi.fn() const closeLocalBuildFeedMock = vi.fn() @@ -234,6 +237,7 @@ export function createUpdaterMocks(): UpdaterMocks { disarmUpdateInstallExitWatchdog: disarmExitWatchdogMock }), updaterPrereleaseFeed: () => ({ + fetchLatestStableReleaseTag: fetchLatestStableReleaseTagMock, fetchNewerReleaseTagsWithReadiness: async (...args: unknown[]) => { const result = await fetchNewerReleaseTagsMock(...args) return Array.isArray(result) @@ -275,6 +279,7 @@ export function createUpdaterMocks(): UpdaterMocks { shouldApplyNudgeMock.mockReset().mockReturnValue(false) fetchChangelogMock.mockReset().mockResolvedValue(null) fetchNewerReleaseTagsMock.mockReset().mockResolvedValue([]) + fetchLatestStableReleaseTagMock.mockReset().mockResolvedValue('v1.0.51') chooseLocalBuildMock.mockReset() closeLocalBuildFeedMock.mockReset() startLocalBuildFeedMock.mockReset().mockResolvedValue({ @@ -308,6 +313,7 @@ export function createUpdaterMocks(): UpdaterMocks { armExitWatchdogMock, disarmExitWatchdogMock, fetchNewerReleaseTagsMock, + fetchLatestStableReleaseTagMock, chooseLocalBuildMock, startLocalBuildFeedMock, closeLocalBuildFeedMock, diff --git a/src/main/updater.build-channel-selection.test.ts b/src/main/updater.build-channel-selection.test.ts index 368507f5c67..3ff6fac892d 100644 --- a/src/main/updater.build-channel-selection.test.ts +++ b/src/main/updater.build-channel-selection.test.ts @@ -219,7 +219,7 @@ describe('updater', () => { expect(autoUpdaterMock.disableDifferentialDownload).toBe(false) expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' + url: 'https://github.com/stablyai/orca/releases/download/v1.0.51' }) } ) @@ -262,7 +262,7 @@ describe('updater', () => { }) expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' + url: 'https://github.com/stablyai/orca/releases/download/v1.0.51' }) } ) @@ -302,7 +302,7 @@ describe('updater', () => { }) expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' + url: 'https://github.com/stablyai/orca/releases/download/v1.0.51' }) } ) @@ -356,7 +356,7 @@ describe('updater', () => { expect(send).toHaveBeenCalledWith('updater:status', { state: 'not-available' }) expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' + url: 'https://github.com/stablyai/orca/releases/download/v1.0.51' }) } ) @@ -402,7 +402,7 @@ describe('updater', () => { }) expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' + url: 'https://github.com/stablyai/orca/releases/download/v1.0.51' }) } ) diff --git a/src/main/updater.check-failure.test.ts b/src/main/updater.check-failure.test.ts index 4c5cf169942..4ba5713ce5a 100644 --- a/src/main/updater.check-failure.test.ts +++ b/src/main/updater.check-failure.test.ts @@ -178,10 +178,18 @@ describe('updater check failure handling', () => { killAllPtyMock.mockReset() vi.unstubAllGlobals() vi.useRealTimers() - netFetchMock.mockReset().mockResolvedValue({ - ok: true, - status: 200, - text: () => Promise.resolve('') + netFetchMock.mockReset().mockImplementation(async (url: string) => { + if (url.endsWith('/releases/latest')) { + return new Response(null, { + status: 302, + headers: { location: 'https://github.com/stablyai/orca/releases/tag/v1.0.51' } + }) + } + return new Response( + url.endsWith('.atom') + ? '' + : 'version: 1.0.51\nfiles:\n - url: orca-windows-setup.exe\n' + ) }) netRequestMock.mockReset() installNetRequestFetchAdapter(netRequestMock, netFetchMock) diff --git a/src/main/updater.feed-attempt-lifetime.test.ts b/src/main/updater.feed-attempt-lifetime.test.ts index 6210888f18e..beb726c5751 100644 --- a/src/main/updater.feed-attempt-lifetime.test.ts +++ b/src/main/updater.feed-attempt-lifetime.test.ts @@ -2,8 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader' import { updaterMainWindow } from './updater-test-main-window' -const { autoUpdaterMock, fetchNewerReleaseTagsMock, moduleFactories, resetUpdaterMocks } = - await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) +const { + autoUpdaterMock, + fetchNewerReleaseTagsMock, + fetchLatestStableReleaseTagMock, + moduleFactories, + resetUpdaterMocks +} = await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) vi.mock('electron', () => moduleFactories.electron()) vi.mock('electron-updater', () => moduleFactories.electronUpdater()) @@ -100,6 +105,29 @@ describe('updater feed preflight ownership', () => { } ) + it('does not let an expired stable fallback replace a newer feed', async () => { + let finish = (_tag: string | null): void => {} + fetchLatestStableReleaseTagMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const updater = await setup() + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(45_000) + fetchNewerReleaseTagsMock.mockResolvedValueOnce({ tags: ['v3.0.0'], state: 'ready' }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + autoUpdaterMock.setFeedURL.mockClear() + + finish('v2.0.0') + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled() + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + it.each([ { result: { tags: ['v3.0.0'], state: 'ready' }, @@ -107,7 +135,7 @@ describe('updater feed preflight ownership', () => { }, { result: { tags: [], state: 'no-newer' }, - url: 'https://github.com/stablyai/orca/releases/latest/download' + url: 'https://github.com/stablyai/orca/releases/download/v1.0.51' } ])('keeps the active $result.state feed choice', async ({ result, url }) => { fetchNewerReleaseTagsMock.mockResolvedValueOnce(result) diff --git a/src/main/updater.install-failure-cause.test.ts b/src/main/updater.install-failure-cause.test.ts index d88da9c51f0..25c6e15c855 100644 --- a/src/main/updater.install-failure-cause.test.ts +++ b/src/main/updater.install-failure-cause.test.ts @@ -94,6 +94,14 @@ vi.mock('electron', () => ({ })) vi.mock('electron-updater', () => ({ autoUpdater: autoUpdaterMock })) +vi.mock('./updater-prerelease-feed', () => ({ + fetchNewerReleaseTagsWithReadiness: vi + .fn() + .mockResolvedValue({ tags: ['v1.4.241'], state: 'ready' }), + getReleaseDownloadUrl: (tag: string) => + `https://github.com/stablyai/orca/releases/download/${tag}` +})) + vi.mock('./electron-updater-loader', () => ({ loadElectronAutoUpdater: () => autoUpdaterMock })) diff --git a/src/main/updater.mac-install.test.ts b/src/main/updater.mac-install.test.ts index bfe1d5d8139..0816c9664a5 100644 --- a/src/main/updater.mac-install.test.ts +++ b/src/main/updater.mac-install.test.ts @@ -111,6 +111,14 @@ vi.mock('electron-updater', () => ({ autoUpdater: autoUpdaterMock })) +vi.mock('./updater-prerelease-feed', () => ({ + fetchNewerReleaseTagsWithReadiness: vi + .fn() + .mockResolvedValue({ tags: ['v1.4.241'], state: 'ready' }), + getReleaseDownloadUrl: (tag: string) => + `https://github.com/stablyai/orca/releases/download/${tag}` +})) + vi.mock('./electron-updater-loader', () => ({ loadElectronAutoUpdater: () => autoUpdaterMock })) diff --git a/src/main/updater.publishing-window-feed.test.ts b/src/main/updater.publishing-window-feed.test.ts index 1cdc98e12c4..3009bc6f2b5 100644 --- a/src/main/updater.publishing-window-feed.test.ts +++ b/src/main/updater.publishing-window-feed.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader' +import { updaterMainWindow } from './updater-test-main-window' const { appMock, @@ -7,6 +8,7 @@ const { fetchNudgeMock, shouldApplyNudgeMock, fetchNewerReleaseTagsMock, + fetchLatestStableReleaseTagMock, moduleFactories, resetUpdaterMocks } = await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) @@ -83,9 +85,10 @@ describe('updater', () => { expect(autoUpdaterMock.allowPrerelease).not.toBe(true) }) - it('falls back to /releases/latest/download when the atom resolver returns null', async () => { + it('pins a stable fallback when Atom has no newer prerelease', async () => { appMock.getVersion.mockReturnValue('1.3.19-rc.6') fetchNewerReleaseTagsMock.mockResolvedValue([]) + fetchLatestStableReleaseTagMock.mockResolvedValue('v1.3.19') autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) const { setupAutoUpdater, checkForUpdatesFromMenu } = await loadUpdaterModule() @@ -100,7 +103,7 @@ describe('updater', () => { }) expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' + url: 'https://github.com/stablyai/orca/releases/download/v1.3.19' }) }) @@ -136,13 +139,14 @@ describe('updater', () => { ]) }) - it('keeps Atom feed outages on the existing moving-feed fallback', async () => { + it('pins the latest stable release during an Atom feed outage', async () => { appMock.getVersion.mockReturnValue('1.4.141') fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'unavailable', unavailableReason: 'feed' }) + fetchLatestStableReleaseTagMock.mockResolvedValue('v1.4.142') const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } const { setupAutoUpdater, checkForUpdatesFromMenu } = await loadUpdaterModule() @@ -155,7 +159,7 @@ describe('updater', () => { }) expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' + url: 'https://github.com/stablyai/orca/releases/download/v1.4.142' }) expect(sendMock).not.toHaveBeenCalledWith( 'updater:status', @@ -163,6 +167,28 @@ describe('updater', () => { ) }) + it('does not launch a moving feed when the stable fallback cannot be resolved', async () => { + fetchLatestStableReleaseTagMock.mockResolvedValue(null) + const sendMock = vi.fn() + const { setupAutoUpdater, checkForUpdatesFromMenu } = await loadUpdaterModule() + setupAutoUpdater(updaterMainWindow(sendMock), { + getLastUpdateCheckAt: () => Date.now() + }) + autoUpdaterMock.setFeedURL.mockClear() + + checkForUpdatesFromMenu() + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: "Couldn't reach the update server. Try again in a few minutes.", + userInitiated: true + }) + }) + expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled() + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + }) + it('uses last-good concrete feed when a user-initiated check lands during publishing', async () => { appMock.getVersion.mockReturnValue('1.4.26') fetchNewerReleaseTagsMock.mockResolvedValue({ diff --git a/src/main/updater/updater-release-feed.ts b/src/main/updater/updater-release-feed.ts index b4f21b64def..3769b13e901 100644 --- a/src/main/updater/updater-release-feed.ts +++ b/src/main/updater/updater-release-feed.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { + fetchLatestStableReleaseTag, fetchNewerReleaseTagsWithReadiness, getReleaseDownloadUrl } from '../updater-prerelease-feed' @@ -207,10 +208,19 @@ export abstract class UpdaterReleaseFeed extends UpdaterInstallExecution { } this.clearPrereleaseFallbackContext() this.clearPublishingWindowLastGoodCheck() - const url = 'https://github.com/stablyai/orca/releases/latest/download' - console.info( - `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) + const stableTag = await fetchLatestStableReleaseTag() + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return 'not-available' + } + if (!stableTag) { + throw new ReleaseFeedPreflightError( + 'manifest-unavailable', + 'default', + 'Unable to resolve a ready stable release on GitHub' + ) + } + const url = getReleaseDownloadUrl(stableTag) + console.info(`[updater] stable fallback feed pinned: current=${currentVersion} → ${url}`) autoUpdater.setFeedURL({ provider: 'generic', url }) return 'ready' } diff --git a/src/renderer/src/components/native-chat/StructuredChatPaneMenuItems.tsx b/src/renderer/src/components/native-chat/StructuredChatPaneMenuItems.tsx new file mode 100644 index 00000000000..8008af94ca6 --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredChatPaneMenuItems.tsx @@ -0,0 +1,57 @@ +import { PanelBottomClose, PanelRightClose, X } from 'lucide-react' +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut +} from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { useStructuredChatPaneActions } from './use-structured-chat-pane-actions' +import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem' + +export function StructuredChatPaneMenuItems({ + pane, + leadingSeparator, + splitShortcutLabels, + resolveOrcaSessionId +}: { + splitShortcutLabels?: { right: string; down: string } + resolveOrcaSessionId?: () => Promise + pane: { tabId: string; worktreeId: string; groupId: string } + leadingSeparator: boolean +}) { + const actions = useStructuredChatPaneActions(pane.tabId, pane.worktreeId, pane.groupId) + const hasSplitPanes = useAppStore( + (state) => (state.groupsByWorktree[pane.worktreeId]?.length ?? 0) > 1 + ) + return ( + <> + {leadingSeparator ? : null} + + + {translate('auto.components.cmd.j.quick.actions.splitChatRight', 'Split Chat Right')} + {splitShortcutLabels?.right} + + + + {translate('auto.components.cmd.j.quick.actions.splitChatDown', 'Split Chat Down')} + {splitShortcutLabels?.down} + + {resolveOrcaSessionId ? ( + <> + + + + ) : null} + {hasSplitPanes && actions.canClosePane ? ( + <> + + + + {actions.closeLabel} + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index a1ce81e53c9..091ca58f7a7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -8,16 +8,7 @@ import { type RefObject } from 'react' import { chatMathSelectionText } from '../sidebar/chat-markdown-math-selection' -import { - Clipboard, - Copy, - GitFork, - Image as ImageIcon, - PanelBottomClose, - PanelRightClose, - SquareTerminal, - X -} from 'lucide-react' +import { Clipboard, Copy, GitFork, Image as ImageIcon, SquareTerminal } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -35,11 +26,11 @@ import { import { isMacPlatform } from './native-chat-shortcut' import { formatOptionalPrimaryShortcutLabel } from '@/hooks/useShortcutLabel' import { useAppStore } from '@/store' -import { useStructuredChatPaneActions } from './use-structured-chat-pane-actions' import { isEditableTarget } from '@/lib/editable-target' import { findEditablePrimarySelectionPasteTarget } from '@/lib/primary-selection-paste' import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem' import { NativeChatSelectionQuote } from './NativeChatSelectionQuote' +import { StructuredChatPaneMenuItems } from './StructuredChatPaneMenuItems' import { NativeChatPaneMenuItems } from './NativeChatPaneMenuItems' import type { NativeChatComposerHandle } from './native-chat-composer-types' import { AgentSessionContinuationMenuItem } from '@/components/terminal-pane/AgentSessionContinuationMenuItem' @@ -343,51 +334,6 @@ export function useNativeChatContextMenu({ } } -function StructuredChatPaneMenuItems({ - pane, - leadingSeparator, - splitShortcutLabels, - resolveOrcaSessionId -}: Pick & { - pane: NonNullable - leadingSeparator: boolean -}) { - const actions = useStructuredChatPaneActions(pane.tabId, pane.worktreeId, pane.groupId) - const hasSplitPanes = useAppStore( - (state) => (state.groupsByWorktree[pane.worktreeId]?.length ?? 0) > 1 - ) - return ( - <> - {leadingSeparator ? : null} - - - {translate('auto.components.cmd.j.quick.actions.splitChatRight', 'Split Chat Right')} - {splitShortcutLabels?.right} - - - - {translate('auto.components.cmd.j.quick.actions.splitChatDown', 'Split Chat Down')} - {splitShortcutLabels?.down} - - {resolveOrcaSessionId ? ( - <> - - - - ) : null} - {hasSplitPanes && actions.canClosePane ? ( - <> - - - - {actions.closeLabel} - - - ) : null} - - ) -} - function keepSelectionThroughMenuPress(event: React.PointerEvent): void { // Preserve selection on menu-opening presses; otherwise it collapses before contextmenu fires. if (