mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into nwparker/sidebar-bucket-count-alloc
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import {
|
||||
CommentMarkdownAsync,
|
||||
preloadCommentMarkdown
|
||||
} from '@/components/sidebar/comment-markdown-lazy'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type DashboardAgentRowMessageProps = {
|
||||
@@ -13,6 +17,9 @@ export function DashboardAgentRowMessage({
|
||||
isInterrupted,
|
||||
lastAssistantMessage
|
||||
}: DashboardAgentRowMessageProps): React.JSX.Element | null {
|
||||
// These rows are the sidebar's only boot-visible markdown, so warm the chunk as
|
||||
// soon as one mounts rather than waiting for text to arrive.
|
||||
useEffect(preloadCommentMarkdown, [])
|
||||
// Why: message slot is always reserved in collapsed view so the row height
|
||||
// stays fixed as assistant text arrives or clears.
|
||||
if (!isInterrupted && !lastAssistantMessage) {
|
||||
@@ -38,7 +45,7 @@ export function DashboardAgentRowMessage({
|
||||
</span>
|
||||
) : null}
|
||||
{lastAssistantMessage ? (
|
||||
<CommentMarkdown
|
||||
<CommentMarkdownAsync
|
||||
content={lastAssistantMessage}
|
||||
// Why: animate between a clipped preview and natural height without
|
||||
// measuring markdown content in JS.
|
||||
|
||||
@@ -6,7 +6,6 @@ import { toast } from 'sonner'
|
||||
import { LinearIcon } from '@/components/icons/LinearIcon'
|
||||
import { JiraIcon } from '@/components/icons/JiraIcon'
|
||||
import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu'
|
||||
import CommentMarkdown from './CommentMarkdown'
|
||||
import { WORKTREE_NATIVE_CONTEXT_MENU_ATTR } from './WorktreeContextMenu'
|
||||
import {
|
||||
WorktreeCardDetailSection,
|
||||
@@ -31,6 +30,10 @@ import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDet
|
||||
import { WorktreeCardCliDetailSection } from './WorktreeCardCliDetailSection'
|
||||
import { WorktreeCardIssueDetailSection } from './WorktreeCardIssueDetailSection'
|
||||
import { WorktreeCardHoverIdentityHeader } from './WorktreeCardHoverIdentityHeader'
|
||||
import { CommentMarkdownAsync, preloadCommentMarkdown } from './comment-markdown-lazy'
|
||||
|
||||
const COMMENT_MARKDOWN_CLASS_NAME =
|
||||
'text-[11.5px] text-foreground break-words leading-normal [&_.comment-md-p]:block [&_.comment-md-p+.comment-md-p]:mt-1'
|
||||
|
||||
export type {
|
||||
WorktreeCardIssueDisplay,
|
||||
@@ -183,7 +186,12 @@ export function WorktreeCardDetailsHover({
|
||||
openDelay={openDelay}
|
||||
closeDelay={closeDelay}
|
||||
>
|
||||
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
|
||||
<HoverCardTrigger
|
||||
asChild
|
||||
onPointerEnter={hasComment(comment) ? preloadCommentMarkdown : undefined}
|
||||
>
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
align="start"
|
||||
@@ -355,9 +363,11 @@ export function WorktreeCardDetailsHover({
|
||||
}
|
||||
/>
|
||||
<WorktreeCardDetailSectionContent className="space-y-2">
|
||||
<CommentMarkdown
|
||||
<CommentMarkdownAsync
|
||||
content={comment ?? ''}
|
||||
className="text-[11.5px] text-foreground break-words leading-normal [&_.comment-md-p]:block [&_.comment-md-p+.comment-md-p]:mt-1"
|
||||
className={COMMENT_MARKDOWN_CLASS_NAME}
|
||||
// Mirrors remark-breaks so the fallback keeps the note's line count.
|
||||
fallbackClassName="whitespace-pre-wrap"
|
||||
/>
|
||||
</WorktreeCardDetailSectionContent>
|
||||
</WorktreeCardDetailSection>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { lazyWithRetry } from '@/lib/lazy-with-retry'
|
||||
|
||||
// Boot-path split: react-markdown + remark/rehype/DOMPurify is ~356 KB of JS that
|
||||
// only the sidebar's two markdown surfaces pull onto the eager graph. One lazy()
|
||||
// identity for both, so they share a component type and a single chunk fetch.
|
||||
const LazyCommentMarkdown = lazyWithRetry(() => import('./CommentMarkdown'), {
|
||||
reloadKey: 'comment-markdown'
|
||||
})
|
||||
|
||||
/** Warms the chunk ahead of render so the fallback is never actually shown. */
|
||||
export function preloadCommentMarkdown(): void {
|
||||
void import('./CommentMarkdown')
|
||||
}
|
||||
|
||||
type CommentMarkdownAsyncProps = React.ComponentProps<typeof LazyCommentMarkdown> & {
|
||||
/** Extra classes for the pre-load fallback only, e.g. to mirror remark-breaks. */
|
||||
fallbackClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the markdown body, falling back to the raw text in an identically
|
||||
* classed box while the chunk loads — same width and wrapping constraints, so a
|
||||
* paint before the chunk lands cannot shift layout.
|
||||
*/
|
||||
export function CommentMarkdownAsync({
|
||||
fallbackClassName,
|
||||
...props
|
||||
}: CommentMarkdownAsyncProps): React.JSX.Element {
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full [overflow-wrap:anywhere]',
|
||||
props.className,
|
||||
fallbackClassName
|
||||
)}
|
||||
title={props.title}
|
||||
>
|
||||
{props.content}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyCommentMarkdown {...props} />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const rendererSrc = join(__dirname, '../..')
|
||||
const entry = join(rendererSrc, 'main.tsx')
|
||||
const COMMENT_MARKDOWN = join(rendererSrc, 'components/sidebar/CommentMarkdown.tsx')
|
||||
|
||||
function source(relativePath: string): string {
|
||||
return readFileSync(join(rendererSrc, relativePath), 'utf8')
|
||||
}
|
||||
|
||||
const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']
|
||||
|
||||
function resolveImport(specifier: string, fromFile: string): string | null {
|
||||
const base = specifier.startsWith('@/')
|
||||
? join(rendererSrc, specifier.slice(2))
|
||||
: specifier.startsWith('.')
|
||||
? resolve(dirname(fromFile), specifier)
|
||||
: null
|
||||
if (base === null) {
|
||||
return null
|
||||
}
|
||||
for (const extension of ['', ...MODULE_EXTENSIONS]) {
|
||||
const candidate = base + extension
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
for (const extension of MODULE_EXTENSIONS) {
|
||||
const candidate = join(base, `index${extension}`)
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Static `from '...'` edges only; `import('...')` and `import type` do not ship
|
||||
// code onto the eager graph.
|
||||
const STATIC_IMPORT =
|
||||
/(?:^|[\n;])\s*(?:import|export)(?:(?!\bfrom\b)[\s\S])*?\bfrom\s*['"]([^'"]+)['"]/g
|
||||
|
||||
/** Walks the renderer entry's static import graph, recording how each module was reached. */
|
||||
function eagerModuleGraph(): Map<string, string | null> {
|
||||
const parents = new Map<string, string | null>([[entry, null]])
|
||||
const queue = [entry]
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as string
|
||||
const contents = readFileSync(current, 'utf8')
|
||||
for (const match of contents.matchAll(STATIC_IMPORT)) {
|
||||
if (/^\s*(?:import|export)\s+type\b/.test(match[0].replace(/^[\n;]/, ''))) {
|
||||
continue
|
||||
}
|
||||
const resolved = resolveImport(match[1], current)
|
||||
if (resolved === null || parents.has(resolved)) {
|
||||
continue
|
||||
}
|
||||
parents.set(resolved, current)
|
||||
queue.push(resolved)
|
||||
}
|
||||
}
|
||||
return parents
|
||||
}
|
||||
|
||||
function importChain(parents: Map<string, string | null>, module: string): string[] {
|
||||
const chain: string[] = []
|
||||
let cursor: string | null | undefined = module
|
||||
while (cursor) {
|
||||
chain.push(cursor.slice(rendererSrc.length + 1))
|
||||
cursor = parents.get(cursor)
|
||||
}
|
||||
return chain.toReversed()
|
||||
}
|
||||
|
||||
describe('worktree card markdown performance isolation', () => {
|
||||
it('keeps CommentMarkdown off the renderer boot graph entirely', () => {
|
||||
const parents = eagerModuleGraph()
|
||||
|
||||
// Names the offending chain when this regresses, instead of a bare boolean.
|
||||
const chain = parents.has(COMMENT_MARKDOWN) ? importChain(parents, COMMENT_MARKDOWN) : []
|
||||
expect(chain).toEqual([])
|
||||
expect(parents.size).toBeGreaterThan(1000)
|
||||
})
|
||||
|
||||
it('routes both sidebar markdown surfaces through the shared lazy boundary', () => {
|
||||
const lazyBoundary = source('components/sidebar/comment-markdown-lazy.tsx')
|
||||
expect(lazyBoundary).toContain("import('./CommentMarkdown')")
|
||||
// A fallback in the same box keeps first paint from shifting layout.
|
||||
expect(lazyBoundary).toContain('React.Suspense')
|
||||
|
||||
for (const file of [
|
||||
'components/sidebar/WorktreeCardMeta.tsx',
|
||||
'components/dashboard/DashboardAgentRowMessage.tsx'
|
||||
]) {
|
||||
const contents = source(file)
|
||||
expect(contents).not.toMatch(/^import CommentMarkdown from/m)
|
||||
expect(contents).toContain('CommentMarkdownAsync')
|
||||
// The chunk must be warmed before the surface renders, not on demand.
|
||||
expect(contents).toContain('preloadCommentMarkdown')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -46,10 +46,10 @@ export type AgentCompletionCoordinatorOptions = {
|
||||
// this renderer CONSUMES that evidence and can tell "no evidence published"
|
||||
// from "host too old to publish it" — mixed-version hosts omit the field.
|
||||
shouldPollNoEvidenceProcessCadence?: () => boolean
|
||||
// Why: on hosts where one inspection forks a whole-process-table scan (local
|
||||
// Windows PowerShell/CIM), panes without agent evidence relax to a slow
|
||||
// cadence; remote authorities can disable no-evidence polling entirely and
|
||||
// re-arm from output/title activity instead.
|
||||
// Why: where one inspection is a whole-process-table scan (local Windows
|
||||
// PowerShell/CIM) or a host round trip plus a host-side scan (remote/SSH),
|
||||
// panes without agent evidence relax to a slow cadence and re-arm from
|
||||
// output/title/hook activity. See agent-process-inspection-cost.ts.
|
||||
isProcessInspectionCostly?: () => boolean
|
||||
shouldSuppressHookCompletion?: (payload: AgentCompletionStatusSnapshot) => boolean
|
||||
}
|
||||
|
||||
+78
-7
@@ -1,20 +1,27 @@
|
||||
// Regression guard: bound the volume of cadence process inspections a visible,
|
||||
// idle terminal with NO agent evidence drives on hosts where each inspection is
|
||||
// a whole-process-table scan (local Windows forks powershell.exe/CIM — the
|
||||
// scan-cost analogue of #6288). Pre-fix a single visible idle shell inspected
|
||||
// every 2s forever (~30 scans/min); with the no-evidence tier it inspects every
|
||||
// 15s, and pane activity (output/title/hook) or agent evidence re-arms the hot
|
||||
// cadence so agent-start detection stays event-driven and agent-finish
|
||||
// detection is unchanged.
|
||||
// expensive — local Windows forks a powershell.exe/CIM whole-process-table scan
|
||||
// (the scan-cost analogue of #6288), and a remote/SSH pane pays a host round
|
||||
// trip plus a host-side foreground scan. Pre-fix a single visible idle shell
|
||||
// inspected every 2s forever (~30 scans/min); with the no-evidence tier it
|
||||
// inspects every 15s, and pane activity (output/title/hook) or agent evidence
|
||||
// re-arms the hot cadence so agent-start detection stays event-driven and
|
||||
// agent-finish detection is unchanged.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createAgentCompletionCoordinator,
|
||||
resetAgentCompletionCoordinatorIdentitiesForTest
|
||||
} from './agent-completion-coordinator'
|
||||
import { resetAgentProcessInspectionQueueForTests } from './agent-process-inspection-queue'
|
||||
import { isAgentProcessInspectionCostly } from './agent-process-inspection-cost'
|
||||
import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
|
||||
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
|
||||
import type { AgentCompletionCoordinatorOptions } from './agent-completion-coordinator-types'
|
||||
|
||||
const MAC_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
||||
const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
|
||||
function processResult(
|
||||
foregroundProcess: string | null,
|
||||
hasChildProcesses = foregroundProcess !== null
|
||||
@@ -67,6 +74,43 @@ describe('agent completion no-evidence inspection cadence', () => {
|
||||
expect(inspectProcess).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('bounds a visible idle remote pane through the shipped cost predicate', async () => {
|
||||
// Why: a remote inspection is an RPC round trip to the execution host plus a
|
||||
// host-side foreground scan — the costliest inspection shape here — yet it
|
||||
// was excluded from the no-evidence tier on every client platform.
|
||||
const sshPtyId = toAppSshPtyId('target-1', 'pty-1')
|
||||
const inspectProcess = vi.fn(async () => processResult(null, false))
|
||||
const { coordinator } = createCoordinator(inspectProcess, {
|
||||
getPtyId: () => sshPtyId,
|
||||
isProcessInspectionCostly: () => isAgentProcessInspectionCostly(MAC_UA, sshPtyId)
|
||||
})
|
||||
|
||||
coordinator.startProcessTracking()
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
// 60s / 15s = 4 host round trips. Pre-fix (2s idle cadence) this was 30.
|
||||
expect(inspectProcess).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('re-arms the remote pane to the 2s cadence on the first byte of PTY output', async () => {
|
||||
// Why: agent-start detection on a remote pane must stay event-driven, not
|
||||
// wait out the relaxed interval.
|
||||
const runtimePtyId = toRemoteRuntimePtyId('term_1', 'env-a')
|
||||
const inspectProcess = vi.fn(async () => processResult(null, false))
|
||||
const { coordinator } = createCoordinator(inspectProcess, {
|
||||
getPtyId: () => runtimePtyId,
|
||||
isProcessInspectionCostly: () => isAgentProcessInspectionCostly(MAC_UA, runtimePtyId)
|
||||
})
|
||||
|
||||
coordinator.startProcessTracking()
|
||||
await vi.advanceTimersByTimeAsync(14_000)
|
||||
expect(inspectProcess).not.toHaveBeenCalled()
|
||||
|
||||
coordinator.observeOutputActivity()
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(inspectProcess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the full 2s idle cadence on hosts where inspection is cheap', async () => {
|
||||
const inspectProcess = vi.fn(async () => processResult(null, false))
|
||||
const { coordinator } = createCoordinator(inspectProcess, {
|
||||
@@ -76,7 +120,7 @@ describe('agent completion no-evidence inspection cadence', () => {
|
||||
coordinator.startProcessTracking()
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
// 60s / 2s = 30: POSIX/SSH/remote panes must not be relaxed.
|
||||
// 60s / 2s = 30: local POSIX panes (cheap `ps`) must not be relaxed.
|
||||
expect(inspectProcess).toHaveBeenCalledTimes(30)
|
||||
})
|
||||
|
||||
@@ -275,3 +319,30 @@ describe('agent completion no-evidence inspection cadence', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAgentProcessInspectionCostly', () => {
|
||||
it('treats remote-execution-host ptys as costly on every client platform', () => {
|
||||
for (const userAgent of [MAC_UA, WINDOWS_UA]) {
|
||||
expect(isAgentProcessInspectionCostly(userAgent, toAppSshPtyId('target-1', 'pty-1'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
isAgentProcessInspectionCostly(userAgent, toRemoteRuntimePtyId('term_1', 'env-a'))
|
||||
).toBe(true)
|
||||
expect(isAgentProcessInspectionCostly(userAgent, toRemoteRuntimePtyId('term_1'))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the local branch unchanged: Windows costly, POSIX cheap', () => {
|
||||
expect(isAgentProcessInspectionCostly(WINDOWS_UA, 'worktree-1|pane-1')).toBe(true)
|
||||
expect(isAgentProcessInspectionCostly(WINDOWS_UA, null)).toBe(false)
|
||||
expect(isAgentProcessInspectionCostly(MAC_UA, 'worktree-1|pane-1')).toBe(false)
|
||||
expect(isAgentProcessInspectionCostly(MAC_UA, null)).toBe(false)
|
||||
})
|
||||
|
||||
// Why: a bare "ssh:" id names no connection, so it is not evidence the
|
||||
// inspection crosses a link (see remote-execution-host-pty.test.ts).
|
||||
it('does not relax a POSIX pane for an ssh-prefixed id carrying no relay pty id', () => {
|
||||
expect(isAgentProcessInspectionCostly(MAC_UA, 'ssh:target-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { isRemoteExecutionHostPtyId } from './remote-execution-host-pty'
|
||||
|
||||
/**
|
||||
* Whether one cadence process inspection for this pane is expensive enough that
|
||||
* a pane with no agent evidence should relax to the `no-evidence` tier.
|
||||
*
|
||||
* Why remote first: a remote inspection is a `terminal.inspectProcess` /
|
||||
* `pty.inspectProcess` round trip to the execution host plus a host-side
|
||||
* foreground scan there — the costliest shape in this codebase, on every client
|
||||
* platform. Local Windows is costly for a different reason: it forks a
|
||||
* powershell.exe whole-process-table CIM scan per poll (~10-40x POSIX `ps`).
|
||||
* Local POSIX (and daemon/WSL panes on it) stays on the full cadence.
|
||||
*
|
||||
* Relaxing is the interim measure: once this renderer consumes the batched
|
||||
* foreground evidence direct-SSH/remote authorities already publish with their
|
||||
* PTY inventory (#17525), those panes can drop to `shouldPollNoEvidenceProcessCadence`
|
||||
* and stop scheduling idle host reads altogether.
|
||||
*/
|
||||
export function isAgentProcessInspectionCostly(userAgent: string, ptyId: string | null): boolean {
|
||||
if (ptyId !== null && isRemoteExecutionHostPtyId(ptyId)) {
|
||||
return true
|
||||
}
|
||||
if (!userAgent.includes('Windows')) {
|
||||
return false
|
||||
}
|
||||
return ptyId !== null
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushAsyncTicks, renderHeadlessBuffer } from './pty-connection-test-async'
|
||||
import { createMockTransport, createPane, createManager } from './pty-connection-test-pane-fixtures'
|
||||
import type { ConnectCallbacks, MockTransport } from './pty-connection-test-pane-fixtures'
|
||||
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import {
|
||||
createInitialStoreState,
|
||||
buildActiveRuntimeEnvironmentState
|
||||
} from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
|
||||
const {
|
||||
resetAndRefreshAllTerminalWebglAtlases,
|
||||
scheduleTerminalWebglAtlasRecovery,
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo,
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
} = vi.hoisted(() => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
|
||||
scheduleTerminalWebglAtlasRecovery: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn(),
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn()
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
let createdTransportOptions: Record<string, unknown>[] = []
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({
|
||||
scheduleRuntimeGraphSync
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
resetAndRefreshAllTerminalWebglAtlases
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
scheduleTerminalWebglAtlasRecovery
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState,
|
||||
subscribe: (listener: (state: StoreState) => void) => {
|
||||
storeSubscribers.push(listener)
|
||||
return () => {
|
||||
storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment')
|
||||
return buildAgentStatusModuleMock(await importOriginal<Record<string, unknown>>())
|
||||
})
|
||||
|
||||
vi.mock('./cache-timer-seeding', () => ({
|
||||
shouldSeedCacheTimerOnInitialTitle
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: toastInfo }
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/codex-stale-pane-sweep', () => ({
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
}))
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof React>()
|
||||
return {
|
||||
...actual,
|
||||
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./pty-transport', () => ({
|
||||
createIpcPtyTransport: vi.fn((options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./remote-runtime-pty-transport', () => ({
|
||||
createRemoteRuntimePtyTransport: vi.fn(
|
||||
(_environmentId: string, options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./pty-dispatcher', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
getEagerPtyBufferHandle: vi.fn(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const HOST_COLS = 143
|
||||
const HOST_ROWS = 12
|
||||
const PANE_COLS = 120
|
||||
const PANE_ROWS = 40
|
||||
|
||||
// A serialized TUI frame the way @xterm/addon-serialize emits one: newline-fed
|
||||
// rows plus a trailing absolute CUP. Both are grid-relative.
|
||||
const HOST_FRAME = `\x1b[?1049h\x1b[2J\x1b[H${Array.from(
|
||||
{ length: HOST_ROWS },
|
||||
(_unused, index) => `host row ${index + 1}`
|
||||
).join('\r\n')}\x1b[${HOST_ROWS};3H`
|
||||
|
||||
function createDeps(overrides: Record<string, unknown> = {}) {
|
||||
return buildPaneConnectionDeps(() => mockStoreState, overrides)
|
||||
}
|
||||
|
||||
async function connectRemotePane(): Promise<{
|
||||
operations: { kind: 'resize' | 'write'; value: string }[]
|
||||
pane: ReturnType<typeof createPane>
|
||||
transport: MockTransport
|
||||
replay: (data: string, meta?: Record<string, unknown>) => void
|
||||
dispose: () => void
|
||||
}> {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, 'env-1')
|
||||
const transport = createMockTransport('remote:env-1@@terminal-1')
|
||||
const captured: { current: ConnectCallbacks['onReplayData'] | null } = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
captured.current = callbacks.onReplayData ?? null
|
||||
return { id: 'remote:env-1@@terminal-1', replay: '' }
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
pane.terminal.cols = PANE_COLS
|
||||
pane.terminal.rows = PANE_ROWS
|
||||
const operations: { kind: 'resize' | 'write'; value: string }[] = []
|
||||
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
|
||||
operations.push({ kind: 'write', value: data })
|
||||
callback?.()
|
||||
})
|
||||
pane.terminal.resize = vi.fn((cols: number, rows: number) => {
|
||||
operations.push({ kind: 'resize', value: `${cols}x${rows}` })
|
||||
pane.terminal.cols = cols
|
||||
pane.terminal.rows = rows
|
||||
})
|
||||
pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: PANE_COLS, rows: PANE_ROWS }))
|
||||
pane.fitAddon.fit = vi.fn(() => {
|
||||
pane.terminal.resize(PANE_COLS, PANE_ROWS)
|
||||
})
|
||||
|
||||
const manager = createManager(1)
|
||||
const disposable = connectPanePty(pane as never, manager as never, createDeps() as never)
|
||||
await flushAsyncTicks(6)
|
||||
transport.resize.mockClear()
|
||||
|
||||
return {
|
||||
operations,
|
||||
pane,
|
||||
transport,
|
||||
replay: (data, meta) => captured.current?.(data, meta as never),
|
||||
dispose: () => disposable.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
describe('pushed remote snapshot replay grid', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
it('replays at the host grid and then pushes the pane grid back to the PTY', async () => {
|
||||
const session = await connectRemotePane()
|
||||
|
||||
session.replay(HOST_FRAME, { snapshotCols: HOST_COLS, snapshotRows: HOST_ROWS })
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
const frameWriteIndex = session.operations.findIndex(
|
||||
(operation) => operation.kind === 'write' && operation.value === HOST_FRAME
|
||||
)
|
||||
const sourceResizeIndex = session.operations.findIndex(
|
||||
(operation) => operation.kind === 'resize' && operation.value === `${HOST_COLS}x${HOST_ROWS}`
|
||||
)
|
||||
expect(sourceResizeIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(frameWriteIndex).toBeGreaterThan(sourceResizeIndex)
|
||||
// Why the PTY push matters: the pane must not be left driving the host at
|
||||
// the replay geometry once the destination fit has run.
|
||||
expect(session.transport.resize).toHaveBeenCalledWith(PANE_COLS, PANE_ROWS)
|
||||
expect(session.transport.resize).not.toHaveBeenCalledWith(HOST_COLS, HOST_ROWS)
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('keeps the pane grid when the host published no snapshot dimensions', async () => {
|
||||
const session = await connectRemotePane()
|
||||
|
||||
session.replay(HOST_FRAME)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(session.pane.terminal.resize).not.toHaveBeenCalledWith(HOST_COLS, HOST_ROWS)
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('only reproduces the host frame when it is parsed at the host grid', async () => {
|
||||
const atHostGrid = await renderHeadlessBuffer([HOST_FRAME], HOST_COLS, HOST_ROWS)
|
||||
const atPaneGrid = await renderHeadlessBuffer([HOST_FRAME], PANE_COLS, HOST_ROWS - 4)
|
||||
|
||||
// Why this is the user-visible failure: the alternate screen has no
|
||||
// scrollback, so rows scrolled off by a shorter grid are gone for good and
|
||||
// an idle TUI never repaints them.
|
||||
expect(atHostGrid.filter((line) => line.startsWith('host row'))).toHaveLength(HOST_ROWS)
|
||||
expect(atPaneGrid.filter((line) => line.startsWith('host row')).length).toBeLessThan(HOST_ROWS)
|
||||
expect(atPaneGrid).not.toContain('host row 1')
|
||||
})
|
||||
})
|
||||
+4
-1
@@ -156,7 +156,10 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio
|
||||
}
|
||||
: {}),
|
||||
...(meta.terminalOwner ? { terminalOwner: meta.terminalOwner } : {}),
|
||||
...(meta.alternateScreen !== undefined ? { alternateScreen: meta.alternateScreen } : {})
|
||||
...(meta.alternateScreen !== undefined ? { alternateScreen: meta.alternateScreen } : {}),
|
||||
...(meta.snapshotCols !== undefined && meta.snapshotRows !== undefined
|
||||
? { snapshotCols: meta.snapshotCols, snapshotRows: meta.snapshotRows }
|
||||
: {})
|
||||
}
|
||||
session.scheduleReplayDataDrain()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
|
||||
import { resolvePositiveTerminalDimensions } from '../terminal-snapshot-replay-paint'
|
||||
|
||||
import {
|
||||
CURSOR_SHOW_SEQUENCE,
|
||||
@@ -66,6 +70,9 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
|
||||
session.pendingReplayData = null
|
||||
session.replayPayloadGeneration = 0
|
||||
let replayDrainQueued = false
|
||||
// Why: a payload replayed at a foreign grid leaves xterm sized to the source,
|
||||
// so the destination fit belongs after the whole transaction parses.
|
||||
let replayedAtSourceGrid = false
|
||||
const drainReplayDataQueue = async (
|
||||
expectedPtyId: string | null,
|
||||
expectedStreamGeneration: number
|
||||
@@ -86,8 +93,15 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
|
||||
return false
|
||||
}
|
||||
const payload = session.pendingReplayData
|
||||
const { data, clearBeforeReplay, pendingEscapeTailAnsi, alternateScreen, terminalOwner } =
|
||||
payload
|
||||
const {
|
||||
data,
|
||||
clearBeforeReplay,
|
||||
pendingEscapeTailAnsi,
|
||||
alternateScreen,
|
||||
terminalOwner,
|
||||
snapshotCols,
|
||||
snapshotRows
|
||||
} = payload
|
||||
session.pendingReplayData = null
|
||||
const isCurrentPayload = (): boolean =>
|
||||
!session.disposed &&
|
||||
@@ -100,12 +114,35 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
|
||||
// Relay replay buffers may overlap with content already rendered in
|
||||
// xterm. Local eager replay decides this earlier so metadata-only frames
|
||||
// can keep restored scrollback while still using the replay guard.
|
||||
// Why ahead of the source-grid resize: the clear is grid-independent, so
|
||||
// dropping the scrollback first spares a reflow of history the very next
|
||||
// sequence discards (see use-terminal-container-fit-sync.ts on its cost).
|
||||
if (clearBeforeReplay) {
|
||||
await session.writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H')
|
||||
if (!isCurrentPayload()) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Why before the frame: the payload's wraps and cursor moves are relative
|
||||
// to the grid the host serialized it at. Parsing it at the pane's own grid
|
||||
// clips or re-wraps the image, and an idle TUI never repaints to correct
|
||||
// it — the pane stays blank until the next byte arrives.
|
||||
const sourceGrid = resolvePositiveTerminalDimensions(snapshotCols, snapshotRows)
|
||||
if (
|
||||
sourceGrid &&
|
||||
(session.pane.terminal.cols !== sourceGrid.cols ||
|
||||
session.pane.terminal.rows !== sourceGrid.rows)
|
||||
) {
|
||||
// Why suppressed: this resize is a layout step for parsing, not the
|
||||
// pane's real geometry — the destination fit below owns the PTY grid.
|
||||
session.suppressStructuralReplayPtyResize = true
|
||||
try {
|
||||
session.pane.terminal.resize(sourceGrid.cols, sourceGrid.rows)
|
||||
} finally {
|
||||
session.suppressStructuralReplayPtyResize = false
|
||||
}
|
||||
replayedAtSourceGrid = true
|
||||
}
|
||||
if (clearBeforeReplay || data.length > 0) {
|
||||
// Why: an empty clearing frame is still an authoritative repaint and
|
||||
// must clear a stale agent signal from an earlier payload.
|
||||
@@ -148,12 +185,59 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
|
||||
}
|
||||
return appliedCurrentPayload
|
||||
}
|
||||
// Why the same helper the reattach payload uses: a source-grid replay leaves
|
||||
// xterm at the host's geometry, so the pane must fit back and push the
|
||||
// resulting grid to the PTY before live bytes resume.
|
||||
const fitAfterSourceGridReplay = async (
|
||||
scheduledPtyId: string | null,
|
||||
scheduledStreamGeneration: number
|
||||
): Promise<void> => {
|
||||
if (!replayedAtSourceGrid) {
|
||||
return
|
||||
}
|
||||
replayedAtSourceGrid = false
|
||||
if (
|
||||
session.disposed ||
|
||||
!scheduledPtyId ||
|
||||
session.transport.getPtyId() !== scheduledPtyId ||
|
||||
session.transportStreamGeneration !== scheduledStreamGeneration
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (getFitOverrideForPty(scheduledPtyId)) {
|
||||
// Why fit without the grid push: a mobile driver owns the PTY geometry,
|
||||
// but the pane must still leave the host's replay grid.
|
||||
safeFit(session.pane)
|
||||
return
|
||||
}
|
||||
const gridPush = session.createReattachGridPush(scheduledStreamGeneration, scheduledPtyId)
|
||||
const fit = safeFitAndThen(session.pane, 'replay-source-grid-fit', gridPush.continuation, {
|
||||
shouldContinue: gridPush.shouldContinue,
|
||||
retryIfUnmeasurable: true,
|
||||
// Why: a hidden or parked pane must still leave the source grid once it
|
||||
// is revealed, or the PTY stays pinned to the host's replay geometry.
|
||||
deferIfHidden: true
|
||||
})
|
||||
session.pendingReattachFit = fit
|
||||
try {
|
||||
await fit.completion
|
||||
} finally {
|
||||
if (session.pendingReattachFit === fit) {
|
||||
session.pendingReattachFit = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.scheduleReplayDataDrain = (): void => {
|
||||
if (replayDrainQueued) {
|
||||
return
|
||||
}
|
||||
const scheduledPtyId = session.pendingReplayData?.ptyId ?? null
|
||||
replayDrainQueued = true
|
||||
// Why reset here: a transaction whose restore was skipped never ran its
|
||||
// afterRestore, and a stale flag would fit a later drain that never left
|
||||
// the pane's own grid.
|
||||
replayedAtSourceGrid = false
|
||||
// Why: live bytes are newer than the authoritative replay frame. Hold
|
||||
// them until clear + replay + reset have all parsed, or replay can erase them.
|
||||
const scheduledStreamGeneration =
|
||||
@@ -171,7 +255,8 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
|
||||
shouldRestore: () =>
|
||||
!session.disposed &&
|
||||
session.transport.getPtyId() === scheduledPtyId &&
|
||||
session.transportStreamGeneration === scheduledStreamGeneration
|
||||
session.transportStreamGeneration === scheduledStreamGeneration,
|
||||
afterRestore: () => fitAfterSourceGridReplay(scheduledPtyId, scheduledStreamGeneration)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-
|
||||
import { registerTerminalSideEffectFactConsumer } from '../terminal-side-effect-facts-handler'
|
||||
|
||||
import { isAgentTaskCompleteTrackingEnabled } from './agent-task-complete-settings'
|
||||
import { isRemoteExecutionHostPtyId } from '../remote-execution-host-pty'
|
||||
import { isAgentProcessInspectionCostly } from '../agent-process-inspection-cost'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
@@ -229,17 +229,8 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void
|
||||
}),
|
||||
shouldPollProcessCadence: () =>
|
||||
isAgentTaskCompleteTrackingEnabled() && session.deps.isVisibleRef.current,
|
||||
isProcessInspectionCostly: () => {
|
||||
// Why: local Windows inspection forks a powershell.exe whole-process-table
|
||||
// CIM scan per poll (~10-40x heavier than POSIX `ps`). Keep the no-evidence
|
||||
// cadence enabled until inventory evidence is consumed by this renderer;
|
||||
// mixed-version relays may omit the optional field.
|
||||
if (!navigator.userAgent.includes('Windows')) {
|
||||
return false
|
||||
}
|
||||
const ptyId = session.transport.getPtyId()
|
||||
return ptyId !== null && !isRemoteExecutionHostPtyId(ptyId)
|
||||
},
|
||||
isProcessInspectionCostly: () =>
|
||||
isAgentProcessInspectionCostly(navigator.userAgent, session.transport.getPtyId()),
|
||||
isLive: () => {
|
||||
if (session.disposed) {
|
||||
return false
|
||||
|
||||
@@ -37,6 +37,8 @@ export type ProcessPtyOutputOptions = {
|
||||
snapshotSeq?: number
|
||||
alternateScreen?: boolean
|
||||
terminalOwner?: 'shell'
|
||||
snapshotCols?: number
|
||||
snapshotRows?: number
|
||||
}
|
||||
|
||||
function removeSuppressedCursorNativeTitles(
|
||||
@@ -217,7 +219,10 @@ export function createPtyOutputProcessor({
|
||||
...(options.alternateScreen !== undefined
|
||||
? { alternateScreen: options.alternateScreen }
|
||||
: {}),
|
||||
...(options.terminalOwner ? { terminalOwner: options.terminalOwner } : {})
|
||||
...(options.terminalOwner ? { terminalOwner: options.terminalOwner } : {}),
|
||||
...(options.snapshotCols !== undefined && options.snapshotRows !== undefined
|
||||
? { snapshotCols: options.snapshotCols, snapshotRows: options.snapshotRows }
|
||||
: {})
|
||||
}
|
||||
if (Object.keys(replayMeta).length > 0) {
|
||||
callbacks.onReplayData(data, replayMeta)
|
||||
|
||||
@@ -60,6 +60,10 @@ export type PtyReplayDataMeta = {
|
||||
snapshotSeq?: number
|
||||
alternateScreen?: boolean
|
||||
terminalOwner?: 'shell'
|
||||
/** Grid the payload was serialized at. Present only when the producer proved
|
||||
* it; the drain replays there and fits back to the pane afterwards. */
|
||||
snapshotCols?: number
|
||||
snapshotRows?: number
|
||||
}
|
||||
|
||||
export type LocalPtySessionMetadata = {
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamJson,
|
||||
encodeTerminalStreamFrame,
|
||||
encodeTerminalStreamJson,
|
||||
encodeTerminalStreamText
|
||||
} from '../../../../shared/terminal-stream-protocol'
|
||||
|
||||
// Client-side wire regression: the host dimensions every snapshot it publishes,
|
||||
// but only the REQUESTED snapshot path ever read `cols`/`rows` back. Both PUSH
|
||||
// paths (initial subscribe, server recovery) dropped them, so the pane parsed a
|
||||
// host-grid image at its own grid and an idle TUI never repainted the damage.
|
||||
// This drives REAL binary frames through the REAL multiplexer
|
||||
// (decodeSnapshotInfo → onSnapshot meta) into the REAL transport
|
||||
// (processData → onReplayData meta). One stream carries every case: the
|
||||
// multiplexer is a module-level singleton, so separate cases would need
|
||||
// separate module registries.
|
||||
|
||||
describe('remote transport snapshot source-grid threading', () => {
|
||||
const runtimeCall = vi.fn()
|
||||
const runtimeSubscribe = vi.fn()
|
||||
const subscriptionSendBinary = vi.fn()
|
||||
let subscriptionCallbacks: {
|
||||
onResponse: (response: unknown) => void
|
||||
onBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
|
||||
onError?: (error: { code: string; message: string }) => void
|
||||
onClose?: () => void
|
||||
} | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.doUnmock('../../runtime/remote-runtime-terminal-multiplexer')
|
||||
vi.clearAllMocks()
|
||||
subscriptionCallbacks = null
|
||||
subscriptionSendBinary.mockReset()
|
||||
runtimeCall.mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: {
|
||||
handle: 'terminal-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
worktreeId: 'wt-1'
|
||||
}
|
||||
}
|
||||
})
|
||||
runtimeSubscribe.mockImplementation(
|
||||
async (_args: unknown, callbacks: typeof subscriptionCallbacks) => {
|
||||
subscriptionCallbacks = callbacks
|
||||
return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary }
|
||||
}
|
||||
)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the host grid on pushed snapshots and omits it when the host has none', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
const onReplayData = vi.fn()
|
||||
transport.attach({
|
||||
existingPtyId: 'remote:env-1@@terminal-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
callbacks: { onReplayData }
|
||||
})
|
||||
|
||||
await expect.poll(() => subscriptionCallbacks !== null, { timeout: 5000 }).toBe(true)
|
||||
subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'ready' } })
|
||||
await expect
|
||||
.poll(() => subscriptionSendBinary.mock.calls.length, { timeout: 5000 })
|
||||
.toBeGreaterThan(0)
|
||||
const subscribeFrame = subscriptionSendBinary.mock.calls
|
||||
.map((call) => decodeTerminalStreamFrame(call[0] as Uint8Array))
|
||||
.find((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe)
|
||||
expect(subscribeFrame).toBeDefined()
|
||||
const streamId = decodeTerminalStreamJson<{ streamId: number }>(
|
||||
subscribeFrame!.payload
|
||||
)!.streamId
|
||||
|
||||
const deliverSnapshot = (start: Record<string, unknown>, body: string): void => {
|
||||
for (const frame of [
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotStart,
|
||||
streamId,
|
||||
seq: 0,
|
||||
payload: encodeTerminalStreamJson(start)
|
||||
}),
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotChunk,
|
||||
streamId,
|
||||
seq: 0,
|
||||
payload: encodeTerminalStreamText(body)
|
||||
}),
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotEnd,
|
||||
streamId,
|
||||
seq: 0,
|
||||
payload: new Uint8Array(0)
|
||||
})
|
||||
]) {
|
||||
subscriptionCallbacks?.onBinary?.(frame)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial subscribe push: the host's 143x43 grid must reach the restorer.
|
||||
deliverSnapshot({ cols: 143, rows: 43, seq: 7, source: 'headless' }, 'restored TUI frame')
|
||||
await expect.poll(() => onReplayData.mock.calls.length, { timeout: 5000 }).toBe(1)
|
||||
expect(onReplayData).toHaveBeenLastCalledWith(
|
||||
'restored TUI frame',
|
||||
expect.objectContaining({ snapshotCols: 143, snapshotRows: 43 })
|
||||
)
|
||||
|
||||
// Server-pushed recovery: untagged, after the initial snapshot landed.
|
||||
deliverSnapshot({ cols: 154, rows: 68, seq: 9, source: 'headless' }, 'recovered')
|
||||
await expect.poll(() => onReplayData.mock.calls.length, { timeout: 5000 }).toBe(2)
|
||||
expect(onReplayData).toHaveBeenLastCalledWith(
|
||||
'\x1b[2J\x1b[3J\x1b[Hrecovered',
|
||||
expect.objectContaining({ snapshotCols: 154, snapshotRows: 68 })
|
||||
)
|
||||
|
||||
// A host that publishes no dimensions must read as unknown, not as a grid.
|
||||
deliverSnapshot({ seq: 11, source: 'headless' }, 'undimensioned')
|
||||
await expect.poll(() => onReplayData.mock.calls.length, { timeout: 5000 }).toBe(3)
|
||||
const [, meta] = onReplayData.mock.calls[2] as [string, Record<string, unknown> | undefined]
|
||||
expect(meta?.snapshotCols).toBeUndefined()
|
||||
expect(meta?.snapshotRows).toBeUndefined()
|
||||
})
|
||||
})
|
||||
+5
-1
@@ -127,7 +127,11 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
emitOutput(streamId, liveOutput, liveSeq)
|
||||
|
||||
expect(onReplayData).toHaveBeenCalledOnce()
|
||||
expect(onReplayData).toHaveBeenCalledWith('AUTHORITATIVE_INITIAL_MARKER')
|
||||
expect(onReplayData).toHaveBeenCalledWith(
|
||||
'AUTHORITATIVE_INITIAL_MARKER',
|
||||
// The host's grid rides the snapshot so the pane replays it there.
|
||||
expect.objectContaining({ snapshotCols: 80, snapshotRows: 24 })
|
||||
)
|
||||
expect(onConnect).toHaveBeenCalledOnce()
|
||||
expect(onData).toHaveBeenCalledWith(liveOutput, expect.objectContaining({ seq: liveSeq }))
|
||||
await vi.waitFor(() => {
|
||||
|
||||
@@ -1922,6 +1922,12 @@ export function createRemoteRuntimePtyTransport(
|
||||
: {}),
|
||||
...(meta?.alternateScreen !== undefined && meta.seq !== undefined
|
||||
? { alternateScreen: meta.alternateScreen }
|
||||
: {}),
|
||||
// Why unconditional on seq: the grid describes the image itself,
|
||||
// not a stream boundary, so it is valid for every snapshot the
|
||||
// host dimensions. Absent/zero degrades to the pane's own grid.
|
||||
...(meta?.cols !== undefined && meta.rows !== undefined
|
||||
? { snapshotCols: meta.cols, snapshotRows: meta.rows }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15962,7 +15962,8 @@
|
||||
"none": "None",
|
||||
"search": "Search",
|
||||
"showUnreadOnly": "Show unread only",
|
||||
"showChildAgents": "Show child agents"
|
||||
"showChildAgents": "Show child agents",
|
||||
"activityOptions": "Activity options"
|
||||
},
|
||||
"clearCompleted": {
|
||||
"clearedOne": "Cleared 1 completed agent",
|
||||
@@ -17259,7 +17260,9 @@
|
||||
"dashboard": {
|
||||
"sidebar": {
|
||||
"label": "Agents",
|
||||
"dashboardLabel": "Agent Dashboard"
|
||||
"dashboardLabel": "Agent Dashboard",
|
||||
"openActivity": "View activity",
|
||||
"closeActivity": "Turn off activity view"
|
||||
}
|
||||
},
|
||||
"runtimeRpc": {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('workspace emoji shortcode index laziness', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('does not build the shared catalog when the renderer index is imported', async () => {
|
||||
const shortcodeIndex = await import('./workspace-emoji-shortcodes')
|
||||
const catalog = await import('../../../shared/emoji-shortcode-catalog')
|
||||
|
||||
expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(false)
|
||||
|
||||
// Cursor/regex-only paths must stay off the catalog too.
|
||||
expect(shortcodeIndex.getActiveWorkspaceEmojiShortcode('hi :tad', 7)).not.toBeNull()
|
||||
expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(false)
|
||||
|
||||
expect(shortcodeIndex.searchWorkspaceEmojiShortcodes('tada')[0]?.emoji).toBe('🎉')
|
||||
expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the exact-shortcode index out of module scope', () => {
|
||||
const indexSource = readFileSync(join(__dirname, 'workspace-emoji-shortcodes.ts'), 'utf8')
|
||||
|
||||
expect(indexSource).not.toMatch(/^const \w+ = new Map\(/m)
|
||||
expect(indexSource).not.toContain('STANDARD_EMOJI_SHORTCODE_ENTRIES')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
STANDARD_EMOJI_SHORTCODE_ENTRIES,
|
||||
getStandardEmojiShortcodeEntries,
|
||||
type StandardEmojiShortcodeEntry
|
||||
} from '../../../shared/emoji-shortcode-catalog'
|
||||
|
||||
@@ -16,9 +16,19 @@ export type WorkspaceEmojiReplacement = {
|
||||
value: string
|
||||
}
|
||||
|
||||
const EXACT_SHORTCODE = new Map(
|
||||
STANDARD_EMOJI_SHORTCODE_ENTRIES.map(({ emoji, shortcode }) => [shortcode, { emoji, shortcode }])
|
||||
)
|
||||
// Lazy for the same reason as the shared catalog it indexes: nothing needs it
|
||||
// until a `:` shortcode is completed.
|
||||
let exactShortcode: ReadonlyMap<string, WorkspaceEmojiSuggestion> | null = null
|
||||
|
||||
function exactShortcodeIndex(): ReadonlyMap<string, WorkspaceEmojiSuggestion> {
|
||||
exactShortcode ??= new Map(
|
||||
getStandardEmojiShortcodeEntries().map(({ emoji, shortcode }) => [
|
||||
shortcode,
|
||||
{ emoji, shortcode }
|
||||
])
|
||||
)
|
||||
return exactShortcode
|
||||
}
|
||||
|
||||
// Lower tiers rank first, so `korea` surfaces `south_korea` above `dishwasher`-style incidental hits.
|
||||
const MATCH_TIER = { exact: 0, prefix: 1, wordStart: 2, substring: 3 } as const
|
||||
@@ -43,15 +53,17 @@ export function searchWorkspaceEmojiShortcodes(
|
||||
return []
|
||||
}
|
||||
|
||||
const matches = STANDARD_EMOJI_SHORTCODE_ENTRIES.flatMap((entry) => {
|
||||
const tier = matchTier(entry.shortcode, normalizedQuery)
|
||||
return tier === null ? [] : [{ ...entry, tier }]
|
||||
}).sort(
|
||||
(left, right) =>
|
||||
left.tier - right.tier ||
|
||||
left.shortcode.length - right.shortcode.length ||
|
||||
left.shortcode.localeCompare(right.shortcode)
|
||||
)
|
||||
const matches = getStandardEmojiShortcodeEntries()
|
||||
.flatMap((entry) => {
|
||||
const tier = matchTier(entry.shortcode, normalizedQuery)
|
||||
return tier === null ? [] : [{ ...entry, tier }]
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.tier - right.tier ||
|
||||
left.shortcode.length - right.shortcode.length ||
|
||||
left.shortcode.localeCompare(right.shortcode)
|
||||
)
|
||||
const seenEmoji = new Set<string>()
|
||||
const suggestions: WorkspaceEmojiSuggestion[] = []
|
||||
for (const { emoji, shortcode } of matches) {
|
||||
@@ -96,7 +108,7 @@ export function replaceCompletedWorkspaceEmojiShortcode(
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const suggestion = EXACT_SHORTCODE.get(match[2].toLowerCase())
|
||||
const suggestion = exactShortcodeIndex().get(match[2].toLowerCase())
|
||||
if (!suggestion) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -93,7 +93,12 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime
|
||||
seq: info?.seq,
|
||||
kittyKeyboardFlags: info?.kittyKeyboardFlags,
|
||||
alternateScreen: info?.alternateScreen,
|
||||
terminalOwner: info?.terminalOwner
|
||||
terminalOwner: info?.terminalOwner,
|
||||
// Why: the image encodes wraps and cursor moves against the host's
|
||||
// grid, so the restorer must replay it there — the request path has
|
||||
// always carried these; the pushes silently dropped them.
|
||||
cols: info?.cols,
|
||||
rows: info?.rows
|
||||
})
|
||||
} else if (target === 'recovery') {
|
||||
// Why: a server-pushed recovery snapshot replaces terminal state
|
||||
@@ -105,7 +110,9 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime
|
||||
seq: info?.seq,
|
||||
kittyKeyboardFlags: info?.kittyKeyboardFlags,
|
||||
alternateScreen: info?.alternateScreen,
|
||||
terminalOwner: info?.terminalOwner
|
||||
terminalOwner: info?.terminalOwner,
|
||||
cols: info?.cols,
|
||||
rows: info?.rows
|
||||
})
|
||||
}
|
||||
} else if (matchesPendingRequest) {
|
||||
|
||||
@@ -41,6 +41,10 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = {
|
||||
kittyKeyboardFlags?: number
|
||||
alternateScreen?: boolean
|
||||
terminalOwner?: 'shell'
|
||||
/** Grid the host serialized this image at. Absent from hosts that omit
|
||||
* it, which must read as unknown so replay keeps the pane's own grid. */
|
||||
cols?: number
|
||||
rows?: number
|
||||
}
|
||||
) => void
|
||||
onSubscribed?: () => void
|
||||
|
||||
@@ -572,7 +572,10 @@ describe('remote runtime terminal multiplex ACK gate', () => {
|
||||
|
||||
injectSnapshot({ kind: 'scrollback', cols: 120, rows: 40, truncated: false }, 'initial state')
|
||||
expect(onSnapshot).toHaveBeenCalledWith('initial state', {
|
||||
pendingEscapeTailAnsi: undefined
|
||||
pendingEscapeTailAnsi: undefined,
|
||||
// The host's serialization grid; the restorer replays there, not at the pane's own.
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})
|
||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -590,7 +593,9 @@ describe('remote runtime terminal multiplex ACK gate', () => {
|
||||
// clears screen and scrollback first and must not replay the subscribe
|
||||
// lifecycle.
|
||||
expect(onSnapshot).toHaveBeenCalledWith(`\x1b[2J\x1b[3J\x1b[H${'recovered state'}`, {
|
||||
pendingEscapeTailAnsi: undefined
|
||||
pendingEscapeTailAnsi: undefined,
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})
|
||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -607,7 +612,9 @@ describe('remote runtime terminal multiplex ACK gate', () => {
|
||||
''
|
||||
)
|
||||
expect(onSnapshot).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', {
|
||||
pendingEscapeTailAnsi: undefined
|
||||
pendingEscapeTailAnsi: undefined,
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})
|
||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
||||
|
||||
|
||||
@@ -179,10 +179,15 @@ export function throwIfE2eWebRuntimeBrowserCapabilityUnavailable(): void {
|
||||
}
|
||||
|
||||
export async function pauseAfterE2eWebRuntimeBrowserCreate(remotePageId: string): Promise<void> {
|
||||
if (!e2eConfig.exposeStore || !armed || !createdPageBarrier) {
|
||||
if (!e2eConfig.exposeStore) {
|
||||
return
|
||||
}
|
||||
// Recorded before the arm check so a journey that never arms the barrier can still prove no host
|
||||
// page was created — a null id is only evidence if a real create would have set one.
|
||||
createdPageId = remotePageId
|
||||
if (!armed || !createdPageBarrier) {
|
||||
return
|
||||
}
|
||||
await createdPageBarrier
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('emoji shortcode catalog laziness', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('does not build the catalog when the shared module is imported', async () => {
|
||||
const catalog = await import('./emoji-shortcode-catalog.js')
|
||||
|
||||
expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(false)
|
||||
|
||||
expect(catalog.getStandardEmojiShortcodeEntries().length).toBeGreaterThan(1000)
|
||||
expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(true)
|
||||
})
|
||||
|
||||
it('builds on first use and keeps the main process off the eager path', async () => {
|
||||
const catalog = await import('./emoji-shortcode-catalog.js')
|
||||
|
||||
expect(catalog.replaceKnownEmojiWithShortcodes('ship \u{1F389}')).toBe('ship party ')
|
||||
expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves the main-process worktree namer importing only the deferred entry point', () => {
|
||||
// A cross-project import would drag src/main into the shared tsconfig, so assert on source.
|
||||
const worktreeLogic = readFileSync(join(__dirname, '../main/ipc/worktree-logic.ts'), 'utf8')
|
||||
const catalogImport = worktreeLogic.match(
|
||||
/import \{([^}]*)\} from '[^']*emoji-shortcode-catalog'/
|
||||
)
|
||||
|
||||
expect(catalogImport?.[1].trim()).toBe('replaceKnownEmojiWithShortcodes')
|
||||
})
|
||||
|
||||
it('keeps the catalog build out of module scope', () => {
|
||||
const sharedSource = readFileSync(join(__dirname, 'emoji-shortcode-catalog.ts'), 'utf8')
|
||||
|
||||
// A module-scope `const X = <expression over the dataset>` is the regression this guards.
|
||||
expect(sharedSource).not.toMatch(/^const \w+ = Object\.entries\(/m)
|
||||
expect(sharedSource).not.toMatch(/^const \w+ = new (?:Map|Intl\.Segmenter)\(/m)
|
||||
expect(sharedSource).toContain('function loadCatalog()')
|
||||
})
|
||||
})
|
||||
@@ -8,22 +8,50 @@ export type StandardEmojiShortcodeEntry = {
|
||||
// Skin-tone aliases (`wave_tone3`) are ~40% of the dataset and would drown the suggestion list.
|
||||
const SKIN_TONE_SHORTCODE = /_tone\d(?:-\d)?$/
|
||||
|
||||
const CATALOG = Object.entries(emojiShortcodes).flatMap(([hexcode, value]) => {
|
||||
const shortcodes = (typeof value === 'string' ? [value] : value).filter(
|
||||
(shortcode) => !SKIN_TONE_SHORTCODE.test(shortcode)
|
||||
)
|
||||
return shortcodes.length > 0 ? [{ emoji: hexcodeToEmoji(hexcode), shortcodes }] : []
|
||||
})
|
||||
type EmojiShortcodeCatalog = {
|
||||
entries: readonly StandardEmojiShortcodeEntry[]
|
||||
primaryShortcodeByEmoji: ReadonlyMap<string, string>
|
||||
segmenter: Intl.Segmenter
|
||||
}
|
||||
|
||||
export const STANDARD_EMOJI_SHORTCODE_ENTRIES: readonly StandardEmojiShortcodeEntry[] =
|
||||
CATALOG.flatMap(({ emoji, shortcodes }) => shortcodes.map((shortcode) => ({ emoji, shortcode })))
|
||||
let catalog: EmojiShortcodeCatalog | null = null
|
||||
|
||||
const PRIMARY_SHORTCODE_BY_EMOJI = new Map(
|
||||
CATALOG.map(({ emoji, shortcodes }) => [
|
||||
normalizeEmojiLookup(emoji),
|
||||
primaryShortcode(shortcodes)
|
||||
])
|
||||
)
|
||||
// Why lazy: this walks ~3,900 shortcodes and is only needed once a `:` is typed
|
||||
// or a worktree name is sanitized, but at module scope every renderer and main
|
||||
// boot paid for it. Memoized so the first caller builds it exactly once.
|
||||
function loadCatalog(): EmojiShortcodeCatalog {
|
||||
if (catalog) {
|
||||
return catalog
|
||||
}
|
||||
const grouped = Object.entries(emojiShortcodes).flatMap(([hexcode, value]) => {
|
||||
const shortcodes = (typeof value === 'string' ? [value] : value).filter(
|
||||
(shortcode) => !SKIN_TONE_SHORTCODE.test(shortcode)
|
||||
)
|
||||
return shortcodes.length > 0 ? [{ emoji: hexcodeToEmoji(hexcode), shortcodes }] : []
|
||||
})
|
||||
catalog = {
|
||||
entries: grouped.flatMap(({ emoji, shortcodes }) =>
|
||||
shortcodes.map((shortcode) => ({ emoji, shortcode }))
|
||||
),
|
||||
primaryShortcodeByEmoji: new Map(
|
||||
grouped.map(({ emoji, shortcodes }) => [
|
||||
normalizeEmojiLookup(emoji),
|
||||
primaryShortcode(shortcodes)
|
||||
])
|
||||
),
|
||||
segmenter: new Intl.Segmenter('en', { granularity: 'grapheme' })
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
export function getStandardEmojiShortcodeEntries(): readonly StandardEmojiShortcodeEntry[] {
|
||||
return loadCatalog().entries
|
||||
}
|
||||
|
||||
/** Test-only probe for the lazy-boundary guard; never branch on this in product code. */
|
||||
export function isEmojiShortcodeCatalogBuiltForTest(): boolean {
|
||||
return catalog !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the alias that reads best as a branch or directory name: skip `+1`/`-1` so the name
|
||||
@@ -40,11 +68,10 @@ function primaryShortcode(shortcodes: readonly string[]): string {
|
||||
)
|
||||
}
|
||||
|
||||
const EMOJI_SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' })
|
||||
|
||||
export function replaceKnownEmojiWithShortcodes(input: string): string {
|
||||
return Array.from(EMOJI_SEGMENTER.segment(input), ({ segment }) => {
|
||||
const shortcode = PRIMARY_SHORTCODE_BY_EMOJI.get(normalizeEmojiLookup(segment))
|
||||
const { primaryShortcodeByEmoji, segmenter } = loadCatalog()
|
||||
return Array.from(segmenter.segment(input), ({ segment }) => {
|
||||
const shortcode = primaryShortcodeByEmoji.get(normalizeEmojiLookup(segment))
|
||||
return shortcode ? ` ${shortcode.replaceAll('_', '-')} ` : segment
|
||||
}).join('')
|
||||
}
|
||||
|
||||
@@ -147,8 +147,13 @@ test.describe('Issue #12656 terminal link tooltip', () => {
|
||||
expect(Math.abs(idle.paneBottom - idle.terminalBottom)).toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await moveToLink(orcaPage, probe)
|
||||
return readTooltipState(orcaPage, probe.tabId)
|
||||
const currentProbe = await locateUrl(orcaPage, url)
|
||||
if (!currentProbe) {
|
||||
return { display: 'none', text: '' }
|
||||
}
|
||||
probe = currentProbe
|
||||
await moveToLink(orcaPage, currentProbe)
|
||||
return readTooltipState(orcaPage, currentProbe.tabId)
|
||||
})
|
||||
.toMatchObject({ display: '', text: expect.stringContaining(url) })
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { RuntimeClient } from '../../src/cli/runtime/client'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { readHostBrowserPageIds, readHostTabs } from './helpers/host-session-tabs'
|
||||
import { openFileExplorer } from './helpers/file-explorer'
|
||||
import {
|
||||
launchHeadlessPairedRuntimeHost,
|
||||
type HeadlessPairedRuntimeHost
|
||||
@@ -17,8 +14,6 @@ import {
|
||||
} from './helpers/paired-electron-client'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
|
||||
const FIXTURE_NAME = 'paired-browser-reconcile-failure.html'
|
||||
|
||||
type FaultSnapshot = {
|
||||
armed: boolean
|
||||
capabilityRejectionArmed: boolean
|
||||
@@ -36,6 +31,22 @@ type FaultWindow = Window & {
|
||||
}
|
||||
}
|
||||
|
||||
// Drives the real create menu so the failure surfaces through handleNewBrowserTab's toast.
|
||||
async function startBrowserCreate(page: Page): Promise<void> {
|
||||
await page.evaluate(() => window.__store?.getState().setBrowserDefaultUrl('about:blank'))
|
||||
await page.getByRole('button', { name: 'New tab' }).first().click()
|
||||
const newBrowserTab = page.getByRole('menuitem', { name: /New Browser Tab/i })
|
||||
await expect(newBrowserTab).toBeVisible({ timeout: 30_000 })
|
||||
await newBrowserTab.click()
|
||||
}
|
||||
|
||||
async function readStableHostTabs(hostClient: RuntimeClient, repoPath: string) {
|
||||
const { publicationEpoch, snapshotVersion, ...state } = await readHostTabs(hostClient, repoPath)
|
||||
expect(publicationEpoch).not.toBe('')
|
||||
expect(snapshotVersion).toBeGreaterThan(0)
|
||||
return state
|
||||
}
|
||||
|
||||
type ClientTabState = {
|
||||
browserTabIds: string[]
|
||||
browserWorkspaceIds: string[]
|
||||
@@ -115,14 +126,7 @@ async function runReconciliationFailureJourney(args: {
|
||||
})
|
||||
.toMatchObject({ terminalTabIds: expect.arrayContaining([expect.any(String)]) })
|
||||
|
||||
await openFileExplorer(page)
|
||||
const fixtureRow = page.locator('[data-file-explorer-row]').filter({ hasText: FIXTURE_NAME })
|
||||
await expect(fixtureRow).toBeVisible({ timeout: 30_000 })
|
||||
await fixtureRow.click()
|
||||
const openPreviewToSide = page.getByRole('button', { name: 'Open Preview to the Side' })
|
||||
await expect(openPreviewToSide).toBeVisible({ timeout: 30_000 })
|
||||
const baselineClient = await readClientTabs(page, worktreeId)
|
||||
expect(baselineClient.editorTabIds).not.toHaveLength(0)
|
||||
expect(baselineClient.terminalTabIds).not.toHaveLength(0)
|
||||
const baselineHostBrowserIds = await readHostBrowserPageIds(args.hostClient, args.repoPath)
|
||||
|
||||
@@ -133,7 +137,7 @@ async function runReconciliationFailureJourney(args: {
|
||||
}
|
||||
fault.arm()
|
||||
})
|
||||
await openPreviewToSide.click()
|
||||
await startBrowserCreate(page)
|
||||
|
||||
const faultSnapshot = await expect
|
||||
.poll(
|
||||
@@ -155,9 +159,8 @@ async function runReconciliationFailureJourney(args: {
|
||||
}
|
||||
|
||||
expect(await readHostBrowserPageIds(args.hostClient, args.repoPath)).toContain(createdPageId)
|
||||
// Why: the tab is staged on click, so while the create is held the user already sees it —
|
||||
// exactly one of it, in the new split. The rollback assertions after release are what prove
|
||||
// the optimism is unwound rather than stranded.
|
||||
// The managed-browser action stages one tab in the active group while the host create is held.
|
||||
// The rollback assertions prove that optimism is unwound rather than stranded.
|
||||
const heldClient = await readClientTabs(page, worktreeId)
|
||||
const addedSince = (baseline: string[], held: string[]): string[] => {
|
||||
expect(held).toEqual(expect.arrayContaining(baseline))
|
||||
@@ -169,7 +172,7 @@ async function runReconciliationFailureJourney(args: {
|
||||
).toHaveLength(1)
|
||||
expect(heldClient.editorTabIds).toEqual(baselineClient.editorTabIds)
|
||||
expect(heldClient.terminalTabIds).toEqual(baselineClient.terminalTabIds)
|
||||
expect(addedSince(baselineClient.groupIds, heldClient.groupIds)).toHaveLength(1)
|
||||
expect(heldClient.groupIds).toEqual(baselineClient.groupIds)
|
||||
|
||||
await page.screenshot({
|
||||
path: args.testInfo.outputPath(`${args.topology}-browser-reconciliation-held.png`),
|
||||
@@ -181,9 +184,9 @@ async function runReconciliationFailureJourney(args: {
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
await expect(page.getByText('Unable to open this file in Orca Browser.')).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
await expect(
|
||||
page.getByText('The paired runtime could not create a managed browser tab.')
|
||||
).toBeVisible({ timeout: 30_000 })
|
||||
await expect
|
||||
.poll(() => readHostBrowserPageIds(args.hostClient, args.repoPath), {
|
||||
timeout: 30_000,
|
||||
@@ -261,14 +264,8 @@ async function runCapabilityFailureJourney(args: {
|
||||
})
|
||||
.toMatchObject({ terminalTabIds: expect.arrayContaining([expect.any(String)]) })
|
||||
|
||||
await openFileExplorer(page)
|
||||
const fixtureRow = page.locator('[data-file-explorer-row]').filter({ hasText: FIXTURE_NAME })
|
||||
await expect(fixtureRow).toBeVisible({ timeout: 30_000 })
|
||||
await fixtureRow.click()
|
||||
const openPreviewToSide = page.getByRole('button', { name: 'Open Preview to the Side' })
|
||||
await expect(openPreviewToSide).toBeVisible({ timeout: 30_000 })
|
||||
const baselineClient = await readClientTabs(page, worktreeId)
|
||||
const baselineHost = await readHostTabs(args.hostClient, args.repoPath)
|
||||
const baselineHost = await readStableHostTabs(args.hostClient, args.repoPath)
|
||||
|
||||
await page.evaluate(() => {
|
||||
const fault = (window as FaultWindow).__webRuntimeBrowserCreationFault
|
||||
@@ -277,18 +274,25 @@ async function runCapabilityFailureJourney(args: {
|
||||
}
|
||||
fault.armCapabilityRejection()
|
||||
})
|
||||
await openPreviewToSide.click()
|
||||
await startBrowserCreate(page)
|
||||
|
||||
await expect(page.getByText('Unable to open this file in Orca Browser.')).toBeVisible({
|
||||
await expect(page.getByText(/E2E forced browser capability rejection/)).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
// Why: baseline equality alone also holds for a create that was rolled back. A null page id is
|
||||
// what separates rejecting before the host create from undoing one afterwards.
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => (window as FaultWindow).__webRuntimeBrowserCreationFault?.snapshot() ?? null
|
||||
)
|
||||
).toMatchObject({ createdPageId: null })
|
||||
await expect
|
||||
.poll(() => readClientTabs(page, worktreeId), {
|
||||
timeout: 30_000,
|
||||
message: 'client split state did not settle after capability rejection'
|
||||
})
|
||||
.toEqual(baselineClient)
|
||||
expect(await readHostTabs(args.hostClient, args.repoPath)).toEqual(baselineHost)
|
||||
expect(await readStableHostTabs(args.hostClient, args.repoPath)).toEqual(baselineHost)
|
||||
await page.screenshot({
|
||||
path: args.testInfo.outputPath(`${args.topology}-browser-capability-rejected.png`),
|
||||
fullPage: true
|
||||
@@ -305,10 +309,6 @@ test('rolls back a headed-host browser when client reconciliation times out @hea
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
test.setTimeout(300_000)
|
||||
writeFileSync(
|
||||
path.join(testRepoPath, FIXTURE_NAME),
|
||||
'<!doctype html><html><body><h1>browser reconciliation fault</h1></body></html>\n'
|
||||
)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
@@ -326,16 +326,12 @@ test('rolls back a headed-host browser when client reconciliation times out @hea
|
||||
})
|
||||
})
|
||||
|
||||
test('cleans up a headed-host preview when capability rejects after preflight @headful', async ({
|
||||
test('cleans up a headed-host browser when capability rejects before create @headful', async ({
|
||||
electronApp,
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
test.setTimeout(300_000)
|
||||
writeFileSync(
|
||||
path.join(testRepoPath, FIXTURE_NAME),
|
||||
'<!doctype html><html><body><h1>browser capability fault</h1></body></html>\n'
|
||||
)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
@@ -355,10 +351,6 @@ test('cleans up a headed-host preview when capability rejects after preflight @h
|
||||
|
||||
test('keeps browser failure cleanup on a headless host', async ({ testRepoPath }, testInfo) => {
|
||||
test.setTimeout(300_000)
|
||||
writeFileSync(
|
||||
path.join(testRepoPath, FIXTURE_NAME),
|
||||
'<!doctype html><html><body><h1>headless capability fault</h1></body></html>\n'
|
||||
)
|
||||
const host: HeadlessPairedRuntimeHost = await launchHeadlessPairedRuntimeHost()
|
||||
try {
|
||||
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
|
||||
|
||||
@@ -438,7 +438,9 @@ test.describe('Source Control large file count (#8013)', () => {
|
||||
// explicit recovery path after the underlying change count drops.
|
||||
removeLargeFileCountUntrackedTree(fixture.repoPath)
|
||||
await expect(tooManyChangesBanner).toBeVisible()
|
||||
await orcaPage.getByRole('button', { name: 'Retry' }).click()
|
||||
const retryButton = tooManyChangesBanner.locator('..').getByRole('button', { name: 'Retry' })
|
||||
await expect(retryButton).toBeVisible()
|
||||
await retryButton.click()
|
||||
await expect(tooManyChangesBanner).not.toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
getActiveTabType,
|
||||
getWorktreeTabs,
|
||||
getTabBarOrder,
|
||||
ensureTerminalVisible
|
||||
ensureTerminalVisible,
|
||||
waitForStartupWorktreeRefresh
|
||||
} from './helpers/store'
|
||||
|
||||
const SORTABLE_TAB = '[data-testid="sortable-tab"]'
|
||||
@@ -69,6 +70,7 @@ async function getFocusedTerminalTabId(page: Page): Promise<string | null> {
|
||||
test.describe('Tabs', () => {
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForStartupWorktreeRefresh(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
})
|
||||
|
||||
@@ -237,24 +237,10 @@ test('deleting the active scrolled worktree preserves position and closes the ro
|
||||
`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(belowId)}]`
|
||||
)
|
||||
await pauseForVisualProof(orcaPage)
|
||||
await target.evaluate((element) => {
|
||||
const scope = element.querySelector<HTMLElement>(
|
||||
'[data-worktree-context-menu-scope="worktree"]'
|
||||
)
|
||||
if (!scope) {
|
||||
throw new Error('Worktree context-menu scope is unavailable')
|
||||
}
|
||||
scope.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
cancelable: true,
|
||||
clientX: scope.getBoundingClientRect().left + 10,
|
||||
clientY: scope.getBoundingClientRect().top + 10
|
||||
})
|
||||
)
|
||||
})
|
||||
const deleteItem = orcaPage.getByRole('menuitem', { name: 'Delete', exact: true })
|
||||
const contextMenuScope = target.locator('[data-worktree-context-menu-scope="worktree"]')
|
||||
await expect(contextMenuScope).toBeVisible()
|
||||
await contextMenuScope.click({ button: 'right' })
|
||||
const deleteItem = orcaPage.getByRole('menuitem', { name: /^Delete(?:\s|$)/ })
|
||||
await expect(deleteItem).toBeVisible()
|
||||
await expect(deleteItem).toBeInViewport()
|
||||
await pauseForVisualProof(orcaPage)
|
||||
|
||||
Reference in New Issue
Block a user