Files
orca/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts
T
Brennan Benson bc1e049b3f fix(terminal): defer metric option writes to unmeasurable panes (#12944)
* fix(terminal): defer metric option writes to unmeasurable panes

Writing fontSize/fontFamily/fontWeight/lineHeight makes xterm re-measure
cell size against the pane's current box. A hidden or mid-layout pane can
measure a wrong-but-nonzero size, which latches (hasValidSize) and mis-keys
the shared WebGL glyph atlas until a manual resize — the stuck variant of
the P0 bold/blurry-font reports.

Metric writes now land only on measurable panes; otherwise the latest
values park per-pane and flush on the next safe fit or reveal (with a refit
on the light tab-resume path, which otherwise skips fitting). Measurability
helpers move to pane-fit-measurability.ts to stay under the pane-fit.ts
line cap.

* fix(terminal): key metric deferral by terminal, not pane view

getPanes() returns a fresh toPublicPane() wrapper per call, so a
WeakMap keyed on ManagedPane never matched across call sites: deferred
metric options were dropped, not deferred. Key on pane.terminal, which
is carried by reference and dies with the pane.

Also from review:
- flushDeferredPaneMetricOptionsIfMeasurable checks the pending WeakMap
  before the measurability probe, so the common no-deferral case costs
  zero forced style/layout on every reveal.
- applyTerminalAppearance skips the apply (and the probe) when all five
  values are already live and nothing is parked; any settings write
  re-runs the pass over every mounted pane, and arming a no-op deferral
  would trigger a refit on the next reveal.
- fitRevealedPane flushes first: its pixel/grid checks can both no-op
  and return without fitting, stranding parked options.
- Font zoom folds its direct fontSize write into any pending deferral so
  the flush inside safeFit cannot clobber the user's zoom.

Corrects comments that asserted a cell-size re-measure mechanism xterm
does not have: CharSizeService measures via OffscreenCanvas TextMetrics,
independent of the pane box, and only fontSize/fontFamily re-measure.

Test fixtures now allocate a fresh pane view per getPanes() call, which
is what production does and what hid the keying bug.

* fix(terminal): re-check the fit floor after a metric flush

performSafeFit evaluated the min cols/rows gate with the pre-flush cell
size, then flushed and fit unconditionally. A large font jump on a
narrow pane passes the gate at the old size and lands under it at the
new one, so fit() pinned the PTY to the tiny grid the floor exists to
reject. Re-check after a flush that actually landed.

The parked values still apply, so the pane is never stuck on stale
metrics; only the fit is skipped.

* fix(terminal): route a reveal metric flush through the stable fit

fitRevealedPane's new flush branch called safeFit directly, which is
exactly what the function's contract forbids on reveal: resumeRendering
has just re-attached WebGL, whose cell metrics transiently differ from
the DOM renderer's, so a raw fit can propose a one-column-off grid and
reflow — and xterm's wrap/unwrap is not a perfect inverse, leaving a
diff-painting inline TUI corrupted.

A landed flush leaves pixels unchanged with a diverged grid, the same
shape as a snapshot resize, so it takes the same steady-grid repair.
A real resize still fits synchronously, after the flush.

Reachable via window wake, which calls fitAllRevealedPanes with no
pre-flush loop.

* fix(terminal): gate metric writes on the pixel box, not the fit floor

canApplyPaneMetricOptions reused canMeasurePaneForFit, whose >=8 cols /
>=4 rows floor exists to stop a fit pinning the PTY to a sliver. But the
divider clamp is 50px, which clears the 48px pixel floor and proposes
~5 cols — so a pane dragged to the clamp deferred every font change and
never flushed: it never hides, and its box never changes, so no reveal
and no ResizeObserver entry ever arrives. It rendered a stale font until
widened, where pre-PR the write was unconditional.

Gate metric writes on display plus the pixel box only. Hidden panes and
the transient worktree-switch overlay are near-zero, so they still
defer — the deferral's purpose is unchanged. The cols/rows floor stays
on the fit, including the post-flush re-check in performSafeFit.

Apply and flush share the same predicate, so no "applies but never
flushes" state can open up.

* fix(terminal): flush heavy reveal metrics after WebGL resume
2026-08-07 10:14:37 -07:00

204 lines
8.1 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { worktreeRow, worktreeRowSurface } from './worktree-row-locators'
const INSPECTION_ERROR_TEXT = "Couldn't verify this repo's setup script right now."
const SETUP_SCRIPT_COMMAND = 'echo orca-e2e-setup'
const RECORDING_DWELL_MS = 1200
type WorktreeIds = {
repoId: string
mainWorktreeId: string
featureWorktreeId: string
}
function runGit(cwd: string, args: string[]): void {
execFileSync('git', args, { cwd, stdio: 'pipe' })
}
/** Repo whose shared orca.yaml carries a real setup script, plus a second worktree. */
function createRepoWithSharedSetupScript(repoPath: string, featureWorktreePath: string): void {
rmSync(repoPath, { recursive: true, force: true })
rmSync(featureWorktreePath, { recursive: true, force: true })
mkdirSync(repoPath, { recursive: true })
runGit(repoPath, ['init'])
runGit(repoPath, ['config', 'user.email', 'e2e@test.local'])
runGit(repoPath, ['config', 'user.name', 'E2E Test'])
writeFileSync(path.join(repoPath, 'README.md'), '# Unreadable orca.yaml E2E\n')
writeFileSync(path.join(repoPath, 'orca.yaml'), `scripts:\n setup: ${SETUP_SCRIPT_COMMAND}\n`)
runGit(repoPath, ['add', '-A'])
runGit(repoPath, ['commit', '-m', 'Initial commit'])
runGit(repoPath, ['worktree', 'add', '-b', 'setup-prompt-proof', featureWorktreePath])
}
/**
* Makes the main process report the failure the fix now surfaces: orca.yaml could
* not be read (SSH filesystem provider gone), so the hook check fails closed with
* `status: 'error'` instead of an authoritative "no setup script".
* The real handler stays captured so healing restores production behavior.
*/
async function installUnreadableOrcaYamlFault(electronApp: ElectronApplication): Promise<void> {
await electronApp.evaluate(({ ipcMain }) => {
type InvokeHandler = (event: unknown, ...args: unknown[]) => unknown
const faultState = globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean }
const registry = (ipcMain as unknown as { _invokeHandlers?: Map<string, InvokeHandler> })
._invokeHandlers
const productionHandler = registry?.get('hooks:check')
if (!productionHandler) {
throw new Error('hooks:check handler was not registered in the main process')
}
faultState.__orcaE2eOrcaYamlUnreadable = true
ipcMain.removeHandler('hooks:check')
ipcMain.handle('hooks:check', async (event, ...args) => {
if (faultState.__orcaE2eOrcaYamlUnreadable) {
return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false }
}
return productionHandler(event, ...args)
})
})
}
/** orca.yaml becomes readable again — every later check runs the production handler. */
async function healOrcaYamlRead(electronApp: ElectronApplication): Promise<void> {
await electronApp.evaluate(() => {
;(
globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean }
).__orcaE2eOrcaYamlUnreadable = false
})
}
async function addRepoAndActivateMainWorktree(
page: Page,
repoPath: string,
featureWorktreePath: string
): Promise<WorktreeIds> {
// Why: repos hide externally created worktrees by default, so the second
// worktree only reaches the sidebar once the repo opts into showing them.
const repoId = await page.evaluate(async (targetRepoPath) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const addedRepo = await store.getState().addRepoPath(targetRepoPath)
if (!addedRepo) {
throw new Error(`Failed to add repo at ${targetRepoPath}`)
}
await store.getState().updateRepo(addedRepo.id, { externalWorktreeVisibility: 'show' })
return addedRepo.id
}, repoPath)
await expect
.poll(
() =>
page.evaluate(async (targetRepoId) => {
const store = window.__store
if (!store) {
return 0
}
await store.getState().fetchWorktrees(targetRepoId)
return store.getState().worktreesByRepo[targetRepoId]?.length ?? 0
}, repoId),
{ timeout: 20_000, message: 'proof repo worktrees did not load' }
)
.toBeGreaterThanOrEqual(2)
return page.evaluate(
({ targetRepoId, targetRepoPath, targetFeaturePath }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const normalize = (value: string): string =>
value.startsWith('/private/var/') ? value.slice('/private'.length) : value
const state = store.getState()
const worktrees = state.worktreesByRepo[targetRepoId] ?? []
const mainWorktree = worktrees.find(
(entry) => normalize(entry.path) === normalize(targetRepoPath)
)
const featureWorktree = worktrees.find(
(entry) => normalize(entry.path) === normalize(targetFeaturePath)
)
if (!mainWorktree || !featureWorktree) {
throw new Error(
`Missing worktrees for ${targetRepoPath}: ${worktrees.map((entry) => entry.path).join(', ')}`
)
}
state.setSidebarOpen(true)
state.setGroupBy('none')
state.setSortBy('recent')
state.setShowActiveOnly(false)
state.setShowSleepingWorkspaces(true)
state.setHideDefaultBranchWorkspace(false)
state.setFilterRepoIds([])
state.setActiveRepo(targetRepoId)
state.setActiveWorktree(mainWorktree.id)
state.revealWorktreeInSidebar(featureWorktree.id, { behavior: 'auto' })
return {
repoId: targetRepoId,
mainWorktreeId: mainWorktree.id,
featureWorktreeId: featureWorktree.id
}
},
{ targetRepoId: repoId, targetRepoPath: repoPath, targetFeaturePath: featureWorktreePath }
)
}
test.describe('Setup script prompt', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
test('recovers from an unreadable orca.yaml instead of pinning the failed verdict', async ({
electronApp,
orcaPage
}, testInfo) => {
const repoPath = testInfo.outputPath('unreadable-orca-yaml-repo')
const featureWorktreePath = testInfo.outputPath('unreadable-orca-yaml-feature')
createRepoWithSharedSetupScript(repoPath, featureWorktreePath)
await installUnreadableOrcaYamlFault(electronApp)
const { repoId, featureWorktreeId } = await addRepoAndActivateMainWorktree(
orcaPage,
repoPath,
featureWorktreePath
)
const promptCard = orcaPage.locator('[data-setup-script-prompt-layer]')
const inspectionError = promptCard.getByText(INSPECTION_ERROR_TEXT)
await expect(inspectionError).toBeVisible({ timeout: 20_000 })
// orca.yaml is readable again; the card is still pinned to the failed verdict.
await healOrcaYamlRead(electronApp)
const healthyCheck = await orcaPage.evaluate(
(targetRepoId) => window.api.hooks.check({ repoId: targetRepoId }),
repoId
)
expect(healthyCheck.status).toBe('ok')
expect((healthyCheck.hooks as { scripts?: { setup?: string } } | null)?.scripts?.setup).toBe(
SETUP_SCRIPT_COMMAND
)
await expect(inspectionError).toBeVisible()
await expect(promptCard.getByRole('button', { name: 'Retry' })).toBeVisible()
// Not a wait for state: holds the pinned card on screen for the proof recording.
await orcaPage.waitForTimeout(RECORDING_DWELL_MS)
// Activating another worktree in the same repo must re-inspect.
const featureRow = worktreeRow(orcaPage, featureWorktreeId)
await expect(featureRow).toBeVisible()
await worktreeRowSurface(orcaPage, featureWorktreeId).click()
await expect(featureRow).toHaveAttribute('aria-current', 'page')
// The repo has a valid orca.yaml scripts.setup, so no prompt may remain.
await expect(inspectionError).toBeHidden({ timeout: 20_000 })
await expect(promptCard).toHaveCount(0)
await orcaPage.waitForTimeout(RECORDING_DWELL_MS)
})
})