mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Merge origin/main (browser copy, TOC, mobile IME, i18n, Windows startup) + add version_suffix input to release-cut
rc kind can now append an extra prerelease identifier (rc.N.perf): sorts above its base rc.N but below rc.N+1, so perf-branch RCs are identifiable by clients without outranking the main RC series. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -4,7 +4,7 @@ import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
|
||||
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
getGitUsernameMock,
|
||||
resolveLocalGitUsernameMock,
|
||||
getSshGitUsernameMock,
|
||||
getSshGitProviderMock,
|
||||
generateBranchNameMock,
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
getConfiguredBranchPrefixMock
|
||||
} = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getGitUsernameMock: vi.fn(() => 'you'),
|
||||
resolveLocalGitUsernameMock: vi.fn(async () => 'you'),
|
||||
getSshGitUsernameMock: vi.fn(async () => 'you'),
|
||||
getSshGitProviderMock: vi.fn(() => undefined),
|
||||
generateBranchNameMock: vi.fn(),
|
||||
@@ -26,8 +26,10 @@ const {
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
||||
vi.mock('../git/repo', () => ({ getGitUsername: getGitUsernameMock }))
|
||||
vi.mock('../git/git-username', () => ({ getSshGitUsername: getSshGitUsernameMock }))
|
||||
vi.mock('../git/git-username', () => ({
|
||||
getSshGitUsername: getSshGitUsernameMock,
|
||||
resolveLocalGitUsername: resolveLocalGitUsernameMock
|
||||
}))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock }))
|
||||
vi.mock('../text-generation/commit-message-text-generation', () => ({
|
||||
generateBranchNameFromContext: generateBranchNameMock,
|
||||
@@ -65,7 +67,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
beforeEach(() => {
|
||||
resetFirstWorkBranchRenameState()
|
||||
vi.clearAllMocks()
|
||||
getGitUsernameMock.mockReturnValue('you')
|
||||
resolveLocalGitUsernameMock.mockResolvedValue('you')
|
||||
getSshGitUsernameMock.mockResolvedValue('you')
|
||||
getSshGitProviderMock.mockReturnValue(undefined)
|
||||
computeBranchNameMock.mockImplementation((leaf: string) => `you/${leaf}`)
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
|
||||
import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { getGitUsername } from '../git/repo'
|
||||
import { getSshGitUsername } from '../git/git-username'
|
||||
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import {
|
||||
branchHasUpstream,
|
||||
@@ -256,7 +255,7 @@ async function runAutoRename(
|
||||
|
||||
const username = provider
|
||||
? (await getSshGitUsername(provider, repo.path)) || null
|
||||
: getGitUsername(repo.path) || null
|
||||
: (await resolveLocalGitUsername(repo.path)) || null
|
||||
// The model is told not to add a prefix, but sometimes echoes the configured
|
||||
// one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or
|
||||
// leak into the display name.
|
||||
|
||||
@@ -96,6 +96,11 @@ describe('AntigravityHookService', () => {
|
||||
expect(script).toContain('payload=$(cat)')
|
||||
expect(script).toContain("payload='{}'")
|
||||
expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi')
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive).
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
}
|
||||
expect(script).toContain('{"decision":""}')
|
||||
})
|
||||
|
||||
@@ -127,7 +127,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
" payload='{}'",
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -138,7 +141,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "hook_event_name=${ORCA_ANTIGRAVITY_EVENT}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -19,10 +19,6 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
getGitUsername: vi.fn().mockReturnValue('testuser')
|
||||
}))
|
||||
|
||||
vi.mock('./precheck-runner', () => ({
|
||||
runAutomationPrecheck: runAutomationPrecheckMock
|
||||
}))
|
||||
|
||||
@@ -19,10 +19,6 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
getGitUsername: vi.fn().mockReturnValue('testuser')
|
||||
}))
|
||||
|
||||
async function createStore() {
|
||||
vi.resetModules()
|
||||
const { Store, initDataPath } = await import('../persistence')
|
||||
|
||||
@@ -105,11 +105,30 @@ describe('setupGuestContextMenu', () => {
|
||||
screenY: 375,
|
||||
pageUrl: 'https://test.dev/page',
|
||||
linkUrl: 'https://test.dev/link',
|
||||
selectionText: '',
|
||||
canGoBack: true,
|
||||
canGoForward: true
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards the native selection text so the renderer can offer Copy', () => {
|
||||
const guest = makeGuest()
|
||||
const renderer = makeRenderer()
|
||||
|
||||
setupGuestContextMenu({
|
||||
browserTabId,
|
||||
guest,
|
||||
resolveRenderer: () => renderer
|
||||
})
|
||||
|
||||
triggerContextMenu(guest, { x: 10, y: 20, selectionText: 'copied selection' })
|
||||
|
||||
expect(rendererSendMock).toHaveBeenCalledWith(
|
||||
'browser:context-menu-requested',
|
||||
expect.objectContaining({ selectionText: 'copied selection' })
|
||||
)
|
||||
})
|
||||
|
||||
it('reads navigation state from navigationHistory', () => {
|
||||
const deprecatedCanGoBack = vi.fn(() => false)
|
||||
const deprecatedCanGoForward = vi.fn(() => false)
|
||||
|
||||
@@ -95,6 +95,11 @@ export function setupGuestContextMenu(args: {
|
||||
screenY: cursor.y,
|
||||
pageUrl,
|
||||
linkUrl,
|
||||
// Why: forward the native selection so the renderer can offer a Copy that
|
||||
// writes it to the clipboard directly, bypassing pages that suppress copy
|
||||
// via oncopy handlers (the reported bug — the selection is never re-read
|
||||
// through a page-visible copy event).
|
||||
selectionText: params.selectionText ?? '',
|
||||
...navigationState
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,6 +106,17 @@ describe('BrowserSessionRegistry', () => {
|
||||
expect(browserSessionRegistry.resolvePartition('nonexistent')).toBe(ORCA_BROWSER_PARTITION)
|
||||
})
|
||||
|
||||
it('strictly resolves known profile partitions without downgrading unknown profiles', () => {
|
||||
const profile = browserSessionRegistry.createProfile('isolated', 'Strict Resolve')
|
||||
expect(profile).not.toBeNull()
|
||||
|
||||
expect(browserSessionRegistry.resolveKnownPartition(null)).toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(browserSessionRegistry.resolveKnownPartition(undefined)).toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(browserSessionRegistry.resolveKnownPartition('default')).toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(browserSessionRegistry.resolveKnownPartition(profile!.id)).toBe(profile!.partition)
|
||||
expect(browserSessionRegistry.resolveKnownPartition('missing-profile')).toBeNull()
|
||||
})
|
||||
|
||||
it('lists all profiles', () => {
|
||||
const before = browserSessionRegistry.listProfiles().length
|
||||
browserSessionRegistry.createProfile('isolated', 'List Test')
|
||||
|
||||
@@ -326,6 +326,13 @@ class BrowserSessionRegistry {
|
||||
return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION
|
||||
}
|
||||
|
||||
resolveKnownPartition(profileId: string | null | undefined): string | null {
|
||||
if (!profileId) {
|
||||
return ORCA_BROWSER_PARTITION
|
||||
}
|
||||
return this.profiles.get(profileId)?.partition ?? null
|
||||
}
|
||||
|
||||
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null {
|
||||
// Why: only the constructor may create the default profile. Allowing the
|
||||
// renderer to pass scope:'default' would create a second profile sharing
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf'
|
||||
|
||||
const DEFAULT_MARGIN_INCHES = 1 / 2.54
|
||||
const TTL_MS = 5 * 60 * 1000
|
||||
|
||||
describe('buildPrintToPdfOptions', () => {
|
||||
it('returns empty options for empty params', () => {
|
||||
expect(buildPrintToPdfOptions({})).toEqual({})
|
||||
})
|
||||
|
||||
it('maps every well-formed field', () => {
|
||||
expect(
|
||||
buildPrintToPdfOptions({
|
||||
landscape: true,
|
||||
displayHeaderFooter: true,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
generateTaggedPDF: true,
|
||||
generateDocumentOutline: false,
|
||||
scale: 1.5,
|
||||
paperWidth: 8.5,
|
||||
paperHeight: 11,
|
||||
marginTop: 0.25,
|
||||
marginBottom: 0.5,
|
||||
marginLeft: 0.75,
|
||||
marginRight: 1,
|
||||
pageRanges: '1-2',
|
||||
headerTemplate: '<span></span>',
|
||||
footerTemplate: '<span></span>'
|
||||
})
|
||||
).toEqual({
|
||||
landscape: true,
|
||||
displayHeaderFooter: true,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
generateTaggedPDF: true,
|
||||
generateDocumentOutline: false,
|
||||
scale: 1.5,
|
||||
pageSize: { width: 8.5, height: 11 },
|
||||
margins: { marginType: 'custom', top: 0.25, bottom: 0.5, left: 0.75, right: 1 },
|
||||
pageRanges: '1-2',
|
||||
headerTemplate: '<span></span>',
|
||||
footerTemplate: '<span></span>'
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults omitted margin sides to 1cm when any side is set', () => {
|
||||
expect(buildPrintToPdfOptions({ marginTop: 0.25 }).margins).toEqual({
|
||||
marginType: 'custom',
|
||||
top: 0.25,
|
||||
bottom: DEFAULT_MARGIN_INCHES,
|
||||
left: DEFAULT_MARGIN_INCHES,
|
||||
right: DEFAULT_MARGIN_INCHES
|
||||
})
|
||||
})
|
||||
|
||||
it('drops non-finite and out-of-range numeric options', () => {
|
||||
const options = buildPrintToPdfOptions({
|
||||
scale: 0,
|
||||
paperWidth: Number.POSITIVE_INFINITY,
|
||||
paperHeight: 11,
|
||||
marginTop: Number.NaN
|
||||
})
|
||||
expect(options.scale).toBeUndefined()
|
||||
expect(options.pageSize).toBeUndefined()
|
||||
expect(options.margins).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires both paper dimensions for pageSize', () => {
|
||||
expect(buildPrintToPdfOptions({ paperWidth: 8.5 }).pageSize).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores wrong-typed options', () => {
|
||||
expect(buildPrintToPdfOptions({ landscape: 'yes', pageRanges: 7 })).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CdpPdfStreamStore', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('claims only its own handles', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('pdf'))
|
||||
|
||||
expect(store.ownsHandle({ handle })).toBe(true)
|
||||
expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/)
|
||||
expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false)
|
||||
expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false)
|
||||
expect(store.ownsHandle({})).toBe(false)
|
||||
})
|
||||
|
||||
it('mints distinct handles per stream', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
expect(store.create(Buffer.from('a'))).not.toBe(store.create(Buffer.from('b')))
|
||||
})
|
||||
|
||||
it('reads sequential chunks and reports eof', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abcdef'))
|
||||
|
||||
expect(store.read({ handle, size: 2 })).toEqual({
|
||||
data: Buffer.from('ab').toString('base64'),
|
||||
eof: false
|
||||
})
|
||||
expect(store.read({ handle })).toEqual({
|
||||
data: Buffer.from('cdef').toString('base64'),
|
||||
eof: true
|
||||
})
|
||||
})
|
||||
|
||||
it('honors an explicit read offset', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abcdef'))
|
||||
|
||||
expect(store.read({ handle, offset: 4 })).toEqual({
|
||||
data: Buffer.from('ef').toString('base64'),
|
||||
eof: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty eof chunk when reading past the end', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abc'))
|
||||
|
||||
expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true })
|
||||
})
|
||||
|
||||
it('returns null for unknown handles', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
expect(store.read({ handle: 'nope' })).toBeNull()
|
||||
expect(store.read({})).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a stream on close', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abc'))
|
||||
|
||||
store.close({ handle })
|
||||
expect(store.read({ handle })).toBeNull()
|
||||
})
|
||||
|
||||
it('drops all streams on clear', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abc'))
|
||||
|
||||
store.clear()
|
||||
expect(store.read({ handle })).toBeNull()
|
||||
})
|
||||
|
||||
it('evicts an abandoned stream after the TTL', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abc'))
|
||||
|
||||
vi.advanceTimersByTime(TTL_MS + 1)
|
||||
expect(store.read({ handle })).toBeNull()
|
||||
})
|
||||
|
||||
it('refreshes the TTL on each read', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abcdef'))
|
||||
|
||||
vi.advanceTimersByTime(TTL_MS - 1)
|
||||
expect(store.read({ handle, size: 1 })).not.toBeNull()
|
||||
vi.advanceTimersByTime(TTL_MS - 1)
|
||||
expect(store.read({ handle, size: 1 })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { PrintToPDFOptions } from 'electron'
|
||||
|
||||
const PDF_DEFAULT_MARGIN_INCHES = 1 / 2.54
|
||||
const PDF_STREAM_CHUNK_BYTES = 1024 * 1024
|
||||
const PDF_STREAM_HANDLE_PREFIX = 'orca-pdf-'
|
||||
const PDF_STREAM_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate CDP `Page.printToPDF` params into Electron `printToPDF` options.
|
||||
* Only well-formed values are forwarded so a malformed param can never smuggle
|
||||
* NaN/Infinity into Electron; omitted margin sides default to CDP's 1cm.
|
||||
*/
|
||||
export function buildPrintToPdfOptions(params: Record<string, unknown>): PrintToPDFOptions {
|
||||
const options: PrintToPDFOptions = {}
|
||||
|
||||
if (typeof params.landscape === 'boolean') {
|
||||
options.landscape = params.landscape
|
||||
}
|
||||
if (typeof params.displayHeaderFooter === 'boolean') {
|
||||
options.displayHeaderFooter = params.displayHeaderFooter
|
||||
}
|
||||
if (typeof params.printBackground === 'boolean') {
|
||||
options.printBackground = params.printBackground
|
||||
}
|
||||
if (typeof params.preferCSSPageSize === 'boolean') {
|
||||
options.preferCSSPageSize = params.preferCSSPageSize
|
||||
}
|
||||
if (typeof params.generateTaggedPDF === 'boolean') {
|
||||
options.generateTaggedPDF = params.generateTaggedPDF
|
||||
}
|
||||
if (typeof params.generateDocumentOutline === 'boolean') {
|
||||
options.generateDocumentOutline = params.generateDocumentOutline
|
||||
}
|
||||
|
||||
const scale = finiteNumber(params.scale)
|
||||
if (scale !== null && scale > 0) {
|
||||
options.scale = scale
|
||||
}
|
||||
|
||||
const paperWidth = finiteNumber(params.paperWidth)
|
||||
const paperHeight = finiteNumber(params.paperHeight)
|
||||
if (paperWidth !== null && paperHeight !== null && paperWidth > 0 && paperHeight > 0) {
|
||||
options.pageSize = { width: paperWidth, height: paperHeight }
|
||||
}
|
||||
|
||||
const marginTop = finiteNumber(params.marginTop)
|
||||
const marginBottom = finiteNumber(params.marginBottom)
|
||||
const marginLeft = finiteNumber(params.marginLeft)
|
||||
const marginRight = finiteNumber(params.marginRight)
|
||||
if ([marginTop, marginBottom, marginLeft, marginRight].some((margin) => margin !== null)) {
|
||||
// CDP and Electron printToPDF both use inches; omitted CDP sides default to 1cm.
|
||||
options.margins = {
|
||||
marginType: 'custom',
|
||||
top: marginTop ?? PDF_DEFAULT_MARGIN_INCHES,
|
||||
bottom: marginBottom ?? PDF_DEFAULT_MARGIN_INCHES,
|
||||
left: marginLeft ?? PDF_DEFAULT_MARGIN_INCHES,
|
||||
right: marginRight ?? PDF_DEFAULT_MARGIN_INCHES
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof params.pageRanges === 'string') {
|
||||
options.pageRanges = params.pageRanges
|
||||
}
|
||||
if (typeof params.headerTemplate === 'string') {
|
||||
options.headerTemplate = params.headerTemplate
|
||||
}
|
||||
if (typeof params.footerTemplate === 'string') {
|
||||
options.footerTemplate = params.footerTemplate
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
type PdfStream = {
|
||||
data: Buffer
|
||||
offset: number
|
||||
cleanupTimer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export type PdfStreamChunk = {
|
||||
data: string
|
||||
eof: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the PDF buffers produced for CDP `transferMode: "ReturnAsStream"` and
|
||||
* serves them back through `IO.read` / `IO.close`, the path Playwright's
|
||||
* `page.pdf()` uses. Handles carry a per-instance random prefix so a CDP client
|
||||
* cannot forge one or collide with a real Chromium IO stream, and each buffer is
|
||||
* evicted after a TTL so an abandoned stream can never leak memory.
|
||||
*/
|
||||
export class CdpPdfStreamStore {
|
||||
private readonly streams = new Map<string, PdfStream>()
|
||||
private readonly handlePrefix = `${PDF_STREAM_HANDLE_PREFIX}${randomUUID()}-`
|
||||
private nextId = 0
|
||||
|
||||
/** True when `params.handle` names one of this store's streams. */
|
||||
ownsHandle(params: Record<string, unknown>): boolean {
|
||||
return typeof params.handle === 'string' && params.handle.startsWith(this.handlePrefix)
|
||||
}
|
||||
|
||||
create(data: Buffer): string {
|
||||
const handle = `${this.handlePrefix}${++this.nextId}`
|
||||
this.streams.set(handle, {
|
||||
data,
|
||||
offset: 0,
|
||||
cleanupTimer: this.scheduleCleanup(handle)
|
||||
})
|
||||
return handle
|
||||
}
|
||||
|
||||
/** Read the next chunk, or `null` if the handle is unknown/expired. */
|
||||
read(params: Record<string, unknown>): PdfStreamChunk | null {
|
||||
const handle = typeof params.handle === 'string' ? params.handle : ''
|
||||
const stream = this.streams.get(handle)
|
||||
if (!stream) {
|
||||
return null
|
||||
}
|
||||
this.refreshCleanup(handle, stream)
|
||||
|
||||
const offset = finiteNumber(params.offset)
|
||||
if (offset !== null) {
|
||||
stream.offset = Math.max(0, Math.floor(offset))
|
||||
}
|
||||
const requestedSize = finiteNumber(params.size)
|
||||
const size =
|
||||
requestedSize !== null && requestedSize > 0
|
||||
? Math.floor(requestedSize)
|
||||
: PDF_STREAM_CHUNK_BYTES
|
||||
const start = Math.min(stream.offset, stream.data.length)
|
||||
const end = Math.min(start + size, stream.data.length)
|
||||
const chunk = stream.data.subarray(start, end)
|
||||
stream.offset = end
|
||||
|
||||
return { data: chunk.toString('base64'), eof: end >= stream.data.length }
|
||||
}
|
||||
|
||||
close(params: Record<string, unknown>): void {
|
||||
const handle = typeof params.handle === 'string' ? params.handle : ''
|
||||
this.delete(handle)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const stream of this.streams.values()) {
|
||||
clearTimeout(stream.cleanupTimer)
|
||||
}
|
||||
this.streams.clear()
|
||||
}
|
||||
|
||||
private scheduleCleanup(handle: string): ReturnType<typeof setTimeout> {
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
this.delete(handle)
|
||||
}, PDF_STREAM_TTL_MS)
|
||||
const maybeNodeTimer = cleanupTimer as { unref?: () => void }
|
||||
maybeNodeTimer.unref?.()
|
||||
return cleanupTimer
|
||||
}
|
||||
|
||||
private refreshCleanup(handle: string, stream: PdfStream): void {
|
||||
clearTimeout(stream.cleanupTimer)
|
||||
stream.cleanupTimer = this.scheduleCleanup(handle)
|
||||
}
|
||||
|
||||
private delete(handle: string): void {
|
||||
const stream = this.streams.get(handle)
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
clearTimeout(stream.cleanupTimer)
|
||||
this.streams.delete(handle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { CdpWsProxy } from './cdp-ws-proxy'
|
||||
import {
|
||||
connect,
|
||||
createMockWebContents,
|
||||
getSendCommandCalls,
|
||||
getSendCommandMethods,
|
||||
sendAndReceive,
|
||||
type MockWebContents
|
||||
} from './cdp-ws-proxy-test-harness'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
webContents: { fromId: vi.fn() }
|
||||
}))
|
||||
|
||||
// Why: the proxy focuses the guest webContents natively before Input.insertText,
|
||||
// which blurs any element a prior DOM.focus targeted. These tests pin the replay
|
||||
// that re-applies that focus so text lands in the intended field.
|
||||
describe('CdpWsProxy DOM.focus replay', () => {
|
||||
let mock: MockWebContents
|
||||
let proxy: CdpWsProxy
|
||||
let endpoint: string
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = createMockWebContents()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
proxy = new CdpWsProxy(mock.webContents as any)
|
||||
endpoint = await proxy.start()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await proxy.stop()
|
||||
})
|
||||
|
||||
it('replays DOM.focus before Input.insertText in the root session', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const focusResponse = await sendAndReceive(client, {
|
||||
id: 14,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 99 }
|
||||
})
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 15,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'hello' }
|
||||
})
|
||||
|
||||
expect(focusResponse.id).toBe(14)
|
||||
expect(insertResponse.id).toBe(15)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 99 }],
|
||||
['DOM.focus', { backendNodeId: 99 }],
|
||||
['Input.insertText', { text: 'hello' }]
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('replays DOM.focus before Input.insertText for OOPIF sessions', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 16,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 123 },
|
||||
sessionId: 'oopif-session-123'
|
||||
})
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 17,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'frame text' },
|
||||
sessionId: 'oopif-session-123'
|
||||
})
|
||||
|
||||
expect(insertResponse.id).toBe(17)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
|
||||
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
|
||||
['Input.insertText', { text: 'frame text' }, 'oopif-session-123']
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('does not replay DOM.focus after adjacent eval traffic', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 18,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 44 }
|
||||
})
|
||||
await sendAndReceive(client, {
|
||||
id: 19,
|
||||
method: 'Runtime.callFunctionOn',
|
||||
params: { functionDeclaration: '() => document.activeElement?.id' }
|
||||
})
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 20,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'after eval' }
|
||||
})
|
||||
|
||||
expect(insertResponse.id).toBe(20)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 44 }],
|
||||
['Runtime.callFunctionOn', { functionDeclaration: '() => document.activeElement?.id' }],
|
||||
['Input.insertText', { text: 'after eval' }]
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('does not replay a failed DOM.focus on the next Input.insertText', async () => {
|
||||
let domFocusAttempt = 0
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const [method] = args as [string]
|
||||
if (method === 'DOM.focus') {
|
||||
domFocusAttempt += 1
|
||||
if (domFocusAttempt === 1) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const focusResponse = await sendAndReceive(client, {
|
||||
id: 21,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 55 }
|
||||
})
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 22,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'fallback' }
|
||||
})
|
||||
|
||||
expect(focusResponse).toEqual({
|
||||
id: 21,
|
||||
error: { code: -32000, message: 'Node not found' }
|
||||
})
|
||||
expect(insertResponse.id).toBe(22)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 55 }],
|
||||
['Input.insertText', { text: 'fallback' }]
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('returns the replay error when the stored DOM.focus fails before Input.insertText', async () => {
|
||||
let domFocusAttempt = 0
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const [method] = args as [string]
|
||||
if (method === 'DOM.focus') {
|
||||
domFocusAttempt += 1
|
||||
if (domFocusAttempt === 2) {
|
||||
throw new Error('Focus target went stale')
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const focusResponse = await sendAndReceive(client, {
|
||||
id: 23,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 77 }
|
||||
})
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 24,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'blocked' }
|
||||
})
|
||||
|
||||
expect(focusResponse.id).toBe(23)
|
||||
expect(focusResponse.result).toEqual({})
|
||||
expect(insertResponse).toEqual({
|
||||
id: 24,
|
||||
error: { code: -32000, message: 'Focus target went stale' }
|
||||
})
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 77 }],
|
||||
['DOM.focus', { backendNodeId: 77 }]
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('still replays DOM.focus when Input.insertText is dispatched while DOM.focus is still in flight', async () => {
|
||||
let resolveFocus: (v: Record<string, unknown>) => void
|
||||
const focusPromise = new Promise<Record<string, unknown>>((r) => {
|
||||
resolveFocus = r
|
||||
})
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const [method] = args as [string]
|
||||
if (method === 'DOM.focus') {
|
||||
return focusPromise
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const client = await connect(endpoint)
|
||||
const responses: Record<string, unknown>[] = []
|
||||
client.on('message', (data) => {
|
||||
responses.push(JSON.parse(data.toString()))
|
||||
})
|
||||
|
||||
client.send(JSON.stringify({ id: 25, method: 'DOM.focus', params: { backendNodeId: 66 } }))
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
// Why: dispatch the next message before the in-flight DOM.focus sendCommand
|
||||
// resolves, reproducing the pipelining race the fix closes.
|
||||
client.send(
|
||||
JSON.stringify({ id: 26, method: 'Input.insertText', params: { text: 'pipelined' } })
|
||||
)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
resolveFocus!({})
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
expect(responses).toHaveLength(2)
|
||||
const focusResponse = responses.find((r) => r.id === 25)
|
||||
const insertResponse = responses.find((r) => r.id === 26)
|
||||
expect(focusResponse?.result).toEqual({})
|
||||
expect(insertResponse?.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'DOM.focus',
|
||||
'Input.insertText'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('clears the pending DOM.focus replay when Page.bringToFront intervenes', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 27,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 88 }
|
||||
})
|
||||
await sendAndReceive(client, { id: 28, method: 'Page.bringToFront', params: {} })
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 29,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'no replay' }
|
||||
})
|
||||
|
||||
expect(insertResponse.id).toBe(29)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
// Why: both Page.bringToFront and Input.insertText natively call focus(),
|
||||
// independent of the (now-cleared) DOM.focus replay.
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(2)
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'Input.insertText'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('clears the pending DOM.focus replay when Page.captureScreenshot intervenes', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 30,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 91 }
|
||||
})
|
||||
await sendAndReceive(client, { id: 31, method: 'Page.captureScreenshot', params: {} })
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 32,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'no replay after screenshot' }
|
||||
})
|
||||
|
||||
expect(insertResponse.id).toBe(32)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'Page.captureScreenshot',
|
||||
'Input.insertText'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('does not replay a pending DOM.focus across a client reconnect', async () => {
|
||||
const first = await connect(endpoint)
|
||||
await sendAndReceive(first, { id: 33, method: 'DOM.focus', params: { backendNodeId: 12 } })
|
||||
first.close()
|
||||
|
||||
// Why: a new client connection replaces the previous one; the stale focus
|
||||
// stored by the departed client must not leak into the new client's insert.
|
||||
const second = await connect(endpoint)
|
||||
const insertResponse = await sendAndReceive(second, {
|
||||
id: 34,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'fresh client' }
|
||||
})
|
||||
|
||||
expect(insertResponse.id).toBe(34)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'Input.insertText'
|
||||
])
|
||||
second.close()
|
||||
})
|
||||
|
||||
it('does not replay or insert once the client disconnects mid-DOM.focus', async () => {
|
||||
let resolveFocus: (v: Record<string, unknown>) => void = () => {}
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const [method] = args as [string]
|
||||
if (method === 'DOM.focus') {
|
||||
return new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveFocus = resolve
|
||||
})
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const client = await connect(endpoint)
|
||||
client.send(JSON.stringify({ id: 35, method: 'DOM.focus', params: { backendNodeId: 7 } }))
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
// Pipeline the insert while DOM.focus is still in flight, then drop the client.
|
||||
client.send(JSON.stringify({ id: 36, method: 'Input.insertText', params: { text: 'gone' } }))
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
client.close()
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
|
||||
// Resolve the in-flight DOM.focus only after the client is gone.
|
||||
resolveFocus({})
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
// Why: a disconnected client's focus replay and insert must not reach the live page.
|
||||
const methods = getSendCommandMethods(mock)
|
||||
expect(methods.filter((m) => m === 'DOM.focus')).toHaveLength(1)
|
||||
expect(methods).not.toContain('Input.insertText')
|
||||
})
|
||||
|
||||
it('does not insert once the client disconnects during the DOM.focus replay', async () => {
|
||||
let domFocusCalls = 0
|
||||
let resolveReplay: (v: Record<string, unknown>) => void = () => {}
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const [method] = args as [string]
|
||||
if (method === 'DOM.focus') {
|
||||
domFocusCalls += 1
|
||||
// Why: let the first DOM.focus resolve so a replay is queued, then hang the
|
||||
// replay so the client can disconnect while it is in flight.
|
||||
if (domFocusCalls === 2) {
|
||||
return new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveReplay = resolve
|
||||
})
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const client = await connect(endpoint)
|
||||
await sendAndReceive(client, { id: 37, method: 'DOM.focus', params: { backendNodeId: 9 } })
|
||||
client.send(JSON.stringify({ id: 38, method: 'Input.insertText', params: { text: 'late' } }))
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
client.close()
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
resolveReplay({})
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
expect(getSendCommandMethods(mock)).not.toContain('Input.insertText')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { vi, type Mock } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
type DebuggerListener = (...args: unknown[]) => void
|
||||
|
||||
type MockDebugger = {
|
||||
isAttached: Mock<() => boolean>
|
||||
attach: Mock<() => void>
|
||||
detach: Mock<() => void>
|
||||
sendCommand: Mock<
|
||||
(
|
||||
method?: string,
|
||||
params?: Record<string, unknown>,
|
||||
sessionId?: string
|
||||
) => Promise<Record<string, unknown>>
|
||||
>
|
||||
on: Mock<(event: string, handler: DebuggerListener) => void>
|
||||
removeListener: Mock<(event: string, handler: DebuggerListener) => void>
|
||||
}
|
||||
|
||||
// Why: annotate the return explicitly so the exported inferred type stays nameable under
|
||||
// composite declaration emit — otherwise the vi.fn() mocks leak @vitest/spy's Procedure
|
||||
// and tsgo reports TS2883.
|
||||
export type MockWebContents = {
|
||||
webContents: {
|
||||
debugger: MockDebugger
|
||||
isDestroyed: () => boolean
|
||||
focus: Mock<() => void>
|
||||
printToPDF: Mock<() => Promise<Buffer>>
|
||||
reload: Mock<() => void>
|
||||
reloadIgnoringCache: Mock<() => void>
|
||||
getTitle: Mock<() => string>
|
||||
getURL: Mock<() => string>
|
||||
}
|
||||
listeners: Map<string, DebuggerListener[]>
|
||||
destroy: () => void
|
||||
emit: (event: string, ...args: unknown[]) => void
|
||||
}
|
||||
|
||||
export function createMockWebContents(): MockWebContents {
|
||||
const listeners = new Map<string, DebuggerListener[]>()
|
||||
let debuggerAttached = false
|
||||
let destroyed = false
|
||||
|
||||
const debuggerObj = {
|
||||
isAttached: vi.fn(() => debuggerAttached),
|
||||
attach: vi.fn(() => {
|
||||
debuggerAttached = true
|
||||
}),
|
||||
detach: vi.fn(() => {
|
||||
debuggerAttached = false
|
||||
}),
|
||||
sendCommand: vi.fn(
|
||||
async (_method?: string, _params?: Record<string, unknown>, _sessionId?: string) => ({})
|
||||
),
|
||||
on: vi.fn((event: string, handler: DebuggerListener) => {
|
||||
const arr = listeners.get(event) ?? []
|
||||
arr.push(handler)
|
||||
listeners.set(event, arr)
|
||||
}),
|
||||
removeListener: vi.fn((event: string, handler: DebuggerListener) => {
|
||||
const arr = listeners.get(event) ?? []
|
||||
listeners.set(
|
||||
event,
|
||||
arr.filter((h) => h !== handler)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
webContents: {
|
||||
debugger: debuggerObj,
|
||||
isDestroyed: () => destroyed,
|
||||
focus: vi.fn(),
|
||||
printToPDF: vi.fn(async () => Buffer.from('%PDF-test')),
|
||||
reload: vi.fn(),
|
||||
reloadIgnoringCache: vi.fn(),
|
||||
getTitle: vi.fn(() => 'Example'),
|
||||
getURL: vi.fn(() => 'https://example.com')
|
||||
},
|
||||
listeners,
|
||||
destroy() {
|
||||
destroyed = true
|
||||
},
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SendCommandCall = [string, Record<string, unknown>?, string?]
|
||||
|
||||
export function connect(endpoint: string): Promise<WebSocket> {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(endpoint)
|
||||
ws.on('open', () => resolve(ws))
|
||||
})
|
||||
}
|
||||
|
||||
export function sendAndReceive(
|
||||
ws: WebSocket,
|
||||
msg: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
ws.once('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
ws.send(JSON.stringify(msg))
|
||||
})
|
||||
}
|
||||
|
||||
export function getSendCommandCalls(mock: MockWebContents): SendCommandCall[] {
|
||||
return mock.webContents.debugger.sendCommand.mock.calls as unknown as SendCommandCall[]
|
||||
}
|
||||
|
||||
export function getSendCommandMethods(mock: MockWebContents): string[] {
|
||||
return getSendCommandCalls(mock).map((call) => call[0])
|
||||
}
|
||||
@@ -1,67 +1,21 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import { CdpWsProxy } from './cdp-ws-proxy'
|
||||
import {
|
||||
connect,
|
||||
createMockWebContents,
|
||||
getSendCommandCalls,
|
||||
getSendCommandMethods,
|
||||
sendAndReceive,
|
||||
type MockWebContents
|
||||
} from './cdp-ws-proxy-test-harness'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
webContents: { fromId: vi.fn() }
|
||||
}))
|
||||
|
||||
type DebuggerListener = (...args: unknown[]) => void
|
||||
|
||||
function createMockWebContents() {
|
||||
const listeners = new Map<string, DebuggerListener[]>()
|
||||
let debuggerAttached = false
|
||||
let destroyed = false
|
||||
|
||||
const debuggerObj = {
|
||||
isAttached: vi.fn(() => debuggerAttached),
|
||||
attach: vi.fn(() => {
|
||||
debuggerAttached = true
|
||||
}),
|
||||
detach: vi.fn(() => {
|
||||
debuggerAttached = false
|
||||
}),
|
||||
sendCommand: vi.fn(
|
||||
async (_method?: string, _params?: Record<string, unknown>, _sessionId?: string) => ({})
|
||||
),
|
||||
on: vi.fn((event: string, handler: DebuggerListener) => {
|
||||
const arr = listeners.get(event) ?? []
|
||||
arr.push(handler)
|
||||
listeners.set(event, arr)
|
||||
}),
|
||||
removeListener: vi.fn((event: string, handler: DebuggerListener) => {
|
||||
const arr = listeners.get(event) ?? []
|
||||
listeners.set(
|
||||
event,
|
||||
arr.filter((h) => h !== handler)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
webContents: {
|
||||
debugger: debuggerObj,
|
||||
isDestroyed: () => destroyed,
|
||||
focus: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
reloadIgnoringCache: vi.fn(),
|
||||
getTitle: vi.fn(() => 'Example'),
|
||||
getURL: vi.fn(() => 'https://example.com')
|
||||
},
|
||||
listeners,
|
||||
destroy() {
|
||||
destroyed = true
|
||||
},
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('CdpWsProxy', () => {
|
||||
let mock: ReturnType<typeof createMockWebContents>
|
||||
let mock: MockWebContents
|
||||
let proxy: CdpWsProxy
|
||||
let endpoint: string
|
||||
|
||||
@@ -76,38 +30,14 @@ describe('CdpWsProxy', () => {
|
||||
await proxy.stop()
|
||||
})
|
||||
|
||||
function connect(): Promise<WebSocket> {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(endpoint)
|
||||
ws.on('open', () => resolve(ws))
|
||||
})
|
||||
function expectPdfStreamHandle(response: Record<string, unknown>): string {
|
||||
const result = response.result as Record<string, unknown>
|
||||
expect(result.data).toBe('')
|
||||
expect(result.stream).toEqual(expect.stringMatching(/^orca-pdf-[\da-f-]{36}-\d+$/))
|
||||
return result.stream as string
|
||||
}
|
||||
|
||||
function sendAndReceive(
|
||||
ws: WebSocket,
|
||||
msg: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
ws.once('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
ws.send(JSON.stringify(msg))
|
||||
})
|
||||
}
|
||||
|
||||
type SendCommandCall = [string, Record<string, unknown>?, string?]
|
||||
|
||||
function getSendCommandCalls(): SendCommandCall[] {
|
||||
const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>?,
|
||||
string?
|
||||
][]
|
||||
return calls
|
||||
}
|
||||
|
||||
function getSendCommandMethods(): string[] {
|
||||
const calls = getSendCommandCalls()
|
||||
return calls.map((call) => call[0])
|
||||
}
|
||||
const defaultPdfMarginInches = 1 / 2.54
|
||||
|
||||
it('starts on a random port and returns ws:// URL', () => {
|
||||
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
|
||||
@@ -131,7 +61,7 @@ describe('CdpWsProxy', () => {
|
||||
it('correlates CDP request/response IDs', async () => {
|
||||
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({ tree: 'nodes' })
|
||||
|
||||
const ws = connect()
|
||||
const ws = connect(endpoint)
|
||||
const client = await ws
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 42,
|
||||
@@ -147,7 +77,7 @@ describe('CdpWsProxy', () => {
|
||||
it('returns error response when sendCommand fails', async () => {
|
||||
mock.webContents.debugger.sendCommand.mockRejectedValueOnce(new Error('Node not found'))
|
||||
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 7,
|
||||
method: 'DOM.describeNode',
|
||||
@@ -160,7 +90,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('returns an error instead of crashing when a command arrives after tab destruction', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
mock.destroy()
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
@@ -197,7 +127,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({ result: 'fast' })
|
||||
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const responses: Record<string, unknown>[] = []
|
||||
client.on('message', (data) => {
|
||||
@@ -232,11 +162,11 @@ describe('CdpWsProxy', () => {
|
||||
)
|
||||
.mockResolvedValueOnce({ result: 'new-client' })
|
||||
|
||||
const firstClient = await connect()
|
||||
const firstClient = await connect(endpoint)
|
||||
firstClient.send(JSON.stringify({ id: 1, method: 'DOM.enable', params: {} }))
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
const secondClient = await connect()
|
||||
const secondClient = await connect(endpoint)
|
||||
const responses: Record<string, unknown>[] = []
|
||||
secondClient.on('message', (data) => {
|
||||
responses.push(JSON.parse(data.toString()))
|
||||
@@ -258,7 +188,7 @@ describe('CdpWsProxy', () => {
|
||||
it('forwards sessionId to sendCommand for OOPIF support', async () => {
|
||||
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({})
|
||||
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
await sendAndReceive(client, {
|
||||
id: 1,
|
||||
method: 'DOM.enable',
|
||||
@@ -277,7 +207,7 @@ describe('CdpWsProxy', () => {
|
||||
// ── Event forwarding ──
|
||||
|
||||
it('forwards CDP events from debugger to client', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
|
||||
client.on('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
@@ -292,7 +222,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('forwards sessionId in events when present', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
|
||||
client.on('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
@@ -306,7 +236,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('does not focus the guest for Runtime.evaluate polling commands', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 9,
|
||||
@@ -319,7 +249,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('still focuses the guest for Input.insertText', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 10,
|
||||
@@ -328,11 +258,16 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Input.insertText'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('primes lifecycle events for Page.navigate', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 11,
|
||||
@@ -342,7 +277,7 @@ describe('CdpWsProxy', () => {
|
||||
|
||||
expect(response.id).toBe(11)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Network.enable',
|
||||
@@ -354,7 +289,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('primes lifecycle events for Page.reload and preserves response id', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 12,
|
||||
@@ -363,7 +298,7 @@ describe('CdpWsProxy', () => {
|
||||
|
||||
expect(response.id).toBe(12)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Network.enable',
|
||||
@@ -371,12 +306,12 @@ describe('CdpWsProxy', () => {
|
||||
'Page.setLifecycleEventsEnabled'
|
||||
])
|
||||
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandMethods()).not.toContain('Page.reload')
|
||||
expect(getSendCommandMethods(mock)).not.toContain('Page.reload')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('preserves explicit Page.navigate session during lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 14,
|
||||
@@ -385,7 +320,7 @@ describe('CdpWsProxy', () => {
|
||||
sessionId: 'iframe-session-123'
|
||||
})
|
||||
|
||||
expect(getSendCommandCalls().slice(2)).toEqual([
|
||||
expect(getSendCommandCalls(mock).slice(2)).toEqual([
|
||||
['Network.enable', {}, 'iframe-session-123'],
|
||||
['Page.enable', {}, 'iframe-session-123'],
|
||||
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
|
||||
@@ -395,7 +330,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('forwards explicit Page.reload session after lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 15,
|
||||
@@ -404,7 +339,7 @@ describe('CdpWsProxy', () => {
|
||||
sessionId: 'iframe-session-123'
|
||||
})
|
||||
|
||||
expect(getSendCommandCalls().slice(2)).toEqual([
|
||||
expect(getSendCommandCalls(mock).slice(2)).toEqual([
|
||||
['Network.enable', {}, 'iframe-session-123'],
|
||||
['Page.enable', {}, 'iframe-session-123'],
|
||||
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
|
||||
@@ -416,7 +351,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('rejects root Page.reload params that direct webContents reload cannot honor', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 16,
|
||||
@@ -433,12 +368,12 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
expect(mock.webContents.reload).not.toHaveBeenCalled()
|
||||
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
expect(getSendCommandMethods()).not.toContain('Network.enable')
|
||||
expect(getSendCommandMethods(mock)).not.toContain('Network.enable')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('still reloads when lifecycle priming stalls', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
|
||||
if (method === 'Network.enable') {
|
||||
return new Promise(() => {})
|
||||
@@ -457,7 +392,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('does not reload after the requesting client disconnects during priming', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
|
||||
if (method === 'Network.enable') {
|
||||
return new Promise(() => {})
|
||||
@@ -475,7 +410,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('forwards Runtime.evaluate without lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 13,
|
||||
@@ -485,7 +420,7 @@ describe('CdpWsProxy', () => {
|
||||
|
||||
expect(response.id).toBe(13)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Runtime.evaluate'
|
||||
@@ -493,12 +428,215 @@ describe('CdpWsProxy', () => {
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('prints PDF data through native webContents printToPDF', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 19,
|
||||
method: 'Page.printToPDF',
|
||||
params: {
|
||||
landscape: true,
|
||||
printBackground: true,
|
||||
paperWidth: 8.5,
|
||||
paperHeight: 11,
|
||||
marginTop: 0.25,
|
||||
marginBottom: 0.5,
|
||||
marginLeft: 0.75,
|
||||
marginRight: 1,
|
||||
pageRanges: '1-2',
|
||||
preferCSSPageSize: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(response).toEqual({
|
||||
id: 19,
|
||||
result: { data: Buffer.from('%PDF-test').toString('base64') }
|
||||
})
|
||||
expect(mock.webContents.printToPDF).toHaveBeenCalledWith({
|
||||
landscape: true,
|
||||
printBackground: true,
|
||||
pageSize: { width: 8.5, height: 11 },
|
||||
margins: {
|
||||
marginType: 'custom',
|
||||
top: 0.25,
|
||||
bottom: 0.5,
|
||||
left: 0.75,
|
||||
right: 1
|
||||
},
|
||||
pageRanges: '1-2',
|
||||
preferCSSPageSize: true
|
||||
})
|
||||
expect(getSendCommandMethods(mock)).not.toContain('Page.printToPDF')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('keeps default PDF margins for omitted sides', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 20,
|
||||
method: 'Page.printToPDF',
|
||||
params: {
|
||||
marginTop: 0.25
|
||||
}
|
||||
})
|
||||
|
||||
expect(mock.webContents.printToPDF).toHaveBeenCalledWith({
|
||||
margins: {
|
||||
marginType: 'custom',
|
||||
top: 0.25,
|
||||
bottom: defaultPdfMarginInches,
|
||||
left: defaultPdfMarginInches,
|
||||
right: defaultPdfMarginInches
|
||||
}
|
||||
})
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('supports streamed Page.printToPDF results for Playwright page.pdf', async () => {
|
||||
mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef'))
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const printResponse = await sendAndReceive(client, {
|
||||
id: 21,
|
||||
method: 'Page.printToPDF',
|
||||
params: { transferMode: 'ReturnAsStream' }
|
||||
})
|
||||
const handle = expectPdfStreamHandle(printResponse)
|
||||
|
||||
const firstRead = await sendAndReceive(client, {
|
||||
id: 22,
|
||||
method: 'IO.read',
|
||||
params: { handle, size: 2 }
|
||||
})
|
||||
const secondRead = await sendAndReceive(client, {
|
||||
id: 23,
|
||||
method: 'IO.read',
|
||||
params: { handle }
|
||||
})
|
||||
const closeResponse = await sendAndReceive(client, {
|
||||
id: 24,
|
||||
method: 'IO.close',
|
||||
params: { handle }
|
||||
})
|
||||
const readAfterClose = await sendAndReceive(client, {
|
||||
id: 25,
|
||||
method: 'IO.read',
|
||||
params: { handle }
|
||||
})
|
||||
|
||||
expect(printResponse.id).toBe(21)
|
||||
expect(firstRead).toEqual({
|
||||
id: 22,
|
||||
result: { base64Encoded: true, data: Buffer.from('ab').toString('base64'), eof: false }
|
||||
})
|
||||
expect(secondRead).toEqual({
|
||||
id: 23,
|
||||
result: { base64Encoded: true, data: Buffer.from('cdef').toString('base64'), eof: true }
|
||||
})
|
||||
expect(closeResponse).toEqual({ id: 24, result: {} })
|
||||
expect(readAfterClose).toEqual({
|
||||
id: 25,
|
||||
error: { code: -32000, message: 'Invalid stream handle' }
|
||||
})
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('clears streamed PDF data when the client disconnects', async () => {
|
||||
mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef'))
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const printResponse = await sendAndReceive(client, {
|
||||
id: 26,
|
||||
method: 'Page.printToPDF',
|
||||
params: { transferMode: 'ReturnAsStream' }
|
||||
})
|
||||
const handle = expectPdfStreamHandle(printResponse)
|
||||
|
||||
expect(printResponse.id).toBe(26)
|
||||
client.close()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
const nextClient = await connect(endpoint)
|
||||
const staleRead = await sendAndReceive(nextClient, {
|
||||
id: 27,
|
||||
method: 'IO.read',
|
||||
params: { handle }
|
||||
})
|
||||
|
||||
expect(staleRead).toEqual({
|
||||
id: 27,
|
||||
error: { code: -32000, message: 'Invalid stream handle' }
|
||||
})
|
||||
nextClient.close()
|
||||
})
|
||||
|
||||
it('does not register a PDF stream when the client disconnects mid-print', async () => {
|
||||
let resolvePrint: (buf: Buffer<ArrayBuffer>) => void = () => {}
|
||||
mock.webContents.printToPDF.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Buffer<ArrayBuffer>>((resolve) => {
|
||||
resolvePrint = resolve
|
||||
})
|
||||
)
|
||||
const store = (proxy as unknown as { pdfStreams: { create: (b: Buffer) => string } }).pdfStreams
|
||||
const createSpy = vi.spyOn(store, 'create')
|
||||
|
||||
const client = await connect(endpoint)
|
||||
client.send(
|
||||
JSON.stringify({
|
||||
id: 30,
|
||||
method: 'Page.printToPDF',
|
||||
params: { transferMode: 'ReturnAsStream' }
|
||||
})
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
client.close()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// Print resolves only after the client is gone: no stream must be created.
|
||||
resolvePrint(Buffer.from('%PDF-late'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
expect(createSpy).not.toHaveBeenCalled()
|
||||
createSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('forwards non-PDF IO streams to the debugger', async () => {
|
||||
mock.webContents.debugger.sendCommand
|
||||
.mockResolvedValueOnce({ data: 'trace-data', eof: false })
|
||||
.mockResolvedValueOnce({})
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const readResponse = await sendAndReceive(client, {
|
||||
id: 28,
|
||||
method: 'IO.read',
|
||||
params: { handle: 'trace-stream', size: 64 }
|
||||
})
|
||||
const closeResponse = await sendAndReceive(client, {
|
||||
id: 29,
|
||||
method: 'IO.close',
|
||||
params: { handle: 'trace-stream' }
|
||||
})
|
||||
|
||||
expect(readResponse).toEqual({ id: 28, result: { data: 'trace-data', eof: false } })
|
||||
expect(closeResponse).toEqual({ id: 29, result: {} })
|
||||
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.read', {
|
||||
handle: 'trace-stream',
|
||||
size: 64
|
||||
})
|
||||
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.close', {
|
||||
handle: 'trace-stream'
|
||||
})
|
||||
client.close()
|
||||
})
|
||||
|
||||
// ── Page.frameNavigated interception ──
|
||||
|
||||
// ── Cleanup ──
|
||||
|
||||
it('detaches debugger and closes server on stop', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
await proxy.stop()
|
||||
|
||||
expect(mock.webContents.debugger.detach).toHaveBeenCalled()
|
||||
@@ -513,7 +651,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('detaches client websocket listeners after client close', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
const serverClient = (proxy as unknown as { client: WebSocket | null }).client
|
||||
expect(serverClient).toBeTruthy()
|
||||
const offSpy = vi.spyOn(serverClient!, 'off')
|
||||
@@ -543,7 +681,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
client.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} }))
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
|
||||
@@ -3,12 +3,19 @@ import { WebSocketServer, WebSocket } from 'ws'
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import type { WebContents } from 'electron'
|
||||
import { captureScreenshot } from './cdp-screenshot'
|
||||
import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
|
||||
|
||||
const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000
|
||||
|
||||
export class CdpWsProxy {
|
||||
// Why: holds each session's last DOM.focus params to replay right before the next
|
||||
// Input.insertText, countering the native webContents.focus() that would blur the target.
|
||||
private pendingDomFocusBySession = new Map<
|
||||
string | undefined,
|
||||
Promise<Record<string, unknown> | undefined>
|
||||
>()
|
||||
private httpServer: Server | null = null
|
||||
private wss: WebSocketServer | null = null
|
||||
private client: WebSocket | null = null
|
||||
@@ -20,6 +27,7 @@ export class CdpWsProxy {
|
||||
private attached = false
|
||||
// Why: agent-browser filters events by sessionId from Target.attachToTarget.
|
||||
private clientSessionId: string | undefined = undefined
|
||||
private readonly pdfStreams = new CdpPdfStreamStore()
|
||||
|
||||
constructor(private readonly webContents: WebContents) {}
|
||||
|
||||
@@ -51,6 +59,7 @@ export class CdpWsProxy {
|
||||
const onClose = (): void => {
|
||||
detach()
|
||||
if (this.client === ws) {
|
||||
this.pdfStreams.clear()
|
||||
this.client = null
|
||||
}
|
||||
}
|
||||
@@ -101,6 +110,9 @@ export class CdpWsProxy {
|
||||
this.detachClientListeners?.()
|
||||
this.detachClientListeners = null
|
||||
this.client = null
|
||||
// Why: a pending focus belongs to the departing client; never replay it for the next one.
|
||||
this.pendingDomFocusBySession.clear()
|
||||
this.pdfStreams.clear()
|
||||
client?.close()
|
||||
}
|
||||
|
||||
@@ -278,6 +290,12 @@ export class CdpWsProxy {
|
||||
)
|
||||
return
|
||||
}
|
||||
const effectiveSessionId = this.resolveDebuggerSessionId(msg.sessionId)
|
||||
// Why: a stored focus is only valid for the immediately following Input.insertText;
|
||||
// any other command may have moved DOM focus, so invalidate the replay in one place.
|
||||
if (msg.method !== 'DOM.focus' && msg.method !== 'Input.insertText') {
|
||||
this.pendingDomFocusBySession.delete(effectiveSessionId)
|
||||
}
|
||||
if (msg.method === 'Page.bringToFront') {
|
||||
if (!this.webContents.isDestroyed()) {
|
||||
this.webContents.focus()
|
||||
@@ -285,11 +303,39 @@ export class CdpWsProxy {
|
||||
this.sendResult(clientId, {}, client)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'DOM.focus') {
|
||||
this.forwardDomFocus(client, clientId, msg.params ?? {}, effectiveSessionId)
|
||||
return
|
||||
}
|
||||
// Why: Page.captureScreenshot via debugger.sendCommand hangs on Electron webview guests.
|
||||
if (msg.method === 'Page.captureScreenshot') {
|
||||
this.handleScreenshot(client, clientId, msg.params)
|
||||
return
|
||||
}
|
||||
// Why: CDP Page.printToPDF is not available for Electron webview guests.
|
||||
// Electron's native printToPDF path is the reliable equivalent.
|
||||
if (msg.method === 'Page.printToPDF') {
|
||||
void this.handlePrintToPdf(client, clientId, msg.params ?? {})
|
||||
return
|
||||
}
|
||||
if (msg.method === 'IO.read') {
|
||||
const params = msg.params ?? {}
|
||||
if (this.pdfStreams.ownsHandle(params)) {
|
||||
this.handleStreamRead(client, clientId, params)
|
||||
return
|
||||
}
|
||||
this.forwardCommand(client, clientId, msg.method, params, msg.sessionId)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'IO.close') {
|
||||
const params = msg.params ?? {}
|
||||
if (this.pdfStreams.ownsHandle(params)) {
|
||||
this.handleStreamClose(client, clientId, params)
|
||||
return
|
||||
}
|
||||
this.forwardCommand(client, clientId, msg.method, params, msg.sessionId)
|
||||
return
|
||||
}
|
||||
// Why: Input.insertText can still require native focus in Electron webviews.
|
||||
// Do not auto-focus generic Runtime.evaluate/callFunctionOn traffic: wait
|
||||
// polling and read-only JS probes use those methods heavily, and focusing on
|
||||
@@ -297,6 +343,8 @@ export class CdpWsProxy {
|
||||
// is running.
|
||||
if (msg.method === 'Input.insertText' && !this.webContents.isDestroyed()) {
|
||||
this.webContents.focus()
|
||||
void this.forwardInsertText(client, clientId, msg.params ?? {}, effectiveSessionId)
|
||||
return
|
||||
}
|
||||
// Why: agent-browser waits for network idle to detect navigation completion.
|
||||
// Electron webview CDP subscriptions silently lapse after cross-process swaps.
|
||||
@@ -439,6 +487,124 @@ export class CdpWsProxy {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: this must stay synchronous up to the `.set()` call so the pending-focus
|
||||
// entry exists before the event loop can dispatch a pipelined Input.insertText
|
||||
// message, closing the race where the replay would otherwise be silently skipped.
|
||||
private forwardDomFocus(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>,
|
||||
effectiveSessionId?: string
|
||||
): void {
|
||||
const focused = this.sendDomFocus(client, clientId, params, effectiveSessionId)
|
||||
this.pendingDomFocusBySession.set(effectiveSessionId, focused)
|
||||
}
|
||||
|
||||
private async sendDomFocus(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>,
|
||||
effectiveSessionId?: string
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
if (this.webContents.isDestroyed()) {
|
||||
this.sendError(clientId, 'Browser tab is no longer available', client)
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const result = await this.sendDebuggerCommand('DOM.focus', params, effectiveSessionId)
|
||||
this.sendResult(clientId, result, client)
|
||||
return { ...params }
|
||||
} catch (err) {
|
||||
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async forwardInsertText(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>,
|
||||
effectiveSessionId?: string
|
||||
): Promise<void> {
|
||||
const pendingFocus = this.pendingDomFocusBySession.get(effectiveSessionId)
|
||||
this.pendingDomFocusBySession.delete(effectiveSessionId)
|
||||
const pendingFocusParams = pendingFocus ? await pendingFocus : undefined
|
||||
// Why: the client can disconnect while DOM.focus is in flight; don't replay its
|
||||
// focus or forward its insert into the live page once it is no longer active.
|
||||
if (!this.isActiveClient(client)) {
|
||||
return
|
||||
}
|
||||
if (pendingFocusParams) {
|
||||
if (this.webContents.isDestroyed()) {
|
||||
this.sendError(clientId, 'Browser tab is no longer available', client)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.sendDebuggerCommand('DOM.focus', pendingFocusParams, effectiveSessionId)
|
||||
} catch (err) {
|
||||
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
|
||||
return
|
||||
}
|
||||
// Why: the replay DOM.focus also awaited a round-trip; bail if the client vanished
|
||||
// during it so its insert never lands in the live page.
|
||||
if (!this.isActiveClient(client)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
this.forwardCommand(client, clientId, 'Input.insertText', params, effectiveSessionId)
|
||||
}
|
||||
|
||||
private async handlePrintToPdf(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
if (this.webContents.isDestroyed()) {
|
||||
this.sendError(clientId, 'Browser tab is no longer available', client)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params))
|
||||
// Why: printToPDF can resolve after the client disconnected (or was
|
||||
// replaced). Bail before registering a stream so its buffer isn't
|
||||
// orphaned in pdfStreams past the disconnect's clear() until the TTL.
|
||||
if (!this.isActiveClient(client)) {
|
||||
return
|
||||
}
|
||||
const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf)
|
||||
if (params.transferMode === 'ReturnAsStream') {
|
||||
const handle = this.pdfStreams.create(buffer)
|
||||
this.sendResult(clientId, { data: '', stream: handle }, client)
|
||||
return
|
||||
}
|
||||
this.sendResult(clientId, { data: buffer.toString('base64') }, client)
|
||||
} catch (err) {
|
||||
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
|
||||
}
|
||||
}
|
||||
|
||||
private handleStreamRead(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>
|
||||
): void {
|
||||
const chunk = this.pdfStreams.read(params)
|
||||
if (!chunk) {
|
||||
this.sendError(clientId, 'Invalid stream handle', client)
|
||||
return
|
||||
}
|
||||
this.sendResult(clientId, { base64Encoded: true, data: chunk.data, eof: chunk.eof }, client)
|
||||
}
|
||||
|
||||
private handleStreamClose(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>
|
||||
): void {
|
||||
this.pdfStreams.close(params)
|
||||
this.sendResult(clientId, {}, client)
|
||||
}
|
||||
|
||||
private handleScreenshot(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
|
||||
@@ -269,6 +269,12 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
expect(script).toContain('#!/bin/sh')
|
||||
expect(script).toContain('DEVIN_PROJECT_DIR')
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive),
|
||||
// matching the Windows curl.exe hook post.
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
|
||||
})
|
||||
|
||||
|
||||
@@ -113,7 +113,10 @@ function getManagedScript(
|
||||
// shell is not safe once a path contains quotes or newlines. Post the raw
|
||||
// hook payload plus metadata as form fields and let the receiver parse it.
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -123,7 +126,7 @@ function getManagedScript(
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -32,6 +32,6 @@ ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e ${quoteShell(APPIMAGE_CLI_SCRIPT)} --
|
||||
`
|
||||
}
|
||||
|
||||
function quoteShell(value: string): string {
|
||||
export function quoteShell(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { isPackaged: true }
|
||||
}))
|
||||
|
||||
import { installLinuxBareOrcaDispatcher } from './linux-bare-orca-dispatcher'
|
||||
|
||||
const created: string[] = []
|
||||
|
||||
async function makeFixture(): Promise<{ homePath: string; resourcesPath: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-'))
|
||||
created.push(root)
|
||||
const resourcesPath = join(root, 'resources')
|
||||
// The bundled orca-ide launcher must exist for the dispatcher to be written.
|
||||
await mkdir(join(resourcesPath, 'bin'), { recursive: true })
|
||||
await writeFile(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8')
|
||||
return { homePath: join(root, 'home'), resourcesPath }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('installLinuxBareOrcaDispatcher', () => {
|
||||
it('writes an executable bare-orca dispatcher that execs the bundled orca-ide launcher', async () => {
|
||||
const { homePath, resourcesPath } = await makeFixture()
|
||||
|
||||
const result = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath,
|
||||
homePath,
|
||||
appImagePath: null
|
||||
})
|
||||
|
||||
const expectedTarget = join(resourcesPath, 'bin', 'orca-ide')
|
||||
expect(result.state).toBe('installed')
|
||||
expect(result.target).toBe(expectedTarget)
|
||||
expect(result.dispatcherPath).toBe(join(homePath, '.local', 'bin', 'orca'))
|
||||
|
||||
const content = await readFile(result.dispatcherPath, 'utf8')
|
||||
expect(content).toContain('#!/usr/bin/env bash')
|
||||
// Single-quoted so a resources path with shell metacharacters can't break out.
|
||||
expect(content).toContain(`exec '${expectedTarget}' "$@"`)
|
||||
|
||||
const mode = (await stat(result.dispatcherPath)).mode & 0o777
|
||||
expect(mode & 0o111).not.toBe(0)
|
||||
})
|
||||
|
||||
it('is idempotent — a second install rewrites its own dispatcher without throwing', async () => {
|
||||
const { homePath, resourcesPath } = await makeFixture()
|
||||
|
||||
const first = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath,
|
||||
homePath,
|
||||
appImagePath: null
|
||||
})
|
||||
const second = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath,
|
||||
homePath,
|
||||
appImagePath: null
|
||||
})
|
||||
|
||||
expect(second).toEqual(first)
|
||||
expect(second.state).toBe('installed')
|
||||
})
|
||||
|
||||
it('quotes a resources path containing spaces so the exec line cannot be split', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-space-'))
|
||||
created.push(root)
|
||||
const resourcesPath = join(root, 'App Support', 'resources')
|
||||
await mkdir(join(resourcesPath, 'bin'), { recursive: true })
|
||||
await writeFile(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8')
|
||||
|
||||
const result = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath,
|
||||
homePath: join(root, 'home'),
|
||||
appImagePath: null
|
||||
})
|
||||
|
||||
const content = await readFile(result.dispatcherPath, 'utf8')
|
||||
expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`)
|
||||
})
|
||||
|
||||
it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => {
|
||||
const { homePath, resourcesPath } = await makeFixture()
|
||||
const appImagePath = join(homePath, 'Applications', 'Orca.AppImage')
|
||||
|
||||
const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, appImagePath })
|
||||
|
||||
expect(result.state).toBe('installed')
|
||||
expect(result.target).toBe(appImagePath)
|
||||
const content = await readFile(result.dispatcherPath, 'utf8')
|
||||
// The AppImage wrapper references the stable outer path, never resourcesPath.
|
||||
expect(content).toContain(appImagePath)
|
||||
expect(content).not.toContain(resourcesPath)
|
||||
})
|
||||
|
||||
it('skips (does not clobber) a user-owned orca already at ~/.local/bin', async () => {
|
||||
const { homePath, resourcesPath } = await makeFixture()
|
||||
const dispatcherPath = join(homePath, '.local', 'bin', 'orca')
|
||||
await mkdir(join(homePath, '.local', 'bin'), { recursive: true })
|
||||
await writeFile(dispatcherPath, '#!/bin/sh\necho my own orca\n', 'utf8')
|
||||
|
||||
const result = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath,
|
||||
homePath,
|
||||
appImagePath: null
|
||||
})
|
||||
|
||||
expect(result.state).toBe('skipped-foreign')
|
||||
expect(await readFile(dispatcherPath, 'utf8')).toBe('#!/bin/sh\necho my own orca\n')
|
||||
})
|
||||
|
||||
it('skips when the bundled orca-ide launcher is missing from the build', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-nolauncher-'))
|
||||
created.push(root)
|
||||
|
||||
const result = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath: join(root, 'resources'),
|
||||
homePath: join(root, 'home'),
|
||||
appImagePath: null
|
||||
})
|
||||
|
||||
expect(result.state).toBe('skipped-launcher-missing')
|
||||
expect(result.target).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { buildAppImageCliWrapper, quoteShell } from './appimage-cli-wrapper'
|
||||
import { getBundledLauncherPath } from './cli-installer'
|
||||
|
||||
// Why: marks a dispatcher this function wrote so repeat serve starts overwrite
|
||||
// our own file idempotently but never clobber a user's own ~/.local/bin/orca.
|
||||
const DISPATCHER_MARKER = '# orca-serve-bare-orca-dispatcher'
|
||||
|
||||
export type LinuxBareOrcaDispatcherOptions = {
|
||||
/** Packaged app resources root; the bundled `orca-ide` launcher lives under it. */
|
||||
resourcesPath: string
|
||||
/** Test seam — defaults to the real home directory. */
|
||||
homePath?: string
|
||||
/** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */
|
||||
appImagePath?: string | null
|
||||
}
|
||||
|
||||
export type LinuxBareOrcaDispatcherState =
|
||||
| 'installed'
|
||||
| 'skipped-foreign'
|
||||
| 'skipped-launcher-missing'
|
||||
|
||||
export type LinuxBareOrcaDispatcherResult = {
|
||||
state: LinuxBareOrcaDispatcherState
|
||||
dispatcherPath: string
|
||||
/** What the dispatcher execs: the stable AppImage, or the bundled orca-ide. */
|
||||
target: string | null
|
||||
}
|
||||
|
||||
// Why: on Linux the CLI installs as `orca-ide`, not bare `orca`, to avoid
|
||||
// shadowing GNOME Orca's /usr/bin/orca. But the Claude Team launcher typed into
|
||||
// the initial managed terminal invokes the literal `orca claude-teams`, so a
|
||||
// headless serve box needs a bare-`orca` dispatcher on the managed-terminal PATH
|
||||
// (~/.local/bin, which patchPackagedProcessPath puts ahead of /usr/bin). It is a
|
||||
// plain file, not a managed symlink, so CliInstaller.removeLegacyLinuxCommandIfManaged
|
||||
// never reclaims it.
|
||||
export async function installLinuxBareOrcaDispatcher(
|
||||
options: LinuxBareOrcaDispatcherOptions
|
||||
): Promise<LinuxBareOrcaDispatcherResult> {
|
||||
const dispatcherPath = join(options.homePath ?? homedir(), '.local', 'bin', 'orca')
|
||||
const appImagePath = options.appImagePath ?? process.env.APPIMAGE ?? null
|
||||
|
||||
const resolved = resolveDispatcherScript(options.resourcesPath, appImagePath)
|
||||
if (!resolved) {
|
||||
return { state: 'skipped-launcher-missing', dispatcherPath, target: null }
|
||||
}
|
||||
|
||||
// Why: only (re)write a dispatcher we previously created; leave a user's own
|
||||
// `orca` untouched rather than silently clobbering it on every serve start.
|
||||
if (existsSync(dispatcherPath) && !(await isOwnedDispatcher(dispatcherPath))) {
|
||||
return { state: 'skipped-foreign', dispatcherPath, target: resolved.target }
|
||||
}
|
||||
|
||||
await mkdir(dirname(dispatcherPath), { recursive: true })
|
||||
await writeFile(dispatcherPath, resolved.script, 'utf8')
|
||||
await chmod(dispatcherPath, 0o755)
|
||||
return { state: 'installed', dispatcherPath, target: resolved.target }
|
||||
}
|
||||
|
||||
function resolveDispatcherScript(
|
||||
resourcesPath: string,
|
||||
appImagePath: string | null
|
||||
): { script: string; target: string } | null {
|
||||
if (appImagePath) {
|
||||
// Why: an AppImage mounts resources under an ephemeral FUSE path per launch,
|
||||
// so the dispatcher must exec the stable outer AppImage — reuse the same
|
||||
// wrapper CliInstaller installs for the AppImage command.
|
||||
return { script: withMarker(buildAppImageCliWrapper(appImagePath)), target: appImagePath }
|
||||
}
|
||||
|
||||
const launcher = getBundledLauncherPath('linux', resourcesPath)
|
||||
// Why: getBundledLauncherPath only joins the path; guard existence so we never
|
||||
// write a dispatcher pointing at a missing launcher (which would fail at exec
|
||||
// time with a confusing error instead of the command-not-found we fix).
|
||||
if (!launcher || !existsSync(launcher)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
script: `#!/usr/bin/env bash\n${DISPATCHER_MARKER}\nexec ${quoteShell(launcher)} "$@"\n`,
|
||||
target: launcher
|
||||
}
|
||||
}
|
||||
|
||||
function withMarker(script: string): string {
|
||||
const firstNewline = script.indexOf('\n')
|
||||
if (firstNewline === -1) {
|
||||
return `${script}\n${DISPATCHER_MARKER}\n`
|
||||
}
|
||||
// Keep the shebang on line 1; insert the marker immediately after it.
|
||||
return `${script.slice(0, firstNewline + 1)}${DISPATCHER_MARKER}\n${script.slice(firstNewline + 1)}`
|
||||
}
|
||||
|
||||
async function isOwnedDispatcher(dispatcherPath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await readFile(dispatcherPath, 'utf8')).includes(DISPATCHER_MARKER)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -712,7 +712,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// shell is not safe once a path contains quotes or newlines. Post the raw
|
||||
// hook payload plus metadata as form fields and let the receiver parse it.
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -722,7 +725,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -121,7 +121,10 @@ export function buildCommandCodeManagedScript(
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/command-code" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/command-code" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -131,7 +134,7 @@ export function buildCommandCodeManagedScript(
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -166,7 +166,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
'if [ -z "$payload" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -177,7 +180,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -77,7 +77,12 @@ describe('CursorHookService', () => {
|
||||
if (process.platform === 'win32') {
|
||||
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
|
||||
} else {
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive).
|
||||
expect(script).toContain('payload=$(cat)')
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -104,7 +104,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// shell is not safe once a path contains quotes or newlines. Post the raw
|
||||
// hook payload plus metadata as form fields and let the receiver parse it.
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -114,7 +117,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -64,6 +64,11 @@ describe('DevinHookService', () => {
|
||||
}
|
||||
const script = readFileSync(getDevinManagedScriptPath(), 'utf8')
|
||||
expect(script).toContain('/hook/devin')
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive).
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
})
|
||||
|
||||
it('preserves unrelated keys in Devin config when installing hooks', () => {
|
||||
|
||||
@@ -80,7 +80,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// shell is not safe once a path contains quotes or newlines. Post the raw
|
||||
// hook payload plus metadata as form fields and let the receiver parse it.
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -90,7 +93,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -92,7 +92,10 @@ function getManagedScript(): string {
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -102,7 +105,7 @@ function getManagedScript(): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -97,7 +97,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// shell is not safe once a path contains quotes or newlines. Post the raw
|
||||
// hook payload plus metadata as form fields and let the receiver parse it.
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -107,7 +110,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { extractExecError, ghExecFileAsync, gitExecFileAsync } from './runner'
|
||||
import { parseHostedRemote } from './hosted-remote-url'
|
||||
import { resolveDefaultBaseRefViaExec } from './repo'
|
||||
|
||||
const EXPLICIT_USERNAME_CONFIG_KEYS = ['github.user', 'user.username'] as const
|
||||
|
||||
const GH_LOGIN_PROBE_TIMEOUT_MS = 2500
|
||||
// Why: a timeout-killed gh can leave a grandchild holding the stdio pipes, so
|
||||
// the exec promise may settle long after the kill. The wall keeps the resolver
|
||||
// on schedule either way (issue #7225: a hung gh froze startup for 127s). It
|
||||
// must exceed ghExecFileAsync's full worst-case envelope — 3 attempts ×
|
||||
// GH_LOGIN_PROBE_TIMEOUT_MS plus its transient-retry backoff sleeps (250ms +
|
||||
// 1000ms ≈ 8.75s total) — or a slow-but-recovering gh would be misread as
|
||||
// stuck and start the retry cooldown. Retry-After sleeps can exceed any wall;
|
||||
// bounding those is exactly what the wall is for.
|
||||
const GH_LOGIN_PROBE_WALL_MS = 10_000
|
||||
// Why: a timed-out probe says nothing about the account, so don't pin '' for
|
||||
// the whole session — retry after a cooldown instead of hammering a stuck gh.
|
||||
const GH_LOGIN_TIMEOUT_RETRY_MS = 5 * 60 * 1000
|
||||
const LOCAL_GIT_READ_TIMEOUT_MS = 5000
|
||||
|
||||
export function normalizeGitUsername(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
@@ -12,6 +30,13 @@ export function normalizeGitUsername(value: string): string {
|
||||
return localPart.replace(/^\d+\+/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved username plus whether every probe on the way to it completed.
|
||||
* Non-authoritative '' (a probe timed out) must not overwrite a previously
|
||||
* persisted username; authoritative '' should clear one.
|
||||
*/
|
||||
export type ResolvedGitUsername = { username: string; authoritative: boolean }
|
||||
|
||||
export async function getSshGitUsername(
|
||||
provider: SshGitProvider,
|
||||
repoPath: string
|
||||
@@ -31,3 +56,241 @@ export async function getSshGitUsername(
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
type GhLoginProbeResult = { stdout: string; stderr: string; timedOut: boolean }
|
||||
type GhLoginOutcome = { login: string; timedOut: boolean }
|
||||
|
||||
// gh reports one account for the whole machine, so the login is cached
|
||||
// per-process rather than per-repo (mirrors the old sync cache). Timed-out
|
||||
// probes use the soft retry timestamp instead of the permanent cache.
|
||||
let cachedGhLogin: string | null = null
|
||||
let ghLoginTimedOutAt: number | null = null
|
||||
let ghLoginProbeInFlight: Promise<GhLoginOutcome> | null = null
|
||||
|
||||
function isExecTimeoutError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false
|
||||
}
|
||||
const { code, killed, signal } = err as { code?: unknown; killed?: unknown; signal?: unknown }
|
||||
// Why: on Windows a timeout kill surfaces as killed/SIGTERM with a null
|
||||
// code, not ETIMEDOUT — the old ETIMEDOUT-only check let a stuck first
|
||||
// probe fall through to a second equally stuck probe (issue #7225).
|
||||
return code === 'ETIMEDOUT' || killed === true || signal === 'SIGTERM'
|
||||
}
|
||||
|
||||
async function runGhLoginProbe(args: string[]): Promise<GhLoginProbeResult> {
|
||||
let wallTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const wall = new Promise<GhLoginProbeResult>((resolve) => {
|
||||
wallTimer = setTimeout(
|
||||
() => resolve({ stdout: '', stderr: '', timedOut: true }),
|
||||
GH_LOGIN_PROBE_WALL_MS
|
||||
)
|
||||
wallTimer.unref?.()
|
||||
})
|
||||
const exec = ghExecFileAsync(args, { timeout: GH_LOGIN_PROBE_TIMEOUT_MS }).then(
|
||||
({ stdout, stderr }) => ({ stdout, stderr, timedOut: false }),
|
||||
(err: unknown) => {
|
||||
// Why: `gh auth status` reports the login on stderr with a non-zero
|
||||
// exit when partially authenticated, so failures still carry output.
|
||||
const { stdout, stderr } = extractExecError(err)
|
||||
return { stdout, stderr, timedOut: isExecTimeoutError(err) }
|
||||
}
|
||||
)
|
||||
try {
|
||||
return await Promise.race([exec, wall])
|
||||
} finally {
|
||||
if (wallTimer) {
|
||||
clearTimeout(wallTimer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: `gh auth status` prints one block per account — the login line first,
|
||||
// then `Active account: true/false`. Parse block-wise so a multi-account
|
||||
// output resolves the ACTIVE account instead of whatever login line happens
|
||||
// to follow the first `Active account: true` marker.
|
||||
function parseGhAuthStatusLogin(output: string): string {
|
||||
let currentLogin = ''
|
||||
let firstLogin = ''
|
||||
for (const line of output.split('\n')) {
|
||||
const login = line.match(/Logged in to github\.com account\s+([A-Za-z0-9-]+)/)?.[1]
|
||||
if (login) {
|
||||
currentLogin = login
|
||||
if (!firstLogin) {
|
||||
firstLogin = login
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (/Active account:\s+true/.test(line) && currentLogin) {
|
||||
return currentLogin
|
||||
}
|
||||
}
|
||||
return firstLogin
|
||||
}
|
||||
|
||||
async function probeGhLoginOnce(): Promise<GhLoginOutcome> {
|
||||
const api = await runGhLoginProbe(['api', 'user', '-q', '.login'])
|
||||
const apiLogin = normalizeGitUsername(api.stdout.trim())
|
||||
if (apiLogin) {
|
||||
return { login: apiLogin, timedOut: false }
|
||||
}
|
||||
if (api.timedOut) {
|
||||
// Why: if `gh api user` timed out, `gh auth status` is likely to hit the
|
||||
// same stuck keychain/network path. Keep resolution bounded to one probe.
|
||||
return { login: '', timedOut: true }
|
||||
}
|
||||
const status = await runGhLoginProbe(['auth', 'status'])
|
||||
if (status.timedOut) {
|
||||
return { login: '', timedOut: true }
|
||||
}
|
||||
const output = `${status.stdout}\n${status.stderr}`
|
||||
return { login: normalizeGitUsername(parseGhAuthStatusLogin(output)), timedOut: false }
|
||||
}
|
||||
|
||||
async function getGhLoginOutcome(): Promise<GhLoginOutcome> {
|
||||
if (cachedGhLogin !== null) {
|
||||
return { login: cachedGhLogin, timedOut: false }
|
||||
}
|
||||
if (ghLoginTimedOutAt !== null && Date.now() - ghLoginTimedOutAt < GH_LOGIN_TIMEOUT_RETRY_MS) {
|
||||
return { login: '', timedOut: true }
|
||||
}
|
||||
if (ghLoginProbeInFlight) {
|
||||
return ghLoginProbeInFlight
|
||||
}
|
||||
const probe = probeGhLoginOnce()
|
||||
.then((outcome) => {
|
||||
if (outcome.timedOut) {
|
||||
ghLoginTimedOutAt = Date.now()
|
||||
} else {
|
||||
cachedGhLogin = outcome.login
|
||||
ghLoginTimedOutAt = null
|
||||
}
|
||||
return outcome
|
||||
})
|
||||
.finally(() => {
|
||||
ghLoginProbeInFlight = null
|
||||
})
|
||||
ghLoginProbeInFlight = probe
|
||||
return probe
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `gh` CLI's GitHub login without ever blocking the caller for
|
||||
* longer than the probe wall. Never rejects; unknown resolves to ''.
|
||||
*/
|
||||
export async function getGhLoginAsync(): Promise<string> {
|
||||
return (await getGhLoginOutcome()).login
|
||||
}
|
||||
|
||||
async function readGitStdout(repoPath: string, args: string[]): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(args, {
|
||||
cwd: repoPath,
|
||||
timeout: LOCAL_GIT_READ_TIMEOUT_MS
|
||||
})
|
||||
return stdout.trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getRemoteNameFromRef(shortRef: string, remotes: readonly string[]): string {
|
||||
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
|
||||
return sortedRemotes.find((remote) => shortRef.startsWith(`${remote}/`)) ?? ''
|
||||
}
|
||||
|
||||
function getDefaultBranchName(shortRef: string, remoteName: string): string {
|
||||
if (!shortRef.includes('/')) {
|
||||
return shortRef
|
||||
}
|
||||
return remoteName ? shortRef.slice(remoteName.length + 1) : shortRef.split('/').slice(1).join('/')
|
||||
}
|
||||
|
||||
async function getConfiguredBranchRemote(repoPath: string, branch: string | null): Promise<string> {
|
||||
if (!branch) {
|
||||
return ''
|
||||
}
|
||||
const remote = await readGitStdout(repoPath, ['config', '--get', `branch.${branch}.remote`])
|
||||
return remote === '.' ? '' : remote
|
||||
}
|
||||
|
||||
/**
|
||||
* Faithful async port of the old candidate-ordered GitHub gate: only the
|
||||
* repo's *effective* remote (current-branch remote, default-branch remote,
|
||||
* default-base remote, origin, or a lone remote) may authorize the gh login.
|
||||
* Why: a GitLab-primary repo with a secondary GitHub mirror must NOT pick up
|
||||
* the GitHub account name as its branch prefix.
|
||||
*/
|
||||
async function localRepoHasEffectiveGitHubRemote(repoPath: string): Promise<boolean> {
|
||||
const remotes = (await readGitStdout(repoPath, ['remote'])).split('\n').filter(Boolean)
|
||||
const defaultBaseRef = await resolveDefaultBaseRefViaExec((argv) =>
|
||||
gitExecFileAsync(argv, { cwd: repoPath, timeout: LOCAL_GIT_READ_TIMEOUT_MS })
|
||||
)
|
||||
const defaultBaseRemote = defaultBaseRef ? getRemoteNameFromRef(defaultBaseRef, remotes) : ''
|
||||
const defaultBranch = defaultBaseRef
|
||||
? getDefaultBranchName(defaultBaseRef, defaultBaseRemote)
|
||||
: null
|
||||
|
||||
const currentBranch = await readGitStdout(repoPath, ['branch', '--show-current'])
|
||||
const candidateRemotes = [
|
||||
await getConfiguredBranchRemote(repoPath, currentBranch || null),
|
||||
await getConfiguredBranchRemote(repoPath, defaultBranch),
|
||||
defaultBaseRemote,
|
||||
'origin',
|
||||
remotes.length === 1 ? remotes[0] : ''
|
||||
]
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const remote of candidateRemotes) {
|
||||
if (!remote || seen.has(remote)) {
|
||||
continue
|
||||
}
|
||||
seen.add(remote)
|
||||
const remoteUrl = await readGitStdout(repoPath, ['remote', 'get-url', remote])
|
||||
if (remoteUrl && parseHostedRemote(remoteUrl)?.provider === 'github') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Async replacement for the old sync `getGitUsername`: explicit config keys
|
||||
* first, then the `gh` login — but only for repos whose effective remote is
|
||||
* GitHub, since a GitHub account name would be the wrong branch prefix for
|
||||
* GitLab/Bitbucket/self-hosted repos. Never rejects; unknown resolves to
|
||||
* { username: '', authoritative: false }.
|
||||
*/
|
||||
export async function resolveLocalGitUsernameDetailed(
|
||||
repoPath: string
|
||||
): Promise<ResolvedGitUsername> {
|
||||
for (const key of EXPLICIT_USERNAME_CONFIG_KEYS) {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['config', '--get', key], {
|
||||
cwd: repoPath,
|
||||
timeout: LOCAL_GIT_READ_TIMEOUT_MS
|
||||
})
|
||||
const username = normalizeGitUsername(stdout)
|
||||
if (username) {
|
||||
return { username, authoritative: true }
|
||||
}
|
||||
} catch {
|
||||
// Missing config keys are expected; try the next explicit username key.
|
||||
}
|
||||
}
|
||||
if (await localRepoHasEffectiveGitHubRemote(repoPath)) {
|
||||
const outcome = await getGhLoginOutcome()
|
||||
return { username: outcome.login, authoritative: !outcome.timedOut }
|
||||
}
|
||||
return { username: '', authoritative: true }
|
||||
}
|
||||
|
||||
export async function resolveLocalGitUsername(repoPath: string): Promise<string> {
|
||||
return (await resolveLocalGitUsernameDetailed(repoPath)).username
|
||||
}
|
||||
|
||||
export function resetGhLoginCacheForTests(): void {
|
||||
cachedGhLogin = null
|
||||
ghLoginTimedOutAt = null
|
||||
ghLoginProbeInFlight = null
|
||||
}
|
||||
|
||||
@@ -1,160 +1,271 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ChildProcess from 'node:child_process'
|
||||
import type * as RepoModule from './repo'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as RunnerModule from './runner'
|
||||
|
||||
const execSyncMock = vi.hoisted(() => vi.fn())
|
||||
const execFileSyncMock = vi.hoisted(() => vi.fn())
|
||||
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
|
||||
const ghExecFileAsyncMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
const actual = await vi.importActual<typeof ChildProcess>('child_process')
|
||||
vi.mock('./runner', async () => {
|
||||
const actual = await vi.importActual<typeof RunnerModule>('./runner')
|
||||
return {
|
||||
...actual,
|
||||
execSync: execSyncMock,
|
||||
execFileSync: execFileSyncMock
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
ghExecFileAsync: ghExecFileAsyncMock
|
||||
}
|
||||
})
|
||||
|
||||
describe('getGitUsername', () => {
|
||||
import {
|
||||
resolveLocalGitUsername,
|
||||
resolveLocalGitUsernameDetailed,
|
||||
resetGhLoginCacheForTests
|
||||
} from './git-username'
|
||||
|
||||
function makeExecError(
|
||||
message: string,
|
||||
extra: { code?: string; killed?: boolean; signal?: string; stderr?: string } = {}
|
||||
): Error {
|
||||
return Object.assign(new Error(message), { stdout: '', stderr: '', ...extra })
|
||||
}
|
||||
|
||||
describe('resolveLocalGitUsername', () => {
|
||||
let gitConfig: Record<string, string>
|
||||
let originRemoteUrl: string | undefined
|
||||
let remoteUrls: Record<string, string>
|
||||
let getGitUsername: typeof RepoModule.getGitUsername
|
||||
let currentBranch: string
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
execSyncMock.mockReset()
|
||||
execFileSyncMock.mockReset()
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
resetGhLoginCacheForTests()
|
||||
gitConfig = {}
|
||||
originRemoteUrl = undefined
|
||||
remoteUrls = {}
|
||||
currentBranch = ''
|
||||
|
||||
execFileSyncMock.mockImplementation((_binary: string, args: string[]) => {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'config' && args[1] === '--get') {
|
||||
const value = gitConfig[args[2]]
|
||||
if (value !== undefined) {
|
||||
return `${value}\n`
|
||||
return { stdout: `${value}\n`, stderr: '' }
|
||||
}
|
||||
throw new Error(`missing config ${args[2]}`)
|
||||
throw makeExecError(`missing config ${args[2]}`)
|
||||
}
|
||||
if (args[0] === 'remote' && args.length === 1) {
|
||||
const remotes = new Set(Object.keys(remoteUrls))
|
||||
if (originRemoteUrl) {
|
||||
remotes.add('origin')
|
||||
}
|
||||
return `${[...remotes].join('\n')}\n`
|
||||
return { stdout: `${[...remotes].join('\n')}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
const remoteUrl = args[2] === 'origin' ? originRemoteUrl : remoteUrls[args[2]]
|
||||
if (remoteUrl) {
|
||||
return `${remoteUrl}\n`
|
||||
return { stdout: `${remoteUrl}\n`, stderr: '' }
|
||||
}
|
||||
throw new Error(`missing ${args[2]} remote`)
|
||||
throw makeExecError(`missing ${args[2]} remote`)
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
return { stdout: `${currentBranch}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
// origin/HEAD unset — resolveDefaultBaseRefViaExec falls through to probes.
|
||||
throw makeExecError('no origin/HEAD')
|
||||
}
|
||||
if (args[0] === 'rev-parse') {
|
||||
throw makeExecError(`missing ref ${args.at(-1)}`)
|
||||
}
|
||||
throw makeExecError(`unexpected git args: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
;({ getGitUsername } = await import('./repo'))
|
||||
})
|
||||
|
||||
it('prefers explicit GitHub user config before checking GitHub CLI login', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('prefers explicit GitHub user config before checking GitHub CLI login', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
gitConfig['github.user'] = 'config-demo'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('config-demo')
|
||||
expect(execSyncMock).not.toHaveBeenCalled()
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('config-demo')
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses explicit username config before checking GitHub CLI login', () => {
|
||||
it('uses explicit username config before checking GitHub CLI login', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
gitConfig['user.username'] = 'repo-demo'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('repo-demo')
|
||||
expect(execSyncMock).not.toHaveBeenCalled()
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('repo-demo')
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses GitHub CLI login for GitHub remotes instead of repo-local author identity', () => {
|
||||
it('uses GitHub CLI login for GitHub remotes instead of repo-local author identity', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
gitConfig['user.email'] = 'demo@example.com'
|
||||
gitConfig['user.name'] = 'Demo User'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('gh-demo')
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(1)
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses GitHub CLI login for a single GitHub remote not named origin', () => {
|
||||
it('uses GitHub CLI login for a single GitHub remote not named origin', async () => {
|
||||
remoteUrls.upstream = 'https://github.com/stablyai/orca.git'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('gh-demo')
|
||||
expect(execFileSyncMock.mock.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
['git', ['remote', 'get-url', 'upstream'], expect.objectContaining({ cwd: '/repo' })]
|
||||
])
|
||||
)
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(1)
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses GitHub CLI login for GitHub SSH-over-443 remotes', () => {
|
||||
it('uses GitHub CLI login for GitHub SSH-over-443 remotes', async () => {
|
||||
remoteUrls.upstream = 'ssh://git@ssh.github.com:443/stablyai/orca.git'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('gh-demo')
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(1)
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not derive GitHub username prefixes from non-GitHub author identity', () => {
|
||||
it('does not derive GitHub username prefixes from non-GitHub remotes', async () => {
|
||||
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
|
||||
gitConfig['user.email'] = 'demo@example.com'
|
||||
gitConfig['user.name'] = 'Demo User'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('')
|
||||
expect(execSyncMock).not.toHaveBeenCalled()
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds and caches failed GitHub CLI lookup', () => {
|
||||
it('ignores a secondary GitHub mirror when the effective remote is GitLab', async () => {
|
||||
// Why: a GitLab-primary repo with a GitHub mirror must not pick up the
|
||||
// GitHub account name as its branch prefix — only the effective remote
|
||||
// (branch remote / default base remote / origin / lone remote) counts.
|
||||
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
|
||||
remoteUrls['github-mirror'] = 'https://github.com/stablyai/orca.git'
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds and caches failed GitHub CLI lookup', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
execSyncMock.mockImplementation(() => {
|
||||
throw new Error('gh unavailable')
|
||||
})
|
||||
ghExecFileAsyncMock.mockRejectedValue(makeExecError('gh unavailable'))
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('')
|
||||
expect(getGitUsername('/repo')).toBe('')
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(2)
|
||||
for (const [, options] of execSyncMock.mock.calls) {
|
||||
// api + auth-status fallback on the first resolution; cached afterwards.
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
for (const [, options] of ghExecFileAsyncMock.mock.calls) {
|
||||
expect(options).toMatchObject({ timeout: 2500 })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips auth status fallback when GitHub CLI API lookup times out', () => {
|
||||
it('skips auth status fallback when GitHub CLI API lookup times out', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
execSyncMock.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error('spawnSync /bin/sh ETIMEDOUT'), { code: 'ETIMEDOUT' })
|
||||
})
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(
|
||||
makeExecError('spawnSync gh ETIMEDOUT', { code: 'ETIMEDOUT' })
|
||||
)
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('')
|
||||
expect(getGitUsername('/repo')).toBe('')
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(execSyncMock.mock.calls[0][1]).toMatchObject({ timeout: 2500 })
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses auth status fallback after fast GitHub CLI API failure', () => {
|
||||
it('treats a Windows timeout kill (SIGTERM, no ETIMEDOUT code) as a timeout', async () => {
|
||||
// Why: on Windows the exec timeout kill surfaces killed/SIGTERM without an
|
||||
// ETIMEDOUT code; the old sync probe missed this and ran a second equally
|
||||
// stuck probe (issue #7225).
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
execSyncMock
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('gh api unavailable')
|
||||
})
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
'github.com\n ✓ Logged in to github.com account demo-user\n - Active account: true\n'
|
||||
)
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(
|
||||
makeExecError('gh was killed', { killed: true, signal: 'SIGTERM' })
|
||||
)
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('demo-user')
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(2)
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('marks a timed-out gh probe non-authoritative and retries after the cooldown', async () => {
|
||||
vi.useFakeTimers()
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(makeExecError('gh timeout', { code: 'ETIMEDOUT' }))
|
||||
.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
|
||||
username: '',
|
||||
authoritative: false
|
||||
})
|
||||
// Within the cooldown the timeout result is reused without a new spawn.
|
||||
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
|
||||
username: '',
|
||||
authoritative: false
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
|
||||
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
|
||||
username: 'gh-demo',
|
||||
authoritative: true
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports authoritative empty for non-GitHub repos', async () => {
|
||||
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
|
||||
|
||||
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
|
||||
username: '',
|
||||
authoritative: true
|
||||
})
|
||||
})
|
||||
|
||||
it('uses auth status fallback after fast GitHub CLI API failure', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(makeExecError('gh api unavailable'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '',
|
||||
stderr:
|
||||
'github.com\n ✓ Logged in to github.com account demo-user\n - Active account: true\n'
|
||||
})
|
||||
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('demo-user')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('settles within the wall even when the gh child never exits', async () => {
|
||||
vi.useFakeTimers()
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
// A promise that never settles — models a killed gh whose grandchild
|
||||
// keeps the stdio pipes open past the exec timeout.
|
||||
ghExecFileAsyncMock.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
const resolution = resolveLocalGitUsernameDetailed('/repo')
|
||||
await vi.advanceTimersByTimeAsync(10_100)
|
||||
await expect(resolution).resolves.toEqual({ username: '', authoritative: false })
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('picks the active account from a multi-account auth status output', async () => {
|
||||
// Why: each account block prints its login line BEFORE its
|
||||
// "Active account" marker; a cross-block regex would capture the next
|
||||
// block's login instead of the active one.
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(makeExecError('gh api unavailable'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '',
|
||||
stderr: [
|
||||
'github.com',
|
||||
' ✓ Logged in to github.com account active-user (keyring)',
|
||||
' - Active account: true',
|
||||
' - Git operations protocol: https',
|
||||
' ✓ Logged in to github.com account inactive-user (keyring)',
|
||||
' - Active account: false'
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('active-user')
|
||||
})
|
||||
})
|
||||
|
||||
+1
-186
@@ -1,5 +1,4 @@
|
||||
/* oxlint-disable max-lines */
|
||||
import { execSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { gitExecFileSync, gitExecFileAsync } from './runner'
|
||||
@@ -8,14 +7,7 @@ import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-outp
|
||||
import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { toWindowsWslPath } from '../wsl'
|
||||
import {
|
||||
buildHostedRemoteCommitUrl,
|
||||
buildHostedRemoteFileUrl,
|
||||
parseHostedRemote
|
||||
} from './hosted-remote-url'
|
||||
import { normalizeGitUsername } from './git-username'
|
||||
|
||||
const GH_LOGIN_TIMEOUT_MS = 2500
|
||||
import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url'
|
||||
|
||||
type LocalGitExecOptions = {
|
||||
wslDistro?: string
|
||||
@@ -464,183 +456,6 @@ function getRemoteUrlByName(path: string, remote: string): string {
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function listRemoteNamesSync(path: string): string[] {
|
||||
try {
|
||||
return gitExecFileSync(['remote'], { cwd: path })
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getConfiguredBranchRemote(path: string, branch: string | null): string {
|
||||
if (!branch) {
|
||||
return ''
|
||||
}
|
||||
const remote = getGitConfigValue(path, `branch.${branch}.remote`)
|
||||
return remote === '.' ? '' : remote
|
||||
}
|
||||
|
||||
function getCurrentBranchName(path: string): string {
|
||||
try {
|
||||
return gitExecFileSync(['branch', '--show-current'], { cwd: path }).trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getRemoteNameFromRef(shortRef: string, remotes: readonly string[]): string {
|
||||
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
|
||||
return sortedRemotes.find((remote) => shortRef.startsWith(`${remote}/`)) ?? ''
|
||||
}
|
||||
|
||||
function getDefaultBranchName(shortRef: string, remoteName: string): string {
|
||||
if (!shortRef.includes('/')) {
|
||||
return shortRef
|
||||
}
|
||||
return remoteName ? shortRef.slice(remoteName.length + 1) : shortRef.split('/').slice(1).join('/')
|
||||
}
|
||||
|
||||
function getGitConfigValue(path: string, key: string): string {
|
||||
try {
|
||||
return gitExecFileSync(['config', '--get', key], {
|
||||
cwd: path
|
||||
}).trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
let cachedGhLogin: string | undefined
|
||||
|
||||
function isGhProbeTimeout(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const err = error as { code?: unknown; message?: unknown }
|
||||
return (
|
||||
err.code === 'ETIMEDOUT' ||
|
||||
(typeof err.message === 'string' && /\bETIMEDOUT\b|timed out/i.test(err.message))
|
||||
)
|
||||
}
|
||||
|
||||
function getGhLogin(): string {
|
||||
if (cachedGhLogin !== undefined) {
|
||||
return cachedGhLogin
|
||||
}
|
||||
|
||||
try {
|
||||
const apiLogin = execSync('gh api user -q .login', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: GH_LOGIN_TIMEOUT_MS
|
||||
}).trim()
|
||||
if (apiLogin) {
|
||||
cachedGhLogin = normalizeGitUsername(apiLogin)
|
||||
return cachedGhLogin
|
||||
}
|
||||
} catch (err) {
|
||||
if (isGhProbeTimeout(err)) {
|
||||
// Why: if `gh api user` timed out, `gh auth status` is likely to hit the
|
||||
// same stuck keychain/network path. Keep repo creation bounded to one probe.
|
||||
cachedGhLogin = ''
|
||||
return ''
|
||||
}
|
||||
// Fall through to auth status parsing
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: gh auth status writes to stderr; redirect via shell so we can capture it.
|
||||
// Use platform-appropriate shell — /bin/bash does not exist on Windows.
|
||||
const output = execSync('gh auth status 2>&1', {
|
||||
encoding: 'utf-8',
|
||||
shell: process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : '/bin/bash',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: GH_LOGIN_TIMEOUT_MS
|
||||
})
|
||||
|
||||
const activeAccountMatch = output.match(
|
||||
/Active account:\s+true[\s\S]*?account\s+([A-Za-z0-9-]+)/
|
||||
)
|
||||
if (activeAccountMatch?.[1]) {
|
||||
cachedGhLogin = normalizeGitUsername(activeAccountMatch[1])
|
||||
return cachedGhLogin
|
||||
}
|
||||
|
||||
const accountMatch = output.match(/Logged in to github\.com account\s+([A-Za-z0-9-]+)/)
|
||||
const login = normalizeGitUsername(accountMatch?.[1] ?? '')
|
||||
if (login) {
|
||||
cachedGhLogin = login
|
||||
}
|
||||
return login
|
||||
} catch {
|
||||
// Why: broken tokens/keychains can block the Electron main process.
|
||||
// Keep the fallback best-effort for this app session.
|
||||
cachedGhLogin = ''
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getGhLoginForGitHubRemote(path: string): string {
|
||||
const remoteUrl = getGitHubRemoteUrlForGhLogin(path)
|
||||
if (!remoteUrl) {
|
||||
return ''
|
||||
}
|
||||
return getGhLogin()
|
||||
}
|
||||
|
||||
function getGitHubRemoteUrlForGhLogin(path: string): string {
|
||||
const remotes = listRemoteNamesSync(path)
|
||||
const defaultBaseRef = getDefaultBaseRef(path)
|
||||
const defaultBaseRemote = defaultBaseRef ? getRemoteNameFromRef(defaultBaseRef, remotes) : ''
|
||||
const defaultBranch = defaultBaseRef
|
||||
? getDefaultBranchName(defaultBaseRef, defaultBaseRemote)
|
||||
: null
|
||||
|
||||
const candidateRemotes = [
|
||||
getConfiguredBranchRemote(path, getCurrentBranchName(path)),
|
||||
getConfiguredBranchRemote(path, defaultBranch),
|
||||
defaultBaseRemote,
|
||||
'origin',
|
||||
remotes.length === 1 ? remotes[0] : ''
|
||||
]
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const remote of candidateRemotes) {
|
||||
if (!remote || seen.has(remote)) {
|
||||
continue
|
||||
}
|
||||
seen.add(remote)
|
||||
try {
|
||||
const remoteUrl = getRemoteUrlByName(path, remote)
|
||||
if (parseHostedRemote(remoteUrl)?.provider === 'github') {
|
||||
return remoteUrl
|
||||
}
|
||||
} catch {
|
||||
// Missing candidate remotes are expected; try the next repo-level fallback.
|
||||
}
|
||||
}
|
||||
// Why: `gh` reports a GitHub account. For GitLab/Bitbucket/self-hosted
|
||||
// repos, using that identity would create the wrong provider prefix.
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GitHub/explicit username-style branch prefix for the repo.
|
||||
*/
|
||||
export function getGitUsername(path: string): string {
|
||||
// Why: this backs the "Git Username" branch-prefix setting. Commit author
|
||||
// email/name are not hosted-account usernames, so keep them out of this path.
|
||||
return normalizeGitUsername(
|
||||
getGitConfigValue(path, 'github.user') ||
|
||||
getGitConfigValue(path, 'user.username') ||
|
||||
getGhLoginForGitHubRemote(path)
|
||||
)
|
||||
}
|
||||
|
||||
function hasGitRef(path: string, ref: string): boolean {
|
||||
try {
|
||||
gitExecFileSync(['rev-parse', '--verify', ref], {
|
||||
|
||||
@@ -2,7 +2,47 @@
|
||||
// (transient detection must propagate, not silently retry on 250ms cadence)
|
||||
// and stderr extraction from execFile rejections (err.message is unreliable).
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { extractExecError, isTransientGhError, parseRetryAfterMs } from './runner'
|
||||
import {
|
||||
extractExecError,
|
||||
isTransientGhError,
|
||||
parseRetryAfterMs,
|
||||
redirectPortedHostnameToEnv
|
||||
} from './runner'
|
||||
|
||||
describe('redirectPortedHostnameToEnv', () => {
|
||||
it('moves a ported --hostname into GITLAB_HOST and strips the flag', () => {
|
||||
const { args, options } = redirectPortedHostnameToEnv(
|
||||
['api', '--hostname', 'gitlab.example.com:8443', 'projects/foo%2Fbar/issues'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
expect(args).toEqual(['api', 'projects/foo%2Fbar/issues'])
|
||||
expect(options.env?.GITLAB_HOST).toBe('gitlab.example.com:8443')
|
||||
expect(options.cwd).toBe('/repo')
|
||||
})
|
||||
|
||||
it('leaves a port-less --hostname untouched', () => {
|
||||
const input = ['api', '--hostname', 'gitlab.com', 'user']
|
||||
const { args, options } = redirectPortedHostnameToEnv(input, {})
|
||||
expect(args).toEqual(input)
|
||||
expect(options.env).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is a no-op when no --hostname is present', () => {
|
||||
const input = ['auth', 'status']
|
||||
const { args, options } = redirectPortedHostnameToEnv(input, { env: { A: '1' } })
|
||||
expect(args).toEqual(input)
|
||||
expect(options.env).toEqual({ A: '1' })
|
||||
})
|
||||
|
||||
it('preserves existing env entries alongside GITLAB_HOST', () => {
|
||||
const { options } = redirectPortedHostnameToEnv(
|
||||
['auth', 'status', '--hostname', 'gl.example.org:3001'],
|
||||
{ env: { PATH: '/usr/bin' } }
|
||||
)
|
||||
expect(options.env?.PATH).toBe('/usr/bin')
|
||||
expect(options.env?.GITLAB_HOST).toBe('gl.example.org:3001')
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
|
||||
+40
-1
@@ -976,6 +976,13 @@ export async function gitStreamStdout(
|
||||
})
|
||||
}
|
||||
|
||||
// Why: sync git calls run on the Electron main thread. Local git is normally
|
||||
// fast, but a repo on a dead network drive / cloud-placeholder path can hang
|
||||
// git on filesystem timeouts for minutes with no timeout set — the leading
|
||||
// explanation for issue #7225's 127s "Not Responding" freeze. Callers needing
|
||||
// longer operations should use the async runners instead.
|
||||
const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000
|
||||
|
||||
/**
|
||||
* Sync git command execution. Drop-in replacement for
|
||||
* `execFileSync('git', args, { cwd, encoding, ... })`.
|
||||
@@ -988,13 +995,15 @@ export function gitExecFileSync(
|
||||
cwd: string
|
||||
encoding?: BufferEncoding
|
||||
stdio?: SpawnOptions['stdio']
|
||||
timeout?: number
|
||||
}
|
||||
): string {
|
||||
const resolved = resolveCommand('git', args, options.cwd)
|
||||
return execFileSync(resolved.binary, resolved.args, {
|
||||
cwd: resolved.cwd,
|
||||
encoding: options.encoding ?? 'utf-8',
|
||||
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe']
|
||||
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'],
|
||||
timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS
|
||||
}) as string
|
||||
}
|
||||
|
||||
@@ -1409,10 +1418,40 @@ type GlabExecOptions = Omit<GitExecOptions, 'cwd'> & {
|
||||
*
|
||||
* Retry policy mirrors ghExecFileAsync.
|
||||
*/
|
||||
/**
|
||||
* glab's `--hostname` flag rejects a host that carries a port
|
||||
* ("error parsing --hostname: invalid hostname"). A self-hosted GitLab on a
|
||||
* non-default port (e.g. `gitlab.example.com:8443`) must instead be selected
|
||||
* via the `GITLAB_HOST` env var, which accepts `host:port`. Translate any
|
||||
* `--hostname host:port` pair into `GITLAB_HOST` so every call site (`api`,
|
||||
* `auth status`, …) works against ported self-hosted instances. Port-less
|
||||
* `--hostname` values are left untouched.
|
||||
*
|
||||
* @internal exported for tests.
|
||||
*/
|
||||
export function redirectPortedHostnameToEnv(
|
||||
args: string[],
|
||||
options: GlabExecOptions
|
||||
): { args: string[]; options: GlabExecOptions } {
|
||||
const i = args.indexOf('--hostname')
|
||||
if (i === -1 || i + 1 >= args.length) {
|
||||
return { args, options }
|
||||
}
|
||||
const host = args[i + 1]
|
||||
if (!/^[^/\s]+:\d+$/.test(host)) {
|
||||
return { args, options }
|
||||
}
|
||||
return {
|
||||
args: [...args.slice(0, i), ...args.slice(i + 2)],
|
||||
options: { ...options, env: { ...(options.env ?? process.env), GITLAB_HOST: host } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function glabExecFileAsync(
|
||||
args: string[],
|
||||
options: GlabExecOptions = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
;({ args, options } = redirectPortedHostnameToEnv(args, options))
|
||||
let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
|
||||
let lastError: unknown
|
||||
let attemptedDefaultWslFallback = false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
owner reset safety, dirty worktree, diverged branch, custom remote) that each
|
||||
need dedicated test coverage. Splitting into separate files would scatter related tests
|
||||
without a meaningful boundary. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted(
|
||||
() => ({
|
||||
@@ -22,11 +22,80 @@ import {
|
||||
addSparseWorktree,
|
||||
addWorktree,
|
||||
listWorktreeGraph,
|
||||
listWorktrees,
|
||||
moveWorktree,
|
||||
parseWorktreeList,
|
||||
removeWorktree
|
||||
} from './worktree'
|
||||
|
||||
describe('listWorktrees in-flight sharing', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
// Later describes in this file assume a pristine mock (no global reset).
|
||||
afterEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('shares one scan across concurrent calls for the same repo', async () => {
|
||||
let resolveScan!: () => void
|
||||
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileAsyncMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveScan = () => resolve({ stdout: scanOutput })
|
||||
})
|
||||
)
|
||||
|
||||
const first = listWorktrees('/repo')
|
||||
const second = listWorktrees('/repo')
|
||||
resolveScan()
|
||||
const [a, b] = await Promise.all([first, second])
|
||||
|
||||
expect(a).toEqual(b)
|
||||
expect(a[0]?.path).toBe('/repo')
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs a fresh scan once the shared one has settled', async () => {
|
||||
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
|
||||
|
||||
await listWorktrees('/repo')
|
||||
await listWorktrees('/repo')
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not share scans across different repos', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation((_args: string[], options: { cwd: string }) =>
|
||||
Promise.resolve({
|
||||
stdout: `worktree ${options.cwd}\nHEAD abc123\nbranch refs/heads/main\n`
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all([listWorktrees('/repo-one'), listWorktrees('/repo-two')])
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not share scans for callers with an AbortSignal', async () => {
|
||||
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
|
||||
const controller = new AbortController()
|
||||
|
||||
// Why: an aborted shared scan would reject for every caller, so signal
|
||||
// callers must keep a private scan.
|
||||
await Promise.all([
|
||||
listWorktrees('/repo'),
|
||||
listWorktrees('/repo', { signal: controller.signal })
|
||||
])
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWorktreeList', () => {
|
||||
it('parses regular and bare worktree blocks from porcelain output', () => {
|
||||
const output = `
|
||||
@@ -237,6 +306,12 @@ bare
|
||||
})
|
||||
|
||||
describe('listWorktreeGraph', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReset()
|
||||
translateWslOutputPathsMock.mockClear()
|
||||
})
|
||||
|
||||
it('returns the worktree graph without sparse-checkout annotation probes', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: `worktree /repo
|
||||
@@ -272,6 +347,98 @@ branch refs/heads/feature/test
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back when older Git rejects worktree list -z with usage exit code', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('git usage error'), {
|
||||
code: 129,
|
||||
stderr: 'usage: git worktree list [<options>]\n'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo-main-2
|
||||
HEAD def456
|
||||
branch refs/heads/main-2
|
||||
`
|
||||
})
|
||||
|
||||
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
|
||||
{
|
||||
path: '/repo',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/repo-main-2',
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/main-2',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
|
||||
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back on a localized (non-English) older-Git usage error via exit code 129', async () => {
|
||||
// Git under a non-English locale translates the usage text, so stderr
|
||||
// matching alone misses it; the 129 exit code must still trigger fallback.
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('git worktree list --porcelain -z'), {
|
||||
code: 129,
|
||||
stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list [<Optionen>]\n'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
`
|
||||
})
|
||||
|
||||
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
|
||||
{
|
||||
path: '/repo',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
|
||||
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not retry without -z for non-usage Git failures', async () => {
|
||||
// A fatal error (exit 128) is not an unsupported-flag signal, so the -z
|
||||
// command must not be silently re-run without it.
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('git worktree list --porcelain -z'), {
|
||||
code: 128,
|
||||
stderr: 'fatal: unable to read tree\n'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(listWorktreeGraph('/repo')).resolves.toEqual([])
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty graph for paths Git reports as non-repositories', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: not a git repository'))
|
||||
|
||||
|
||||
@@ -104,9 +104,13 @@ function isNotGitRepositoryError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
function isUnsupportedWorktreeListZError(error: unknown): boolean {
|
||||
return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test(
|
||||
getErrorText(error)
|
||||
)
|
||||
// `-z` is this command's only flag older Git (<2.36) lacks, so its usage
|
||||
// exit 129 signals the rejection in any locale; stderr match is a fallback.
|
||||
if (getErrorCode(error) === '129') {
|
||||
return true
|
||||
}
|
||||
|
||||
return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error))
|
||||
}
|
||||
|
||||
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
|
||||
@@ -605,10 +609,51 @@ export async function listWorktreeGraph(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a cold start lists every repo's worktrees from several independent
|
||||
// paths (renderer worktrees fetch, lineage resolution, boot pty-registry
|
||||
// hydration), and each `git worktree list` is a full process spawn —
|
||||
// expensive on Windows (issue #7225). Sharing the in-flight promise collapses
|
||||
// concurrent duplicate scans; nothing is served after a scan settles.
|
||||
const inFlightWorktreeScans = new Map<string, Promise<GitWorktreeInfo[]>>()
|
||||
|
||||
// Why: a caller listing AFTER a mutation completed must observe it, so it may
|
||||
// not join a scan that started before the mutation. Bumping the generation on
|
||||
// mutation completion retires older in-flight scans from sharing (they still
|
||||
// settle for their original callers).
|
||||
const worktreeScanGenerations = new Map<string, number>()
|
||||
|
||||
function bumpWorktreeScanGeneration(repoPath: string): void {
|
||||
worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* List all worktrees for a git repo at the given path.
|
||||
* List all worktrees for a git repo at the given path. Concurrent calls for
|
||||
* the same repo share one scan (unless the caller passes an AbortSignal,
|
||||
* which must only cancel its own scan).
|
||||
*/
|
||||
export async function listWorktrees(
|
||||
export function listWorktrees(
|
||||
repoPath: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
if (options.signal) {
|
||||
return listWorktreesUnshared(repoPath, options)
|
||||
}
|
||||
const generation = worktreeScanGenerations.get(repoPath) ?? 0
|
||||
const key = `${repoPath}\0${options.wslDistro ?? ''}\0${generation}`
|
||||
const inFlight = inFlightWorktreeScans.get(key)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
const scan = listWorktreesUnshared(repoPath, options).finally(() => {
|
||||
if (inFlightWorktreeScans.get(key) === scan) {
|
||||
inFlightWorktreeScans.delete(key)
|
||||
}
|
||||
})
|
||||
inFlightWorktreeScans.set(key, scan)
|
||||
return scan
|
||||
}
|
||||
|
||||
async function listWorktreesUnshared(
|
||||
repoPath: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
@@ -763,6 +808,30 @@ export async function addWorktree(
|
||||
refreshLocalBaseRef = false,
|
||||
noCheckout = false,
|
||||
options: AddWorktreeOptions = {}
|
||||
): Promise<AddWorktreeResult> {
|
||||
try {
|
||||
return await performAddWorktree(
|
||||
repoPath,
|
||||
worktreePath,
|
||||
branch,
|
||||
baseBranch,
|
||||
refreshLocalBaseRef,
|
||||
noCheckout,
|
||||
options
|
||||
)
|
||||
} finally {
|
||||
bumpWorktreeScanGeneration(repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function performAddWorktree(
|
||||
repoPath: string,
|
||||
worktreePath: string,
|
||||
branch: string,
|
||||
baseBranch?: string,
|
||||
refreshLocalBaseRef = false,
|
||||
noCheckout = false,
|
||||
options: AddWorktreeOptions = {}
|
||||
): Promise<AddWorktreeResult> {
|
||||
let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined
|
||||
let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined
|
||||
@@ -953,7 +1022,11 @@ export async function moveWorktree(
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): Promise<void> {
|
||||
await gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
|
||||
try {
|
||||
await gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
|
||||
} finally {
|
||||
bumpWorktreeScanGeneration(repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -968,6 +1041,19 @@ export async function removeWorktree(
|
||||
// and must be removed outright. User-initiated deletes leave it false so unmerged
|
||||
// commits are preserved.
|
||||
options: RemoveWorktreeOptions = {}
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
try {
|
||||
return await performRemoveWorktree(repoPath, worktreePath, force, options)
|
||||
} finally {
|
||||
bumpWorktreeScanGeneration(repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function performRemoveWorktree(
|
||||
repoPath: string,
|
||||
worktreePath: string,
|
||||
force = false,
|
||||
options: RemoveWorktreeOptions = {}
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
const removedWorktree =
|
||||
options.knownRemovedWorktree ??
|
||||
|
||||
@@ -245,7 +245,7 @@ export async function getProjectSlug(
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<ProjectRef | null> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
return getProjectRef(
|
||||
repoPath,
|
||||
knownHosts,
|
||||
@@ -265,7 +265,7 @@ export async function getMergeRequest(
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<MRInfo | null> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
|
||||
const localGitOptions = localGitArgs[0] ?? {}
|
||||
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs)
|
||||
@@ -316,7 +316,7 @@ export async function getMergeRequestForBranch(
|
||||
if (!branchName && linkedMRIid == null) {
|
||||
return null
|
||||
}
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
|
||||
const localGitOptions = localGitArgs[0] ?? {}
|
||||
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs)
|
||||
@@ -400,7 +400,7 @@ export async function listMergeRequests(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<ListMergeRequestsResult> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
// Why: MRs sit on `origin` in the fork model (the user's fork is where
|
||||
// they push branches and submit MRs). Mirror github's `getOwnerRepo`
|
||||
// call site by going through the upstream/origin preference resolver
|
||||
@@ -429,7 +429,7 @@ export async function listMergeRequests(
|
||||
}
|
||||
}
|
||||
// Why: fallback — let glab infer project from cwd, same as listIssues.
|
||||
// Used when the repo's remote host is not in getGlabKnownHosts()
|
||||
// Used when the repo's remote host is not in getGlabKnownHosts(connectionId)
|
||||
// (e.g. a fresh self-hosted instance), but glab itself can still
|
||||
// resolve it from the local git config.
|
||||
const stateFlag = mrListStateFlags(state)
|
||||
@@ -598,7 +598,7 @@ export async function listWorkItems(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitLabPagedResult<GitLabWorkItem>> {
|
||||
const issueState = mrStateToIssueState(state)
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
@@ -734,7 +734,7 @@ export async function listTodos(
|
||||
): Promise<GitLabTodo[]> {
|
||||
const projectRef = await getProjectRef(
|
||||
repoPath,
|
||||
await getGlabKnownHosts(),
|
||||
await getGlabKnownHosts(connectionId),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
@@ -813,7 +813,7 @@ async function withProjectRef<T>(
|
||||
await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
await getGlabKnownHosts(),
|
||||
await getGlabKnownHosts(connectionId),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
@@ -20,7 +20,17 @@ export type LocalGitExecOptions = {
|
||||
const PROJECT_REF_CACHE_MAX_ENTRIES = 512
|
||||
const projectRefCache = new Map<string, ProjectRef | null>()
|
||||
|
||||
let knownHostsCache: readonly string[] | null = null
|
||||
// Why: known hosts are cached PER connection. A repo on an SSH connection
|
||||
// authenticates against a different glab context than the local one, so a
|
||||
// process-global cache would leak one connection's hosts into another (and
|
||||
// poison a connection that probes before its tunnel is ready). The local
|
||||
// context uses the `'local'` key.
|
||||
const LOCAL_CONNECTION_KEY = 'local'
|
||||
const knownHostsCacheByConnection = new Map<string, readonly string[]>()
|
||||
|
||||
function connectionCacheKey(connectionId?: string | null): string {
|
||||
return connectionId ?? LOCAL_CONNECTION_KEY
|
||||
}
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
export function _resetProjectRefCache(): void {
|
||||
@@ -35,7 +45,7 @@ export function _getProjectRefCacheSize(): number {
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
export function _resetKnownHostsCache(): void {
|
||||
knownHostsCache = null
|
||||
knownHostsCacheByConnection.clear()
|
||||
}
|
||||
|
||||
function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void {
|
||||
@@ -108,7 +118,7 @@ async function resolveProjectRefForRemote(
|
||||
localGitOptions
|
||||
))
|
||||
) {
|
||||
rememberGlabKnownHost(remoteCandidate.host)
|
||||
rememberGlabKnownHost(remoteCandidate.host, connectionId)
|
||||
rememberProjectRefCacheEntry(cacheKey, remoteCandidate)
|
||||
return remoteCandidate
|
||||
}
|
||||
@@ -220,12 +230,14 @@ export function glabHostnameArgs(
|
||||
return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : []
|
||||
}
|
||||
|
||||
function rememberGlabKnownHost(host: string): void {
|
||||
function rememberGlabKnownHost(host: string, connectionId?: string | null): void {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) {
|
||||
const key = connectionCacheKey(connectionId)
|
||||
const cached = knownHostsCacheByConnection.get(key)
|
||||
if (!cached || cached.map(normalizeGitLabHost).includes(normalizedHost)) {
|
||||
return
|
||||
}
|
||||
knownHostsCache = [...knownHostsCache, normalizedHost]
|
||||
knownHostsCacheByConnection.set(key, [...cached, normalizedHost])
|
||||
}
|
||||
|
||||
async function isGlabConfiguredForRemoteHost(
|
||||
@@ -251,30 +263,47 @@ async function isGlabConfiguredForRemoteHost(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGlabKnownHosts(): Promise<readonly string[]> {
|
||||
if (knownHostsCache) {
|
||||
return knownHostsCache
|
||||
export async function getGlabKnownHosts(connectionId?: string | null): Promise<readonly string[]> {
|
||||
const key = connectionCacheKey(connectionId)
|
||||
const cached = knownHostsCacheByConnection.get(key)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
try {
|
||||
// Why: `glab auth status` is host-scoped, not cwd-scoped — glab reads its
|
||||
// own config to list authenticated hosts. The connectionId is threaded so
|
||||
// the RESULT is cached per connection (a connected repo can have a
|
||||
// different set of authenticated self-hosted hosts than the local one),
|
||||
// mirroring how project-ref resolution caches per connection.
|
||||
const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'])
|
||||
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
|
||||
knownHostsCache = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
|
||||
return knownHostsCache
|
||||
const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
|
||||
knownHostsCacheByConnection.set(key, merged)
|
||||
return merged
|
||||
} catch {
|
||||
knownHostsCache = [...DEFAULT_GITLAB_HOSTS]
|
||||
return knownHostsCache
|
||||
// Auth check failed (glab not installed, no auth, tunnel not ready,
|
||||
// etc.) — fall back to the canonical default for THIS call, but do NOT
|
||||
// cache the fallback. A later probe (e.g. after the SSH tunnel comes
|
||||
// up) must be able to discover the real self-hosted host.
|
||||
return [...DEFAULT_GITLAB_HOSTS]
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGlabAuthStatusHosts(output: string): string[] {
|
||||
const hosts = new Set<string>()
|
||||
for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) {
|
||||
// Why: self-hosted GitLab can run on a non-default port (e.g.
|
||||
// `gitlab.example.com:8443`); capture the optional `:port` so two services
|
||||
// on the same hostname but different ports stay distinct downstream.
|
||||
for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) {
|
||||
hosts.add(m[1].toLowerCase())
|
||||
}
|
||||
for (const line of output.split('\n')) {
|
||||
const bareLine = line.trim()
|
||||
const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine
|
||||
if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) {
|
||||
if (
|
||||
line === bareLine &&
|
||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$/.test(hostLine)
|
||||
) {
|
||||
hosts.add(hostLine.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +344,30 @@ Self-hosted-git
|
||||
it('returns empty list for output with no hosts', () => {
|
||||
expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([])
|
||||
})
|
||||
|
||||
it('captures a non-default port on "Logged in to" lines', () => {
|
||||
const out = '✓ Logged in to gitlab.example.com:8080 as user (token)'
|
||||
expect(parseGlabAuthStatusHosts(out)).toEqual(['gitlab.example.com:8080'])
|
||||
})
|
||||
|
||||
it('captures a non-default port on header-style lines', () => {
|
||||
const out = `
|
||||
gitlab.example.com:8080:
|
||||
✓ Logged in as user
|
||||
`
|
||||
expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com:8080')
|
||||
})
|
||||
|
||||
it('keeps two services on the same host distinct by port', () => {
|
||||
const out = `
|
||||
✓ Logged in to gitlab.example.com:8443 as user (token)
|
||||
✓ Logged in to gitlab.example.com:3030 as user (token)
|
||||
`
|
||||
expect(parseGlabAuthStatusHosts(out).sort()).toEqual([
|
||||
'gitlab.example.com:3030',
|
||||
'gitlab.example.com:8443'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGlabApiResponse', () => {
|
||||
@@ -428,4 +452,52 @@ describe('getGlabKnownHosts', () => {
|
||||
await getGlabKnownHosts()
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recognizes a self-hosted host on a non-default port', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com:8080'])
|
||||
})
|
||||
|
||||
it('caches per connection — the local probe does not satisfy a connection probe', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.com as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
// A second probe for the same connection is served from cache.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not permanently cache the failure fallback — a later probe can re-discover hosts', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
// First probe fails → canonical default, NOT cached.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com'])
|
||||
// Re-probe (e.g. after tunnel comes up) discovers the real host.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -187,31 +187,35 @@ describe('gitlab issue operations', () => {
|
||||
expect(result.error?.type).toBe('permission_denied')
|
||||
})
|
||||
|
||||
it('falls back to glab issue list with updated ordering for unresolved self-hosted repos', async () => {
|
||||
it('returns an isolated not_found error (never a cwd-inferred glab call) when the project is unresolved', async () => {
|
||||
// Why: a cwd-inferred `glab issue list` would hit `git: exit status 128`
|
||||
// on an SSH connection and, in an "All projects" aggregate, sink the
|
||||
// whole panel. The unresolvable project must isolate to a structured
|
||||
// error instead, and must not spawn any glab subprocess.
|
||||
getIssueProjectRefMock.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await listIssues('/repo-root', 5, undefined, 'opened', '@me')
|
||||
|
||||
expect(result.items).toEqual([])
|
||||
expect(result.error?.type).toBe('not_found')
|
||||
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(acquireMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null for getIssue (and spawns no glab call) when the project is unresolved', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce(null)
|
||||
|
||||
await expect(getIssue('/repo-root', 7)).resolves.toBeNull()
|
||||
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('threads connectionId into getGlabKnownHosts for listIssues', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await expect(listIssues('/repo-root', 5, undefined, 'opened', '@me')).resolves.toEqual({
|
||||
items: []
|
||||
})
|
||||
await listIssues('/repo-root', 5, undefined, 'opened', undefined, 'conn-7')
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'issue',
|
||||
'list',
|
||||
'--output',
|
||||
'json',
|
||||
'--per-page',
|
||||
'5',
|
||||
'--order',
|
||||
'updated_at',
|
||||
'--sort',
|
||||
'desc',
|
||||
'--assignee',
|
||||
'@me'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(getGlabKnownHostsMock).toHaveBeenCalledWith('conn-7')
|
||||
})
|
||||
|
||||
it('creates an issue and returns its iid + web_url', async () => {
|
||||
|
||||
+45
-57
@@ -44,25 +44,24 @@ export async function getIssue(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitLabIssueInfo | null> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions)
|
||||
// Why: don't fall back to a cwd-inferred `glab issue view` when the project
|
||||
// can't be resolved — on an SSH connection cwd is not the repo dir, so glab
|
||||
// hits a non-repo dir and fails with `git: exit status 128`. Return null
|
||||
// (the caller already treats a missing project as "no issue") instead of
|
||||
// spawning a doomed cwd-dependent call.
|
||||
if (!projectRef) {
|
||||
return null
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
if (projectRef) {
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
const data = JSON.parse(stdout)
|
||||
return mapGitLabIssueInfo(data)
|
||||
}
|
||||
// Fallback for non-GitLab remotes — let glab infer the project from cwd.
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
['issue', 'view', String(issueNumber), '--output', 'json'],
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
const data = JSON.parse(stdout)
|
||||
@@ -93,7 +92,7 @@ export async function listIssues(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<IssueListResult> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
@@ -101,49 +100,38 @@ export async function listIssues(
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
await acquire()
|
||||
try {
|
||||
if (projectRef) {
|
||||
const stateParam = state === 'all' ? '' : `&state=${state}`
|
||||
const scopeParam = assignee === '@me' ? '&scope=assigned_to_me' : ''
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
const data = JSON.parse(stdout) as Record<string, unknown>[]
|
||||
// Why: GitLab's project issues endpoint returns true issues only
|
||||
// (MRs are a separate endpoint), so no equivalent of GitHub's
|
||||
// pull_request filter is needed here.
|
||||
return {
|
||||
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
|
||||
// Why: when the project can't be resolved we must NOT fall back to an
|
||||
// unscoped `glab issue list` that infers the project from cwd. For a repo
|
||||
// on an SSH connection there is no local cwd matching the repo, so glab
|
||||
// runs git resolution in a non-repo dir and fails with `git: exit status
|
||||
// 128`. In an "All projects" aggregate one such failure must not sink the
|
||||
// whole panel — return a structured, isolated result so the resolvable
|
||||
// projects still load.
|
||||
if (!projectRef) {
|
||||
return {
|
||||
items: [],
|
||||
error: {
|
||||
type: 'not_found',
|
||||
message: 'Could not resolve a GitLab project for this repository.'
|
||||
}
|
||||
}
|
||||
// Fallback — let glab infer project from cwd. glab issue list defaults
|
||||
// to opened; only pass --closed / --all when explicitly requested.
|
||||
const stateFlag = state === 'closed' ? ['--closed'] : state === 'all' ? ['--all'] : []
|
||||
const assigneeFlag = assignee ? ['--assignee', assignee] : []
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const stateParam = state === 'all' ? '' : `&state=${state}`
|
||||
const scopeParam = assignee === '@me' ? '&scope=assigned_to_me' : ''
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'issue',
|
||||
'list',
|
||||
'--output',
|
||||
'json',
|
||||
'--per-page',
|
||||
String(limit),
|
||||
'--order',
|
||||
'updated_at',
|
||||
'--sort',
|
||||
'desc',
|
||||
...stateFlag,
|
||||
...assigneeFlag
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
const data = JSON.parse(stdout) as unknown[]
|
||||
const data = JSON.parse(stdout) as Record<string, unknown>[]
|
||||
// Why: GitLab's project issues endpoint returns true issues only
|
||||
// (MRs are a separate endpoint), so no equivalent of GitHub's
|
||||
// pull_request filter is needed here.
|
||||
return {
|
||||
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
|
||||
}
|
||||
@@ -174,7 +162,7 @@ export async function createIssue(
|
||||
if (!trimmedTitle) {
|
||||
return { ok: false, error: 'Title is required' }
|
||||
}
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
@@ -244,7 +232,7 @@ export async function updateIssue(
|
||||
await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
await getGlabKnownHosts(),
|
||||
await getGlabKnownHosts(connectionId),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
@@ -381,7 +369,7 @@ export async function addIssueComment(
|
||||
await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
await getGlabKnownHosts(),
|
||||
await getGlabKnownHosts(connectionId),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
@@ -439,7 +427,7 @@ export async function listLabels(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<string[]> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
@@ -480,7 +468,7 @@ export async function listAssignableUsers(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitLabAssignableUser[]> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
|
||||
@@ -38,21 +38,68 @@ describe('gitlab project ref parsing', () => {
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => {
|
||||
it('drops the SSH transport port from the GitLab host identity', () => {
|
||||
// Why: for ssh remotes the port is a transport port (e.g. :2222), not the
|
||||
// web/API endpoint, so it must not become part of the recognized host.
|
||||
expect(
|
||||
parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('keeps the HTTP(S) port as part of the self-hosted GitLab host identity', () => {
|
||||
// Why: a self-hosted GitLab served on a non-default web port (e.g. :8443)
|
||||
// is identified by host:port end-to-end so `glab --hostname` targets it.
|
||||
expect(
|
||||
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8443'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com:8443', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('matches a port-bearing http remote against a port-less legacy known host', () => {
|
||||
// Why: a known host recorded without a port (legacy/bare entry) still
|
||||
// recognizes a remote on any port of the same hostname.
|
||||
expect(
|
||||
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com:8443', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('distinguishes two services on the same host by port — only the GitLab one matches', () => {
|
||||
// Why: a GitLab on :8443 and a Gitea on :3030 share a hostname but are
|
||||
// different services. With only the GitLab port in known hosts, the Gitea
|
||||
// remote must NOT be classified as GitLab.
|
||||
const knownHosts = ['gitlab.com', 'gitea.example.com:8443']
|
||||
expect(parseGitLabProjectRef('http://gitea.example.com:8443/team/api.git', knownHosts)).toEqual(
|
||||
{ host: 'gitea.example.com:8443', path: 'team/api' }
|
||||
)
|
||||
expect(
|
||||
parseGitLabProjectRef('http://gitea.example.com:3030/team/api.git', knownHosts)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('matches an SCP-like self-hosted remote against a port-less known host', () => {
|
||||
expect(
|
||||
parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('keeps gitlab.com (no port) recognized as a default host', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects single-segment paths (host root or user-only)', () => {
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull()
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull()
|
||||
|
||||
@@ -12,10 +12,33 @@ export function normalizeGitLabHost(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
// Why: host recognition is port-aware so two services on the same hostname
|
||||
// but different ports (e.g. a GitLab on :8080 and a Gitea on :3030) are not
|
||||
// conflated. The hostname (port-less) part is kept for legacy known-host
|
||||
// entries that were recorded without a port.
|
||||
function hostnameOf(host: string): string {
|
||||
// `host` may be `name` or `name:port`. Strip a trailing `:digits` port.
|
||||
return host.replace(/:\d+$/, '')
|
||||
}
|
||||
|
||||
function stripGitSuffix(path: string): string {
|
||||
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
// Why: the GitLab host identity is the web/API endpoint, which is what `glab
|
||||
// --hostname` and the known-hosts list speak in terms of. For http(s)
|
||||
// remotes the URL port IS that endpoint port (e.g. self-hosted on :8080),
|
||||
// so it must be kept. For ssh/git remotes the port is a transport port
|
||||
// (e.g. ssh on :2222) that does not identify the GitLab instance, so it is
|
||||
// dropped and only the hostname is used.
|
||||
function hostIdentityFromUrl(url: URL): string {
|
||||
const protocol = url.protocol.toLowerCase()
|
||||
if (protocol === 'http:' || protocol === 'https:') {
|
||||
return url.host
|
||||
}
|
||||
return url.hostname
|
||||
}
|
||||
|
||||
function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
|
||||
@@ -27,6 +50,27 @@ function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef |
|
||||
return { host: normalizedHost, path: normalizedPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `urlHost` (which may include a `:port`) match a known-host entry?
|
||||
* - An exact match (including any port) always counts.
|
||||
* - A known entry WITHOUT a port also matches a URL host on the same
|
||||
* hostname regardless of the URL's port — this preserves recognition for
|
||||
* legacy `gitlab.com` / bare-hostname known entries.
|
||||
* - A known entry WITH a port only matches a URL host with the exact same
|
||||
* port, so `gitlab.example.com:8443` does not accept a
|
||||
* `gitea.example.com:3000` (or same-host different-port) remote.
|
||||
*/
|
||||
function knownHostMatches(urlHost: string, knownHost: string): boolean {
|
||||
if (urlHost === knownHost) {
|
||||
return true
|
||||
}
|
||||
if (hostnameOf(knownHost) === knownHost) {
|
||||
// Known entry has no port — match on hostname alone.
|
||||
return hostnameOf(urlHost) === knownHost
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function makeProjectRef(
|
||||
host: string,
|
||||
path: string,
|
||||
@@ -34,7 +78,7 @@ function makeProjectRef(
|
||||
): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
|
||||
if (!normalizedKnownHosts.includes(normalizedHost)) {
|
||||
if (!normalizedKnownHosts.some((knownHost) => knownHostMatches(normalizedHost, knownHost))) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(normalizedHost, path)
|
||||
@@ -54,7 +98,7 @@ export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef |
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(url.hostname, url.pathname)
|
||||
return makeProjectRefForTrustedHost(hostIdentityFromUrl(url), url.pathname)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -77,7 +121,7 @@ export function parseGitLabProjectRef(
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRef(url.hostname, url.pathname, knownHosts)
|
||||
return makeProjectRef(hostIdentityFromUrl(url), url.pathname, knownHosts)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ export async function getWorkItemDetails(
|
||||
await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
await getGlabKnownHosts(),
|
||||
await getGlabKnownHosts(connectionId),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
@@ -76,7 +76,12 @@ describe('GrokHookService', () => {
|
||||
if (process.platform === 'win32') {
|
||||
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
|
||||
} else {
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive).
|
||||
expect(script).toContain('payload=$(cat)')
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -90,7 +90,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -100,7 +103,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
+108
-28
@@ -91,13 +91,20 @@ import {
|
||||
shouldBypassSingleInstanceLock
|
||||
} from './startup/single-instance-lock'
|
||||
import { startEventLoopStallProbe } from './startup/event-loop-stall-probe'
|
||||
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics'
|
||||
import {
|
||||
isStartupDiagnosticsEnabled,
|
||||
logStartupDiagnostic,
|
||||
logStartupMilestone
|
||||
} from './startup/startup-diagnostics'
|
||||
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
|
||||
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
|
||||
import { RateLimitService } from './rate-limits/service'
|
||||
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
|
||||
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
|
||||
import { attachMainWindowServices } from './window/attach-main-window-services'
|
||||
import {
|
||||
attachMainWindowServices,
|
||||
ensureAutoUpdaterConfigured
|
||||
} from './window/attach-main-window-services'
|
||||
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
|
||||
import { createSystemTray, destroySystemTray } from './tray/system-tray'
|
||||
import { focusExistingMainWindow } from './window/focus-existing-window'
|
||||
@@ -171,6 +178,8 @@ import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-fac
|
||||
import { KeybindingService } from './keybindings/keybinding-service'
|
||||
import { applyElectronProxySettings } from './network/proxy-settings'
|
||||
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
|
||||
import { CliInstaller } from './cli/cli-installer'
|
||||
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
|
||||
@@ -397,6 +406,11 @@ if (app.isPackaged && process.platform !== 'win32') {
|
||||
}
|
||||
configureDevUserDataPath(is.dev)
|
||||
configureOrcaUserDataPathEnv()
|
||||
|
||||
// Why: just past createMainWindow's win32 10s ready-to-show reveal fallback,
|
||||
// so a window revealed on that path still gets its tray icon.
|
||||
const TRAY_CREATE_FALLBACK_MS = 12_000
|
||||
|
||||
const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled()
|
||||
if (startupDiagnosticsEnabled) {
|
||||
logStartupDiagnostic('before-single-instance-lock', {
|
||||
@@ -410,14 +424,6 @@ if (startupDiagnosticsEnabled) {
|
||||
startEventLoopStallProbe()
|
||||
}
|
||||
|
||||
// Why: startup benchmarking needs in-process timestamps — harness-side stderr
|
||||
// arrival times include pipe buffering jitter. `t` is ms since process start.
|
||||
function logStartupMilestone(event: string, details: Record<string, unknown> = {}): void {
|
||||
if (startupDiagnosticsEnabled) {
|
||||
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
|
||||
}
|
||||
}
|
||||
|
||||
function focusExistingWindow(): void {
|
||||
focusExistingMainWindow({
|
||||
app,
|
||||
@@ -781,9 +787,45 @@ function openMainWindow(): BrowserWindow {
|
||||
})
|
||||
recordCrashBreadcrumb('main_window_created')
|
||||
logStartupMilestone('window-created')
|
||||
// Why: new Tray() is a synchronous Shell_NotifyIcon call that can block for
|
||||
// seconds while explorer.exe's notification area is busy (part of issue
|
||||
// #7225's pre-paint stall), so create it after first paint. The timer
|
||||
// fallback covers windows revealed without ready-to-show ever firing
|
||||
// (createMainWindow's win32 10s reveal fallback) — those can still be
|
||||
// hidden to the tray on close, so the icon must exist by then.
|
||||
let trayCreated = false
|
||||
const createSystemTrayDeferred = (): void => {
|
||||
if (trayCreated || window.isDestroyed() || isQuitting || !store) {
|
||||
return
|
||||
}
|
||||
trayCreated = true
|
||||
// Why: Windows-only system tray. createSystemTray is idempotent and a
|
||||
// no-op off win32, so calling it on each window open keeps exactly one
|
||||
// live icon.
|
||||
createSystemTray({
|
||||
appIcon: store.getSettings().appIcon,
|
||||
onOpen: showMainWindowFromTray,
|
||||
onQuit: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
// Why: a real quit can still surface renderer save/discard prompts;
|
||||
// the window must be visible if a hidden-to-tray session vetoes
|
||||
// shutdown.
|
||||
showMainWindowFromTray()
|
||||
}
|
||||
// Why: set the quit latch before app.quit() so the window 'close'
|
||||
// handler proceeds to teardown instead of re-hiding to the tray.
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
logStartupMilestone('tray-created')
|
||||
}
|
||||
window.once('ready-to-show', () => {
|
||||
logStartupMilestone('ready-to-show')
|
||||
setImmediate(createSystemTrayDeferred)
|
||||
})
|
||||
const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS)
|
||||
trayCreateFallback.unref?.()
|
||||
|
||||
// Why: telemetry-plan.md§First-launch experience anchors default-on
|
||||
// `app_opened` to the first main-window load. Existing users in the
|
||||
@@ -879,23 +921,6 @@ function openMainWindow(): BrowserWindow {
|
||||
stopAllSyntheticTitleSpinners()
|
||||
})
|
||||
mainWindow = window
|
||||
// Why: Windows-only system tray. createSystemTray is idempotent and a no-op
|
||||
// off win32, so calling it on each window open keeps exactly one live icon.
|
||||
createSystemTray({
|
||||
appIcon: store.getSettings().appIcon,
|
||||
onOpen: showMainWindowFromTray,
|
||||
onQuit: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
// Why: a real quit can still surface renderer save/discard prompts; the
|
||||
// window must be visible if a hidden-to-tray session vetoes shutdown.
|
||||
showMainWindowFromTray()
|
||||
}
|
||||
// Why: set the quit latch before app.quit() so the window 'close' handler
|
||||
// proceeds to teardown instead of re-hiding the window to the tray.
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
window.on('show', resumeSyntheticTitleSpinnerTimer)
|
||||
window.on('restore', resumeSyntheticTitleSpinnerTimer)
|
||||
window.on('hide', stopSyntheticTitleSpinnerTimer)
|
||||
@@ -1845,7 +1870,12 @@ app.whenReady().then(async () => {
|
||||
logStartupMilestone('i18n-ready')
|
||||
|
||||
registerAppMenu({
|
||||
onCheckForUpdates: (options) => checkForUpdatesFromMenu(options),
|
||||
onCheckForUpdates: (options) => {
|
||||
// Why: the menu is clickable before first paint; a manual check must
|
||||
// run against a configured updater (see attach-main-window-services).
|
||||
ensureAutoUpdaterConfigured()
|
||||
return checkForUpdatesFromMenu(options)
|
||||
},
|
||||
onBeforeReload: ({ ignoreCache, webContentsId }) => {
|
||||
if (mainWindow?.webContents.id === webContentsId) {
|
||||
markExpectedRendererReload(webContentsId)
|
||||
@@ -1989,6 +2019,56 @@ app.whenReady().then(async () => {
|
||||
throw error
|
||||
})
|
||||
installServeSignalHandlers()
|
||||
// Why: the orca CLI command is normally installed by the renderer onboarding /
|
||||
// Settings "Install CLI" flow via the cli:install IPC. Headless serve has no
|
||||
// renderer, so the command is never created and an in-terminal `orca …` fails
|
||||
// with command-not-found. Run the idempotent installer here for the platforms
|
||||
// where it puts a resolvable command on the managed-terminal PATH: macOS (bare
|
||||
// `orca` in /usr/local/bin or ~/.local/bin) and Linux (`orca-ide`; bare `orca`
|
||||
// is added by the dispatcher below). Windows is excluded — there install() would
|
||||
// only mutate the persistent user-registry PATH without helping the current
|
||||
// serve's child terminals. Best-effort: a failure must not block serve start.
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') {
|
||||
try {
|
||||
// Why: serve is headless — never let a missing-write-permission fallback
|
||||
// pop an osascript admin prompt (it would hang serve on a GUI host). Skip
|
||||
// instead; the user-writable ~/.local/bin path needs no elevation anyway.
|
||||
const cliStatus = await new CliInstaller({
|
||||
privilegedRunner: async () => {
|
||||
throw new Error('serve CLI auto-install must not request administrator privileges')
|
||||
}
|
||||
}).install()
|
||||
console.log(
|
||||
`[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}`
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[serve] orca CLI install skipped:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why: on Linux the CLI installs as `orca-ide`, NOT bare `orca` (above), but the
|
||||
// Claude Team launcher typed into the initial managed terminal invokes bare `orca`.
|
||||
// Drop a bare-`orca` dispatcher on ~/.local/bin (ahead of /usr/bin on the managed
|
||||
// terminal PATH) so `orca claude-teams` resolves. Best-effort: a failure must not
|
||||
// block serve startup. See installLinuxBareOrcaDispatcher for the full rationale.
|
||||
if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) {
|
||||
try {
|
||||
const dispatcher = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath: process.resourcesPath
|
||||
})
|
||||
console.log(
|
||||
`[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` +
|
||||
`${dispatcher.target ? ` -> ${dispatcher.target}` : ''}`
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[serve] bare orca dispatcher install skipped:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
await printServeReady(serveOptions)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ vi.mock('../git/runner', () => ({
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
getGitUsername: vi.fn().mockReturnValue(''),
|
||||
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
|
||||
searchBaseRefs: vi.fn().mockResolvedValue([])
|
||||
|
||||
@@ -22,7 +22,6 @@ vi.mock('../git/runner', () => ({
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
isGitRepo: vi.fn(),
|
||||
getGitUsername: vi.fn(),
|
||||
getRepoName: vi.fn(),
|
||||
getBaseRefDefault: vi.fn(),
|
||||
searchBaseRefs: vi.fn()
|
||||
|
||||
@@ -94,7 +94,6 @@ vi.mock('../git/repo', async () => {
|
||||
// Stub only the functions that spawn git / touch the filesystem.
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
getGitRepoRoot: vi.fn((path: string) => path),
|
||||
getGitUsername: vi.fn().mockReturnValue(''),
|
||||
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
|
||||
getRemoteCount: vi.fn().mockResolvedValue(1),
|
||||
|
||||
@@ -35,7 +35,6 @@ vi.mock('electron', () => ({
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
getGitUsername: vi.fn().mockReturnValue(''),
|
||||
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
|
||||
searchBaseRefs: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -61,7 +61,6 @@ import { createNestedRepoImportTargetResolver } from '../project-groups/nested-r
|
||||
import {
|
||||
isGitRepo,
|
||||
getGitRepoRoot,
|
||||
getGitUsername,
|
||||
getRepoName,
|
||||
getBaseRefDefault,
|
||||
getRemoteCount,
|
||||
@@ -76,7 +75,8 @@ import {
|
||||
} from '../git/repo'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { getSshGitUsername } from '../git/git-username'
|
||||
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
|
||||
import { enrichRepoGitUsernames } from '../repo-git-username-enrichment'
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
||||
import { track } from '../telemetry/client'
|
||||
@@ -1163,6 +1163,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
enrichMissingRepoGitRemoteIdentities(store, {
|
||||
onChanged: () => notifyReposChanged(mainWindow)
|
||||
})
|
||||
// Why: username resolution spawns git/gh and must stay off this handler's
|
||||
// synchronous path (issue #7225); the background pass notifies the
|
||||
// renderer to re-list once values land.
|
||||
enrichRepoGitUsernames(store, {
|
||||
onChanged: () => notifyReposChanged(mainWindow)
|
||||
})
|
||||
return store.getRepos()
|
||||
})
|
||||
|
||||
@@ -2400,7 +2406,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
}
|
||||
return getSshGitUsername(provider, repo.path)
|
||||
}
|
||||
return getGitUsername(repo.path)
|
||||
return resolveLocalGitUsername(repo.path)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
|
||||
@@ -27,7 +27,8 @@ import type {
|
||||
import { getPRForBranch } from '../github/client'
|
||||
import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
|
||||
import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree'
|
||||
import { getGitUsername, getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo'
|
||||
import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo'
|
||||
import { resolveLocalGitUsername } from '../git/git-username'
|
||||
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
|
||||
import { getHostedReviewForBranch } from '../source-control/hosted-review'
|
||||
import type { ForgeProviderId } from '../source-control/forge-provider'
|
||||
@@ -1872,7 +1873,7 @@ export async function createLocalWorktree(
|
||||
return { ...options, ...localWorktreeGitOptions }
|
||||
}
|
||||
|
||||
const username = getGitUsername(repo.path)
|
||||
const username = await resolveLocalGitUsername(repo.path)
|
||||
const requestedName = args.name
|
||||
const sanitizedName = sanitizeWorktreeName(args.name)
|
||||
const requestedDisplayName = args.displayName
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GitUsernameModule from '../git/git-username'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
@@ -7,7 +8,7 @@ const {
|
||||
assertWorktreeCleanForRemovalMock,
|
||||
addWorktreeMock,
|
||||
removeWorktreeMock,
|
||||
getGitUsernameMock,
|
||||
resolveLocalGitUsernameMock,
|
||||
getDefaultBaseRefMock,
|
||||
resolveDefaultBaseRefViaExecMock,
|
||||
getBranchConflictKindMock,
|
||||
@@ -35,7 +36,7 @@ const {
|
||||
assertWorktreeCleanForRemovalMock: vi.fn(),
|
||||
addWorktreeMock: vi.fn(),
|
||||
removeWorktreeMock: vi.fn(),
|
||||
getGitUsernameMock: vi.fn(),
|
||||
resolveLocalGitUsernameMock: vi.fn(),
|
||||
getDefaultBaseRefMock: vi.fn(),
|
||||
resolveDefaultBaseRefViaExecMock: vi.fn(),
|
||||
getBranchConflictKindMock: vi.fn(),
|
||||
@@ -82,12 +83,16 @@ vi.mock('../git/runner', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
getGitUsername: getGitUsernameMock,
|
||||
getDefaultBaseRef: getDefaultBaseRefMock,
|
||||
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
|
||||
getBranchConflictKind: getBranchConflictKindMock
|
||||
}))
|
||||
|
||||
vi.mock('../git/git-username', async () => {
|
||||
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
|
||||
return { ...actual, resolveLocalGitUsername: resolveLocalGitUsernameMock }
|
||||
})
|
||||
|
||||
vi.mock('../github/client', () => ({
|
||||
getPRForBranch: getPRForBranchMock,
|
||||
createGitHubPullRequest: createGitHubPullRequestMock
|
||||
@@ -157,7 +162,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
assertWorktreeCleanForRemovalMock.mockReset()
|
||||
addWorktreeMock.mockReset()
|
||||
removeWorktreeMock.mockReset()
|
||||
getGitUsernameMock.mockReset()
|
||||
resolveLocalGitUsernameMock.mockReset()
|
||||
getDefaultBaseRefMock.mockReset()
|
||||
resolveDefaultBaseRefViaExecMock.mockReset()
|
||||
getBranchConflictKindMock.mockReset()
|
||||
@@ -223,7 +228,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
})
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.setWorktreeMeta.mockReturnValue({})
|
||||
getGitUsernameMock.mockReturnValue('')
|
||||
resolveLocalGitUsernameMock.mockResolvedValue('')
|
||||
getDefaultBaseRefMock.mockReturnValue('origin/main')
|
||||
resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main')
|
||||
getBranchConflictKindMock.mockResolvedValue(null)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GitUsernameModule from '../git/git-username'
|
||||
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -24,7 +25,7 @@ const {
|
||||
addSparseWorktreeMock,
|
||||
removeWorktreeMock,
|
||||
forceDeleteLocalBranchMock,
|
||||
getGitUsernameMock,
|
||||
resolveLocalGitUsernameMock,
|
||||
getDefaultBaseRefMock,
|
||||
resolveDefaultBaseRefViaExecMock,
|
||||
getDefaultRemoteMock,
|
||||
@@ -73,7 +74,7 @@ const {
|
||||
addSparseWorktreeMock: vi.fn(),
|
||||
removeWorktreeMock: vi.fn(),
|
||||
forceDeleteLocalBranchMock: vi.fn(),
|
||||
getGitUsernameMock: vi.fn(),
|
||||
resolveLocalGitUsernameMock: vi.fn(),
|
||||
getDefaultBaseRefMock: vi.fn(),
|
||||
resolveDefaultBaseRefViaExecMock: vi.fn(),
|
||||
getDefaultRemoteMock: vi.fn(),
|
||||
@@ -127,13 +128,17 @@ vi.mock('../git/runner', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
getGitUsername: getGitUsernameMock,
|
||||
getDefaultBaseRef: getDefaultBaseRefMock,
|
||||
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
|
||||
getDefaultRemote: getDefaultRemoteMock,
|
||||
getBranchConflictKind: getBranchConflictKindMock
|
||||
}))
|
||||
|
||||
vi.mock('../git/git-username', async () => {
|
||||
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
|
||||
return { ...actual, resolveLocalGitUsername: resolveLocalGitUsernameMock }
|
||||
})
|
||||
|
||||
vi.mock('../github/client', () => ({
|
||||
getPRForBranch: getPRForBranchMock,
|
||||
getWorkItem: getWorkItemMock,
|
||||
@@ -286,7 +291,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
addSparseWorktreeMock,
|
||||
removeWorktreeMock,
|
||||
forceDeleteLocalBranchMock,
|
||||
getGitUsernameMock,
|
||||
resolveLocalGitUsernameMock,
|
||||
getDefaultBaseRefMock,
|
||||
resolveDefaultBaseRefViaExecMock,
|
||||
getDefaultRemoteMock,
|
||||
@@ -386,7 +391,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
}
|
||||
])
|
||||
store.getAllWorktreeLineage.mockReturnValue({})
|
||||
getGitUsernameMock.mockReturnValue('')
|
||||
resolveLocalGitUsernameMock.mockResolvedValue('')
|
||||
getDefaultBaseRefMock.mockReturnValue('origin/main')
|
||||
resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main')
|
||||
getDefaultRemoteMock.mockResolvedValue('origin')
|
||||
|
||||
@@ -55,6 +55,11 @@ describe('KimiHookService', () => {
|
||||
// The managed script must exist and POST to the Kimi hook endpoint.
|
||||
const script = readFileSync(scriptPath(), 'utf-8')
|
||||
expect(script).toContain('/hook/kimi')
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive).
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
// The command Kimi runs points at the managed script via sh.
|
||||
expect(config).toContain('agent-hooks/kimi-hook.sh')
|
||||
})
|
||||
|
||||
@@ -75,7 +75,10 @@ function getManagedScript(): string {
|
||||
// Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX
|
||||
// shell is not safe once a path contains quotes or newlines. Post the raw
|
||||
// hook payload plus metadata as form fields and let the receiver parse it.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/kimi" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/kimi" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -85,7 +88,7 @@ function getManagedScript(): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -123,10 +123,6 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./git/repo', () => ({
|
||||
getGitUsername: vi.fn().mockReturnValue('testuser')
|
||||
}))
|
||||
|
||||
vi.mock('./telemetry/client', () => ({
|
||||
track: trackMock
|
||||
}))
|
||||
@@ -1004,7 +1000,9 @@ describe('Store', () => {
|
||||
// ── 2. Load from existing valid file ─────────────────────────────────
|
||||
|
||||
it('reads repos from an existing data file', async () => {
|
||||
const repo = makeRepo()
|
||||
// Why: hydration must serve the persisted username without spawning
|
||||
// git/gh (issue #7225); resolution happens in background enrichment.
|
||||
const repo = makeRepo({ gitUsername: 'testuser' })
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [repo],
|
||||
@@ -2828,6 +2826,24 @@ describe('Store', () => {
|
||||
expect(store.getSettings().terminalUseSeparateLightTheme).toBe(false)
|
||||
})
|
||||
|
||||
it('round-trips selected terminal theme names across reload', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
store.updateSettings({
|
||||
terminalThemeDark: 'One Light',
|
||||
terminalThemeLight: 'GitHub Light'
|
||||
})
|
||||
store.flush()
|
||||
|
||||
const persisted = readDataFile() as PersistedState
|
||||
expect(persisted.settings.terminalThemeDark).toBe('One Light')
|
||||
expect(persisted.settings.terminalThemeLight).toBe('GitHub Light')
|
||||
|
||||
const reopened = await createStore()
|
||||
expect(reopened.getSettings().terminalThemeDark).toBe('One Light')
|
||||
expect(reopened.getSettings().terminalThemeLight).toBe('GitHub Light')
|
||||
})
|
||||
|
||||
// ── 5. addRepo and getRepo ──────────────────────────────────────────
|
||||
|
||||
it('addRepo stores a repo retrievable by getRepo', async () => {
|
||||
@@ -2837,7 +2853,24 @@ describe('Store', () => {
|
||||
const fetched = store.getRepo('r1')
|
||||
expect(fetched).toBeDefined()
|
||||
expect(fetched!.displayName).toBe('test')
|
||||
expect(fetched!.gitUsername).toBe('testuser')
|
||||
// No username has been resolved yet — hydration must not probe git/gh.
|
||||
expect(fetched!.gitUsername).toBe('')
|
||||
})
|
||||
|
||||
it('setResolvedRepoGitUsername persists the enriched username for hydration', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
expect(store.getRepo('r1')!.gitUsername).toBe('')
|
||||
|
||||
expect(store.setResolvedRepoGitUsername('r1', 'testuser')).toBe(true)
|
||||
expect(store.getRepo('r1')!.gitUsername).toBe('testuser')
|
||||
// Unchanged value reports no change so callers can skip renderer notify.
|
||||
expect(store.setResolvedRepoGitUsername('r1', 'testuser')).toBe(false)
|
||||
expect(store.setResolvedRepoGitUsername('missing', 'x')).toBe(false)
|
||||
|
||||
store.flush()
|
||||
const persisted = readDataFile() as PersistedState
|
||||
expect(persisted.repos[0].gitUsername).toBe('testuser')
|
||||
})
|
||||
|
||||
it('deleteProjectGroup ungroups repos from the deleted group subtree', async () => {
|
||||
|
||||
+35
-10
@@ -86,7 +86,6 @@ import {
|
||||
type SshTarget
|
||||
} from '../shared/ssh-types'
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
import { getGitUsername } from './git/repo'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../shared/execution-host'
|
||||
import {
|
||||
getDefaultPersistedState,
|
||||
@@ -3805,9 +3804,8 @@ export class Store {
|
||||
|
||||
/**
|
||||
* O(1) read of the persisted repo count. Use this when you only need the
|
||||
* count (e.g. cohort-classifier) — `getRepos()` hydrates each repo and
|
||||
* may run a synchronous git subprocess via `getGitUsername()`, which is
|
||||
* wasteful when the caller only reads `.length`.
|
||||
* count (e.g. cohort-classifier) — `getRepos()` hydrates each repo, which
|
||||
* is wasteful when the caller only reads `.length`.
|
||||
*/
|
||||
getRepoCount(): number {
|
||||
return this.state.repos.length
|
||||
@@ -3818,6 +3816,32 @@ export class Store {
|
||||
return repo ? this.hydrateRepo(repo) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a background-resolved git username (repo-git-username-enrichment).
|
||||
* Kept out of updateRepo's whitelist so the renderer-facing update surface
|
||||
* cannot write it directly. Returns true when the hydrated value changed.
|
||||
*/
|
||||
setResolvedRepoGitUsername(id: string, username: string): boolean {
|
||||
const repo = this.state.repos.find((r) => r.id === id)
|
||||
if (!repo) {
|
||||
return false
|
||||
}
|
||||
const previous = this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? ''
|
||||
this.gitUsernameCache.set(repo.path, username)
|
||||
if (previous === username) {
|
||||
return false
|
||||
}
|
||||
if (username) {
|
||||
// Why: persisting the resolved value lets the next launch hydrate repos
|
||||
// with the right branch prefix before enrichment has re-run.
|
||||
repo.gitUsername = username
|
||||
} else {
|
||||
delete repo.gitUsername
|
||||
}
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
getProjectGroups(): ProjectGroup[] {
|
||||
return [...(this.state.projectGroups ?? [])].sort(
|
||||
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
|
||||
@@ -4351,14 +4375,15 @@ export class Store {
|
||||
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
|
||||
const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod)
|
||||
const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode)
|
||||
// Why: username resolution spawns git/gh subprocesses, so it must never
|
||||
// run inside hydration — the first getRepos() of a launch executes on the
|
||||
// Electron main thread and a stuck probe froze startup for minutes on
|
||||
// Windows (issue #7225). Hydration only reads the enrichment cache or the
|
||||
// value persisted by a previous launch; repo-git-username-enrichment.ts
|
||||
// refreshes both in the background.
|
||||
const gitUsername = isFolderRepo(repo)
|
||||
? ''
|
||||
: (this.gitUsernameCache.get(repo.path) ??
|
||||
(() => {
|
||||
const username = getGitUsername(repo.path)
|
||||
this.gitUsernameCache.set(repo.path, username)
|
||||
return username
|
||||
})())
|
||||
: (this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '')
|
||||
|
||||
return {
|
||||
...repoWithoutIcon,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../shared/types'
|
||||
import type { ResolvedGitUsername } from './git/git-username'
|
||||
|
||||
const resolveLocalGitUsernameDetailedMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./git/git-username', () => ({
|
||||
resolveLocalGitUsernameDetailed: resolveLocalGitUsernameDetailedMock
|
||||
}))
|
||||
|
||||
import {
|
||||
enrichRepoGitUsernames,
|
||||
flushRepoGitUsernameEnrichmentForTests,
|
||||
resetRepoGitUsernameEnrichmentForTests
|
||||
} from './repo-git-username-enrichment'
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id: 'r1',
|
||||
path: 'C:/repos/one',
|
||||
displayName: 'One',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1,
|
||||
...overrides
|
||||
} as Repo
|
||||
}
|
||||
|
||||
function makeStore(repos: Repo[]): {
|
||||
getRepos: () => Repo[]
|
||||
setResolvedRepoGitUsername: ReturnType<typeof vi.fn<(id: string, username: string) => boolean>>
|
||||
} {
|
||||
return {
|
||||
getRepos: () => repos,
|
||||
setResolvedRepoGitUsername: vi.fn<(id: string, username: string) => boolean>(() => true)
|
||||
}
|
||||
}
|
||||
|
||||
function resolved(username: string, authoritative = true): ResolvedGitUsername {
|
||||
return { username, authoritative }
|
||||
}
|
||||
|
||||
describe('enrichRepoGitUsernames', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetRepoGitUsernameEnrichmentForTests()
|
||||
resolveLocalGitUsernameDetailedMock.mockResolvedValue(resolved('demo-user'))
|
||||
})
|
||||
|
||||
it('resolves and persists usernames, then notifies once', async () => {
|
||||
const store = makeStore([makeRepo(), makeRepo({ id: 'r2', path: 'C:/repos/two' })])
|
||||
const onChanged = vi.fn()
|
||||
|
||||
enrichRepoGitUsernames(store, { onChanged })
|
||||
await flushRepoGitUsernameEnrichmentForTests()
|
||||
|
||||
expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(2)
|
||||
expect(store.setResolvedRepoGitUsername).toHaveBeenCalledWith('r1', 'demo-user')
|
||||
expect(store.setResolvedRepoGitUsername).toHaveBeenCalledWith('r2', 'demo-user')
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips folder and SSH repos', async () => {
|
||||
const store = makeStore([
|
||||
makeRepo({ id: 'folder', kind: 'folder' }),
|
||||
makeRepo({ id: 'ssh', path: '/remote/repo', connectionId: 'conn-1' })
|
||||
])
|
||||
|
||||
enrichRepoGitUsernames(store)
|
||||
await flushRepoGitUsernameEnrichmentForTests()
|
||||
|
||||
expect(resolveLocalGitUsernameDetailedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('probes each repo location at most once per session', async () => {
|
||||
const store = makeStore([makeRepo()])
|
||||
|
||||
enrichRepoGitUsernames(store)
|
||||
await flushRepoGitUsernameEnrichmentForTests()
|
||||
enrichRepoGitUsernames(store)
|
||||
await flushRepoGitUsernameEnrichmentForTests()
|
||||
|
||||
expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps persisted usernames on a non-authoritative empty resolution', async () => {
|
||||
resolveLocalGitUsernameDetailedMock.mockResolvedValue(resolved('', false))
|
||||
const store = makeStore([makeRepo()])
|
||||
const onChanged = vi.fn()
|
||||
|
||||
enrichRepoGitUsernames(store, { onChanged })
|
||||
await flushRepoGitUsernameEnrichmentForTests()
|
||||
|
||||
expect(store.setResolvedRepoGitUsername).not.toHaveBeenCalled()
|
||||
expect(onChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears stale persisted usernames on an authoritative empty resolution', async () => {
|
||||
// Why: the user removed github.user / logged out of gh — a completed
|
||||
// probe returning '' must clear the stale prefix instead of pinning it.
|
||||
resolveLocalGitUsernameDetailedMock.mockResolvedValue(resolved('', true))
|
||||
const store = makeStore([makeRepo()])
|
||||
const onChanged = vi.fn()
|
||||
|
||||
enrichRepoGitUsernames(store, { onChanged })
|
||||
await flushRepoGitUsernameEnrichmentForTests()
|
||||
|
||||
expect(store.setResolvedRepoGitUsername).toHaveBeenCalledWith('r1', '')
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not notify when the store reports no change', async () => {
|
||||
const store = makeStore([makeRepo()])
|
||||
store.setResolvedRepoGitUsername.mockReturnValue(false)
|
||||
const onChanged = vi.fn()
|
||||
|
||||
enrichRepoGitUsernames(store, { onChanged })
|
||||
await flushRepoGitUsernameEnrichmentForTests()
|
||||
|
||||
expect(onChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-runs after the in-flight pass for repos added mid-pass', async () => {
|
||||
const repos = [makeRepo()]
|
||||
const store = makeStore(repos)
|
||||
let releaseFirstProbe!: () => void
|
||||
resolveLocalGitUsernameDetailedMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<ResolvedGitUsername>((resolve) => {
|
||||
releaseFirstProbe = () => resolve(resolved('demo-user'))
|
||||
})
|
||||
)
|
||||
|
||||
enrichRepoGitUsernames(store)
|
||||
// A repo lands while the first pass is still probing r1.
|
||||
repos.push(makeRepo({ id: 'r2', path: 'C:/repos/two' }))
|
||||
enrichRepoGitUsernames(store)
|
||||
releaseFirstProbe()
|
||||
await flushRepoGitUsernameEnrichmentForTests()
|
||||
|
||||
expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(2)
|
||||
expect(store.setResolvedRepoGitUsername).toHaveBeenCalledWith('r2', 'demo-user')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Repo } from '../shared/types'
|
||||
import { resolveLocalGitUsernameDetailed } from './git/git-username'
|
||||
|
||||
type RepoUsernameStore = {
|
||||
getRepos(): Repo[]
|
||||
setResolvedRepoGitUsername(id: string, username: string): boolean
|
||||
}
|
||||
|
||||
type EnrichmentOptions = {
|
||||
onChanged?: () => void
|
||||
}
|
||||
|
||||
// Why: resolution spawns git (and possibly gh) subprocesses, so run it at most
|
||||
// once per repo location per app session — hydrateRepo serves the persisted
|
||||
// value in between, and a relaunch picks up config changes.
|
||||
const attemptedLocations = new Set<string>()
|
||||
let enrichmentInFlight: Promise<void> | null = null
|
||||
let rerunRequested = false
|
||||
|
||||
function getRepoLocationKey(repo: Pick<Repo, 'path' | 'connectionId'>): string {
|
||||
return `${repo.connectionId ?? 'local'}\0${repo.path}`
|
||||
}
|
||||
|
||||
async function enrichRepoGitUsernamesInBackground(
|
||||
store: RepoUsernameStore,
|
||||
options: EnrichmentOptions
|
||||
): Promise<void> {
|
||||
const candidates = store.getRepos().filter(
|
||||
(repo) =>
|
||||
repo.kind !== 'folder' &&
|
||||
// Why: SSH repo paths are remote; local git cannot inspect them. The
|
||||
// SSH username path (getSshGitUsername) stays caller-driven.
|
||||
!repo.connectionId &&
|
||||
!attemptedLocations.has(getRepoLocationKey(repo))
|
||||
)
|
||||
let changed = false
|
||||
for (const repo of candidates) {
|
||||
attemptedLocations.add(getRepoLocationKey(repo))
|
||||
const { username, authoritative } = await resolveLocalGitUsernameDetailed(repo.path)
|
||||
// Why: a non-authoritative '' means a probe timed out and says nothing
|
||||
// about the account — keep the persisted value. An authoritative result
|
||||
// (including '') is the current truth: it must also CLEAR a stale
|
||||
// persisted username after the user removes github.user or logs out.
|
||||
if (!authoritative && !username) {
|
||||
continue
|
||||
}
|
||||
if (store.setResolvedRepoGitUsername(repo.id, username)) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
options.onChanged?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve git usernames for repos that haven't been probed this session, off
|
||||
* the caller's critical path. Fire-and-forget by design: repos:list must stay
|
||||
* subprocess-free (issue #7225 — a stuck sync probe froze startup for minutes).
|
||||
*/
|
||||
export function enrichRepoGitUsernames(
|
||||
store: RepoUsernameStore,
|
||||
options: EnrichmentOptions = {}
|
||||
): void {
|
||||
if (enrichmentInFlight) {
|
||||
// Why: a repo added mid-pass would otherwise be dropped until some later
|
||||
// repos:list happens to fire — queue one follow-up pass instead.
|
||||
rerunRequested = true
|
||||
return
|
||||
}
|
||||
enrichmentInFlight = enrichRepoGitUsernamesInBackground(store, options)
|
||||
.catch((error: unknown) => {
|
||||
console.error('[repo-username] Failed to enrich git usernames:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
enrichmentInFlight = null
|
||||
if (rerunRequested) {
|
||||
rerunRequested = false
|
||||
enrichRepoGitUsernames(store, options)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function flushRepoGitUsernameEnrichmentForTests(): Promise<void> {
|
||||
// A queued rerun replaces enrichmentInFlight when the first pass settles.
|
||||
while (enrichmentInFlight) {
|
||||
await enrichmentInFlight
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRepoGitUsernameEnrichmentForTests(): void {
|
||||
attemptedLocations.clear()
|
||||
enrichmentInFlight = null
|
||||
rerunRequested = false
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* Tests the full lifecycle: mobile-fit → restore → verify PTY resized.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type * as GitUsernameModule from '../git/git-username'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
@@ -38,11 +39,15 @@ vi.mock('../git/repo', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
|
||||
getBranchConflictKind: vi.fn().mockResolvedValue(null),
|
||||
getGitUsername: vi.fn().mockReturnValue('')
|
||||
getBranchConflictKind: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../git/git-username', async () => {
|
||||
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
|
||||
return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') }
|
||||
})
|
||||
|
||||
const store = {
|
||||
getRepo: () => ({
|
||||
id: 'repo-1',
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* - terminalDriverChanged notifications fire at the right transitions
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type * as GitUsernameModule from '../git/git-username'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
@@ -34,11 +35,15 @@ vi.mock('../git/repo', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
|
||||
getBranchConflictKind: vi.fn().mockResolvedValue(null),
|
||||
getGitUsername: vi.fn().mockReturnValue('')
|
||||
getBranchConflictKind: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../git/git-username', async () => {
|
||||
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
|
||||
return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') }
|
||||
})
|
||||
|
||||
const store = {
|
||||
getRepo: () => ({
|
||||
id: 'repo-1',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* debounced restore, inline restore on timer cancel, and cleanup paths.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type * as GitUsernameModule from '../git/git-username'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
@@ -40,11 +41,15 @@ vi.mock('../git/repo', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
|
||||
getBranchConflictKind: vi.fn().mockResolvedValue(null),
|
||||
getGitUsername: vi.fn().mockReturnValue('')
|
||||
getBranchConflictKind: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../git/git-username', async () => {
|
||||
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
|
||||
return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') }
|
||||
})
|
||||
|
||||
// Why: many tests pre-date the mobileAutoRestoreFitMs preference. Default
|
||||
// the mock store to MIN (5_000ms — the new clamp floor) so legacy
|
||||
// assertions about "restore fires after the configured delay" keep their
|
||||
|
||||
@@ -11,14 +11,42 @@ const {
|
||||
webContentsFromIdMock,
|
||||
startBrowserScreencastMock,
|
||||
waitForTabRegistrationMock,
|
||||
waitForWorktreeTabRegistrationMock
|
||||
waitForWorktreeTabRegistrationMock,
|
||||
browserSessionRegistryMock
|
||||
} = vi.hoisted(() => ({
|
||||
ipcMainOnMock: vi.fn(),
|
||||
ipcMainRemoveListenerMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
startBrowserScreencastMock: vi.fn(),
|
||||
waitForTabRegistrationMock: vi.fn(),
|
||||
waitForWorktreeTabRegistrationMock: vi.fn()
|
||||
waitForWorktreeTabRegistrationMock: vi.fn(),
|
||||
browserSessionRegistryMock: {
|
||||
profiles: new Map([
|
||||
[
|
||||
'default',
|
||||
{
|
||||
id: 'default',
|
||||
scope: 'default',
|
||||
partition: 'persist:orca-browser',
|
||||
label: 'Default',
|
||||
source: null
|
||||
}
|
||||
],
|
||||
[
|
||||
'profile-isolated',
|
||||
{
|
||||
id: 'profile-isolated',
|
||||
scope: 'isolated',
|
||||
partition: 'persist:orca-browser-session-profile-isolated',
|
||||
label: 'Isolated',
|
||||
source: null
|
||||
}
|
||||
]
|
||||
]),
|
||||
getDefaultProfile: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
resolveKnownPartition: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -35,6 +63,10 @@ vi.mock('../ipc/browser', () => ({
|
||||
waitForWorktreeTabRegistration: waitForWorktreeTabRegistrationMock
|
||||
}))
|
||||
|
||||
vi.mock('../browser/browser-session-registry', () => ({
|
||||
browserSessionRegistry: browserSessionRegistryMock
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
@@ -83,6 +115,23 @@ describe('RuntimeBrowserCommands browser screencast', () => {
|
||||
waitForTabRegistrationMock.mockResolvedValue(undefined)
|
||||
waitForWorktreeTabRegistrationMock.mockReset()
|
||||
waitForWorktreeTabRegistrationMock.mockResolvedValue(undefined)
|
||||
browserSessionRegistryMock.getDefaultProfile.mockReset()
|
||||
browserSessionRegistryMock.getDefaultProfile.mockImplementation(() =>
|
||||
browserSessionRegistryMock.profiles.get('default')
|
||||
)
|
||||
browserSessionRegistryMock.getProfile.mockReset()
|
||||
browserSessionRegistryMock.getProfile.mockImplementation(
|
||||
(profileId: string) => browserSessionRegistryMock.profiles.get(profileId) ?? null
|
||||
)
|
||||
browserSessionRegistryMock.resolveKnownPartition.mockReset()
|
||||
browserSessionRegistryMock.resolveKnownPartition.mockImplementation(
|
||||
(profileId: string | null | undefined) => {
|
||||
if (!profileId) {
|
||||
return 'persist:orca-browser'
|
||||
}
|
||||
return browserSessionRegistryMock.profiles.get(profileId)?.partition ?? null
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('waits for explicit worktree browser registration after requesting a hidden mount', async () => {
|
||||
@@ -191,14 +240,133 @@ describe('RuntimeBrowserCommands browser screencast', () => {
|
||||
).resolves.toEqual({ browserPageId: 'page-new' })
|
||||
|
||||
expect(waitForWorktreeTabRegistrationMock).not.toHaveBeenCalled()
|
||||
// Why: with no explicit profile, main must leave sessionProfileId/
|
||||
// sessionPartition undefined so the renderer applies the user's configured
|
||||
// default-profile inheritance instead of being forced onto the shared
|
||||
// default partition.
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'browser:requestTabCreate',
|
||||
expect.objectContaining({ url: 'about:blank', worktreeId: 'wt-1' })
|
||||
expect.objectContaining({
|
||||
url: 'about:blank',
|
||||
worktreeId: 'wt-1',
|
||||
sessionProfileId: undefined,
|
||||
sessionPartition: undefined
|
||||
})
|
||||
)
|
||||
expect(waitForTabRegistrationMock).toHaveBeenCalledWith('page-new')
|
||||
expect(bridge.setActiveTab).toHaveBeenCalledWith(101, 'wt-1')
|
||||
})
|
||||
|
||||
it('sends the resolved isolated profile partition when creating a renderer tab', async () => {
|
||||
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
|
||||
const webContents = { send: vi.fn() }
|
||||
const send = vi.fn((channel: string, data: { requestId: string }) => {
|
||||
expect(channel).toBe('browser:requestTabCreate')
|
||||
const handler = ipcMainOnMock.mock.calls.find(
|
||||
([eventName]) => eventName === 'browser:tabCreateReply'
|
||||
)?.[1] as
|
||||
| ((event: unknown, reply: { requestId: string; browserPageId?: string }) => void)
|
||||
| undefined
|
||||
handler?.({ sender: webContents } as never, {
|
||||
requestId: data.requestId,
|
||||
browserPageId: 'page-isolated'
|
||||
})
|
||||
})
|
||||
webContents.send = send
|
||||
const bridge = {
|
||||
getRegisteredTabs: vi.fn(() => new Map([['page-isolated', 101]])),
|
||||
getActivePageId: vi.fn(() => 'page-isolated'),
|
||||
setActiveTab: vi.fn(),
|
||||
tabList: vi.fn(() => ({ tabs: [] }))
|
||||
} as unknown as AgentBrowserBridge
|
||||
const commands = new RuntimeBrowserCommands(
|
||||
createHost({
|
||||
getAgentBrowserBridge: () => bridge,
|
||||
getAvailableAuthoritativeWindow: vi.fn(() => ({}) as never),
|
||||
getAuthoritativeWindow: vi.fn(() => ({ webContents }) as never)
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
commands.browserTabCreate({
|
||||
worktree: 'id:wt-1',
|
||||
url: 'https://example.com',
|
||||
profileId: 'profile-isolated'
|
||||
})
|
||||
).resolves.toEqual({ browserPageId: 'page-isolated' })
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'browser:requestTabCreate',
|
||||
expect.objectContaining({
|
||||
sessionProfileId: 'profile-isolated',
|
||||
sessionPartition: 'persist:orca-browser-session-profile-isolated'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unknown explicit profile ids before requesting a renderer tab', async () => {
|
||||
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
|
||||
const send = vi.fn()
|
||||
const commands = new RuntimeBrowserCommands(
|
||||
createHost({
|
||||
getAvailableAuthoritativeWindow: vi.fn(() => ({}) as never),
|
||||
getAuthoritativeWindow: vi.fn(() => ({ webContents: { send } }) as never)
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
commands.browserTabCreate({
|
||||
worktree: 'id:wt-1',
|
||||
url: 'https://example.com',
|
||||
profileId: 'missing-profile'
|
||||
})
|
||||
).rejects.toThrow(/Browser profile missing-profile was not found/)
|
||||
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends the resolved partition when switching a renderer tab profile', async () => {
|
||||
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
|
||||
const webContents = { send: vi.fn() }
|
||||
const send = vi.fn((channel: string, data: { requestId: string }) => {
|
||||
expect(channel).toBe('browser:requestTabSetProfile')
|
||||
const handler = ipcMainOnMock.mock.calls.find(
|
||||
([eventName]) => eventName === 'browser:tabSetProfileReply'
|
||||
)?.[1] as ((event: unknown, reply: { requestId: string; error?: string }) => void) | undefined
|
||||
handler?.({ sender: webContents } as never, { requestId: data.requestId })
|
||||
})
|
||||
webContents.send = send
|
||||
const bridge = {
|
||||
getRegisteredTabs: vi.fn(() => new Map([['page-1', 101]])),
|
||||
getActivePageId: vi.fn(() => 'page-1')
|
||||
} as unknown as AgentBrowserBridge
|
||||
const commands = new RuntimeBrowserCommands(
|
||||
createHost({
|
||||
getAgentBrowserBridge: () => bridge,
|
||||
getAvailableAuthoritativeWindow: vi.fn(() => ({}) as never),
|
||||
getAuthoritativeWindow: vi.fn(() => ({ webContents }) as never)
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
commands.browserTabSetProfile({ worktree: 'id:wt-1', profileId: 'profile-isolated' })
|
||||
).resolves.toEqual({
|
||||
browserPageId: 'page-1',
|
||||
profileId: 'profile-isolated',
|
||||
profileLabel: 'Isolated'
|
||||
})
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'browser:requestTabSetProfile',
|
||||
expect.objectContaining({
|
||||
browserPageId: 'page-1',
|
||||
profileId: 'profile-isolated',
|
||||
sessionPartition: 'persist:orca-browser-session-profile-isolated'
|
||||
})
|
||||
)
|
||||
expect(waitForTabRegistrationMock).toHaveBeenCalledWith('page-1')
|
||||
})
|
||||
|
||||
it('wakes the requested page instead of the first worktree tab for page-scoped commands', async () => {
|
||||
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
|
||||
webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true })
|
||||
|
||||
@@ -1335,6 +1335,13 @@ export class RuntimeBrowserCommands {
|
||||
const worktreeId = params.worktree
|
||||
? (await this.host.resolveWorktreeSelector(params.worktree)).id
|
||||
: undefined
|
||||
const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId)
|
||||
if (!sessionPartition) {
|
||||
throw new BrowserError(
|
||||
'invalid_argument',
|
||||
`Browser profile ${params.profileId} was not found`
|
||||
)
|
||||
}
|
||||
// Why: a desktop renderer mounts a <webview>; a headless serve has none and
|
||||
// backs the page with a main-process offscreen WebContents instead. Both
|
||||
// register into BrowserManager so all downstream commands resolve uniformly.
|
||||
@@ -1356,6 +1363,7 @@ export class RuntimeBrowserCommands {
|
||||
url,
|
||||
worktreeId,
|
||||
params.profileId,
|
||||
params.profileId ? sessionPartition : undefined,
|
||||
params.activate
|
||||
)
|
||||
|
||||
@@ -1460,7 +1468,8 @@ export class RuntimeBrowserCommands {
|
||||
win.webContents.send('browser:requestTabSetProfile', {
|
||||
requestId,
|
||||
browserPageId,
|
||||
profileId: profile.id
|
||||
profileId: profile.id,
|
||||
sessionPartition: profile.partition
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1520,7 +1529,8 @@ export class RuntimeBrowserCommands {
|
||||
const created = await this.createBrowserTabInRenderer(
|
||||
sourceTab.url,
|
||||
sourceTab.worktreeId ?? target.worktreeId,
|
||||
profile.id
|
||||
profile.id,
|
||||
profile.partition
|
||||
)
|
||||
// Why: parity with browserTabCreate. Wait for the cloned tab's webview to
|
||||
// register so the returned browserPageId is operable by the next CLI call.
|
||||
@@ -1781,8 +1791,9 @@ export class RuntimeBrowserCommands {
|
||||
|
||||
private async createBrowserTabInRenderer(
|
||||
url: string,
|
||||
worktreeId?: string,
|
||||
profileId?: string,
|
||||
worktreeId: string | undefined,
|
||||
profileId: string | undefined,
|
||||
sessionPartition: string | undefined,
|
||||
activate?: boolean
|
||||
): Promise<{ browserPageId: string }> {
|
||||
const win = this.host.getAuthoritativeWindow()
|
||||
@@ -1814,7 +1825,13 @@ export class RuntimeBrowserCommands {
|
||||
requestId,
|
||||
url,
|
||||
worktreeId,
|
||||
// Why: leave sessionProfileId/sessionPartition undefined when no explicit
|
||||
// profile was chosen so the renderer still applies the user's configured
|
||||
// default-profile inheritance. Only thread the resolved partition when a
|
||||
// profile is named — sending null here would suppress inheritance and
|
||||
// force the shared default partition.
|
||||
sessionProfileId: profileId,
|
||||
sessionPartition,
|
||||
activate
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GitUsernameModule from '../git/git-username'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -492,11 +493,15 @@ vi.mock('../git/repo', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
|
||||
getBranchConflictKind: vi.fn().mockResolvedValue(null),
|
||||
getGitUsername: vi.fn().mockReturnValue('')
|
||||
getBranchConflictKind: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../git/git-username', async () => {
|
||||
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
|
||||
return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') }
|
||||
})
|
||||
|
||||
function resetRuntimeTestMocks(): void {
|
||||
resetPlatform()
|
||||
advertisedUrlWatcher.clear()
|
||||
|
||||
@@ -570,7 +570,6 @@ import type {
|
||||
UpdatePullRequestBySlugArgs
|
||||
} from '../../shared/github-project-types'
|
||||
import {
|
||||
getGitUsername,
|
||||
getBaseRefDefault,
|
||||
getDefaultBaseRef,
|
||||
getDefaultRemote,
|
||||
@@ -590,6 +589,7 @@ import {
|
||||
getRecentDriftSubjects
|
||||
} from '../git/repo'
|
||||
import { hasLocalCommitObject } from '../git/commit-object-ref'
|
||||
import { resolveLocalGitUsername } from '../git/git-username'
|
||||
import {
|
||||
listWorktrees,
|
||||
listWorktreesStrict,
|
||||
@@ -13518,7 +13518,7 @@ export class OrcaRuntimeService {
|
||||
const requestedDisplayName = args.displayName?.trim() || undefined
|
||||
const sanitizedName = sanitizeWorktreeName(args.name)
|
||||
let effectiveSanitizedName = sanitizedName
|
||||
const username = getGitUsername(repo.path)
|
||||
const username = await resolveLocalGitUsername(repo.path)
|
||||
|
||||
const baseBranch =
|
||||
args.baseBranch ||
|
||||
@@ -15185,7 +15185,7 @@ export class OrcaRuntimeService {
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
|
||||
}
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(repo.connectionId ?? null)
|
||||
const projectRef = await getGitLabProjectRefForRemote(
|
||||
repo.path,
|
||||
remote,
|
||||
@@ -15310,7 +15310,7 @@ export class OrcaRuntimeService {
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<string> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const localGitOptionArgs =
|
||||
Object.keys(localGitOptions).length > 0 ? ([localGitOptions] as const) : []
|
||||
if (preference === 'origin') {
|
||||
|
||||
@@ -29,3 +29,11 @@ export function logStartupDiagnostic(
|
||||
.join(' ')
|
||||
writeStartupDiagnosticLine(`[startup] ${event}${detailText ? ` ${detailText}` : ''}`, write)
|
||||
}
|
||||
|
||||
// Why: startup benchmarking needs in-process timestamps — harness-side stderr
|
||||
// arrival times include pipe buffering jitter. `t` is ms since process start.
|
||||
export function logStartupMilestone(event: string, details: Record<string, unknown> = {}): void {
|
||||
if (isStartupDiagnosticsEnabled()) {
|
||||
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ type MainWindowStub = {
|
||||
id?: number
|
||||
isDestroyed?: MockFn
|
||||
on: MockFn
|
||||
once: MockFn
|
||||
webContents: {
|
||||
id?: number
|
||||
isDestroyed?: MockFn
|
||||
@@ -126,6 +127,7 @@ function createMainWindow(extraWebContents: { on?: MockFn; send?: MockFn } = {})
|
||||
id: 1,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
webContents: {
|
||||
id: 1,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
@@ -167,6 +169,16 @@ function getClosedHandlers(mainWindowOnMock: MockFn): (() => void)[] {
|
||||
.map(([, handler]) => handler as () => void)
|
||||
}
|
||||
|
||||
// Updater setup is deferred to first paint; fire the captured ready-to-show
|
||||
// handler and flush its setImmediate hop.
|
||||
async function fireReadyToShow(mainWindow: MainWindowStub): Promise<void> {
|
||||
const handler = mainWindow.once.mock.calls.find(([event]) => event === 'ready-to-show')?.[1] as
|
||||
| (() => void)
|
||||
| undefined
|
||||
handler?.()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
describe('attachMainWindowServices', () => {
|
||||
beforeEach(() => {
|
||||
onMock.mockReset()
|
||||
@@ -241,9 +253,10 @@ describe('attachMainWindowServices', () => {
|
||||
it('passes injected update quit cleanup to the auto-updater', async () => {
|
||||
const onBeforeUpdateQuit = vi.fn()
|
||||
const store = createStore()
|
||||
const mainWindow = createMainWindow()
|
||||
|
||||
attachMainWindowServices(
|
||||
createMainWindow() as never,
|
||||
mainWindow as never,
|
||||
store,
|
||||
createRuntime() as never,
|
||||
undefined,
|
||||
@@ -251,6 +264,9 @@ describe('attachMainWindowServices', () => {
|
||||
{ onBeforeUpdateQuit }
|
||||
)
|
||||
|
||||
// Deferred to first paint — must not be configured at attach time.
|
||||
expect(setupAutoUpdaterMock).not.toHaveBeenCalled()
|
||||
await fireReadyToShow(mainWindow)
|
||||
expect(setupAutoUpdaterMock).toHaveBeenCalledTimes(1)
|
||||
await setupAutoUpdaterMock.mock.calls[0][1].onBeforeQuit()
|
||||
|
||||
@@ -260,9 +276,11 @@ describe('attachMainWindowServices', () => {
|
||||
|
||||
it('flushes the store before update quit when no cleanup is injected', async () => {
|
||||
const store = createStore()
|
||||
const mainWindow = createMainWindow()
|
||||
|
||||
attachMainWindowServices(createMainWindow() as never, store, createRuntime() as never)
|
||||
attachMainWindowServices(mainWindow as never, store, createRuntime() as never)
|
||||
|
||||
await fireReadyToShow(mainWindow)
|
||||
await setupAutoUpdaterMock.mock.calls[0][1].onBeforeQuit()
|
||||
|
||||
expect(store.flush).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -41,6 +41,19 @@ import {
|
||||
scheduleWorktreeBaseDirectoryWatcherSync,
|
||||
setWorktreeBaseDirectoryWatcherSyncContext
|
||||
} from '../ipc/worktree-base-directory-watcher'
|
||||
import { logStartupMilestone } from '../startup/startup-diagnostics'
|
||||
|
||||
const UPDATER_SETUP_FALLBACK_MS = 15_000
|
||||
|
||||
// Why: updater setup is deferred past first paint, but a manual check (app
|
||||
// menu or updater:check IPC) can arrive inside that window — it must run
|
||||
// against a configured updater (listeners, autoDownload=false, window ref),
|
||||
// so those entry points force the pending setup first.
|
||||
let pendingAutoUpdaterSetup: (() => void) | null = null
|
||||
|
||||
export function ensureAutoUpdaterConfigured(): void {
|
||||
pendingAutoUpdaterSetup?.()
|
||||
}
|
||||
|
||||
let appReloadHandlerTokenCounter = 0
|
||||
let activeAppReloadHandlerToken: number | null = null
|
||||
@@ -116,37 +129,54 @@ export function attachMainWindowServices(
|
||||
registerSshHandlers(store, () => mainWindow, runtime)
|
||||
registerRemoteWorkspaceHandlers(store, () => mainWindow)
|
||||
registerFileDropRelay(mainWindow)
|
||||
setupAutoUpdater(mainWindow, {
|
||||
getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt,
|
||||
onBeforeQuit: async () => {
|
||||
try {
|
||||
await options?.onBeforeUpdateQuit?.()
|
||||
} finally {
|
||||
store.flush()
|
||||
}
|
||||
},
|
||||
setLastUpdateCheckAt: (timestamp) => {
|
||||
store.updateUI({ lastUpdateCheckAt: timestamp })
|
||||
},
|
||||
getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null,
|
||||
getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null,
|
||||
setPendingUpdateNudgeId: (id) => {
|
||||
// Why: the nudge lifecycle is owned by the main process. When applying a
|
||||
// new campaign, persist the pending id AND clear the version dismissal
|
||||
// together so relaunches cannot resurrect the old hidden-card state
|
||||
// between nudge apply and renderer sync. When clearing (id is null),
|
||||
// only touch pendingUpdateNudgeId — clearing dismissedUpdateVersion here
|
||||
// would silently un-dismiss an update if the flow ever changes.
|
||||
if (id) {
|
||||
store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null })
|
||||
} else {
|
||||
store.updateUI({ pendingUpdateNudgeId: null })
|
||||
}
|
||||
},
|
||||
setDismissedUpdateNudgeId: (id) => {
|
||||
store.updateUI({ dismissedUpdateNudgeId: id })
|
||||
// Why: setupAutoUpdater's first getAutoUpdater() call synchronously
|
||||
// require()s electron-updater in packaged builds — seconds on a cold
|
||||
// Windows disk under Defender scanning (part of issue #7225's pre-paint
|
||||
// stall) — so defer it past first paint. The timer fallback keeps update
|
||||
// checks alive for renderers that crash-loop before ever painting.
|
||||
let updaterSetupDone = false
|
||||
const setupAutoUpdaterDeferred = (): void => {
|
||||
if (updaterSetupDone || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
})
|
||||
updaterSetupDone = true
|
||||
setupAutoUpdater(mainWindow, {
|
||||
getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt,
|
||||
onBeforeQuit: async () => {
|
||||
try {
|
||||
await options?.onBeforeUpdateQuit?.()
|
||||
} finally {
|
||||
store.flush()
|
||||
}
|
||||
},
|
||||
setLastUpdateCheckAt: (timestamp) => {
|
||||
store.updateUI({ lastUpdateCheckAt: timestamp })
|
||||
},
|
||||
getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null,
|
||||
getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null,
|
||||
setPendingUpdateNudgeId: (id) => {
|
||||
// Why: the nudge lifecycle is owned by the main process. When applying a
|
||||
// new campaign, persist the pending id AND clear the version dismissal
|
||||
// together so relaunches cannot resurrect the old hidden-card state
|
||||
// between nudge apply and renderer sync. When clearing (id is null),
|
||||
// only touch pendingUpdateNudgeId — clearing dismissedUpdateVersion here
|
||||
// would silently un-dismiss an update if the flow ever changes.
|
||||
if (id) {
|
||||
store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null })
|
||||
} else {
|
||||
store.updateUI({ pendingUpdateNudgeId: null })
|
||||
}
|
||||
},
|
||||
setDismissedUpdateNudgeId: (id) => {
|
||||
store.updateUI({ dismissedUpdateNudgeId: id })
|
||||
}
|
||||
})
|
||||
logStartupMilestone('updater-setup-done')
|
||||
}
|
||||
pendingAutoUpdaterSetup = setupAutoUpdaterDeferred
|
||||
mainWindow.once('ready-to-show', () => setImmediate(setupAutoUpdaterDeferred))
|
||||
const updaterSetupFallback = setTimeout(setupAutoUpdaterDeferred, UPDATER_SETUP_FALLBACK_MS)
|
||||
updaterSetupFallback.unref?.()
|
||||
registerRuntimeWindowLifecycle(mainWindow, runtime)
|
||||
|
||||
const allowedPermissions = new Set(['media', 'fullscreen', 'pointerLock'])
|
||||
@@ -415,9 +445,10 @@ export function registerUpdaterHandlers(_store: Store): void {
|
||||
|
||||
ipcMain.handle('updater:getStatus', () => getUpdateStatus())
|
||||
ipcMain.handle('updater:getVersion', () => app.getVersion())
|
||||
ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) =>
|
||||
checkForUpdatesFromMenu(options)
|
||||
)
|
||||
ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) => {
|
||||
ensureAutoUpdaterConfigured()
|
||||
return checkForUpdatesFromMenu(options)
|
||||
})
|
||||
ipcMain.handle('updater:download', () => downloadUpdate())
|
||||
ipcMain.handle('updater:quitAndInstall', () => quitAndInstall())
|
||||
ipcMain.handle('updater:dismissNudge', () => dismissNudge())
|
||||
|
||||
@@ -2581,13 +2581,19 @@ export type PreloadApi = {
|
||||
requestId: string
|
||||
url: string
|
||||
worktreeId?: string
|
||||
sessionProfileId?: string
|
||||
sessionProfileId?: string | null
|
||||
sessionPartition?: string
|
||||
activate?: boolean
|
||||
}) => void
|
||||
) => () => void
|
||||
replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }) => void
|
||||
onRequestTabSetProfile: (
|
||||
callback: (data: { requestId: string; browserPageId: string; profileId: string }) => void
|
||||
callback: (data: {
|
||||
requestId: string
|
||||
browserPageId: string
|
||||
profileId: string
|
||||
sessionPartition?: string
|
||||
}) => void
|
||||
) => () => void
|
||||
replyTabSetProfile: (reply: { requestId: string; error?: string }) => void
|
||||
onRequestTabClose: (
|
||||
|
||||
+18
-4
@@ -2230,6 +2230,7 @@ const api = {
|
||||
screenY: number
|
||||
pageUrl: string
|
||||
linkUrl: string | null
|
||||
selectionText: string
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
}) => void
|
||||
@@ -2244,6 +2245,7 @@ const api = {
|
||||
screenY: number
|
||||
pageUrl: string
|
||||
linkUrl: string | null
|
||||
selectionText: string
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
}
|
||||
@@ -3093,7 +3095,8 @@ const api = {
|
||||
requestId: string
|
||||
url: string
|
||||
worktreeId?: string
|
||||
sessionProfileId?: string
|
||||
sessionProfileId?: string | null
|
||||
sessionPartition?: string
|
||||
activate?: boolean
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
@@ -3103,7 +3106,8 @@ const api = {
|
||||
requestId: string
|
||||
url: string
|
||||
worktreeId?: string
|
||||
sessionProfileId?: string
|
||||
sessionProfileId?: string | null
|
||||
sessionPartition?: string
|
||||
activate?: boolean
|
||||
}
|
||||
) => callback(data)
|
||||
@@ -3118,11 +3122,21 @@ const api = {
|
||||
ipcRenderer.send('browser:tabCreateReply', reply)
|
||||
},
|
||||
onRequestTabSetProfile: (
|
||||
callback: (data: { requestId: string; browserPageId: string; profileId: string }) => void
|
||||
callback: (data: {
|
||||
requestId: string
|
||||
browserPageId: string
|
||||
profileId: string
|
||||
sessionPartition?: string
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { requestId: string; browserPageId: string; profileId: string }
|
||||
data: {
|
||||
requestId: string
|
||||
browserPageId: string
|
||||
profileId: string
|
||||
sessionPartition?: string
|
||||
}
|
||||
) => callback(data)
|
||||
ipcRenderer.on('browser:requestTabSetProfile', listener)
|
||||
return () => ipcRenderer.removeListener('browser:requestTabSetProfile', listener)
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseStatusOutput } from './git-status-output-parser'
|
||||
import { isUnsupportedWorktreeListZError } from './git-handler-utils'
|
||||
|
||||
describe('isUnsupportedWorktreeListZError', () => {
|
||||
it('detects an unknown-switch usage error from stderr when the exit code is absent', () => {
|
||||
// Isolates the regex fallback: no numeric code, so only the stderr text
|
||||
// (a runner that dropped the exit code) can classify the rejection.
|
||||
const error = Object.assign(new Error('worktree list -z'), {
|
||||
stderr: "error: unknown switch `z'\nusage: git worktree list [<options>]\n"
|
||||
})
|
||||
expect(isUnsupportedWorktreeListZError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a localized (non-English) usage error via exit code 129', () => {
|
||||
// The SSH remote may run under a non-English locale where the stderr text is
|
||||
// translated; the numeric exit code must still classify the -z rejection.
|
||||
const error = Object.assign(new Error('worktree list -z'), {
|
||||
code: 129,
|
||||
stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list [<Optionen>]\n'
|
||||
})
|
||||
expect(isUnsupportedWorktreeListZError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not classify a fatal (exit 128) error as an unsupported -z rejection', () => {
|
||||
const error = Object.assign(new Error('fatal'), {
|
||||
code: 128,
|
||||
stderr: 'fatal: unable to read tree\n'
|
||||
})
|
||||
expect(isUnsupportedWorktreeListZError(error)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStatusOutput', () => {
|
||||
it('parses upstream ahead/behind from porcelain v2 branch headers', () => {
|
||||
|
||||
@@ -147,10 +147,20 @@ function getErrorText(error: unknown): string {
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
return typeof error === 'object' && error !== null && 'code' in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function isUnsupportedWorktreeListZError(error: unknown): boolean {
|
||||
return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test(
|
||||
getErrorText(error)
|
||||
)
|
||||
// `-z` is this command's only flag older Git (<2.36) lacks, so its usage exit
|
||||
// 129 signals the rejection in any locale; key for SSH remotes on old Git.
|
||||
if (getErrorCode(error) === '129') {
|
||||
return true
|
||||
}
|
||||
|
||||
return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error))
|
||||
}
|
||||
|
||||
export function parseWorktreeList(
|
||||
|
||||
@@ -865,6 +865,25 @@ function App(): React.JSX.Element {
|
||||
useAppStore.getState().settings,
|
||||
getSystemPrefersDark()
|
||||
)
|
||||
// Why: these three reads are main-side store/file reads with no
|
||||
// dependency on anything fetched below, so start them now and await
|
||||
// them at their original positions — the round-trips overlap the
|
||||
// repo/worktree scans instead of queuing after them. Browser session
|
||||
// profiles are deliberately NOT started early: on a remote runtime
|
||||
// they route through a runtime RPC that may not be connected this
|
||||
// early, and a failed fetch clears the profile list. The floating
|
||||
// .catch marks the rejection handled if an earlier awaited step
|
||||
// throws first; each await still rethrows its own failure.
|
||||
const uiGetPromise = timeRendererStartupStep('ui-get', () => window.api.ui.get())
|
||||
uiGetPromise.catch(() => {})
|
||||
const keybindingsPromise = timeRendererStartupStep('fetch-keybindings', () =>
|
||||
actions.fetchKeybindings()
|
||||
)
|
||||
keybindingsPromise.catch(() => {})
|
||||
const onboardingPromise = timeRendererStartupStep('onboarding-get', () =>
|
||||
window.api.onboarding.get()
|
||||
)
|
||||
onboardingPromise.catch(() => {})
|
||||
// Why: load local + every configured runtime environment (not just the
|
||||
// active one) so a cold start that restored a remote workspace doesn't
|
||||
// hide local repos. The sidebar "All hosts" scope then shows them all.
|
||||
@@ -875,11 +894,17 @@ function App(): React.JSX.Element {
|
||||
await timeRendererStartupStep('fetch-folder-workspaces', () =>
|
||||
actions.fetchFolderWorkspacesForAllHosts()
|
||||
)
|
||||
await timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees())
|
||||
await timeRendererStartupStep('fetch-worktree-lineage', () =>
|
||||
actions.fetchWorktreeLineage()
|
||||
)
|
||||
const persistedUI = await timeRendererStartupStep('ui-get', () => window.api.ui.get())
|
||||
// Why: both of these fan out `git worktree list` per repo on the main
|
||||
// process. Running them concurrently lets the main process share one
|
||||
// in-flight scan per repo instead of paying the process-spawn fan-out
|
||||
// twice back-to-back — the dominant renderer-chain cost on Windows
|
||||
// (issue #7225). Lineage only reads settings + its own slice, so it
|
||||
// does not depend on the worktrees fetch having landed.
|
||||
await Promise.all([
|
||||
timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees()),
|
||||
timeRendererStartupStep('fetch-worktree-lineage', () => actions.fetchWorktreeLineage())
|
||||
])
|
||||
const persistedUI = await uiGetPromise
|
||||
uiHydrated = timeRendererStartupSyncStep('hydrate-persisted-ui', () =>
|
||||
hydratePersistedUIAfterStartupRead({
|
||||
persistedUI,
|
||||
@@ -894,7 +919,7 @@ function App(): React.JSX.Element {
|
||||
const session = await timeRendererStartupStep('session-get', () =>
|
||||
fetchWorkspaceSessionFromHosts(window.api.session, useAppStore.getState().repos)
|
||||
)
|
||||
await timeRendererStartupStep('fetch-keybindings', () => actions.fetchKeybindings())
|
||||
await keybindingsPromise
|
||||
if (!cancelled) {
|
||||
timeRendererStartupSyncStep('hydrate-session-stores', () => {
|
||||
actions.hydrateWorkspaceSession(session)
|
||||
@@ -917,9 +942,7 @@ function App(): React.JSX.Element {
|
||||
await timeRendererStartupStep('fetch-browser-session-profiles', () =>
|
||||
actions.fetchBrowserSessionProfiles()
|
||||
)
|
||||
const onboardingState = await timeRendererStartupStep('onboarding-get', () =>
|
||||
window.api.onboarding.get()
|
||||
)
|
||||
const onboardingState = await onboardingPromise
|
||||
if (!cancelled) {
|
||||
setOnboarding(onboardingState)
|
||||
setOnboardingLoaded(true)
|
||||
|
||||
@@ -219,6 +219,12 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pane-drop-overlay[data-pane-drop-overlay-kind='insertion'] {
|
||||
background: var(--chart-2);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tab-drop-overlay__label {
|
||||
font-size: 11px;
|
||||
color: var(--primary-foreground);
|
||||
|
||||
@@ -362,8 +362,8 @@ export default function Landing(): React.JSX.Element {
|
||||
onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })}
|
||||
>
|
||||
<GitBranchPlus className="size-3.5" />
|
||||
{translate('auto.components.Landing.76a95f7f47', 'Create')}
|
||||
{createTargetLabel}
|
||||
{translate('auto.components.Landing.76a95f7f47', 'Create')}{' '}
|
||||
{createTargetLabel.toLowerCase()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
getEffectiveLayoutForWorktree as getEffectiveLayout,
|
||||
anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout
|
||||
} from './terminal/split-group-mount'
|
||||
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs'
|
||||
import {
|
||||
@@ -1174,8 +1175,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
return
|
||||
}
|
||||
createBrowserTab(activeWorktreeId, source.url, {
|
||||
title: source.title,
|
||||
sessionProfileId: source.sessionProfileId
|
||||
...buildDuplicatedBrowserTabOptions(source)
|
||||
})
|
||||
},
|
||||
[activeWorktreeId, createBrowserTab]
|
||||
|
||||
@@ -77,14 +77,12 @@ import {
|
||||
browserViewportPresetToOverride,
|
||||
getBrowserViewportPreset
|
||||
} from '../../../../shared/browser-viewport-presets'
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'
|
||||
import { rememberLiveBrowserUrl } from './browser-runtime'
|
||||
import { ensureBrowserPageWebview } from './browser-page-webview'
|
||||
import {
|
||||
destroyPersistentWebview,
|
||||
moveFocusToRendererBeforeWebviewDetach,
|
||||
registerPersistentWebview,
|
||||
registeredWebContentsIds,
|
||||
webviewRegistry
|
||||
registeredWebContentsIds
|
||||
} from './webview-registry'
|
||||
import {
|
||||
applyBrowserPageViewportLayout,
|
||||
@@ -283,6 +281,7 @@ type RemoteBrowserContextMenu = {
|
||||
y: number
|
||||
linkUrl: string | null
|
||||
pageUrl: string
|
||||
selectionText: string
|
||||
}
|
||||
|
||||
type RemoteBrowserViewportSize = {
|
||||
@@ -617,16 +616,21 @@ function buildRemoteContextMenuExpression(x: number, y: number): string {
|
||||
return `(() => {
|
||||
const target = document.elementFromPoint(${JSON.stringify(x)}, ${JSON.stringify(y)});
|
||||
const anchor = target && typeof target.closest === 'function' ? target.closest('a[href]') : null;
|
||||
// Why: read the guest selection here so the remote/paired browser can offer
|
||||
// the same Copy affordance as the local webview (there is no ContextMenuParams
|
||||
// over the runtime RPC).
|
||||
const selection = typeof window.getSelection === 'function' ? window.getSelection() : null;
|
||||
return JSON.stringify({
|
||||
linkUrl: anchor && anchor.href ? anchor.href : null,
|
||||
pageUrl: location.href || 'about:blank'
|
||||
pageUrl: location.href || 'about:blank',
|
||||
selectionText: selection ? String(selection) : ''
|
||||
});
|
||||
})()`
|
||||
}
|
||||
|
||||
function readRemoteContextMenuResult(
|
||||
result: unknown
|
||||
): Pick<RemoteBrowserContextMenu, 'linkUrl' | 'pageUrl'> | null {
|
||||
): Pick<RemoteBrowserContextMenu, 'linkUrl' | 'pageUrl' | 'selectionText'> | null {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null
|
||||
}
|
||||
@@ -635,10 +639,16 @@ function readRemoteContextMenuResult(
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { linkUrl?: unknown; pageUrl?: unknown }
|
||||
const parsed = JSON.parse(raw) as {
|
||||
linkUrl?: unknown
|
||||
pageUrl?: unknown
|
||||
selectionText?: unknown
|
||||
}
|
||||
return {
|
||||
linkUrl: typeof parsed.linkUrl === 'string' && parsed.linkUrl ? parsed.linkUrl : null,
|
||||
pageUrl: typeof parsed.pageUrl === 'string' && parsed.pageUrl ? parsed.pageUrl : 'about:blank'
|
||||
pageUrl:
|
||||
typeof parsed.pageUrl === 'string' && parsed.pageUrl ? parsed.pageUrl : 'about:blank',
|
||||
selectionText: typeof parsed.selectionText === 'string' ? parsed.selectionText : ''
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -869,6 +879,7 @@ export default function BrowserPane({
|
||||
workspaceId={browserTab.id}
|
||||
worktreeId={browserTab.worktreeId}
|
||||
sessionProfileId={browserTab.sessionProfileId ?? null}
|
||||
sessionPartition={browserTab.sessionPartition ?? null}
|
||||
isActive={isActive && page.id === activeBrowserPage?.id}
|
||||
isAutomationVisible={automationVisiblePageIds.has(page.id)}
|
||||
isMobileDriven={mobileDrivenPageIds.has(page.id)}
|
||||
@@ -2159,7 +2170,9 @@ function RemoteBrowserPagePane({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
linkUrl: null,
|
||||
pageUrl: browserTab.url || 'about:blank'
|
||||
pageUrl: browserTab.url || 'about:blank',
|
||||
// Why: filled in below once the async eval reads the guest selection.
|
||||
selectionText: ''
|
||||
})
|
||||
enqueueRemoteInput(async () => {
|
||||
const operationToken = createRemoteOperationToken(pageId)
|
||||
@@ -2184,7 +2197,8 @@ function RemoteBrowserPagePane({
|
||||
? {
|
||||
...current,
|
||||
linkUrl: parsed.linkUrl,
|
||||
pageUrl: redactKagiSessionToken(parsed.pageUrl)
|
||||
pageUrl: redactKagiSessionToken(parsed.pageUrl),
|
||||
selectionText: parsed.selectionText
|
||||
}
|
||||
: current
|
||||
)
|
||||
@@ -2442,6 +2456,21 @@ function RemoteBrowserPagePane({
|
||||
<div className="my-1 h-px bg-border/70" />
|
||||
</>
|
||||
) : null}
|
||||
{contextMenu.selectionText.trim() ? (
|
||||
<>
|
||||
<button
|
||||
role="menuitem"
|
||||
className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14"
|
||||
onClick={() => {
|
||||
void window.api.ui.writeClipboardText(contextMenu.selectionText)
|
||||
setContextMenu(null)
|
||||
}}
|
||||
>
|
||||
{translate('auto.components.browser.pane.BrowserPane.2a4c4b8e1f', 'Copy')}
|
||||
</button>
|
||||
<div className="my-1 h-px bg-border/70" />
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
role="menuitem"
|
||||
className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14"
|
||||
@@ -2648,6 +2677,7 @@ function BrowserPagePane({
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
sessionProfileId,
|
||||
sessionPartition,
|
||||
isActive,
|
||||
isAutomationVisible,
|
||||
isMobileDriven,
|
||||
@@ -2659,6 +2689,7 @@ function BrowserPagePane({
|
||||
workspaceId: string
|
||||
worktreeId: string
|
||||
sessionProfileId: string | null
|
||||
sessionPartition: string | null
|
||||
isActive: boolean
|
||||
isAutomationVisible: boolean
|
||||
isMobileDriven: boolean
|
||||
@@ -2760,6 +2791,7 @@ function BrowserPagePane({
|
||||
y: number
|
||||
linkUrl: string | null
|
||||
pageUrl: string
|
||||
selectionText: string
|
||||
} | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [findOpen, setFindOpen] = useState(false)
|
||||
@@ -2814,7 +2846,7 @@ function BrowserPagePane({
|
||||
const sessionProfile = sessionProfileId
|
||||
? (browserSessionProfiles.find((p) => p.id === sessionProfileId) ?? null)
|
||||
: null
|
||||
const webviewPartition = sessionProfile?.partition ?? ORCA_BROWSER_PARTITION
|
||||
const webviewPartition = sessionPartition ?? sessionProfile?.partition ?? ORCA_BROWSER_PARTITION
|
||||
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
|
||||
const clearBrowserSessionImportState = useAppStore((s) => s.clearBrowserSessionImportState)
|
||||
const showBrowserZoomFeedback = useCallback((level: number): void => {
|
||||
@@ -3070,7 +3102,8 @@ function BrowserPagePane({
|
||||
x,
|
||||
y,
|
||||
linkUrl: event.linkUrl,
|
||||
pageUrl: event.pageUrl
|
||||
pageUrl: event.pageUrl,
|
||||
selectionText: event.selectionText ?? ''
|
||||
})
|
||||
})
|
||||
}, [browserTab.id])
|
||||
@@ -3576,31 +3609,23 @@ function BrowserPagePane({
|
||||
return
|
||||
}
|
||||
|
||||
let webview = webviewRegistry.get(browserTab.id)
|
||||
let needsInitialNavigation = false
|
||||
let needsInitialDefaultZoom = false
|
||||
if (webview && webview.parentElement !== container) {
|
||||
// Why: moving an Electron webview between DOM parents can recreate the
|
||||
// guest document. Treat unexpected parent drift as stale state instead.
|
||||
destroyPersistentWebview(browserTab.id)
|
||||
webview = undefined
|
||||
container = ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
const ensuredWebview = ensureBrowserPageWebview({
|
||||
browserTabId: browserTab.id,
|
||||
container,
|
||||
inputLocked: inputLockedRef.current,
|
||||
webviewPartition,
|
||||
resolveContainer: () =>
|
||||
ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null
|
||||
})
|
||||
if (!ensuredWebview) {
|
||||
return
|
||||
}
|
||||
if (webview && webview.getAttribute('partition') !== webviewPartition) {
|
||||
// Why: Electron partitions are immutable after creation. If restored state
|
||||
// or another store path changes the profile, the persisted guest must be
|
||||
// replaced rather than parked/reused with the stale session.
|
||||
destroyPersistentWebview(browserTab.id)
|
||||
webview = undefined
|
||||
container = ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (webview) {
|
||||
container = ensuredWebview.container
|
||||
const webview = ensuredWebview.webview
|
||||
const needsInitialNavigation = ensuredWebview.created
|
||||
let needsInitialDefaultZoom = ensuredWebview.created
|
||||
|
||||
if (!ensuredWebview.created) {
|
||||
webview.style.pointerEvents = inputLockedRef.current ? 'none' : 'auto'
|
||||
syncNavigationState(webview)
|
||||
// Why: seed the ref with the store URL so the URL sync effect does not
|
||||
@@ -3609,35 +3634,6 @@ function BrowserPagePane({
|
||||
// last navigation event.
|
||||
lastKnownWebviewUrlRef.current =
|
||||
normalizeBrowserNavigationUrl(browserTabUrlRef.current) ?? null
|
||||
} else {
|
||||
webview = document.createElement('webview') as Electron.WebviewTag
|
||||
webview.setAttribute('partition', webviewPartition)
|
||||
webview.setAttribute('allowpopups', '')
|
||||
// Why: keep HTML fullscreen contained to the <webview> element instead of
|
||||
// letting guest content resize the host BrowserWindow into native macOS
|
||||
// fullscreen. Without this, requestFullscreen() resizes the OS window but
|
||||
// exitFullscreen() has nothing to restore, leaving the pane stuck
|
||||
// fullscreen (issue #6442). Containing fullscreen to the element makes
|
||||
// exit reliable and fires fullscreenchange as guests expect.
|
||||
// Why: the key must be camelCase — Electron's <webview> webpreferences
|
||||
// parser spreads keys verbatim into WebPreferences, so a lowercase key is
|
||||
// silently ignored.
|
||||
webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE)
|
||||
webview.style.display = 'flex'
|
||||
webview.style.flex = '1'
|
||||
webview.style.width = '100%'
|
||||
webview.style.height = '100%'
|
||||
webview.style.border = 'none'
|
||||
webview.style.pointerEvents = inputLockedRef.current ? 'none' : 'auto'
|
||||
// Why: default to white so sites that don't set an html/body background
|
||||
// (e.g. httpbin.org/html) don't show through to Orca's dark chrome. Real
|
||||
// browsers paint the viewport white by default; sites that specify their
|
||||
// own background (including dark ones) still override this.
|
||||
webview.style.background = '#ffffff'
|
||||
registerPersistentWebview(browserTab.id, webview)
|
||||
container.appendChild(webview)
|
||||
needsInitialNavigation = true
|
||||
needsInitialDefaultZoom = true
|
||||
}
|
||||
|
||||
webviewRef.current = webview
|
||||
@@ -4816,6 +4812,21 @@ function BrowserPagePane({
|
||||
<div className="my-1 h-px bg-border/70" />
|
||||
</>
|
||||
) : null}
|
||||
{contextMenu.selectionText.trim() ? (
|
||||
<>
|
||||
<button
|
||||
role="menuitem"
|
||||
className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14"
|
||||
onClick={() => {
|
||||
void window.api.ui.writeClipboardText(contextMenu.selectionText)
|
||||
setContextMenu(null)
|
||||
}}
|
||||
>
|
||||
{translate('auto.components.browser.pane.BrowserPane.2a4c4b8e1f', 'Copy')}
|
||||
</button>
|
||||
<div className="my-1 h-px bg-border/70" />
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
role="menuitem"
|
||||
disabled={!browserTab.canGoBack}
|
||||
|
||||
@@ -1,38 +1,106 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'
|
||||
|
||||
const BROWSER_PANE_SOURCE = resolve(__dirname, 'BrowserPane.tsx')
|
||||
const registryMocks = vi.hoisted(() => ({
|
||||
destroyPersistentWebview: vi.fn(),
|
||||
registerPersistentWebview: vi.fn(),
|
||||
webviewRegistry: new Map<string, Electron.WebviewTag>()
|
||||
}))
|
||||
|
||||
function browserPaneSource(): string {
|
||||
return readFileSync(BROWSER_PANE_SOURCE, 'utf8')
|
||||
}
|
||||
vi.mock('./webview-registry', () => ({
|
||||
destroyPersistentWebview: registryMocks.destroyPersistentWebview,
|
||||
registerPersistentWebview: registryMocks.registerPersistentWebview,
|
||||
webviewRegistry: registryMocks.webviewRegistry
|
||||
}))
|
||||
|
||||
function sourceBetween(source: string, start: string, end: string): string {
|
||||
const startIndex = source.indexOf(start)
|
||||
const endIndex = source.indexOf(end, startIndex + start.length)
|
||||
import { ensureBrowserPageWebview } from './browser-page-webview'
|
||||
|
||||
expect(startIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(endIndex).toBeGreaterThan(startIndex)
|
||||
|
||||
return source.slice(startIndex, endIndex)
|
||||
function createContainer(id: string): HTMLDivElement {
|
||||
const container = document.createElement('div')
|
||||
container.dataset.testid = id
|
||||
document.body.appendChild(container)
|
||||
return container
|
||||
}
|
||||
|
||||
describe('BrowserPane webview preferences', () => {
|
||||
it('keeps HTML fullscreen contained inside the browser pane webview', () => {
|
||||
const source = browserPaneSource()
|
||||
const creationBlock = sourceBetween(
|
||||
source,
|
||||
"webview = document.createElement('webview')",
|
||||
'registerPersistentWebview'
|
||||
)
|
||||
beforeEach(() => {
|
||||
registryMocks.destroyPersistentWebview.mockReset()
|
||||
registryMocks.registerPersistentWebview.mockReset()
|
||||
registryMocks.webviewRegistry.clear()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
expect(source).toContain(
|
||||
"import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'"
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('creates a webview with the resolved partition and shared guest webpreferences', () => {
|
||||
const container = createContainer('initial')
|
||||
|
||||
const ensuredWebview = ensureBrowserPageWebview({
|
||||
browserTabId: 'browser-page-1',
|
||||
container,
|
||||
inputLocked: false,
|
||||
webviewPartition: 'persist:orca-browser-session-profile-1',
|
||||
resolveContainer: () => container
|
||||
})
|
||||
|
||||
expect(ensuredWebview).not.toBeNull()
|
||||
expect(ensuredWebview?.created).toBe(true)
|
||||
expect(ensuredWebview?.container).toBe(container)
|
||||
expect(ensuredWebview?.webview.getAttribute('partition')).toBe(
|
||||
'persist:orca-browser-session-profile-1'
|
||||
)
|
||||
expect(creationBlock).toContain(
|
||||
"webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE)"
|
||||
expect(ensuredWebview?.webview.getAttribute('webpreferences')).toBe(
|
||||
ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE
|
||||
)
|
||||
expect(creationBlock).not.toContain('disablehtmlfullscreenwindowresize')
|
||||
expect(registryMocks.registerPersistentWebview).toHaveBeenCalledWith(
|
||||
'browser-page-1',
|
||||
ensuredWebview?.webview
|
||||
)
|
||||
expect(container.lastElementChild).toBe(ensuredWebview?.webview as unknown as Element)
|
||||
})
|
||||
|
||||
it('remounts the webview in a refreshed container when the stored resolved partition changes', () => {
|
||||
const staleContainer = createContainer('stale')
|
||||
const staleWebview = document.createElement('webview') as Electron.WebviewTag
|
||||
staleWebview.setAttribute('partition', 'persist:orca-browser')
|
||||
staleContainer.appendChild(staleWebview)
|
||||
registryMocks.webviewRegistry.set('browser-page-1', staleWebview)
|
||||
|
||||
const refreshedContainer = document.createElement('div')
|
||||
refreshedContainer.dataset.testid = 'refreshed'
|
||||
const resolveContainer = vi.fn(() => {
|
||||
if (!refreshedContainer.isConnected) {
|
||||
document.body.appendChild(refreshedContainer)
|
||||
}
|
||||
return refreshedContainer
|
||||
})
|
||||
registryMocks.destroyPersistentWebview.mockImplementation(() => {
|
||||
staleWebview.remove()
|
||||
staleContainer.remove()
|
||||
registryMocks.webviewRegistry.delete('browser-page-1')
|
||||
})
|
||||
|
||||
const ensuredWebview = ensureBrowserPageWebview({
|
||||
browserTabId: 'browser-page-1',
|
||||
container: staleContainer,
|
||||
inputLocked: true,
|
||||
webviewPartition: 'persist:orca-browser-session-profile-1',
|
||||
resolveContainer
|
||||
})
|
||||
|
||||
expect(registryMocks.destroyPersistentWebview).toHaveBeenCalledWith('browser-page-1')
|
||||
expect(resolveContainer).toHaveBeenCalledTimes(1)
|
||||
expect(ensuredWebview).not.toBeNull()
|
||||
expect(ensuredWebview?.created).toBe(true)
|
||||
expect(ensuredWebview?.container).toBe(refreshedContainer)
|
||||
expect(ensuredWebview?.webview).not.toBe(staleWebview)
|
||||
expect(ensuredWebview?.webview.getAttribute('partition')).toBe(
|
||||
'persist:orca-browser-session-profile-1'
|
||||
)
|
||||
expect(ensuredWebview?.webview.style.pointerEvents).toBe('none')
|
||||
expect(refreshedContainer.lastElementChild).toBe(ensuredWebview?.webview as unknown as Element)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,12 +102,14 @@ export function BrowserToolbarMenu({
|
||||
return
|
||||
}
|
||||
const targetId = pendingSwitchProfileId ?? 'default'
|
||||
const profile = browserSessionProfiles.find((p) => p.id === targetId)
|
||||
// Why: Must destroy before store update. The webviewRegistry is keyed by
|
||||
// workspace ID (stable across switches). Without explicit destroy, the mount
|
||||
// effect would reclaim the old webview with the stale partition.
|
||||
onDestroyWebview()
|
||||
switchBrowserTabProfile(workspaceId, pendingSwitchProfileId)
|
||||
const profile = browserSessionProfiles.find((p) => p.id === targetId)
|
||||
// Why: persist the resolved partition alongside the profile so the tab stays
|
||||
// isolated even if the renderer profile mirror is later stale (issue #6923).
|
||||
switchBrowserTabProfile(workspaceId, pendingSwitchProfileId, profile?.partition ?? null)
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.browser.pane.BrowserToolbarMenu.3ccd29d771',
|
||||
@@ -147,7 +149,7 @@ export function BrowserToolbarMenu({
|
||||
setNewProfileName('')
|
||||
|
||||
onDestroyWebview()
|
||||
switchBrowserTabProfile(workspaceId, profile.id)
|
||||
switchBrowserTabProfile(workspaceId, profile.id, profile.partition)
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.browser.pane.BrowserToolbarMenu.a7a86702b3',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'
|
||||
import {
|
||||
destroyPersistentWebview,
|
||||
registerPersistentWebview,
|
||||
webviewRegistry
|
||||
} from './webview-registry'
|
||||
|
||||
export function ensureBrowserPageWebview({
|
||||
browserTabId,
|
||||
container,
|
||||
inputLocked,
|
||||
webviewPartition,
|
||||
resolveContainer
|
||||
}: {
|
||||
browserTabId: string
|
||||
container: HTMLDivElement
|
||||
inputLocked: boolean
|
||||
webviewPartition: string
|
||||
resolveContainer: () => HTMLDivElement | null
|
||||
}): { container: HTMLDivElement; created: boolean; webview: Electron.WebviewTag } | null {
|
||||
let webview = webviewRegistry.get(browserTabId)
|
||||
let created = false
|
||||
let activeContainer = container
|
||||
|
||||
// Why: a persisted guest must be torn down and rebuilt when its DOM parent
|
||||
// drifted (moving a <webview> across parents can recreate the guest document)
|
||||
// or when its partition no longer matches — Electron partitions are immutable
|
||||
// after creation, so reuse would keep the stale session. Re-resolve the
|
||||
// viewport container the teardown may have detached; bail if it is gone.
|
||||
if (
|
||||
webview &&
|
||||
(webview.parentElement !== container || webview.getAttribute('partition') !== webviewPartition)
|
||||
) {
|
||||
destroyPersistentWebview(browserTabId)
|
||||
webview = undefined
|
||||
const refreshedContainer = resolveContainer()
|
||||
if (!refreshedContainer) {
|
||||
return null
|
||||
}
|
||||
activeContainer = refreshedContainer
|
||||
}
|
||||
if (webview) {
|
||||
webview.style.pointerEvents = inputLocked ? 'none' : 'auto'
|
||||
return { container: activeContainer, created, webview }
|
||||
}
|
||||
|
||||
webview = document.createElement('webview') as Electron.WebviewTag
|
||||
webview.setAttribute('partition', webviewPartition)
|
||||
webview.setAttribute('allowpopups', '')
|
||||
// Why: Electron spreads the webpreferences keys verbatim, so the shared
|
||||
// camelCase attribute must stay intact for fullscreen containment to work.
|
||||
webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE)
|
||||
webview.style.display = 'flex'
|
||||
webview.style.flex = '1'
|
||||
webview.style.width = '100%'
|
||||
webview.style.height = '100%'
|
||||
webview.style.border = 'none'
|
||||
webview.style.pointerEvents = inputLocked ? 'none' : 'auto'
|
||||
// Why: some pages never paint a background, and a white viewport matches
|
||||
// normal browser behavior instead of leaking Orca chrome through the guest.
|
||||
webview.style.background = '#ffffff'
|
||||
registerPersistentWebview(browserTabId, webview)
|
||||
activeContainer.appendChild(webview)
|
||||
created = true
|
||||
|
||||
return { container: activeContainer, created, webview }
|
||||
}
|
||||
@@ -357,7 +357,10 @@ describe('Cmd+J project and repo-group search', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('uses project header keys for multi-setup projects on one host', () => {
|
||||
it('splits independent same-host checkouts of one project into per-setup keys', () => {
|
||||
// Why: two `cloned` checkouts share the project's remote identity but are
|
||||
// distinct user clones; the palette follows the sidebar and surfaces each as
|
||||
// its own jump target rather than collapsing them. See #5374.
|
||||
const results = searchCmdJProjectResults({
|
||||
query: 'platform',
|
||||
projectGroups: [],
|
||||
@@ -369,6 +372,27 @@ describe('Cmd+J project and repo-group search', () => {
|
||||
]
|
||||
})
|
||||
|
||||
expect(results.map((result) => result.rowKey)).toEqual([
|
||||
'project:project-1::setup:repo-1',
|
||||
'project:project-1::setup:repo-2'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a provisioned runtime copy under one project key alongside a same-host checkout', () => {
|
||||
// Why: a `provisioned` (recipe-created ephemeral) copy shares the project's
|
||||
// remote identity but must not split the user's real checkout; it nests
|
||||
// under the single project key. Mirrors the sidebar grouping. See #6320 / #5374.
|
||||
const results = searchCmdJProjectResults({
|
||||
query: 'platform',
|
||||
projectGroups: [],
|
||||
repos: [repo('repo-1', 'platform-a'), repo('repo-2', 'platform-runtime')],
|
||||
projects: [project('project-1', 'Platform')],
|
||||
projectHostSetups: [
|
||||
setup('setup-1', 'project-1', 'local', 'repo-1'),
|
||||
{ ...setup('setup-2', 'project-1', 'local', 'repo-2'), setupMethod: 'provisioned' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(results.map((result) => result.rowKey)).toEqual(['project:project-1'])
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useEditorScrollRestore } from './useEditorScrollRestore'
|
||||
import { useModifierHeldClass } from './useModifierHeldClass'
|
||||
import { registerPendingEditorFlush } from './editor-pending-flush'
|
||||
import type { MarkdownTocItem } from './markdown-table-of-contents'
|
||||
import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target'
|
||||
import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate'
|
||||
import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface'
|
||||
import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance'
|
||||
@@ -331,18 +332,11 @@ export default function RichMarkdownEditor({
|
||||
|
||||
const navigateToTableOfContentsItem = useCallback(
|
||||
(id: string): void => {
|
||||
const target = flatTableOfContentsItems.find((item) => item.id === id)
|
||||
const container = scrollContainerRef.current
|
||||
if (!target || !container) {
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
const sameTitleIndex = flatTableOfContentsItems
|
||||
.filter((item) => item.title === target.title)
|
||||
.findIndex((item) => item.id === target.id)
|
||||
const matchingHeadings = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('h1, h2, h3')
|
||||
).filter((candidate) => candidate.textContent?.trim() === target.title)
|
||||
const heading = matchingHeadings.at(Math.max(0, sameTitleIndex))
|
||||
const heading = findRichMarkdownTocHeadingTarget(container, flatTableOfContentsItems, id)
|
||||
heading?.scrollIntoView({ block: 'center' })
|
||||
},
|
||||
[flatTableOfContentsItems]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MarkdownTocItem } from './markdown-table-of-contents'
|
||||
import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target'
|
||||
|
||||
function makeTocItem(
|
||||
id: string,
|
||||
level: MarkdownTocItem['level'],
|
||||
title: string,
|
||||
children: MarkdownTocItem[] = []
|
||||
): MarkdownTocItem {
|
||||
return { id, level, title, children }
|
||||
}
|
||||
|
||||
describe('findRichMarkdownTocHeadingTarget', () => {
|
||||
it('finds deep headings that the table of contents exposes', () => {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = `
|
||||
<h1>Intro</h1>
|
||||
<h4>Configure</h4>
|
||||
<h5>Options</h5>
|
||||
`
|
||||
const headings = container.querySelectorAll<HTMLElement>('h1, h4, h5')
|
||||
const items = [
|
||||
makeTocItem('intro', 1, 'Intro'),
|
||||
makeTocItem('configure', 4, 'Configure'),
|
||||
makeTocItem('options', 5, 'Options')
|
||||
]
|
||||
|
||||
expect(findRichMarkdownTocHeadingTarget(container, items, 'configure')).toBe(headings[1])
|
||||
expect(findRichMarkdownTocHeadingTarget(container, items, 'options')).toBe(headings[2])
|
||||
})
|
||||
|
||||
it('keeps duplicate heading navigation aligned with TOC slug order', () => {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = `
|
||||
<h4>Repeat</h4>
|
||||
<h5>Repeat</h5>
|
||||
`
|
||||
const headings = container.querySelectorAll<HTMLElement>('h4, h5')
|
||||
const items = [makeTocItem('repeat', 4, 'Repeat'), makeTocItem('repeat-1', 5, 'Repeat')]
|
||||
|
||||
expect(findRichMarkdownTocHeadingTarget(container, items, 'repeat')).toBe(headings[0])
|
||||
expect(findRichMarkdownTocHeadingTarget(container, items, 'repeat-1')).toBe(headings[1])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MarkdownTocItem } from './markdown-table-of-contents'
|
||||
|
||||
// Why: must cover every level the TOC exposes (MarkdownTocLevel = 1-5). The
|
||||
// original bug was this selector stopping at h3 while the TOC listed h4/h5, so
|
||||
// those rows rendered but never resolved a heading to scroll to.
|
||||
const RICH_MARKDOWN_TOC_HEADING_SELECTOR = 'h1, h2, h3, h4, h5'
|
||||
|
||||
export function findRichMarkdownTocHeadingTarget(
|
||||
container: ParentNode,
|
||||
items: readonly MarkdownTocItem[],
|
||||
id: string
|
||||
): HTMLElement | undefined {
|
||||
const target = items.find((item) => item.id === id)
|
||||
if (!target) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sameTitleIndex = items
|
||||
.filter((item) => item.title === target.title)
|
||||
.findIndex((item) => item.id === target.id)
|
||||
const matchingHeadings = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(RICH_MARKDOWN_TOC_HEADING_SELECTOR)
|
||||
).filter((candidate) => candidate.textContent?.trim() === target.title)
|
||||
|
||||
return matchingHeadings.at(Math.max(0, sameTitleIndex))
|
||||
}
|
||||
@@ -50,6 +50,7 @@ type FloatingPanelStoreState = {
|
||||
activate?: boolean
|
||||
focusAddressBar?: boolean
|
||||
sessionProfileId?: string | null
|
||||
sessionPartition?: string | null
|
||||
title?: string
|
||||
targetGroupId?: string
|
||||
}
|
||||
@@ -1241,6 +1242,7 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
sessionProfileId: 'profile-1',
|
||||
sessionPartition: 'persist:orca-browser-session-profile-1',
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
@@ -1272,6 +1274,7 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
{
|
||||
title: 'Example',
|
||||
sessionProfileId: 'profile-1',
|
||||
sessionPartition: 'persist:orca-browser-session-profile-1',
|
||||
targetGroupId: 'floating-group',
|
||||
browserRuntimeEnvironmentId: null
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { appendUniqueOpenFileIds } from '@/components/terminal/unsaved-close-que
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite'
|
||||
import {
|
||||
@@ -1490,8 +1491,7 @@ export function FloatingTerminalPanel({
|
||||
return
|
||||
}
|
||||
createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, source.url, {
|
||||
title: source.title,
|
||||
sessionProfileId: source.sessionProfileId,
|
||||
...buildDuplicatedBrowserTabOptions(source),
|
||||
targetGroupId: activeGroup?.id,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
|
||||
@@ -6,10 +6,11 @@ import type { MobileNetworkInterface } from '../settings/mobile-network-interfac
|
||||
|
||||
// Why: MobileHero (mobile pairing screen) and MobileNetworkInterfaceSection
|
||||
// (Settings → Mobile) both need the same network selector. This wraps the
|
||||
// generic AddressPicker with the mobile grammar (IPv4 / Tailscale *.ts.net)
|
||||
// and copy. Discovered interfaces come from the OS; "Add custom address…"
|
||||
// opens a dialog for an address the OS didn't surface — the only way to pair
|
||||
// across networks.
|
||||
// generic AddressPicker with the mobile grammar (IPv4, any RFC 1123
|
||||
// hostname — including Tailscale *.ts.net and DDNS domains — optionally
|
||||
// with :port) and copy. Discovered interfaces come from the OS; "Add custom
|
||||
// address…" opens a dialog for an address the OS didn't surface — the only
|
||||
// way to pair across networks.
|
||||
|
||||
export type NetworkInterfacePickerProps = {
|
||||
networkInterfaces: readonly MobileNetworkInterface[]
|
||||
@@ -81,11 +82,11 @@ export function NetworkInterfacePicker({
|
||||
inputLabel: translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address'),
|
||||
placeholder: translate(
|
||||
'auto.components.mobile.CustomNetworkAddressDialog.placeholder',
|
||||
'my-mac.ts.net or 192.168.1.50'
|
||||
'my-mac.ts.net, home.example.com, or 192.168.1.50'
|
||||
),
|
||||
hint: translate(
|
||||
'auto.components.mobile.CustomNetworkAddressDialog.hint',
|
||||
'Enter an IP address or a Tailscale hostname (ends in .ts.net).'
|
||||
'Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.'
|
||||
),
|
||||
cancel: translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel'),
|
||||
confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address')
|
||||
|
||||
@@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record<IosChannel, InstallCopy> = {
|
||||
|
||||
const ANDROID_COPY: InstallCopy = {
|
||||
ctaLabel: 'Download APK',
|
||||
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk'
|
||||
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk'
|
||||
}
|
||||
|
||||
export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy {
|
||||
|
||||
@@ -210,6 +210,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
const getSessionResumeState = useCallback(
|
||||
(session: AiVaultSession) =>
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: session.filePath,
|
||||
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
|
||||
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
|
||||
worktrees: allWorktrees,
|
||||
@@ -229,6 +230,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
const getSessionResumeActions = useCallback(
|
||||
(session: AiVaultSession) =>
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: session.filePath,
|
||||
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
|
||||
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
|
||||
worktrees: allWorktrees,
|
||||
|
||||
@@ -101,12 +101,13 @@ export function VaultSessionRow({
|
||||
sessionId: session.sessionId,
|
||||
title: session.title,
|
||||
command: resumeStartup.command,
|
||||
sessionFilePath: session.filePath,
|
||||
...(resumeStartup.env ? { env: resumeStartup.env } : {}),
|
||||
...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {})
|
||||
})
|
||||
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT))
|
||||
},
|
||||
[resumeDisabled, session.agent, session.sessionId, session.title, resumeStartup]
|
||||
[resumeDisabled, session, resumeStartup]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
} from '@/lib/worktree-activation'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
getAiVaultResumeWorkspaceTargetStatus,
|
||||
isSupportedAiVaultResumeTargetStatus
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeWorkspaceTargetStatus
|
||||
} from '@/lib/ai-vault-resume-target'
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
@@ -78,6 +78,7 @@ export function useAiVaultSessionLaunchActions({
|
||||
const handleResume = useCallback(
|
||||
(session: AiVaultSession, targetWorktreeId?: string): void => {
|
||||
const targetId = resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: session.filePath,
|
||||
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
|
||||
targetWorktreeId,
|
||||
targetState
|
||||
@@ -93,17 +94,7 @@ export function useAiVaultSessionLaunchActions({
|
||||
}
|
||||
|
||||
if (targetId.status === 'unsupported') {
|
||||
toast.error(
|
||||
targetId.targetStatus === 'runtime'
|
||||
? translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported',
|
||||
'Resume from history is not available in runtime-hosted workspaces.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
|
||||
'Open a local or SSH workspace before resuming a session.'
|
||||
)
|
||||
)
|
||||
toast.error(aiVaultResumeUnsupportedMessage(targetId.targetStatus))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,6 +129,7 @@ export type AiVaultSessionLaunchTarget =
|
||||
| { status: 'ready'; worktreeId: string }
|
||||
|
||||
export function resolveAiVaultSessionLaunchTarget(args: {
|
||||
sessionFilePath: string | null
|
||||
activeWorktreeId: string | null
|
||||
targetWorktreeId?: string
|
||||
targetState: AiVaultSessionResumeTargetState
|
||||
@@ -151,13 +143,36 @@ export function resolveAiVaultSessionLaunchTarget(args: {
|
||||
}
|
||||
|
||||
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId)
|
||||
if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) {
|
||||
if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) {
|
||||
return { status: 'unsupported', targetStatus }
|
||||
}
|
||||
|
||||
return { status: 'ready', worktreeId: targetWorktreeId }
|
||||
}
|
||||
|
||||
function aiVaultResumeUnsupportedMessage(
|
||||
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
|
||||
): string {
|
||||
if (targetStatus === 'runtime') {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported',
|
||||
'Resume from history is not available in runtime-hosted workspaces.'
|
||||
)
|
||||
}
|
||||
// Why: 'ssh' only reaches the unsupported branch when the session file lives
|
||||
// on this machine, so the message explains the host mismatch, not SSH itself.
|
||||
if (targetStatus === 'ssh') {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported',
|
||||
"This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead."
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
|
||||
'Open a local or SSH workspace before resuming a session.'
|
||||
)
|
||||
}
|
||||
|
||||
function activateAiVaultResumeWorkspace(workspaceId: string): void {
|
||||
const workspaceScope = parseWorkspaceKey(workspaceId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
|
||||
@@ -106,10 +106,15 @@ function makeWorktreeInfo(
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_SESSION_FILE = '/Users/ada/.claude/projects/-repo-orca/session-1.jsonl'
|
||||
const WSL_SESSION_FILE =
|
||||
'\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-repo-orca\\session-1.jsonl'
|
||||
|
||||
describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('prefers the session worktree over the active workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-1::/repo/other',
|
||||
worktrees: [
|
||||
@@ -128,6 +133,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('falls back to the active workspace when the session worktree is unavailable', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: 'repo-1::/repo/orca',
|
||||
worktrees: [makeWorktree()],
|
||||
@@ -143,6 +149,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('blocks missing worktrees even when the repo is SSH-owned', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [],
|
||||
@@ -158,6 +165,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('allows SSH-owned session worktrees', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [makeWorktree()],
|
||||
@@ -173,6 +181,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('allows SSH-stamped worktrees even when the repo owner is runtime', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [makeWorktree({ hostId: 'ssh:ssh-1' })],
|
||||
@@ -188,6 +197,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('blocks runtime-owned targets even when they have no SSH connection', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [makeWorktree()],
|
||||
@@ -208,6 +218,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('blocks runtime-stamped worktrees even when the repo owner is local', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [makeWorktree({ hostId: 'runtime:env-1' })],
|
||||
@@ -223,6 +234,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('prefers the session worktree when the active workspace is SSH-owned', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-2::/remote/orca',
|
||||
worktrees: [
|
||||
@@ -245,6 +257,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('falls back to the active workspace when the session worktree is runtime-owned', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-2::/repo/other',
|
||||
worktrees: [
|
||||
@@ -275,6 +288,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('falls back to an active SSH folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
@@ -288,9 +302,27 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks host-stored sessions when only an SSH workspace is open', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
repos: [],
|
||||
targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' })
|
||||
})
|
||||
).toEqual({
|
||||
blocked: true,
|
||||
worktreeId: null,
|
||||
usesSessionWorktree: false
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks an active runtime folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
@@ -309,6 +341,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
it('exposes separate session-worktree and active-workspace targets', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-1::/repo/other',
|
||||
worktrees: [
|
||||
@@ -326,6 +359,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
it('enables an SSH active-workspace action when the session worktree is local', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-2::/remote/orca',
|
||||
worktrees: [
|
||||
@@ -344,9 +378,32 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the SSH active-workspace action for host-stored sessions', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-2::/remote/orca',
|
||||
worktrees: [
|
||||
makeWorktree(),
|
||||
makeWorktree({
|
||||
id: 'repo-2::/remote/orca',
|
||||
repoId: 'repo-2',
|
||||
path: '/remote/orca'
|
||||
})
|
||||
],
|
||||
repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo]
|
||||
})
|
||||
).toEqual({
|
||||
worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false },
|
||||
newTab: { worktreeId: 'repo-2::/remote/orca', disabled: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('disables runtime-owned targets without disabling local targets', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-2::/repo/other',
|
||||
worktrees: [
|
||||
@@ -376,6 +433,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
it('does not expose the active workspace as a duplicate new-tab target', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('current'),
|
||||
activeWorktreeId: 'repo-1::/repo/orca',
|
||||
worktrees: [makeWorktree()],
|
||||
@@ -390,6 +448,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
it('enables the active folder workspace action when it is local or SSH-owned', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
@@ -405,6 +464,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
it('disables the active folder workspace action when it is runtime-owned', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
@@ -422,6 +482,7 @@ describe('resolveAiVaultSessionLaunchTarget', () => {
|
||||
it('allows direct resume into an active SSH folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' })
|
||||
})
|
||||
@@ -431,9 +492,23 @@ describe('resolveAiVaultSessionLaunchTarget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks direct resume of a host-stored session into an SSH folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' })
|
||||
})
|
||||
).toEqual({
|
||||
status: 'unsupported',
|
||||
targetStatus: 'ssh'
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks direct resume into a runtime folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' })
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
getAiVaultResumeWorkspaceTargetStatus,
|
||||
isSupportedAiVaultResumeTargetStatus
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeWorkspaceTargetStatus
|
||||
} from '@/lib/ai-vault-resume-target'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -33,6 +33,7 @@ export type AiVaultSessionResumeActions = {
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionResumeState(args: {
|
||||
sessionFilePath: string | null
|
||||
worktreeInfo: AiVaultSessionWorktreeInfo | null
|
||||
activeWorktreeId: string | null
|
||||
worktrees: readonly Worktree[]
|
||||
@@ -54,6 +55,7 @@ export function resolveAiVaultSessionResumeState(args: {
|
||||
|
||||
for (const worktreeId of candidateWorktreeIds) {
|
||||
const targetId = resolveSupportedResumeWorktreeId({
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
worktreeId,
|
||||
targetState
|
||||
})
|
||||
@@ -75,6 +77,7 @@ export function resolveAiVaultSessionResumeState(args: {
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionResumeActions(args: {
|
||||
sessionFilePath: string | null
|
||||
worktreeInfo: AiVaultSessionWorktreeInfo | null
|
||||
activeWorktreeId: string | null
|
||||
worktrees: readonly Worktree[]
|
||||
@@ -88,10 +91,12 @@ export function resolveAiVaultSessionResumeActions(args: {
|
||||
const targetState = resolveAiVaultResumeTargetState(args)
|
||||
|
||||
const sessionTargetId = resolveSupportedResumeWorktreeId({
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
worktreeId: sessionWorktreeId,
|
||||
targetState
|
||||
})
|
||||
const activeTargetId = resolveSupportedResumeWorktreeId({
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
worktreeId:
|
||||
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
|
||||
? args.activeWorktreeId
|
||||
@@ -136,6 +141,7 @@ export function isKnownAiVaultResumeWorkspaceTarget(
|
||||
}
|
||||
|
||||
function resolveSupportedResumeWorktreeId(args: {
|
||||
sessionFilePath: string | null
|
||||
worktreeId: string | null
|
||||
targetState: AiVaultSessionResumeTargetState
|
||||
}): string | null {
|
||||
@@ -148,7 +154,7 @@ function resolveSupportedResumeWorktreeId(args: {
|
||||
}
|
||||
|
||||
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId)
|
||||
if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) {
|
||||
if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries }
|
||||
|
||||
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
|
||||
const ORCA_ANDROID_APK_URL =
|
||||
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk'
|
||||
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk'
|
||||
|
||||
export function MobileSettingsPane(): React.JSX.Element {
|
||||
const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false)
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { RepositoryIconPicker } from './RepositoryIconPicker'
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: vi.fn(),
|
||||
getActiveRuntimeTarget: () => ({ kind: 'local' })
|
||||
}))
|
||||
|
||||
vi.mock('./RepositoryIconColorSection', () => ({
|
||||
RepositoryIconColorSection: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./RepositoryIconTabs', () => ({
|
||||
RepositoryIconTabs: () => null
|
||||
}))
|
||||
|
||||
const apiMocks = {
|
||||
repoSlug: vi.fn(),
|
||||
repoUpstream: vi.fn()
|
||||
}
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
// @ts-expect-error test window mock
|
||||
globalThis.window = { api: { gh: apiMocks } }
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/orca',
|
||||
displayName: 'orca',
|
||||
badgeColor: '#2563eb',
|
||||
addedAt: 1,
|
||||
kind: 'git',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function flushEffects(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
describe('RepositoryIconPicker GitHub avatar refresh', () => {
|
||||
beforeEach(() => {
|
||||
apiMocks.repoSlug.mockReset()
|
||||
apiMocks.repoUpstream.mockReset()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('refreshes stale GitHub avatar metadata lazily when repo settings opens', async () => {
|
||||
const updateRepo = vi.fn()
|
||||
// Non-fork repo (upstream resolved to null) transferred stablyai -> parkerrex.
|
||||
const repo = makeRepo({
|
||||
upstream: null,
|
||||
repoIcon: {
|
||||
type: 'image',
|
||||
src: 'https://github.com/stablyai.png?size=64',
|
||||
source: 'github',
|
||||
label: 'stablyai/orca'
|
||||
}
|
||||
})
|
||||
apiMocks.repoUpstream.mockResolvedValueOnce(null)
|
||||
apiMocks.repoSlug.mockResolvedValueOnce({ owner: 'parkerrex', repo: 'orca' })
|
||||
|
||||
act(() => {
|
||||
root.render(<RepositoryIconPicker repo={repo} updateRepo={updateRepo} />)
|
||||
})
|
||||
await flushEffects()
|
||||
|
||||
expect(updateRepo).toHaveBeenCalledExactlyOnceWith('repo-1', {
|
||||
repoIcon: {
|
||||
type: 'image',
|
||||
src: 'https://github.com/parkerrex.png?size=64',
|
||||
source: 'github',
|
||||
label: 'parkerrex/orca'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not clobber a fork identity when the live upstream lookup fails offline', async () => {
|
||||
const updateRepo = vi.fn()
|
||||
// A fork whose avatar tracks its parent org, resolved earlier while online.
|
||||
const repo = makeRepo({
|
||||
upstream: { owner: 'stablyai', repo: 'orca' },
|
||||
repoIcon: {
|
||||
type: 'image',
|
||||
src: 'https://github.com/stablyai.png?size=64',
|
||||
source: 'github',
|
||||
label: 'stablyai/orca'
|
||||
}
|
||||
})
|
||||
// Offline/unauthed: the parent lookup returns null. The fork's own origin
|
||||
// owner must NOT be persisted over the parent identity.
|
||||
apiMocks.repoUpstream.mockResolvedValueOnce(null)
|
||||
apiMocks.repoSlug.mockResolvedValueOnce({ owner: 'parkerrex', repo: 'orca' })
|
||||
|
||||
act(() => {
|
||||
root.render(<RepositoryIconPicker repo={repo} updateRepo={updateRepo} />)
|
||||
})
|
||||
await flushEffects()
|
||||
|
||||
expect(updateRepo).not.toHaveBeenCalled()
|
||||
expect(apiMocks.repoSlug).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { githubAvatarIcon, type RepoIcon } from '../../../../shared/repo-icon'
|
||||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import { DEFAULT_REPO_BADGE_COLOR } from '../../../../shared/constants'
|
||||
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
||||
import { Button } from '../ui/button'
|
||||
@@ -15,7 +15,8 @@ import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { RepositoryIconColorSection } from './RepositoryIconColorSection'
|
||||
import { RepositoryIconTabs } from './RepositoryIconTabs'
|
||||
import {
|
||||
resolveRepositoryGitHubAvatarIcon,
|
||||
buildRepositoryGitHubAvatarUpdate,
|
||||
resolveRepositoryGitHubAvatar,
|
||||
resolveRepositoryUpstreamLive
|
||||
} from './repository-icon-github'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -72,19 +73,20 @@ export function RepositoryIconPicker({
|
||||
[runtimeTarget, repo]
|
||||
)
|
||||
|
||||
const resolveGitHubAvatarIcon = useCallback(
|
||||
() => resolveRepositoryGitHubAvatarIcon(runtimeTarget, repo),
|
||||
const resolveGitHubAvatar = useCallback(
|
||||
(options?: { forceLive?: boolean }) =>
|
||||
resolveRepositoryGitHubAvatar(runtimeTarget, repo, options),
|
||||
[runtimeTarget, repo]
|
||||
)
|
||||
|
||||
const handleUseGitHubAvatar = async () => {
|
||||
setLoadingGitHub(true)
|
||||
try {
|
||||
const icon = await resolveGitHubAvatarIcon()
|
||||
const resolution = await resolveGitHubAvatar({ forceLive: true })
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (!icon) {
|
||||
if (!resolution.repoIcon) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.settings.RepositoryIconPicker.f79972271a',
|
||||
@@ -93,7 +95,11 @@ export function RepositoryIconPicker({
|
||||
)
|
||||
return
|
||||
}
|
||||
setIcon(icon)
|
||||
// A null build means the stored icon/upstream already match — nothing to write.
|
||||
const updates = buildRepositoryGitHubAvatarUpdate(repo, resolution)
|
||||
if (updates) {
|
||||
updateRepo(repo.id, updates)
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
@@ -113,11 +119,16 @@ export function RepositoryIconPicker({
|
||||
const handleResetToDefault = async () => {
|
||||
setResetting(true)
|
||||
try {
|
||||
const icon = await resolveGitHubAvatarIcon().catch(() => null)
|
||||
const resolution = await resolveGitHubAvatar({ forceLive: true }).catch(() => null)
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setIcon(icon)
|
||||
const updates = resolution
|
||||
? buildRepositoryGitHubAvatarUpdate(repo, resolution, { clearMissingIcon: true })
|
||||
: { repoIcon: null }
|
||||
if (updates) {
|
||||
updateRepo(repo.id, updates)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setResetting(false)
|
||||
@@ -125,33 +136,39 @@ export function RepositoryIconPicker({
|
||||
}
|
||||
}
|
||||
|
||||
const upstreamBackfilledRef = useRef<string | null>(null)
|
||||
const githubIdentityRefreshedRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (repo.upstream !== undefined || upstreamBackfilledRef.current === repo.id) {
|
||||
const hasGitHubAvatar = repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github'
|
||||
const shouldRefresh = hasGitHubAvatar || repo.upstream === undefined
|
||||
if (!shouldRefresh || githubIdentityRefreshedRef.current === repo.id) {
|
||||
return
|
||||
}
|
||||
upstreamBackfilledRef.current = repo.id
|
||||
githubIdentityRefreshedRef.current = repo.id
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
let upstream
|
||||
let updates: Partial<Repo> | null
|
||||
try {
|
||||
upstream = await resolveUpstreamLive()
|
||||
if (hasGitHubAvatar) {
|
||||
// Why: stored upstream/icon metadata can outlive a GitHub repo transfer.
|
||||
// Refresh only when settings opens for the affected GitHub-avatar repo.
|
||||
const resolution = await resolveGitHubAvatar({ forceLive: true })
|
||||
updates = buildRepositoryGitHubAvatarUpdate(repo, resolution)
|
||||
} else {
|
||||
const upstream = await resolveUpstreamLive()
|
||||
updates = { upstream: upstream ?? null }
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (cancelled || !mountedRef.current) {
|
||||
if (cancelled || !mountedRef.current || !updates) {
|
||||
return
|
||||
}
|
||||
const updates: Partial<Repo> = { upstream: upstream ?? null }
|
||||
if (upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github') {
|
||||
updates.repoIcon = githubAvatarIcon(upstream)
|
||||
}
|
||||
updateRepo(repo.id, updates)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [repo.id, repo.upstream, repo.repoIcon, resolveUpstreamLive, updateRepo, mountedRef])
|
||||
}, [repo, resolveGitHubAvatar, resolveUpstreamLive, updateRepo, mountedRef])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user