Files
orca/tests/e2e/linear-issue-view-persistence.spec.ts
ce20a109da Persist the Linear issue list view and per-workspace filters (#12710)
* Persist the Linear issue list view and per-workspace filters

Layout, grouping, ordering, columns, and attribute filters survive a restart.
Facet ids are workspace-scoped, so filters are kept per Linear workspace and the
active filter is *derived* from the selected workspace rather than reset by an
effect on switch — no ordering race can apply workspace A's facets to B, and an
unresolved or cross-workspace selection reads as unfiltered without erasing
anything.

A single shared catalog backs the renderer state, `TaskResumeState`, and the
strict `ui.set` schema, so a new view option cannot leave paired web/mobile/relay
clients rejecting the whole payload. Persisted values are normalized as untrusted
input: a corrupt preference or a single bad workspace entry is dropped without
taking the rest of the resume state with it.

Deriving the filter also removed the guard that used to make three neighbouring
behaviours safe, so they are re-scoped here:

- The primary-team facet reset now fires only on an in-workspace team change.
  A workspace switch also changes the primary team, and clearing there wiped the
  filter that had just been restored for the workspace being switched *to*.
- The list-read force check no longer fires on the session's first read, so a
  restored filter serves warm cache instead of forcing a network round trip
  behind a blocking spinner on every cold start.
- The filter dropdown derives "no single workspace" from `workspaceId` alone.
  With an unresolved workspace it previously rendered the statically populated
  priority section, whose clicks now have nowhere to be stored.

* Harden Linear view persistence against the failures review surfaced

Five issues, each found by a reviewer and reproduced before fixing:

- The filter dropdown's prune effect only ran when the user opened the popover,
  because the filter was always empty at startup. Restoration makes it run on
  mount, where `availableTeams` may still be the issue-scraped fallback rather
  than the real fetch. Metadata complete for a *partial* team set passes every
  R12 guard, so it pruned facets belonging to teams it simply hadn't seen — and
  the write persisted, deleting them permanently. Gated on `teamsSettled`.

- `canonicalize` dedupes but enforces none of the transport bounds; only the
  throwing parser does. So `serialize` could emit a 101-label filter that the
  strict `ui.set` schema rejects, which drops the WHOLE taskResumeState — github,
  jira and linear query included — on every subsequent write, since the renderer
  resends the merged object each time. Added `boundLinearIssueAttributeFilter`
  and a round-trip test built from serializer output rather than a literal, which
  is the only kind that can catch renderer/schema drift.

- `linearIssueView` now carries `.catch(undefined)`: value tolerance stops at the
  top level, so any future instance of the above is a cosmetic reset of the view
  instead of silent loss of every other resume field.

- A workspace switch forced an uncached list read in both directions. The switch
  is a later observation, so the null-baseline fix didn't cover it; the cache is
  already workspace-keyed, making the force pure cost.

- Recency for the 20-workspace cap came from object key order, which is wrong
  twice: re-filtering an existing workspace left it at the head (first evicted,
  though just used), and an array-index-like key enumerates first regardless of
  insertion, so a write could evict the very entry it added. Recency is now an
  explicit ordered key list.

Also adds the nested parity assertion — the top-level one compares only
TaskResumeState's own keys, so a field added to LinearIssueViewResumeState stayed
invisible to it, which is exactly what `.strict()` rejects.

The wiring test was blind: deleting the hydration guard outright left all four
assertions green. The gate is now `shouldPersistLinearIssueView`, unit-tested
directly, and the file is renamed to the repo's `*-boundary.test.ts` convention
with an assertion that fails on that mutation.

* Log discarded Linear views and fix empty-filter serialization

- Schema now logs when linearIssueView is discarded, making validation failures visible
- Fixed serialization: filters that become empty after bounding are now omitted
- Added AssertNoExtraKeys type check for bidirectional schema/type parity
- Refactored view option catalogs to use canonical constants, preventing UI/schema drift

* Remove workspace persistence limits and LRU eviction

Stop capping persisted Linear workspace filters at 20 and evicting
least-recently-used workspaces. Simplify persistence to store all
workspace filters, gate persistence only on resume state application,
and remove tests that pinned implementation details. Users can now
persist filters for all their workspaces without arbitrary limits.

* add test for linear persistence

* Improve Linear filter test clarity and fix e2e overlay dismissal for CI

- Convert parameterized filter-pruning test to sequential assertions
- Fix dismissOverlayChrome to toggle overlay triggers instead of
  force-clicking inert page elements in headless CI

* Prevent TaskPage from stealing Escape from Radix menus

- Add check to detect open Radix dropdown menus and popovers; return
  early from Escape handler to respect their capture-phase ownership
- Update overlay dismissal in e2e tests to use keyboard.press('Escape'),
  now that TaskPage no longer interferes

* The capture-phase Escape guard in TaskPage bailed out for open dropdown menus and popovers, but an open Radix Select matches none of those selectors: the shared SelectContent wrapper (src/renderer/src/components/ui/select.tsx:60) renders data-slot="select-content" and Radix gives its content role="listbox", not role="menu". So with a select open, the window-level capture handler ran first, called preventDefault() and closeTaskPage() — closing the whole task page instead of just the select. Added [data-slot="select-content"] to the guard, as suggested. I did not add [role="listbox"]; the reviewer explicitly notes it's too broad, and the data-slot selector covers every select rendered through the shared wrapper.

---------

Co-authored-by: m4air <m4air@MacBook-Air.localdomain>
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-08-07 20:41:59 -07:00

552 lines
18 KiB
TypeScript

/**
* Linear issue list view persistence.
*
* Layout (list/board), grouping, ordering, and per-workspace attribute filters
* ride `taskResumeState.linearIssueView` through ui.set and rehydrate on TaskPage
* mount. Unit tests cover serialize/sanitize; these specs prove the user-visible
* round-trip with a mocked Linear IPC backend.
*/
import { existsSync, readFileSync } from 'node:fs'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
import { getStoreState, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { TEST_REPO_PATH_FILE } from './global-setup'
const WORKSPACE_A = {
id: 'linear-workspace-a',
displayName: 'Linear E2E User A',
email: 'linear-e2e-a@example.test',
organizationId: 'linear-org-a',
organizationName: 'Alpha Workspace'
} as const
const WORKSPACE_B = {
id: 'linear-workspace-b',
displayName: 'Linear E2E User B',
email: 'linear-e2e-b@example.test',
organizationId: 'linear-org-b',
organizationName: 'Beta Workspace'
} as const
const TEAM_A = {
id: 'linear-team-a',
name: 'Engineering',
key: 'ENG',
workspaceId: WORKSPACE_A.id,
workspaceName: WORKSPACE_A.organizationName
} as const
const TEAM_B = {
id: 'linear-team-b',
name: 'Product',
key: 'PROD',
workspaceId: WORKSPACE_B.id,
workspaceName: WORKSPACE_B.organizationName
} as const
const STATE_A = {
id: 'linear-state-a',
name: 'In Review',
type: 'started',
color: '#888888',
position: 1
} as const
const STATE_B = {
id: 'linear-state-b',
name: 'Todo',
type: 'unstarted',
color: '#666666',
position: 0
} as const
const ISSUE_A = {
id: 'linear-issue-a',
workspaceId: WORKSPACE_A.id,
identifier: 'ENG-100',
title: 'Persist Alpha view prefs',
url: 'https://linear.example.test/ENG-100',
state: { name: STATE_A.name, type: STATE_A.type, color: STATE_A.color },
team: { id: TEAM_A.id, name: TEAM_A.name, key: TEAM_A.key },
labels: [],
labelIds: [],
priority: 2,
updatedAt: '2026-08-04T18:00:00.000Z'
} as const
const ISSUE_B = {
id: 'linear-issue-b',
workspaceId: WORKSPACE_B.id,
identifier: 'PROD-200',
title: 'Persist Beta view prefs',
url: 'https://linear.example.test/PROD-200',
state: { name: STATE_B.name, type: STATE_B.type, color: STATE_B.color },
team: { id: TEAM_B.id, name: TEAM_B.name, key: TEAM_B.key },
labels: [],
labelIds: [],
priority: 4,
updatedAt: '2026-08-04T19:00:00.000Z'
} as const
type LinearIssueViewResume = {
viewMode?: string
groupBy?: string
orderBy?: string
displayProperties?: string[]
teamPropertyTouched?: boolean
filtersByWorkspaceId?: Record<
string,
{
stateIds?: string[]
priorities?: number[]
assignee?: unknown
labelIds?: string[]
}
>
}
const FIXTURE = {
workspaces: [WORKSPACE_A, WORKSPACE_B],
teams: [TEAM_A, TEAM_B],
statesByTeamId: {
[TEAM_A.id]: [STATE_A],
[TEAM_B.id]: [STATE_B]
},
issues: [ISSUE_A, ISSUE_B]
} as const
async function installLinearPersistenceBackend(
electronApp: ElectronApplication,
options?: { multiWorkspace?: boolean }
): Promise<void> {
const multiWorkspace = options?.multiWorkspace === true
await electronApp.evaluate(
({ ipcMain }, payload) => {
const workspaces = payload.multiWorkspace
? payload.fixture.workspaces
: [payload.fixture.workspaces[0]]
let activeWorkspaceId: string = workspaces[0].id
const status = () => ({
connected: true,
viewer: workspaces[0],
workspaces,
activeWorkspaceId,
selectedWorkspaceId: activeWorkspaceId
})
const teamsFor = (workspaceId: string | undefined) => {
if (!workspaceId || workspaceId === 'all') {
return payload.fixture.teams.filter((team) =>
workspaces.some((workspace) => workspace.id === team.workspaceId)
)
}
return payload.fixture.teams.filter((team) => team.workspaceId === workspaceId)
}
const issuesFor = (workspaceId: string | undefined) => {
if (!workspaceId || workspaceId === 'all') {
return payload.fixture.issues.filter((issue) =>
workspaces.some((workspace) => workspace.id === issue.workspaceId)
)
}
return payload.fixture.issues.filter((issue) => issue.workspaceId === workspaceId)
}
ipcMain.removeHandler('linear:status')
ipcMain.handle('linear:status', async () => status())
ipcMain.removeHandler('linear:selectWorkspace')
ipcMain.handle(
'linear:selectWorkspace',
async (_event, args: { workspaceId?: string } | undefined) => {
const next = args?.workspaceId
if (typeof next === 'string' && next.trim()) {
if (next === 'all' || workspaces.some((workspace) => workspace.id === next)) {
activeWorkspaceId = next
}
}
return status()
}
)
ipcMain.removeHandler('linear:listTeams')
ipcMain.handle(
'linear:listTeams',
async (_event, args: { workspaceId?: string } | undefined) =>
teamsFor(args?.workspaceId ?? activeWorkspaceId)
)
ipcMain.removeHandler('linear:listIssues')
ipcMain.handle(
'linear:listIssues',
async (_event, args: { workspaceId?: string } | undefined) => ({
items: issuesFor(args?.workspaceId ?? activeWorkspaceId),
hasMore: false
})
)
ipcMain.removeHandler('linear:teamStates')
ipcMain.handle('linear:teamStates', async (_event, args: { teamId?: string } | undefined) => {
const teamId = args?.teamId
if (!teamId) {
return []
}
return (payload.fixture.statesByTeamId as Record<string, (typeof STATE_A)[]>)[teamId] ?? []
})
ipcMain.removeHandler('linear:teamLabels')
ipcMain.handle('linear:teamLabels', async () => [])
ipcMain.removeHandler('linear:teamMembers')
ipcMain.handle('linear:teamMembers', async () => [])
},
{ fixture: FIXTURE, multiWorkspace }
)
}
async function openLinearTasks(page: Page): Promise<void> {
await page.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
await store.getState().checkLinearConnection(true)
store.getState().openTaskPage({ taskSource: 'linear' })
})
}
async function closeTasksPage(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
// Why: store close is locale-stable; the Close tasks label is localized.
store.getState().closeTaskPage()
})
await expect
.poll(async () => getStoreState<string>(page, 'activeView'), { timeout: 5_000 })
.not.toBe('tasks')
}
async function waitForLinearIssuesChrome(page: Page, issueTitle: string): Promise<void> {
await expect
.poll(async () => getStoreState<string>(page, 'activeView'), { timeout: 10_000 })
.toBe('tasks')
await expect(page.getByRole('button', { name: 'Filters', exact: true })).toBeVisible({
timeout: 15_000
})
await expect(page.getByText(issueTitle, { exact: true })).toBeVisible({ timeout: 15_000 })
}
async function openViewMenu(page: Page): Promise<void> {
const viewButton = page.getByRole('button', { name: 'View', exact: true })
await expect(viewButton).toBeVisible()
if ((await page.getByRole('menuitemradio', { name: 'Board' }).count()) > 0) {
return
}
await viewButton.click()
await expect(page.getByRole('menuitemradio', { name: 'Board' })).toBeVisible()
}
async function dismissOverlayChrome(page: Page): Promise<void> {
// Why: open Radix menus aria-hide/inert page chrome so later getByRole for
// Filters/chips fails. Outside force-clicks on inert chrome do not close modal
// menus in headless Electron, and the open portal covers the View trigger.
// Esc closes the menu/popover (TaskPage leaves Esc alone while those are open).
if (
(await page.getByRole('menuitemradio', { name: 'Board' }).count()) > 0 ||
(await page.locator('[data-slot="popover-content"]').count()) > 0
) {
await page.keyboard.press('Escape')
}
await expect(page.getByRole('menuitemradio', { name: 'Board' })).toHaveCount(0)
await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0)
}
async function selectViewMenuRadio(page: Page, name: string): Promise<void> {
await openViewMenu(page)
const item = page.getByRole('menuitemradio', { name })
await expect(item).toBeVisible()
// Why: Radix menus can briefly report "not stable" while the issues list reflows.
await item.click({ force: true })
}
async function setLinearViewPreferences(
page: Page,
options: { viewMode: 'List' | 'Board'; groupBy: string; orderBy: string }
): Promise<void> {
// Why: grouping/ordering first, then Board last — Board remounts the issue
// surface and would detach an open menu if applied earlier.
await selectViewMenuRadio(page, options.groupBy)
await selectViewMenuRadio(page, options.orderBy)
await selectViewMenuRadio(page, options.viewMode)
await dismissOverlayChrome(page)
// Toolbar List/Board toggle is md+ only; prove mode via the View menu radios.
await openViewMenu(page)
await expect(page.getByRole('menuitemradio', { name: options.viewMode })).toHaveAttribute(
'aria-checked',
'true'
)
await expect(page.getByRole('menuitemradio', { name: options.groupBy })).toHaveAttribute(
'aria-checked',
'true'
)
await expect(page.getByRole('menuitemradio', { name: options.orderBy })).toHaveAttribute(
'aria-checked',
'true'
)
await dismissOverlayChrome(page)
}
async function applyStatusFilter(page: Page, statusName: string): Promise<void> {
await dismissOverlayChrome(page)
const filtersButton = page.getByRole('button', { name: 'Filters', exact: true })
await filtersButton.click()
const popover = page.locator('[data-slot="popover-content"]')
await popover.getByRole('button', { name: 'Status', exact: true }).click()
await popover.getByText(statusName, { exact: true }).click()
await filtersButton.click()
await expect(popover).toHaveCount(0)
}
async function applyPriorityFilter(page: Page, priorityLabel: string): Promise<void> {
const filtersButton = page.getByRole('button', { name: 'Filters', exact: true })
await filtersButton.click()
const popover = page.locator('[data-slot="popover-content"]')
await popover.getByRole('button', { name: 'Priority', exact: true }).click()
await popover.getByText(priorityLabel, { exact: true }).click()
await filtersButton.click()
await expect(popover).toHaveCount(0)
}
async function waitForLinearIssueViewPersisted(
page: Page,
predicate: (view: LinearIssueViewResume | undefined) => boolean
): Promise<void> {
await expect
.poll(
async () => {
const resume = await getStoreState<{ linearIssueView?: LinearIssueViewResume } | undefined>(
page,
'taskResumeState'
)
return predicate(resume?.linearIssueView) ? 'ready' : 'pending'
},
{
timeout: 10_000,
message: 'Linear issue view preferences did not persist to taskResumeState'
}
)
.toBe('ready')
}
async function expectRestoredLinearView(page: Page): Promise<void> {
await openViewMenu(page)
await expect(page.getByRole('menuitemradio', { name: 'Board' })).toHaveAttribute(
'aria-checked',
'true'
)
await expect(page.getByRole('menuitemradio', { name: 'Status' })).toHaveAttribute(
'aria-checked',
'true'
)
await expect(page.getByRole('menuitemradio', { name: 'Updated' })).toHaveAttribute(
'aria-checked',
'true'
)
await dismissOverlayChrome(page)
// Board surface renders status sections; flat list shows a Key column header.
await expect(page.getByText('Key', { exact: true })).toHaveCount(0)
}
async function switchLinearWorkspace(page: Page, organizationName: string): Promise<void> {
// Why: multi-workspace trigger label is "Org / All teams".
const trigger = page
.locator('button[role="combobox"]')
.filter({ hasText: /All teams|Alpha|Beta|All workspaces/ })
.first()
await expect(trigger).toBeVisible({ timeout: 10_000 })
await trigger.click()
const popover = page.locator('[data-slot="popover-content"]')
await popover.getByText(organizationName, { exact: true }).click()
await expect(popover).toHaveCount(0)
}
function seededRepoPathOrSkip(): string {
const repoPath = existsSync(TEST_REPO_PATH_FILE)
? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
: ''
test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo')
return repoPath
}
test.describe('Linear issue view persistence', () => {
test('preserves view mode, grouping, ordering, and filters across a tasks remount', async ({
electronApp,
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await installLinearPersistenceBackend(electronApp)
await openLinearTasks(orcaPage)
await waitForLinearIssuesChrome(orcaPage, ISSUE_A.title)
await setLinearViewPreferences(orcaPage, {
viewMode: 'Board',
groupBy: 'Status',
orderBy: 'Updated'
})
await applyStatusFilter(orcaPage, STATE_A.name)
await waitForLinearIssueViewPersisted(orcaPage, (view) => {
if (
!view ||
view.viewMode !== 'board' ||
view.groupBy !== 'status' ||
view.orderBy !== 'updated'
) {
return false
}
const filter = view.filtersByWorkspaceId?.[WORKSPACE_A.id]
return Boolean(filter?.stateIds?.includes(STATE_A.id))
})
// User-visible before remount.
await expectRestoredLinearView(orcaPage)
const statusChip = orcaPage.getByRole('button', { name: 'Remove Status filter' }).locator('..')
await expect(statusChip).toContainText(STATE_A.name)
await closeTasksPage(orcaPage)
await openLinearTasks(orcaPage)
await waitForLinearIssuesChrome(orcaPage, ISSUE_A.title)
await expectRestoredLinearView(orcaPage)
await expect(
orcaPage.getByRole('button', { name: 'Remove Status filter' }).locator('..')
).toContainText(STATE_A.name)
// Board surface, not the flat list column header.
await expect(orcaPage.getByText(STATE_A.name, { exact: true }).first()).toBeVisible()
})
test('keeps attribute filters scoped per Linear workspace', async ({ electronApp, orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await installLinearPersistenceBackend(electronApp, { multiWorkspace: true })
await openLinearTasks(orcaPage)
await waitForLinearIssuesChrome(orcaPage, ISSUE_A.title)
await applyPriorityFilter(orcaPage, 'High')
await expect(
orcaPage.getByRole('button', { name: 'Remove Priority filter' }).locator('..')
).toContainText('High')
await waitForLinearIssueViewPersisted(orcaPage, (view) => {
const filter = view?.filtersByWorkspaceId?.[WORKSPACE_A.id]
return Boolean(filter?.priorities?.includes(2))
})
await switchLinearWorkspace(orcaPage, WORKSPACE_B.organizationName)
await waitForLinearIssuesChrome(orcaPage, ISSUE_B.title)
// Workspace B starts unfiltered — Alpha's High must not leak.
await expect(orcaPage.getByRole('button', { name: 'Remove Priority filter' })).toHaveCount(0)
await applyPriorityFilter(orcaPage, 'Low')
await expect(
orcaPage.getByRole('button', { name: 'Remove Priority filter' }).locator('..')
).toContainText('Low')
await waitForLinearIssueViewPersisted(orcaPage, (view) => {
const a = view?.filtersByWorkspaceId?.[WORKSPACE_A.id]
const b = view?.filtersByWorkspaceId?.[WORKSPACE_B.id]
return Boolean(a?.priorities?.includes(2) && b?.priorities?.includes(4))
})
await switchLinearWorkspace(orcaPage, WORKSPACE_A.organizationName)
await waitForLinearIssuesChrome(orcaPage, ISSUE_A.title)
await expect(
orcaPage.getByRole('button', { name: 'Remove Priority filter' }).locator('..')
).toContainText('High')
await expect(
orcaPage.getByRole('button', { name: 'Remove Priority filter' }).locator('..')
).not.toContainText('Low')
await switchLinearWorkspace(orcaPage, WORKSPACE_B.organizationName)
await waitForLinearIssuesChrome(orcaPage, ISSUE_B.title)
await expect(
orcaPage.getByRole('button', { name: 'Remove Priority filter' }).locator('..')
).toContainText('Low')
})
})
test('restores Linear view preferences after an app restart', async (// oxlint-disable-next-line no-empty-pattern -- Playwright fixture opt-out
{}, testInfo) => {
test.setTimeout(300_000)
const repoPath = seededRepoPathOrSkip()
const session = createRestartSession(testInfo)
let firstApp: ElectronApplication | null = null
let secondApp: ElectronApplication | null = null
try {
const first = await session.launch()
firstApp = first.app
await waitForSessionReady(first.page)
await attachRepoAndOpenTerminal(first.page, repoPath)
await installLinearPersistenceBackend(firstApp)
await openLinearTasks(first.page)
await waitForLinearIssuesChrome(first.page, ISSUE_A.title)
await setLinearViewPreferences(first.page, {
viewMode: 'Board',
groupBy: 'Status',
orderBy: 'Updated'
})
await applyStatusFilter(first.page, STATE_A.name)
await waitForLinearIssueViewPersisted(first.page, (view) => {
if (
!view ||
view.viewMode !== 'board' ||
view.groupBy !== 'status' ||
view.orderBy !== 'updated'
) {
return false
}
return Boolean(view.filtersByWorkspaceId?.[WORKSPACE_A.id]?.stateIds?.includes(STATE_A.id))
})
await expectRestoredLinearView(first.page)
await session.close(firstApp)
firstApp = null
const second = await session.launch()
secondApp = second.app
await waitForSessionReady(second.page)
// Why: IPC mocks die with the process; reinstall before reopening Linear.
await installLinearPersistenceBackend(secondApp)
await openLinearTasks(second.page)
await waitForLinearIssuesChrome(second.page, ISSUE_A.title)
await expectRestoredLinearView(second.page)
await expect(
second.page.getByRole('button', { name: 'Remove Status filter' }).locator('..')
).toContainText(STATE_A.name)
} finally {
for (const app of [secondApp, firstApp]) {
if (!app) {
continue
}
try {
await session.close(app)
} catch {
// best-effort cleanup
}
}
await session.dispose()
}
})