Files
orca/tests/e2e/helpers/dead-terminal.ts
T
NeilandOrca 46646d7ff1 chore(lint): upgrade oxlint to 1.71 + enable 7 new rules (autofixed backlog) (#6841)
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules

Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day
minimum-release-age supply-chain guard; nothing here needs it). The
bump is a no-op on the existing config.

Enable 3 error rules (backlog autofixed to zero in this commit) and
4 warn rules (surface signal without gating CI):

error (autofixed, behavior-preserving):
- unicorn/prefer-node-protocol        (~1531 sites: bare builtin -> node:)
- typescript/no-import-type-side-effects (~36: all-inline-type -> import type)
- unicorn/no-array-reverse            (19: copy-then-reverse -> toReversed)

warn (real signal, current fires are test-only/correct):
- unicorn/no-array-fill-with-reference-type  (aliasing footgun guard)
- typescript/no-unsafe-function-type         (bans bare Function type)
- unicorn/prefer-array-flat-map              (map().flat() -> flatMap())
- unicorn/prefer-regexp-test                 (.match() in bool ctx -> .test())

mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix
ran from root and covered mobile/ too.

Verification (all green): oxlint 0 errors (root+mobile+aux configs),
oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed,
builds (electron-vite + web + cli) succeed. node: rewrites confirmed to
skip embedded SSH/CLI string payloads (AST-only); all toReversed sites
verified to operate on fresh copies or write-once locals.

* chore(lint): bump mobile oxlint to 1.71 so inherited rules parse

mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which
lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since
mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile &&
oxlint' failed to parse the new rule. Bump mobile to match root (1.71).

Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit
pass, vitest 978 passed / 0 failed.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-06-29 22:38:29 -07:00

213 lines
6.8 KiB
TypeScript

/**
* Shared helpers for dead-terminal reproduction tests.
*
* These helpers exercise the real production worktree-creation-with-setup-split
* flow and verify that terminal panes render content after the split.
*/
import { expect } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import type { getActiveWorktreeId } from './store'
type TestPage = Parameters<typeof getActiveWorktreeId>[0]
const SETUP_HOOK_CONTENT = 'scripts:\n setup: echo SETUP_COMPLETE\n'
async function ensureSetupHookCommitted(page: TestPage): Promise<void> {
const activeWorktreePath = await page.evaluate(() => {
const state = window.__store?.getState()
if (!state?.activeWorktreeId) {
throw new Error('No active worktree')
}
const activeWorktree = Object.values(state.worktreesByRepo)
.flat()
.find((wt) => wt.id === state.activeWorktreeId)
if (!activeWorktree) {
throw new Error('Active worktree not found in store')
}
return activeWorktree.path
})
writeFileSync(path.join(activeWorktreePath, 'orca.yaml'), SETUP_HOOK_CONTENT, 'utf-8')
execFileSync('git', ['add', 'orca.yaml'], { cwd: activeWorktreePath, stdio: 'pipe' })
try {
execFileSync('git', ['diff', '--cached', '--quiet'], { cwd: activeWorktreePath, stdio: 'pipe' })
} catch {
// Why: worktree creation reads orca.yaml from the created worktree, so the
// temp repo must commit this hook before `git worktree add` copies it.
execFileSync('git', ['commit', '-m', 'Add e2e setup hook'], {
cwd: activeWorktreePath,
stdio: 'pipe'
})
}
}
/**
* Create a worktree with setup via the real IPC flow, then activate it
* replicating the exact activateAndRevealWorktree + ensureWorktreeHasInitialTerminal
* path. The setup split is queued on the tab BEFORE the TerminalPane mounts,
* matching production timing.
*/
export async function createAndActivateWorktreeWithSetup(
page: TestPage,
suffix: string,
direction: 'vertical' | 'horizontal'
): Promise<string> {
await ensureSetupHookCommitted(page)
const name = `e2e-dead-term-${suffix}-${Date.now()}`
return page.evaluate(
async ({ worktreeName, direction }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const activeWorktreeId = state.activeWorktreeId
if (!activeWorktreeId) {
throw new Error('No active worktree')
}
const activeWorktree = Object.values(state.worktreesByRepo)
.flat()
.find((wt) => wt.id === activeWorktreeId)
if (!activeWorktree) {
throw new Error('Active worktree not found in store')
}
const result = await state.createWorktree(
activeWorktree.repoId,
worktreeName,
undefined,
'run'
)
await state.fetchWorktrees(activeWorktree.repoId)
const worktreeId = result.worktree.id
if (activeWorktree.repoId !== state.activeRepoId) {
state.setActiveRepo(activeWorktree.repoId)
}
if (store.getState().activeView !== 'terminal') {
state.setActiveView('terminal')
}
state.setActiveWorktree(worktreeId)
const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId)
if (renderableTabCount > 0) {
return worktreeId
}
const tab = state.createTab(worktreeId, undefined, undefined, {
pendingActivationSpawn: true
})
state.setActiveTab(tab.id)
if (result.setup) {
const runnerPath = result.setup.runnerScriptPath
const command = `bash ${runnerPath}`
state.queueTabSetupSplit(tab.id, {
command,
env: result.setup.envVars,
direction
})
}
state.revealWorktreeInSidebar(worktreeId)
return worktreeId
},
{ worktreeName: name, direction }
)
}
export async function removeWorktreeViaStore(page: TestPage, worktreeId: string): Promise<void> {
await page.evaluate(async (id) => {
try {
await window.__store?.getState().removeWorktree(id, true)
} catch {
/* best-effort */
}
}, worktreeId)
}
/**
* Poll until every pane in the active tab's PaneManager has non-empty buffer
* content. A dead terminal has an empty serialize() result because the WebGL
* canvas never painted the shell prompt.
*/
export async function waitForAllPanesToHaveContent(
page: TestPage,
label: string,
timeoutMs = 15_000
): Promise<void> {
await expect
.poll(
async () => {
return page.evaluate(() => {
const store = window.__store
if (!store) {
return { ok: false, reason: 'no store' }
}
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) {
return { ok: false, reason: 'no active worktree' }
}
const tabs = state.tabsByWorktree[wId] ?? []
const tabId =
state.activeTabType === 'terminal'
? state.activeTabId
: (state.activeTabIdByWorktree?.[wId] ?? tabs[0]?.id)
if (!tabId) {
return { ok: false, reason: 'no tab' }
}
const manager = window.__paneManagers?.get(tabId)
if (!manager) {
return { ok: false, reason: 'no manager' }
}
const panes = manager.getPanes?.() ?? []
if (panes.length === 0) {
return { ok: false, reason: 'no panes' }
}
const paneStates = panes.map((pane) => {
const content = pane.serializeAddon?.serialize?.() ?? ''
// oxlint-disable-next-line no-control-regex -- stripping terminal control chars is intentional
const stripped = content.replace(/[\s\x00-\x1f]/g, '')
return { id: pane.id, hasContent: stripped.length > 0 }
})
return { ok: paneStates.every((p) => p.hasContent), paneStates }
})
},
{
timeout: timeoutMs,
message: `[${label}] Not all terminal panes have rendered content (possible dead terminal)`
}
)
.toMatchObject({ ok: true })
}
/**
* Log WebGL canvas state for diagnostics. In headful mode, visible panes
* should have WebGL canvases; hidden panes (suspended rendering) should not.
*/
export async function checkWebglState(page: TestPage, label: string): Promise<void> {
const paneStates = await page.evaluate(() => {
const containers = document.querySelectorAll('.pane[data-pane-id]')
return Array.from(containers).map((c) => ({
paneId: (c as HTMLElement).dataset.paneId,
canvasCount: c.querySelectorAll('canvas').length
}))
})
const hasCanvas = paneStates.some((p) => p.canvasCount > 0)
if (!hasCanvas) {
console.warn(`[${label}] No WebGL canvases — DOM renderer only.`)
}
}