mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
/**
|
|
* Zustand store inspection helpers for Orca E2E tests.
|
|
*
|
|
* Why: In dev mode, Orca exposes `window.__store` (the Zustand useAppStore).
|
|
* Reading store state gives tests reliable access to app state without
|
|
* fragile DOM scraping.
|
|
*/
|
|
|
|
import type { Page } from '@stablyai/playwright-test'
|
|
import { expect } from '@stablyai/playwright-test'
|
|
import {
|
|
type BrowserTabSummary,
|
|
type ExplorerFileSummary,
|
|
type TerminalTabSummary
|
|
} from './runtime-types'
|
|
|
|
/** Read a value from the Zustand store. Returns the raw JS value. */
|
|
export async function getStoreState<T>(page: Page, selector: string): Promise<T> {
|
|
return page.evaluate((selector) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is not available — is the app in dev mode?')
|
|
}
|
|
|
|
const state = store.getState()
|
|
// Support dot-notation selectors like 'activeWorktreeId' or 'tabsByWorktree'
|
|
return selector.split('.').reduce<unknown>((value, key) => {
|
|
if (value && typeof value === 'object') {
|
|
return (value as Record<string, unknown>)[key]
|
|
}
|
|
|
|
return undefined
|
|
}, state) as T
|
|
}, selector)
|
|
}
|
|
|
|
/** Get the active worktree ID. */
|
|
export async function getActiveWorktreeId(page: Page): Promise<string | null> {
|
|
return getStoreState<string | null>(page, 'activeWorktreeId')
|
|
}
|
|
|
|
/** Get the active tab ID. */
|
|
export async function getActiveTabId(page: Page): Promise<string | null> {
|
|
return getStoreState<string | null>(page, 'activeTabId')
|
|
}
|
|
|
|
/** Get the active tab type ('terminal' | 'editor' | 'browser'). */
|
|
export async function getActiveTabType(page: Page): Promise<string | null> {
|
|
return getStoreState<string | null>(page, 'activeTabType')
|
|
}
|
|
|
|
/** Get all terminal tabs for a given worktree. */
|
|
export async function getWorktreeTabs(
|
|
page: Page,
|
|
worktreeId: string
|
|
): Promise<{ id: string; title?: string }[]> {
|
|
return page.evaluate((worktreeId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
|
|
const state = store.getState()
|
|
return (state.tabsByWorktree[worktreeId] ?? []).map(
|
|
(tab): TerminalTabSummary => ({
|
|
id: tab.id,
|
|
title: tab.customTitle || tab.title
|
|
})
|
|
)
|
|
}, worktreeId)
|
|
}
|
|
|
|
/**
|
|
* Get the tab bar order for a worktree.
|
|
*
|
|
* Why: split groups manage tab order via group.tabOrder on each TabGroup,
|
|
* not the legacy tabBarOrderByWorktree field. Read from the active group's
|
|
* tabOrder so drag-reorder assertions work with the split-group model.
|
|
* Falls back to the legacy field for worktrees that haven't been absorbed
|
|
* into the split-group model yet.
|
|
*/
|
|
export async function getTabBarOrder(page: Page, worktreeId: string): Promise<string[]> {
|
|
return page.evaluate((worktreeId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
|
|
const state = store.getState()
|
|
const groups = state.groupsByWorktree?.[worktreeId] ?? []
|
|
const activeGroupId = state.activeGroupIdByWorktree?.[worktreeId]
|
|
const activeGroup = activeGroupId
|
|
? groups.find((g: { id: string }) => g.id === activeGroupId)
|
|
: groups[0]
|
|
if (activeGroup?.tabOrder?.length > 0) {
|
|
const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? []
|
|
return activeGroup.tabOrder.map((itemId: string) => {
|
|
const tab = unifiedTabs.find((t: { id: string }) => t.id === itemId)
|
|
if (!tab) {
|
|
return itemId
|
|
}
|
|
return tab.contentType === 'terminal' || tab.contentType === 'browser'
|
|
? tab.entityId
|
|
: tab.id
|
|
})
|
|
}
|
|
return state.tabBarOrderByWorktree[worktreeId] ?? []
|
|
}, worktreeId)
|
|
}
|
|
|
|
/** Get browser tabs for a given worktree. */
|
|
export async function getBrowserTabs(
|
|
page: Page,
|
|
worktreeId: string
|
|
): Promise<{ id: string; url?: string; title?: string }[]> {
|
|
return page.evaluate((worktreeId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
|
|
const state = store.getState()
|
|
return (state.browserTabsByWorktree[worktreeId] ?? []).map(
|
|
(tab): BrowserTabSummary => ({
|
|
id: tab.id,
|
|
url: tab.url,
|
|
title: tab.title
|
|
})
|
|
)
|
|
}, worktreeId)
|
|
}
|
|
|
|
/** Get open editor files for a given worktree. */
|
|
export async function getOpenFiles(
|
|
page: Page,
|
|
worktreeId: string
|
|
): Promise<{ id: string; filePath: string; relativePath: string }[]> {
|
|
return page.evaluate((worktreeId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
|
|
const state = store.getState()
|
|
return state.openFiles
|
|
.filter((file) => file.worktreeId === worktreeId)
|
|
.map(
|
|
(file): ExplorerFileSummary => ({
|
|
id: file.id,
|
|
filePath: file.filePath,
|
|
relativePath: file.relativePath
|
|
})
|
|
)
|
|
}, worktreeId)
|
|
}
|
|
|
|
/** Wait until the workspace session is ready. Uses expect.poll for proper Playwright waiting. */
|
|
export async function waitForSessionReady(page: Page, timeoutMs = 30_000): Promise<void> {
|
|
await expect
|
|
.poll(async () => getStoreState<boolean>(page, 'workspaceSessionReady'), {
|
|
timeout: timeoutMs,
|
|
message: 'workspaceSessionReady did not become true'
|
|
})
|
|
.toBe(true)
|
|
}
|
|
|
|
/** Wait until a worktree is active and return its ID. */
|
|
export async function waitForActiveWorktree(page: Page, timeoutMs = 30_000): Promise<string> {
|
|
const existingId = await getActiveWorktreeId(page)
|
|
if (existingId) {
|
|
return existingId
|
|
}
|
|
|
|
const activatedFromStore = await page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return false
|
|
}
|
|
|
|
const state = store.getState()
|
|
if (state.activeWorktreeId) {
|
|
return true
|
|
}
|
|
|
|
const firstWorktree = Object.values(state.worktreesByRepo).flat()[0]
|
|
if (!firstWorktree) {
|
|
return false
|
|
}
|
|
|
|
// Why: the sidebar no longer guarantees a role="option" worktree row
|
|
// during hydration, so DOM-click fallback can miss the only selectable
|
|
// worktree and leave fresh E2E sessions stuck with activeWorktreeId=null.
|
|
// Activating the first loaded worktree through the store matches the app's
|
|
// real selection path and keeps setup independent from sidebar markup.
|
|
state.setActiveWorktree(firstWorktree.id)
|
|
return true
|
|
})
|
|
|
|
if (!activatedFromStore) {
|
|
const primaryWorktreeOption = page.getByRole('option', { name: /primary/i }).first()
|
|
const anyWorktreeOption = page.getByRole('option').first()
|
|
const optionToClick =
|
|
(await primaryWorktreeOption.count()) > 0 ? primaryWorktreeOption : anyWorktreeOption
|
|
|
|
if ((await optionToClick.count()) > 0) {
|
|
// Why: isolated E2E sessions can finish hydrating with worktrees loaded but
|
|
// no selection restored. Clicking the sidebar option matches the real user
|
|
// path and drives the same activation logic the app relies on in production.
|
|
await optionToClick.click()
|
|
}
|
|
}
|
|
|
|
await expect
|
|
.poll(async () => getActiveWorktreeId(page), {
|
|
timeout: timeoutMs,
|
|
message: 'activeWorktreeId did not become available'
|
|
})
|
|
.not.toBeNull()
|
|
|
|
return (await getActiveWorktreeId(page))!
|
|
}
|
|
|
|
/** Get all worktree IDs across all repos. */
|
|
export async function getAllWorktreeIds(page: Page): Promise<string[]> {
|
|
return page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
|
|
const state = store.getState()
|
|
const allWorktrees = Object.values(state.worktreesByRepo).flat()
|
|
return allWorktrees.map((worktree) => worktree.id)
|
|
})
|
|
}
|
|
|
|
/** Switch to a different worktree via the store. Returns the new worktree ID or null. */
|
|
export async function switchToOtherWorktree(
|
|
page: Page,
|
|
currentWorktreeId: string
|
|
): Promise<string | null> {
|
|
return page.evaluate((currentId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return null
|
|
}
|
|
|
|
const state = store.getState()
|
|
const allWorktrees = Object.values(state.worktreesByRepo).flat()
|
|
const other = allWorktrees.find((worktree) => worktree.id !== currentId)
|
|
if (!other) {
|
|
return null
|
|
}
|
|
|
|
state.setActiveWorktree(other.id)
|
|
return other.id
|
|
}, currentWorktreeId)
|
|
}
|
|
|
|
/** Switch to a specific worktree via the store. */
|
|
export async function switchToWorktree(page: Page, worktreeId: string): Promise<void> {
|
|
await page.evaluate((id) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return
|
|
}
|
|
|
|
store.getState().setActiveWorktree(id)
|
|
}, worktreeId)
|
|
}
|
|
|
|
/**
|
|
* Ensure the active tab is a terminal and that the first terminal tab exists.
|
|
*
|
|
* Why: the first terminal tab is created by a renderer effect after session
|
|
* hydration. Waiting on store state is more reliable than DOM visibility in
|
|
* hidden-window mode and avoids racing that initial auto-create step.
|
|
*/
|
|
export async function ensureTerminalVisible(page: Page, timeoutMs = 10_000): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return
|
|
}
|
|
|
|
const state = store.getState()
|
|
if (state.activeWorktreeId) {
|
|
const tabs = state.tabsByWorktree[state.activeWorktreeId] ?? []
|
|
if (tabs.length === 0) {
|
|
// Why: fresh isolated E2E profiles may not have finished the UI-driven
|
|
// auto-create effect yet. Use the same store action to create the first
|
|
// terminal tab so terminal-focused specs start from a stable baseline.
|
|
state.createTab(state.activeWorktreeId)
|
|
}
|
|
}
|
|
if (state.activeTabType !== 'terminal') {
|
|
state.setActiveTabType('terminal')
|
|
}
|
|
})
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return false
|
|
}
|
|
const state = store.getState()
|
|
if (state.activeTabType !== 'terminal' || !state.activeWorktreeId) {
|
|
return false
|
|
}
|
|
const tabs = state.tabsByWorktree[state.activeWorktreeId] ?? []
|
|
return tabs.some((tab) => tab.id === state.activeTabId)
|
|
}),
|
|
{ timeout: timeoutMs, message: 'No active terminal tab found for current worktree' }
|
|
)
|
|
.toBe(true)
|
|
}
|
|
|
|
/** Check if a worktree exists in the store. */
|
|
export async function worktreeExists(page: Page, name: string): Promise<boolean> {
|
|
return page.evaluate((name) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return false
|
|
}
|
|
|
|
const state = store.getState()
|
|
const allWorktrees = Object.values(state.worktreesByRepo).flat()
|
|
return allWorktrees.some(
|
|
(worktree) => worktree.displayName === name || worktree.path.endsWith(`/${name}`)
|
|
)
|
|
}, name)
|
|
}
|