mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
The row keeps #24606's "Sending…" in the time's slot and #24918's muted not-sent line; the notices keep both: an entry still sending reads as sending, and a recorded rejection no entry carries reads as not sent.
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type * as ReactModule from 'react'
|
||||
import type * as GhUtils from '../../src/main/github/gh-utils'
|
||||
import type * as IssueMetadata from '../../src/renderer/src/hooks/useIssueMetadata'
|
||||
import type { GitHubWorkItem } from '../../src/shared/github/work-item-types'
|
||||
import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types'
|
||||
import type { TaskSourceContext } from '../../src/shared/task-source-context'
|
||||
import type { Repo } from '../../src/shared/repo-types'
|
||||
|
||||
const fixture = vi.hoisted(() => {
|
||||
const state: {
|
||||
loads: { key: string | null; load: () => Promise<unknown[]> }[]
|
||||
requests: { args: string[]; host?: string }[]
|
||||
gh: ReturnType<typeof vi.fn>
|
||||
apiUpdate: ReturnType<typeof vi.fn>
|
||||
preference: 'origin' | 'upstream'
|
||||
} = { loads: [], requests: [], gh: vi.fn(), apiUpdate: vi.fn(), preference: 'upstream' }
|
||||
return state
|
||||
})
|
||||
|
||||
vi.mock('react', async (original) => ({
|
||||
...(await original<typeof ReactModule>()),
|
||||
useState: (initial: unknown) => [typeof initial === 'function' ? initial() : initial, vi.fn()],
|
||||
useMemo: <T>(value: () => T) => value(),
|
||||
useCallback: <T>(value: T) => value,
|
||||
useRef: <T>(initial: T) => ({ current: initial }),
|
||||
useEffect: vi.fn()
|
||||
}))
|
||||
vi.mock('zustand/react/shallow', () => ({ useShallow: <T>(value: T) => value }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: unknown) => unknown) =>
|
||||
selector({ patchWorkItem: vi.fn(), patchProjectRowContent: vi.fn() }),
|
||||
{ getState: () => ({ recordFeatureInteraction: vi.fn() }) }
|
||||
)
|
||||
}))
|
||||
vi.mock('@/lib/repo-runtime-owner', () => ({
|
||||
getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null })
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({
|
||||
useGitHubDuplicateIssueCandidates: () => []
|
||||
}))
|
||||
vi.mock('@/components/github/github-work-item-comment-mutations', () => ({
|
||||
notifyWorkItemDetailsMutation: vi.fn()
|
||||
}))
|
||||
vi.mock('@/hooks/useIssueMetadata', async (original) => ({
|
||||
...(await original<typeof IssueMetadata>()),
|
||||
useImmediateMutation: () => ({ isPending: () => false, run: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/hooks/useMetadataListRequest', () => ({
|
||||
useMetadataListRequest: <T>(args: { cacheKey: string | null; load: () => Promise<T[]> }) => {
|
||||
fixture.loads.push({ key: args.cacheKey, load: args.load })
|
||||
return { data: [], loading: false, error: null }
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/github/gh-utils', async (original) => ({
|
||||
...(await original<typeof GhUtils>()),
|
||||
ghExecFileAsync: fixture.gh,
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn(),
|
||||
getOwnerRepoForRemote: async (_path: string, remote: string) => ({
|
||||
owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner',
|
||||
repo: 'widgets'
|
||||
})
|
||||
}))
|
||||
vi.mock('../../src/main/git/remote-name-listing', () => ({
|
||||
shouldProbeGitRemote: async () => true
|
||||
}))
|
||||
|
||||
import { GHEditSection } from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section'
|
||||
import {
|
||||
runGHEditLabelToggle,
|
||||
runGHEditStateChange
|
||||
} from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations'
|
||||
import { findTaskPageDialogWorkItem } from '../../src/renderer/src/components/task-page-cache-selectors'
|
||||
import { getTaskPageRepoSourceContext } from '../../src/renderer/src/components/task-page-source-context'
|
||||
import { workItemsCacheKey } from '../../src/renderer/src/store/github/cache-identity'
|
||||
import { createTestStore } from '../../src/renderer/src/store/slices/github-slice-test-harness'
|
||||
import { getTaskSourceCacheScope } from '../../src/shared/task-source-context'
|
||||
import { listLabels, listAssignableUsers } from '../../src/main/github/issue-field-options'
|
||||
import { useRepoLabels, useRepoAssignees } from '../../src/renderer/src/hooks/useIssueMetadata'
|
||||
import { updateIssue } from '../../src/main/github/issue-update'
|
||||
import { materializeTaskPageItemList } from '../../src/renderer/src/components/task-page-github-work-item-mutations'
|
||||
import {
|
||||
resetTaskPageGitHubMutationRegistryForTests,
|
||||
setTaskPageGitHubMutationQueryKey
|
||||
} from '../../src/renderer/src/components/task-page-github-work-item-mutation-registry'
|
||||
|
||||
function renderEditSection(props: Parameters<typeof GHEditSection>[0]): void {
|
||||
renderToStaticMarkup(createElement(GHEditSection, props))
|
||||
}
|
||||
|
||||
const registeredRepo: Repo = {
|
||||
id: 'repo-1',
|
||||
path: join(tmpdir(), 'orca-opened-issue-repository-fixture'),
|
||||
displayName: 'widgets',
|
||||
badgeColor: 'primary',
|
||||
addedAt: 1,
|
||||
upstream: { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' }
|
||||
}
|
||||
|
||||
function sourceFor(preference: 'origin' | 'upstream'): TaskSourceContext {
|
||||
const source = getTaskPageRepoSourceContext(
|
||||
{ ...registeredRepo, issueSourcePreference: preference },
|
||||
'github'
|
||||
)
|
||||
if (!source) {
|
||||
throw new Error('Registered fixture must produce a source context')
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
const sourceContext = sourceFor('origin')
|
||||
const fork: GitHubWorkItem = {
|
||||
id: 'issue:5',
|
||||
type: 'issue',
|
||||
number: 5,
|
||||
title: 'FORK title',
|
||||
state: 'open',
|
||||
url: 'https://github.com/fork-owner/widgets/issues/5',
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null,
|
||||
repoId: 'repo-1'
|
||||
}
|
||||
const upstream: GitHubWorkItem = {
|
||||
...fork,
|
||||
title: 'UPSTREAM title',
|
||||
url: 'https://github.com/upstream-owner/widgets/issues/5'
|
||||
}
|
||||
const issueRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
|
||||
|
||||
type MetadataArgs = { repoPath: string; ownerRepo?: GitHubOwnerRepo }
|
||||
|
||||
beforeEach(() => {
|
||||
fixture.preference = 'upstream'
|
||||
fixture.loads = []
|
||||
fixture.requests = []
|
||||
resetTaskPageGitHubMutationRegistryForTests()
|
||||
setTaskPageGitHubMutationQueryKey('current-upstream-list')
|
||||
fixture.gh.mockReset()
|
||||
fixture.gh.mockImplementation(async (args: string[], options: { host?: string }) => {
|
||||
fixture.requests.push({ args, host: options.host })
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
fixture.apiUpdate = vi.fn((args: Parameters<typeof window.api.gh.updateIssue>[0]) =>
|
||||
updateIssue(
|
||||
args.repoPath,
|
||||
args.number,
|
||||
args.updates,
|
||||
null,
|
||||
{},
|
||||
fixture.preference,
|
||||
args.ownerRepo
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
gh: {
|
||||
updateIssue: fixture.apiUpdate,
|
||||
listLabels: (args: MetadataArgs) =>
|
||||
listLabels(args.repoPath, fixture.preference, null, {}, args.ownerRepo),
|
||||
listAssignableUsers: (args: MetadataArgs) =>
|
||||
listAssignableUsers(args.repoPath, fixture.preference, null, {}, args.ownerRepo)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetTaskPageGitHubMutationRegistryForTests()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ openedItem: fork, listItem: upstream, preference: 'upstream' },
|
||||
{ openedItem: upstream, listItem: fork, preference: 'origin' },
|
||||
{ openedItem: fork, listItem: fork, preference: 'origin' }
|
||||
] as const)(
|
||||
'scopes $openedItem.title labels under $preference to its canonical list row',
|
||||
async ({ openedItem, listItem, preference }) => {
|
||||
fixture.preference = preference
|
||||
expect(sourceFor('upstream')).toEqual(sourceContext)
|
||||
const store = createTestStore()
|
||||
const key = workItemsCacheKey(
|
||||
registeredRepo.id,
|
||||
36,
|
||||
'',
|
||||
getTaskSourceCacheScope(sourceFor('upstream'))
|
||||
)
|
||||
store.setState({
|
||||
workItemsCache: { [key]: { data: [listItem], fetchedAt: Date.now() } }
|
||||
})
|
||||
const opened =
|
||||
findTaskPageDialogWorkItem(store.getState().workItemsCache, {
|
||||
id: openedItem.id,
|
||||
repoId: openedItem.repoId,
|
||||
url: openedItem.url
|
||||
}) ?? openedItem
|
||||
expect(opened.url).toBe(openedItem.url)
|
||||
const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' }
|
||||
let mutation: Promise<unknown> = Promise.resolve()
|
||||
runGHEditLabelToggle({
|
||||
itemId: opened.id,
|
||||
itemNumber: opened.number,
|
||||
itemRepoId: opened.repoId,
|
||||
repoPath: registeredRepo.path,
|
||||
sourceContext,
|
||||
projectOrigin: undefined,
|
||||
issueRepo: target,
|
||||
label: 'fork-only-label',
|
||||
localLabels: [],
|
||||
run: async (_key, options) => {
|
||||
options.onOptimistic?.()
|
||||
mutation = options.mutate()
|
||||
await mutation
|
||||
options.onSuccess?.()
|
||||
return true
|
||||
},
|
||||
onLabelsChange: vi.fn(),
|
||||
patchWorkItem: store.getState().patchWorkItem,
|
||||
patchProjectRowIfNeeded: vi.fn(),
|
||||
onMutated: vi.fn()
|
||||
})
|
||||
await mutation
|
||||
expect(fixture.requests[0].args).toContain(`${target.owner}/widgets`)
|
||||
expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target }))
|
||||
expect(store.getState().workItemsCache[key]?.data?.[0].labels).toEqual(
|
||||
listItem.url === openedItem.url ? ['fork-only-label'] : []
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ openedItem: fork, owner: 'fork-owner', preference: 'origin' },
|
||||
{ openedItem: fork, owner: 'fork-owner', preference: 'upstream' },
|
||||
{ openedItem: upstream, owner: 'upstream-owner', preference: 'origin' },
|
||||
{ openedItem: upstream, owner: 'upstream-owner', preference: 'upstream' }
|
||||
] as const)(
|
||||
'loads $owner picker candidates while preference=$preference',
|
||||
async ({ preference, openedItem, owner }) => {
|
||||
fixture.preference = preference
|
||||
renderEditSection({
|
||||
item: openedItem,
|
||||
repoPath: registeredRepo.path,
|
||||
repoId: fork.repoId,
|
||||
sourceContext,
|
||||
projectOrigin: undefined,
|
||||
localState: 'open',
|
||||
localLabels: [],
|
||||
assignees: [],
|
||||
onStateChange: vi.fn(),
|
||||
onLabelsChange: vi.fn(),
|
||||
onMutated: vi.fn(),
|
||||
onUse: vi.fn()
|
||||
})
|
||||
for (const request of fixture.loads.filter((load) => load.key !== null)) {
|
||||
await request.load()
|
||||
}
|
||||
expect(
|
||||
fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/')))
|
||||
).toEqual([`repos/${owner}/widgets/labels`, `repos/${owner}/widgets/assignees?per_page=100`])
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ openedItem: fork, listItem: fork },
|
||||
{ openedItem: fork, listItem: upstream },
|
||||
{ openedItem: upstream, listItem: fork },
|
||||
{ openedItem: upstream, listItem: upstream }
|
||||
])(
|
||||
'a $openedItem.title close only controls its own row while search lags (list=$listItem.title)',
|
||||
async ({ openedItem, listItem }) => {
|
||||
const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' }
|
||||
let pending = Promise.resolve()
|
||||
runGHEditStateChange({
|
||||
newState: 'closed',
|
||||
localState: 'open',
|
||||
itemId: fork.id,
|
||||
itemNumber: fork.number,
|
||||
itemRepoId: fork.repoId,
|
||||
repoPath: registeredRepo.path,
|
||||
sourceContext,
|
||||
projectOrigin: undefined,
|
||||
issueRepo: target,
|
||||
run: (_key, options) => {
|
||||
pending = (async () => {
|
||||
options.onOptimistic?.()
|
||||
await options.mutate()
|
||||
options.onSuccess?.()
|
||||
})()
|
||||
return pending
|
||||
},
|
||||
onStateChange: vi.fn(),
|
||||
patchWorkItem: vi.fn(),
|
||||
patchProjectRowIfNeeded: vi.fn(),
|
||||
onMutated: vi.fn()
|
||||
})
|
||||
await pending
|
||||
expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target }))
|
||||
const displayed = materializeTaskPageItemList({
|
||||
networkItems: [listItem],
|
||||
previousItems: [listItem],
|
||||
queryKey: 'current-upstream-list'
|
||||
})
|
||||
expect(displayed[0]?.state).toBe(listItem.url === openedItem.url ? 'closed' : 'open')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps ordinary metadata caches distinct by canonical repository and host', () => {
|
||||
const identities = [
|
||||
issueRepo,
|
||||
{ ...issueRepo, owner: 'upstream-owner' },
|
||||
{ ...issueRepo, host: 'ghe.example:8443' }
|
||||
]
|
||||
for (const ownerRepo of identities) {
|
||||
useRepoLabels(registeredRepo.path, registeredRepo.id, { ownerRepo })
|
||||
useRepoAssignees(registeredRepo.path, registeredRepo.id, { ownerRepo })
|
||||
}
|
||||
expect(new Set(fixture.loads.filter((_, i) => i % 2 === 0).map((load) => load.key)).size).toBe(3)
|
||||
expect(new Set(fixture.loads.filter((_, i) => i % 2 === 1).map((load) => load.key)).size).toBe(3)
|
||||
})
|
||||
|
||||
it('keeps metadata requests without an explicit target compatible', async () => {
|
||||
useRepoLabels(registeredRepo.path, registeredRepo.id)
|
||||
useRepoAssignees(registeredRepo.path, registeredRepo.id)
|
||||
for (const request of fixture.loads) {
|
||||
await request.load()
|
||||
}
|
||||
expect(fixture.loads.map((load) => load.key)).toEqual([registeredRepo.id, registeredRepo.id])
|
||||
expect(
|
||||
fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/')))
|
||||
).toEqual([
|
||||
'repos/upstream-owner/widgets/labels',
|
||||
'repos/upstream-owner/widgets/assignees?per_page=100'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps Project row metadata on the existing slug route', async () => {
|
||||
const labels = vi.fn().mockResolvedValue({ ok: true, labels: [] })
|
||||
const users = vi.fn().mockResolvedValue({ ok: true, users: [] })
|
||||
vi.stubGlobal('window', {
|
||||
api: { gh: { listLabelsBySlug: labels, listAssignableUsersBySlug: users } }
|
||||
})
|
||||
renderEditSection({
|
||||
item: fork,
|
||||
repoPath: registeredRepo.path,
|
||||
repoId: fork.repoId,
|
||||
sourceContext,
|
||||
projectOrigin: {
|
||||
owner: 'project-owner',
|
||||
repo: 'outside',
|
||||
host: 'ghe.example',
|
||||
number: fork.number,
|
||||
type: 'issue',
|
||||
projectId: 'project-1',
|
||||
projectItemId: 'row-1',
|
||||
cacheKey: 'project-key'
|
||||
},
|
||||
localState: 'open',
|
||||
localLabels: [],
|
||||
assignees: [],
|
||||
onStateChange: vi.fn(),
|
||||
onLabelsChange: vi.fn(),
|
||||
onMutated: vi.fn(),
|
||||
onUse: vi.fn()
|
||||
})
|
||||
for (const request of fixture.loads.filter((load) => load.key !== null)) {
|
||||
await request.load()
|
||||
}
|
||||
expect(labels).toHaveBeenCalledWith({
|
||||
owner: 'project-owner',
|
||||
repo: 'outside',
|
||||
host: 'ghe.example'
|
||||
})
|
||||
expect(users).toHaveBeenCalledWith({
|
||||
owner: 'project-owner',
|
||||
repo: 'outside',
|
||||
host: 'ghe.example'
|
||||
})
|
||||
expect(fixture.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls back a rejected fork label without changing the upstream row', async () => {
|
||||
const store = createTestStore()
|
||||
const key = workItemsCacheKey(registeredRepo.id, 36, '', getTaskSourceCacheScope(sourceContext))
|
||||
store.setState({ workItemsCache: { [key]: { data: [upstream, fork], fetchedAt: 1 } } })
|
||||
fixture.gh.mockRejectedValueOnce(new Error('Fixture rejects label'))
|
||||
let pending = Promise.resolve(false)
|
||||
const observed: string[][][] = []
|
||||
runGHEditLabelToggle({
|
||||
itemId: fork.id,
|
||||
itemNumber: fork.number,
|
||||
itemRepoId: fork.repoId,
|
||||
repoPath: registeredRepo.path,
|
||||
sourceContext,
|
||||
projectOrigin: undefined,
|
||||
issueRepo,
|
||||
label: 'fork-only-label',
|
||||
localLabels: [],
|
||||
run: (_key, options) => {
|
||||
pending = (async () => {
|
||||
options.onOptimistic?.()
|
||||
observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? [])
|
||||
try {
|
||||
await options.mutate()
|
||||
return true
|
||||
} catch {
|
||||
options.onRevert?.()
|
||||
observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? [])
|
||||
return false
|
||||
}
|
||||
})()
|
||||
return pending
|
||||
},
|
||||
onLabelsChange: vi.fn(),
|
||||
patchWorkItem: store.getState().patchWorkItem,
|
||||
patchProjectRowIfNeeded: vi.fn(),
|
||||
onMutated: vi.fn()
|
||||
})
|
||||
expect(await pending).toBe(false)
|
||||
expect(observed).toEqual([
|
||||
[[], ['fork-only-label']],
|
||||
[[], []]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not fall back to upstream metadata for an invalid explicit repository', async () => {
|
||||
const invalid = { owner: '../escape', repo: 'widgets', host: 'github.com' }
|
||||
await expect(listLabels(registeredRepo.path, 'upstream', null, {}, invalid)).resolves.toEqual([])
|
||||
await expect(
|
||||
listAssignableUsers(registeredRepo.path, 'upstream', null, {}, invalid)
|
||||
).resolves.toEqual([])
|
||||
expect(fixture.requests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as GhUtils from '../../src/main/github/gh-utils'
|
||||
import type * as ReactModule from 'react'
|
||||
import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem } from '../../src/shared/github/work-item-types'
|
||||
import type { GitHubIssueUpdate } from '../../src/shared/issue-mutation-types'
|
||||
|
||||
const fixture = vi.hoisted(() => {
|
||||
const state: {
|
||||
callbacks: unknown[]
|
||||
mutation: Promise<unknown> | null
|
||||
gh: ReturnType<typeof vi.fn>
|
||||
apiUpdate: ReturnType<typeof vi.fn>
|
||||
patch: ReturnType<typeof vi.fn>
|
||||
preference: 'origin' | 'upstream'
|
||||
localGitOptions: { wslDistro?: string }
|
||||
requests: { args: string[]; host?: string; cwd?: string; wslDistro?: string }[]
|
||||
} = {
|
||||
callbacks: [],
|
||||
mutation: null,
|
||||
gh: vi.fn(),
|
||||
apiUpdate: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
preference: 'origin',
|
||||
localGitOptions: {},
|
||||
requests: []
|
||||
}
|
||||
return state
|
||||
})
|
||||
|
||||
vi.mock('react', async (original) => ({
|
||||
...(await original<typeof ReactModule>()),
|
||||
useState: (value: unknown) => [typeof value === 'function' ? value() : value, vi.fn()],
|
||||
useMemo: <T>(getValue: () => T) => getValue(),
|
||||
useCallback: <T>(callback: T) => {
|
||||
fixture.callbacks.push(callback)
|
||||
return callback
|
||||
}
|
||||
}))
|
||||
vi.mock('zustand/react/shallow', () => ({ useShallow: <T>(selector: T) => selector }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
patchWorkItem: fixture.patch,
|
||||
patchProjectRowContent: fixture.patch,
|
||||
repos: [],
|
||||
settings: {}
|
||||
}),
|
||||
{ getState: () => ({ recordFeatureInteraction: vi.fn() }) }
|
||||
)
|
||||
}))
|
||||
vi.mock('@/lib/repo-runtime-owner', () => ({
|
||||
getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null })
|
||||
}))
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: vi.fn(),
|
||||
PopoverContent: vi.fn(),
|
||||
PopoverTrigger: vi.fn()
|
||||
}))
|
||||
vi.mock('@/hooks/useIssueMetadata', () => ({
|
||||
useRepoAssignees: () => ({ data: [], loading: false, error: null }),
|
||||
useImmediateMutation: () => ({
|
||||
isPending: () => false,
|
||||
run: (_key: string, spec: { mutate: () => Promise<unknown> }) => {
|
||||
fixture.mutation = spec.mutate()
|
||||
}
|
||||
})
|
||||
}))
|
||||
vi.mock('@/hooks/useGitHubSlugMetadata', () => ({
|
||||
useRepoAssigneesBySlug: () => ({
|
||||
data: [{ login: 'octo', name: null, avatarUrl: '' }],
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('@/components/github/work-item-state-presentation', () => ({ ReviewerAvatar: vi.fn() }))
|
||||
vi.mock('../../src/main/github/gh-utils', async (original) => ({
|
||||
...(await original<typeof GhUtils>()),
|
||||
ghExecFileAsync: fixture.gh,
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn(),
|
||||
getOwnerRepoForRemote: async (_path: string, remote: string) => ({
|
||||
owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner',
|
||||
repo: 'widgets'
|
||||
})
|
||||
}))
|
||||
vi.mock('../../src/main/git/remote-name-listing', () => ({
|
||||
shouldProbeGitRemote: async () => true
|
||||
}))
|
||||
|
||||
import { PRAssigneesPanel } from '../../src/renderer/src/components/github/PRAssigneesPanel'
|
||||
import { updateIssue } from '../../src/main/github/issue-update'
|
||||
import { _resetOriginGitHubApiRepositoryCache } from '../../src/main/github/github-api-repository'
|
||||
|
||||
const repoPath = join(tmpdir(), 'orca-pr-assignee-repository-fixture')
|
||||
|
||||
beforeEach(() => {
|
||||
fixture.callbacks = []
|
||||
fixture.mutation = null
|
||||
fixture.requests = []
|
||||
fixture.localGitOptions = {}
|
||||
fixture.gh.mockReset()
|
||||
fixture.gh.mockImplementation(
|
||||
async (
|
||||
args: string[],
|
||||
options: {
|
||||
host?: string
|
||||
cwd?: string
|
||||
wslDistro?: string
|
||||
}
|
||||
) => {
|
||||
fixture.requests.push({
|
||||
args,
|
||||
host: options.host,
|
||||
cwd: options.cwd,
|
||||
wslDistro: options.wslDistro
|
||||
})
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
)
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
fixture.apiUpdate = vi.fn(
|
||||
(args: {
|
||||
repoPath: string
|
||||
number: number
|
||||
updates: GitHubIssueUpdate
|
||||
ownerRepo?: GitHubOwnerRepo
|
||||
}) =>
|
||||
updateIssue(
|
||||
args.repoPath,
|
||||
args.number,
|
||||
args.updates,
|
||||
null,
|
||||
fixture.localGitOptions,
|
||||
fixture.preference,
|
||||
args.ownerRepo
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('window', { api: { gh: { updateIssue: fixture.apiUpdate } } })
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it.each([
|
||||
{ owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: false },
|
||||
{ owner: 'upstream-owner', preference: 'origin', assigned: true, legacy: false },
|
||||
{ owner: 'fork-owner', preference: 'upstream', assigned: false, legacy: false },
|
||||
{ owner: 'fork-owner', preference: 'upstream', assigned: true, legacy: false },
|
||||
{ owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: true }
|
||||
] as const)(
|
||||
'keeps $owner PR assignees under $preference (remove=$assigned, legacy=$legacy)',
|
||||
async ({ owner, preference, assigned, legacy }) => {
|
||||
fixture.preference = preference
|
||||
fixture.localGitOptions = owner === 'fork-owner' ? { wslDistro: 'Ubuntu' } : {}
|
||||
const item: GitHubWorkItem = {
|
||||
id: 'pr:5',
|
||||
type: 'pr',
|
||||
number: 5,
|
||||
title: 'Opened PR',
|
||||
state: 'open',
|
||||
url: `https://github.com/${owner}/widgets/pull/5`,
|
||||
prRepo: legacy ? undefined : { owner, repo: 'widgets', host: 'github.com' },
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null,
|
||||
repoId: 'repo-1',
|
||||
assignees: assigned ? [{ login: 'octo', name: null, avatarUrl: '' }] : []
|
||||
}
|
||||
PRAssigneesPanel({ item, repoPath, projectOrigin: undefined, onMutated: vi.fn() })
|
||||
const toggleAssignee = fixture.callbacks.at(-1)
|
||||
if (typeof toggleAssignee !== 'function') {
|
||||
throw new Error('PR panel did not create an assignee handler')
|
||||
}
|
||||
toggleAssignee('octo')
|
||||
await fixture.mutation
|
||||
expect(fixture.requests).toEqual([
|
||||
{
|
||||
args: [
|
||||
'issue',
|
||||
'edit',
|
||||
'5',
|
||||
'--repo',
|
||||
`${owner}/widgets`,
|
||||
assigned ? '--remove-assignee' : '--add-assignee',
|
||||
'octo'
|
||||
],
|
||||
host: 'github.com',
|
||||
cwd: repoPath,
|
||||
wslDistro: fixture.localGitOptions.wslDistro
|
||||
}
|
||||
])
|
||||
expect(fixture.apiUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoPath,
|
||||
repoId: item.repoId,
|
||||
ownerRepo: { owner, repo: 'widgets', host: 'github.com' }
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 90m">
|
||||
<title>downloads: 90m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 91m">
|
||||
<title>downloads: 91m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">90m</text>
|
||||
<text x="90" y="14">90m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">91m</text>
|
||||
<text x="90" y="14">91m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -1941,6 +1941,49 @@ remained red. Its review recognized all five shards as a complete reference
|
||||
(10,608 files, 8,965,977 worker-ms). This was again a full fallback with
|
||||
`selectionEvaluated: false`, not evidence for enabling selected tests.
|
||||
|
||||
## October 4 runtime imports and recovery fixtures
|
||||
|
||||
Three helper-only tests now import the existing terminal modules directly rather
|
||||
than initializing the runtime service. Ten copied-loop cases never exercised
|
||||
runtime memoization: they passed with its cache, timestamp update or prune
|
||||
invalidation disabled. Two actual helper checks remain. The existing runtime
|
||||
prune suite now exercises real leaf cache reuse, split prompt timestamps,
|
||||
ordinary output, fresh prompts and detection after retained-history eviction.
|
||||
Each of those three production faults fails a real runtime assertion.
|
||||
|
||||
Recovery tests now seed three exact fixture variants once, after the seed child
|
||||
has closed. Each crash still receives an independent byte-for-byte copy of the
|
||||
entire database/WAL family and remapped paths. Buffer.equals retains exact byte
|
||||
comparison without recursive matcher overhead. All 46 original crash boundaries
|
||||
and retries remain. Four additional copy-isolation/WAL checks run, and teardown
|
||||
requires that all seed bytes remain unchanged after the full suite.
|
||||
|
||||
Three alternating one-worker hosted ARM pairs in
|
||||
[37182181976](https://github.com/stablyai/orca/actions/runs/37182181976)
|
||||
measured these complete invocations:
|
||||
|
||||
| Cohort | Baseline seconds | Candidate seconds | Median saving |
|
||||
| --- | --- | --- | --- |
|
||||
| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% |
|
||||
| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% |
|
||||
| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% |
|
||||
|
||||
The final runtime cohort has seven real cases versus 16 including the copied
|
||||
loops; its new runtime case is included in candidate timing. Recovery has 50
|
||||
passes versus the original 46. Hosted Node typecheck passed. Recovery faults for
|
||||
last-byte database/WAL corruption, shared database paths, missing WAL copies and
|
||||
accepted/unaccepted seed collision failed the intended assertions. These are
|
||||
focused workload savings, not measured whole-shard or queue-delay gains.
|
||||
|
||||
An independent local cache screen left both caches disabled. Across 14 unchanged
|
||||
files and 92 cases, a warm Vitest transform cache reduced median invocation time
|
||||
3.090 to 1.948 seconds, excluding archive costs; its cold arm increased time to
|
||||
3.281 seconds. Node compilation caching showed no gain. Controls reproduced stale
|
||||
transforms after TypeScript configuration or plugin-option changes, so persisted
|
||||
reuse requires a complete transform-input stamp and hosted net-cost evidence.
|
||||
A separate 130,000-pane leaf-collection optimization was restored: its complete
|
||||
migration-file timing stayed within noise. The regression fixture remains.
|
||||
|
||||
## October 3 removal fixture cleanup ordering
|
||||
|
||||
[37105566358](https://github.com/stablyai/orca/actions/runs/37105566358)
|
||||
@@ -1959,3 +2002,60 @@ same ordering assertion. The gate was released, both controls drained the captur
|
||||
job, and the instrumentation was removed. Changed-code quality passed. This proves
|
||||
the teardown ordering mechanism, not a measured avoided-retry saving. Final-head
|
||||
hosted qualification remains required.
|
||||
|
||||
## October 4 store oracle and retention fixtures
|
||||
|
||||
The randomized in-place-store test validated the copying oracle twice after
|
||||
accepted mutations and compared snapshots through the same production parser.
|
||||
Its 5,000-step retention fixture generated enough tombstones to hit the count
|
||||
limit, but never reached the 4,096-revision age boundary.
|
||||
|
||||
The test retains all four seeds and 1,500 mutations per seed, removes the duplicate
|
||||
validation, and projects snapshots directly from the copying oracle's validated
|
||||
maps. Separate fixtures now check the revision before, at and after expiry and
|
||||
count overflow. Production code is unchanged.
|
||||
|
||||
Three alternating one-worker pairs on `ubuntu-24.04-arm` in
|
||||
[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143)
|
||||
measured baseline invocation times 33.551 / 33.304 / 33.529 seconds and candidate
|
||||
13.848 / 13.816 / 13.875 seconds: median 33.529 to 13.848 seconds, saving 19.681
|
||||
seconds (58.7%). Baseline passed seven tests; candidate passed eight. This is a
|
||||
focused test saving, not a measured whole-shard or queue-delay change.
|
||||
|
||||
Hosted Node typecheck passed. Separate fault controls failed the intended
|
||||
assertion for early, late and disabled age expiry, disabled count compaction,
|
||||
and a snapshot that drops child descriptions. The description fault passes with
|
||||
the original parser-sharing oracle and fails with the independent projection.
|
||||
|
||||
## October 4 Git contention and remaining readiness waits
|
||||
|
||||
The full Git admission benchmark compared a disabled arm with no correctness
|
||||
assertions to an enabled arm with structural ledger checks. Its default CI test
|
||||
now saturates the real base and headroom budgets with FIFO-gated child processes,
|
||||
queues older background and newer interactive work, releases base slots, and
|
||||
requires interactive priority, matching outputs and complete permit release.
|
||||
The full original diagnostic remains opt-in through
|
||||
`ORCA_GIT_ADMISSION_STORM_MEASUREMENT=1`; both opt-in tests passed locally.
|
||||
The existing Windows real-Git parity tests remain unchanged; this fixture retains
|
||||
its existing POSIX platform scope.
|
||||
|
||||
Two remaining Antigravity transcript tests used real 5,000ms refusal windows.
|
||||
They now use the existing scoped `waitForTranscriptIdle` timer harness after the
|
||||
emulator drains. All 60 tests, original captured transcripts, deadlines and
|
||||
readiness assertions remain.
|
||||
|
||||
Three alternating one-worker hosted ARM pairs in
|
||||
[37180614492](https://github.com/stablyai/orca/actions/runs/37180614492)
|
||||
measured these complete focused invocations:
|
||||
|
||||
| Suite | Baseline seconds | Candidate seconds | Median saving |
|
||||
| --- | --- | --- | --- |
|
||||
| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) |
|
||||
| Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) |
|
||||
|
||||
Each candidate passed its original meaningful checks. Hosted Node typecheck
|
||||
passed. Separate scheduler faults for bypassed admission, withheld release and
|
||||
FIFO-only priority failed the queued-contention or interactive-start assertion.
|
||||
Two additional local transcript faults failed the original picker-rejection and
|
||||
repaint-readiness assertions. These are focused suite savings; whole-shard time
|
||||
and queue delay were not measured by this experiment.
|
||||
|
||||
@@ -114,6 +114,8 @@ You can pin a worktree to the top of its project to keep long-running work in vi
|
||||
|
||||
When a worktree has nested child worktrees (for example from orchestration or `worktree create` with a parent), the context menu can also offer **Sleep with Descendants (N)** and **Delete with Descendants…**. Sleep with descendants closes active panels on the selected workspace and every validated nested child in the same project, repo, and host — only workspaces with live terminals or browser tabs are targeted for sleep. Delete with descendants makes the existing cascading delete explicit. Stale lineage links, cycles, and children across host or repo boundaries are excluded.
|
||||
|
||||
A parent worktree shows its nested children under an **N children** chip; click the chip to show or hide them. To do the same from the keyboard, assign **Toggle Child Workspaces** under [Settings → Shortcuts](/docs/settings). It acts on the worktree under the pointer, or on the active worktree when nothing is hovered, and does exactly what that card's chip does. On a card with no chip of its own (it has no children, or a sidebar filter hides all of them), it uses the chip on the card's parent instead, and does nothing if there is none.
|
||||
|
||||
Double-click a worktree title in the sidebar to rename it inline. Double-clicking elsewhere on the card still opens the full edit dialog. In **Edit Worktree Details**, the issue field accepts **GitHub** or **Linear** (chip on the field; paste a URL to auto-detect). One linked issue per workspace — changing provider or clearing the field unlinks the previous one. For SSH workspaces whose host is disconnected, the card title row can show an inline reconnect control (see [SSH worktrees](/docs/ssh)).
|
||||
|
||||
## Resource Manager cleanup
|
||||
|
||||
@@ -133,6 +133,7 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then
|
||||
|
||||
- Full keymap — every binding remappable.
|
||||
- Toggle Sleeping Workspaces ships unbound; assign it here if you want a direct shortcut for the sidebar sleeping-worktree filter.
|
||||
- **Toggle Child Workspaces** ships unbound; assign it here to show or hide a parent worktree's nested children, the same as clicking its **N children** chip. It targets the hovered worktree, or the active one when nothing is hovered.
|
||||
- **Toggle Workspace Board** ships unbound; assign it here to open or close the Workspace Board with one shortcut. Existing bindings for `workspace.openBoard` continue to work.
|
||||
- Close all editor tabs defaults to `Cmd+Option+W` on macOS and `Ctrl+Alt+W` on Windows / Linux.
|
||||
- **Tab navigation defaults (new installs):** next/previous tab **across all types** is `Cmd+Shift+]` / `Cmd+Shift+[` (Ctrl on Linux/Windows). Same-type next/previous is `Cmd+Option+]` / `Cmd+Option+[`. Previous recent tab is `Ctrl+Tab`. Existing installs keep customized overrides under `~/.orca/keybindings.json`.
|
||||
|
||||
@@ -73,6 +73,7 @@ describe('MobileNativeChatMessage', () => {
|
||||
workedSeconds: number | null
|
||||
} | null
|
||||
onToggleTurn?: () => void
|
||||
unsentNotice?: string
|
||||
} = {}
|
||||
): ReactTestRenderer {
|
||||
act(() => {
|
||||
@@ -142,6 +143,36 @@ describe('MobileNativeChatMessage', () => {
|
||||
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
|
||||
})
|
||||
|
||||
it('says under a message the host recorded but never delivered why it was not sent', () => {
|
||||
const tree = render(
|
||||
{ ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true },
|
||||
{ unsentNotice: "Orca couldn't reach the agent. Your message was not sent." }
|
||||
)
|
||||
expect(textIn(tree.root)).toEqual([
|
||||
'hello',
|
||||
"Orca couldn't reach the agent. Your message was not sent."
|
||||
])
|
||||
})
|
||||
|
||||
it('still says it was not sent when no words for it are loaded', () => {
|
||||
const tree = render({ ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true })
|
||||
expect(textIn(tree.root)).toEqual(['hello', 'Your message was not sent.'])
|
||||
})
|
||||
|
||||
it('says it was not sent as a muted label, not an error', () => {
|
||||
const tree = render({ ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true })
|
||||
const [label] = tree.root.findAll(
|
||||
(node) =>
|
||||
node.props.style !== undefined && node.children.join('') === 'Your message was not sent.'
|
||||
)
|
||||
expect(label?.props.style).toMatchObject({ color: colors.textMuted })
|
||||
})
|
||||
|
||||
it('says nothing more under a delivered message', () => {
|
||||
const tree = render(userMessage([{ type: 'text', text: 'hello' }]))
|
||||
expect(textIn(tree.root)).toEqual(['hello'])
|
||||
})
|
||||
|
||||
it('makes user message text selectable', () => {
|
||||
const tree = render(userMessage([{ type: 'text', text: 'Prompt I typed' }]))
|
||||
const text = tree.root
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ToolRun } from './MobileNativeChatToolRun'
|
||||
import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status'
|
||||
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
|
||||
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
|
||||
import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../src/shared/agent-session-write-notice-copy'
|
||||
|
||||
function Prose({
|
||||
block,
|
||||
@@ -87,7 +88,8 @@ function MobileNativeChatMessageImpl({
|
||||
turnKey,
|
||||
onToggleTurn,
|
||||
activeTurnIsWorking,
|
||||
structuredActivityUi = false
|
||||
structuredActivityUi = false,
|
||||
unsentNotice
|
||||
}: {
|
||||
message: NativeChatMessage
|
||||
toolsExpanded?: boolean
|
||||
@@ -108,6 +110,8 @@ function MobileNativeChatMessageImpl({
|
||||
activeTurnIsWorking?: boolean
|
||||
/** Structured lane only: live tool progress plus the turn-status disclosure. */
|
||||
structuredActivityUi?: boolean
|
||||
/** Why the host did not deliver this message, when it is shown as not sent. */
|
||||
unsentNotice?: string
|
||||
}): React.JSX.Element {
|
||||
const isUser = message.role === 'user'
|
||||
const isReasoning = message.role === 'reasoning'
|
||||
@@ -169,6 +173,11 @@ function MobileNativeChatMessageImpl({
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{isUser && message.unsent === true ? (
|
||||
<NativeText style={styles.unsentLabel}>
|
||||
{unsentNotice ?? AGENT_SESSION_WRITE_NOTICE_COPY.notDoneSend}
|
||||
</NativeText>
|
||||
) : null}
|
||||
</View>
|
||||
{turnStatusAbove ? null : statusRow}
|
||||
</>
|
||||
|
||||
@@ -40,6 +40,7 @@ import type { MobileChatPermission } from './mobile-native-chat-permission'
|
||||
import type { MobileChatQuestion } from './mobile-native-chat-question'
|
||||
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices'
|
||||
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||||
|
||||
/** Why the composer input is locked: the transport is disconnected, or the
|
||||
@@ -280,6 +281,10 @@ export function MobileNativeChatView({
|
||||
const hasPendingStructuredInteraction =
|
||||
structuredActivityUi && (ask != null || permission != null || question != null)
|
||||
|
||||
const unsentNotices = useMemo(
|
||||
() => mobileNativeChatUnsentNotices(turnJournal, agent ?? null),
|
||||
[turnJournal, agent]
|
||||
)
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: NativeChatMessage; index: number }) => (
|
||||
<MobileNativeChatMessage
|
||||
@@ -289,10 +294,11 @@ export function MobileNativeChatView({
|
||||
onOpenFile={onOpenFile}
|
||||
structuredActivityUi={structuredActivityUi}
|
||||
onToggleTurn={turns.onToggleTurn}
|
||||
unsentNotice={item.unsent === true ? unsentNotices.get(item.id) : undefined}
|
||||
{...turns.resolveRow(index, item)}
|
||||
/>
|
||||
),
|
||||
[toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns]
|
||||
[toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns, unsentNotices]
|
||||
)
|
||||
|
||||
const liveStatus =
|
||||
|
||||
@@ -37,6 +37,11 @@ export const styles = StyleSheet.create({
|
||||
reasoning: {
|
||||
opacity: 0.7
|
||||
},
|
||||
unsentLabel: {
|
||||
marginTop: spacing.xs,
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
toolRun: {
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
|
||||
@@ -29,8 +29,9 @@ type MobileNativeChatSendArgs = {
|
||||
/** 'unknown' = the RPC failed without proof the request never reached the
|
||||
* desktop (ack loss after a write, or a cutover that cannot tell whether the
|
||||
* frame was written) — callers must not present it as a definite send failure.
|
||||
* 'queued' = structured lane only: the host holds the message as a queued
|
||||
* draft, so it shows as a card above the composer, never a transcript echo. */
|
||||
* 'queued' = structured lane only: the host holds the message, as a queued
|
||||
* draft's card above the composer or as a recorded row in the transcript, so
|
||||
* the client draws no echo and hands no text back. */
|
||||
export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'queued'
|
||||
|
||||
/** What a terminal write can answer: the PTY lane has no draft queue. */
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionFailureFact } from '../../../src/shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words'
|
||||
import {
|
||||
agentJournalItemKey,
|
||||
agentJournalSubmissionKey
|
||||
} from '../../../src/shared/agent-session-journal-item-key'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../src/shared/agent-session-journal-types'
|
||||
import { structuredAgentSessionCommandResultRowIdentity } from '../../../src/shared/structured-agent-session-command-entry'
|
||||
import { structuredAgentSessionStartFailureRowIdentity } from '../../../src/shared/structured-agent-session-start-failure-row-key'
|
||||
import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices'
|
||||
|
||||
function rejected(patch: Partial<AgentJournalSubmission> = {}): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId: 'sent-elsewhere',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason: 'provider_write_failed: broken pipe',
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1,
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
describe('the line under a message the host recorded and did not deliver', () => {
|
||||
it("says why, in the host's words, keyed by the message's row", () => {
|
||||
const notices = mobileNativeChatUnsentNotices(
|
||||
{ items: [], submissions: [rejected()] },
|
||||
'claude'
|
||||
)
|
||||
expect(notices.get(agentJournalSubmissionKey('sent-elsewhere'))).toBe(
|
||||
"Orca couldn't reach the agent. Your message was not sent."
|
||||
)
|
||||
})
|
||||
|
||||
it('says only that it was not sent when the start-failure row already says why', () => {
|
||||
const startFailed: AgentSessionFailureFact = { kind: 'startFailed' }
|
||||
const startRow: AgentJournalRenderItem = {
|
||||
itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')),
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: {
|
||||
kind: 'status',
|
||||
tone: 'error',
|
||||
...agentSessionFailureWords(startFailed, { agentName: 'Claude', surface: 'row' })
|
||||
}
|
||||
}
|
||||
const notices = mobileNativeChatUnsentNotices(
|
||||
{
|
||||
items: [startRow],
|
||||
submissions: [rejected({ reason: 'Written by the host.', rejection: startFailed })]
|
||||
},
|
||||
'claude'
|
||||
)
|
||||
expect(notices.get(agentJournalSubmissionKey('sent-elsewhere'))).toBe(
|
||||
'Your message was not sent.'
|
||||
)
|
||||
})
|
||||
|
||||
// Its turn's result row says how a refused command ended; a blocked one has only its own line.
|
||||
it("says only that a command was not sent when its result row says why, else the command's words", () => {
|
||||
const providerRejected: AgentSessionFailureFact = {
|
||||
kind: 'providerRejected',
|
||||
detail: { text: 'busy', audience: 'person' }
|
||||
}
|
||||
const refused = rejected({ clientMessageId: 'compact-1', rejection: providerRejected })
|
||||
const resultRow: AgentJournalRenderItem = {
|
||||
itemId: agentJournalItemKey(structuredAgentSessionCommandResultRowIdentity('compact-1')),
|
||||
revision: 1,
|
||||
sequence: 2,
|
||||
observedAt: 2,
|
||||
body: {
|
||||
kind: 'status',
|
||||
tone: 'error',
|
||||
...agentSessionFailureWords(
|
||||
{ kind: 'compactionFailed', detail: { text: 'busy', audience: 'person' } },
|
||||
{ agentName: 'Codex', surface: 'row' }
|
||||
)
|
||||
}
|
||||
}
|
||||
const blocked = rejected({
|
||||
clientMessageId: 'compact-2',
|
||||
rejection: { kind: 'commandRefused' }
|
||||
})
|
||||
const notices = mobileNativeChatUnsentNotices(
|
||||
{ items: [resultRow], submissions: [refused, blocked] },
|
||||
'codex'
|
||||
)
|
||||
expect(notices.get(agentJournalSubmissionKey('compact-1'))).toBe('Your message was not sent.')
|
||||
expect(notices.get(agentJournalSubmissionKey('compact-2'))).toBe(
|
||||
"This command didn't run. Try it again."
|
||||
)
|
||||
})
|
||||
|
||||
it('has nothing to say when no send was rejected', () => {
|
||||
expect(
|
||||
mobileNativeChatUnsentNotices(
|
||||
{ items: [], submissions: [rejected({ dispatchState: 'accepted' })] },
|
||||
'claude'
|
||||
).size
|
||||
).toBe(0)
|
||||
expect(mobileNativeChatUnsentNotices(null, 'claude').size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
// The line under each message the host recorded and then did not deliver: the desktop row's words,
|
||||
// in English. Only the transcript says it, so the phone's own send never repeats it in its banner.
|
||||
|
||||
import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
|
||||
import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key'
|
||||
import { structuredAgentSessionCommandResultRows } from '../../../src/shared/structured-agent-session-command-entry'
|
||||
import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice'
|
||||
import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership'
|
||||
import {
|
||||
structuredAgentSessionRecordedRejectionParts,
|
||||
structuredAgentSessionStartFailureFacts
|
||||
} from '../../../src/shared/structured-agent-session-recorded-rejection-words'
|
||||
|
||||
const NO_NOTICES: ReadonlyMap<string, string> = new Map()
|
||||
|
||||
/** Keyed by the message id the transcript renders each recorded send under. */
|
||||
export function mobileNativeChatUnsentNotices(
|
||||
journal: NativeChatTurnJournal | null,
|
||||
agent: string | null
|
||||
): ReadonlyMap<string, string> {
|
||||
const rejected = journal?.submissions.filter((row) => row.dispatchState === 'rejected') ?? []
|
||||
if (!journal || rejected.length === 0) {
|
||||
return NO_NOTICES
|
||||
}
|
||||
const startFailures = structuredAgentSessionStartFailureFacts(journal.items)
|
||||
const commandResults = structuredAgentSessionCommandResultRows(journal.items)
|
||||
const context = {
|
||||
retryControl: false,
|
||||
...(agent ? { agentName: formatAgentTypeLabel(agent) } : {})
|
||||
}
|
||||
return new Map(
|
||||
rejected.map((submission) => [
|
||||
agentJournalSubmissionKey(submission.clientMessageId),
|
||||
agentSessionWriteNoticeEnglish(
|
||||
structuredAgentSessionRecordedRejectionParts(
|
||||
submission,
|
||||
context,
|
||||
startFailures,
|
||||
commandResults
|
||||
)
|
||||
)
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -31,8 +31,33 @@ function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'):
|
||||
})
|
||||
}
|
||||
|
||||
/** Each `agentSession.send` answered in turn: a lost answer, or a queued draft in that state. */
|
||||
function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) {
|
||||
function submissionAnswer(clientMessageId: string, dispatchState: 'accepted' | 'rejected') {
|
||||
return ok({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 3,
|
||||
cursor: { epoch: 'epoch-1', sequence: 1 },
|
||||
value: {
|
||||
clientMessageId,
|
||||
submission: {
|
||||
clientMessageId,
|
||||
fence: 3,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState,
|
||||
providerItemId: null,
|
||||
reason: dispatchState === 'rejected' ? 'provider_write_failed: broken pipe' : null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Each `agentSession.send` answered in turn: a lost answer, a queued draft in that state, or a
|
||||
* recorded submission in that state. */
|
||||
function hostAnswering(
|
||||
answers: readonly ('lost' | 'waiting' | 'withdrawn' | 'accepted' | 'rejected')[]
|
||||
) {
|
||||
const ids: string[] = []
|
||||
const deliveries: unknown[] = []
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>(async (_method, params) => {
|
||||
@@ -44,7 +69,9 @@ function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) {
|
||||
if (answer === 'lost' || answer === undefined) {
|
||||
throw markRpcDeliveryUnknown(new Error('Connection closed'))
|
||||
}
|
||||
return queuedAnswer(id, answer)
|
||||
return answer === 'accepted' || answer === 'rejected'
|
||||
? submissionAnswer(id, answer)
|
||||
: queuedAnswer(id, answer)
|
||||
})
|
||||
const client: RpcClient = {
|
||||
sendRequest,
|
||||
@@ -154,4 +181,27 @@ describe('a resend past a saved record storage would not clear', () => {
|
||||
"Sent, but this phone couldn't update its record of sent messages."
|
||||
)
|
||||
})
|
||||
|
||||
// Its row says it was not sent, so replaying the kept id would answer that again and do nothing.
|
||||
it('sends the same text as a new message when its kept id answers as recorded and rejected', async () => {
|
||||
const { client, ids } = hostAnswering(['lost', 'rejected', 'accepted'])
|
||||
const onError = vi.fn()
|
||||
expect(await sendAgain(client, onError, false)).toBe('unknown')
|
||||
expect(await sendAgain(client, onError, false)).toBe('accepted')
|
||||
expect(ids).toHaveLength(3)
|
||||
expect(ids[1]).toBe(ids[0])
|
||||
expect(ids[2]).not.toBe(ids[0])
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never says a resend went out when the host recorded and rejected it', async () => {
|
||||
const { client, ids } = hostAnswering(['lost', 'withdrawn', 'rejected'])
|
||||
const onError = vi.fn()
|
||||
expect(await sendAgain(client, onError)).toBe('unknown')
|
||||
asyncStorage.setItem.mockRejectedValue(new Error('disk full'))
|
||||
asyncStorage.removeItem.mockRejectedValue(new Error('disk full'))
|
||||
expect(await sendAgain(client, onError)).toBe('queued')
|
||||
expect(ids).toHaveLength(3)
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,10 +11,12 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import {
|
||||
requestStructuredAgentSessionMutation,
|
||||
timeoutForDeadline
|
||||
timeoutForDeadline,
|
||||
type StructuredAgentSessionMutationCallResult
|
||||
} from './mobile-structured-agent-session-rpc'
|
||||
import { structuredSessionOperationId } from './structured-session-operation-id'
|
||||
import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery'
|
||||
import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection'
|
||||
import {
|
||||
bypassedMobileStructuredSendOperationId,
|
||||
clearMobileStructuredSendOperation,
|
||||
@@ -36,13 +38,13 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
|
||||
delivery?: 'queue-if-active'
|
||||
deadline?: number
|
||||
onError: (message: string) => void
|
||||
/** Internal: the one fresh-id resend after a withdrawn replay. */
|
||||
resendingAfterWithdrawal?: true
|
||||
/** Internal: that resend when the withdrawn id's record could not be cleared. It bypasses the
|
||||
/** Internal: the one fresh-id resend after a replay that settled without delivering. */
|
||||
resendingAfterSettledReplay?: true
|
||||
/** Internal: that resend when the settled id's record could not be cleared. It bypasses the
|
||||
* record, so failing storage never blocks the send; its id is kept in memory for this app run,
|
||||
* so a retry after a lost answer replays it rather than sending again. */
|
||||
bypassRetainedRecord?: true
|
||||
/** Internal: on that resend, the key the withdrawn record matched. The remembered id is keyed
|
||||
/** Internal: on that resend, the key the settled record matched. The remembered id is keyed
|
||||
* by it, so a capability change since the lost answer never mints another id. */
|
||||
resendOperationKey?: string
|
||||
}): Promise<MobileNativeChatSendOutcome> {
|
||||
@@ -165,22 +167,22 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
|
||||
result.status === 'accepted' &&
|
||||
'queued' in result.value &&
|
||||
result.value.queued?.state === 'withdrawn'
|
||||
if (withdrawnReplay && operation.retained && !input.resendingAfterWithdrawal) {
|
||||
// The retained id's draft was withdrawn, so it never reached the agent:
|
||||
// this identical message is a new one, not a replay to swallow. A record
|
||||
// storage would not clear is bookkeeping: it is reported, never allowed to
|
||||
// block the send.
|
||||
const resent = await sendMobileStructuredAgentSessionMessage({
|
||||
if (
|
||||
operation.retained &&
|
||||
!input.resendingAfterSettledReplay &&
|
||||
// A recorded rejection's row already says it was not sent, so a replay would do nothing.
|
||||
(withdrawnReplay || recordedRejection(result))
|
||||
) {
|
||||
// The retained id's message never reached the agent (its draft was withdrawn, or the host
|
||||
// recorded and rejected it): this identical message is a new one, not a replay to swallow. A
|
||||
// record storage would not clear is bookkeeping: it is reported, never allowed to block the
|
||||
// send.
|
||||
return sendMobileStructuredAgentSessionMessage({
|
||||
...input,
|
||||
resendingAfterWithdrawal: true,
|
||||
resendingAfterSettledReplay: true,
|
||||
resendOperationKey: operationKey,
|
||||
...(released ? {} : { bypassRetainedRecord: true as const })
|
||||
})
|
||||
// Only when the resend is known to have gone out; an unconfirmed one may not have.
|
||||
if (!released && (resent === 'accepted' || resent === 'queued')) {
|
||||
input.onError("Sent, but this phone couldn't update its record of sent messages.")
|
||||
}
|
||||
return resent
|
||||
}
|
||||
if (withdrawnReplay) {
|
||||
// Not resent: no card and no bubble holds the text, so it goes back to the
|
||||
@@ -191,6 +193,13 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
|
||||
if (outcome.error !== null) {
|
||||
input.onError(outcome.error)
|
||||
}
|
||||
// Only once the resend is known to have gone out: an unconfirmed one may not have, and one the
|
||||
// host recorded and rejected shows as not sent.
|
||||
const wentOut =
|
||||
outcome.outcome === 'accepted' || (outcome.outcome === 'queued' && !recordedRejection(result))
|
||||
if (input.bypassRetainedRecord && wentOut) {
|
||||
input.onError("Sent, but this phone couldn't update its record of sent messages.")
|
||||
}
|
||||
return outcome.outcome
|
||||
}
|
||||
|
||||
@@ -240,3 +249,14 @@ async function adoptBypassedOperation(input: {
|
||||
return bypassOperation(input.operationKey, input.payloadFingerprint, input.attachmentPaths)
|
||||
}
|
||||
}
|
||||
|
||||
/** An answer that the host recorded the message and then rejected it, not by a Stop. */
|
||||
function recordedRejection(
|
||||
result: StructuredAgentSessionMutationCallResult<AgentSessionSendResult>
|
||||
): boolean {
|
||||
const submission =
|
||||
result.status === 'accepted' && 'submission' in result.value
|
||||
? result.value.submission
|
||||
: undefined
|
||||
return submission?.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ function setup() {
|
||||
conversationCommands: ['clear', 'compact']
|
||||
},
|
||||
canRun: () => true,
|
||||
recorded: () => false,
|
||||
onError: vi.fn(),
|
||||
timeoutMs: 15000
|
||||
}
|
||||
@@ -119,6 +120,31 @@ describe('mobile structured conversation commands', () => {
|
||||
expect(fields.command).toBe('clear')
|
||||
expect(asyncStorage.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
// Its row in the chat says it was not sent and why, for every viewer, so no banner repeats it.
|
||||
it('banners a refused /compact only when the journal does not show the host recorded it', async () => {
|
||||
const refused = {
|
||||
ok: true,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { command: 'compact', state: 'completed', error: "This command didn't run." }
|
||||
}
|
||||
}
|
||||
const { input, sendRequest } = setup()
|
||||
sendRequest.mockResolvedValue(refused)
|
||||
const sentId = () => {
|
||||
const envelope = requestFields(sendRequest.mock.calls.at(-1)).envelope
|
||||
return typeof envelope === 'object' && envelope !== null && 'clientOperationId' in envelope
|
||||
? envelope.clientOperationId
|
||||
: undefined
|
||||
}
|
||||
input.recorded = (clientMessageId) => clientMessageId === sentId()
|
||||
expect(await dispatchMobileStructuredCommand(input)).toBe('accepted')
|
||||
expect(input.onError).not.toHaveBeenCalled()
|
||||
|
||||
input.recorded = () => false
|
||||
expect(await dispatchMobileStructuredCommand(input)).toBe('rejected')
|
||||
expect(input.onError).toHaveBeenCalledWith("This command didn't run.")
|
||||
})
|
||||
it('keeps ordinary messages on the existing send path', async () => {
|
||||
const { input, sendRequest } = setup()
|
||||
expect(await dispatchMobileStructuredCommand({ ...input, text: 'hello' })).toBeNull()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc'
|
||||
import { structuredSessionOperationId } from './structured-session-operation-id'
|
||||
|
||||
export async function dispatchMobileStructuredCommand(input: {
|
||||
text: string
|
||||
@@ -17,6 +18,8 @@ export async function dispatchMobileStructuredCommand(input: {
|
||||
pending: { current: boolean }
|
||||
controller: StructuredAgentSessionComposerOptions
|
||||
canRun: () => boolean
|
||||
/** Whether the loaded journal shows the message the host recorded under this id. */
|
||||
recorded: (clientMessageId: string) => boolean
|
||||
onError: (message: string) => void
|
||||
timeoutMs: number
|
||||
}): Promise<MobileNativeChatSendOutcome | null> {
|
||||
@@ -41,6 +44,7 @@ export async function dispatchMobileStructuredCommand(input: {
|
||||
}
|
||||
}
|
||||
input.pending.current = true
|
||||
const clientOperationId = structuredSessionOperationId()
|
||||
try {
|
||||
const result =
|
||||
await requestStructuredAgentSessionMutation<AgentSessionConversationCommandResult>({
|
||||
@@ -50,6 +54,7 @@ export async function dispatchMobileStructuredCommand(input: {
|
||||
method: 'agentSession.conversationCommand',
|
||||
fingerprintMethod: 'agentSession.conversationCommand',
|
||||
fields: { command },
|
||||
clientOperationId,
|
||||
timeoutMs: Math.max(input.timeoutMs, 195_000)
|
||||
})
|
||||
if (
|
||||
@@ -62,9 +67,15 @@ export async function dispatchMobileStructuredCommand(input: {
|
||||
error: 'Conversation operation was not confirmed.'
|
||||
}
|
||||
}
|
||||
return result.status === 'accepted'
|
||||
? { accepted: !result.value.error, error: result.value.error ?? null }
|
||||
: { accepted: false, error: result.message }
|
||||
if (result.status !== 'accepted') {
|
||||
return { accepted: false, error: result.message }
|
||||
}
|
||||
// The host answered for a command it recorded: its row in the chat says how it went, so
|
||||
// no banner repeats it and the text stays the row's, not the composer's.
|
||||
if (result.value.state === 'completed' && input.recorded(clientOperationId)) {
|
||||
return { accepted: true, error: null }
|
||||
}
|
||||
return { accepted: !result.value.error, error: result.value.error ?? null }
|
||||
} finally {
|
||||
input.pending.current = false
|
||||
}
|
||||
|
||||
@@ -112,26 +112,26 @@ describe('mobileStructuredSendDelivery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('spends the id of a rejection and withholds its internal reason', () => {
|
||||
it('spends the id of a recorded rejection and leaves the message to its row in the chat', () => {
|
||||
// Provably undelivered and terminal, so the id can only replay it: spending the
|
||||
// id makes the retry a first delivery. The marker itself names nothing a person
|
||||
// can act on, so it must not reach the screen.
|
||||
expect(
|
||||
mobileStructuredSendDelivery(accepted('rejected', 'provider_write_failed: broken pipe'))
|
||||
).toEqual({
|
||||
outcome: 'rejected',
|
||||
operationIdSpent: true,
|
||||
error: "Orca couldn't reach the agent. Your message was not sent. Send it again."
|
||||
})
|
||||
// id makes the retry a first delivery. The host recorded the message, so the
|
||||
// transcript holds it and shows it as not sent, with why: no banner, no hand-back.
|
||||
for (const reason of ['provider_write_failed: broken pipe', 'Claude does not support .bmp']) {
|
||||
expect(mobileStructuredSendDelivery(accepted('rejected', reason))).toEqual({
|
||||
outcome: 'queued',
|
||||
operationIdSpent: true,
|
||||
error: null
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('shows a provider content rejection verbatim', () => {
|
||||
it('says a send a Stop withdrew was not sent: no row in the chat says it', () => {
|
||||
expect(
|
||||
mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp'))
|
||||
mobileStructuredSendDelivery(accepted('rejected', 'provider_cancelled_before_start'))
|
||||
).toEqual({
|
||||
outcome: 'rejected',
|
||||
operationIdSpent: true,
|
||||
error: 'Claude does not support .bmp'
|
||||
error: 'Your message was not sent. Send it again.'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
//
|
||||
// accepted/pending — the send happened. The id is spent; a later identical
|
||||
// message is a new message and must carry a new id.
|
||||
// rejected — a terminal refusal or rejected submission spends a fresh id. A
|
||||
// rejected — a terminal refusal or rejected submission spends a fresh id (a
|
||||
// recorded one reports `queued`: the host holds its text, not the composer). A
|
||||
// pending-admission refusal, or any refusal after earlier transport doubt,
|
||||
// keeps it because neither proves a retained delivery did not happen. The
|
||||
// one exception is a host that refuses the replay's request shape itself
|
||||
@@ -29,6 +30,7 @@ import type { AgentJournalSubmission } from '../../../src/shared/agent-session-j
|
||||
import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire'
|
||||
import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry'
|
||||
import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition'
|
||||
import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc'
|
||||
|
||||
@@ -86,13 +88,19 @@ export function mobileStructuredSendDelivery(
|
||||
if (!submission || submission.dispatchState === 'unknown') {
|
||||
return { outcome: 'unknown', operationIdSpent: false, error: null }
|
||||
}
|
||||
if (submission.dispatchState === 'rejected') {
|
||||
if (submission.dispatchState === 'rejected' && dispatchWasWithdrawn(submission)) {
|
||||
// A Stop withdrew it, so no row holds it: its text goes back to the composer, with why.
|
||||
return {
|
||||
outcome: 'rejected',
|
||||
operationIdSpent: true,
|
||||
error: structuredAgentSessionRejectionNotice(submission.reason, 'composer-send')
|
||||
}
|
||||
}
|
||||
if (submission.dispatchState === 'rejected') {
|
||||
// The host recorded it: its row in the chat holds the text and says it was not sent and why,
|
||||
// so nothing is handed back.
|
||||
return { outcome: 'queued', operationIdSpent: true, error: null }
|
||||
}
|
||||
if (retained) {
|
||||
// A payload match cannot distinguish retrying the ambiguous action from a
|
||||
// later identical intent. Wait for the stream to settle and release it.
|
||||
|
||||
@@ -21,13 +21,13 @@ function ok(result: unknown) {
|
||||
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function sendResult(dispatchState: AgentJournalDispatchState) {
|
||||
function sendResult(dispatchState: AgentJournalDispatchState, reason: string | null = null) {
|
||||
return ok({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 3,
|
||||
cursor: { epoch: 'epoch-1', sequence: 1 },
|
||||
value: structuredSendResultFixture(dispatchState)
|
||||
value: structuredSendResultFixture(dispatchState, reason)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -257,6 +257,30 @@ describe('mobile structured send retries', () => {
|
||||
expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true)
|
||||
})
|
||||
|
||||
// The transcript owns a message the host recorded, so the composer never gets it back.
|
||||
it('hands back only a send a Stop withdrew, never one the host recorded and rejected', async () => {
|
||||
let reason = 'provider_write_failed: broken pipe'
|
||||
sendRequest.mockImplementation(async (method) =>
|
||||
method === 'agentSession.send'
|
||||
? sendResult('rejected', reason)
|
||||
: method === 'agentSession.options'
|
||||
? ok({ models: [], current: {} })
|
||||
: ok({})
|
||||
)
|
||||
await mountSession()
|
||||
|
||||
await act(async () => {
|
||||
expect(await hook!.sendWithOutcome('recorded, then rejected')).toBe('queued')
|
||||
})
|
||||
expect(onSendError).not.toHaveBeenCalled()
|
||||
|
||||
reason = 'provider_cancelled_before_start'
|
||||
await act(async () => {
|
||||
expect(await hook!.sendWithOutcome('withdrawn by Stop')).toBe('rejected')
|
||||
})
|
||||
expect(onSendError).toHaveBeenCalledWith('Your message was not sent. Send it again.')
|
||||
})
|
||||
|
||||
it('reuses the original uploaded attachment identity after acknowledgement loss', async () => {
|
||||
let attempts = 0
|
||||
sendRequest.mockImplementation(async (method) => {
|
||||
|
||||
@@ -77,8 +77,8 @@ export function useMobileStructuredNativeChatSendBridge(args: {
|
||||
return 'accepted'
|
||||
}
|
||||
if (outcome === 'queued') {
|
||||
// The host holds the draft and publishes it as a card above the
|
||||
// composer — never an optimistic transcript bubble.
|
||||
// The host holds the text, as a card above the composer or a recorded
|
||||
// row in the transcript — never an optimistic bubble or a hand-back.
|
||||
return 'queued'
|
||||
}
|
||||
if (outcome === 'unknown') {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type StructuredAgentSessionAttachment
|
||||
} from '../../../src/shared/structured-agent-session-outbox'
|
||||
import type { StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer'
|
||||
import { structuredAgentSessionJournalShowsSubmission } from '../../../src/shared/structured-agent-session-message-projection'
|
||||
import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
@@ -98,6 +99,11 @@ export function useMobileStructuredSendWithOutcome(args: {
|
||||
!stateRef.current.items.some(
|
||||
(item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item)
|
||||
),
|
||||
recorded: (clientMessageId) =>
|
||||
structuredAgentSessionJournalShowsSubmission(
|
||||
stateRef.current.submissions,
|
||||
clientMessageId
|
||||
),
|
||||
onError: onSendError,
|
||||
timeoutMs
|
||||
})
|
||||
|
||||
@@ -1,250 +1,183 @@
|
||||
/**
|
||||
* POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path.
|
||||
* Timing distributions are reported for field comparison; CI assertions stay structural.
|
||||
*/
|
||||
import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { existsSync, watch } from 'node:fs'
|
||||
import { chmod, mkdir, readdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '../../../shared/child-process/run-process'
|
||||
import { gitExecFileAsync } from './git-exec-file'
|
||||
import {
|
||||
GIT_ADMISSION_AGING_MS,
|
||||
GENERAL_CAP,
|
||||
GENERAL_HEADROOM,
|
||||
GitAdmissionScheduler,
|
||||
MAX_GIT_CHILDREN,
|
||||
_gitAdmissionSnapshotForTests,
|
||||
_resetGitAdmissionForTests,
|
||||
type GitAdmissionEvent
|
||||
} from './git-subprocess-admission'
|
||||
import {
|
||||
assertAdmissionLedger,
|
||||
createStormRoot,
|
||||
formatMeasurementTable,
|
||||
measureStorm,
|
||||
type InteractiveQueueSnapshot
|
||||
} from './git-admission-storm-test-fixture'
|
||||
|
||||
type StormMeasurement = {
|
||||
mode: 'disabled' | 'enabled'
|
||||
maxConcurrentChildren: number
|
||||
eventLoopMaxDriftMs: number
|
||||
eventLoopP99DriftMs: number
|
||||
interactiveP50Ms: number
|
||||
interactiveP95Ms: number
|
||||
totalWallMs: number
|
||||
admissionEvents: GitAdmissionEvent[]
|
||||
interactiveQueueSnapshots: InteractiveQueueSnapshot[]
|
||||
function waitForLiveChild(stateDir: string, id: string, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const watcher = watch(stateDir)
|
||||
const finish = (error?: Error): void => {
|
||||
clearTimeout(deadline)
|
||||
watcher.close()
|
||||
signal.removeEventListener('abort', aborted)
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
const aborted = (): void => finish(new Error(`Aborted waiting for ${id}`))
|
||||
const deadline = setTimeout(() => finish(new Error(`Child ${id} did not start`)), 5_000)
|
||||
const check = (): void => {
|
||||
if (existsSync(path.join(stateDir, `${id}.live`))) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
watcher.on('change', check)
|
||||
watcher.once('error', finish)
|
||||
signal.addEventListener('abort', aborted, { once: true })
|
||||
if (signal.aborted) {
|
||||
aborted()
|
||||
} else {
|
||||
check()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type InteractiveQueueSnapshot = {
|
||||
commandLabel: string
|
||||
backgroundWaiterIds: number[]
|
||||
}
|
||||
|
||||
const tempRoots: string[] = []
|
||||
const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalAdmissionDisabled === undefined) {
|
||||
delete process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
} else {
|
||||
process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled
|
||||
}
|
||||
_resetGitAdmissionForTests()
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
function percentile(values: readonly number[], percentileValue: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)]
|
||||
}
|
||||
|
||||
async function liveChildCount(stateDir: string): Promise<number> {
|
||||
return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length
|
||||
}
|
||||
|
||||
async function createStubGit(root: string): Promise<string> {
|
||||
async function verifySpawnContention(): Promise<void> {
|
||||
delete process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
const admissionEvents: GitAdmissionEvent[] = []
|
||||
_resetGitAdmissionForTests(
|
||||
new GitAdmissionScheduler({
|
||||
now: () => 0,
|
||||
onAdmissionEvent: (event) => admissionEvents.push(event)
|
||||
})
|
||||
)
|
||||
const root = await createStormRoot('contention')
|
||||
const stateDir = path.join(root, 'state')
|
||||
const gateDir = path.join(root, 'gates')
|
||||
const binDir = path.join(root, 'bin')
|
||||
await mkdir(binDir)
|
||||
const stubPath = path.join(binDir, 'git')
|
||||
await Promise.all([stateDir, gateDir, binDir].map((directory) => mkdir(directory)))
|
||||
const backgroundIds = Array.from({ length: GENERAL_CAP + 4 }, (_, index) => `background-${index}`)
|
||||
const headroomIds = Array.from({ length: GENERAL_HEADROOM }, (_, index) => `interactive-${index}`)
|
||||
const queuedIds = ['interactive-queued-0', 'interactive-queued-1']
|
||||
const ids = [...backgroundIds, ...headroomIds, ...queuedIds]
|
||||
const gates = await runProcess({
|
||||
program: 'mkfifo',
|
||||
args: ids.map((id) => path.join(gateDir, id))
|
||||
})
|
||||
expect(gates.code, gates.stderr).toBe(0)
|
||||
const stub = path.join(binDir, 'git')
|
||||
await writeFile(
|
||||
stubPath,
|
||||
stub,
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live"
|
||||
: > "$live"
|
||||
trap 'rm -f "$live"' EXIT HUP INT TERM
|
||||
sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")"
|
||||
IFS= read -r release < "$ORCA_STUB_GATE_DIR/$ORCA_STUB_ID"
|
||||
printf 'stub:%s\\n' "$*"
|
||||
`
|
||||
)
|
||||
await chmod(stubPath, 0o755)
|
||||
return binDir
|
||||
}
|
||||
|
||||
function setAdmissionMode(mode: StormMeasurement['mode']): void {
|
||||
if (mode === 'disabled') {
|
||||
process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
|
||||
} else {
|
||||
delete process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
}
|
||||
}
|
||||
|
||||
async function measureStorm(mode: StormMeasurement['mode']): Promise<StormMeasurement> {
|
||||
setAdmissionMode(mode)
|
||||
const admissionEvents: GitAdmissionEvent[] = []
|
||||
const admissionClockStartedAt = performance.now()
|
||||
_resetGitAdmissionForTests(
|
||||
new GitAdmissionScheduler({
|
||||
now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1),
|
||||
onAdmissionEvent: (event) => admissionEvents.push(event)
|
||||
})
|
||||
)
|
||||
const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${mode}-`))
|
||||
tempRoots.push(root)
|
||||
const stateDir = path.join(root, 'state')
|
||||
await mkdir(stateDir)
|
||||
const binDir = await createStubGit(root)
|
||||
const repoDirs = await Promise.all(
|
||||
Array.from({ length: 6 }, async (_, index) => {
|
||||
const repoDir = path.join(root, `repo-${index}`)
|
||||
await mkdir(repoDir)
|
||||
return repoDir
|
||||
})
|
||||
)
|
||||
const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
|
||||
const startedAt = performance.now()
|
||||
const drifts: number[] = []
|
||||
let nextJankSample = performance.now() + 50
|
||||
const jankTimer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
drifts.push(Math.max(0, now - nextJankSample))
|
||||
nextJankSample = now + 50
|
||||
}, 50)
|
||||
let maxConcurrentChildren = 0
|
||||
const censusTimer = setInterval(() => {
|
||||
void liveChildCount(stateDir).then((count) => {
|
||||
maxConcurrentChildren = Math.max(maxConcurrentChildren, count)
|
||||
})
|
||||
}, 5)
|
||||
|
||||
const background = Array.from({ length: 60 }, (_, index) =>
|
||||
gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], {
|
||||
cwd: repoDirs[index % repoDirs.length],
|
||||
env: {
|
||||
...baseEnv,
|
||||
ORCA_STUB_ID: `background-${index}`,
|
||||
ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200',
|
||||
ORCA_STUB_STATE_DIR: stateDir
|
||||
},
|
||||
admissionTier: 'background'
|
||||
})
|
||||
)
|
||||
await chmod(stub, 0o755)
|
||||
const controller = new AbortController()
|
||||
const commands = new Map<string, ReturnType<typeof gitExecFileAsync>>()
|
||||
const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = []
|
||||
const interactiveLatencies = await Promise.all(
|
||||
Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) =>
|
||||
new Promise<number>((resolve, reject) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
void (async () => {
|
||||
const concurrentAtInjection = await liveChildCount(stateDir)
|
||||
const commandStartedAt = performance.now()
|
||||
const commandLabel = `interactive-${index}`
|
||||
interactiveQueueSnapshots.push({
|
||||
commandLabel,
|
||||
backgroundWaiterIds: _gitAdmissionSnapshotForTests()
|
||||
.queuedWaiters.filter((waiter) => waiter.tier === 'background')
|
||||
.map((waiter) => waiter.id)
|
||||
})
|
||||
await gitExecFileAsync(['rev-parse', commandLabel], {
|
||||
cwd: repoDirs[index % repoDirs.length],
|
||||
env: {
|
||||
...baseEnv,
|
||||
ORCA_STUB_ID: `interactive-${index}`,
|
||||
ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)),
|
||||
ORCA_STUB_STATE_DIR: stateDir
|
||||
},
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
resolve(performance.now() - commandStartedAt)
|
||||
})().catch(reject)
|
||||
},
|
||||
50 * (index + 1)
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
await Promise.all(background)
|
||||
clearInterval(censusTimer)
|
||||
clearInterval(jankTimer)
|
||||
maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir))
|
||||
const totalWallMs = performance.now() - startedAt
|
||||
return {
|
||||
mode,
|
||||
maxConcurrentChildren,
|
||||
eventLoopMaxDriftMs: Math.max(0, ...drifts),
|
||||
eventLoopP99DriftMs: percentile(drifts, 0.99),
|
||||
interactiveP50Ms: percentile(interactiveLatencies, 0.5),
|
||||
interactiveP95Ms: percentile(interactiveLatencies, 0.95),
|
||||
totalWallMs,
|
||||
admissionEvents,
|
||||
interactiveQueueSnapshots
|
||||
}
|
||||
}
|
||||
|
||||
function formatMeasurementTable(rows: readonly StormMeasurement[]): string {
|
||||
const rounded = rows.map((row) => ({
|
||||
mode: row.mode,
|
||||
maxConcurrentChildren: row.maxConcurrentChildren,
|
||||
eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1),
|
||||
eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1),
|
||||
interactiveP50Ms: row.interactiveP50Ms.toFixed(1),
|
||||
interactiveP95Ms: row.interactiveP95Ms.toFixed(1),
|
||||
totalWallMs: row.totalWallMs.toFixed(1)
|
||||
}))
|
||||
return JSON.stringify(rounded)
|
||||
}
|
||||
|
||||
function assertAdmissionLedger(measurement: StormMeasurement): void {
|
||||
const activeWaiters = new Set<number>()
|
||||
const grantSequenceByWaiter = new Map<number, number>()
|
||||
const grantByLabel = new Map<string, GitAdmissionEvent>()
|
||||
|
||||
measurement.admissionEvents.forEach((event, index) => {
|
||||
expect(event.sequence).toBe(index)
|
||||
if (event.phase === 'grant') {
|
||||
activeWaiters.add(event.waiterId)
|
||||
grantSequenceByWaiter.set(event.waiterId, event.sequence)
|
||||
const label = event.args.find((arg) => arg.startsWith('interactive-'))
|
||||
if (label) {
|
||||
grantByLabel.set(label, event)
|
||||
const start = (id: string, tier: 'background' | 'interactive'): void => {
|
||||
if (tier === 'interactive') {
|
||||
interactiveQueueSnapshots.push({
|
||||
commandLabel: id,
|
||||
backgroundWaiterIds: _gitAdmissionSnapshotForTests()
|
||||
.queuedWaiters.filter((waiter) => waiter.tier === 'background')
|
||||
.map((waiter) => waiter.id)
|
||||
})
|
||||
}
|
||||
const command = gitExecFileAsync([tier === 'background' ? 'status' : 'rev-parse', id], {
|
||||
cwd: root,
|
||||
admissionTier: tier,
|
||||
signal: controller.signal,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
||||
ORCA_STUB_ID: id,
|
||||
ORCA_STUB_STATE_DIR: stateDir,
|
||||
ORCA_STUB_GATE_DIR: gateDir
|
||||
}
|
||||
} else {
|
||||
expect(activeWaiters.delete(event.waiterId)).toBe(true)
|
||||
})
|
||||
void command.catch(() => {})
|
||||
commands.set(id, command)
|
||||
}
|
||||
const release = async (id: string): Promise<void> => {
|
||||
await waitForLiveChild(stateDir, id, controller.signal)
|
||||
await writeFile(path.join(gateDir, id), 'release\n')
|
||||
await expect(commands.get(id)).resolves.toEqual({
|
||||
stdout: `stub:${id.startsWith('background-') ? 'status' : 'rev-parse'} ${id}\n`,
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
try {
|
||||
backgroundIds.forEach((id) => start(id, 'background'))
|
||||
expect(_gitAdmissionSnapshotForTests().queued).toBe(4)
|
||||
headroomIds.forEach((id) => start(id, 'interactive'))
|
||||
queuedIds.forEach((id) => start(id, 'interactive'))
|
||||
expect(_gitAdmissionSnapshotForTests()).toMatchObject({
|
||||
queued: 6,
|
||||
budgets: { general: { baseUsed: GENERAL_CAP, headroomUsed: GENERAL_HEADROOM } }
|
||||
})
|
||||
await Promise.all(
|
||||
[...backgroundIds.slice(0, GENERAL_CAP), ...headroomIds].map((id) =>
|
||||
waitForLiveChild(stateDir, id, controller.signal)
|
||||
)
|
||||
)
|
||||
expect(await readdir(stateDir)).toHaveLength(GENERAL_CAP + GENERAL_HEADROOM)
|
||||
for (const [index, id] of queuedIds.entries()) {
|
||||
await release(backgroundIds[index])
|
||||
await waitForLiveChild(stateDir, id, controller.signal)
|
||||
}
|
||||
expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN)
|
||||
for (const budget of event.budgets) {
|
||||
expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity)
|
||||
expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity)
|
||||
expect(_gitAdmissionSnapshotForTests().queuedWaiters.map((waiter) => waiter.tier)).toEqual([
|
||||
'background',
|
||||
'background',
|
||||
'background',
|
||||
'background'
|
||||
])
|
||||
for (const id of [...headroomIds, ...queuedIds, ...backgroundIds.slice(queuedIds.length)]) {
|
||||
await release(id)
|
||||
}
|
||||
})
|
||||
expect(activeWaiters.size).toBe(0)
|
||||
|
||||
for (const snapshot of measurement.interactiveQueueSnapshots) {
|
||||
const interactiveGrant = grantByLabel.get(snapshot.commandLabel)
|
||||
expect(interactiveGrant).toBeDefined()
|
||||
const preceded = snapshot.backgroundWaiterIds.filter(
|
||||
(waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity)
|
||||
).length
|
||||
expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9))
|
||||
expect(admissionEvents.filter((event) => event.phase === 'grant')).toHaveLength(ids.length)
|
||||
expect(admissionEvents.filter((event) => event.phase === 'release')).toHaveLength(ids.length)
|
||||
assertAdmissionLedger({ admissionEvents, interactiveQueueSnapshots })
|
||||
expect(_gitAdmissionSnapshotForTests()).toMatchObject({
|
||||
queued: 0,
|
||||
budgets: { general: { baseUsed: 0, headroomUsed: 0 } }
|
||||
})
|
||||
expect(await readdir(stateDir)).toEqual([])
|
||||
} finally {
|
||||
controller.abort()
|
||||
await Promise.allSettled(commands.values())
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('git admission storm measurement', () => {
|
||||
it('reports bounded-concurrency before and after measurements', async () => {
|
||||
const disabled = await measureStorm('disabled')
|
||||
const enabled = await measureStorm('enabled')
|
||||
console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`)
|
||||
assertAdmissionLedger(enabled)
|
||||
})
|
||||
it(
|
||||
'bounds real children and drains interactive work before the background backlog',
|
||||
verifySpawnContention
|
||||
)
|
||||
|
||||
// Output parity lives in git-admission-output-parity.test.ts: it needs real git,
|
||||
// not the PATH stub, so it runs on win32 too and cannot share this file's gate.
|
||||
it.runIf(process.env.ORCA_GIT_ADMISSION_STORM_MEASUREMENT === '1')(
|
||||
'reports bounded-concurrency before and after measurements',
|
||||
async () => {
|
||||
const disabled = await measureStorm('disabled')
|
||||
const enabled = await measureStorm('enabled')
|
||||
console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`)
|
||||
assertAdmissionLedger(enabled)
|
||||
}
|
||||
)
|
||||
// Real-git output parity remains in git-admission-output-parity.test.ts, including Windows.
|
||||
})
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path.
|
||||
* Timing distributions are reported for field comparison; CI assertions stay structural.
|
||||
*/
|
||||
import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, expect } from 'vitest'
|
||||
import { gitExecFileAsync } from './git-exec-file'
|
||||
import {
|
||||
GIT_ADMISSION_AGING_MS,
|
||||
GitAdmissionScheduler,
|
||||
MAX_GIT_CHILDREN,
|
||||
_gitAdmissionSnapshotForTests,
|
||||
_resetGitAdmissionForTests,
|
||||
type GitAdmissionEvent
|
||||
} from './git-subprocess-admission'
|
||||
|
||||
type StormMeasurement = {
|
||||
mode: 'disabled' | 'enabled'
|
||||
maxConcurrentChildren: number
|
||||
eventLoopMaxDriftMs: number
|
||||
eventLoopP99DriftMs: number
|
||||
interactiveP50Ms: number
|
||||
interactiveP95Ms: number
|
||||
totalWallMs: number
|
||||
admissionEvents: GitAdmissionEvent[]
|
||||
interactiveQueueSnapshots: InteractiveQueueSnapshot[]
|
||||
}
|
||||
|
||||
export type InteractiveQueueSnapshot = {
|
||||
commandLabel: string
|
||||
backgroundWaiterIds: number[]
|
||||
}
|
||||
|
||||
const tempRoots: string[] = []
|
||||
const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalAdmissionDisabled === undefined) {
|
||||
delete process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
} else {
|
||||
process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled
|
||||
}
|
||||
_resetGitAdmissionForTests()
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
export async function createStormRoot(label: string): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${label}-`))
|
||||
tempRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function percentile(values: readonly number[], percentileValue: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)]
|
||||
}
|
||||
|
||||
async function liveChildCount(stateDir: string): Promise<number> {
|
||||
return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length
|
||||
}
|
||||
|
||||
async function createStubGit(root: string): Promise<string> {
|
||||
const binDir = path.join(root, 'bin')
|
||||
await mkdir(binDir)
|
||||
const stubPath = path.join(binDir, 'git')
|
||||
await writeFile(
|
||||
stubPath,
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live"
|
||||
: > "$live"
|
||||
trap 'rm -f "$live"' EXIT HUP INT TERM
|
||||
sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")"
|
||||
printf 'stub:%s\\n' "$*"
|
||||
`
|
||||
)
|
||||
await chmod(stubPath, 0o755)
|
||||
return binDir
|
||||
}
|
||||
|
||||
function setAdmissionMode(mode: StormMeasurement['mode']): void {
|
||||
if (mode === 'disabled') {
|
||||
process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
|
||||
} else {
|
||||
delete process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
}
|
||||
}
|
||||
|
||||
export async function measureStorm(mode: StormMeasurement['mode']): Promise<StormMeasurement> {
|
||||
setAdmissionMode(mode)
|
||||
const admissionEvents: GitAdmissionEvent[] = []
|
||||
const admissionClockStartedAt = performance.now()
|
||||
_resetGitAdmissionForTests(
|
||||
new GitAdmissionScheduler({
|
||||
now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1),
|
||||
onAdmissionEvent: (event) => admissionEvents.push(event)
|
||||
})
|
||||
)
|
||||
const root = await createStormRoot(mode)
|
||||
const stateDir = path.join(root, 'state')
|
||||
await mkdir(stateDir)
|
||||
const binDir = await createStubGit(root)
|
||||
const repoDirs = await Promise.all(
|
||||
Array.from({ length: 6 }, async (_, index) => {
|
||||
const repoDir = path.join(root, `repo-${index}`)
|
||||
await mkdir(repoDir)
|
||||
return repoDir
|
||||
})
|
||||
)
|
||||
const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
|
||||
const startedAt = performance.now()
|
||||
const drifts: number[] = []
|
||||
let nextJankSample = performance.now() + 50
|
||||
const jankTimer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
drifts.push(Math.max(0, now - nextJankSample))
|
||||
nextJankSample = now + 50
|
||||
}, 50)
|
||||
let maxConcurrentChildren = 0
|
||||
const censusTimer = setInterval(() => {
|
||||
void liveChildCount(stateDir).then((count) => {
|
||||
maxConcurrentChildren = Math.max(maxConcurrentChildren, count)
|
||||
})
|
||||
}, 5)
|
||||
|
||||
const background = Array.from({ length: 60 }, (_, index) =>
|
||||
gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], {
|
||||
cwd: repoDirs[index % repoDirs.length],
|
||||
env: {
|
||||
...baseEnv,
|
||||
ORCA_STUB_ID: `background-${index}`,
|
||||
ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200',
|
||||
ORCA_STUB_STATE_DIR: stateDir
|
||||
},
|
||||
admissionTier: 'background'
|
||||
})
|
||||
)
|
||||
const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = []
|
||||
const interactiveLatencies = await Promise.all(
|
||||
Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) =>
|
||||
new Promise<number>((resolve, reject) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
void (async () => {
|
||||
const concurrentAtInjection = await liveChildCount(stateDir)
|
||||
const commandStartedAt = performance.now()
|
||||
const commandLabel = `interactive-${index}`
|
||||
interactiveQueueSnapshots.push({
|
||||
commandLabel,
|
||||
backgroundWaiterIds: _gitAdmissionSnapshotForTests()
|
||||
.queuedWaiters.filter((waiter) => waiter.tier === 'background')
|
||||
.map((waiter) => waiter.id)
|
||||
})
|
||||
await gitExecFileAsync(['rev-parse', commandLabel], {
|
||||
cwd: repoDirs[index % repoDirs.length],
|
||||
env: {
|
||||
...baseEnv,
|
||||
ORCA_STUB_ID: `interactive-${index}`,
|
||||
ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)),
|
||||
ORCA_STUB_STATE_DIR: stateDir
|
||||
},
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
resolve(performance.now() - commandStartedAt)
|
||||
})().catch(reject)
|
||||
},
|
||||
50 * (index + 1)
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
await Promise.all(background)
|
||||
clearInterval(censusTimer)
|
||||
clearInterval(jankTimer)
|
||||
maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir))
|
||||
const totalWallMs = performance.now() - startedAt
|
||||
return {
|
||||
mode,
|
||||
maxConcurrentChildren,
|
||||
eventLoopMaxDriftMs: Math.max(0, ...drifts),
|
||||
eventLoopP99DriftMs: percentile(drifts, 0.99),
|
||||
interactiveP50Ms: percentile(interactiveLatencies, 0.5),
|
||||
interactiveP95Ms: percentile(interactiveLatencies, 0.95),
|
||||
totalWallMs,
|
||||
admissionEvents,
|
||||
interactiveQueueSnapshots
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMeasurementTable(rows: readonly StormMeasurement[]): string {
|
||||
const rounded = rows.map((row) => ({
|
||||
mode: row.mode,
|
||||
maxConcurrentChildren: row.maxConcurrentChildren,
|
||||
eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1),
|
||||
eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1),
|
||||
interactiveP50Ms: row.interactiveP50Ms.toFixed(1),
|
||||
interactiveP95Ms: row.interactiveP95Ms.toFixed(1),
|
||||
totalWallMs: row.totalWallMs.toFixed(1)
|
||||
}))
|
||||
return JSON.stringify(rounded)
|
||||
}
|
||||
|
||||
export function assertAdmissionLedger(
|
||||
measurement: Pick<StormMeasurement, 'admissionEvents' | 'interactiveQueueSnapshots'>
|
||||
): void {
|
||||
const activeWaiters = new Set<number>()
|
||||
const grantSequenceByWaiter = new Map<number, number>()
|
||||
const grantByLabel = new Map<string, GitAdmissionEvent>()
|
||||
|
||||
measurement.admissionEvents.forEach((event, index) => {
|
||||
expect(event.sequence).toBe(index)
|
||||
if (event.phase === 'grant') {
|
||||
activeWaiters.add(event.waiterId)
|
||||
grantSequenceByWaiter.set(event.waiterId, event.sequence)
|
||||
const label = event.args.find((arg) => arg.startsWith('interactive-'))
|
||||
if (label) {
|
||||
grantByLabel.set(label, event)
|
||||
}
|
||||
} else {
|
||||
expect(activeWaiters.delete(event.waiterId)).toBe(true)
|
||||
}
|
||||
expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN)
|
||||
for (const budget of event.budgets) {
|
||||
expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity)
|
||||
expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity)
|
||||
}
|
||||
})
|
||||
expect(activeWaiters.size).toBe(0)
|
||||
|
||||
for (const snapshot of measurement.interactiveQueueSnapshots) {
|
||||
const interactiveGrant = grantByLabel.get(snapshot.commandLabel)
|
||||
expect(interactiveGrant).toBeDefined()
|
||||
const preceded = snapshot.backgroundWaiterIds.filter(
|
||||
(waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity)
|
||||
).length
|
||||
expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9))
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,11 @@ export {
|
||||
type PullRequestPushTarget
|
||||
} from './client/lookup/pull-request-push-target'
|
||||
export { getRepoSlug, getRepoUpstream } from './client/fetch/repo-slug-upstream'
|
||||
export { getWorkItem, getWorkItemByOwnerRepo } from './client/fetch/get-work-item'
|
||||
export {
|
||||
getWorkItem,
|
||||
getWorkItemByOwnerRepo,
|
||||
getWorkItemWithRepository
|
||||
} from './client/fetch/get-work-item'
|
||||
export { listWorkItems } from './client/list/list-work-items'
|
||||
export { mergePR } from './client/merge/merge-pr'
|
||||
export { removePRReviewers, requestPRReviewers } from './client/update/pr-reviewers'
|
||||
|
||||
@@ -21,6 +21,30 @@ export async function getWorkItem(
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<MainWorkItem | null> {
|
||||
const { item } = await getWorkItemWithRepository(
|
||||
repoPath,
|
||||
number,
|
||||
type,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
return item
|
||||
}
|
||||
|
||||
export type WorkItemRepositoryLookup = {
|
||||
item: MainWorkItem | null
|
||||
repository: GitHubApiRepository | null
|
||||
}
|
||||
|
||||
export async function getWorkItemWithRepository(
|
||||
repoPath: string,
|
||||
number: number,
|
||||
type?: 'issue' | 'pr',
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<WorkItemRepositoryLookup> {
|
||||
await acquire()
|
||||
try {
|
||||
// Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number
|
||||
@@ -35,18 +59,24 @@ export async function getWorkItem(
|
||||
// Why: explicit origin with no origin identity must not bare-lookup ambient gh
|
||||
// (same fail-closed rule as origin-pinned PR candidate resolution).
|
||||
if (!source && preference === 'origin') {
|
||||
return null
|
||||
return { item: null, repository: null }
|
||||
}
|
||||
return {
|
||||
item: await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions),
|
||||
repository: source
|
||||
}
|
||||
return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions)
|
||||
}
|
||||
if (type === 'pr') {
|
||||
return await fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
return {
|
||||
item: await fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
),
|
||||
repository: null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -65,7 +95,7 @@ export async function getWorkItem(
|
||||
localGitOptions
|
||||
)
|
||||
if (issue) {
|
||||
return issue
|
||||
return { item: issue, repository: source }
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -75,15 +105,18 @@ export async function getWorkItem(
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return await fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
return {
|
||||
item: await fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
),
|
||||
repository: null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
return { item: null, repository: null }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
|
||||
@@ -22,4 +22,25 @@ describe('isValidGitHubApiRepository', () => {
|
||||
expect(isValidGitHubApiRepository({ owner: 'acme', repo: '..' })).toBe(false)
|
||||
expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'a/b' })).toBe(false)
|
||||
})
|
||||
|
||||
it.each([42, false, null, {}])('rejects a non-string repository host: %j', (host) => {
|
||||
expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(false)
|
||||
})
|
||||
|
||||
it.each([undefined, 'github.com', 'github.enterprise.test:8443'])(
|
||||
'accepts supported legacy or explicit repository host types: %j',
|
||||
(host) => {
|
||||
expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
null,
|
||||
'acme/orca',
|
||||
{ owner: 42, repo: 'orca' },
|
||||
{ owner: 'acme', repo: 42 },
|
||||
{ owner: 'acme' }
|
||||
])('rejects malformed runtime repository identities: %j', (repository) => {
|
||||
expect(isValidGitHubApiRepository(repository)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,8 +11,21 @@ export type GitHubApiRepositoryResolution =
|
||||
const OWNER_SLUG_RE = GITHUB_OWNER_SLUG_RE
|
||||
const REPOSITORY_SLUG_RE = /^[A-Za-z0-9._-]+$/
|
||||
|
||||
export function isValidGitHubApiRepository(repository: GitHubOwnerRepo): boolean {
|
||||
export function isValidGitHubApiRepository(repository: unknown): repository is GitHubOwnerRepo {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository !== 'object' ||
|
||||
!('owner' in repository) ||
|
||||
!('repo' in repository)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
typeof repository.owner === 'string' &&
|
||||
typeof repository.repo === 'string' &&
|
||||
(!('host' in repository) ||
|
||||
repository.host === undefined ||
|
||||
typeof repository.host === 'string') &&
|
||||
OWNER_SLUG_RE.test(repository.owner) &&
|
||||
REPOSITORY_SLUG_RE.test(repository.repo) &&
|
||||
repository.repo !== '.' &&
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import type { GitHubCommentResult, PRComment } from '../../shared/github/comment-types'
|
||||
import type { IssueSourcePreference } from '../../shared/repo-types'
|
||||
import type { LocalGitExecOptions, OwnerRepo } from './gh-utils'
|
||||
import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
|
||||
import {
|
||||
resolveGitHubRepoExecution,
|
||||
resolveIssueGitHubApiRepositorySource
|
||||
} from './github-api-repository'
|
||||
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
|
||||
|
||||
/**
|
||||
* Add a comment to an existing GitHub issue.
|
||||
*
|
||||
* Why this path doesn't take a preference (mirrors `getIssue` / `updateIssue`):
|
||||
* a comment is posted against an issue number already bound to a worktree or
|
||||
* surfaced from a prior read. Routing through the live per-repo preference
|
||||
* would let a user read upstream#N, toggle the selector to origin, and have
|
||||
* their reply silently post on origin#N — a different issue entirely. That
|
||||
* is the same silent-source-switch class of wrongness #1186 / the parent
|
||||
* design doc guard against. List and create paths honor preference;
|
||||
* mutations stay on the heuristic `getIssueOwnerRepo`.
|
||||
*/
|
||||
// An explicit target keeps replies bound to the conversation; otherwise use the selected source.
|
||||
export async function addIssueComment(
|
||||
repoPath: string,
|
||||
issueNumber: number,
|
||||
body: string,
|
||||
connectionId?: string | null,
|
||||
ownerRepoOverride?: OwnerRepo | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<GitHubCommentResult> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
ownerRepoOverride ??
|
||||
(() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)),
|
||||
(async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GitHubAssignableUser } from '../../shared/github/pull-request-types'
|
||||
import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../shared/github/pull-request-types'
|
||||
import type { IssueSourcePreference } from '../../shared/repo-types'
|
||||
import type { LocalGitExecOptions } from './gh-utils'
|
||||
import {
|
||||
@@ -11,19 +11,21 @@ export async function listLabels(
|
||||
repoPath: string,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
repositoryOverride?: GitHubOwnerRepo | null
|
||||
): Promise<string[]> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source,
|
||||
repositoryOverride ??
|
||||
(async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
@@ -57,19 +59,21 @@ export async function listAssignableUsers(
|
||||
repoPath: string,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
repositoryOverride?: GitHubOwnerRepo | null
|
||||
): Promise<GitHubAssignableUser[]> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source,
|
||||
repositoryOverride ??
|
||||
(async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
import type * as GhUtils from './gh-utils'
|
||||
|
||||
const { ghExecFileAsyncMock, resolveIssueSourceMock, acquireMock, releaseMock } = vi.hoisted(
|
||||
() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
resolveIssueSourceMock: vi.fn(),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('./gh-utils', async () => {
|
||||
const actual = await vi.importActual<typeof GhUtils>('./gh-utils')
|
||||
return {
|
||||
...actual,
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
acquire: acquireMock,
|
||||
release: releaseMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./github-api-repository', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GithubApiRepositoryModule>()),
|
||||
resolveIssueGitHubApiRepositorySource: resolveIssueSourceMock
|
||||
}))
|
||||
|
||||
import { addIssueComment } from './issue-comment'
|
||||
import { updateIssue } from './issue-update'
|
||||
|
||||
const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
|
||||
const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' }
|
||||
|
||||
describe('issue mutations follow the issue source preference', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
acquireMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
releaseMock.mockReset()
|
||||
resolveIssueSourceMock.mockReset()
|
||||
resolveIssueSourceMock.mockImplementation(async (_repoPath: string, preference: unknown) => ({
|
||||
source: preference === 'origin' ? ORIGIN : UPSTREAM,
|
||||
fellBack: false
|
||||
}))
|
||||
})
|
||||
|
||||
it('posts issue comments to origin when origin is selected', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 1, user: { login: 'octo', avatar_url: '' }, body: 'hi' })
|
||||
})
|
||||
|
||||
await expect(
|
||||
addIssueComment('/repo-root', 5, 'hi', null, null, {}, 'origin')
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
|
||||
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
|
||||
'repos/fork-owner/widgets/issues/5/comments'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an explicit owner/repo override ahead of the preference', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 2, user: { login: 'octo', avatar_url: '' }, body: 'hi' })
|
||||
})
|
||||
|
||||
await addIssueComment('/repo-root', 7, 'hi', null, UPSTREAM, {}, 'origin')
|
||||
|
||||
expect(resolveIssueSourceMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
|
||||
'repos/upstream-owner/widgets/issues/7/comments'
|
||||
)
|
||||
})
|
||||
|
||||
it('edits issues on origin when origin is selected', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
|
||||
|
||||
await expect(
|
||||
updateIssue('/repo-root', 5, { body: 'Updated', addAssignees: ['octo'] }, null, {}, 'origin')
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/fork-owner/widgets/issues/5')
|
||||
expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([
|
||||
'issue',
|
||||
'edit',
|
||||
'5',
|
||||
'--repo',
|
||||
'fork-owner/widgets',
|
||||
'--add-assignee',
|
||||
'octo'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the upstream-first default when no preference is set', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
|
||||
|
||||
await updateIssue('/repo-root', 5, { body: 'Updated' })
|
||||
|
||||
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', undefined, undefined, {})
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/upstream-owner/widgets/issues/5')
|
||||
})
|
||||
|
||||
it('keeps edits on the opened issue when the saved preference changes', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
|
||||
|
||||
await updateIssue(
|
||||
'/repo-root',
|
||||
5,
|
||||
{ body: 'Origin edit', state: 'closed', addLabels: ['bug'], addAssignees: ['octo'] },
|
||||
null,
|
||||
{},
|
||||
'upstream',
|
||||
ORIGIN
|
||||
)
|
||||
|
||||
expect(resolveIssueSourceMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([
|
||||
['issue', 'close', '5', '--repo', 'fork-owner/widgets'],
|
||||
[
|
||||
'api',
|
||||
'-X',
|
||||
'PATCH',
|
||||
'repos/fork-owner/widgets/issues/5',
|
||||
'--raw-field',
|
||||
'body=Origin edit'
|
||||
],
|
||||
[
|
||||
'issue',
|
||||
'edit',
|
||||
'5',
|
||||
'--repo',
|
||||
'fork-owner/widgets',
|
||||
'--add-label',
|
||||
'bug',
|
||||
'--add-assignee',
|
||||
'octo'
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves local WSL execution with an explicit issue repository', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
|
||||
|
||||
await updateIssue(
|
||||
'/home/fixture/widgets',
|
||||
5,
|
||||
{ body: 'Origin edit' },
|
||||
null,
|
||||
{ wslDistro: 'Ubuntu' },
|
||||
'upstream',
|
||||
ORIGIN
|
||||
)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['repos/fork-owner/widgets/issues/5']),
|
||||
expect.objectContaining({
|
||||
cwd: '/home/fixture/widgets',
|
||||
wslDistro: 'Ubuntu',
|
||||
host: 'github.com'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses unresolved Origin mutations instead of using ambient gh', async () => {
|
||||
resolveIssueSourceMock.mockResolvedValue({ source: null, fellBack: false })
|
||||
|
||||
await expect(
|
||||
updateIssue('/repo-root', 5, { body: 'Origin edit' }, null, {}, 'origin')
|
||||
).resolves.toMatchObject({ ok: false })
|
||||
await expect(
|
||||
addIssueComment('/repo-root', 5, 'Origin reply', null, null, {}, 'origin')
|
||||
).resolves.toMatchObject({ ok: false })
|
||||
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,31 +1,35 @@
|
||||
import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types'
|
||||
import type { IssueSourcePreference } from '../../shared/repo-types'
|
||||
import type { LocalGitExecOptions } from './gh-utils'
|
||||
import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
|
||||
import {
|
||||
resolveGitHubRepoExecution,
|
||||
resolveIssueGitHubApiRepositorySource,
|
||||
type GitHubApiRepository
|
||||
} from './github-api-repository'
|
||||
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
|
||||
|
||||
/**
|
||||
* Update an existing GitHub issue. Fans out to separate gh commands for
|
||||
* state changes vs field edits since `gh issue edit` does not support state.
|
||||
*
|
||||
* Why this path doesn't take a preference (mirrors `getIssue`): mutations
|
||||
* target an issue number already bound to a worktree / linked elsewhere in
|
||||
* the UI. Routing an update through the live per-repo preference would let
|
||||
* a user open upstream#N, toggle the selector to origin, save, and silently
|
||||
* write to origin#N — a different issue (or 404). That is the exact
|
||||
* silent-source-switch class of wrongness #1186 / the parent design doc
|
||||
* guard against. List and create paths honor preference; mutations stay on
|
||||
* the heuristic `getIssueOwnerRepo`.
|
||||
*/
|
||||
// Use the selected source for issue-number edits.
|
||||
export async function updateIssue(
|
||||
repoPath: string,
|
||||
issueNumber: number,
|
||||
updates: GitHubIssueUpdate,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference,
|
||||
ownerRepoOverride?: GitHubApiRepository | null
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
ownerRepoOverride ??
|
||||
(async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
@@ -27,8 +28,11 @@ vi.mock('./gh-utils', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getPRComments: getPRCommentsMock,
|
||||
getPRChecks: getPRChecksMock
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as NodeOs from 'node:os'
|
||||
import type * as GitExecError from '../git/exec-error'
|
||||
import type * as WorkItemLookup from './client/fetch/get-work-item'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
|
||||
const { ghMock, fixture } = vi.hoisted(() => {
|
||||
const fixture: {
|
||||
path: string
|
||||
initialHost: string
|
||||
replacementUrl: string
|
||||
requests: { args: string[]; host?: string }[]
|
||||
} = { path: '', initialHost: '', replacementUrl: '', requests: [] }
|
||||
return { ghMock: vi.fn(), fixture }
|
||||
})
|
||||
|
||||
vi.mock('node:os', async (original) => ({
|
||||
...(await original<typeof NodeOs>()),
|
||||
homedir: () => join(fixture.path, 'isolated-home')
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', async () => {
|
||||
const errors = await vi.importActual<typeof GitExecError>('../git/exec-error')
|
||||
return {
|
||||
...errors,
|
||||
ghExecFileAsync: ghMock,
|
||||
gitExecFileAsync: async (args: string[], options: { cwd?: string }) => {
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig')
|
||||
}
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr)
|
||||
}
|
||||
return { stdout: result.stdout, stderr: result.stderr }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./client', async () => ({
|
||||
...(await vi.importActual<typeof WorkItemLookup>('./client/fetch/get-work-item')),
|
||||
getPRChecks: vi.fn(),
|
||||
getPRComments: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./rate-limit', () => ({
|
||||
repositoryRateLimitGuard: () => ({ blocked: false }),
|
||||
noteRepositoryRateLimitSpend: vi.fn()
|
||||
}))
|
||||
|
||||
import { getWorkItemDetails } from './work-item-details'
|
||||
import { _resetOwnerRepoCache } from './github-repository-identity'
|
||||
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
|
||||
import { _resetGitHubHostAuthCache } from './github-enterprise-repository'
|
||||
|
||||
async function fixtureGit(args: string[]): Promise<void> {
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
args,
|
||||
cwd: fixture.path,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig')
|
||||
}
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
fixture.path = await mkdtemp(join(tmpdir(), 'orca-issue-config-change-'))
|
||||
await mkdir(join(fixture.path, 'isolated-home'))
|
||||
await fixtureGit(['init', '--quiet'])
|
||||
await fixtureGit(['remote', 'add', 'origin', 'https://github.com/fork-owner/widgets.git'])
|
||||
_resetOwnerRepoCache()
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
_resetGitHubHostAuthCache()
|
||||
fixture.initialHost = 'github.com'
|
||||
fixture.replacementUrl = 'https://github.com/replacement-owner/widgets.git'
|
||||
fixture.requests = []
|
||||
ghMock.mockReset()
|
||||
ghMock.mockImplementation(async (args: string[], options: { host?: string }) => {
|
||||
fixture.requests.push({ args, host: options.host })
|
||||
if (args[0] === 'auth' && args[1] === 'status') {
|
||||
return {
|
||||
stdout: ['github.com', 'github.enterprise.test']
|
||||
.map((host) => `${host}\n Logged in to ${host} account fixture (keyring)`)
|
||||
.join('\n'),
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args.includes('repos/fork-owner/widgets/issues/5')) {
|
||||
await fixtureGit(['remote', 'set-url', 'origin', fixture.replacementUrl])
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
number: 5,
|
||||
title: 'ORIGIN title',
|
||||
state: 'open',
|
||||
html_url: `https://${fixture.initialHost}/fork-owner/widgets/issues/5`,
|
||||
labels: [],
|
||||
updated_at: '2026-10-02T00:00:00Z',
|
||||
user: { login: 'fork-author' }
|
||||
}),
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args.includes('graphql')) {
|
||||
const original = args.includes('owner=fork-owner') && options.host === fixture.initialHost
|
||||
const marker = original ? 'ORIGIN' : 'REPLACEMENT'
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body: `${marker} body`,
|
||||
assignees: { nodes: [{ login: `${marker}-assignee` }] },
|
||||
participants: { nodes: [] },
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
databaseId: 1,
|
||||
body: `${marker} comment`,
|
||||
createdAt: '2026-10-02T00:00:00Z',
|
||||
url: `https://${options.host}/fork-owner/widgets/issues/5#issuecomment-1`,
|
||||
author: { login: `${marker}-author`, avatarUrl: '' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args.some((arg) => arg.includes('/timeline?'))) {
|
||||
return { stdout: '[]', stderr: '' }
|
||||
}
|
||||
throw new Error(`Unexpected fixture gh request: ${args.join(' ')}`)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(fixture.path, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each(['issue', undefined] as const)(
|
||||
'keeps implicit %s conversation on the repository used for the item fetch',
|
||||
async (type) => {
|
||||
const details = await getWorkItemDetails(fixture.path, 5, type, null, {}, 'origin')
|
||||
expect(details?.item.title).toBe('ORIGIN title')
|
||||
expect(details?.body).toBe('ORIGIN body')
|
||||
expect(details?.assignees).toEqual(['ORIGIN-assignee'])
|
||||
expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment'])
|
||||
expect(fixture.requests[1].args).toContain('owner=fork-owner')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an explicit local Tasks identity through the same real config change', async () => {
|
||||
const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', {
|
||||
owner: 'fork-owner',
|
||||
repo: 'widgets',
|
||||
host: 'github.com'
|
||||
})
|
||||
expect(details?.item.title).toBe('ORIGIN title')
|
||||
expect(details?.body).toBe('ORIGIN body')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ from: 'github.com', to: 'github.enterprise.test', qualified: false },
|
||||
{ from: 'github.enterprise.test', to: 'github.com', qualified: false },
|
||||
{ from: 'github.com', to: 'github.enterprise.test', qualified: true },
|
||||
{ from: 'github.enterprise.test', to: 'github.com', qualified: true }
|
||||
])(
|
||||
'keeps explicit host from $from to $to (qualified=$qualified)',
|
||||
async ({ from, to, qualified }) => {
|
||||
fixture.initialHost = from
|
||||
fixture.replacementUrl = `https://${to}/fork-owner/widgets.git`
|
||||
await fixtureGit(['remote', 'set-url', 'origin', `https://${from}/fork-owner/widgets.git`])
|
||||
const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', {
|
||||
owner: 'fork-owner',
|
||||
repo: 'widgets',
|
||||
...(qualified ? { host: from } : {})
|
||||
})
|
||||
expect(details?.item.url).toBe(`https://${from}/fork-owner/widgets/issues/5`)
|
||||
expect(details?.body).toBe('ORIGIN body')
|
||||
expect(details?.assignees).toEqual(['ORIGIN-assignee'])
|
||||
expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment'])
|
||||
const conversationRequests = fixture.requests.filter(
|
||||
({ args }) => args.includes('graphql') || args.some((arg) => arg.includes('/timeline?'))
|
||||
)
|
||||
expect(conversationRequests.some(({ args }) => args.includes('graphql'))).toBe(true)
|
||||
expect(
|
||||
conversationRequests.some(({ args }) => args.some((arg) => arg.includes('/timeline?')))
|
||||
).toBe(true)
|
||||
expect(conversationRequests.map(({ host }) => host)).toEqual([from, from])
|
||||
}
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RateLimitGuardResult =
|
||||
@@ -47,8 +48,11 @@ vi.mock('./gh-utils', () => ({
|
||||
release: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
|
||||
getPRChecks: getPRChecksMock,
|
||||
getPRComments: getPRCommentsMock
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RateLimitGuardResult =
|
||||
@@ -59,8 +60,11 @@ vi.mock('./gh-utils', () => ({
|
||||
release: releaseMock
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: vi.fn(),
|
||||
getPRChecks: getPRChecksMock,
|
||||
getPRComments: getPRCommentsMock
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
getOwnerRepoForRemoteMock,
|
||||
getWorkItemMock,
|
||||
getWorkItemByOwnerRepoMock,
|
||||
acquireMock,
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
getOwnerRepoForRemoteMock: vi.fn(),
|
||||
getWorkItemMock: vi.fn(),
|
||||
getWorkItemByOwnerRepoMock: vi.fn(),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./gh-utils', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
getOwnerRepo: getOwnerRepoMock,
|
||||
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
|
||||
ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })),
|
||||
githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null,
|
||||
...localGitOptions
|
||||
})),
|
||||
acquire: acquireMock,
|
||||
release: releaseMock
|
||||
}))
|
||||
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
|
||||
getPRChecks: vi.fn(),
|
||||
getPRComments: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./github-enterprise-repository', () => ({
|
||||
getEnterpriseGitHubRepoSlug: vi.fn().mockResolvedValue(null),
|
||||
getEnterpriseGitHubRepoSlugForRemote: vi.fn().mockResolvedValue(null),
|
||||
isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
vi.mock('./rate-limit', () => ({
|
||||
repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })),
|
||||
noteRepositoryRateLimitSpend: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/remote-name-listing', () => ({
|
||||
shouldProbeGitRemote: vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
import { getWorkItemDetails } from './work-item-details'
|
||||
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
|
||||
|
||||
const REMOTES: Record<string, { owner: string; repo: string }> = {
|
||||
origin: { owner: 'fork-owner', repo: 'widgets' },
|
||||
upstream: { owner: 'upstream-owner', repo: 'widgets' }
|
||||
}
|
||||
|
||||
function graphQLIssueResponse(assignee: string): { stdout: string } {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body: `${assignee} body`,
|
||||
assignees: { nodes: [{ login: assignee }] },
|
||||
participants: { nodes: [] },
|
||||
comments: { nodes: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function graphQLOwnerArg(): string | undefined {
|
||||
const args: string[] = ghExecFileAsyncMock.mock.calls[0][0]
|
||||
return args.find((arg) => arg.startsWith('owner='))
|
||||
}
|
||||
|
||||
describe('getWorkItemDetails issue source', () => {
|
||||
beforeEach(() => {
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
getOwnerRepoMock.mockReset()
|
||||
getOwnerRepoMock.mockResolvedValue(REMOTES.origin)
|
||||
getOwnerRepoForRemoteMock.mockReset()
|
||||
getOwnerRepoForRemoteMock.mockImplementation(
|
||||
async (_repoPath: string, remoteName: string) => REMOTES[remoteName] ?? null
|
||||
)
|
||||
getWorkItemMock.mockReset()
|
||||
getWorkItemByOwnerRepoMock.mockReset()
|
||||
getWorkItemMock.mockResolvedValue({
|
||||
id: 'issue:5',
|
||||
type: 'issue',
|
||||
number: 5,
|
||||
title: 'Origin issue',
|
||||
state: 'open',
|
||||
url: 'https://github.com/fork-owner/widgets/issues/5',
|
||||
labels: [],
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
author: 'fork-author'
|
||||
})
|
||||
acquireMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
releaseMock.mockReset()
|
||||
})
|
||||
|
||||
it('reads body, assignees and comments from origin when origin is selected', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin')
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 5, 'issue', null, {}, 'origin')
|
||||
expect(graphQLOwnerArg()).toBe('owner=fork-owner')
|
||||
expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain(
|
||||
'repos/fork-owner/widgets/issues/5/timeline?per_page=100&page=1'
|
||||
)
|
||||
expect(details?.assignees).toEqual(['fork-assignee'])
|
||||
})
|
||||
|
||||
it('keeps the upstream-first default when no preference is set', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee'))
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {})
|
||||
|
||||
expect(graphQLOwnerArg()).toBe('owner=upstream-owner')
|
||||
expect(details?.assignees).toEqual(['upstream-assignee'])
|
||||
})
|
||||
|
||||
it('keeps a Project row on upstream even when Origin is selected', async () => {
|
||||
const upstream = { ...REMOTES.upstream, host: 'github.com' }
|
||||
getWorkItemByOwnerRepoMock.mockResolvedValue({
|
||||
id: 'issue:5',
|
||||
type: 'issue',
|
||||
number: 5,
|
||||
title: 'Upstream project issue',
|
||||
url: 'https://github.com/upstream-owner/widgets/issues/5',
|
||||
state: 'open',
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: 'upstream-author'
|
||||
})
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee'))
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin', upstream)
|
||||
|
||||
expect(getWorkItemMock).not.toHaveBeenCalled()
|
||||
expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith(
|
||||
'/repo-root',
|
||||
upstream,
|
||||
5,
|
||||
'issue',
|
||||
null,
|
||||
{}
|
||||
)
|
||||
expect(graphQLOwnerArg()).toBe('owner=upstream-owner')
|
||||
expect(details?.item.title).toBe('Upstream project issue')
|
||||
expect(details?.assignees).toEqual(['upstream-assignee'])
|
||||
})
|
||||
|
||||
it('does not fall back when the selected issue repository is unavailable', async () => {
|
||||
getWorkItemByOwnerRepoMock.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', {
|
||||
...REMOTES.origin,
|
||||
host: 'github.com'
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(getWorkItemMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an invalid runtime issue host before calling the repository lookup', async () => {
|
||||
await expect(
|
||||
getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', {
|
||||
...REMOTES.origin,
|
||||
host: 42
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(getWorkItemMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores an issue repository override on an untyped lookup', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await getWorkItemDetails('/repo-root', 5, undefined, null, {}, 'origin', {
|
||||
owner: 'untrusted',
|
||||
repo: 'other',
|
||||
host: 'untrusted.example'
|
||||
})
|
||||
|
||||
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(graphQLOwnerArg()).toBe('owner=fork-owner')
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' })
|
||||
})
|
||||
|
||||
it('normalizes host-less issue targets before loading the conversation', async () => {
|
||||
getWorkItemByOwnerRepoMock.mockResolvedValue({
|
||||
type: 'issue',
|
||||
number: 5,
|
||||
title: 'Origin issue',
|
||||
url: 'https://github.com/fork-owner/widgets/issues/5',
|
||||
author: null
|
||||
})
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', REMOTES.origin)
|
||||
|
||||
expect(graphQLOwnerArg()).toBe('owner=fork-owner')
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' })
|
||||
expect(ghExecFileAsyncMock.mock.calls[1][1]).toMatchObject({ host: 'github.com' })
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RateLimitGuardResult =
|
||||
@@ -52,8 +53,11 @@ vi.mock('./gh-utils', () => ({
|
||||
release: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: vi.fn(),
|
||||
getPRChecks: getPRChecksMock,
|
||||
getPRComments: getPRCommentsMock
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { resolveIssueGitHubApiRepositorySource } from './github-api-repository'
|
||||
import type { getWorkItem } from './client'
|
||||
import type { WorkItemRepositoryLookup } from './client/fetch/get-work-item'
|
||||
|
||||
// Why: detail tests replace item lookup; retain its selected-source metadata.
|
||||
export function makeWorkItemDetailsLookupMock(lookup: typeof getWorkItem) {
|
||||
return async (...args: Parameters<typeof getWorkItem>): Promise<WorkItemRepositoryLookup> => {
|
||||
const item = await lookup(...args)
|
||||
return {
|
||||
item,
|
||||
repository:
|
||||
item?.type === 'issue'
|
||||
? (await resolveIssueGitHubApiRepositorySource(args[0], args[5], args[3], args[4])).source
|
||||
: null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RateLimitGuardResult =
|
||||
@@ -62,8 +63,11 @@ vi.mock('./gh-utils', () => ({
|
||||
release: releaseMock
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
|
||||
getPRChecks: getPRChecksMock,
|
||||
getPRComments: getPRCommentsMock
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { PRCheckDetail } from '../../shared/github/check-types'
|
||||
import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types'
|
||||
import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types'
|
||||
import type { IssueSourcePreference } from '../../shared/repo-types'
|
||||
import { getPRChecks, getPRComments, getWorkItem } from './client'
|
||||
import { acquire, release, type LocalGitExecOptions } from './gh-utils'
|
||||
import {
|
||||
getIssueGitHubApiRepository,
|
||||
resolveGitHubRepoExecution,
|
||||
type GitHubApiRepository
|
||||
} from './github-api-repository'
|
||||
getPRChecks,
|
||||
getPRComments,
|
||||
getWorkItemWithRepository,
|
||||
getWorkItemByOwnerRepo
|
||||
} from './client'
|
||||
import { acquire, release, type LocalGitExecOptions } from './gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from './github-api-repository'
|
||||
import { isValidGitHubApiRepository } from './github-api-repository-validation'
|
||||
import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details'
|
||||
import {
|
||||
getPRFiles,
|
||||
@@ -69,23 +71,49 @@ export async function getWorkItemDetails(
|
||||
type?: 'issue' | 'pr',
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
preference?: IssueSourcePreference,
|
||||
repositoryOverride?: unknown
|
||||
): Promise<GitHubWorkItemDetails | null> {
|
||||
const item: Omit<GitHubWorkItem, 'repoId'> | null = await getWorkItem(
|
||||
repoPath,
|
||||
number,
|
||||
type,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
let issueRepositoryOverride: GitHubApiRepository | null = null
|
||||
if (type === 'issue' && repositoryOverride != null) {
|
||||
if (!isValidGitHubApiRepository(repositoryOverride)) {
|
||||
return null
|
||||
}
|
||||
issueRepositoryOverride = (
|
||||
await resolveGitHubRepoExecution(repoPath, repositoryOverride, connectionId, localGitOptions)
|
||||
).ownerRepo
|
||||
if (!issueRepositoryOverride) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const { item, repository: selectedRepository } = issueRepositoryOverride
|
||||
? {
|
||||
item: await getWorkItemByOwnerRepo(
|
||||
repoPath,
|
||||
issueRepositoryOverride,
|
||||
number,
|
||||
'issue',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
),
|
||||
repository: issueRepositoryOverride
|
||||
}
|
||||
: await getWorkItemWithRepository(
|
||||
repoPath,
|
||||
number,
|
||||
type,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Keep the conversation in the same repository as the selected issue.
|
||||
const resolvedRepository =
|
||||
item.type === 'issue'
|
||||
? await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)
|
||||
? selectedRepository
|
||||
: (await resolveGitHubRepoExecution(repoPath, item.prRepo, connectionId, localGitOptions))
|
||||
.ownerRepo
|
||||
|
||||
|
||||
@@ -136,38 +136,60 @@ describe('filesystem-list-files', () => {
|
||||
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version')
|
||||
})
|
||||
|
||||
it('merges normal files and ignored files and filters correctly', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
it('keeps source files first when only a serialized byte budget is provided', async () => {
|
||||
const source = createMockProcess()
|
||||
const broad = createMockProcess()
|
||||
spawnMock.mockImplementation((_command, args: string[]) =>
|
||||
isIgnoredRgPass(args) ? broad : source
|
||||
)
|
||||
const listing = listQuickOpenFiles(
|
||||
'/mock/root',
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization and workspace lookup are mocked above.
|
||||
{} as Store,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
20
|
||||
)
|
||||
await flushMicrotasks()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--no-ignore-vcs')
|
||||
source.stdout?.emit('data', 'source.ts\n')
|
||||
source.emit('close', 0, null)
|
||||
await flushMicrotasks()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock.mock.calls[1]?.[1]).toContain('--no-ignore-vcs')
|
||||
broad.stdout?.emit('data', 'ignored-file.ts\n')
|
||||
await expect(listing).resolves.toEqual(['source.ts'])
|
||||
expect(broad.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
it('lists normal and ignored files with one broad scan and filters correctly', async () => {
|
||||
const p1 = createMockProcess()
|
||||
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
await flushMicrotasks()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(spawnMock.mock.calls[0]?.[1]).toContain('--no-ignore-vcs')
|
||||
|
||||
// Simulate stdout output for normal files
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'node_modules/bad.js\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.git/config\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.github/workflows/ci.yml\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'dir1/') // incomplete line
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'file2.js\n')
|
||||
p1.emit('close', 0, null)
|
||||
p1.stdout?.emit('data', 'file1.ts\n')
|
||||
p1.stdout?.emit('data', 'node_modules/bad.js\n')
|
||||
p1.stdout?.emit('data', '.git/config\n')
|
||||
p1.stdout?.emit('data', '.github/workflows/ci.yml\n')
|
||||
p1.stdout?.emit('data', 'dir1/') // incomplete line
|
||||
p1.stdout?.emit('data', 'file2.js\n')
|
||||
|
||||
// Simulate stdout output for ignored files
|
||||
;(p2.stdout as unknown as EventEmitter).emit('data', '.env.local\n')
|
||||
;(p2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n')
|
||||
;(p2.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') // Duplicate
|
||||
;(p2.stdout as unknown as EventEmitter).emit('data', 'node_modules/ignored.js\n')
|
||||
p2.emit('close', 0, null)
|
||||
// The broad pass includes ignored files too.
|
||||
p1.stdout?.emit('data', '.env.local\n')
|
||||
p1.stdout?.emit('data', 'dist/generated.js\n')
|
||||
p1.stdout?.emit('data', 'file1.ts\n') // Duplicate
|
||||
p1.stdout?.emit('data', 'node_modules/ignored.js\n')
|
||||
p1.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
const result = await promise
|
||||
@@ -183,15 +205,9 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
it('spawns the bundled Linux rg inside the registered WSL runtime for Windows-path worktrees', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('C:\\repo', storeMock)
|
||||
@@ -199,7 +215,6 @@ describe('filesystem-list-files', () => {
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
p1.emit('close', 0, null)
|
||||
p2.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
await expect(promise).resolves.toEqual(['src/index.ts'])
|
||||
@@ -214,15 +229,9 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
it('normalizes absolute WSL rg output for Windows-path worktrees', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('C:\\repo', storeMock)
|
||||
@@ -230,7 +239,6 @@ describe('filesystem-list-files', () => {
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n')
|
||||
p1.emit('close', 0, null)
|
||||
p2.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
await expect(promise).resolves.toEqual(['src/index.ts'])
|
||||
@@ -238,16 +246,13 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
it('treats a WSL launcher exit 127 as the bundled rg failing to start', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
Object.defineProperty(p1, 'pid', { value: 1 })
|
||||
Object.defineProperty(p2, 'pid', { value: 2 })
|
||||
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const promise = listQuickOpenFiles('C:\\repo', {} as unknown as Store)
|
||||
setTimeout(() => {
|
||||
p1.emit('close', 127, null)
|
||||
p2.emit('close', 0, null)
|
||||
}, 0)
|
||||
|
||||
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
|
||||
@@ -256,48 +261,33 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
it("rejects with the bundled-ripgrep error when a native launcher exits outside ripgrep's contract", async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store)
|
||||
setTimeout(() => p1.emit('close', 127, null), 0)
|
||||
|
||||
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
|
||||
expect(p2.kill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects rg failures instead of resolving a false-empty list', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
p1.emit('close', 2, null)
|
||||
p2.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
await expect(promise).rejects.toThrow('rg exited with code 2')
|
||||
})
|
||||
|
||||
it('kills the sibling rg pass after one pass fails', async () => {
|
||||
it('does not start another scan after the admitted pass fails', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
@@ -308,19 +298,13 @@ describe('filesystem-list-files', () => {
|
||||
}, 10)
|
||||
|
||||
await expect(promise).rejects.toThrow('rg exited with code 2')
|
||||
expect(p2.kill).toHaveBeenCalled()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts rg code 2 when rg emitted parseable paths first', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
@@ -328,7 +312,6 @@ describe('filesystem-list-files', () => {
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
p1.emit('close', 2, null)
|
||||
p2.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
await expect(promise).resolves.toEqual(['src/index.ts'])
|
||||
@@ -339,14 +322,8 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
try {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
@@ -362,7 +339,6 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
await rejection
|
||||
expect(p1.kill).toHaveBeenCalled()
|
||||
expect(p2.kill).toHaveBeenCalled()
|
||||
expect((p1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0)
|
||||
expect((p1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0)
|
||||
expect(p1.listenerCount('error')).toBe(0)
|
||||
@@ -374,8 +350,7 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
it('kills local rg scans when a paired listing is cancelled', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
|
||||
spawnMock.mockReturnValue(p1)
|
||||
const controller = new AbortController()
|
||||
const cancellation = new FileListingCancelledError('superseded')
|
||||
const promise = listQuickOpenFiles(
|
||||
@@ -390,19 +365,12 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
await expect(promise).rejects.toBe(cancellation)
|
||||
expect(p1.kill).toHaveBeenCalledOnce()
|
||||
expect(p2.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('filters out .next, .cache, .stably, .vscode, .idea', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
@@ -415,9 +383,6 @@ describe('filesystem-list-files', () => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.idea/workspace.xml\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n')
|
||||
p1.emit('close', 0, null)
|
||||
|
||||
// Empty ignored result
|
||||
p2.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
const result = await promise
|
||||
@@ -451,7 +416,7 @@ describe('filesystem-list-files', () => {
|
||||
})
|
||||
|
||||
describe('when the bundled rg cannot start', () => {
|
||||
it('kills only the admitted pass when ignored rg fails before spawn', async () => {
|
||||
it('does not kill a process that failed before receiving a pid', async () => {
|
||||
const primary = createMockProcess()
|
||||
const missingIgnored = createMockProcess()
|
||||
Object.defineProperty(missingIgnored, 'pid', { value: undefined })
|
||||
@@ -461,11 +426,11 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store)
|
||||
await flushMicrotasks()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
missingIgnored.emit('close', -2, null)
|
||||
|
||||
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
|
||||
expect(primary.kill).toHaveBeenCalled()
|
||||
expect(primary.kill).not.toHaveBeenCalled()
|
||||
expect(missingIgnored.kill).not.toHaveBeenCalled()
|
||||
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
|
||||
expect(() => missingIgnored.emit('error', error)).not.toThrow()
|
||||
|
||||
@@ -277,9 +277,7 @@ export async function listQuickOpenFiles(
|
||||
}
|
||||
|
||||
const killSurvivors = (): void => {
|
||||
// Why: if one rg pass fails, Promise.all rejects immediately while the
|
||||
// sibling scan can keep walking a huge tree until timeout. Stop it so
|
||||
// repeated Quick Open attempts do not accumulate local rg processes.
|
||||
// Failed listings must release any process still walking the tree.
|
||||
for (const entry of children) {
|
||||
if (entry.isDone()) {
|
||||
continue
|
||||
@@ -303,16 +301,13 @@ export async function listQuickOpenFiles(
|
||||
}
|
||||
}
|
||||
try {
|
||||
const primaryRun = runRg(primary)
|
||||
if (maxResults === undefined && maxSerializedBytes === undefined) {
|
||||
// Why: a pid-less primary proves launch failure; avoid doubling the failed spawn.
|
||||
await (children[0]?.child.pid === undefined
|
||||
? primaryRun
|
||||
: Promise.all([primaryRun, runRg(ignoredPass)]))
|
||||
// The broader pass already includes source files; an unbounded listing needs only one scan.
|
||||
await runRg(ignoredPass)
|
||||
} else {
|
||||
// Why: ignored-file output can be much larger and faster than the primary pass; let source
|
||||
// files claim every bounded autocomplete budget first, including the transport byte cap.
|
||||
await primaryRun
|
||||
await runRg(primary)
|
||||
if (
|
||||
(maxResults === undefined || files.size < maxResults) &&
|
||||
(maxSerializedBytes === undefined || serializedBytes < maxSerializedBytes)
|
||||
|
||||
@@ -15,7 +15,14 @@ import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-eve
|
||||
export function registerGitHubIssueMutationHandlers(store: Store): void {
|
||||
ipcMain.handle(
|
||||
'gh:updateIssue',
|
||||
async (event, args: GitHubRepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => {
|
||||
async (
|
||||
event,
|
||||
args: GitHubRepoScopedArgs & {
|
||||
number: number
|
||||
updates: GitHubIssueUpdate
|
||||
ownerRepo?: GitHubOwnerRepo
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) {
|
||||
return { ok: false, error: 'Invalid issue number' }
|
||||
@@ -28,7 +35,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
|
||||
args.number,
|
||||
args.updates,
|
||||
getGitHubRepoConnectionId(repo),
|
||||
...getGitHubLocalGitOptionArgs(store, repo)
|
||||
getGitHubLocalGitOptionArgs(store, repo)[0],
|
||||
repo.issueSourcePreference,
|
||||
...(args.ownerRepo ? [args.ownerRepo] : [])
|
||||
)
|
||||
if (result.ok) {
|
||||
broadcastGitHubWorkItemMutation(
|
||||
@@ -67,7 +76,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
|
||||
args.body.trim(),
|
||||
getGitHubRepoConnectionId(repo),
|
||||
args.prRepo ?? null,
|
||||
...getGitHubLocalGitOptionArgs(store, repo)
|
||||
getGitHubLocalGitOptionArgs(store, repo)[0],
|
||||
// Why: the issue source selector only scopes issues; PR comments keep their resolution.
|
||||
args.type === 'pr' ? undefined : repo.issueSourcePreference
|
||||
)
|
||||
if (result.ok) {
|
||||
broadcastGitHubWorkItemMutation(
|
||||
@@ -84,23 +95,46 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:listLabels', (_event, args: GitHubRepoScopedArgs) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
return listLabels(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
getGitHubRepoConnectionId(repo),
|
||||
...getGitHubLocalGitOptionArgs(store, repo)
|
||||
)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'gh:listLabels',
|
||||
(_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)
|
||||
const connectionId = getGitHubRepoConnectionId(repo)
|
||||
if (args.ownerRepo) {
|
||||
return listLabels(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
connectionId,
|
||||
localGitOptions[0],
|
||||
args.ownerRepo
|
||||
)
|
||||
}
|
||||
return listLabels(repo.path, repo.issueSourcePreference, connectionId, ...localGitOptions)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:listAssignableUsers', (_event, args: GitHubRepoScopedArgs) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
return listAssignableUsers(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
getGitHubRepoConnectionId(repo),
|
||||
...getGitHubLocalGitOptionArgs(store, repo)
|
||||
)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'gh:listAssignableUsers',
|
||||
(_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)
|
||||
const connectionId = getGitHubRepoConnectionId(repo)
|
||||
if (args.ownerRepo) {
|
||||
return listAssignableUsers(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
connectionId,
|
||||
localGitOptions[0],
|
||||
args.ownerRepo
|
||||
)
|
||||
}
|
||||
return listAssignableUsers(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
connectionId,
|
||||
...localGitOptions
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,4 +143,124 @@ describe('registerGitHubHandlers', () => {
|
||||
'origin'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the displayed repository ahead of the current source preference', async () => {
|
||||
harness.repos[0].issueSourcePreference = 'upstream'
|
||||
const ownerRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
|
||||
mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' })
|
||||
getWorkItemDetailsMock.mockResolvedValue(null)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered.
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
await handlers['gh:workItemDetails'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
number: 12,
|
||||
type: 'issue',
|
||||
ownerRepo
|
||||
})
|
||||
await handlers['gh:updateIssue'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
number: 12,
|
||||
updates: { body: 'Origin edit' },
|
||||
ownerRepo
|
||||
})
|
||||
await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo', ownerRepo })
|
||||
await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo', ownerRepo })
|
||||
|
||||
expect(getWorkItemDetailsMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
12,
|
||||
'issue',
|
||||
null,
|
||||
undefined,
|
||||
'upstream',
|
||||
ownerRepo
|
||||
)
|
||||
expect(mocks.client.updateIssue).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
12,
|
||||
{ body: 'Origin edit' },
|
||||
null,
|
||||
undefined,
|
||||
'upstream',
|
||||
ownerRepo
|
||||
)
|
||||
expect(mocks.client.listLabels).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'upstream',
|
||||
null,
|
||||
undefined,
|
||||
ownerRepo
|
||||
)
|
||||
expect(mocks.client.listAssignableUsers).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'upstream',
|
||||
null,
|
||||
undefined,
|
||||
ownerRepo
|
||||
)
|
||||
})
|
||||
|
||||
it('pins the repo origin source preference on issue update and comment IPC', async () => {
|
||||
harness.repos = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
issueSourcePreference: 'origin'
|
||||
}
|
||||
]
|
||||
mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' })
|
||||
mocks.client.addIssueComment.mockResolvedValue({ ok: false, error: 'stub' })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered.
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
await handlers['gh:updateIssue'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
number: 5,
|
||||
updates: { body: 'Updated' }
|
||||
})
|
||||
await handlers['gh:addIssueComment'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
number: 5,
|
||||
body: 'Issue comment'
|
||||
})
|
||||
await handlers['gh:addIssueComment'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
number: 6,
|
||||
body: 'PR comment',
|
||||
type: 'pr'
|
||||
})
|
||||
|
||||
expect(mocks.client.updateIssue).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
5,
|
||||
{ body: 'Updated' },
|
||||
null,
|
||||
undefined,
|
||||
'origin'
|
||||
)
|
||||
expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/workspace/repo',
|
||||
5,
|
||||
'Issue comment',
|
||||
null,
|
||||
null,
|
||||
undefined,
|
||||
'origin'
|
||||
)
|
||||
expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/workspace/repo',
|
||||
6,
|
||||
'PR comment',
|
||||
null,
|
||||
null,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -146,15 +146,20 @@ export function registerGitHubWorkItemHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
return dispatchWorkItem(
|
||||
args,
|
||||
repo,
|
||||
getWorkItemDetails,
|
||||
getGitHubLocalGitOptionArgs(store, repo)[0]
|
||||
)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'gh:workItemDetails',
|
||||
(_event, args: WorkItemArgs & { ownerRepo?: GitHubOwnerRepo }) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
return dispatchWorkItem(
|
||||
args,
|
||||
repo,
|
||||
args.ownerRepo
|
||||
? (...params) => getWorkItemDetails(...params, args.ownerRepo)
|
||||
: getWorkItemDetails,
|
||||
getGitHubLocalGitOptionArgs(store, repo)[0]
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:notifyWorkItemMutated',
|
||||
|
||||
@@ -138,6 +138,15 @@ describe('registerGitHubHandlers', () => {
|
||||
)
|
||||
await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo' })
|
||||
await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo' })
|
||||
const metadataRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
|
||||
await handlers['gh:listLabels'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
ownerRepo: metadataRepo
|
||||
})
|
||||
await handlers['gh:listAssignableUsers'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
ownerRepo: metadataRepo
|
||||
})
|
||||
|
||||
expect(getPRForBranchMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
@@ -197,7 +206,8 @@ describe('registerGitHubHandlers', () => {
|
||||
7,
|
||||
{ body: 'Updated' },
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
undefined
|
||||
)
|
||||
expect(addIssueCommentMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
@@ -205,9 +215,24 @@ describe('registerGitHubHandlers', () => {
|
||||
'Comment',
|
||||
null,
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
undefined
|
||||
)
|
||||
expect(listLabelsMock).toHaveBeenCalledWith('/workspace/repo', undefined, null, localGitOptions)
|
||||
expect(listLabelsMock).toHaveBeenLastCalledWith(
|
||||
'/workspace/repo',
|
||||
undefined,
|
||||
null,
|
||||
localGitOptions,
|
||||
metadataRepo
|
||||
)
|
||||
expect(listAssignableUsersMock).toHaveBeenLastCalledWith(
|
||||
'/workspace/repo',
|
||||
undefined,
|
||||
null,
|
||||
localGitOptions,
|
||||
metadataRepo
|
||||
)
|
||||
expect(listAssignableUsersMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
undefined,
|
||||
|
||||
@@ -111,9 +111,9 @@ export async function listMarkdownDocuments(
|
||||
'--null',
|
||||
'--path-separator',
|
||||
'/',
|
||||
// Directory-only globs preserve hidden Markdown files without traversing hidden folders.
|
||||
// Keep case variants in --glob: --iglob is applied after exclusions and can reopen hidden folders.
|
||||
'--glob',
|
||||
'**',
|
||||
'*.{[mM][dD],[mM][dD][xX],[mM][aA][rR][kK][dD][oO][wW][nN]}',
|
||||
'--glob',
|
||||
'!**/.*/',
|
||||
'--glob',
|
||||
|
||||
@@ -27,6 +27,7 @@ import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-te
|
||||
import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn'
|
||||
import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch'
|
||||
|
||||
/** Carries deletions to provider-owned environments, including persistent older daemons. */
|
||||
export async function buildPtyIpcSpawnOptions(
|
||||
@@ -64,6 +65,18 @@ export async function buildPtyIpcSpawnOptions(
|
||||
ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete)
|
||||
}
|
||||
deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete)
|
||||
ctx.spawnEnv = await prepareOpenCodePtyLaunch({
|
||||
command: ctx.launchCommand,
|
||||
agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
env: ctx.spawnEnv,
|
||||
envToDelete: (ctx.combinedEnvToDelete ??= []),
|
||||
cwd: ctx.cwd,
|
||||
connectionId: args.connectionId,
|
||||
isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined,
|
||||
...(ctx.codexSelectionTarget.runtime === 'wsl'
|
||||
? { wsl: { distro: ctx.expectedWslDistro ?? undefined } }
|
||||
: {})
|
||||
})
|
||||
promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath)
|
||||
ctx.spawnOptions = {
|
||||
cols: args.cols,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import type { RuntimePtySpawnState } from './spawn-state'
|
||||
import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn'
|
||||
import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native-account-launch'
|
||||
import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch'
|
||||
|
||||
/** Headless spawns need the same host-side environment isolation as desktop spawns. */
|
||||
export async function buildRuntimePtySpawnOptions(
|
||||
@@ -85,20 +86,12 @@ export async function buildRuntimePtySpawnOptions(
|
||||
? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData'))
|
||||
: [],
|
||||
// Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn.
|
||||
getInheritedAgentSessionStampEnvKeysToDelete(ctx.env)
|
||||
)
|
||||
if (ctx.skipCodexHomeEnv) {
|
||||
ctx.spawnOptions.envToDelete = mergePtyEnvDeletions(
|
||||
ctx.spawnOptions.envToDelete,
|
||||
CODEX_HOME_ENV_KEYS
|
||||
)
|
||||
} else if (ctx.stripInheritedOrcaCodexHome) {
|
||||
getInheritedAgentSessionStampEnvKeysToDelete(ctx.env),
|
||||
ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [],
|
||||
// Why: the daemon owns a persistent inherited environment that may
|
||||
// differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair.
|
||||
ctx.spawnOptions.envToDelete = mergePtyEnvDeletions(ctx.spawnOptions.envToDelete, [
|
||||
'ORCA_CODEX_HOME'
|
||||
])
|
||||
}
|
||||
ctx.stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : []
|
||||
)
|
||||
if (ctx.codexResumeHomeSelected) {
|
||||
ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete)
|
||||
}
|
||||
@@ -111,6 +104,19 @@ export async function buildRuntimePtySpawnOptions(
|
||||
env: ctx.env,
|
||||
envToDelete: ctx.spawnOptions.envToDelete
|
||||
})
|
||||
ctx.env = await prepareOpenCodePtyLaunch({
|
||||
command: ctx.launchCommand,
|
||||
agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
env: ctx.env,
|
||||
envToDelete: (ctx.spawnOptions.envToDelete ??= []),
|
||||
cwd: ctx.cwd,
|
||||
connectionId: args.connectionId,
|
||||
isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined,
|
||||
...(ctx.codexSelectionTarget.runtime === 'wsl'
|
||||
? { wsl: { distro: ctx.expectedWslDistro ?? undefined } }
|
||||
: {})
|
||||
})
|
||||
ctx.spawnOptions.env = ctx.env
|
||||
promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath)
|
||||
const noDaemonLaunch = planCodexNoDaemonLaunch({
|
||||
command: ctx.launchCommand,
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { AgentSessionConversationCommand } from '../../../shared/agent-sess
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
|
||||
import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals'
|
||||
import { structuredAgentSessionCommandResultRowIdentity } from '../../../shared/structured-agent-session-command-entry'
|
||||
import {
|
||||
agentJournalTurnBody,
|
||||
readAgentJournalTurn
|
||||
@@ -102,7 +103,7 @@ export function structuredAgentSessionCommandTurn(clientMessageId: string): {
|
||||
identity,
|
||||
itemId: agentJournalItemKey(identity),
|
||||
turnId: `compact:${clientMessageId}`,
|
||||
resultIdentity: { provider: 'orca', clientMessageId: `command-result:${clientMessageId}` }
|
||||
resultIdentity: structuredAgentSessionCommandResultRowIdentity(clientMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ type PluginModule = {
|
||||
const ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
'ORCA_OPENCODE_AGENT',
|
||||
'ORCA_OPENCODE_PLUGIN_API',
|
||||
'ORCA_AGENT_HOOK_ENDPOINT',
|
||||
'ORCA_AGENT_HOOK_PORT',
|
||||
'ORCA_AGENT_HOOK_TOKEN'
|
||||
@@ -81,6 +82,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin disposal by host',
|
||||
}
|
||||
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
|
||||
process.env.ORCA_OPENCODE_AGENT = agent
|
||||
process.env.ORCA_OPENCODE_PLUGIN_API = 'v2'
|
||||
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
process.env.ORCA_AGENT_HOOK_PORT = '59999'
|
||||
process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token'
|
||||
|
||||
@@ -6,7 +6,8 @@ import { pathToFileURL } from 'node:url'
|
||||
import { getOpenCodePluginSource } from './status-plugin-module-source'
|
||||
import { writeOpenCodeTuiPlugin } from '../../shared/opencode-tui-plugin-install'
|
||||
import { fakeLegacyTui, type LegacyTuiEvent } from './opencode-legacy-tui-fixture'
|
||||
import { readV1Plugin } from './opencode-legacy-plugin-loader-fixture'
|
||||
import { getLegacyPlugins, readV1Plugin } from './opencode-legacy-plugin-loader-fixture'
|
||||
import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version'
|
||||
|
||||
type Post = {
|
||||
paneKey?: string
|
||||
@@ -23,7 +24,8 @@ const ENV_KEYS = [
|
||||
'ORCA_AGENT_HOOK_ENDPOINT',
|
||||
'ORCA_AGENT_HOOK_PORT',
|
||||
'ORCA_AGENT_HOOK_TOKEN',
|
||||
'ORCA_AGENT_HOOK_OPENCODE_TUI'
|
||||
'ORCA_AGENT_HOOK_OPENCODE_TUI',
|
||||
'ORCA_OPENCODE_PLUGIN_API'
|
||||
] as const
|
||||
const created = (id: string, parentID?: string): LegacyTuiEvent => ({
|
||||
type: 'session.created',
|
||||
@@ -48,6 +50,7 @@ describe('OpenCode 1 TUI API pane reporting', () => {
|
||||
savedArgv = process.argv
|
||||
savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]))
|
||||
process.argv = ['opencode', 'attach', 'http://127.0.0.1:4096']
|
||||
process.env.ORCA_OPENCODE_PLUGIN_API = 'v2'
|
||||
process.env.ORCA_OPENCODE_AGENT = 'opencode'
|
||||
process.env.ORCA_AGENT_HOOK_PORT = '59999'
|
||||
process.env.ORCA_AGENT_HOOK_TOKEN = 'fixture'
|
||||
@@ -129,6 +132,50 @@ describe('OpenCode 1 TUI API pane reporting', () => {
|
||||
expect(tui.listenerCount()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('loads the selected v1 factory through the release fallback once and retains the separate TUI', async () => {
|
||||
process.env.ORCA_OPENCODE_PLUGIN_API = getOpenCodeCliCapabilities('1.18.30').pluginApi
|
||||
process.argv = ['opencode', 'serve']
|
||||
process.env.ORCA_PANE_KEY = PANE_A
|
||||
const path = join(dir, 'selected-v1-server.mjs')
|
||||
writeFileSync(path, getOpenCodePluginSource())
|
||||
const plugin: unknown = await import(pathToFileURL(path).href)
|
||||
if (typeof plugin !== 'object' || !plugin || !('default' in plugin)) {
|
||||
throw new Error('No server plugin')
|
||||
}
|
||||
expect(readV1Plugin(plugin, path, 'server', 'detect')).toBeUndefined()
|
||||
const factories = getLegacyPlugins(plugin)
|
||||
expect(factories).toHaveLength(1)
|
||||
expect(factories[0]).toBe(plugin.default)
|
||||
const hooks: unknown = await factories[0]({
|
||||
client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } }
|
||||
})
|
||||
if (
|
||||
!hooks ||
|
||||
typeof hooks !== 'object' ||
|
||||
!('event' in hooks) ||
|
||||
typeof hooks.event !== 'function' ||
|
||||
!('dispose' in hooks) ||
|
||||
typeof hooks.dispose !== 'function'
|
||||
) {
|
||||
throw new Error('No legacy event hooks')
|
||||
}
|
||||
const dispose = hooks.dispose
|
||||
cleanups.push(async () => {
|
||||
await dispose()
|
||||
})
|
||||
await hooks.event({
|
||||
event: {
|
||||
type: 'session.status',
|
||||
properties: { sessionID: 'ses_root', status: { type: 'busy' } }
|
||||
}
|
||||
})
|
||||
await settle('SessionBusy:ses_root')
|
||||
expect(posts.at(-1)).toMatchObject({ opencodeSharedServer: 1 })
|
||||
process.argv = ['opencode', 'attach', 'http://127.0.0.1:4096']
|
||||
const tui = await start()
|
||||
expect(tui.listenerCount()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reproduces the release loader rejection of a combined server and TUI entry', () => {
|
||||
expect(() =>
|
||||
readV1Plugin({ default: { server() {}, tui() {} } }, 'orca-opencode-status-tui/tui.js', 'tui')
|
||||
|
||||
@@ -35,6 +35,7 @@ describe('OpenCode status plugin module contract', () => {
|
||||
// env. Pin every input here so the run does not depend on the developer's Orca session
|
||||
// (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app).
|
||||
const ENV_KEYS = [
|
||||
'ORCA_OPENCODE_PLUGIN_API',
|
||||
'ORCA_PANE_KEY',
|
||||
'ORCA_AGENT_HOOK_ENDPOINT',
|
||||
'ORCA_AGENT_HOOK_PORT',
|
||||
@@ -55,6 +56,7 @@ describe('OpenCode status plugin module contract', () => {
|
||||
// Why: the generated plugin self-disables when this names a different major,
|
||||
// so an inherited value from the developer's own Orca pane would leave
|
||||
// `hooks.event` undefined and fail the contract for the wrong reason.
|
||||
delete process.env.ORCA_OPENCODE_PLUGIN_API
|
||||
delete process.env.ORCA_OPENCODE_AGENT
|
||||
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
process.env.ORCA_AGENT_HOOK_PORT = '59999'
|
||||
@@ -86,6 +88,13 @@ describe('OpenCode status plugin module contract', () => {
|
||||
return (await import(pathToFileURL(pluginPath).href)) as PluginModule
|
||||
}
|
||||
|
||||
it('selects the same factory for a v1 execution host', async () => {
|
||||
process.env.ORCA_OPENCODE_PLUGIN_API = 'v1'
|
||||
const module = await loadPluginModule()
|
||||
expect(module.default).toBeTypeOf('function')
|
||||
expect(module.default).toBe(module.OrcaOpenCodeStatusPlugin)
|
||||
})
|
||||
|
||||
it('exposes a default export carrying a string id and a callable server()', async () => {
|
||||
const module = await loadPluginModule()
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
|
||||
const ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
'ORCA_OPENCODE_AGENT',
|
||||
'ORCA_OPENCODE_PLUGIN_API',
|
||||
'ORCA_AGENT_HOOK_ENDPOINT',
|
||||
'ORCA_AGENT_HOOK_PORT',
|
||||
'ORCA_AGENT_HOOK_TOKEN'
|
||||
@@ -66,6 +67,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
|
||||
savedEnv[key] = process.env[key]
|
||||
}
|
||||
process.env.ORCA_OPENCODE_AGENT = agent
|
||||
process.env.ORCA_OPENCODE_PLUGIN_API = 'v2'
|
||||
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
process.env.ORCA_AGENT_HOOK_PORT = '59999'
|
||||
process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token'
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v2')
|
||||
setAppEnvironment({
|
||||
getPath: getPathMock,
|
||||
getAppPath: () => process.cwd(),
|
||||
@@ -43,6 +44,8 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
const { isUsableId, toSafeDirName } = _internals
|
||||
|
||||
describe('OpenCode id safety guard', () => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { probeOpenCodeCliVersion } from './opencode-cli-version'
|
||||
|
||||
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() }))
|
||||
beforeEach(() => vi.mocked(runProcess).mockReset())
|
||||
|
||||
it('shares concurrent probes and isolates executable, host and environment identities', async () => {
|
||||
vi.mocked(runProcess).mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: '1.1.23',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
const options = {
|
||||
executablePath: '/test/opencode',
|
||||
env: { PATH: '/runtime', XDG_CONFIG_HOME: '/one' },
|
||||
hostIdentity: 'native'
|
||||
}
|
||||
const results = await Promise.all([
|
||||
probeOpenCodeCliVersion(options),
|
||||
probeOpenCodeCliVersion(options)
|
||||
])
|
||||
expect(results[0].pluginApi).toBe('v1')
|
||||
expect(runProcess).toHaveBeenCalledTimes(1)
|
||||
await probeOpenCodeCliVersion({ ...options, executablePath: '/other/opencode' })
|
||||
await probeOpenCodeCliVersion({ ...options, hostIdentity: 'wsl:ubuntu' })
|
||||
await probeOpenCodeCliVersion({ ...options, env: { ...options.env, XDG_CONFIG_HOME: '/two' } })
|
||||
expect(runProcess).toHaveBeenCalledTimes(4)
|
||||
expect(runProcess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ args: ['--version'], timeoutMs: 5_000, maxOutputBytes: 4_096 })
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades timeouts and missing executables to unknown', async () => {
|
||||
vi.mocked(runProcess).mockResolvedValueOnce({
|
||||
code: null,
|
||||
signal: 'SIGTERM',
|
||||
stdout: '2.0.16',
|
||||
stderr: '',
|
||||
timedOut: true
|
||||
})
|
||||
expect(
|
||||
(await probeOpenCodeCliVersion({ executablePath: '/timeout/opencode', env: {} })).pluginApi
|
||||
).toBe('unknown')
|
||||
vi.mocked(runProcess).mockRejectedValueOnce(new Error('ENOENT'))
|
||||
expect(
|
||||
(await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('shares a bounded execution-host callback without probing the native machine', async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ code: 0, timedOut: false, stdout: 'opencode v2.0.16' })
|
||||
const options = { executablePath: 'opencode', env: {}, hostIdentity: 'wsl:private', execute }
|
||||
const results = await Promise.all([
|
||||
probeOpenCodeCliVersion(options),
|
||||
probeOpenCodeCliVersion(options)
|
||||
])
|
||||
expect(results[0].promptMode).toBe('prefill')
|
||||
expect(execute).toHaveBeenCalledTimes(1)
|
||||
expect(runProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shares probes across pane identities while preserving launch-affecting environment keys', async () => {
|
||||
vi.mocked(runProcess).mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: '2.0.16',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
const options = {
|
||||
executablePath: '/pane-cache/opencode',
|
||||
cwd: '/workspace',
|
||||
env: {
|
||||
PATH: '/runtime',
|
||||
OPENCODE_CONFIG_DIR: '/config',
|
||||
ORCA_PANE_KEY: 'pane-1',
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
ORCA_TERMINAL_HANDLE: 'term-1',
|
||||
ORCA_WORKTREE_ID: 'workspace-1',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'launch-1',
|
||||
ORCA_AGENT_PANE: 'pane-1',
|
||||
ORCA_AGENT_LAUNCH: 'launch-1'
|
||||
},
|
||||
hostIdentity: 'pane-cache-host'
|
||||
}
|
||||
await Promise.all([
|
||||
probeOpenCodeCliVersion(options),
|
||||
probeOpenCodeCliVersion({
|
||||
...options,
|
||||
env: {
|
||||
...options.env,
|
||||
ORCA_PANE_KEY: 'pane-2',
|
||||
ORCA_TAB_ID: 'tab-2',
|
||||
ORCA_TERMINAL_HANDLE: 'term-2',
|
||||
ORCA_WORKTREE_ID: 'workspace-2',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'launch-2',
|
||||
ORCA_AGENT_PANE: 'pane-2',
|
||||
ORCA_AGENT_LAUNCH: 'launch-2'
|
||||
}
|
||||
})
|
||||
])
|
||||
expect(runProcess).toHaveBeenCalledTimes(1)
|
||||
await probeOpenCodeCliVersion({ ...options, env: { ...options.env, PATH: '/other-runtime' } })
|
||||
await probeOpenCodeCliVersion({
|
||||
...options,
|
||||
env: { ...options.env, OPENCODE_CONFIG_DIR: '/other-config' }
|
||||
})
|
||||
await probeOpenCodeCliVersion({ ...options, cwd: '/other-workspace' })
|
||||
expect(runProcess).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('separates native and host-callback backends even with the same host identity', async () => {
|
||||
vi.mocked(runProcess).mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: '1.18.32',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
const options = { executablePath: '/backend/opencode', env: {}, hostIdentity: 'backend-host' }
|
||||
expect((await probeOpenCodeCliVersion(options)).pluginApi).toBe('v1')
|
||||
const execute = vi.fn(async () => ({ code: 0, timedOut: false, stdout: '2.0.16' }))
|
||||
expect((await probeOpenCodeCliVersion({ ...options, execute })).pluginApi).toBe('v2')
|
||||
expect(execute).toHaveBeenCalledOnce()
|
||||
expect(runProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
ORCA_SCRUB_SAFE_LAUNCH_ENV,
|
||||
ORCA_SCRUB_SAFE_PANE_ENV
|
||||
} from '../../shared/agent-hook-scrub-safe-env'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import {
|
||||
getOpenCodeCliCapabilities,
|
||||
type OpenCodeCliCapabilities
|
||||
} from '../../shared/opencode-cli-version'
|
||||
|
||||
export type OpenCodeCliVersionProbe = {
|
||||
executablePath: string
|
||||
env: NodeJS.ProcessEnv
|
||||
cwd?: string
|
||||
hostIdentity?: string
|
||||
execute?: () => Promise<{ code: number | null; timedOut: boolean; stdout: string }>
|
||||
}
|
||||
|
||||
const probes = new Map<string, { expiresAt: number; result: Promise<OpenCodeCliCapabilities> }>()
|
||||
const CACHE_TTL_MS = 60_000
|
||||
const MAX_CACHED_PROBES = 128
|
||||
const PANE_IDENTITY_ENV_KEYS = new Set([
|
||||
'ORCA_PANE_KEY',
|
||||
'ORCA_TAB_ID',
|
||||
'ORCA_WORKTREE_ID',
|
||||
'ORCA_TERMINAL_HANDLE',
|
||||
'ORCA_AGENT_LAUNCH_TOKEN',
|
||||
ORCA_SCRUB_SAFE_PANE_ENV,
|
||||
ORCA_SCRUB_SAFE_LAUNCH_ENV
|
||||
])
|
||||
|
||||
export function probeOpenCodeCliVersion(
|
||||
options: OpenCodeCliVersionProbe
|
||||
): Promise<OpenCodeCliCapabilities> {
|
||||
const identity = JSON.stringify([
|
||||
options.execute ? 'host-callback' : 'native-process',
|
||||
options.hostIdentity ?? process.platform,
|
||||
options.executablePath,
|
||||
options.cwd,
|
||||
Object.entries(options.env)
|
||||
.filter(([key]) => !PANE_IDENTITY_ENV_KEYS.has(key))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
])
|
||||
const key = createHash('sha256').update(identity).digest('hex')
|
||||
const cached = probes.get(key)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.result
|
||||
}
|
||||
const result = runVersionProbe(options)
|
||||
probes.delete(key)
|
||||
probes.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, result })
|
||||
if (probes.size > MAX_CACHED_PROBES) {
|
||||
const oldest = probes.keys().next().value
|
||||
if (oldest !== undefined) {
|
||||
probes.delete(oldest)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise<OpenCodeCliCapabilities> {
|
||||
try {
|
||||
const pathKey = process.platform === 'win32' && options.env.Path !== undefined ? 'Path' : 'PATH'
|
||||
const executableDir = path.dirname(options.executablePath)
|
||||
const inheritedPath = options.env[pathKey]
|
||||
const result = options.execute
|
||||
? await options.execute()
|
||||
: await runProcess({
|
||||
program: options.executablePath,
|
||||
args: ['--version'],
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
...options.env,
|
||||
[pathKey]: inheritedPath
|
||||
? `${executableDir}${path.delimiter}${inheritedPath}`
|
||||
: executableDir
|
||||
},
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 4_096
|
||||
})
|
||||
return getOpenCodeCliCapabilities(result.code === 0 && !result.timedOut ? result.stdout : null)
|
||||
} catch {
|
||||
return getOpenCodeCliCapabilities(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getOpenCodeLaunchExecutable,
|
||||
probeOpenCodeLaunchCapabilities
|
||||
} from './opencode-launch-capabilities'
|
||||
import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ resolve: vi.fn(), probe: vi.fn(), wsl: vi.fn() }))
|
||||
vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: mocks.resolve }))
|
||||
vi.mock('./opencode-cli-version', () => ({ probeOpenCodeCliVersion: mocks.probe }))
|
||||
vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
|
||||
mocks.wsl.mockResolvedValue({
|
||||
code: 0,
|
||||
timedOut: false,
|
||||
stdout: '1.18.30',
|
||||
environmentResolved: true
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenCode execution-host launch capability probe', () => {
|
||||
it('refuses a successful version response from an unresolved WSL execution environment', async () => {
|
||||
mocks.wsl.mockResolvedValue({
|
||||
code: 0,
|
||||
timedOut: false,
|
||||
stdout: '1.18.30',
|
||||
environmentResolved: false
|
||||
})
|
||||
await probeOpenCodeLaunchCapabilities({
|
||||
command: 'opencode',
|
||||
env: {},
|
||||
wsl: { distro: 'Ubuntu' }
|
||||
})
|
||||
expect(await mocks.probe.mock.calls[0]?.[0].execute()).toMatchObject({ code: null })
|
||||
})
|
||||
it('recognizes quoted executables and preserves explicit run commands', () => {
|
||||
expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode')
|
||||
expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe(
|
||||
'custom-launcher'
|
||||
)
|
||||
expect(getOpenCodeLaunchExecutable('claude')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses native resolution with the execution environment and cwd', async () => {
|
||||
mocks.resolve.mockResolvedValue('/bin/opencode')
|
||||
const env = { PATH: '/bin', OPENCODE_CONFIG_DIR: '/private/config' }
|
||||
await probeOpenCodeLaunchCapabilities({
|
||||
command: 'opencode run task',
|
||||
env,
|
||||
cwd: '/repo',
|
||||
hostIdentity: 'native-test'
|
||||
})
|
||||
expect(mocks.resolve).toHaveBeenCalledWith('opencode', { env, cwd: '/repo' })
|
||||
expect(mocks.probe).toHaveBeenCalledWith({
|
||||
executablePath: '/bin/opencode',
|
||||
env,
|
||||
cwd: '/repo',
|
||||
hostIdentity: 'native-test'
|
||||
})
|
||||
expect(mocks.wsl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the relay resolver rather than client PATH resolution', async () => {
|
||||
const resolveExecutable = vi.fn().mockResolvedValue('/host/opencode')
|
||||
await probeOpenCodeLaunchCapabilities({
|
||||
command: 'opencode',
|
||||
env: {},
|
||||
hostIdentity: 'relay:linux',
|
||||
resolveExecutable
|
||||
})
|
||||
expect(resolveExecutable).toHaveBeenCalledWith('opencode')
|
||||
expect(mocks.resolve).not.toHaveBeenCalled()
|
||||
expect(mocks.probe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ executablePath: '/host/opencode', hostIdentity: 'relay:linux' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns unknown when the host cannot resolve the binary', async () => {
|
||||
mocks.resolve.mockResolvedValue(null)
|
||||
expect(await probeOpenCodeLaunchCapabilities({ command: 'opencode', env: {} })).toEqual(
|
||||
getOpenCodeCliCapabilities(null)
|
||||
)
|
||||
expect(mocks.probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds WSL probes and matches guest cwd plus explicitly imported config roots', async () => {
|
||||
const env = {
|
||||
HOME: '/native',
|
||||
PATH: '/native/bin',
|
||||
OPENCODE_CONFIG_DIR: '/guest/config',
|
||||
XDG_DATA_HOME: '/guest/data',
|
||||
WSLENV: 'OPENCODE_CONFIG_DIR:XDG_DATA_HOME'
|
||||
}
|
||||
await probeOpenCodeLaunchCapabilities({
|
||||
command: 'opencode --standalone',
|
||||
env,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo',
|
||||
wsl: { distro: 'Debian' },
|
||||
hostIdentity: 'host-a'
|
||||
})
|
||||
expect(mocks.resolve).not.toHaveBeenCalled()
|
||||
const options = mocks.probe.mock.calls[0]?.[0]
|
||||
const guestEnv = {
|
||||
OPENCODE_CONFIG_DIR: '/guest/config',
|
||||
XDG_DATA_HOME: '/guest/data',
|
||||
WSLENV: env.WSLENV
|
||||
}
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
executablePath: 'opencode',
|
||||
hostIdentity: 'host-a:wsl:Ubuntu',
|
||||
cwd: '/home/user/repo',
|
||||
env: guestEnv
|
||||
})
|
||||
)
|
||||
await options.execute()
|
||||
expect(mocks.wsl).toHaveBeenCalledWith({
|
||||
distro: 'Ubuntu',
|
||||
loginPath: 'preferred',
|
||||
cwd: '/home/user/repo',
|
||||
program: 'opencode',
|
||||
args: ['--version'],
|
||||
env: guestEnv,
|
||||
timeoutMs: 5000,
|
||||
maxOutputBytes: 4096
|
||||
})
|
||||
})
|
||||
|
||||
it('does not import native configuration the actual WSL pane would not receive', async () => {
|
||||
await probeOpenCodeLaunchCapabilities({
|
||||
command: 'opencode',
|
||||
env: { OPENCODE_CONFIG_DIR: 'C:\\native' },
|
||||
cwd: 'D:\\repo',
|
||||
wsl: { distro: 'Ubuntu' }
|
||||
})
|
||||
expect(mocks.probe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: '/mnt/d/repo', env: { WSLENV: '' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps WSL path translation flags and skips Windows-only values', async () => {
|
||||
await probeOpenCodeLaunchCapabilities({
|
||||
command: 'opencode',
|
||||
env: {
|
||||
WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME',
|
||||
XDG_DATA_HOME: 'D:\\data',
|
||||
WINDOWS_ONLY: 'private',
|
||||
HOME: 'C:\\native'
|
||||
},
|
||||
wsl: {}
|
||||
})
|
||||
expect(mocks.probe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: { WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', XDG_DATA_HOME: 'D:\\data' }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
getFirstCommandToken,
|
||||
getCommandTokenPathBasename
|
||||
} from '../../shared/command-token-scanner'
|
||||
import {
|
||||
getOpenCodeCliCapabilities,
|
||||
type OpenCodeCliCapabilities
|
||||
} from '../../shared/opencode-cli-version'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import { probeOpenCodeCliVersion } from './opencode-cli-version'
|
||||
|
||||
export function getOpenCodeLaunchExecutable(
|
||||
command: string | undefined,
|
||||
agent?: TuiAgent
|
||||
): string | null {
|
||||
const executable = getFirstCommandToken(command ?? '')
|
||||
const name = getCommandTokenPathBasename(executable)
|
||||
.toLowerCase()
|
||||
.replace(/\.(?:exe|cmd|sh)$/, '')
|
||||
return agent === 'opencode' ||
|
||||
agent === 'opencode2' ||
|
||||
(!agent && (name === 'opencode' || name === 'opencode2'))
|
||||
? executable || null
|
||||
: null
|
||||
}
|
||||
|
||||
export async function probeOpenCodeLaunchCapabilities(options: {
|
||||
command: string | undefined
|
||||
agent?: TuiAgent
|
||||
env: NodeJS.ProcessEnv
|
||||
cwd?: string
|
||||
wsl?: { distro?: string }
|
||||
hostIdentity?: string
|
||||
resolveExecutable?: (executable: string) => Promise<string | null>
|
||||
}): Promise<OpenCodeCliCapabilities | null> {
|
||||
const executable = getOpenCodeLaunchExecutable(options.command, options.agent)
|
||||
if (!executable) {
|
||||
return null
|
||||
}
|
||||
if (options.wsl) {
|
||||
// An empty carrier prevents the runner from restoring deleted ambient imports.
|
||||
const guestEnv: Record<string, string> = { WSLENV: '' }
|
||||
const wslEnv = options.env.WSLENV?.split(':')
|
||||
.filter((token) => options.env[token.split('/')[0]] !== undefined)
|
||||
.join(':')
|
||||
if (wslEnv) {
|
||||
guestEnv.WSLENV = wslEnv
|
||||
for (const token of wslEnv.split(':')) {
|
||||
const [key, flags = ''] = token.split('/')
|
||||
if (!key || flags.includes('w') || ['PATH', 'HOME', 'TMP', 'TEMP'].includes(key)) {
|
||||
continue
|
||||
}
|
||||
const value = options.env[key]
|
||||
if (value !== undefined) {
|
||||
guestEnv[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
const wslPath = options.cwd ? parseWslUncPath(options.cwd) : null
|
||||
const cwd = options.cwd ? (wslPath?.linuxPath ?? toLinuxPath(options.cwd)) : undefined
|
||||
if (cwd !== undefined && !cwd.startsWith('/')) {
|
||||
return getOpenCodeCliCapabilities(null)
|
||||
}
|
||||
const distro = wslPath?.distro ?? options.wsl.distro
|
||||
return probeOpenCodeCliVersion({
|
||||
executablePath: executable,
|
||||
env: guestEnv,
|
||||
cwd,
|
||||
hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`,
|
||||
execute: async () => {
|
||||
const result = await runWslProcess({
|
||||
distro,
|
||||
loginPath: 'preferred',
|
||||
cwd,
|
||||
program: executable,
|
||||
args: ['--version'],
|
||||
env: guestEnv,
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 4_096
|
||||
})
|
||||
return result.environmentResolved ? result : { ...result, code: null }
|
||||
}
|
||||
})
|
||||
}
|
||||
const executablePath = options.resolveExecutable
|
||||
? await options.resolveExecutable(executable)
|
||||
: await resolveCommandOnLocalPath(executable, { env: options.env, cwd: options.cwd })
|
||||
return executablePath
|
||||
? probeOpenCodeCliVersion({
|
||||
executablePath,
|
||||
env: options.env,
|
||||
cwd: options.cwd,
|
||||
hostIdentity: options.hostIdentity
|
||||
})
|
||||
: getOpenCodeCliCapabilities(null)
|
||||
}
|
||||
@@ -44,3 +44,39 @@ export function readV1Plugin(
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// OpenCode v1.18.30 (5cd8e68): packages/opencode/src/plugin/index.ts.
|
||||
type ServerPlugin = (...args: unknown[]) => unknown
|
||||
function isServerPlugin(value: unknown): value is ServerPlugin {
|
||||
return typeof value === 'function'
|
||||
}
|
||||
|
||||
function getServerPlugin(value: unknown) {
|
||||
if (isServerPlugin(value)) {
|
||||
return value
|
||||
}
|
||||
if (!value || typeof value !== 'object' || !('server' in value)) {
|
||||
return
|
||||
}
|
||||
if (!isServerPlugin(value.server)) {
|
||||
return
|
||||
}
|
||||
return value.server
|
||||
}
|
||||
|
||||
export function getLegacyPlugins(mod: Record<string, unknown>) {
|
||||
const seen = new Set<unknown>()
|
||||
const result: ServerPlugin[] = []
|
||||
for (const entry of Object.values(mod)) {
|
||||
if (seen.has(entry)) {
|
||||
continue
|
||||
}
|
||||
seen.add(entry)
|
||||
const plugin = getServerPlugin(entry)
|
||||
if (!plugin) {
|
||||
throw new TypeError('Plugin export is not a function')
|
||||
}
|
||||
result.push(plugin)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version'
|
||||
import { prepareOpenCodePtyLaunch } from './opencode-pty-launch'
|
||||
import {
|
||||
buildLocalPtySpawnEnvironment,
|
||||
enforceLocalPtySpawnEnvironmentOverrides
|
||||
} from '../providers/local-pty-spawn-environment'
|
||||
import type { LocalPtyLaunchPlan } from '../providers/local-pty-launch-plan'
|
||||
|
||||
const plan: LocalPtyLaunchPlan = {
|
||||
startupAgentRecognition: null,
|
||||
defaultCwd: '',
|
||||
cwd: '',
|
||||
wslInfo: null,
|
||||
worktreeWslContext: undefined,
|
||||
preferredWslContext: undefined,
|
||||
launchWslContext: undefined,
|
||||
shellPath: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh',
|
||||
shellArgs: [],
|
||||
effectiveCwd: '',
|
||||
validationCwd: '',
|
||||
startupCommandDeliveredInShellArgs: false,
|
||||
windowsFallbackAttempts: [],
|
||||
shellReadyLaunch: null,
|
||||
getFallbackShellReadyConfig: undefined,
|
||||
primaryPreLaunchEnv: {},
|
||||
isWslShell: false,
|
||||
launchWslDistro: null
|
||||
}
|
||||
|
||||
const probe = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe }))
|
||||
|
||||
beforeEach(() => probe.mockReset())
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('execution-host OpenCode launch preparation', () => {
|
||||
it('keeps deleted credentials and config absent from the probe and final provider environment', async () => {
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key')
|
||||
vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config')
|
||||
vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1')
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities(null))
|
||||
const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR']
|
||||
const env = await prepareOpenCodePtyLaunch({
|
||||
command: 'opencode',
|
||||
env: {},
|
||||
envToDelete,
|
||||
isFreshLaunch: true
|
||||
})
|
||||
const probeEnv = probe.mock.calls[0]?.[0].env
|
||||
expect(probeEnv).not.toHaveProperty('ANTHROPIC_API_KEY')
|
||||
expect(probeEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR')
|
||||
expect(probeEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API')
|
||||
const finalEnv = await buildLocalPtySpawnEnvironment({
|
||||
id: 'probe',
|
||||
spawn: { cols: 80, rows: 24, env, envToDelete },
|
||||
getOptions: () => ({}),
|
||||
plan
|
||||
})
|
||||
enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv)
|
||||
expect(finalEnv).not.toHaveProperty('ANTHROPIC_API_KEY')
|
||||
expect(finalEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR')
|
||||
expect(finalEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API')
|
||||
})
|
||||
|
||||
it('retains a verified selection through the final provider deletion pass', async () => {
|
||||
vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1')
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
|
||||
const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API']
|
||||
const env = await prepareOpenCodePtyLaunch({
|
||||
command: 'opencode',
|
||||
env: {},
|
||||
envToDelete,
|
||||
isFreshLaunch: true
|
||||
})
|
||||
const finalEnv = await buildLocalPtySpawnEnvironment({
|
||||
id: 'probe',
|
||||
spawn: { cols: 80, rows: 24, env, envToDelete },
|
||||
getOptions: () => ({}),
|
||||
plan
|
||||
})
|
||||
finalEnv.KEEP_DELETED = 'dummy'
|
||||
enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv)
|
||||
expect(finalEnv.ORCA_OPENCODE_PLUGIN_API).toBe('v2')
|
||||
expect(finalEnv).not.toHaveProperty('KEEP_DELETED')
|
||||
})
|
||||
|
||||
it.each(['1.1.23', '2.0.16'])(
|
||||
'selects the probed %s plugin for the execution host',
|
||||
async (version) => {
|
||||
const capabilities = getOpenCodeCliCapabilities(version)
|
||||
probe.mockResolvedValue(capabilities)
|
||||
const env = {
|
||||
KEEP: '1',
|
||||
ORCA_OPENCODE_PLUGIN_API: 'stale'
|
||||
}
|
||||
const result = await prepareOpenCodePtyLaunch({
|
||||
command: 'opencode --prompt test',
|
||||
agent: 'opencode',
|
||||
env,
|
||||
envToDelete: [],
|
||||
cwd: '/repo',
|
||||
isFreshLaunch: true
|
||||
})
|
||||
expect(result).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi })
|
||||
expect(env).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' })
|
||||
expect(probe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'opencode --prompt test',
|
||||
cwd: '/repo',
|
||||
env: expect.objectContaining({ KEEP: '1' })
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('creates a launch environment for a known binary without caller env', async () => {
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
|
||||
expect(
|
||||
await prepareOpenCodePtyLaunch({
|
||||
command: 'opencode',
|
||||
env: undefined,
|
||||
envToDelete: [],
|
||||
isFreshLaunch: true
|
||||
})
|
||||
).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' })
|
||||
})
|
||||
|
||||
it('forwards WSL plugin selection through WSLENV after a guest probe', async () => {
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23'))
|
||||
const env = { KEEP: '1' }
|
||||
const result = await prepareOpenCodePtyLaunch({
|
||||
command: 'opencode',
|
||||
agent: 'opencode',
|
||||
env,
|
||||
envToDelete: [],
|
||||
isFreshLaunch: true,
|
||||
wsl: { distro: 'Ubuntu' }
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
ORCA_OPENCODE_PLUGIN_API: 'v1',
|
||||
WSLENV: 'ORCA_OPENCODE_PLUGIN_API'
|
||||
})
|
||||
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ wsl: { distro: 'Ubuntu' } }))
|
||||
})
|
||||
|
||||
it.each([{ connectionId: 'remote', isFreshLaunch: true }, { isFreshLaunch: false }])(
|
||||
'never probes the client for an attach or SSH launch',
|
||||
async (route) => {
|
||||
const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' }
|
||||
expect(
|
||||
await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route })
|
||||
).toEqual({})
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { addWslEnvKeys } from '../../shared/wsl-env'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path'
|
||||
import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities'
|
||||
|
||||
export async function prepareOpenCodePtyLaunch(options: {
|
||||
command: string | undefined
|
||||
agent?: TuiAgent
|
||||
env: Record<string, string> | undefined
|
||||
envToDelete: string[]
|
||||
cwd?: string
|
||||
connectionId?: string | null
|
||||
isFreshLaunch: boolean
|
||||
wsl?: { distro?: string }
|
||||
}): Promise<Record<string, string> | undefined> {
|
||||
const env = options.env ? { ...options.env } : undefined
|
||||
if (env) {
|
||||
delete env.ORCA_OPENCODE_PLUGIN_API
|
||||
}
|
||||
// Providers merge their own ambient environment after this preparation.
|
||||
if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) {
|
||||
options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API')
|
||||
}
|
||||
if (options.connectionId || !options.isFreshLaunch) {
|
||||
return env
|
||||
}
|
||||
const probeEnv: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries({ ...process.env, ...env })) {
|
||||
if (value !== undefined) {
|
||||
probeEnv[key] = value
|
||||
}
|
||||
}
|
||||
deleteRequestedEnvKeys(probeEnv, options.envToDelete)
|
||||
const capabilities = await probeOpenCodeLaunchCapabilities({
|
||||
...options,
|
||||
env: probeEnv
|
||||
})
|
||||
if (!capabilities || capabilities.pluginApi === 'unknown') {
|
||||
return env
|
||||
}
|
||||
const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi }
|
||||
options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1)
|
||||
if (options.wsl) {
|
||||
addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API'])
|
||||
}
|
||||
return launchEnv
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { prepareOpenCodePtyLaunch } from './opencode-pty-launch'
|
||||
import { seedWslGuestEnvironmentForTests } from '../wsl/wsl-guest-environment'
|
||||
|
||||
const run = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: run }))
|
||||
vi.mock('../wsl/wsl-executable-path', () => ({ resolveWslExecutablePath: () => 'wsl.exe' }))
|
||||
vi.mock('../agent-hooks/server', () => ({ agentHookServer: { endpointFilePath: null } }))
|
||||
|
||||
beforeEach(() => {
|
||||
run.mockReset().mockResolvedValue({ code: 0, stdout: '2.0.16', timedOut: false })
|
||||
seedWslGuestEnvironmentForTests('probe-deletion', {
|
||||
path: '/usr/bin',
|
||||
home: '/private/guest',
|
||||
envBinary: '/usr/bin/env'
|
||||
})
|
||||
vi.stubEnv('DELETED_CREDENTIAL', 'dummy-host-value')
|
||||
vi.stubEnv('OPENCODE_CONFIG_DIR', 'C:\\dummy\\config')
|
||||
vi.stubEnv('KEEP', 'C:\\keep')
|
||||
vi.stubEnv('EMPTY', '')
|
||||
vi.stubEnv('WSLENV', 'DELETED_CREDENTIAL/u:OPENCODE_CONFIG_DIR/p:KEEP/p:EMPTY/u')
|
||||
})
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('OpenCode WSL version-probe deletion boundary', () => {
|
||||
it.each([
|
||||
{ deleted: ['DELETED_CREDENTIAL', 'OPENCODE_CONFIG_DIR'], kept: ['KEEP/p', 'EMPTY/u'] },
|
||||
{ deleted: ['WSLENV'], kept: [] }
|
||||
])(
|
||||
'does not restore deleted keys through the inherited carrier: $deleted',
|
||||
async ({ deleted, kept }) => {
|
||||
await prepareOpenCodePtyLaunch({
|
||||
command: 'opencode',
|
||||
env: {},
|
||||
envToDelete: [...deleted],
|
||||
isFreshLaunch: true,
|
||||
wsl: { distro: 'probe-deletion' },
|
||||
cwd: `/private/${deleted.join('-')}`
|
||||
})
|
||||
const hostEnv = run.mock.calls[0]?.[0].env
|
||||
expect(hostEnv).toBeDefined()
|
||||
const tokens = String(hostEnv.WSLENV).split(':').filter(Boolean)
|
||||
expect(tokens).not.toContain('DELETED_CREDENTIAL/u')
|
||||
expect(tokens).not.toContain('OPENCODE_CONFIG_DIR/p')
|
||||
expect(tokens).toEqual(expect.arrayContaining(kept))
|
||||
// The host value may exist; its carrier must not import it into the guest.
|
||||
expect(hostEnv.DELETED_CREDENTIAL).toBe('dummy-host-value')
|
||||
}
|
||||
)
|
||||
|
||||
it('shares a sanitized concurrent probe without losing kept flags or empty values', async () => {
|
||||
const options = {
|
||||
command: 'opencode',
|
||||
env: {},
|
||||
envToDelete: ['DELETED_CREDENTIAL', 'OPENCODE_CONFIG_DIR'],
|
||||
isFreshLaunch: true,
|
||||
wsl: { distro: 'probe-deletion' },
|
||||
cwd: '/private/concurrent'
|
||||
}
|
||||
await Promise.all([
|
||||
prepareOpenCodePtyLaunch({ ...options, envToDelete: [...options.envToDelete] }),
|
||||
prepareOpenCodePtyLaunch({ ...options, envToDelete: [...options.envToDelete] })
|
||||
])
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
const hostEnv = run.mock.calls[0]?.[0].env
|
||||
expect(hostEnv.WSLENV).toContain('KEEP/p')
|
||||
expect(hostEnv.WSLENV).toContain('EMPTY/u')
|
||||
expect(hostEnv.WSLENV).not.toContain('DELETED_CREDENTIAL')
|
||||
expect(hostEnv.WSLENV).not.toContain('OPENCODE_CONFIG_DIR')
|
||||
expect(hostEnv.EMPTY).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -289,11 +289,8 @@ export function getStatusPluginFactorySource(options: {
|
||||
'};',
|
||||
...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []),
|
||||
'',
|
||||
'// Why: OpenCode also resolves plugins through the module default export, and that',
|
||||
'// loader rejects the module unless the default exposes `server()` ("must default',
|
||||
'// export an object with server()"). `setup()` does not satisfy it. Keep the named',
|
||||
'// export so the factory-based loader still finds the same instance.',
|
||||
'export default {',
|
||||
'// OpenCode 1 requires a callable default; OpenCode 2 validates a plugin object.',
|
||||
'export default process.env.ORCA_OPENCODE_PLUGIN_API === "v1" ? OrcaOpenCodeStatusPlugin : {',
|
||||
` id: "${pluginID}",`,
|
||||
' server: OrcaOpenCodeStatusPlugin,',
|
||||
...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { basename, dirname, join, relative } from 'node:path'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setSecretStore } from '../../../shared/secret-store'
|
||||
import { profileStateStorage } from '../../orca-profiles/profile-project-state-file'
|
||||
@@ -103,13 +104,25 @@ afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
afterAll(() => rmSync(suiteRoot, { recursive: true, force: true }))
|
||||
afterAll(() => {
|
||||
try {
|
||||
for (const seed of seededFixtures.values()) {
|
||||
for (const [suffix, bytes] of seed.originalFamily) {
|
||||
expect(readFileSync(`${seed.databasePath}${suffix}`).equals(bytes)).toBe(true)
|
||||
}
|
||||
expect(readFileSync(seed.backupPath).equals(seed.backupBytes)).toBe(true)
|
||||
expect(readFileSync(seed.exportPath, 'utf8')).toBe(selectedJson)
|
||||
}
|
||||
} finally {
|
||||
rmSync(suiteRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
type Fixture = RecoveryCrashOptions & { backupBytes: Buffer; originalFamily: Map<string, Buffer> }
|
||||
const seededFixtures = new Map<string, Fixture>()
|
||||
|
||||
async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise<Fixture> {
|
||||
const root = mkdtempSync(join(suiteRoot, 'profile-'))
|
||||
fixtureRoots.push(root)
|
||||
async function seedFixture(kind: 'json' | 'sqlite', accepted: boolean): Promise<Fixture> {
|
||||
const root = mkdtempSync(join(suiteRoot, 'seed-'))
|
||||
const directory = join(root, 'profiles', profileId)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -173,6 +186,39 @@ async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise<Fixt
|
||||
return { ...options, backupBytes: readFileSync(backupPath), originalFamily }
|
||||
}
|
||||
|
||||
function cloneFixture(seed: Fixture): Fixture {
|
||||
const root = mkdtempSync(join(suiteRoot, 'profile-'))
|
||||
fixtureRoots.push(root)
|
||||
cpSync(seed.root, root, { recursive: true })
|
||||
const options = {
|
||||
...seed,
|
||||
root,
|
||||
dataFile: join(root, relative(seed.root, seed.dataFile)),
|
||||
databasePath: join(root, relative(seed.root, seed.databasePath)),
|
||||
exportPath: join(root, relative(seed.root, seed.exportPath)),
|
||||
backupPath: join(root, relative(seed.root, seed.backupPath)),
|
||||
markerPath: join(root, relative(seed.root, seed.markerPath))
|
||||
}
|
||||
const originalFamily = new Map(
|
||||
['', '-wal', '-shm', '-journal'].map((suffix) => [
|
||||
suffix,
|
||||
readFileSync(`${options.databasePath}${suffix}`)
|
||||
])
|
||||
)
|
||||
return { ...options, backupBytes: readFileSync(options.backupPath), originalFamily }
|
||||
}
|
||||
|
||||
async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise<Fixture> {
|
||||
const key = `${kind}/${accepted}`
|
||||
let seed = seededFixtures.get(key)
|
||||
if (!seed) {
|
||||
// The seed child's close event has fired before its WAL family is copied.
|
||||
seed = await seedFixture(kind, accepted)
|
||||
seededFixtures.set(key, seed)
|
||||
}
|
||||
return cloneFixture(seed)
|
||||
}
|
||||
|
||||
function readSqlite(path: string): unknown {
|
||||
const opened = openProfileStateDatabaseReadOnly(path, profileId)
|
||||
try {
|
||||
@@ -192,10 +238,12 @@ function assertQuarantine(profile: Fixture): void {
|
||||
const directory = join(dirname(profile.databasePath), quarantine)
|
||||
// Check exact family bytes before opening the copied WAL snapshot.
|
||||
for (const [suffix, bytes] of profile.originalFamily) {
|
||||
expect(readFileSync(join(directory, `profile-state.db${suffix}`))).toEqual(bytes)
|
||||
expect(readFileSync(join(directory, `profile-state.db${suffix}`)).equals(bytes)).toBe(true)
|
||||
}
|
||||
expect(readFileSync(join(directory, basename(profile.exportPath)), 'utf8')).toBe(selectedJson)
|
||||
expect(readFileSync(join(directory, basename(profile.backupPath)))).toEqual(profile.backupBytes)
|
||||
expect(
|
||||
readFileSync(join(directory, basename(profile.backupPath))).equals(profile.backupBytes)
|
||||
).toBe(true)
|
||||
expect(readSqlite(join(directory, 'profile-state.db'))).toEqual(oldState)
|
||||
}
|
||||
|
||||
@@ -346,7 +394,7 @@ describe('SQLite recovery process death', () => {
|
||||
const profile = await fixture('sqlite', true)
|
||||
await killRecoveryAt(bundle, profile, stage(profile, boundary))
|
||||
assertQuarantine(profile)
|
||||
expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes)
|
||||
expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true)
|
||||
const expected = [
|
||||
'marker-invalidated',
|
||||
'selected-export',
|
||||
@@ -365,7 +413,7 @@ describe('SQLite recovery process death', () => {
|
||||
: 'refused'
|
||||
assertRestart(profile, expected)
|
||||
retry(profile)
|
||||
expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes)
|
||||
expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
@@ -378,3 +426,38 @@ describe('SQLite recovery process death', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('seeded recovery fixture copies', () => {
|
||||
it.each([
|
||||
['json', false],
|
||||
['json', true],
|
||||
['sqlite', true]
|
||||
] as const)(
|
||||
'isolates %s/accepted=%s through corruption and WAL reopen',
|
||||
async (kind, accepted) => {
|
||||
const first = await fixture(kind, accepted)
|
||||
const second = await fixture(kind, accepted)
|
||||
const seed = seededFixtures.get(`${kind}/${accepted}`)
|
||||
if (!seed) {
|
||||
throw new Error('Seed fixture was not retained')
|
||||
}
|
||||
expect(new Set([first.root, second.root, seed.root]).size).toBe(3)
|
||||
for (const [suffix, bytes] of seed.originalFamily) {
|
||||
expect(readFileSync(`${first.databasePath}${suffix}`).equals(bytes)).toBe(true)
|
||||
writeFileSync(`${first.databasePath}${suffix}`, 'corrupted-copy')
|
||||
expect(readFileSync(`${second.databasePath}${suffix}`).equals(bytes)).toBe(true)
|
||||
expect(readFileSync(`${seed.databasePath}${suffix}`).equals(bytes)).toBe(true)
|
||||
}
|
||||
const third = await fixture(kind, accepted)
|
||||
expect(readSqlite(third.databasePath)).toEqual(oldState)
|
||||
expect(readFileSync(seed.backupPath).equals(seed.backupBytes)).toBe(true)
|
||||
expect(readFileSync(seed.exportPath, 'utf8')).toBe(selectedJson)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects an incomplete copied WAL family before a recovery child starts', async () => {
|
||||
const incomplete = await fixture('json', false)
|
||||
rmSync(`${incomplete.databasePath}-wal`)
|
||||
expect(() => cloneFixture(incomplete)).toThrow(/ENOENT/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -175,6 +175,7 @@ describe('PTY provider dispatch', () => {
|
||||
'CLAUDE_CODE_CHILD_SESSION',
|
||||
'CLAUDE_CODE_SESSION_ID',
|
||||
'CLAUDE_CODE_BRIDGE_SESSION_ID',
|
||||
'ORCA_OPENCODE_PLUGIN_API',
|
||||
'ORCA_PI_STATUS_OWNED',
|
||||
'ORCA_PRIME_AGENT_STATUS_OWNED',
|
||||
'ORCA_PI_TITLE_MARKER_OWNED',
|
||||
|
||||
@@ -4,8 +4,11 @@ import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation'
|
||||
import type { TerminalOwner } from '../../shared/terminal-owner'
|
||||
import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version'
|
||||
|
||||
export type PtySpawnResult = {
|
||||
/** Execution-host probe; absent on older hosts and reattachments. */
|
||||
openCodeCapabilities?: OpenCodeCliCapabilities
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
/** App-facing PTY id. Remote providers must return globally routable ids,
|
||||
* not relay-local handles, because renderer/runtime IPC routes by this key. */
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness'
|
||||
import {
|
||||
createTranscriptPane,
|
||||
TRANSCRIPT_PANE_PTY_ID,
|
||||
waitForTranscriptIdle
|
||||
} from './agent-transcript-pane-test-harness'
|
||||
import {
|
||||
finalReplayFrame,
|
||||
readRuntimeFixture,
|
||||
@@ -112,9 +116,8 @@ describe('Antigravity 1.2.14 readiness from captured bytes', () => {
|
||||
data: `${String.fromCharCode(27)}]0;agy${String.fromCharCode(7)}${readRuntimeFixture('antigravity-1-2-14-model-picker')}`,
|
||||
size: { cols: 120, rows: 40 }
|
||||
})
|
||||
await expect(
|
||||
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 })
|
||||
).rejects.toThrow(/timeout/)
|
||||
await runtime.readTerminal(handle, { screen: true })
|
||||
await expect(waitForTranscriptIdle({ runtime, handle }, 5_000)).rejects.toThrow(/timeout/)
|
||||
}, 15_000)
|
||||
|
||||
// Why this recording: only the screen reads it ready, so settling proves the grid is trusted.
|
||||
@@ -133,11 +136,8 @@ describe('Antigravity 1.2.14 readiness from captured bytes', () => {
|
||||
runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, cols, rows)
|
||||
}
|
||||
const settles = async () =>
|
||||
(
|
||||
await runtime
|
||||
.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 })
|
||||
.catch(() => ({ satisfied: false }))
|
||||
).satisfied
|
||||
(await waitForTranscriptIdle({ runtime, handle }, 5_000).catch(() => ({ satisfied: false })))
|
||||
.satisfied
|
||||
options.size = { cols: 120, rows: 40 }
|
||||
runtime.reflowHeadlessTerminalToPtyGrid(TRANSCRIPT_PANE_PTY_ID, 120, 40)
|
||||
await runtime.readTerminal(handle, { screen: true })
|
||||
|
||||
@@ -1,124 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
appendNormalizedToTailBuffer,
|
||||
buildPreview,
|
||||
computeTerminalTailWaitState,
|
||||
tailGainedNewerBlockedReason,
|
||||
type TerminalTailWaitState
|
||||
} from './orca-runtime'
|
||||
import { computeTerminalTailWaitState } from './terminal-wait-tail-state'
|
||||
|
||||
// These tests pin the onPtyData wait-detection memoization: caching the
|
||||
// post-append wait state and reusing it as the next chunk's pre-append state
|
||||
// must produce byte-for-byte the same waitBlockedAt stamping as recomputing the
|
||||
// wait-state scan on both sides of every chunk (the pre-memoization behavior),
|
||||
// while doing roughly half the state computations.
|
||||
|
||||
type RedrawCursor = ReturnType<typeof appendNormalizedToTailBuffer>['redrawCursor']
|
||||
|
||||
type TailSim = {
|
||||
tailBuffer: string[]
|
||||
tailPartialLine: string
|
||||
tailRedrawCursor: RedrawCursor
|
||||
preview: string
|
||||
waitBlockedAt: number | null
|
||||
tailWaitState?: TerminalTailWaitState
|
||||
}
|
||||
|
||||
function newSim(): TailSim {
|
||||
return {
|
||||
tailBuffer: [],
|
||||
tailPartialLine: '',
|
||||
tailRedrawCursor: null,
|
||||
preview: '',
|
||||
waitBlockedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
type Compute = typeof computeTerminalTailWaitState
|
||||
|
||||
// Mirrors the memoized onPtyData tail loop: reuse the cached tail-derived state
|
||||
// as the previous state; only recompute it on a preview-fallback (empty tail).
|
||||
function stepMemoized(sim: TailSim, chunk: string, at: number, compute: Compute): void {
|
||||
const previousWaitState =
|
||||
sim.tailWaitState?.fromTail === true
|
||||
? sim.tailWaitState
|
||||
: compute(sim.tailBuffer, sim.tailPartialLine, sim.preview)
|
||||
const nextTail = appendNormalizedToTailBuffer(
|
||||
sim.tailBuffer,
|
||||
sim.tailPartialLine,
|
||||
chunk,
|
||||
sim.tailRedrawCursor
|
||||
)
|
||||
const nextWaitState = compute(nextTail.lines, nextTail.partialLine, sim.preview)
|
||||
if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, chunk)) {
|
||||
sim.waitBlockedAt = at
|
||||
}
|
||||
sim.tailWaitState = nextWaitState
|
||||
sim.tailBuffer = nextTail.lines
|
||||
sim.tailPartialLine = nextTail.partialLine
|
||||
sim.tailRedrawCursor = nextTail.redrawCursor
|
||||
sim.preview = buildPreview(nextTail.lines, nextTail.partialLine)
|
||||
}
|
||||
|
||||
// Reference: the pre-memoization behavior — recompute the previous state fresh
|
||||
// from the current tail on every chunk (no cache).
|
||||
function stepReference(sim: TailSim, chunk: string, at: number, compute: Compute): void {
|
||||
const previousWaitState = compute(sim.tailBuffer, sim.tailPartialLine, sim.preview)
|
||||
const nextTail = appendNormalizedToTailBuffer(
|
||||
sim.tailBuffer,
|
||||
sim.tailPartialLine,
|
||||
chunk,
|
||||
sim.tailRedrawCursor
|
||||
)
|
||||
const nextWaitState = compute(nextTail.lines, nextTail.partialLine, sim.preview)
|
||||
if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, chunk)) {
|
||||
sim.waitBlockedAt = at
|
||||
}
|
||||
sim.tailBuffer = nextTail.lines
|
||||
sim.tailPartialLine = nextTail.partialLine
|
||||
sim.tailRedrawCursor = nextTail.redrawCursor
|
||||
sim.preview = buildPreview(nextTail.lines, nextTail.partialLine)
|
||||
}
|
||||
|
||||
function runBoth(chunks: string[]): { memoized: (number | null)[]; reference: (number | null)[] } {
|
||||
const memoSim = newSim()
|
||||
const refSim = newSim()
|
||||
const memoized: (number | null)[] = []
|
||||
const reference: (number | null)[] = []
|
||||
chunks.forEach((chunk, index) => {
|
||||
const at = index + 1
|
||||
stepMemoized(memoSim, chunk, at, computeTerminalTailWaitState)
|
||||
stepReference(refSim, chunk, at, computeTerminalTailWaitState)
|
||||
memoized.push(memoSim.waitBlockedAt)
|
||||
reference.push(refSim.waitBlockedAt)
|
||||
})
|
||||
return { memoized, reference }
|
||||
}
|
||||
|
||||
const SCENARIOS: Record<string, string[]> = {
|
||||
'plain output never blocks': ['building...\n', 'compiled ok\n', 'watching for changes\n'],
|
||||
'blocked prompt in one chunk': ['Update available! Press Enter to continue.\n'],
|
||||
'blocked prompt split across chunks': ['Update available!\n', 'Press Enter to continue.\n'],
|
||||
'blocked then plain output stays blocked': [
|
||||
'Update available! Press Enter to continue.\n',
|
||||
'still here\n',
|
||||
'more logs\n'
|
||||
],
|
||||
'partial lines without newline then completion': [
|
||||
'Update ava',
|
||||
'ilable! Press Enter ',
|
||||
'to continue.\n'
|
||||
],
|
||||
'empty and whitespace chunks': ['', ' ', '\n', 'ok\n', ''],
|
||||
'ready header after stale blocked prompt': [
|
||||
'Update available! Press Enter to continue.\n',
|
||||
'OpenAI Codex\n',
|
||||
'model: gpt\n',
|
||||
'directory: /repo\n'
|
||||
]
|
||||
}
|
||||
|
||||
describe('onPtyData tail wait memoization', () => {
|
||||
describe('terminal tail wait state', () => {
|
||||
it('computeTerminalTailWaitState reports fromTail and blocked signals', () => {
|
||||
const empty = computeTerminalTailWaitState([], '', '')
|
||||
expect(empty.fromTail).toBe(false)
|
||||
@@ -151,92 +34,4 @@ describe('onPtyData tail wait memoization', () => {
|
||||
lastIndexOf.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
for (const [name, chunks] of Object.entries(SCENARIOS)) {
|
||||
it(`memoized stamping matches recompute reference: ${name}`, () => {
|
||||
const { memoized, reference } = runBoth(chunks)
|
||||
expect(memoized).toEqual(reference)
|
||||
})
|
||||
}
|
||||
|
||||
it('stays equivalent across tail eviction beyond the retained cap', () => {
|
||||
const chunks: string[] = []
|
||||
for (let i = 0; i < 2600; i += 1) {
|
||||
chunks.push(`line ${i} of streaming build output that keeps the tail busy\n`)
|
||||
}
|
||||
// Introduce a real blocked prompt well past the eviction boundary.
|
||||
chunks.push('Update available! Press Enter to continue.\n')
|
||||
chunks.push('trailing log after prompt\n')
|
||||
const { memoized, reference } = runBoth(chunks)
|
||||
expect(memoized).toEqual(reference)
|
||||
// The prompt must actually be detected (guards against a vacuous match).
|
||||
expect(memoized.at(-1)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('recomputes correctly after a transcript prune empties the tail (prune-then-resume)', () => {
|
||||
// pruneDisconnectedPtyTranscript empties the tail AND clears tailWaitState;
|
||||
// model that here and assert the memoized stamping still tracks a fresh
|
||||
// recompute across the reset (a stale cache would desync the first resumed
|
||||
// chunk, since the pre-prune tail held a blocked prompt).
|
||||
const prune = (sim: TailSim): void => {
|
||||
sim.tailBuffer = []
|
||||
sim.tailPartialLine = ''
|
||||
sim.tailRedrawCursor = null
|
||||
sim.preview = ''
|
||||
sim.waitBlockedAt = null
|
||||
sim.tailWaitState = undefined
|
||||
}
|
||||
const memoSim = newSim()
|
||||
const refSim = newSim()
|
||||
const memoOut: (number | null)[] = []
|
||||
const refOut: (number | null)[] = []
|
||||
let at = 0
|
||||
const feed = (chunk: string): void => {
|
||||
at += 1
|
||||
stepMemoized(memoSim, chunk, at, computeTerminalTailWaitState)
|
||||
stepReference(refSim, chunk, at, computeTerminalTailWaitState)
|
||||
memoOut.push(memoSim.waitBlockedAt)
|
||||
refOut.push(refSim.waitBlockedAt)
|
||||
}
|
||||
// Pre-prune: leave a stale blocked prompt in the tail.
|
||||
;['building\n', 'Update available! Press Enter to continue.\n', 'more log\n'].forEach(feed)
|
||||
prune(memoSim)
|
||||
prune(refSim)
|
||||
// Resume: a fresh blocked prompt must be stamped, not masked by stale cache.
|
||||
;['fresh start\n', 'Update available! Press Enter to continue.\n', 'after\n'].forEach(feed)
|
||||
|
||||
expect(memoOut).toEqual(refOut)
|
||||
expect(memoSim.waitBlockedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does roughly half the wait-state computations of the recompute reference', () => {
|
||||
const chunks: string[] = []
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
chunks.push(`streaming line ${i}\n`)
|
||||
}
|
||||
|
||||
let memoCalls = 0
|
||||
const countingMemo: Compute = (lines, partial, preview) => {
|
||||
memoCalls += 1
|
||||
return computeTerminalTailWaitState(lines, partial, preview)
|
||||
}
|
||||
let refCalls = 0
|
||||
const countingRef: Compute = (lines, partial, preview) => {
|
||||
refCalls += 1
|
||||
return computeTerminalTailWaitState(lines, partial, preview)
|
||||
}
|
||||
|
||||
const memoSim = newSim()
|
||||
const refSim = newSim()
|
||||
chunks.forEach((chunk, index) => {
|
||||
stepMemoized(memoSim, chunk, index + 1, countingMemo)
|
||||
stepReference(refSim, chunk, index + 1, countingRef)
|
||||
})
|
||||
|
||||
// Reference recomputes both sides every chunk: 2 per chunk.
|
||||
expect(refCalls).toBe(chunks.length * 2)
|
||||
// Memoized reuses the cached previous state on every non-empty-tail chunk:
|
||||
// 1 per chunk plus a single first-chunk cold miss.
|
||||
expect(memoCalls).toBe(chunks.length + 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -319,7 +319,8 @@ describe('OrcaRuntimeService', () => {
|
||||
12,
|
||||
issueUpdates,
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
undefined
|
||||
)
|
||||
expect(addGitHubIssueCommentMock).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
@@ -327,7 +328,8 @@ describe('OrcaRuntimeService', () => {
|
||||
'Comment',
|
||||
null,
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
undefined
|
||||
)
|
||||
expect(listGitHubLabelsMock).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
/**
|
||||
* Regression: pruneDisconnectedPtyTranscript empties a disconnected PTY's
|
||||
* retained tail. The onPtyData wait-scan memoization caches the tail's wait
|
||||
* state on the record (tailWaitState) and reuses it as the next chunk's
|
||||
* "previous" state — so the prune MUST also clear that cache, or a record that
|
||||
* resumes output after adoption/reattach would reuse a stale (pre-prune,
|
||||
* possibly blocked) wait state and mis-stamp waitBlockedAt on its first chunk.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// A disconnected transcript must not keep a wait-scan cache from before its history was pruned.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import type { TerminalTailWaitState } from './orca-runtime'
|
||||
import { MAX_TAIL_LINES } from './terminal-tail-limits'
|
||||
import * as terminalWaitTailState from './terminal-wait-tail-state'
|
||||
import type { TerminalTailWaitState } from './terminal-wait-tail-state'
|
||||
|
||||
type PtyRecord = {
|
||||
connected: boolean
|
||||
@@ -20,9 +15,33 @@ type RuntimeInternals = {
|
||||
pruneDisconnectedPtyTranscript: (pty: PtyRecord) => void
|
||||
}
|
||||
|
||||
function onlyRuntimeLeaf(runtime: unknown) {
|
||||
if (typeof runtime !== 'object' || runtime === null || !('leaves' in runtime)) {
|
||||
throw new Error('Runtime has no leaves')
|
||||
}
|
||||
const leaves = runtime.leaves
|
||||
if (!(leaves instanceof Map) || leaves.size !== 1) {
|
||||
throw new Error('Expected exactly one runtime leaf')
|
||||
}
|
||||
const leaf: unknown = leaves.values().next().value
|
||||
if (
|
||||
typeof leaf !== 'object' ||
|
||||
leaf === null ||
|
||||
!('tailBuffer' in leaf) ||
|
||||
!Array.isArray(leaf.tailBuffer) ||
|
||||
!('tailLinesTotal' in leaf) ||
|
||||
typeof leaf.tailLinesTotal !== 'number' ||
|
||||
!('waitBlockedAt' in leaf)
|
||||
) {
|
||||
throw new Error('Runtime leaf has no terminal tail state')
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
describe('pruneDisconnectedPtyTranscript clears the wait-scan cache', () => {
|
||||
it('empties the tail and drops tailWaitState so resume recomputes', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both protected methods are declared on OrcaRuntimeService's inheritance chain.
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
const pty = internals.recordPtyWorktree('pty-1', 'wt-1', { connected: true })
|
||||
|
||||
@@ -40,4 +59,65 @@ describe('pruneDisconnectedPtyTranscript clears the wait-scan cache', () => {
|
||||
expect(pty.tailBuffer).toEqual([])
|
||||
expect(pty.tailWaitState).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reuses the real leaf wait scan and stamps each newly arriving blocked prompt', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const ptyId = 'pty-memo'
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [{ tabId: 'tab-1', worktreeId: 'wt-1', title: '', activeLeafId: leafId, layout: null }],
|
||||
leaves: [
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1', leafId, paneRuntimeId: 1, ptyId, paneTitle: null }
|
||||
]
|
||||
})
|
||||
const leaf = onlyRuntimeLeaf(runtime)
|
||||
// Different retained history exercises the leaf scan rather than the shared PTY-tail path.
|
||||
leaf.tailBuffer = ['leaf-only history']
|
||||
leaf.tailLinesTotal = 1
|
||||
const scan = vi.spyOn(terminalWaitTailState, 'computeTerminalTailWaitState')
|
||||
const leafScans = () =>
|
||||
scan.mock.calls.filter(([lines]) => lines.includes('leaf-only history')).length
|
||||
try {
|
||||
runtime.onPtyData(ptyId, 'first plain line\n', 1_000)
|
||||
expect(leafScans()).toBe(2)
|
||||
expect('tailWaitState' in leaf ? leaf.tailWaitState : undefined).toMatchObject({
|
||||
fromTail: true,
|
||||
signal: null
|
||||
})
|
||||
runtime.onPtyData(ptyId, 'second plain line\n', 2_000)
|
||||
expect(leafScans()).toBe(3)
|
||||
expect(leaf.waitBlockedAt).toBeNull()
|
||||
runtime.onPtyData(ptyId, 'Update ava', 3_000)
|
||||
expect(leafScans()).toBe(4)
|
||||
expect(leaf.waitBlockedAt).toBeNull()
|
||||
runtime.onPtyData(ptyId, 'ilable! Press Enter to continue.\n', 4_000)
|
||||
expect(leafScans()).toBe(5)
|
||||
expect(leaf.waitBlockedAt).toBe(4_000)
|
||||
runtime.onPtyData(ptyId, 'ordinary log after the prompt\n', 5_000)
|
||||
expect(leafScans()).toBe(6)
|
||||
expect(leaf.waitBlockedAt).toBe(4_000)
|
||||
runtime.onPtyData(ptyId, 'Update available! Press Enter to continue.\n', 6_000)
|
||||
expect(leafScans()).toBe(7)
|
||||
expect(leaf.waitBlockedAt).toBe(6_000)
|
||||
runtime.onPtyData(
|
||||
ptyId,
|
||||
Array.from({ length: MAX_TAIL_LINES + 1 }, (_, index) => `streaming line ${index}\n`).join(
|
||||
''
|
||||
),
|
||||
7_000
|
||||
)
|
||||
expect(leaf.tailBuffer).toHaveLength(MAX_TAIL_LINES)
|
||||
expect(leaf.tailBuffer).not.toContain('leaf-only history')
|
||||
expect('tailWaitState' in leaf ? leaf.tailWaitState : undefined).toMatchObject({
|
||||
fromTail: true,
|
||||
signal: null
|
||||
})
|
||||
runtime.onPtyData(ptyId, 'Update available! Press Enter to continue.\n', 8_000)
|
||||
expect(leaf.waitBlockedAt).toBe(8_000)
|
||||
} finally {
|
||||
scan.mockRestore()
|
||||
await runtime.onPtyExit(ptyId, 0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendNormalizedToTailBuffer,
|
||||
appendNormalizedToMultilineTailBufferUnwindowed
|
||||
} from './orca-runtime'
|
||||
import { appendNormalizedToTailBuffer } from './terminal-tail-buffer'
|
||||
import { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail-redraw-buffer'
|
||||
|
||||
// Differential guard for the windowed redraw tail path: the public
|
||||
// appendNormalizedToTailBuffer routes vertical-control chunks through a
|
||||
|
||||
@@ -35,6 +35,19 @@ export const GITHUB_ISSUE_METHODS = [
|
||||
name: 'github.addIssueComment',
|
||||
params: IssueComment,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.addRepoIssueComment(params.repo, params.number, params.body, params.prRepo ?? null)
|
||||
params.type
|
||||
? runtime.addRepoIssueComment(
|
||||
params.repo,
|
||||
params.number,
|
||||
params.body,
|
||||
params.prRepo ?? null,
|
||||
params.type
|
||||
)
|
||||
: runtime.addRepoIssueComment(
|
||||
params.repo,
|
||||
params.number,
|
||||
params.body,
|
||||
params.prRepo ?? null
|
||||
)
|
||||
})
|
||||
]
|
||||
|
||||
@@ -626,10 +626,13 @@ describe('github RPC methods', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.addRepoIssueComment).toHaveBeenCalledWith('repo-1', 3, 'Looks good', {
|
||||
owner: 'acme',
|
||||
repo: 'widgets'
|
||||
})
|
||||
expect(runtime.addRepoIssueComment).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
3,
|
||||
'Looks good',
|
||||
{ owner: 'acme', repo: 'widgets' },
|
||||
'pr'
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 1 } } })
|
||||
})
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ export class RuntimeGitHubIssueCommentCommands {
|
||||
number,
|
||||
updates,
|
||||
repo.connectionId ?? null,
|
||||
...this.deps.getLocalGitArgs(repo)
|
||||
this.deps.getLocalGitArgs(repo)[0],
|
||||
repo.issueSourcePreference
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,7 +59,8 @@ export class RuntimeGitHubIssueCommentCommands {
|
||||
repoSelector: string,
|
||||
number: number,
|
||||
body: string,
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
prRepo?: GitHubOwnerRepo | null,
|
||||
type?: 'issue' | 'pr'
|
||||
): Promise<Awaited<ReturnType<typeof addIssueComment>>> {
|
||||
const repo = await this.deps.resolveRepo(repoSelector)
|
||||
return addIssueComment(
|
||||
@@ -67,7 +69,9 @@ export class RuntimeGitHubIssueCommentCommands {
|
||||
body,
|
||||
repo.connectionId ?? null,
|
||||
prRepo ?? null,
|
||||
...this.deps.getLocalGitArgs(repo)
|
||||
this.deps.getLocalGitArgs(repo)[0],
|
||||
// Why: the issue source selector only scopes issues; PR comments keep their resolution.
|
||||
type === 'pr' ? undefined : repo.issueSourcePreference
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalCursorContext } from '../../shared/terminal-composer-draft'
|
||||
import type { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import { projectTerminalTailLines } from './orca-runtime'
|
||||
import { projectTerminalTailLines } from './orca-runtime-terminal-projection'
|
||||
|
||||
describe('projectTerminalTailLines', () => {
|
||||
it('does not splice a scrolled viewport into unrelated tail rows', () => {
|
||||
|
||||
@@ -75,6 +75,7 @@ export type GithubWorkItemApi = {
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
number: number
|
||||
type?: 'issue' | 'pr'
|
||||
ownerRepo?: GitHubOwnerRepo
|
||||
}
|
||||
) => Promise<GitHubWorkItemDetails | null>
|
||||
notifyWorkItemMutated: (args: {
|
||||
@@ -106,6 +107,7 @@ export type GithubWorkItemApi = {
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
number: number
|
||||
updates: GitHubIssueUpdate
|
||||
ownerRepo?: GitHubOwnerRepo
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
addIssueComment: (
|
||||
@@ -121,11 +123,13 @@ export type GithubWorkItemApi = {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
ownerRepo?: GitHubOwnerRepo
|
||||
}) => Promise<string[]>
|
||||
listAssignableUsers: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
ownerRepo?: GitHubOwnerRepo
|
||||
}) => Promise<GitHubAssignableUser[]>
|
||||
/** Subscribe to local-mutation broadcasts so the work-item-drawer cache can invalidate across windows. Returns an unsubscribe. */
|
||||
onWorkItemMutated: (
|
||||
|
||||
@@ -44,26 +44,22 @@ describe('relay list-files cancellation', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('listFilesWithRg kills both rg passes and rejects when aborted mid-flight', async () => {
|
||||
const primaryProc = createMockProcess()
|
||||
it('listFilesWithRg kills the broad rg pass and rejects when aborted mid-flight', async () => {
|
||||
const ignoredProc = createMockProcess()
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) =>
|
||||
args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc
|
||||
)
|
||||
spawnMock.mockReturnValue(ignoredProc)
|
||||
|
||||
const controller = new AbortController()
|
||||
const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal })
|
||||
|
||||
// Partial output before the abort — must be discarded, not resolved.
|
||||
;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
ignoredProc.stdout?.emit('data', 'src/index.ts\n')
|
||||
controller.abort()
|
||||
|
||||
await expect(promise).rejects.toSatisfy(isFileListingCancellation)
|
||||
expect(primaryProc.kill).toHaveBeenCalled()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(ignoredProc.kill).toHaveBeenCalled()
|
||||
|
||||
// Late close events after cancellation must not fire anything.
|
||||
primaryProc.emit('close', null, 'SIGTERM')
|
||||
ignoredProc.emit('close', null, 'SIGTERM')
|
||||
})
|
||||
|
||||
@@ -78,18 +74,14 @@ describe('relay list-files cancellation', () => {
|
||||
})
|
||||
|
||||
it('listFilesWithRg still resolves normally when a signal is provided but never aborted', async () => {
|
||||
const primaryProc = createMockProcess()
|
||||
const ignoredProc = createMockProcess()
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) =>
|
||||
args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc
|
||||
)
|
||||
spawnMock.mockReturnValue(ignoredProc)
|
||||
|
||||
const controller = new AbortController()
|
||||
const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal })
|
||||
|
||||
setTimeout(() => {
|
||||
;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
primaryProc.emit('close', 0, null)
|
||||
ignoredProc.stdout?.emit('data', 'src/index.ts\n')
|
||||
;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n')
|
||||
ignoredProc.emit('close', 0, null)
|
||||
}, 5)
|
||||
|
||||
@@ -71,24 +71,16 @@ describe('relay quick open ignored file listing', () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
it('rg ignored pass includes ignored non-env files and keeps blocklists/excludes', async () => {
|
||||
const primaryProc = createMockProcess()
|
||||
it('uses one broad rg pass for unbounded listings and keeps blocklists/excludes', async () => {
|
||||
const ignoredProc = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args.includes('--no-ignore-vcs')) {
|
||||
return ignoredProc
|
||||
}
|
||||
return primaryProc
|
||||
})
|
||||
spawnMock.mockReturnValue(ignoredProc)
|
||||
|
||||
const promise = listFilesWithRg('/remote/root', ['packages/other'])
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
setTimeout(() => {
|
||||
;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
primaryProc.emit('close', 0, null)
|
||||
|
||||
ignoredProc.stdout?.emit('data', 'src/index.ts\n')
|
||||
;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n')
|
||||
;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'node_modules/pkg/index.js\n')
|
||||
;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n')
|
||||
@@ -97,9 +89,7 @@ describe('relay quick open ignored file listing', () => {
|
||||
|
||||
await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js'])
|
||||
|
||||
const ignoredArgs = spawnMock.mock.calls.find((call) =>
|
||||
(call[1] as string[]).includes('--no-ignore-vcs')
|
||||
)?.[1] as string[]
|
||||
const ignoredArgs = spawnMock.mock.calls[0][1]
|
||||
expect(ignoredArgs).toBeDefined()
|
||||
expect(ignoredArgs).toContain('--no-ignore-vcs')
|
||||
expect(ignoredArgs).not.toContain('.env*')
|
||||
@@ -150,6 +140,24 @@ describe('relay quick open ignored file listing', () => {
|
||||
await expect(promise).resolves.toEqual(['scripts/check-target.ts', 'src/components/target.ts'])
|
||||
})
|
||||
|
||||
it('fills a bounded listing from primary files before admitting ignored files', async () => {
|
||||
const primary = createMockProcess()
|
||||
const broad = createMockProcess()
|
||||
spawnMock.mockReturnValueOnce(primary).mockReturnValueOnce(broad)
|
||||
|
||||
const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 })
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(spawnMock.mock.calls[0][1]).not.toContain('--no-ignore-vcs')
|
||||
primary.stdout?.emit('data', 'src/index.ts\n')
|
||||
primary.emit('close', 0, null)
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2))
|
||||
expect(spawnMock.mock.calls[1][1]).toContain('--no-ignore-vcs')
|
||||
broad.stdout?.emit('data', 'src/index.ts\ndist/generated.js\ndist/extra.js\n')
|
||||
|
||||
await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js'])
|
||||
expect(broad.kill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries a transient remote rg spawn failure without reporting ripgrep as missing', async () => {
|
||||
const failed = createMockProcess()
|
||||
const succeeded = createMockProcess()
|
||||
@@ -191,32 +199,27 @@ describe('relay quick open ignored file listing', () => {
|
||||
await expect(promise).resolves.toEqual(['src/target.ts'])
|
||||
})
|
||||
|
||||
it('runs the ignored pass after an unbounded primary listing retry succeeds', async () => {
|
||||
const failedPrimary = createMockProcess()
|
||||
const succeededPrimary = createMockProcess()
|
||||
const ignored = createMockProcess()
|
||||
Object.defineProperty(failedPrimary, 'pid', { value: undefined })
|
||||
spawnMock
|
||||
.mockReturnValueOnce(failedPrimary)
|
||||
.mockReturnValueOnce(succeededPrimary)
|
||||
.mockReturnValueOnce(ignored)
|
||||
it('retries an unbounded broad listing once after a transient spawn failure', async () => {
|
||||
const failed = createMockProcess()
|
||||
const succeeded = createMockProcess()
|
||||
Object.defineProperty(failed, 'pid', { value: undefined })
|
||||
spawnMock.mockReturnValueOnce(failed).mockReturnValueOnce(succeeded)
|
||||
|
||||
const promise = listFilesWithRg('/remote/root')
|
||||
failedPrimary.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' }))
|
||||
failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' }))
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2))
|
||||
|
||||
;(succeededPrimary.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
succeededPrimary.emit('close', 0, null)
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(3))
|
||||
;(ignored.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n')
|
||||
ignored.emit('close', 0, null)
|
||||
succeeded.stdout?.emit('data', 'src/index.ts\ndist/generated.js\n')
|
||||
succeeded.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js'])
|
||||
expect(spawnMock.mock.calls[2][1]).toContain('--no-ignore-vcs')
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs')
|
||||
expect(spawnMock.mock.calls[1][1]).toContain('--no-ignore-vcs')
|
||||
})
|
||||
|
||||
it.each(['error-first', 'close-first'] as const)(
|
||||
'tags a %s pre-spawn listing failure without starting the ignored pass',
|
||||
'tags a %s pre-spawn listing failure without starting another pass',
|
||||
async (order) => {
|
||||
const root = await makeTempRoot()
|
||||
const missing = createMockProcess()
|
||||
@@ -243,21 +246,17 @@ describe('relay quick open ignored file listing', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('kills only the admitted pass when ignored rg fails before spawn', async () => {
|
||||
it('does not signal the unbounded broad pass when it fails before spawn', async () => {
|
||||
const root = await makeTempRoot()
|
||||
const primary = createMockProcess()
|
||||
const missingIgnored = createMockProcess()
|
||||
Object.defineProperty(missingIgnored, 'pid', { value: undefined })
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) =>
|
||||
args.includes('--no-ignore-vcs') ? missingIgnored : primary
|
||||
)
|
||||
spawnMock.mockReturnValue(missingIgnored)
|
||||
|
||||
const promise = listFilesWithRg(root)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
missingIgnored.emit('close', -2, null)
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError)
|
||||
expect(primary.kill).toHaveBeenCalled()
|
||||
expect(missingIgnored.kill).not.toHaveBeenCalled()
|
||||
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
|
||||
expect(() => missingIgnored.emit('error', error)).not.toThrow()
|
||||
@@ -548,14 +547,8 @@ describe('relay quick open ignored file listing', () => {
|
||||
it('rg file listing rejects and detaches when a timed-out child does not emit close', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const primaryProc = createMockProcess()
|
||||
const ignoredProc = createMockProcess()
|
||||
let callIndex = 0
|
||||
|
||||
spawnMock.mockImplementation(() => {
|
||||
callIndex++
|
||||
return callIndex === 1 ? primaryProc : ignoredProc
|
||||
})
|
||||
spawnMock.mockReturnValue(ignoredProc)
|
||||
|
||||
const promise = listFilesWithRg('/remote/root')
|
||||
const outcomePromise = promise.then(
|
||||
@@ -567,12 +560,8 @@ describe('relay quick open ignored file listing', () => {
|
||||
const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')])
|
||||
|
||||
expect(outcome).toBe('rejected:rg list timed out')
|
||||
expect(primaryProc.kill).toHaveBeenCalled()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(ignoredProc.kill).toHaveBeenCalled()
|
||||
expect((primaryProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0)
|
||||
expect((primaryProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0)
|
||||
expect(primaryProc.listenerCount('error')).toBe(0)
|
||||
expect(primaryProc.listenerCount('close')).toBe(0)
|
||||
expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0)
|
||||
expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0)
|
||||
expect(ignoredProc.listenerCount('error')).toBe(0)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* matching files" even though the file existed on disk. This implementation:
|
||||
* - streams via spawn (no maxBuffer failure mode)
|
||||
* - prunes traversal at rg level using the shared blocklist globs
|
||||
* - runs a second --no-ignore-vcs pass for ignored files
|
||||
* - includes gitignored files, preserving primary-first order for bounded listings
|
||||
* - honors excludePathPrefixes for nested linked worktrees
|
||||
* - rejects (not resolves) on timeout / spawn error / signal exit so
|
||||
* the UI shows a load error instead of a false-empty list
|
||||
@@ -284,10 +284,7 @@ export function listFilesWithRg(
|
||||
})
|
||||
|
||||
const killSurvivors = (reason: string): void => {
|
||||
// Why: when one pass rejects, Promise.all surfaces the error immediately
|
||||
// but the sibling rg keeps running up to LIST_FILES_TIMEOUT_MS. Kill it
|
||||
// so repeated Quick Open opens don't pile up orphan rg processes on the
|
||||
// remote.
|
||||
// Cancellation or a reached budget must stop any admitted scan or retry.
|
||||
for (const entry of children) {
|
||||
if (entry.isDone()) {
|
||||
continue
|
||||
@@ -322,21 +319,13 @@ export function listFilesWithRg(
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
// Without a result budget, the broader pass already contains every primary path.
|
||||
const passes =
|
||||
searchQuery !== undefined
|
||||
searchQuery !== undefined || maxResults === undefined
|
||||
? runPass(ignoredPass)
|
||||
: (() => {
|
||||
const primaryPass = runPass(primary)
|
||||
return maxResults === undefined
|
||||
? children[0]?.child.pid === undefined
|
||||
? primaryPass.then(() => runPass(ignoredPass))
|
||||
: Promise.all([primaryPass, runPass(ignoredPass)])
|
||||
: // Why: deterministic primary-first budgeting prevents a large ignored
|
||||
// tree from starving ordinary source paths on a remote host.
|
||||
primaryPass.then(() =>
|
||||
files.size < maxResults ? runPass(ignoredPass) : Promise.resolve()
|
||||
)
|
||||
})()
|
||||
: runPass(primary).then(() =>
|
||||
files.size < maxResults ? runPass(ignoredPass) : Promise.resolve()
|
||||
)
|
||||
|
||||
passes
|
||||
.then(() => {
|
||||
@@ -353,7 +342,7 @@ export function listFilesWithRg(
|
||||
}
|
||||
done = true
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
killSurvivors('rg list canceled after sibling failure')
|
||||
killSurvivors('rg list canceled after failure')
|
||||
reject(err instanceof Error ? err : new Error(String(err)))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
getOpenCodeCliCapabilities,
|
||||
type OpenCodeCliCapabilities
|
||||
} from '../shared/opencode-cli-version'
|
||||
import { addWslEnvKeys } from '../shared/wsl-env'
|
||||
|
||||
export function restoreOpenCodeCapabilities(value: unknown): OpenCodeCliCapabilities | undefined {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!('version' in value) ||
|
||||
typeof value.version !== 'string'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const capabilities = getOpenCodeCliCapabilities(value.version)
|
||||
return capabilities.pluginApi === 'unknown' ? undefined : capabilities
|
||||
}
|
||||
|
||||
export function applyOpenCodePluginSelection(
|
||||
env: Record<string, string>,
|
||||
envToDelete: string[],
|
||||
capabilities: OpenCodeCliCapabilities | null | undefined,
|
||||
wsl: boolean
|
||||
): void {
|
||||
delete env.ORCA_OPENCODE_PLUGIN_API
|
||||
if (!capabilities || capabilities.pluginApi === 'unknown') {
|
||||
return
|
||||
}
|
||||
env.ORCA_OPENCODE_PLUGIN_API = capabilities.pluginApi
|
||||
for (let index = envToDelete.length - 1; index >= 0; index -= 1) {
|
||||
if (envToDelete[index] === 'ORCA_OPENCODE_PLUGIN_API') {
|
||||
envToDelete.splice(index, 1)
|
||||
}
|
||||
}
|
||||
if (wsl) {
|
||||
addWslEnvKeys(env, ['ORCA_OPENCODE_PLUGIN_API'])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import './mock-descendant-sweep'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { getOpenCodeCliCapabilities } from '../shared/opencode-cli-version'
|
||||
import { applyOpenCodePluginSelection } from './opencode-plugin-selection'
|
||||
import type { PtyHandler } from './pty-handler'
|
||||
import {
|
||||
beginPtyHandlerTest,
|
||||
createMockDispatcher,
|
||||
createTestPtyHandler,
|
||||
endPtyHandlerTest,
|
||||
testPtyId,
|
||||
type MockDispatcher
|
||||
} from './pty-handler-test-harness'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mockPtySpawn: vi.fn(),
|
||||
mockCreateShellPromptReadinessProbe: vi.fn(),
|
||||
probe: vi.fn(),
|
||||
mockPtyInstance: {
|
||||
pid: process.pid,
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn }))
|
||||
vi.mock('../main/shell-prompt-readiness-probe', () => ({
|
||||
createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe
|
||||
}))
|
||||
vi.mock('../main/opencode/opencode-launch-capabilities', () => ({
|
||||
probeOpenCodeLaunchCapabilities: mocks.probe
|
||||
}))
|
||||
|
||||
let dispatcher: MockDispatcher
|
||||
let handler: PtyHandler
|
||||
let originalPlatform: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest(mocks))
|
||||
mocks.probe.mockReset()
|
||||
})
|
||||
|
||||
afterEach(async () => endPtyHandlerTest(handler, originalPlatform))
|
||||
|
||||
function spawnedEnvironment(): Record<string, unknown> {
|
||||
const options: unknown = mocks.mockPtySpawn.mock.calls.at(-1)?.[2]
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
!('env' in options) ||
|
||||
!options.env ||
|
||||
typeof options.env !== 'object'
|
||||
) {
|
||||
throw new Error('Expected a PTY spawn environment')
|
||||
}
|
||||
return Object.fromEntries(Object.entries(options.env))
|
||||
}
|
||||
|
||||
async function serialize(): Promise<string> {
|
||||
const state = await dispatcher.callRequest('pty.serialize', { ids: [testPtyId(1)] })
|
||||
if (typeof state !== 'string') {
|
||||
throw new Error('Expected serialized PTY state')
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
async function restartHandler(): Promise<void> {
|
||||
await handler.dispose({ waitForPhysicalExit: false })
|
||||
dispatcher = createMockDispatcher()
|
||||
handler = createTestPtyHandler(dispatcher)
|
||||
mocks.mockPtySpawn.mockClear()
|
||||
}
|
||||
|
||||
it.each(['1.18.32', '2.0.16'])(
|
||||
'preserves host-selected plugin exports through two relay revives on %s',
|
||||
async (version) => {
|
||||
const capabilities = getOpenCodeCliCapabilities(version)
|
||||
mocks.probe.mockResolvedValue(capabilities)
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
cwd: tmpdir(),
|
||||
command: 'opencode --standalone',
|
||||
launchAgent: 'opencode',
|
||||
env: { ORCA_PANE_KEY: 'tab-oc:leaf', ORCA_OPENCODE_PLUGIN_API: 'untrusted' },
|
||||
envToDelete: ['ORCA_OPENCODE_PLUGIN_API', 'ORCA_OPENCODE_PLUGIN_API']
|
||||
})
|
||||
expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi)
|
||||
const state = await serialize()
|
||||
expect(JSON.parse(state)).toMatchObject([
|
||||
{ openCodeCapabilities: capabilities, envToDelete: [] }
|
||||
])
|
||||
|
||||
await restartHandler()
|
||||
await dispatcher.callRequest('pty.revive', { state })
|
||||
expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi)
|
||||
const second = await serialize()
|
||||
await restartHandler()
|
||||
await dispatcher.callRequest('pty.revive', { state: second })
|
||||
expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi)
|
||||
expect(mocks.probe).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
{ version: 'garbage', pluginApi: 'v1' },
|
||||
{ version: '3.0.0', pluginApi: 'v1' },
|
||||
{ version: 2, pluginApi: 'v1' }
|
||||
])('keeps legacy or unverifiable serialized selection unset: %j', async (openCodeCapabilities) => {
|
||||
handler.addEnvAugmenter(() => ({ ORCA_OPENCODE_PLUGIN_API: 'v1' }))
|
||||
await dispatcher.callRequest('pty.revive', {
|
||||
state: JSON.stringify([
|
||||
{
|
||||
id: testPtyId(1),
|
||||
pid: process.pid,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: tmpdir(),
|
||||
...(openCodeCapabilities ? { openCodeCapabilities } : {})
|
||||
}
|
||||
])
|
||||
})
|
||||
expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBeUndefined()
|
||||
expect(mocks.probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('carries a selected API into WSL without dropping other forwarded keys', () => {
|
||||
const env = { WSLENV: 'XDG_DATA_HOME/p', ORCA_OPENCODE_PLUGIN_API: 'untrusted' }
|
||||
const envToDelete = ['ORCA_OPENCODE_PLUGIN_API', 'OTHER_ENV']
|
||||
applyOpenCodePluginSelection(env, envToDelete, getOpenCodeCliCapabilities('1.18.32'), true)
|
||||
expect(env).toMatchObject({
|
||||
ORCA_OPENCODE_PLUGIN_API: 'v1',
|
||||
WSLENV: 'XDG_DATA_HOME/p:ORCA_OPENCODE_PLUGIN_API'
|
||||
})
|
||||
expect(envToDelete).toEqual(['OTHER_ENV'])
|
||||
})
|
||||
@@ -5,6 +5,13 @@ import { restoreManagedDataAccountEnvironment } from '../shared/managed-data-acc
|
||||
import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects'
|
||||
import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature'
|
||||
import { FreebuffStatusProjection } from './freebuff-status-projection'
|
||||
import { probeOpenCodeLaunchCapabilities } from '../main/opencode/opencode-launch-capabilities'
|
||||
import type { OpenCodeCliCapabilities } from '../shared/opencode-cli-version'
|
||||
import {
|
||||
applyOpenCodePluginSelection,
|
||||
restoreOpenCodeCapabilities
|
||||
} from './opencode-plugin-selection'
|
||||
import { resolveCommandPathForRelay } from './preflight-handler'
|
||||
import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn'
|
||||
import type { IPty } from 'node-pty'
|
||||
import { killWithDescendantSweep } from '../main/pty-descendant-termination'
|
||||
@@ -218,6 +225,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest |
|
||||
}
|
||||
|
||||
type ManagedPty = {
|
||||
openCodeCapabilities?: OpenCodeCliCapabilities
|
||||
freebuffStatus?: FreebuffStatusProjection
|
||||
id: string
|
||||
incarnationId: string
|
||||
@@ -278,6 +286,7 @@ type ManagedPty = {
|
||||
}
|
||||
|
||||
type RelayAgentSessionCreateResult = {
|
||||
openCodeCapabilities?: OpenCodeCliCapabilities
|
||||
id: string
|
||||
incarnationId: string
|
||||
replay?: string
|
||||
@@ -449,6 +458,7 @@ type PtyProcessSummary = {
|
||||
}
|
||||
|
||||
type SerializedPtyEntry = {
|
||||
openCodeCapabilities?: OpenCodeCliCapabilities
|
||||
id: string
|
||||
pid: number
|
||||
cols: number
|
||||
@@ -1954,6 +1964,9 @@ export class PtyHandler {
|
||||
id: managed.id,
|
||||
incarnationId: managed.incarnationId,
|
||||
agentSessionEnsure: result,
|
||||
...(result.disposition === 'created' && managed.openCodeCapabilities
|
||||
? { openCodeCapabilities: managed.openCodeCapabilities }
|
||||
: {}),
|
||||
...(sourceActivation ? { sourceActivation } : {}),
|
||||
...(adoptedReplay ? { replay: adoptedReplay } : {}),
|
||||
...(managed.shellReadyArmed !== undefined
|
||||
@@ -1982,6 +1995,7 @@ export class PtyHandler {
|
||||
incarnationId: string
|
||||
sourceActivation?: PtySourceReceivingActivation
|
||||
shellReadyArmed?: boolean
|
||||
openCodeCapabilities?: OpenCodeCliCapabilities
|
||||
}> {
|
||||
const pty = await this.loadPty()
|
||||
if (!pty) {
|
||||
@@ -2028,6 +2042,22 @@ export class PtyHandler {
|
||||
{ id, paneKey, shell, command, launchAgent },
|
||||
envToDelete
|
||||
)
|
||||
delete spawnEnv.ORCA_OPENCODE_PLUGIN_API
|
||||
const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({
|
||||
command,
|
||||
agent: launchAgent,
|
||||
env: spawnEnv,
|
||||
cwd,
|
||||
hostIdentity: `relay:${process.platform}`,
|
||||
resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }),
|
||||
...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {})
|
||||
})
|
||||
applyOpenCodePluginSelection(
|
||||
spawnEnv,
|
||||
envToDelete,
|
||||
openCodeCapabilities,
|
||||
isRelayWslShell(shell)
|
||||
)
|
||||
await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, {
|
||||
wslShell: isRelayWslShell(shell)
|
||||
})
|
||||
@@ -2129,6 +2159,7 @@ export class PtyHandler {
|
||||
const ownerClientInstanceId =
|
||||
context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null)
|
||||
const managed: ManagedPty = {
|
||||
...(openCodeCapabilities ? { openCodeCapabilities } : {}),
|
||||
...(launchAgent === 'freebuff'
|
||||
? { freebuffStatus: new FreebuffStatusProjection(cols, rows) }
|
||||
: {}),
|
||||
@@ -2208,7 +2239,8 @@ export class PtyHandler {
|
||||
id,
|
||||
incarnationId: managed.incarnationId,
|
||||
...(sourceActivation ? { sourceActivation } : {}),
|
||||
shellReadyArmed: rendererShellReadySupported
|
||||
shellReadyArmed: rendererShellReadySupported,
|
||||
...(openCodeCapabilities ? { openCodeCapabilities } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3097,6 +3129,9 @@ export class PtyHandler {
|
||||
worktreeId: managed.worktreeId,
|
||||
...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}),
|
||||
envToDelete: managed.envToDelete,
|
||||
...(managed.openCodeCapabilities
|
||||
? { openCodeCapabilities: managed.openCodeCapabilities }
|
||||
: {}),
|
||||
gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded,
|
||||
...(managed.historyIsolationEnabled ? { historyIsolationEnabled: true } : {}),
|
||||
// Why serialized: revive re-spawns the shell, and without these a WSL
|
||||
@@ -3191,6 +3226,8 @@ export class PtyHandler {
|
||||
{ id: entry.id, paneKey: entry.paneKey, shell },
|
||||
envToDelete
|
||||
)
|
||||
const openCodeCapabilities = restoreOpenCodeCapabilities(entry.openCodeCapabilities)
|
||||
applyOpenCodePluginSelection(spawnEnv, envToDelete, openCodeCapabilities, wslShell)
|
||||
if (
|
||||
historyIsolationEnabled &&
|
||||
entry.worktreeId &&
|
||||
@@ -3268,6 +3305,7 @@ export class PtyHandler {
|
||||
...(explicitTerm !== undefined ? { explicitTerm } : {}),
|
||||
envToDelete,
|
||||
gitCredentialPromptGuarded,
|
||||
...(openCodeCapabilities ? { openCodeCapabilities } : {}),
|
||||
...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}),
|
||||
shellPath: shell,
|
||||
// Why re-stored: a revived pane can be serialized again, and losing the
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppShortcutState, ShortcutDispatchInput } from './app-command-handlers'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const target: { groupKey: string | null } = { groupKey: 'lineage:parent' }
|
||||
return {
|
||||
target,
|
||||
floatingFocused: false,
|
||||
visibleModal: false,
|
||||
requestScrollAnchor: vi.fn(),
|
||||
notifyTerminalCapture: vi.fn(),
|
||||
store: {
|
||||
activeModal: 'none',
|
||||
collapsedGroups: new Set<string>(),
|
||||
setSidebarOpen: vi.fn(),
|
||||
toggleCollapsedGroup: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useAppStore: Object.assign(vi.fn(), { getState: () => mocks.store })
|
||||
}))
|
||||
|
||||
vi.mock('../components/sidebar/child-workspaces-toggle-target', () => ({
|
||||
getRenderedLineageChipKeys: () => new Set<string>(),
|
||||
resolveChildWorkspacesToggleGroupKey: () => mocks.target.groupKey
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/requestVirtualizedScrollAnchorRecord', () => ({
|
||||
requestVirtualizedScrollAnchorRecord: mocks.requestScrollAnchor
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/floating-workspace-terminal-actions', () => ({
|
||||
isFloatingWorkspacePanelFocused: () => mocks.floatingFocused
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/visible-overlay', () => ({
|
||||
hasVisibleOverlay: () => mocks.visibleModal
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/terminal-shortcut-capture-notification', () => ({
|
||||
showTerminalShortcutCaptureNotification: mocks.notifyTerminalCapture
|
||||
}))
|
||||
|
||||
import { createAppCommandHandlers } from './app-command-handlers'
|
||||
|
||||
function shortcutState(): AppShortcutState {
|
||||
return {
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: 'parent',
|
||||
actions: {
|
||||
toggleSidebar: vi.fn(),
|
||||
toggleRightSidebar: vi.fn(),
|
||||
setRightSidebarOpen: vi.fn(),
|
||||
setRightSidebarTab: vi.fn(),
|
||||
showRightSidebarFiles: vi.fn(),
|
||||
showRightSidebarSearch: vi.fn(),
|
||||
openDiffNotesSendMenuForActiveWorktree: vi.fn()
|
||||
},
|
||||
creationLayoutActive: false,
|
||||
floatingTerminalEnabled: false,
|
||||
floatingTerminalOpen: false,
|
||||
floatingVisibleTabCount: 0,
|
||||
keybindings: {},
|
||||
openFloatingWorkspaceMaximized: vi.fn(),
|
||||
pluginCommands: [],
|
||||
setFloatingTerminalOpen: vi.fn(),
|
||||
terminalShortcutPolicy: 'orca-first',
|
||||
workspaceChromeActive: true
|
||||
}
|
||||
}
|
||||
|
||||
function shortcutInput(): ShortcutDispatchInput {
|
||||
return { target: null, defaultPrevented: false, preventDefault: vi.fn() }
|
||||
}
|
||||
|
||||
function runToggle(input: ShortcutDispatchInput): boolean | undefined {
|
||||
return createAppCommandHandlers(shortcutState(), input).get('sidebar.childWorkspaces.toggle')?.()
|
||||
}
|
||||
|
||||
describe('child workspaces toggle app command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.target.groupKey = 'lineage:parent'
|
||||
mocks.floatingFocused = false
|
||||
mocks.visibleModal = false
|
||||
mocks.store.activeModal = 'none'
|
||||
mocks.store.collapsedGroups = new Set()
|
||||
})
|
||||
|
||||
it('hides the target’s children without forcing the sidebar open', () => {
|
||||
const input = shortcutInput()
|
||||
|
||||
expect(runToggle(input)).toBe(true)
|
||||
expect(input.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(mocks.requestScrollAnchor).toHaveBeenCalledWith('[data-worktree-sidebar]')
|
||||
expect(mocks.store.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent')
|
||||
expect(mocks.store.setSidebarOpen).not.toHaveBeenCalled()
|
||||
expect(mocks.requestScrollAnchor.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.store.toggleCollapsedGroup.mock.invocationCallOrder[0] ?? 0
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the sidebar when showing hidden children', () => {
|
||||
mocks.store.collapsedGroups = new Set(['lineage:parent'])
|
||||
|
||||
expect(runToggle(shortcutInput())).toBe(true)
|
||||
expect(mocks.store.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent')
|
||||
expect(mocks.store.setSidebarOpen).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('lets the chord through when the target is in no lineage', () => {
|
||||
mocks.target.groupKey = null
|
||||
const input = shortcutInput()
|
||||
|
||||
expect(runToggle(input)).toBe(false)
|
||||
expect(input.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled()
|
||||
expect(mocks.requestScrollAnchor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a modal keep its keyboard input', () => {
|
||||
mocks.store.activeModal = 'delete-worktree'
|
||||
const input = shortcutInput()
|
||||
|
||||
expect(runToggle(input)).toBe(false)
|
||||
expect(input.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets the focused floating workspace keep its keyboard input', () => {
|
||||
mocks.floatingFocused = true
|
||||
const input = shortcutInput()
|
||||
|
||||
expect(runToggle(input)).toBe(false)
|
||||
expect(input.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves locally controlled modal input and persistence untouched', () => {
|
||||
mocks.visibleModal = true
|
||||
const input = shortcutInput()
|
||||
|
||||
expect(runToggle(input)).toBe(false)
|
||||
expect(input.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled()
|
||||
expect(mocks.requestScrollAnchor).not.toHaveBeenCalled()
|
||||
expect(mocks.store.setSidebarOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a claimed terminal shortcut through the existing notification policy', () => {
|
||||
const input = shortcutInput()
|
||||
expect(
|
||||
createAppCommandHandlers(shortcutState(), input, 'terminal').get(
|
||||
'sidebar.childWorkspaces.toggle'
|
||||
)?.()
|
||||
).toBe(true)
|
||||
expect(mocks.notifyTerminalCapture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ actionId: 'sidebar.childWorkspaces.toggle' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility'
|
||||
import { isFloatingWorkspacePanelFocused } from '@/lib/floating-workspace-terminal-actions'
|
||||
import { hasVisibleOverlay } from '@/lib/visible-overlay'
|
||||
import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to-current-workspace-status'
|
||||
import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord'
|
||||
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
|
||||
import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls'
|
||||
import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel'
|
||||
import {
|
||||
getRenderedLineageChipKeys,
|
||||
resolveChildWorkspacesToggleGroupKey
|
||||
} from '../components/sidebar/child-workspaces-toggle-target'
|
||||
import { requestTerminalTabRename } from '../components/tab-bar/terminal-tab-rename-request'
|
||||
import {
|
||||
deleteHoveredWorkspaceImmediately,
|
||||
@@ -178,6 +184,36 @@ export function createAppCommandHandlers(
|
||||
}
|
||||
})
|
||||
],
|
||||
[
|
||||
'sidebar.childWorkspaces.toggle',
|
||||
() => {
|
||||
const store = useAppStore.getState()
|
||||
// Locally controlled dialogs do not set activeModal.
|
||||
if (
|
||||
store.activeModal !== 'none' ||
|
||||
floatingWorkspaceFocused ||
|
||||
hasVisibleOverlay({ ignoreMatches: '[role="listbox"], [role="menu"]' })
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const groupKey = resolveChildWorkspacesToggleGroupKey(
|
||||
store,
|
||||
getRenderedLineageChipKeys(store)
|
||||
)
|
||||
if (!groupKey) {
|
||||
return false
|
||||
}
|
||||
return claim('sidebar.childWorkspaces.toggle', () => {
|
||||
const expanding = store.collapsedGroups.has(groupKey)
|
||||
// Why: same scroll anchoring as the chip click, so the viewport does not jump.
|
||||
requestVirtualizedScrollAnchorRecord('[data-worktree-sidebar]')
|
||||
store.toggleCollapsedGroup(groupKey)
|
||||
if (expanding) {
|
||||
store.setSidebarOpen(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
],
|
||||
[
|
||||
'floatingWorkspace.maximize',
|
||||
() => {
|
||||
|
||||
@@ -239,6 +239,12 @@ export function useGlobalKeybindings(args: {
|
||||
if (matchShortcut('workspace.delete') && handlers.get('workspace.delete')?.()) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
matchShortcut('sidebar.childWorkspaces.toggle') &&
|
||||
handlers.get('sidebar.childWorkspaces.toggle')?.()
|
||||
) {
|
||||
return
|
||||
}
|
||||
for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) {
|
||||
if (matchShortcut(actionId) && handlers.get(actionId)?.()) {
|
||||
return
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { MarkdownReviewNote } from '@/lib/markdown-review-notes'
|
||||
import type { RichMarkdownAnnotationTarget } from './rich-markdown-review-annotations'
|
||||
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
|
||||
import type { DiffComment } from '../../../../shared/diff-comment-types'
|
||||
import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus'
|
||||
|
||||
function shouldFocusEmptyEditorFromSurfaceClick(
|
||||
event: React.MouseEvent<HTMLDivElement>,
|
||||
@@ -209,6 +210,7 @@ export function RichMarkdownEditorSurface({
|
||||
// Image layout must not anchor-scroll over the restored tab position.
|
||||
className="relative h-full overflow-auto scrollbar-editor [overflow-anchor:none]"
|
||||
onMouseDown={(event) => {
|
||||
focusRichMarkdownEditorFromSearch(event.nativeEvent, editor?.view ?? null)
|
||||
if (!shouldFocusEmptyEditorFromSurfaceClick(event, editor)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { TableKit } from '@tiptap/extension-table'
|
||||
import { CellSelection } from '@tiptap/pm/tables'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus'
|
||||
|
||||
function findTextPosition(editor: Editor, text: string): number {
|
||||
let position: number | null = null
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text === text) {
|
||||
position = pos
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (position === null) {
|
||||
throw new Error(`Expected editor text: ${text}`)
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
function createSurface(content: string) {
|
||||
const root = document.createElement('div')
|
||||
root.className = 'rich-markdown-editor-shell'
|
||||
document.body.append(root)
|
||||
const editor = new Editor({ extensions: [StarterKit, TableKit], content })
|
||||
root.append(editor.view.dom)
|
||||
const search = document.createElement('div')
|
||||
search.className = 'rich-markdown-search'
|
||||
const input = document.createElement('input')
|
||||
search.append(input)
|
||||
root.append(search)
|
||||
root.addEventListener('mousedown', (event) => {
|
||||
if (event instanceof MouseEvent) {
|
||||
focusRichMarkdownEditorFromSearch(event, editor.view)
|
||||
}
|
||||
})
|
||||
return { editor, input }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('Find focus with editor-owned selection', () => {
|
||||
it('restores the remembered caret before Shift click when Find owns the native selection', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, input } = createSurface('<p>first</p><p>lower caret</p>')
|
||||
const caret = findTextPosition(editor, 'lower caret') + 6
|
||||
const updates = vi.fn()
|
||||
editor.on('update', updates)
|
||||
try {
|
||||
editor.commands.setTextSelection(caret)
|
||||
input.focus()
|
||||
document.getSelection()?.removeAllRanges()
|
||||
const paragraph = editor.view.dom.querySelector('p:last-child')
|
||||
const text = paragraph?.firstChild
|
||||
if (!paragraph || !text) {
|
||||
throw new Error('Expected lower paragraph text')
|
||||
}
|
||||
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: caret, inside: -1 })
|
||||
const originalDoc = editor.state.doc
|
||||
const selection = editor.state.selection
|
||||
const event = new MouseEvent('mousedown', {
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
paragraph.dispatchEvent(event)
|
||||
|
||||
expect(document.activeElement).toBe(editor.view.dom)
|
||||
expect(document.getSelection()?.anchorNode).toBe(text)
|
||||
expect(document.getSelection()?.anchorOffset).toBe(6)
|
||||
expect(document.getSelection()?.focusOffset).toBe(6)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(20)
|
||||
|
||||
expect(editor.state.selection).toBe(selection)
|
||||
expect(editor.state.doc).toBe(originalDoc)
|
||||
expect(updates).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('focuses a handled cross-cell selection and preserves its cells without marking content dirty', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, input } = createSurface(
|
||||
'<table><tbody><tr><td>first</td><td>second</td></tr></tbody></table>'
|
||||
)
|
||||
const updates = vi.fn()
|
||||
try {
|
||||
editor.commands.setTextSelection(findTextPosition(editor, 'first'))
|
||||
editor.on('update', updates)
|
||||
input.focus()
|
||||
const secondPos = findTextPosition(editor, 'second')
|
||||
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: secondPos, inside: secondPos })
|
||||
const second = editor.view.dom.querySelector('td:last-child p')
|
||||
if (!second) {
|
||||
throw new Error('Expected second table cell')
|
||||
}
|
||||
const originalDoc = editor.state.doc
|
||||
const event = new MouseEvent('mousedown', {
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
second.dispatchEvent(event)
|
||||
|
||||
const selection = editor.state.selection
|
||||
expect(selection).toBeInstanceOf(CellSelection)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(document.activeElement).toBe(editor.view.dom)
|
||||
expect(editor.view.dom.querySelectorAll('.selectedCell')).toHaveLength(2)
|
||||
expect(editor.view.dom.classList.contains('ProseMirror-hideselection')).toBe(true)
|
||||
expect(document.getSelection()?.rangeCount).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(20)
|
||||
|
||||
expect(editor.state.selection).toBe(selection)
|
||||
expect(editor.state.doc).toBe(originalDoc)
|
||||
expect(updates).not.toHaveBeenCalled()
|
||||
expect(editor.view.dom.querySelectorAll('.selectedCell')).toHaveLength(2)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { Schema } from '@tiptap/pm/model'
|
||||
import { EditorState } from '@tiptap/pm/state'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus'
|
||||
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'paragraph+' },
|
||||
paragraph: { content: 'text*' },
|
||||
text: {}
|
||||
}
|
||||
})
|
||||
|
||||
function createSurface() {
|
||||
const root = document.createElement('div')
|
||||
root.className = 'rich-markdown-editor-shell'
|
||||
const editorDom = document.createElement('div')
|
||||
editorDom.contentEditable = 'true'
|
||||
editorDom.tabIndex = -1
|
||||
const paragraph = document.createElement('p')
|
||||
paragraph.textContent = 'First editable paragraph'
|
||||
editorDom.append(paragraph)
|
||||
const search = document.createElement('div')
|
||||
search.className = 'rich-markdown-search'
|
||||
const findInput = document.createElement('input')
|
||||
const replaceInput = document.createElement('input')
|
||||
search.append(findInput, replaceInput)
|
||||
root.append(editorDom, search)
|
||||
document.body.append(root)
|
||||
const focus = vi.spyOn(editorDom, 'focus')
|
||||
const viewFocus = vi.fn(() => editorDom.focus({ preventScroll: true }))
|
||||
const view = { dom: editorDom, focus: viewFocus, state: EditorState.create({ schema }) }
|
||||
root.addEventListener('mousedown', (event) => {
|
||||
if (event instanceof MouseEvent) {
|
||||
focusRichMarkdownEditorFromSearch(event, view)
|
||||
}
|
||||
})
|
||||
return { root, editorDom, paragraph, findInput, replaceInput, focus, viewFocus }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('rich markdown search focus handoff', () => {
|
||||
it.each(['find', 'replace'] as const)(
|
||||
'returns keyboard focus from %s without preventing native selection or requesting scroll',
|
||||
(field) => {
|
||||
const { paragraph, editorDom, findInput, replaceInput, focus, viewFocus } = createSurface()
|
||||
const input = field === 'find' ? findInput : replaceInput
|
||||
input.focus()
|
||||
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })
|
||||
|
||||
paragraph.dispatchEvent(event)
|
||||
|
||||
expect(document.activeElement).toBe(editorDom)
|
||||
expect(focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true })
|
||||
expect(viewFocus).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('restores the editor selection before native Shift click handling', () => {
|
||||
const { paragraph, editorDom, findInput, viewFocus } = createSurface()
|
||||
findInput.focus()
|
||||
const event = new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
paragraph.dispatchEvent(event)
|
||||
|
||||
expect(document.activeElement).toBe(editorDom)
|
||||
expect(viewFocus).toHaveBeenCalledExactlyOnceWith()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['button', 'input', 'textarea', 'select', 'noneditable', 'task-label'])(
|
||||
'leaves embedded %s controls in charge of focus',
|
||||
(kind) => {
|
||||
const { editorDom, findInput, focus } = createSurface()
|
||||
const control = document.createElement(
|
||||
kind === 'noneditable' ? 'div' : kind === 'task-label' ? 'label' : kind
|
||||
)
|
||||
if (kind === 'noneditable' || kind === 'task-label') {
|
||||
control.setAttribute('contenteditable', 'false')
|
||||
}
|
||||
const child = document.createElement('span')
|
||||
control.append(child)
|
||||
editorDom.append(control)
|
||||
findInput.focus()
|
||||
|
||||
child.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
|
||||
expect(document.activeElement).toBe(findInput)
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not steal focus from a different editor find widget or unrelated field', () => {
|
||||
const first = createSurface()
|
||||
const second = createSurface()
|
||||
const unrelated = document.createElement('input')
|
||||
document.body.append(unrelated)
|
||||
|
||||
for (const input of [second.findInput, unrelated]) {
|
||||
input.focus()
|
||||
first.paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
expect(document.activeElement).toBe(input)
|
||||
}
|
||||
|
||||
expect(first.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves ordinary editor clicks and blank surface clicks untouched', () => {
|
||||
const { root, editorDom, paragraph, findInput, focus } = createSurface()
|
||||
editorDom.focus()
|
||||
focus.mockClear()
|
||||
paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
findInput.focus()
|
||||
root.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(findInput)
|
||||
})
|
||||
|
||||
it('leaves canceled and non-primary presses untouched', () => {
|
||||
const { paragraph, findInput, focus } = createSurface()
|
||||
findInput.focus()
|
||||
const canceled = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })
|
||||
canceled.preventDefault()
|
||||
|
||||
for (const event of [
|
||||
canceled,
|
||||
new MouseEvent('mousedown', { bubbles: true, button: 1 }),
|
||||
new MouseEvent('mousedown', { bubbles: true, button: 2 })
|
||||
]) {
|
||||
paragraph.dispatchEvent(event)
|
||||
}
|
||||
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(findInput)
|
||||
})
|
||||
|
||||
it('allows editable content nested inside an unrelated noneditable ancestor', () => {
|
||||
const { root, paragraph, findInput, focus } = createSurface()
|
||||
root.setAttribute('contenteditable', 'false')
|
||||
findInput.focus()
|
||||
|
||||
paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
|
||||
expect(focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true })
|
||||
})
|
||||
|
||||
it('does nothing when the editor has not mounted', () => {
|
||||
focusRichMarkdownEditorFromSearch(new MouseEvent('mousedown', { button: 0 }), null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { CellSelection } from '@tiptap/pm/tables'
|
||||
|
||||
export function focusRichMarkdownEditorFromSearch(
|
||||
event: MouseEvent,
|
||||
view: Pick<EditorView, 'dom' | 'focus' | 'state'> | null
|
||||
): void {
|
||||
if (
|
||||
!view ||
|
||||
event.button !== 0 ||
|
||||
(event.defaultPrevented && !(view.state.selection instanceof CellSelection))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const editorDom = view.dom
|
||||
const target = event.target
|
||||
if (!(target instanceof Element) || !editorDom.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
const root = editorDom.closest('.rich-markdown-editor-shell')
|
||||
const activeElement = editorDom.ownerDocument.activeElement
|
||||
if (
|
||||
!root ||
|
||||
!activeElement?.closest('.rich-markdown-search') ||
|
||||
activeElement.closest('.rich-markdown-editor-shell') !== root
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const control = target.closest('button, input, textarea, select, [contenteditable="false"]')
|
||||
if (control && editorDom.contains(control)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey || event.defaultPrevented) {
|
||||
// Shift extends the current selection; handled cell selection has no browser default.
|
||||
view.focus()
|
||||
} else {
|
||||
// Native focus preserves the browser's upcoming click or drag selection.
|
||||
editorDom.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { StrictMode, useLayoutEffect, type ReactNode } from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (select: (state: unknown) => unknown) => select({ keybindings: {} })
|
||||
}))
|
||||
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
function mountSearch(content = '<p>beta beta</p><p>Edit here</p>', strict = false) {
|
||||
vi.useFakeTimers()
|
||||
const editor = new Editor({ extensions: [StarterKit], content })
|
||||
const root = document.createElement('div')
|
||||
root.className = 'rich-markdown-editor-shell'
|
||||
const searchBar = document.createElement('div')
|
||||
searchBar.className = 'rich-markdown-search'
|
||||
const input = document.createElement('input')
|
||||
const otherControl = document.createElement('input')
|
||||
searchBar.append(input)
|
||||
root.append(editor.view.dom, searchBar)
|
||||
document.body.append(root, otherControl)
|
||||
const scrollContainer = document.createElement('div')
|
||||
const rootRef = { current: root }
|
||||
const scrollContainerRef = { current: scrollContainer }
|
||||
const beforePassiveRef: { current: (() => void) | null } = { current: null }
|
||||
const scrollTo = vi.spyOn(scrollContainer, 'scrollTo')
|
||||
vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ top: 20, bottom: 40, left: 0, right: 0 })
|
||||
const initialProps: { currentEditor: Editor | null } = { currentEditor: editor }
|
||||
const hook = renderHook(
|
||||
({ currentEditor }: { currentEditor: Editor | null }) => {
|
||||
const result = useRichMarkdownSearch({ editor: currentEditor, rootRef, scrollContainerRef })
|
||||
result.searchState.searchInputRef.current = input
|
||||
useLayoutEffect(() => {
|
||||
const callback = beforePassiveRef.current
|
||||
beforePassiveRef.current = null
|
||||
callback?.()
|
||||
})
|
||||
return result
|
||||
},
|
||||
{
|
||||
initialProps,
|
||||
wrapper: strict
|
||||
? ({ children }: { children: ReactNode }) => <StrictMode>{children}</StrictMode>
|
||||
: undefined
|
||||
}
|
||||
)
|
||||
cleanups.push(() => {
|
||||
hook.unmount()
|
||||
editor.destroy()
|
||||
root.remove()
|
||||
otherControl.remove()
|
||||
})
|
||||
act(() => hook.result.current.openSearch())
|
||||
const query = (value: string) => {
|
||||
act(() => hook.result.current.searchActions.setSearchQuery(value))
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
}
|
||||
const selectedText = () =>
|
||||
editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)
|
||||
const documentCaret = () => {
|
||||
act(() => {
|
||||
editor.view.dom.focus({ preventScroll: true })
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
|
||||
})
|
||||
}
|
||||
return {
|
||||
editor,
|
||||
root,
|
||||
hook,
|
||||
input,
|
||||
otherControl,
|
||||
scrollTo,
|
||||
query,
|
||||
selectedText,
|
||||
documentCaret,
|
||||
beforePassiveRef
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length > 0) {
|
||||
cleanups.pop()?.()
|
||||
}
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('rich Markdown search adversarial interactions', () => {
|
||||
it('does not revive a delayed search after a document click and another control focus', () => {
|
||||
const { editor, hook, otherControl, query, documentCaret, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('Edit'))
|
||||
documentCaret()
|
||||
const selection = editor.state.selection
|
||||
act(() => otherControl.focus())
|
||||
scrollTo.mockClear()
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
expect(editor.state.selection.eq(selection)).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(otherControl)
|
||||
})
|
||||
|
||||
it('continues typing at the document caret through case and whole-word changes', () => {
|
||||
const { editor, hook, query, documentCaret, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
documentCaret()
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.toggleMatchCase())
|
||||
act(() => hook.result.current.searchActions.toggleWholeWord())
|
||||
act(() => editor.commands.insertContent('XY'))
|
||||
expect(editor.getText()).toBe('beta beta\n\nEdit hereXY')
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets explicit search navigation select the only match after document editing', () => {
|
||||
const { editor, hook, query, documentCaret, selectedText, scrollTo } = mountSearch(
|
||||
'<p>beta</p><p>Edit here</p>'
|
||||
)
|
||||
query('beta')
|
||||
documentCaret()
|
||||
act(() => editor.commands.insertContent('X'))
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
expect(selectedText()).toBe('beta')
|
||||
expect(scrollTo).toHaveBeenCalledOnce()
|
||||
expect(editor.isFocused).toBe(true)
|
||||
})
|
||||
|
||||
it('advances past replacement text which still contains the search query', () => {
|
||||
const { editor, hook, query, selectedText } = mountSearch()
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.setReplaceQuery('betaX'))
|
||||
act(() => hook.result.current.searchActions.replaceCurrentMatch())
|
||||
expect(editor.getText()).toBe('betaX beta\n\nEdit here')
|
||||
expect(selectedText()).toBe('beta')
|
||||
expect(editor.state.selection.from).toBe(7)
|
||||
act(() => hook.result.current.searchActions.replaceCurrentMatch())
|
||||
expect(editor.getText()).toBe('betaX betaX\n\nEdit here')
|
||||
})
|
||||
|
||||
it('does not move the document caret when Replace All retains matching text', () => {
|
||||
const { editor, hook, query, documentCaret, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.setReplaceQuery('betaX'))
|
||||
documentCaret()
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.replaceAllMatches())
|
||||
expect(editor.getText()).toBe('betaX betaX\n\nEdit here')
|
||||
expect(editor.state.selection.empty).toBe(true)
|
||||
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clamps navigation after external edits remove the active match', () => {
|
||||
const { editor, hook, query, selectedText } = mountSearch('<p>beta beta beta</p>')
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
act(() => editor.commands.setContent('<p>beta</p>'))
|
||||
expect(hook.result.current.searchState.activeMatchIndex).toBe(0)
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
expect(selectedText()).toBe('beta')
|
||||
})
|
||||
|
||||
it('keeps decorations fresh without moving selection through batched document edits', () => {
|
||||
const { editor, hook, query, documentCaret, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
documentCaret()
|
||||
scrollTo.mockClear()
|
||||
act(() => {
|
||||
editor.commands.insertContent(' beta')
|
||||
editor.commands.insertContent(' beta')
|
||||
})
|
||||
expect(hook.result.current.searchState.matchCount).toBe(4)
|
||||
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1)
|
||||
expect(editor.state.selection.empty).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dispatch stale match positions when the document changes before passive effects', () => {
|
||||
const { editor, hook, beforePassiveRef } = mountSearch('<p>beta</p><p>target</p>')
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('target'))
|
||||
beforePassiveRef.current = () => editor.commands.setContent('<p>x</p>')
|
||||
expect(() => act(() => vi.advanceTimersByTime(150))).not.toThrow()
|
||||
expect(editor.getText()).toBe('x')
|
||||
expect(hook.result.current.searchState.matchCount).toBe(0)
|
||||
})
|
||||
|
||||
it('cancels a pending query when the input is cleared', () => {
|
||||
const { editor, hook, query, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
const selection = editor.state.selection
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('Edit'))
|
||||
act(() => hook.result.current.searchActions.setSearchQuery(''))
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(0)
|
||||
expect(editor.state.selection.eq(selection)).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses refreshed positions when the document changes before search navigation commits', () => {
|
||||
const { editor, hook, beforePassiveRef, selectedText, scrollTo } = mountSearch(
|
||||
'<p>beta</p><p>target</p>'
|
||||
)
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('target'))
|
||||
beforePassiveRef.current = () => editor.commands.setContent('<p>prefix prefix target</p>')
|
||||
scrollTo.mockClear()
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(1)
|
||||
expect(selectedText()).toBe('target')
|
||||
expect(editor.state.selection.from).toBe(15)
|
||||
expect(scrollTo).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('moves Next relative to the clamped active match after several matches disappear', () => {
|
||||
const { editor, hook, query } = mountSearch('<p>beta beta beta beta beta</p>')
|
||||
query('beta')
|
||||
for (let index = 0; index < 4; index++) {
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
}
|
||||
expect(hook.result.current.searchState.activeMatchIndex).toBe(4)
|
||||
act(() => editor.commands.setContent('<p>beta beta beta</p>'))
|
||||
expect(hook.result.current.searchState.activeMatchIndex).toBe(0)
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
expect(hook.result.current.searchState.activeMatchIndex).toBe(1)
|
||||
expect(editor.state.selection.from).toBe(6)
|
||||
})
|
||||
|
||||
it('does not navigate to an old query when replacing during the debounce window', () => {
|
||||
const { editor, hook, query, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('Edit'))
|
||||
act(() => hook.result.current.searchActions.setReplaceQuery('changed'))
|
||||
act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1))
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.replaceCurrentMatch())
|
||||
expect(editor.getText()).toBe('beta beta\n\nchanged here')
|
||||
expect(editor.state.selection.empty).toBe(true)
|
||||
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('survives strict effect replay, null editor, and a fresh editor instance', () => {
|
||||
const { editor, hook, input, query, documentCaret, scrollTo } = mountSearch(undefined, true)
|
||||
query('beta')
|
||||
documentCaret()
|
||||
act(() => editor.commands.insertContent('X'))
|
||||
act(() => input.focus())
|
||||
act(() => hook.rerender({ currentEditor: null }))
|
||||
const replacement = new Editor({ extensions: [StarterKit], content: '<p>beta</p>' })
|
||||
vi.spyOn(replacement.view, 'coordsAtPos').mockReturnValue({
|
||||
top: 20,
|
||||
bottom: 40,
|
||||
left: 0,
|
||||
right: 0
|
||||
})
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.rerender({ currentEditor: replacement }))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(1)
|
||||
expect(
|
||||
replacement.state.doc.textBetween(
|
||||
replacement.state.selection.from,
|
||||
replacement.state.selection.to
|
||||
)
|
||||
).toBe('beta')
|
||||
expect(scrollTo).toHaveBeenCalledOnce()
|
||||
act(() => hook.rerender({ currentEditor: null }))
|
||||
replacement.destroy()
|
||||
})
|
||||
|
||||
it('survives a debounce completing after its editor has been destroyed', () => {
|
||||
const { editor, hook } = mountSearch()
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('beta'))
|
||||
act(() => editor.destroy())
|
||||
expect(() => act(() => vi.advanceTimersByTime(150))).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not replay an old Next request into a replacement editor after focus leaves Find', () => {
|
||||
const { editor, hook, root, query, documentCaret, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
documentCaret()
|
||||
act(() => hook.rerender({ currentEditor: null }))
|
||||
editor.view.dom.remove()
|
||||
const replacement = new Editor({ extensions: [StarterKit], content: '<p>beta</p>' })
|
||||
root.prepend(replacement.view.dom)
|
||||
vi.spyOn(replacement.view, 'coordsAtPos').mockReturnValue({
|
||||
top: 20,
|
||||
bottom: 40,
|
||||
left: 0,
|
||||
right: 0
|
||||
})
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.rerender({ currentEditor: replacement }))
|
||||
expect(replacement.state.selection.empty).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
act(() => hook.rerender({ currentEditor: null }))
|
||||
replacement.destroy()
|
||||
})
|
||||
|
||||
it('survives the editor being destroyed by a selection-update listener', () => {
|
||||
const { editor, query } = mountSearch()
|
||||
editor.on('selectionUpdate', () => editor.destroy())
|
||||
expect(() => query('beta')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (select: (state: unknown) => unknown) => select({ keybindings: {} })
|
||||
}))
|
||||
|
||||
function mountSearch() {
|
||||
const editor = new Editor({
|
||||
extensions: [StarterKit],
|
||||
content: '<p>beta beta</p><p>Edit here</p>'
|
||||
})
|
||||
const scrollContainer = document.createElement('div')
|
||||
const scrollTo = vi.spyOn(scrollContainer, 'scrollTo')
|
||||
vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ top: 20, bottom: 40, left: 0, right: 0 })
|
||||
const root = document.createElement('div')
|
||||
root.className = 'rich-markdown-editor-shell'
|
||||
const search = document.createElement('div')
|
||||
search.className = 'rich-markdown-search'
|
||||
const input = document.createElement('input')
|
||||
search.append(input)
|
||||
root.append(editor.view.dom)
|
||||
root.append(search)
|
||||
document.body.append(root)
|
||||
const hook = renderHook(() => {
|
||||
const result = useRichMarkdownSearch({
|
||||
editor,
|
||||
rootRef: { current: root },
|
||||
scrollContainerRef: { current: scrollContainer }
|
||||
})
|
||||
result.searchState.searchInputRef.current = input
|
||||
return result
|
||||
})
|
||||
act(() => hook.result.current.openSearch())
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('beta'))
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
const dispose = () => {
|
||||
hook.unmount()
|
||||
editor.destroy()
|
||||
root.remove()
|
||||
}
|
||||
return { editor, hook, scrollTo, dispose }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('rich markdown editing with Find open', () => {
|
||||
it('updates highlights without moving the typing caret or scrolling to a match', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, hook, scrollTo, dispose } = mountSearch()
|
||||
try {
|
||||
const insertion = editor.state.doc.content.size - 1
|
||||
act(() => editor.commands.setTextSelection(insertion))
|
||||
scrollTo.mockClear()
|
||||
act(() => editor.commands.insertContent('X'))
|
||||
act(() => editor.commands.insertContent('Y'))
|
||||
expect(editor.getText()).toBe('beta beta\n\nEdit hereXY')
|
||||
expect(editor.state.selection.from).toBe(insertion + 2)
|
||||
expect(editor.state.selection.empty).toBe(true)
|
||||
expect(hook.result.current.searchState.matchCount).toBe(2)
|
||||
expect(hook.result.current.searchState.isSearchOpen).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
act(() => editor.commands.undo())
|
||||
expect(editor.getText()).toBe('beta beta\n\nEdit here')
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves selection when edits remove every match and later restore one', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, hook, scrollTo, dispose } = mountSearch()
|
||||
try {
|
||||
scrollTo.mockClear()
|
||||
act(() => editor.commands.setContent('<p>No results</p><p>End</p>'))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(0)
|
||||
act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1))
|
||||
act(() => editor.commands.insertContent(' beta'))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(1)
|
||||
expect(editor.state.selection.empty).toBe(true)
|
||||
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('still navigates on Next with only one match, and advances after Replace', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, hook, scrollTo, dispose } = mountSearch()
|
||||
try {
|
||||
act(() => hook.result.current.searchActions.setReplaceQuery('changed'))
|
||||
act(() => hook.result.current.searchActions.replaceCurrentMatch())
|
||||
expect(editor.getText()).toBe('changed beta\n\nEdit here')
|
||||
expect(hook.result.current.searchState.matchCount).toBe(1)
|
||||
expect(
|
||||
editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)
|
||||
).toBe('beta')
|
||||
act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1))
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
expect(
|
||||
editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)
|
||||
).toBe('beta')
|
||||
expect(scrollTo).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not apply delayed search navigation after focus returns to the document', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, hook, scrollTo, dispose } = mountSearch()
|
||||
try {
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('Edit'))
|
||||
act(() => {
|
||||
editor.view.dom.focus({ preventScroll: true })
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
|
||||
})
|
||||
expect(editor.isFocused).toBe(true)
|
||||
const selection = editor.state.selection
|
||||
scrollTo.mockClear()
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(1)
|
||||
expect(editor.state.selection.eq(selection)).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
richMarkdownSearchPluginKey
|
||||
} from './rich-markdown-search'
|
||||
import { createRichMarkdownSearchMatchesCache } from './rich-markdown-search-matches-cache'
|
||||
import { useRichMarkdownSearchHighlights } from './useRichMarkdownSearchHighlights'
|
||||
|
||||
export function useRichMarkdownSearch({
|
||||
editor,
|
||||
@@ -42,7 +42,8 @@ export function useRichMarkdownSearch({
|
||||
const [matchCase, setMatchCase] = useState(false)
|
||||
const [wholeWord, setWholeWord] = useState(false)
|
||||
const [rawActiveMatchIndex, setRawActiveMatchIndex] = useState(-1)
|
||||
const [searchRevision, setSearchRevision] = useState(0)
|
||||
const [navigationRequest, setNavigationRequest] = useState({ revision: 0, selectMatch: false })
|
||||
const [, setSearchRevision] = useState(0)
|
||||
// Why: debouncing the query that drives match computation prevents the
|
||||
// expensive full-doc walk from running on every keystroke — the old
|
||||
// un-debounced path froze the main thread on large documents.
|
||||
@@ -59,24 +60,24 @@ export function useRichMarkdownSearch({
|
||||
const searchRequestQuery = isMarkdownPreviewSearchQueryTooLarge(debouncedQuery)
|
||||
? ''
|
||||
: debouncedQuery
|
||||
const searchDocument = editor && !editor.isDestroyed ? editor.state.doc : null
|
||||
|
||||
const matches = useMemo(() => {
|
||||
if (!editor || !isSearchOpen || !searchRequestQuery) {
|
||||
if (!searchDocument || !isSearchOpen || !searchRequestQuery) {
|
||||
return []
|
||||
}
|
||||
return findMatches(editor.state.doc, searchRequestQuery, {
|
||||
return findMatches(searchDocument, searchRequestQuery, {
|
||||
matchCase,
|
||||
wholeWord
|
||||
})
|
||||
// searchRevision is bumped on ProseMirror doc edits to trigger recomputation
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor, findMatches, isSearchOpen, searchRequestQuery, searchRevision, matchCase, wholeWord])
|
||||
}, [findMatches, isSearchOpen, searchRequestQuery, searchDocument, matchCase, wholeWord])
|
||||
|
||||
const matchCount = matches.length
|
||||
|
||||
const getLiveMatches = useCallback(() => {
|
||||
if (
|
||||
!editor ||
|
||||
editor.isDestroyed ||
|
||||
!isSearchOpen ||
|
||||
!searchQuery ||
|
||||
isMarkdownPreviewSearchQueryTooLarge(searchQuery)
|
||||
@@ -175,10 +176,14 @@ export function useRichMarkdownSearch({
|
||||
if (!match || liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)) {
|
||||
return
|
||||
}
|
||||
// Why: removing the active match shifts the next match into the same index,
|
||||
// so leaving rawActiveMatchIndex untouched advances to it after recompute.
|
||||
replaceRange(match.from, match.to)
|
||||
}, [activeMatchIndex, getLiveMatches, replaceRange])
|
||||
// Skip matches inside the replacement, including when it still contains the query.
|
||||
const replacementEnd = match.from + replaceQuery.length
|
||||
const nextIndex = getLiveMatches().findIndex((candidate) => candidate.from >= replacementEnd)
|
||||
setRawActiveMatchIndex(Math.max(0, nextIndex))
|
||||
setDebouncedQuery(searchQuery)
|
||||
setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: true }))
|
||||
}, [activeMatchIndex, getLiveMatches, replaceQuery, replaceRange, searchQuery])
|
||||
|
||||
const replaceAllMatches = useCallback(() => {
|
||||
if (!editor) {
|
||||
@@ -203,24 +208,25 @@ export function useRichMarkdownSearch({
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr)
|
||||
}, [editor, getLiveMatches, replaceQuery])
|
||||
setDebouncedQuery(searchQuery)
|
||||
setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: false }))
|
||||
}, [editor, getLiveMatches, replaceQuery, searchQuery])
|
||||
|
||||
const moveToMatch = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
if (matchCount === 0) {
|
||||
const liveMatchCount = getLiveMatches().length
|
||||
if (liveMatchCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: rawActiveMatchIndex starts at -1 before the user navigates, but the
|
||||
// derived activeMatchIndex is already 0 (first match shown). Using 0 as the
|
||||
// base when raw is -1 ensures the first Enter press advances to match 1
|
||||
// instead of computing (-1+1)%N = 0 and leaving the effect unchanged.
|
||||
setRawActiveMatchIndex((currentIndex) => {
|
||||
const baseIndex = Math.max(currentIndex, 0)
|
||||
return (baseIndex + direction + matchCount) % matchCount
|
||||
const baseIndex = currentIndex >= 0 && currentIndex < liveMatchCount ? currentIndex : 0
|
||||
return (baseIndex + direction + liveMatchCount) % liveMatchCount
|
||||
})
|
||||
setDebouncedQuery(searchQuery)
|
||||
setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: true }))
|
||||
},
|
||||
[matchCount]
|
||||
[getLiveMatches, searchQuery]
|
||||
)
|
||||
|
||||
const handleEditorUpdate = useCallback(() => {
|
||||
@@ -259,52 +265,18 @@ export function useRichMarkdownSearch({
|
||||
searchInputRef.current?.select()
|
||||
}, [isSearchOpen])
|
||||
|
||||
// Why: single effect to sync search state to ProseMirror. The old two-effect
|
||||
// chain (compute matches → set state → dispatch) caused an extra render cycle
|
||||
// and called findRichMarkdownSearchMatches twice per change.
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const query = isSearchOpen ? searchRequestQuery : ''
|
||||
|
||||
// Why: combining decoration meta and selection+scrollIntoView into one
|
||||
// transaction avoids a split-dispatch where the first dispatch updates
|
||||
// editor.state and the second dispatch's scrollIntoView can be lost
|
||||
// when ProseMirror coalesces view updates.
|
||||
// Why: passing pre-computed matches avoids the plugin re-walking the
|
||||
// entire document — the old double-walk froze the UI on large files.
|
||||
const tr = editor.state.tr
|
||||
tr.setMeta(richMarkdownSearchPluginKey, {
|
||||
activeIndex: activeMatchIndex,
|
||||
matches,
|
||||
query
|
||||
})
|
||||
|
||||
const activeMatch = query && activeMatchIndex >= 0 ? matches[activeMatchIndex] : null
|
||||
if (activeMatch) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to))
|
||||
}
|
||||
|
||||
editor.view.dispatch(tr)
|
||||
|
||||
// Why: ProseMirror's tr.scrollIntoView() delegates to the view's
|
||||
// scrollDOMIntoView which may fail to reach the outer flex scroll container
|
||||
// (the editor element itself has min-height: 100% and no overflow).
|
||||
// Reading coordsAtPos *after* the dispatch and manually scrolling the
|
||||
// container mirrors the approach used by MarkdownPreview search.
|
||||
if (activeMatch) {
|
||||
const container = scrollContainerRef.current
|
||||
if (container) {
|
||||
const coords = editor.view.coordsAtPos(activeMatch.from)
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const relativeTop = coords.top - containerRect.top
|
||||
const targetScroll = container.scrollTop + relativeTop - containerRect.height / 2
|
||||
container.scrollTo({ top: targetScroll, behavior: 'instant' })
|
||||
}
|
||||
}
|
||||
}, [activeMatchIndex, searchRequestQuery, editor, isSearchOpen, matches, scrollContainerRef])
|
||||
useRichMarkdownSearchHighlights({
|
||||
activeMatchIndex,
|
||||
editor,
|
||||
matchCase,
|
||||
matches,
|
||||
navigationRequest,
|
||||
query: isSearchOpen ? searchRequestQuery : '',
|
||||
scrollContainerRef,
|
||||
wholeWord,
|
||||
rootRef,
|
||||
searchDocument
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import { richMarkdownSearchPluginKey, type RichMarkdownSearchMatch } from './rich-markdown-search'
|
||||
|
||||
type SearchNavigation = {
|
||||
editor: Editor
|
||||
query: string
|
||||
matchCase: boolean
|
||||
wholeWord: boolean
|
||||
navigationRequest: { revision: number; selectMatch: boolean }
|
||||
}
|
||||
|
||||
export function useRichMarkdownSearchHighlights({
|
||||
activeMatchIndex,
|
||||
editor,
|
||||
matchCase,
|
||||
matches,
|
||||
navigationRequest,
|
||||
query,
|
||||
rootRef,
|
||||
searchDocument,
|
||||
scrollContainerRef,
|
||||
wholeWord
|
||||
}: {
|
||||
activeMatchIndex: number
|
||||
editor: Editor | null
|
||||
matchCase: boolean
|
||||
matches: RichMarkdownSearchMatch[]
|
||||
navigationRequest: SearchNavigation['navigationRequest']
|
||||
query: string
|
||||
rootRef: RefObject<HTMLDivElement | null>
|
||||
searchDocument: ProseMirrorNode | null
|
||||
scrollContainerRef: RefObject<HTMLDivElement | null>
|
||||
wholeWord: boolean
|
||||
}): void {
|
||||
const lastNavigationRef = useRef<SearchNavigation | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
lastNavigationRef.current = null
|
||||
return
|
||||
}
|
||||
// A newer editor transaction can arrive between render and this effect.
|
||||
if (editor.state.doc !== searchDocument) {
|
||||
return
|
||||
}
|
||||
const previous = lastNavigationRef.current
|
||||
const searchChanged =
|
||||
previous?.editor !== editor ||
|
||||
previous.query !== query ||
|
||||
previous.matchCase !== matchCase ||
|
||||
previous.wholeWord !== wholeWord
|
||||
const navigationRequested =
|
||||
previous?.editor === editor && previous.navigationRequest !== navigationRequest
|
||||
lastNavigationRef.current = { editor, query, matchCase, wholeWord, navigationRequest }
|
||||
|
||||
// Refreshing matches after an edit must preserve the user's caret and viewport.
|
||||
const activeElement = rootRef.current?.ownerDocument.activeElement
|
||||
const searchOwnsFocus =
|
||||
activeElement &&
|
||||
rootRef.current?.contains(activeElement) &&
|
||||
activeElement.closest('.rich-markdown-search')
|
||||
const shouldNavigate = navigationRequested
|
||||
? navigationRequest.selectMatch
|
||||
: searchChanged && searchOwnsFocus
|
||||
const activeMatch =
|
||||
shouldNavigate && query && activeMatchIndex >= 0 ? matches[activeMatchIndex] : null
|
||||
const tr = editor.state.tr.setMeta(richMarkdownSearchPluginKey, {
|
||||
activeIndex: activeMatchIndex,
|
||||
matches,
|
||||
query
|
||||
})
|
||||
if (activeMatch) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to))
|
||||
}
|
||||
editor.view.dispatch(tr)
|
||||
if (editor.isDestroyed || editor.state.doc !== tr.doc) {
|
||||
return
|
||||
}
|
||||
|
||||
// The editor's scrollIntoView does not reliably reach the outer flex viewport.
|
||||
const container = scrollContainerRef.current
|
||||
if (activeMatch && container) {
|
||||
const coords = editor.view.coordsAtPos(activeMatch.from)
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const relativeTop = coords.top - containerRect.top
|
||||
const targetScroll = container.scrollTop + relativeTop - containerRect.height / 2
|
||||
container.scrollTo({ top: targetScroll, behavior: 'instant' })
|
||||
}
|
||||
}, [
|
||||
activeMatchIndex,
|
||||
editor,
|
||||
matchCase,
|
||||
matches,
|
||||
navigationRequest,
|
||||
query,
|
||||
rootRef,
|
||||
searchDocument,
|
||||
scrollContainerRef,
|
||||
wholeWord
|
||||
])
|
||||
}
|
||||
+35
-38
@@ -10,7 +10,9 @@ import {
|
||||
import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page-github-dialog-state-authority'
|
||||
import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
|
||||
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
|
||||
import type { GitHubPatchWorkItemOptions } from '@/store/github/cache-model'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types'
|
||||
|
||||
@@ -28,6 +30,7 @@ type GHEditMutationBase = {
|
||||
repoPath: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin: GitHubItemDialogProjectOrigin | undefined
|
||||
issueRepo?: GitHubOwnerRepo | null
|
||||
run: GHEditMutationRun
|
||||
patchProjectRowIfNeeded: (patch: GHEditProjectRowPatch) => void
|
||||
onMutated: () => void
|
||||
@@ -43,6 +46,7 @@ export function runGHEditStateChange({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
run,
|
||||
onStateChange,
|
||||
patchWorkItem,
|
||||
@@ -58,7 +62,7 @@ export function runGHEditStateChange({
|
||||
id: string,
|
||||
patch: { state: GitHubWorkItem['state'] },
|
||||
repoId: string | undefined,
|
||||
options: { sourceContext?: TaskSourceContext | null }
|
||||
options: GitHubPatchWorkItemOptions
|
||||
) => void
|
||||
}): void {
|
||||
// Why: a close reason still has to reach GitHub even when the item already reads as closed locally.
|
||||
@@ -76,6 +80,7 @@ export function runGHEditStateChange({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
number: itemNumber,
|
||||
updates:
|
||||
newState === 'closed' && closeAction
|
||||
@@ -87,22 +92,32 @@ export function runGHEditStateChange({
|
||||
repoId: itemRepoId,
|
||||
itemId,
|
||||
state: newState,
|
||||
sourceContext
|
||||
sourceContext,
|
||||
ownerRepo: projectOrigin ?? issueRepo
|
||||
})
|
||||
onStateChange(newState)
|
||||
patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext })
|
||||
patchWorkItem(itemId, { state: newState }, itemRepoId, {
|
||||
sourceContext,
|
||||
ownerRepo: projectOrigin ?? issueRepo
|
||||
})
|
||||
patchProjectRowIfNeeded({ state: newState })
|
||||
},
|
||||
onRevert: () => {
|
||||
if (authority?.revert()) {
|
||||
onStateChange(prevState)
|
||||
patchWorkItem(itemId, { state: prevState }, itemRepoId, { sourceContext })
|
||||
patchWorkItem(itemId, { state: prevState }, itemRepoId, {
|
||||
sourceContext,
|
||||
ownerRepo: projectOrigin ?? issueRepo
|
||||
})
|
||||
patchProjectRowIfNeeded({ state: prevState })
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('github-tasks')
|
||||
patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext })
|
||||
patchWorkItem(itemId, { state: newState }, itemRepoId, {
|
||||
sourceContext,
|
||||
ownerRepo: projectOrigin ?? issueRepo
|
||||
})
|
||||
patchProjectRowIfNeeded({ state: newState })
|
||||
onMutated()
|
||||
},
|
||||
@@ -145,6 +160,7 @@ export function runGHEditLabelToggle({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
run,
|
||||
onLabelsChange,
|
||||
patchWorkItem,
|
||||
@@ -159,42 +175,13 @@ export function runGHEditLabelToggle({
|
||||
id: string,
|
||||
patch: { labels: string[] },
|
||||
repoId: string | undefined,
|
||||
options: { sourceContext?: TaskSourceContext | null }
|
||||
options: GitHubPatchWorkItemOptions
|
||||
) => void
|
||||
}): void {
|
||||
const isAdding = !localLabels.includes(label)
|
||||
const prevLabels = localLabels
|
||||
const newLabels = isAdding ? [...prevLabels, label] : prevLabels.filter((l) => l !== label)
|
||||
|
||||
if (isAdding) {
|
||||
void run('labels', {
|
||||
mutate: () =>
|
||||
runIssueUpdate({
|
||||
repoId: itemRepoId,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
number: itemNumber,
|
||||
updates: { addLabels: [label] }
|
||||
}),
|
||||
onOptimistic: () => {
|
||||
onLabelsChange(newLabels)
|
||||
patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext })
|
||||
patchProjectRowIfNeeded({ labels: newLabels })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('github-tasks')
|
||||
onMutated()
|
||||
},
|
||||
onRevert: () => {
|
||||
onLabelsChange(prevLabels)
|
||||
patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { sourceContext })
|
||||
patchProjectRowIfNeeded({ labels: prevLabels })
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
return
|
||||
}
|
||||
void run('labels', {
|
||||
mutate: () =>
|
||||
runIssueUpdate({
|
||||
@@ -202,17 +189,24 @@ export function runGHEditLabelToggle({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
number: itemNumber,
|
||||
updates: { removeLabels: [label] }
|
||||
updates: isAdding ? { addLabels: [label] } : { removeLabels: [label] }
|
||||
}),
|
||||
onOptimistic: () => {
|
||||
onLabelsChange(newLabels)
|
||||
patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext })
|
||||
patchWorkItem(itemId, { labels: newLabels }, itemRepoId, {
|
||||
sourceContext,
|
||||
ownerRepo: projectOrigin ?? issueRepo
|
||||
})
|
||||
patchProjectRowIfNeeded({ labels: newLabels })
|
||||
},
|
||||
onRevert: () => {
|
||||
onLabelsChange(prevLabels)
|
||||
patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { sourceContext })
|
||||
patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, {
|
||||
sourceContext,
|
||||
ownerRepo: projectOrigin ?? issueRepo
|
||||
})
|
||||
patchProjectRowIfNeeded({ labels: prevLabels })
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -233,6 +227,7 @@ export function runGHEditAssigneeToggle({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
run,
|
||||
setLocalAssignees,
|
||||
patchProjectRowIfNeeded,
|
||||
@@ -260,6 +255,7 @@ export function runGHEditAssigneeToggle({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
number: itemNumber,
|
||||
updates: { removeAssignees: [login] }
|
||||
}),
|
||||
@@ -288,6 +284,7 @@ export function runGHEditAssigneeToggle({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
number: itemNumber,
|
||||
updates: { addAssignees: [login] }
|
||||
}),
|
||||
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types'
|
||||
import { GHEditSection } from './gh-edit-section'
|
||||
import { resetTaskPageGitHubMutationRegistryForTests } from '@/components/task-page-github-work-item-mutation-registry'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
update: vi.fn(),
|
||||
patchWorkItem: vi.fn(),
|
||||
patchProjectRowContent: vi.fn(),
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
onMutated: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const state = {
|
||||
patchWorkItem: mocks.patchWorkItem,
|
||||
patchProjectRowContent: mocks.patchProjectRowContent,
|
||||
recordFeatureInteraction: mocks.recordFeatureInteraction,
|
||||
repos: [],
|
||||
settings: null
|
||||
}
|
||||
return {
|
||||
useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), {
|
||||
getState: () => state
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/hooks/useIssueMetadata', async () => {
|
||||
const { useImmediateMutation } = await import('@/hooks/useImmediateMutation')
|
||||
return {
|
||||
useImmediateMutation,
|
||||
useRepoLabels: () => ({ data: [] }),
|
||||
useRepoAssignees: () => ({ data: [] })
|
||||
}
|
||||
})
|
||||
vi.mock('@/hooks/useGitHubSlugMetadata', () => ({
|
||||
useRepoLabelsBySlug: () => ({ data: [] }),
|
||||
useRepoAssigneesBySlug: () => ({ data: [] })
|
||||
}))
|
||||
vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({
|
||||
useGitHubDuplicateIssueCandidates: () => []
|
||||
}))
|
||||
vi.mock('@/components/github/github-work-item-edit-mutations', () => ({
|
||||
runIssueUpdate: mocks.update
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('./gh-edit-section-horizontal', () => ({ GHEditSectionHorizontal: () => null }))
|
||||
vi.mock('./gh-edit-section-top-columns', () => ({
|
||||
GHEditSectionTopColumns: ({
|
||||
localAssignees,
|
||||
onAssigneeToggle,
|
||||
localState,
|
||||
localLabels,
|
||||
onStateChange,
|
||||
onLabelToggle
|
||||
}: {
|
||||
localAssignees: string[]
|
||||
onAssigneeToggle: (login: string) => void
|
||||
localState: GitHubWorkItem['state']
|
||||
localLabels: string[]
|
||||
onStateChange: (state: 'open' | 'closed') => void
|
||||
onLabelToggle: (label: string) => void
|
||||
}) => (
|
||||
<>
|
||||
<output data-field="assignees">{localAssignees.join(',')}</output>
|
||||
<output data-field="state">{localState}</output>
|
||||
<output data-field="labels">{localLabels.join(',')}</output>
|
||||
<button onClick={() => onAssigneeToggle('candidate-user')}>Assign candidate</button>
|
||||
<button onClick={() => onAssigneeToggle('original-user')}>Toggle original</button>
|
||||
<button onClick={() => onStateChange('closed')}>Close issue</button>
|
||||
<button onClick={() => onLabelToggle('candidate-label')}>Toggle label</button>
|
||||
</>
|
||||
)
|
||||
}))
|
||||
|
||||
const item: GitHubWorkItem = {
|
||||
id: 'issue:5',
|
||||
repoId: 'registered-repo',
|
||||
type: 'issue',
|
||||
number: 5,
|
||||
title: 'Issue five',
|
||||
state: 'open',
|
||||
url: 'https://github.com/upstream/widgets/issues/5',
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null
|
||||
}
|
||||
let root: Root
|
||||
let container: HTMLDivElement
|
||||
|
||||
async function render(
|
||||
nextItem = item,
|
||||
assignees: string[] = [],
|
||||
projectOrigin?: GitHubItemDialogProjectOrigin
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GHEditSection
|
||||
item={nextItem}
|
||||
repoPath={null}
|
||||
repoId={nextItem.repoId}
|
||||
projectOrigin={projectOrigin}
|
||||
localState="open"
|
||||
localLabels={[]}
|
||||
onStateChange={() => {}}
|
||||
onLabelsChange={() => {}}
|
||||
onMutated={mocks.onMutated}
|
||||
assignees={assignees}
|
||||
onUse={() => {}}
|
||||
layout="top-columns"
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function click(label = 'Assign candidate'): Promise<void> {
|
||||
const button = [...container.querySelectorAll('button')].find(
|
||||
(candidate) => candidate.textContent === label
|
||||
)
|
||||
expect(button).toBeDefined()
|
||||
await act(async () => button?.click())
|
||||
}
|
||||
|
||||
function selected(): string | null {
|
||||
return container.querySelector('[data-field="assignees"]')?.textContent ?? null
|
||||
}
|
||||
|
||||
let updateParentSelection: (nextItem: GitHubWorkItem) => void = () => {}
|
||||
|
||||
function ParentSelection({ item }: { item: GitHubWorkItem }): React.JSX.Element {
|
||||
const [localState, setLocalState] = useState(item.state)
|
||||
const [localLabels, setLocalLabels] = useState(item.labels)
|
||||
updateParentSelection = (nextItem) => {
|
||||
setLocalState(nextItem.state)
|
||||
setLocalLabels(nextItem.labels)
|
||||
}
|
||||
return (
|
||||
<GHEditSection
|
||||
item={item}
|
||||
repoPath={null}
|
||||
repoId={item.repoId}
|
||||
projectOrigin={undefined}
|
||||
localState={localState}
|
||||
localLabels={localLabels}
|
||||
onStateChange={setLocalState}
|
||||
onLabelsChange={setLocalLabels}
|
||||
onMutated={mocks.onMutated}
|
||||
assignees={[]}
|
||||
onUse={() => {}}
|
||||
layout="top-columns"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
async function renderParent(nextItem: GitHubWorkItem): Promise<void> {
|
||||
await act(async () => {
|
||||
updateParentSelection(nextItem)
|
||||
root.render(<ParentSelection item={nextItem} />)
|
||||
})
|
||||
}
|
||||
|
||||
describe('opened issue assignee ownership', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
||||
vi.clearAllMocks()
|
||||
resetTaskPageGitHubMutationRegistryForTests()
|
||||
updateParentSelection = () => {}
|
||||
mocks.update.mockResolvedValue({ ok: true })
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
vi.unstubAllGlobals()
|
||||
resetTaskPageGitHubMutationRegistryForTests()
|
||||
})
|
||||
|
||||
it('retires an optimistic selection across fork and return navigation', async () => {
|
||||
await render()
|
||||
await click()
|
||||
expect(selected()).toBe('candidate-user')
|
||||
await render(item, [])
|
||||
expect(selected()).toBe('candidate-user')
|
||||
|
||||
await render({ ...item, url: 'https://github.com/fork/widgets/issues/5' }, ['fork-user'])
|
||||
expect(selected()).toBe('fork-user')
|
||||
await render(item, ['authoritative-upstream-user'])
|
||||
expect(selected()).toBe('authoritative-upstream-user')
|
||||
})
|
||||
|
||||
it('does not revive an edited guard when returning from a different issue number', async () => {
|
||||
await render()
|
||||
await click()
|
||||
await render({ ...item, id: 'issue:6', number: 6 }, ['other-issue-user'])
|
||||
expect(selected()).toBe('other-issue-user')
|
||||
await render(item, ['authoritative-upstream-user'])
|
||||
expect(selected()).toBe('authoritative-upstream-user')
|
||||
})
|
||||
|
||||
it.each(['Assign candidate', 'Toggle original'])(
|
||||
'does not roll a late failed %s edit into another opened issue',
|
||||
async (button) => {
|
||||
let rejectUpdate: (reason: Error) => void = () => {}
|
||||
mocks.update.mockImplementation(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectUpdate = reject
|
||||
})
|
||||
)
|
||||
await render(item, ['original-user'])
|
||||
await click(button)
|
||||
await render({ ...item, id: 'issue:6', number: 6 }, ['other-issue-user'])
|
||||
expect(selected()).toBe('other-issue-user')
|
||||
await act(async () => rejectUpdate(new Error('held edit failed')))
|
||||
expect(selected()).toBe('other-issue-user')
|
||||
}
|
||||
)
|
||||
|
||||
it('treats another GitHub host as a distinct same-number issue', async () => {
|
||||
await render()
|
||||
await click()
|
||||
await render({ ...item, url: 'https://github.example.test/upstream/widgets/issues/5' }, [])
|
||||
expect(selected()).toBe('')
|
||||
})
|
||||
|
||||
it('retires the issue edit when the same legacy id is used by a pull request', async () => {
|
||||
await render()
|
||||
await click()
|
||||
await render({ ...item, type: 'pr' }, [])
|
||||
await render(item, ['authoritative-upstream-user'])
|
||||
expect(selected()).toBe('authoritative-upstream-user')
|
||||
})
|
||||
|
||||
it('preserves optimistic edits across canonical URL casing and trailing paths', async () => {
|
||||
await render()
|
||||
await click()
|
||||
await render({ ...item, url: 'https://GITHUB.COM/Upstream/Widgets/issues/5#activity' }, [])
|
||||
expect(selected()).toBe('candidate-user')
|
||||
})
|
||||
|
||||
it('uses the Project row repository before an unrelated item URL', async () => {
|
||||
const projectOrigin: GitHubItemDialogProjectOrigin = {
|
||||
owner: 'upstream',
|
||||
repo: 'widgets',
|
||||
number: 5,
|
||||
type: 'issue',
|
||||
projectId: 'project',
|
||||
projectItemId: 'row',
|
||||
cacheKey: 'project-cache'
|
||||
}
|
||||
await render(item, [], projectOrigin)
|
||||
await click()
|
||||
await render(
|
||||
{ ...item, url: 'https://github.com/unrelated/widgets/issues/5' },
|
||||
[],
|
||||
projectOrigin
|
||||
)
|
||||
expect(selected()).toBe('candidate-user')
|
||||
await render(item, ['fork-user'], { ...projectOrigin, owner: 'fork' })
|
||||
expect(selected()).toBe('fork-user')
|
||||
})
|
||||
|
||||
it('still rolls back a failed edit while its issue remains open', async () => {
|
||||
mocks.update.mockRejectedValue(new Error('edit failed'))
|
||||
await render(item, ['original-user'])
|
||||
await click()
|
||||
expect(selected()).toBe('original-user')
|
||||
await render(item, ['refetched-user'])
|
||||
expect(selected()).toBe('refetched-user')
|
||||
})
|
||||
|
||||
it('keeps a late Project-row rollback scoped to the captured row', async () => {
|
||||
const projectOrigin: GitHubItemDialogProjectOrigin = {
|
||||
owner: 'upstream',
|
||||
repo: 'widgets',
|
||||
number: 5,
|
||||
type: 'issue',
|
||||
projectId: 'project',
|
||||
projectItemId: 'upstream-row',
|
||||
cacheKey: 'upstream-cache'
|
||||
}
|
||||
let rejectUpdate: (reason: Error) => void = () => {}
|
||||
mocks.update.mockImplementation(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectUpdate = reject
|
||||
})
|
||||
)
|
||||
await render(item, ['original-user'], projectOrigin)
|
||||
await click()
|
||||
await render(item, ['fork-user'], {
|
||||
...projectOrigin,
|
||||
owner: 'fork',
|
||||
projectItemId: 'fork-row',
|
||||
cacheKey: 'fork-cache'
|
||||
})
|
||||
expect(selected()).toBe('fork-user')
|
||||
await act(async () => rejectUpdate(new Error('held Project edit failed')))
|
||||
expect(selected()).toBe('fork-user')
|
||||
expect(mocks.patchProjectRowContent).toHaveBeenLastCalledWith(
|
||||
'upstream-cache',
|
||||
'upstream-row',
|
||||
{ assignees: ['original-user'] }
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ button: 'Toggle label', field: 'labels', expected: 'fork-only-label' },
|
||||
{ button: 'Close issue', field: 'state', expected: 'closed' }
|
||||
])(
|
||||
'a late failed $field edit cannot overwrite the new parent selection',
|
||||
async ({ button, field, expected }) => {
|
||||
let rejectUpdate: (reason: Error) => void = () => {}
|
||||
mocks.update.mockImplementation(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectUpdate = reject
|
||||
})
|
||||
)
|
||||
await renderParent({ ...item, labels: ['upstream-original-label'] })
|
||||
await click(button)
|
||||
await renderParent({
|
||||
...item,
|
||||
state: 'closed',
|
||||
url: 'https://github.com/fork/widgets/issues/5',
|
||||
labels: ['fork-only-label']
|
||||
})
|
||||
expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected)
|
||||
await act(async () => rejectUpdate(new Error('held edit failed')))
|
||||
expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected)
|
||||
expect(mocks.patchWorkItem).toHaveBeenLastCalledWith(
|
||||
item.id,
|
||||
field === 'labels' ? { labels: ['upstream-original-label'] } : { state: 'open' },
|
||||
item.repoId,
|
||||
{
|
||||
sourceContext: undefined,
|
||||
ownerRepo: { owner: 'upstream', repo: 'widgets', host: 'github.com' }
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ button: 'Toggle label', field: 'labels', expected: 'upstream-original-label' },
|
||||
{ button: 'Close issue', field: 'state', expected: 'open' }
|
||||
])(
|
||||
'a same-item failed $field edit still restores its prior parent value',
|
||||
async ({ button, field, expected }) => {
|
||||
mocks.update.mockRejectedValue(new Error('same issue edit failed'))
|
||||
await renderParent({ ...item, labels: ['upstream-original-label'] })
|
||||
await click(button)
|
||||
expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
+40
-31
@@ -3,11 +3,8 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata'
|
||||
import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
|
||||
import {
|
||||
getTaskSourceRuntimeSettings,
|
||||
type TaskSourceContext
|
||||
} from '../../../../../shared/task-source-context'
|
||||
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
|
||||
import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context'
|
||||
import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key'
|
||||
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
|
||||
import {
|
||||
getTaskPageGitHubDuplicateCandidates,
|
||||
@@ -17,7 +14,8 @@ import {
|
||||
} from '@/components/task-page-github-status-actions'
|
||||
import { parseOwnerRepoFromItemUrl } from '@/components/github/github-work-item-identity'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types'
|
||||
import type { GitHubItemDialogEditSectionProps } from '../load-item-details/github-item-dialog-types'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { getGitHubRepositoryLabelsUrl } from './repository-labels-url'
|
||||
import {
|
||||
closeGHEditAsDuplicate,
|
||||
@@ -29,7 +27,14 @@ import { GHEditSectionTopColumns } from './gh-edit-section-top-columns'
|
||||
import { GHEditSectionHorizontal } from './gh-edit-section-horizontal'
|
||||
import { useGitHubDuplicateIssueCandidates } from '@/components/github/github-duplicate-issue-candidates'
|
||||
|
||||
export function GHEditSection({
|
||||
export function GHEditSection(props: GitHubItemDialogEditSectionProps): React.JSX.Element | null {
|
||||
const { item, projectOrigin } = props
|
||||
const repository = projectOrigin ?? parseOwnerRepoFromItemUrl(item.url)
|
||||
const itemKey = `${item.repoId}\0${repository ? githubRepoIdentityKey(repository) : item.url}\0${item.type}\0${item.id}`
|
||||
return <GHEditSectionItem key={itemKey} {...props} />
|
||||
}
|
||||
|
||||
function GHEditSectionItem({
|
||||
item,
|
||||
repoPath,
|
||||
repoId,
|
||||
@@ -45,25 +50,7 @@ export function GHEditSection({
|
||||
onOpenOrUse,
|
||||
attachedWorkspaceLabel,
|
||||
layout = 'horizontal'
|
||||
}: {
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
repoId: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin: GitHubItemDialogProjectOrigin | undefined
|
||||
localState: GitHubWorkItem['state']
|
||||
localLabels: string[]
|
||||
onStateChange: (state: GitHubWorkItem['state']) => void
|
||||
onLabelsChange: (labels: string[]) => void
|
||||
/** Why: lets the parent invalidate its details cache after a mutation, else a reopen within FRESH_MS paints pre-mutation data. */
|
||||
onMutated: () => void
|
||||
assignees: string[]
|
||||
onUse: (item: GitHubWorkItem) => void
|
||||
onOpenOrUse?: (item: GitHubWorkItem) => void
|
||||
attachedWorkspaceLabel?: string | null
|
||||
/** `horizontal`: compact pill strip for the non-issue drawer/header; `top-columns`: labeled columns above the issue page body. */
|
||||
layout?: 'horizontal' | 'top-columns'
|
||||
}): React.JSX.Element | null {
|
||||
}: GitHubItemDialogEditSectionProps): React.JSX.Element | null {
|
||||
const [labelPopoverOpen, setLabelPopoverOpen] = useState(false)
|
||||
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false)
|
||||
const [statusPopoverOpen, setStatusPopoverOpen] = useState(false)
|
||||
@@ -72,6 +59,7 @@ export function GHEditSection({
|
||||
const [duplicateError, setDuplicateError] = useState<string | null>(null)
|
||||
const [localAssignees, setLocalAssignees] = useState<string[]>(assignees)
|
||||
const editedAssigneesItemKeyRef = useRef<string | null>(null)
|
||||
const mountedRef = useMountedRef()
|
||||
const assigneesItemKey = `${item.repoId}\0${item.id}`
|
||||
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
|
||||
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
|
||||
@@ -101,13 +89,18 @@ export function GHEditSection({
|
||||
[projectOrigin, patchProjectRowContent]
|
||||
)
|
||||
|
||||
// Why: with projectOrigin set, read labels/assignees from the row's repo, not the workspace path, or popovers list a different repo than writes target.
|
||||
const issueRepo = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url])
|
||||
const metadataOptions = useMemo(
|
||||
() => ({ ...sourceSettings, ownerRepo: issueRepo }),
|
||||
[sourceSettings, issueRepo]
|
||||
)
|
||||
// Project metadata comes from the row repository.
|
||||
const slugOwner = projectOrigin?.owner ?? null
|
||||
const slugRepo = projectOrigin?.repo ?? null
|
||||
const repoLabelsByPath = useRepoLabels(
|
||||
projectOrigin ? null : repoPath,
|
||||
projectOrigin ? null : repoId,
|
||||
sourceSettings
|
||||
metadataOptions
|
||||
)
|
||||
const repoLabelsBySlug = useRepoLabelsBySlug(
|
||||
slugOwner,
|
||||
@@ -120,7 +113,7 @@ export function GHEditSection({
|
||||
const repoAssigneesByPath = useRepoAssignees(
|
||||
projectOrigin ? null : repoPath,
|
||||
projectOrigin ? null : repoId,
|
||||
sourceSettings
|
||||
metadataOptions
|
||||
)
|
||||
const repoAssigneesBySlug = useRepoAssigneesBySlug(
|
||||
slugOwner,
|
||||
@@ -187,8 +180,13 @@ export function GHEditSection({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
run,
|
||||
onStateChange,
|
||||
onStateChange: (state) => {
|
||||
if (mountedRef.current) {
|
||||
onStateChange(state)
|
||||
}
|
||||
},
|
||||
patchWorkItem,
|
||||
patchProjectRowIfNeeded,
|
||||
onMutated
|
||||
@@ -202,9 +200,11 @@ export function GHEditSection({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
patchWorkItem,
|
||||
patchProjectRowIfNeeded,
|
||||
run,
|
||||
mountedRef,
|
||||
onStateChange,
|
||||
onMutated
|
||||
]
|
||||
@@ -253,8 +253,13 @@ export function GHEditSection({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
run,
|
||||
onLabelsChange,
|
||||
onLabelsChange: (labels) => {
|
||||
if (mountedRef.current) {
|
||||
onLabelsChange(labels)
|
||||
}
|
||||
},
|
||||
patchWorkItem,
|
||||
patchProjectRowIfNeeded,
|
||||
onMutated
|
||||
@@ -268,9 +273,11 @@ export function GHEditSection({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
patchWorkItem,
|
||||
patchProjectRowIfNeeded,
|
||||
run,
|
||||
mountedRef,
|
||||
onLabelsChange,
|
||||
onMutated
|
||||
]
|
||||
@@ -288,6 +295,7 @@ export function GHEditSection({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
run,
|
||||
setLocalAssignees,
|
||||
patchProjectRowIfNeeded,
|
||||
@@ -301,6 +309,7 @@ export function GHEditSection({
|
||||
repoPath,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo,
|
||||
localAssignees,
|
||||
patchProjectRowIfNeeded,
|
||||
run,
|
||||
|
||||
+20
@@ -26,3 +26,23 @@ export type GitHubItemDialogProps = {
|
||||
/** Optional Project-origin context; when set, edits route via slug-addressed IPCs against the row's repo (slug routing wins for writes). */
|
||||
projectOrigin?: GitHubItemDialogProjectOrigin
|
||||
}
|
||||
|
||||
export type GitHubItemDialogEditSectionProps = {
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
repoId: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
projectOrigin: GitHubItemDialogProjectOrigin | undefined
|
||||
localState: GitHubWorkItem['state']
|
||||
localLabels: string[]
|
||||
onStateChange: (state: GitHubWorkItem['state']) => void
|
||||
onLabelsChange: (labels: string[]) => void
|
||||
/** Why: lets the parent invalidate its details cache after a mutation, else a reopen within FRESH_MS paints pre-mutation data. */
|
||||
onMutated: () => void
|
||||
assignees: string[]
|
||||
onUse: (item: GitHubWorkItem) => void
|
||||
onOpenOrUse?: (item: GitHubWorkItem) => void
|
||||
attachedWorkspaceLabel?: string | null
|
||||
/** `horizontal`: compact pill strip for the non-issue drawer/header; `top-columns`: labeled columns above the issue page body. */
|
||||
layout?: 'horizontal' | 'top-columns'
|
||||
}
|
||||
|
||||
+23
-8
@@ -4,6 +4,7 @@ import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-sou
|
||||
import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context'
|
||||
import {
|
||||
normalizeItemDialogTab,
|
||||
parseOwnerRepoFromItemUrl,
|
||||
type ItemDialogTab
|
||||
} from '@/components/github/github-work-item-identity'
|
||||
import type { PRComment } from '../../../../../shared/github/comment-types'
|
||||
@@ -61,6 +62,13 @@ export function useGitHubItemDialogDetails({
|
||||
?.issueSourcePreference
|
||||
})
|
||||
const canUseDetailsRepoContext = canUseGitHubRepoContext(repoPath, sourceContext)
|
||||
const issueRepository = useMemo(
|
||||
() =>
|
||||
workItem?.type === 'issue'
|
||||
? (projectOrigin ?? parseOwnerRepoFromItemUrl(workItem.url))
|
||||
: null,
|
||||
[projectOrigin, workItem]
|
||||
)
|
||||
const detailsCacheKey = useMemo(() => {
|
||||
if (!workItem || !effectiveRepoId || !canUseDetailsRepoContext) {
|
||||
return null
|
||||
@@ -69,10 +77,12 @@ export function useGitHubItemDialogDetails({
|
||||
repoPath: repoPath ?? '',
|
||||
repoId: effectiveRepoId,
|
||||
issueSourcePreference,
|
||||
sourceContext,
|
||||
sourceCacheScope:
|
||||
sourceContext?.provider === 'github' ? getTaskSourceCacheScope(sourceContext) : null,
|
||||
type: workItem.type,
|
||||
number: workItem.number
|
||||
number: workItem.number,
|
||||
ownerRepo: issueRepository
|
||||
})
|
||||
}, [
|
||||
canUseDetailsRepoContext,
|
||||
@@ -80,7 +90,8 @@ export function useGitHubItemDialogDetails({
|
||||
effectiveRepoId,
|
||||
sourceContext,
|
||||
workItem,
|
||||
issueSourcePreference
|
||||
issueSourcePreference,
|
||||
issueRepository
|
||||
])
|
||||
|
||||
// Why: reset during render so an item switch never paints the previous item's tab.
|
||||
@@ -97,7 +108,7 @@ export function useGitHubItemDialogDetails({
|
||||
}
|
||||
|
||||
// Why: hold comments added before the detail fetch resolves so they merge into the result instead of being overwritten.
|
||||
const optimisticCommentsRef = useRef<PRComment[]>([])
|
||||
const optimisticCommentsRef = useRef(new Map<string, PRComment[]>())
|
||||
// Why: distinguish "reopen same item" from "switch item" — reopen must keep optimistic comments since gh's 60s cache omits the just-posted one.
|
||||
const prevItemIdRef = useRef<string | null>(null)
|
||||
|
||||
@@ -116,7 +127,7 @@ export function useGitHubItemDialogDetails({
|
||||
// Why: key off cachedEntry identity (stable), not the optimistic ref array (fresh each render), to avoid needless recompute.
|
||||
const details = useMemo<GitHubWorkItemDetails | null>(() => {
|
||||
const cachedDetails = cachedEntry?.details ?? null
|
||||
const opt = optimisticCommentsRef.current
|
||||
const opt = optimisticCommentsRef.current.get(detailsCacheKey ?? '') ?? []
|
||||
if (!cachedDetails) {
|
||||
// Why: on cold open, details may still be loading — surface optimistic comments via a minimal shell so a pre-fetch comment isn't invisible.
|
||||
if (opt.length > 0 && workItem) {
|
||||
@@ -138,7 +149,7 @@ export function useGitHubItemDialogDetails({
|
||||
}
|
||||
// Why: optimisticTick forces this ref-reading memo to re-run on cold-open writes; lint can't see the dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cachedEntry, workItem, optimisticTick])
|
||||
}, [cachedEntry, workItem, detailsCacheKey, optimisticTick])
|
||||
|
||||
const loading = !!cachedEntry?.pending && !cachedEntry?.details
|
||||
const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null
|
||||
@@ -158,7 +169,7 @@ export function useGitHubItemDialogDetails({
|
||||
}
|
||||
// Why: clear optimistic comments only on item switch — on reopen, gh's 60s cache omits the just-posted comment, so keep the ref to re-merge.
|
||||
if (workItem.id !== prevItemIdRef.current) {
|
||||
optimisticCommentsRef.current = []
|
||||
optimisticCommentsRef.current.clear()
|
||||
}
|
||||
prevItemIdRef.current = workItem.id
|
||||
|
||||
@@ -178,7 +189,8 @@ export function useGitHubItemDialogDetails({
|
||||
repoId: effectiveRepoId,
|
||||
sourceContext,
|
||||
number: workItem.number,
|
||||
type: workItem.type
|
||||
type: workItem.type,
|
||||
ownerRepo: issueRepository
|
||||
})
|
||||
|
||||
// Why: snapshot the invalidation generation; if it advances before resolve, a mid-flight mutation invalidated the entry — don't write back.
|
||||
@@ -201,6 +213,7 @@ export function useGitHubItemDialogDetails({
|
||||
sourceContext,
|
||||
workItem,
|
||||
detailsCacheKey,
|
||||
issueRepository,
|
||||
refetchTick
|
||||
])
|
||||
|
||||
@@ -231,7 +244,9 @@ export function useGitHubItemDialogDetails({
|
||||
(comment: PRComment) => {
|
||||
useAppStore.getState().recordFeatureInteraction('github-tasks')
|
||||
// Why: skip refreshDetails() — gh's 60s cache would overwrite the optimistic comment; next open picks up the server version.
|
||||
optimisticCommentsRef.current.push(comment)
|
||||
const optimisticKey = detailsCacheKey ?? ''
|
||||
const optimisticComments = optimisticCommentsRef.current.get(optimisticKey) ?? []
|
||||
optimisticCommentsRef.current.set(optimisticKey, [...optimisticComments, comment])
|
||||
// Why: write through the module cache so concurrent drawers re-render; mark fetchedAt stale (0) so next open refetches server fields.
|
||||
if (detailsCacheKey) {
|
||||
const prev = workItemDetailsCache.get(detailsCacheKey)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user