Files
orca/tests/e2e/native-chat-first-flush-race.spec.ts
T
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00

166 lines
6.5 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal'
import type { GlobalSettings } from '../../src/shared/global-settings-types'
const LOADING_TITLE = 'Loading conversation…'
const ERROR_TITLE = 'Could not load conversation'
async function enableNativeChatSetting(page: Page): Promise<void> {
await page.evaluate(async () => {
const nextSettings = await window.api.settings.set({ experimentalNativeChat: true })
window.__store?.setState({ settings: nextSettings as GlobalSettings })
})
}
// Why: seeding agentStatusByPaneKey directly (rather than posting a real
// `/hook/claude` event) mirrors the technique agent-session-quit-resume.spec.ts
// uses to stay hermetic — it exercises the identical store → NativeChatView
// path a real Claude Code hook would drive, without an installed CLI.
async function seedClaudeProviderSession(
page: Page,
args: { paneKey: string; worktreeId: string; sessionId: string; transcriptPath: string }
): Promise<void> {
await page.evaluate(({ paneKey, worktreeId, sessionId, transcriptPath }) => {
window.__store
?.getState()
.setAgentStatus(
paneKey,
{ state: 'working', prompt: 'e2e first-flush race probe', agentType: 'claude' },
'Claude',
undefined,
{ worktreeId },
{ providerSession: { key: 'session_id', id: sessionId, transcriptPath } }
)
}, args)
}
// Why: toggleTabViewMode keys off the *unified* tab id, which can differ from
// the terminal tab id embedded in paneKey — resolve it the same way
// TerminalPane.tsx does before calling the store action a real toggle/shortcut
// would use.
async function toggleTerminalTabToChatView(
page: Page,
args: { tabId: string; worktreeId: string }
): Promise<void> {
await page.evaluate(({ tabId, worktreeId }) => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
const state = store.getState()
const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
(tab) => tab.contentType === 'terminal' && tab.entityId === tabId
)
if (!unifiedTab) {
throw new Error('Unified terminal tab not found for chat toggle')
}
state.toggleTabViewMode(unifiedTab.id)
}, args)
}
function claudeTranscriptLines(args: {
sessionId: string
userText: string
assistantText: string
}): string {
// Why: distinct timestamps keep the rendered order deterministic (a tie is
// broken by uuid, which would put the assistant turn first).
const userTime = new Date()
const assistantTime = new Date(userTime.getTime() + 2_000)
const lines = [
{
sessionId: args.sessionId,
uuid: `${args.sessionId}-user`,
timestamp: userTime.toISOString(),
type: 'user',
message: { role: 'user', content: [{ type: 'text', text: args.userText }] }
},
{
sessionId: args.sessionId,
uuid: `${args.sessionId}-assistant`,
timestamp: assistantTime.toISOString(),
type: 'assistant',
message: { model: 'claude-opus-4', content: [{ type: 'text', text: args.assistantText }] }
}
]
return `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`
}
test.describe('Native chat first-flush transcript race (#8401)', () => {
test('stays in loading (never errors) until a not-yet-flushed transcript appears, then hydrates live', async ({
orcaPage
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const descriptor = await waitForActivePaneHookDescriptor(orcaPage)
const [tabId] = descriptor.paneKey.split(':')
const sessionId = `e2e-first-flush-${randomUUID()}`
// Why: a real Claude Code session flushes its first JSONL line up to
// minutes after launch (#8401) — this directory intentionally has no file
// yet when the pane resolves its providerSession.
const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-native-chat-'))
const transcriptPath = path.join(scratchDir, `${sessionId}.jsonl`)
const screenshotDir = path.join(
process.cwd(),
'validation-screenshots',
`native-chat-first-flush-race-${Date.now()}`
)
mkdirSync(screenshotDir, { recursive: true })
await testInfo.attach('validation-screenshot-dir', {
body: screenshotDir,
contentType: 'text/plain'
})
try {
await enableNativeChatSetting(orcaPage)
await seedClaudeProviderSession(orcaPage, {
paneKey: descriptor.paneKey,
worktreeId: descriptor.worktreeId,
sessionId,
transcriptPath
})
await toggleTerminalTabToChatView(orcaPage, { tabId, worktreeId: descriptor.worktreeId })
await expect(orcaPage.locator('[data-native-chat-root="true"]')).toBeVisible({
timeout: 15_000
})
await expect(orcaPage.getByText(LOADING_TITLE)).toBeVisible({ timeout: 10_000 })
await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0)
await orcaPage.screenshot({
path: path.join(screenshotDir, '01-loading-no-error.png')
})
// Why: a short real delay proves the first readSession attempt already
// hit the not-yet-flushed file (returning notFound) and the renderer's
// backoff retry — not a lucky first read — is what picks it up below.
await orcaPage.waitForTimeout(1_500)
await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0)
const userText = 'Explain the native chat first-flush race fix for #8401'
const assistantText =
'The main process now retries a not-yet-flushed transcript instead of caching a permanent miss.'
writeFileSync(transcriptPath, claudeTranscriptLines({ sessionId, userText, assistantText }))
await expect(orcaPage.getByText(userText)).toBeVisible({ timeout: 30_000 })
await expect(orcaPage.getByText(assistantText)).toBeVisible({ timeout: 30_000 })
await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0)
await orcaPage.screenshot({
path: path.join(screenshotDir, '02-hydrated.png')
})
} finally {
rmSync(scratchDir, { recursive: true, force: true })
}
})
})