mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
* Add board layout support for GitHub project views Board-layout views render as a kanban. Columns come from the view's verticalGroupByFields (the host retries without the selection on older GHES schemas and the renderer falls back to the Status field), with one column per single-select option in option order — empty ones included — plus a trailing no-value column whose drop clears the field. Card drops reuse the table's field mutation path, committed from a document-level capture listener because the preload's native-drop bridge stops drop events before React's root ever sees them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(github-projects): harden board capability probe and drop lifecycle Review follow-up. The verticalGroupByFields capability probe now matches parsed GraphQL error messages instead of substring-scanning the whole response body — partial-error responses echo the field name as a data key on healthy schemas, so one SAML/FORBIDDEN partial error could permanently degrade github.com boards for the session. Covered by new project-view-config tests per the capability-cache testing contract. Also: only single-select/iteration vertical fields shape columns (a drifted field kind no longer yields a clear-on-drop no-value column), optimistic patches resolve the column field through the board/group config, drag cleanup moved to a document-level dragend listener, the column dot follows dark mode via the chip CSS variables, the supported- layout allowlist is a single shared predicate, and the board's edit handler is stable so its drop listener stops re-registering per render. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(github-projects): serialize board edits and verify rendered drops * fix(github-projects): preserve refresh baselines and order edits across views * fix(github-projects): accept source settings projection in cache scope * fix(github-projects): distinguish view switches from refreshed field baselines * test(github-projects): type board IPC recordings and verify clear request --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Neil <neil@stably.ai>
272 lines
9.4 KiB
TypeScript
272 lines
9.4 KiB
TypeScript
import { test, expect } from './helpers/orca-app'
|
|
import { waitForSessionReady } from './helpers/store'
|
|
import { githubProjectIdentityKey } from '../../src/shared/github/project-identity'
|
|
import type { GitHubProjectTable } from '../../src/shared/github/project-types'
|
|
import type {
|
|
ClearProjectItemFieldArgs,
|
|
UpdateProjectItemFieldArgs
|
|
} from '../../src/shared/github/project-request-types'
|
|
|
|
type BoardFieldCall = UpdateProjectItemFieldArgs | ClearProjectItemFieldArgs
|
|
|
|
declare global {
|
|
var __githubProjectBoardCalls: BoardFieldCall[] | undefined
|
|
}
|
|
|
|
const project = { owner: 'board-proof', ownerType: 'user', number: 1, host: 'github.com' } as const
|
|
const board: GitHubProjectTable = {
|
|
project: {
|
|
...project,
|
|
id: 'project',
|
|
title: 'Board verification',
|
|
url: 'https://github.com/users/board-proof/projects/1'
|
|
},
|
|
selectedView: {
|
|
id: 'view',
|
|
number: 1,
|
|
name: 'Board',
|
|
layout: 'BOARD_LAYOUT',
|
|
filter: '',
|
|
fields: [],
|
|
groupByFields: [],
|
|
sortByFields: [],
|
|
verticalGroupByFields: [
|
|
{
|
|
id: 'status',
|
|
name: 'Status',
|
|
kind: 'single-select',
|
|
dataType: 'SINGLE_SELECT',
|
|
options: [
|
|
{ id: 'todo', name: 'Todo', color: 'GREEN' },
|
|
{ id: 'doing', name: 'In progress', color: 'YELLOW' },
|
|
{ id: 'done', name: 'Done', color: 'PURPLE' }
|
|
]
|
|
}
|
|
]
|
|
},
|
|
rows: [
|
|
{
|
|
id: 'row',
|
|
itemType: 'ISSUE',
|
|
updatedAt: '',
|
|
position: 0,
|
|
content: {
|
|
title: 'Verify a real board drop through the preload bridge',
|
|
number: 7,
|
|
body: null,
|
|
url: '',
|
|
state: 'OPEN',
|
|
stateReason: null,
|
|
isDraft: null,
|
|
repository: 'board-proof/repo',
|
|
labels: [],
|
|
parentIssue: null,
|
|
issueType: null,
|
|
assignees: [{ login: 'tester', name: null, avatarUrl: null }]
|
|
},
|
|
fieldValuesByFieldId: {
|
|
status: {
|
|
kind: 'single-select',
|
|
fieldId: 'status',
|
|
optionId: 'todo',
|
|
name: 'Todo',
|
|
color: 'GREEN'
|
|
}
|
|
}
|
|
}
|
|
],
|
|
totalCount: 1,
|
|
parentFieldDropped: false
|
|
}
|
|
|
|
test('board drops cross preload, settle optimistically, and roll back failures', async ({
|
|
orcaPage,
|
|
electronApp,
|
|
seededRepoPath
|
|
}, testInfo) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await electronApp.evaluate(({ ipcMain }, data) => {
|
|
const responses = {
|
|
'gh:repoSlug': () => ({ owner: 'board-proof', repo: 'repo' }),
|
|
'gh:listProjectViews': () => ({ ok: true, views: [data.selectedView] }),
|
|
'gh:getProjectViewTable': () => ({ ok: true, data })
|
|
}
|
|
for (const [channel, handler] of Object.entries(responses)) {
|
|
ipcMain.removeHandler(channel)
|
|
ipcMain.handle(channel, handler)
|
|
}
|
|
const calls: BoardFieldCall[] = []
|
|
globalThis.__githubProjectBoardCalls = calls
|
|
ipcMain.removeHandler('gh:updateProjectItemField')
|
|
ipcMain.handle('gh:updateProjectItemField', async (_event, args) => {
|
|
calls.push(args)
|
|
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
return { ok: true }
|
|
})
|
|
ipcMain.removeHandler('gh:clearProjectItemField')
|
|
ipcMain.handle('gh:clearProjectItemField', async (_event, args) => {
|
|
calls.push(args)
|
|
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
return { ok: false, error: { type: 'network_error', message: 'Board verification: offline' } }
|
|
})
|
|
}, board)
|
|
await orcaPage.evaluate(
|
|
({ project, identity, seededRepoPath }) => {
|
|
const store = window.__store!
|
|
const state = store.getState()
|
|
const repo = state.repos.find((candidate) => candidate.path === seededRepoPath)
|
|
if (!repo || !state.settings) {
|
|
throw new Error('Seeded repository/settings missing')
|
|
}
|
|
store.setState({
|
|
repos: state.repos.map((candidate) =>
|
|
candidate.id === repo.id
|
|
? {
|
|
...candidate,
|
|
gitRemoteIdentity: {
|
|
canonicalKey: 'github.com/board-proof/repo',
|
|
remoteName: 'origin',
|
|
remoteUrl: 'https://github.com/board-proof/repo.git'
|
|
}
|
|
}
|
|
: candidate
|
|
),
|
|
settings: {
|
|
...state.settings,
|
|
defaultTaskSource: 'github',
|
|
defaultRepoSelection: [repo.id],
|
|
githubProjects: {
|
|
pinned: [],
|
|
recent: [],
|
|
activeProject: project,
|
|
lastViewByProject: { [identity]: { viewId: 'view' } }
|
|
}
|
|
},
|
|
taskResumeState: { githubMode: 'project' }
|
|
})
|
|
},
|
|
{ project, identity: githubProjectIdentityKey(project), seededRepoPath }
|
|
)
|
|
await orcaPage.getByRole('button', { name: 'Tasks', exact: true }).click()
|
|
await orcaPage.getByRole('button', { name: 'Projects', exact: true }).click()
|
|
const todo = orcaPage.getByTestId('board-column-todo')
|
|
const done = orcaPage.getByTestId('board-column-done')
|
|
const empty = orcaPage.getByTestId('board-column-__empty__')
|
|
const title = board.rows[0].content.title
|
|
await expect(todo.getByRole('button', { name: title })).toBeVisible()
|
|
await expect(orcaPage.getByTestId('board-column-doing')).toBeVisible()
|
|
await orcaPage.evaluate(() => document.documentElement.classList.add('dark'))
|
|
await orcaPage.screenshot({ path: testInfo.outputPath('board-dark.png') })
|
|
|
|
await todo.getByRole('listitem').dragTo(done)
|
|
await expect(done.getByRole('button', { name: title })).toBeVisible()
|
|
await expect(todo.getByRole('button', { name: title })).toHaveCount(0)
|
|
await expect
|
|
.poll(() => electronApp.evaluate(() => globalThis.__githubProjectBoardCalls))
|
|
.toEqual([
|
|
{
|
|
projectId: 'project',
|
|
host: 'github.com',
|
|
itemId: 'row',
|
|
fieldId: 'status',
|
|
value: { kind: 'single-select', optionId: 'done' }
|
|
}
|
|
])
|
|
await done.getByRole('listitem').dragTo(done)
|
|
await done.getByRole('listitem').dragTo(empty)
|
|
await expect(empty.getByRole('button', { name: title })).toBeVisible()
|
|
await expect(orcaPage.getByText('Board verification: offline', { exact: true })).toBeVisible()
|
|
await expect(done.getByRole('button', { name: title })).toBeVisible()
|
|
await expect(empty.getByRole('button', { name: title })).toHaveCount(0)
|
|
await expect
|
|
.poll(() => electronApp.evaluate(() => globalThis.__githubProjectBoardCalls))
|
|
.toEqual([
|
|
{
|
|
projectId: 'project',
|
|
host: 'github.com',
|
|
itemId: 'row',
|
|
fieldId: 'status',
|
|
value: { kind: 'single-select', optionId: 'done' }
|
|
},
|
|
{ projectId: 'project', host: 'github.com', itemId: 'row', fieldId: 'status' }
|
|
])
|
|
await orcaPage.screenshot({ path: testInfo.outputPath('board-rollback.png') })
|
|
await orcaPage.evaluate(() => document.documentElement.classList.remove('dark'))
|
|
await orcaPage.screenshot({ path: testInfo.outputPath('board-light.png') })
|
|
await orcaPage.evaluate(() => {
|
|
const store = window.__store!
|
|
const entry = Object.entries(store.getState().projectViewCache).find(
|
|
([, entry]) => entry.data?.project.id === 'project'
|
|
)
|
|
if (!entry?.[1].data) {
|
|
throw new Error('Board cache missing')
|
|
}
|
|
const [key, cached] = entry
|
|
const table = cached.data!
|
|
const source = table.rows[0]
|
|
const field = table.selectedView.verticalGroupByFields?.[0]
|
|
if (field?.kind !== 'single-select') {
|
|
throw new Error('Status field missing')
|
|
}
|
|
store.setState({
|
|
projectViewCache: {
|
|
[key]: {
|
|
...cached,
|
|
data: {
|
|
...table,
|
|
selectedView: {
|
|
...table.selectedView,
|
|
verticalGroupByFields: [
|
|
{
|
|
...field,
|
|
options: [
|
|
...field.options,
|
|
...Array.from({ length: 6 }, (_, index) => ({
|
|
id: `extra-${index}`,
|
|
name: `Extra ${index}`,
|
|
color: 'BLUE'
|
|
}))
|
|
]
|
|
}
|
|
]
|
|
},
|
|
rows: [
|
|
source,
|
|
...Array.from({ length: 40 }, (_, index) => ({
|
|
...source,
|
|
id: `overflow-${index}`,
|
|
position: index + 1,
|
|
content: {
|
|
...source.content,
|
|
title: `Overflow card ${index}`,
|
|
number: index + 100
|
|
},
|
|
fieldValuesByFieldId: {
|
|
status: {
|
|
kind: 'single-select' as const,
|
|
fieldId: 'status',
|
|
optionId: 'todo',
|
|
name: 'Todo',
|
|
color: 'GREEN'
|
|
}
|
|
}
|
|
}))
|
|
],
|
|
totalCount: 41
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
const scroller = todo.locator('.overflow-y-auto')
|
|
await expect
|
|
.poll(() => scroller.evaluate((element) => element.scrollHeight > element.clientHeight))
|
|
.toBe(true)
|
|
await todo.getByRole('button', { name: 'Overflow card 39', exact: true }).scrollIntoViewIfNeeded()
|
|
await expect(todo.getByRole('button', { name: 'Overflow card 39', exact: true })).toBeVisible()
|
|
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
|
await orcaPage.getByTestId('board-column-extra-5').scrollIntoViewIfNeeded()
|
|
await expect(orcaPage.getByTestId('board-column-extra-5')).toBeVisible()
|
|
await orcaPage.screenshot({ path: testInfo.outputPath('board-scroll.png') })
|
|
})
|