test: trim project view inventories and duplicate dialog cases (#38399)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-04 22:07:26 -08:00
committed by GitHub
co-authored by OrcaWin
parent f64d9c80a8
commit 319a01aacd
11 changed files with 9 additions and 1612 deletions
-1
View File
@@ -7227,7 +7227,6 @@
"src/renderer/src/components/github-item-dialog/load-item-details/use-github-task-refresh.test.tsx": 1070,
"src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.task-record.test.tsx": 1391,
"src/renderer/src/components/github-link-copy-state.test.ts": 41,
"src/renderer/src/components/github-pr-merge-state.test.ts": 243,
"src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts": 479,
"src/renderer/src/components/github-project/ProjectBoard.test.tsx": 988,
"src/renderer/src/components/github-project/ProjectRoadmap.test.tsx": 2369,
@@ -13,7 +13,7 @@ import type { AppState } from '@/store/types'
import type { Repo } from '../../../../../shared/repo-types'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import { buildTaskSourceContextFromRepo } from '../../../../../shared/task-source-context'
import { useGitHubTaskItems } from '@/hooks/useGitHubTaskItems'
import { useTaskPageGitHubWorkItemMutation } from '@/hooks/useTaskPageGitHubWorkItemMutation'
import { parseTaskQuery } from '../../../../../shared/task-query'
import type { GitHubProjectTable } from '../../../../../shared/github/project-types'
@@ -220,10 +220,7 @@ function renderDialog() {
)
})
}
function TasksRow({ rows }: { rows: GitHubWorkItem[] }) {
const [projected] = useGitHubTaskItems(rows)
return <output data-field="tasks">{projected.assignees?.map((u) => u.login).join(',')}</output>
}
const text = (field: string) => node.querySelector(`[data-field="${field}"]`)?.textContent
async function click(label: string) {
const button = [...node.querySelectorAll('button')].find((b) => b.textContent === label)
@@ -262,38 +259,6 @@ describe('issue dialog edits reach the Tasks row through the shared record', ()
node.remove()
})
it('assigns in the dialog, keeps it through a stale search, and starts with the item', async () => {
const rows = answered(await mocks.store!.getState().fetchWorkItems(repo.id, repo.path, 20, ''))
await act(async () => {
root.render(
<>
<TasksRow rows={[...rows]} />
<GitHubItemDialog
workItem={rows[0]}
repoPath={repo.path}
repoId={repo.id}
sourceContext={source}
onUse={mocks.onUse}
onClose={() => {}}
/>
</>
)
})
await click('Assign me')
await act(async () => {
await Promise.resolve()
})
expect(mocks.update).toHaveBeenCalledOnce()
expect(text('tasks')).toBe('alice')
expect(text('dialog')).toBe('alice')
const stale = answered(
await mocks.store!.getState().fetchWorkItems(repo.id, repo.path, 20, '', { force: true })
)
expect(stale[0].assignees?.map((u) => u.login)).toEqual(['alice'])
await click('Start')
expect(mocks.onUse).toHaveBeenCalledWith(expect.objectContaining({ id: row.id }))
})
it.each([
['tasks', false],
['tasks', true],
@@ -1,288 +0,0 @@
import { summarizeProviderChecks } from '../../../shared/source-control/checks/provider-check-summary'
import { describe, expect, it } from 'vitest'
import type { GitHubPRMergeStateInput } from '../../../shared/github/pull-request-merge-state'
import { presentGitHubPRMergeState } from './github-pr-merge-state'
function pr(overrides: Partial<GitHubPRMergeStateInput> = {}): GitHubPRMergeStateInput {
return {
state: 'open',
mergeable: 'MERGEABLE',
mergeStateStatus: 'CLEAN',
checksSummary: { state: 'success', total: 1, passed: 1, failed: 0, pending: 0, neutral: 0 },
autoMergeAllowed: true,
...overrides
}
}
describe('presentGitHubPRMergeState', () => {
it('blocks direct merge when approval is required or changes are requested', () => {
expect(presentGitHubPRMergeState(pr({ reviewDecision: 'REVIEW_REQUIRED' }))).toMatchObject({
label: 'Approval required',
directMergeAvailable: false
})
expect(presentGitHubPRMergeState(pr({ reviewDecision: 'CHANGES_REQUESTED' }))).toMatchObject({
label: 'Changes requested',
directMergeAvailable: false
})
})
it('uses the merge-queue label for auto-merge when a queue is required', () => {
expect(presentGitHubPRMergeState(pr({ mergeQueueRequired: true }))).toMatchObject({
label: 'Merge when ready',
directMergeAvailable: false,
autoMergeAction: { kind: 'enable', label: 'Merge when ready' }
})
})
it('blocks conflicts on merge-queue branches', () => {
expect(
presentGitHubPRMergeState(
pr({ mergeQueueRequired: true, mergeable: 'UNKNOWN', mergeStateStatus: 'DIRTY' })
)
).toMatchObject({
label: 'Conflicts',
directMergeAvailable: false,
autoMergeAction: null
})
})
it('offers enable auto-merge for open PRs that are waiting on requirements', () => {
expect(presentGitHubPRMergeState(pr({ mergeQueueRequired: null })).autoMergeAction).toBeNull()
expect(
presentGitHubPRMergeState(
pr({ reviewDecision: 'REVIEW_REQUIRED', mergeStateStatus: 'BLOCKED' })
).autoMergeAction
).toMatchObject({ kind: 'enable', label: 'Enable auto-merge' })
expect(
presentGitHubPRMergeState(
pr({
mergeStateStatus: 'BLOCKED',
checksSummary: {
state: 'pending',
total: 1,
passed: 0,
failed: 0,
pending: 1,
neutral: 0
}
})
).autoMergeAction
).toMatchObject({ kind: 'enable', label: 'Enable auto-merge' })
})
it('does not offer enable auto-merge when only optional checks are pending', () => {
expect(
presentGitHubPRMergeState(
pr({
checksSummary: {
state: 'pending',
total: 1,
passed: 0,
failed: 0,
pending: 1,
neutral: 0
}
})
)
).toMatchObject({
label: 'Checks pending',
directMergeAvailable: true,
autoMergeAction: null
})
})
it.each(['UNSTABLE', 'HAS_HOOKS'])(
'allows a direct merge for %s even before conflict detection finishes',
(mergeStateStatus) => {
expect(
presentGitHubPRMergeState(
pr({
mergeable: 'UNKNOWN',
mergeStateStatus,
checksSummary: {
state: 'failure',
total: 1,
passed: 0,
failed: 1,
pending: 0,
neutral: 0
}
})
)
).toMatchObject({
label: 'Checks failed',
directMergeAvailable: true,
autoMergeAction: null
})
}
)
it('does not offer enable auto-merge on conflicting PRs (GitHub would reject it)', () => {
expect(
presentGitHubPRMergeState(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }))
.autoMergeAction
).toBeNull()
expect(
presentGitHubPRMergeState(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'DIRTY' }))
.autoMergeAction
).toBeNull()
})
it('does not offer enable auto-merge when GitHub reports the repository disallows it', () => {
expect(
presentGitHubPRMergeState(
pr({ autoMergeAllowed: false, mergeable: 'UNKNOWN', mergeStateStatus: 'BLOCKED' })
).autoMergeAction
).toBeNull()
expect(
presentGitHubPRMergeState(
pr({ autoMergeAllowed: undefined, mergeable: 'UNKNOWN', mergeStateStatus: 'BLOCKED' })
).autoMergeAction
).toMatchObject({ kind: 'enable', label: 'Enable auto-merge' })
})
it('offers disable auto-merge when GitHub reports auto-merge is already enabled', () => {
expect(presentGitHubPRMergeState(pr({ autoMergeEnabled: true }))).toMatchObject({
autoMergeAction: { kind: 'disable', label: 'Disable auto-merge' }
})
})
it('suppresses enable auto-merge but keeps disable when direct merge is available', () => {
// GitHub rejects enabling auto-merge on directly mergeable PRs with a clean-status error.
expect(
presentGitHubPRMergeState({
state: 'open',
checksSummary: { state: 'success', total: 1, passed: 1, failed: 0, pending: 0, neutral: 0 }
})
).toMatchObject({ label: 'Checks passed', directMergeAvailable: true, autoMergeAction: null })
expect(presentGitHubPRMergeState(pr())).toMatchObject({
directMergeAvailable: true,
autoMergeAction: null
})
expect(presentGitHubPRMergeState(pr({ autoMergeEnabled: true })).autoMergeAction).toMatchObject(
{ kind: 'disable' }
)
})
it('blocks conflicts and behind branches, but not optional aggregate check failures', () => {
expect(
presentGitHubPRMergeState(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }))
).toMatchObject({ label: 'Conflicts', directMergeAvailable: false })
expect(
presentGitHubPRMergeState(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'DIRTY' }))
).toMatchObject({ label: 'Conflicts', directMergeAvailable: false })
expect(presentGitHubPRMergeState(pr({ mergeStateStatus: 'BEHIND' }))).toMatchObject({
label: 'Behind',
directMergeAvailable: false
})
expect(
presentGitHubPRMergeState(
pr({
checksSummary: {
state: 'pending',
total: 1,
passed: 0,
failed: 0,
pending: 1,
neutral: 0
}
})
)
).toMatchObject({ label: 'Checks pending', directMergeAvailable: true })
expect(
presentGitHubPRMergeState(
pr({
checksSummary: {
state: 'failure',
total: 1,
passed: 0,
failed: 1,
pending: 0,
neutral: 0
}
})
)
).toMatchObject({ label: 'Checks failed', directMergeAvailable: true })
expect(presentGitHubPRMergeState(pr())).toMatchObject({
label: 'Able to merge',
directMergeAvailable: true
})
})
it('labels unresolved GitHub mergeability as checking', () => {
expect(
presentGitHubPRMergeState(
pr({
mergeable: 'UNKNOWN',
mergeStateStatus: null,
checksSummary: {
state: 'pending',
total: 1,
passed: 0,
failed: 0,
pending: 1,
neutral: 0
}
})
)
).toMatchObject({
label: 'Checking',
directMergeAvailable: false
})
})
it('allows direct merge when GitHub mergeability is unavailable but checks have passed', () => {
expect(
presentGitHubPRMergeState({
state: 'open',
checksSummary: { state: 'success', total: 3, passed: 3, failed: 0, pending: 0, neutral: 0 }
})
).toMatchObject({
label: 'Checks passed',
directMergeAvailable: true
})
expect(
presentGitHubPRMergeState(
pr({
mergeable: 'UNKNOWN',
mergeStateStatus: null,
checksSummary: {
state: 'success',
total: 3,
passed: 3,
failed: 0,
pending: 0,
neutral: 0
}
})
)
).toMatchObject({
label: 'Checks passed',
directMergeAvailable: true
})
})
it('suppresses auto-merge actions for non-open PR states', () => {
expect(
presentGitHubPRMergeState(pr({ state: 'closed', mergeQueueRequired: true })).autoMergeAction
).toBeNull()
expect(
presentGitHubPRMergeState(pr({ state: 'merged', autoMergeEnabled: true })).autoMergeAction
).toBeNull()
expect(
presentGitHubPRMergeState(pr({ state: 'draft', mergeQueueRequired: true })).autoMergeAction
).toBeNull()
})
it('names cancelled checks accurately without changing the provider merge permission', () => {
const checksSummary = summarizeProviderChecks([
{ status: 'completed', conclusion: 'success' },
{ status: 'completed', conclusion: 'cancelled' }
])
expect(presentGitHubPRMergeState(pr({ checksStatus: 'failure', checksSummary }))).toMatchObject(
{
label: 'Cancelled',
directMergeAvailable: true
}
)
})
})
@@ -1,12 +1,12 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ProjectRoadmap from './ProjectRoadmap'
import { i18n, loadRendererUiLanguage, setRendererPluginLanguagePacks } from '@/i18n/i18n'
import { DEFAULT_LOCALE } from '@/i18n/supported-languages'
import { pluginLanguageResourceId } from '../../../../shared/plugins/artifacts/plugin-language-pack-artifact'
import type {
GitHubProjectField,
GitHubProjectFieldValue,
@@ -32,12 +32,6 @@ const TARGET_FIELD: GitHubProjectField = {
name: 'Target date',
dataType: 'DATE'
}
const TITLE_FIELD: GitHubProjectField = {
kind: 'field',
id: 'f_title',
name: 'Title',
dataType: 'TITLE'
}
function row(id: string, title: string, values: GitHubProjectFieldValue[]): GitHubProjectRow {
const fieldValuesByFieldId: Record<string, GitHubProjectFieldValue> = {}
@@ -106,56 +100,6 @@ afterEach(async () => {
})
describe('ProjectRoadmap', () => {
it.each([
['pt-BR', 'pt-BR'],
['en-US-US', DEFAULT_LOCALE]
])('renders date ticks and bars with plugin locale %s', async (packLocale, dateLocale) => {
const packId = 'plugin:stablyai.orca-portuguese/pt-BR' as const
const resourceLanguage = pluginLanguageResourceId(packId)
setRendererPluginLanguagePacks([
{
id: packId,
resourceLanguage,
pluginKey: 'stablyai.orca-portuguese',
locale: packLocale,
catalog: { common: { cancel: 'Cancelar' } },
coverage: 100
}
])
await i18n.changeLanguage(await loadRendererUiLanguage(packId))
expect(i18n.resolvedLanguage).toBe(resourceLanguage)
render(
<ProjectRoadmap
table={table(
[TITLE_FIELD, START_FIELD, TARGET_FIELD],
[
row('scheduled', 'Scheduled', [
{ kind: 'date', fieldId: START_FIELD.id, date: '2026-09-01' },
{ kind: 'date', fieldId: TARGET_FIELD.id, date: '2026-10-15' }
])
]
)}
fallback={<div>list</div>}
/>
)
const month = new Intl.DateTimeFormat(dateLocale, {
month: 'short',
timeZone: 'UTC'
}).format(new Date(Date.UTC(2026, 8, 1)))
expect(screen.getAllByText(month, { exact: false }).length).toBeGreaterThan(0)
const dateFormat = new Intl.DateTimeFormat(dateLocale, {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC'
})
const start = dateFormat.format(new Date(Date.UTC(2026, 8, 1)))
const end = dateFormat.format(new Date(Date.UTC(2026, 9, 15)))
expect(screen.getByRole('button', { name: `Scheduled — ${start} – ${end}` })).toBeTruthy()
})
it('moves the today marker across local midnight without resetting scroll and cleans up its timer', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 8, 5, 23, 59, 59))
@@ -179,165 +123,4 @@ describe('ProjectRoadmap', () => {
unmount()
expect(vi.getTimerCount()).toBe(0)
})
it.each([false, true])(
'centers when an initially empty view gains dated rows (fields hidden: %s)',
(hidden) => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 8, 5, 12))
const fields = hidden ? [TITLE_FIELD] : [TITLE_FIELD, START_FIELD, TARGET_FIELD]
const { rerender } = render(
<ProjectRoadmap table={table(fields, [])} fallback={<div>list</div>} />
)
const populated = table(fields, [
row('one', 'Arrived', [
{ kind: 'date', fieldId: 'f_start', date: '2026-01-01' },
{ kind: 'date', fieldId: 'f_end', date: '2026-09-10' }
])
])
rerender(<ProjectRoadmap table={populated} fallback={<div>list</div>} />)
const scroller = screen.getByTestId('project-roadmap-scroller')
expect(scroller.scrollLeft).toBeGreaterThan(1000)
scroller.scrollLeft = 123
rerender(
<ProjectRoadmap
table={{ ...populated, rows: [...populated.rows] }}
fallback={<div>list</div>}
/>
)
expect(scroller.scrollLeft).toBe(123)
fireEvent.click(screen.getByRole('button', { name: 'Year' }))
expect(scroller.scrollLeft).not.toBe(123)
expect(window.localStorage.getItem('orca.githubProject.roadmapZoom')).toBe('year')
}
)
it('places a dated row on the timeline and names the fields driving it', () => {
render(
<ProjectRoadmap
table={table(
[TITLE_FIELD, START_FIELD, TARGET_FIELD],
[
row('PVTI_1', 'Ship the thing', [
{ kind: 'date', fieldId: 'f_start', date: '2026-03-02' },
{ kind: 'date', fieldId: 'f_end', date: '2026-03-20' }
])
]
)}
fallback={<div>list</div>}
/>
)
expect(screen.getByText('Placed by Start date → Target date')).toBeTruthy()
expect(screen.getByLabelText(/^Ship the thing — /)).toBeTruthy()
expect(screen.queryByText('list')).toBeNull()
})
it('keeps an undated row in place and flags it rather than hiding it', () => {
render(
<ProjectRoadmap
table={table(
[TITLE_FIELD, START_FIELD, TARGET_FIELD],
[
row('PVTI_1', 'Dated', [{ kind: 'date', fieldId: 'f_start', date: '2026-03-02' }]),
row('PVTI_2', 'Undated', [])
]
)}
fallback={<div>list</div>}
/>
)
expect(screen.getByText('No dates')).toBeTruthy()
expect(screen.getByText('1 without dates')).toBeTruthy()
expect(screen.queryByLabelText(/^Undated — /)).toBeNull()
})
it('opens the row dialog when a bar is clicked', () => {
const onOpenDialog = vi.fn()
render(
<ProjectRoadmap
table={table(
[TITLE_FIELD, START_FIELD, TARGET_FIELD],
[
row('PVTI_1', 'Ship the thing', [
{ kind: 'date', fieldId: 'f_start', date: '2026-03-02' },
{ kind: 'date', fieldId: 'f_end', date: '2026-03-20' }
])
]
)}
onOpenDialog={onOpenDialog}
fallback={<div>list</div>}
/>
)
fireEvent.click(screen.getByLabelText(/^Ship the thing — /))
expect(onOpenDialog).toHaveBeenCalledTimes(1)
expect(onOpenDialog.mock.calls[0]?.[0]).toMatchObject({ id: 'PVTI_1' })
})
it('places items from row-carried dates when the view hides its date fields', () => {
render(
<ProjectRoadmap
table={table(
[TITLE_FIELD],
[
row('PVTI_1', 'Hidden-field item', [
{ kind: 'date', fieldId: 'f_start', date: '2026-03-02', fieldName: 'Start date' },
{ kind: 'date', fieldId: 'f_end', date: '2026-03-20', fieldName: 'Target date' }
])
]
)}
fallback={<div>list</div>}
/>
)
expect(screen.getByText('Placed by Start date → Target date')).toBeTruthy()
expect(screen.getByLabelText(/^Hidden-field item — /)).toBeTruthy()
expect(screen.queryByText('list')).toBeNull()
})
it('announces restricted items by name in the bar label', () => {
const redacted: GitHubProjectRow = {
...row('PVTI_9', '', [
{ kind: 'date', fieldId: 'f_start', date: '2026-03-02' },
{ kind: 'date', fieldId: 'f_end', date: '2026-03-05' }
]),
itemType: 'REDACTED'
}
render(
<ProjectRoadmap
table={table([TITLE_FIELD, START_FIELD, TARGET_FIELD], [redacted])}
fallback={<div>list</div>}
/>
)
expect(screen.getByLabelText(/^Restricted item — /)).toBeTruthy()
})
it('falls back to the caller-supplied list when no field can place items', () => {
render(<ProjectRoadmap table={table([TITLE_FIELD], [])} fallback={<div>list</div>} />)
expect(screen.getByText('list')).toBeTruthy()
expect(
screen.getByText(
'This roadmap view has no date or iteration field to place items on, so Orca is listing them instead.'
)
).toBeTruthy()
})
it('reports an empty filter result instead of drawing an empty grid', () => {
render(
<ProjectRoadmap
table={table([TITLE_FIELD, START_FIELD, TARGET_FIELD], [], 'status:Todo')}
fallback={<div>list</div>}
/>
)
expect(screen.getByText("No items match this view's filter.")).toBeTruthy()
expect(screen.queryByText('list')).toBeNull()
})
it('does not blame a filter an unfiltered roadmap does not have', () => {
render(
<ProjectRoadmap
table={table([TITLE_FIELD, START_FIELD, TARGET_FIELD], [])}
fallback={<div>list</div>}
/>
)
expect(screen.getByText('This view has no items yet.')).toBeTruthy()
expect(screen.queryByText("No items match this view's filter.")).toBeNull()
})
})
@@ -19,17 +19,6 @@ const singleSelectField: GitHubProjectField = {
]
}
const iterationField: GitHubProjectField = {
kind: 'iteration',
id: 'F_iter',
name: 'Iteration',
dataType: 'ITERATION',
iterations: [
{ id: 'iter_1', title: 'Sprint 1', startDate: '2026-01-01', duration: 14, completed: false },
{ id: 'iter_2', title: 'Sprint 2', startDate: '2026-01-15', duration: 14, completed: false }
]
}
const assigneesField: GitHubProjectField = {
kind: 'field',
id: 'F_assignees',
@@ -37,20 +26,6 @@ const assigneesField: GitHubProjectField = {
dataType: 'ASSIGNEES'
}
const labelsField: GitHubProjectField = {
kind: 'field',
id: 'F_labels',
name: 'Labels',
dataType: 'LABELS'
}
const textField: GitHubProjectField = {
kind: 'field',
id: 'F_text',
name: 'Notes',
dataType: 'TEXT'
}
const numberField: GitHubProjectField = {
kind: 'field',
id: 'F_points',
@@ -117,35 +92,6 @@ function makeTable(view: GitHubProjectView, rows: GitHubProjectRow[]): GitHubPro
}
describe('sortRows', () => {
it('orders rows by single-select option order', () => {
const view = makeView(singleSelectField, {
direction: 'ASC',
field: singleSelectField
})
const rows = [
makeRow('r2', 1, {
F_status: {
kind: 'single-select',
fieldId: 'F_status',
optionId: 'opt_b',
name: 'In Progress',
color: 'YELLOW'
}
}),
makeRow('r1', 0, {
F_status: {
kind: 'single-select',
fieldId: 'F_status',
optionId: 'opt_a',
name: 'Todo',
color: 'GRAY'
}
})
]
const sorted = sortRows(makeTable(view, rows), rows)
expect(sorted.map((r) => r.id)).toEqual(['r1', 'r2'])
})
it('does not produce NaN when both rows reference unknown single-select options', () => {
// Avoid Infinity - Infinity producing NaN and bypassing the position tie-break for orphaned options.
const view = makeView(singleSelectField, {
@@ -176,58 +122,6 @@ describe('sortRows', () => {
expect(sorted.map((r) => r.id)).toEqual(['rA', 'rB'])
})
it('does not produce NaN when both rows reference unknown iteration ids', () => {
const view = makeView(iterationField, {
direction: 'ASC',
field: iterationField
})
const rows = [
makeRow('rB', 5, {
F_iter: {
kind: 'iteration',
fieldId: 'F_iter',
iterationId: 'gone_b',
title: 'Gone B',
startDate: '2025-01-01',
duration: 14
}
}),
makeRow('rA', 1, {
F_iter: {
kind: 'iteration',
fieldId: 'F_iter',
iterationId: 'gone_a',
title: 'Gone A',
startDate: '2025-01-01',
duration: 14
}
})
]
const sorted = sortRows(makeTable(view, rows), rows)
expect(sorted.map((r) => r.id)).toEqual(['rA', 'rB'])
})
it('places rows missing the sort field after rows that have it', () => {
const view = makeView(singleSelectField, {
direction: 'ASC',
field: singleSelectField
})
const rows = [
makeRow('rEmpty', 0, {}),
makeRow('rHas', 1, {
F_status: {
kind: 'single-select',
fieldId: 'F_status',
optionId: 'opt_a',
name: 'Todo',
color: 'GRAY'
}
})
]
const sorted = sortRows(makeTable(view, rows), rows)
expect(sorted.map((r) => r.id)).toEqual(['rHas', 'rEmpty'])
})
it('sorts an empty user list last in both directions, like a missing value', () => {
const rows = [
makeRow('empty-list', 0, {
@@ -249,55 +143,6 @@ describe('sortRows', () => {
expect(sorted.map((r) => r.id)).toEqual(['alice', 'empty-list', 'no-value'])
}
})
it('sorts an empty label list last in both directions, like a missing value', () => {
const rows = [
makeRow('empty-list', 0, {
F_labels: { kind: 'labels', fieldId: 'F_labels', labels: [] }
}),
makeRow('bug', 1, {
F_labels: {
kind: 'labels',
fieldId: 'F_labels',
labels: [{ name: 'bug', color: 'ff0000' }]
}
}),
makeRow('no-value', 2, {})
]
for (const direction of ['ASC', 'DESC'] as const) {
const view = makeView(labelsField, { direction, field: labelsField })
const sorted = sortRows(makeTable(view, rows), rows)
expect(sorted.map((r) => r.id)).toEqual(['bug', 'empty-list', 'no-value'])
}
})
it('sorts a blank text value last in both directions, like a missing value', () => {
// Why reachable: the normalizer turns a null GitHub text/date into ''.
const rows = [
makeRow('blank', 0, { F_text: { kind: 'text', fieldId: 'F_text', text: '' } }),
makeRow('alpha', 1, { F_text: { kind: 'text', fieldId: 'F_text', text: 'alpha' } }),
makeRow('no-value', 2, {})
]
for (const direction of ['ASC', 'DESC'] as const) {
const view = makeView(textField, { direction, field: textField })
const sorted = sortRows(makeTable(view, rows), rows)
expect(sorted.map((r) => r.id)).toEqual(['alpha', 'blank', 'no-value'])
}
})
it('keeps sort fallback finite when row positions are absent', () => {
const view = makeView(singleSelectField)
const rows = [
{ ...makeRow('rA', 0, {}), position: undefined as unknown as number },
{ ...makeRow('rB', 0, {}), position: undefined as unknown as number }
]
const sorted = sortRows(makeTable(view, rows), rows)
expect(sorted.map((r) => r.id)).toEqual(['rA', 'rB'])
})
})
describe('groupRows', () => {
@@ -337,74 +182,6 @@ describe('groupRows', () => {
expect(groups.map((group) => group.label)).toEqual(['-10', '-2', '1', '2', '8', '10'])
})
it('places the empty group last', () => {
const view = {
...makeView(singleSelectField),
groupByFields: [singleSelectField]
}
const rows = [
makeRow('rNone', 0, {}),
makeRow('rA', 1, {
F_status: {
kind: 'single-select',
fieldId: 'F_status',
optionId: 'opt_a',
name: 'Todo',
color: 'GRAY'
}
})
]
const groups = groupRows(makeTable(view, rows), rows)
expect(groups.map((g) => g.key)).toEqual(['opt_a', '__empty__'])
})
})
it('indexes field ordering once for grouping and sorting a large project table', () => {
let reads = 0
const field: GitHubProjectField = {
kind: 'single-select',
id: 'field',
name: 'Status',
dataType: 'SINGLE_SELECT',
options: Array.from({ length: 1000 }, (_, i) => ({
get id() {
reads++
return `option-${i}`
},
name: String(i),
color: 'GRAY'
}))
}
const rows = Array.from({ length: 1000 }, (_, i) =>
makeRow(String(i), i, {
field: {
kind: 'single-select',
fieldId: 'field',
optionId: `option-${(i * 173) % 1000}`,
name: String((i * 173) % 1000),
color: 'GRAY'
}
})
)
const view = { ...makeView(field, { field, direction: 'ASC' }), groupByFields: [field] }
const table = makeTable(view, rows)
const sorted = sortRows(table, rows)
expect(reads).toBe(1000)
expect(
sorted.map((row) =>
Number(
row.fieldValuesByFieldId.field.kind === 'single-select' &&
row.fieldValuesByFieldId.field.name
)
)
).toEqual(Array.from({ length: 1000 }, (_, i) => i))
reads = 0
const groups = groupRows(table, rows)
expect(reads).toBe(1000)
expect(groups.map((group) => group.key)).toEqual(
Array.from({ length: 1000 }, (_, i) => `option-${i}`)
)
})
it('uses the first iteration ordering and metadata when legacy field IDs repeat', () => {
@@ -1,10 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github/project-types'
import type { Repo } from '../../../../shared/repo-types'
import {
filterProjectTableRowsBySelectedRepos,
resolveSelectedProjectRowRepo
} from './project-row-filtering'
import { filterProjectTableRowsBySelectedRepos } from './project-row-filtering'
function repo(id: string): Repo {
return {
@@ -125,177 +122,4 @@ describe('filterProjectTableRowsBySelectedRepos', () => {
expect(filtered.rows).toEqual([...drafts, ...restricted, issue, pullRequest])
expect(filtered.totalCount).toBe(71)
})
it('keeps a row when at least one slug match is selected', () => {
const rows = [row('visible', 'acme/orca'), row('hidden', 'acme/tool')]
const filtered = filterProjectTableRowsBySelectedRepos(
table(rows),
(slug) =>
originOnly(slug?.toLowerCase() === 'acme/orca' ? [repo('repo-1')] : [repo('repo-2')]),
true,
new Set(['repo-1'])
)
expect(filtered.rows.map((r) => r.id)).toEqual(['visible'])
expect(filtered.totalCount).toBe(1)
})
it('filters a row when only unselected repos match', () => {
const rows = [row('hidden', 'acme/orca')]
const filtered = filterProjectTableRowsBySelectedRepos(
table(rows),
() => originOnly([repo('repo-2')]),
true,
new Set(['repo-1'])
)
expect(filtered.rows).toEqual([])
expect(filtered.totalCount).toBe(0)
})
it('keeps a row whose only selected match is a fork of the row s repo', () => {
const rows = [row('fork-only', 'acme/orca')]
const filtered = filterProjectTableRowsBySelectedRepos(
table(rows),
() => ({ origin: [], upstream: [repo('fork')] }),
true,
new Set(['fork'])
)
expect(filtered.rows.map((r) => r.id)).toEqual(['fork-only'])
expect(filtered.totalCount).toBe(1)
})
it('keeps a row with multiple selected matches for action ambiguity handling', () => {
const rows = [row('ambiguous', 'acme/orca')]
const filtered = filterProjectTableRowsBySelectedRepos(
table(rows),
() => originOnly([repo('repo-1'), repo('repo-2'), repo('repo-3')]),
true,
new Set(['repo-1', 'repo-2'])
)
expect(filtered.rows.map((r) => r.id)).toEqual(['ambiguous'])
})
})
describe('resolveSelectedProjectRowRepo', () => {
it('reports loading without reading stale slug matches', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('loading', 'acme/orca'),
lookupSlugMatches: () => {
throw new Error('should not read stale matches')
},
slugIndexReady: false,
selectedRepoIds: new Set(['repo-1'])
})
expect(resolution.status).toBe('loading')
})
it('reports invalid slug for rows without a repository', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('missing-slug', null),
lookupSlugMatches: () => originOnly([repo('repo-1')]),
slugIndexReady: true,
selectedRepoIds: new Set(['repo-1'])
})
expect(resolution.status).toBe('invalid_slug')
})
it('reports no global match when Orca has no repo for the slug', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('missing', 'acme/orca'),
lookupSlugMatches: () => originOnly([]),
slugIndexReady: true,
selectedRepoIds: new Set(['repo-1'])
})
expect(resolution.status).toBe('no_global_match')
})
it('reports global-only matches when the repo is not selected', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('unselected', 'acme/orca'),
lookupSlugMatches: () => originOnly([repo('repo-2')]),
slugIndexReady: true,
selectedRepoIds: new Set(['repo-1'])
})
expect(resolution.status).toBe('unselected_match')
})
it('returns the selected match when exactly one matching repo is selected', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('selected', 'acme/orca'),
lookupSlugMatches: () => originOnly([repo('repo-1'), repo('repo-2')]),
slugIndexReady: true,
selectedRepoIds: new Set(['repo-2'])
})
expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'repo-2' } })
})
it('passes the project host into repository matching', () => {
const lookupSlugMatches = vi.fn(() => originOnly([repo('repo-1')]))
expect(
resolveSelectedProjectRowRepo({
row: row('enterprise', 'acme/orca'),
lookupSlugMatches,
host: 'ghe.example:8443',
slugIndexReady: true,
selectedRepoIds: new Set(['repo-1'])
})
).toMatchObject({ status: 'selected_match' })
expect(lookupSlugMatches).toHaveBeenCalledWith('acme/orca', 'ghe.example:8443')
})
it('reports ambiguity when multiple matching repos are selected', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('ambiguous', 'acme/orca'),
lookupSlugMatches: () => originOnly([repo('repo-1'), repo('repo-2')]),
slugIndexReady: true,
selectedRepoIds: new Set(['repo-1', 'repo-2'])
})
expect(resolution.status).toBe('ambiguous_selected_match')
})
it('falls through to a selected fork when the upstream clone is unselected', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('fork-selected', 'acme/orca'),
lookupSlugMatches: () => ({ origin: [repo('upstream')], upstream: [repo('fork')] }),
slugIndexReady: true,
selectedRepoIds: new Set(['fork'])
})
expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'fork' } })
})
it('prefers the selected upstream clone over a selected fork of it', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('both-selected', 'acme/orca'),
lookupSlugMatches: () => ({ origin: [repo('upstream')], upstream: [repo('fork')] }),
slugIndexReady: true,
selectedRepoIds: new Set(['upstream', 'fork'])
})
expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'upstream' } })
})
it('still reports no selection when neither the upstream clone nor the fork is selected', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('neither', 'acme/orca'),
lookupSlugMatches: () => ({ origin: [repo('upstream')], upstream: [repo('fork')] }),
slugIndexReady: true,
selectedRepoIds: new Set(['other'])
})
expect(resolution).toMatchObject({
status: 'unselected_match',
globalMatches: [{ id: 'upstream' }, { id: 'fork' }]
})
})
})
@@ -3,15 +3,12 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getExecutionHostLabel } from '../../../../../shared/execution-host'
import es from '@/i18n/locales/es.json'
import { i18n, loadRendererUiLanguage, translate } from '@/i18n/i18n'
import { i18n, loadRendererUiLanguage } from '@/i18n/i18n'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { TooltipProvider } from '../../ui/tooltip'
import { LinearIntegrationCard } from './linear-integration-card'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
type StoreState = {
linearStatus: {
connected: boolean
@@ -126,114 +123,6 @@ describe('LinearIntegrationCard account scope', () => {
Reflect.deleteProperty(window, 'api')
})
it.each(['en', 'ko'] as const)(
'renders complete %s connection counts without an English suffix',
async (language) => {
await i18n.changeLanguage(await loadRendererUiLanguage(language))
for (const count of [0, 1, 2]) {
const state = installStore(true)
state.linearStatus.workspaces = Array.from({ length: count }, (_, index) => ({
id: `workspace-${index}`,
organizationName: 'Fixture',
displayName: 'Fixture workspace'
}))
const rendered = await renderCard()
const description =
rendered.querySelector('[data-settings-section="integrations-linear"]')?.textContent ?? ''
const expected =
language === 'ko'
? `워크스페이스 ${count}개 연결됨`
: `${count} workspace${count === 1 ? '' : 's'} connected`
expect(description).toContain(expected)
expect(description).not.toMatch(/개(?: 사이트| 워크스페이스)?s/)
await act(async () => {
root?.unmount()
})
root = null
container?.remove()
container = null
}
}
)
it('translates a coded connection failure at the display site', async () => {
await i18n.changeLanguage(await loadRendererUiLanguage('es'))
const state = installStore(true)
state.testLinearConnection = vi.fn(async () => ({
ok: false,
error: 'Wire detail from the host',
errorCode: 'linear.noApiKey'
}))
const rendered = await renderCard()
const label = translate(
'auto.components.settings.task.tracker.integration.cards.c24e56c532',
'Test'
)
const button = Array.from(rendered.querySelectorAll('button')).find(
(button) => button.textContent === label
)
expect(button).toBeDefined()
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(rendered.textContent).toContain(es.taskProviders.errors.linear.noApiKey)
expect(rendered.textContent).not.toContain('Wire detail from the host')
})
it('shows local-client account ownership when Linear is disconnected', async () => {
const state = installStore(false)
const rendered = await renderCard()
expect(rendered.querySelector('[data-settings-section="integrations-linear"]')).not.toBeNull()
expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`)
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > New projects open on.'
)
expect(rendered.textContent).toContain('Open Remote Servers')
expect(rendered.textContent).toContain('Add access with a Personal API key')
await act(async () => {
Array.from(rendered.querySelectorAll('button'))
.find((button) => button.textContent === 'Re-check')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(state.checkLinearConnection).toHaveBeenCalledWith(true)
})
it('shows remote-server account ownership and connected workspace rows', async () => {
const state = installStore(true, { activeRuntimeEnvironmentId: 'runtime-1' })
const rendered = await renderCard()
expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1')
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > New projects open on.'
)
await act(async () => {
Array.from(rendered.querySelectorAll('button'))
.find((button) => button.textContent === 'Open Remote Servers')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(state.openSettingsPage).toHaveBeenCalledTimes(1)
expect(state.openSettingsTarget).toHaveBeenCalledWith({
pane: 'servers',
repoId: null,
sectionId: 'default-runtime'
})
expect(rendered.textContent).toContain('Acme')
expect(rendered.textContent).toContain('Acme workspace · linear@example.test')
await act(async () => {
Array.from(rendered.querySelectorAll('button'))
.find((button) => button.textContent === 'Test')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(state.testLinearConnection).toHaveBeenCalledWith('workspace-1')
})
it('clears verification state after adding another Linear workspace', async () => {
installStore(true)
const rendered = await renderCard()
@@ -228,19 +228,6 @@ afterEach(async () => {
})
describe('Jira issue workspace image ownership', () => {
it('keeps explicit ownership without duplicate reads when the default host changes', async () => {
const selected = issue()
const owner: RuntimeClientTarget = { kind: 'environment', environmentId: 'environment-1' }
await render(selected)
boundary.settings = { activeRuntimeEnvironmentId: 'environment-2' }
await render(selected)
for (const reads of [boundary.issues, boundary.comments, boundary.transitions]) {
expect(reads).toHaveLength(1)
expect(reads[0]?.target).toEqual(owner)
}
expect(text('image-owner')).toBe(JSON.stringify(owner))
})
it('fences late detail, comments and transitions when the same issue switches owners', async () => {
const selected = issue()
await render(selected)
@@ -272,20 +259,4 @@ describe('Jira issue workspace image ownership', () => {
expect(text('comments')).toBe('new')
expect(text('transitions')).toBe('new')
})
it('uses one account owner for row-less detail, comments, transitions and images', async () => {
await render(issue(), null)
const detail = take(boundary.issues)
const comments = take(boundary.comments)
const transitions = take(boundary.transitions)
expect(detail.target).toEqual({ kind: 'environment', environmentId: 'environment-1' })
expect(comments.target).toBe(detail.target)
expect(transitions.target).toBe(detail.target)
expect(text('image-owner')).toBe(JSON.stringify(detail.target))
await act(async () => {
detail.resolve(null)
comments.resolve([])
transitions.resolve([])
})
})
})
@@ -392,32 +392,6 @@ describe('Jira issue workspace request lifetime', () => {
expect(text('comments-loading')).toBe('false')
})
it('discards late detail and comments after closing the mounted workspace', async () => {
await render(issue())
await render(null)
await act(async () => {
take(boundary.issues).resolve({ ...issue(), description: 'Screenshot data'.repeat(32_768) })
take(boundary.comments).resolve([{ ...comment('late'), body: 'Image data'.repeat(32_768) }])
})
expect(container.querySelector('[data-open]')?.getAttribute('data-open')).toBe('false')
expect(container.querySelector('[data-testid="detail"]')).toBeNull()
expect(boundary.composer).toBeNull()
})
it('clears an already hydrated issue and comments when it closes', async () => {
await render(issue())
await act(async () => {
take(boundary.issues).resolve(issue('JIR-1', 'Hydrated'))
take(boundary.comments).resolve([comment('loaded')])
})
expect(text('title')).toBe('Hydrated')
expect(text('comments')).toBe('loaded')
expect(text('issue-loading')).toBe('false')
expect(text('comments-loading')).toBe('false')
await render(null)
expect(container.querySelector('[data-testid="detail"]')).toBeNull()
})
it('ignores old replies and accepts the reopened issue replies', async () => {
await render(issue())
const oldIssue = take(boundary.issues)
@@ -4,13 +4,8 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinearTeam } from '../../../shared/linear/workspace-types'
import {
clearLinearIssueAttributeFacet,
countLinearIssueAttributeFilters,
linearIssueAttributeFilterPillLabels
} from './linear-issue-attribute-filter-pills'
import {
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS,
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS,
type LinearIssueAttributeFilter
} from '../../../shared/linear/issue-attribute-filter'
@@ -61,208 +56,6 @@ afterEach(() => {
metadataMocks.useTeamsMembers.mockReset()
})
const sample: LinearIssueAttributeFilter = {
stateIds: ['s1', 's2'],
priorities: [0, 1],
assignee: { kind: 'unassigned' },
labelIds: ['l1']
}
describe('linear-issue-attribute-filter helpers', () => {
it('counts active facets and clears individual facets', () => {
expect(countLinearIssueAttributeFilters(sample)).toBe(4)
expect(clearLinearIssueAttributeFacet(sample, 'status').stateIds).toEqual([])
expect(clearLinearIssueAttributeFacet(sample, 'priority').priorities).toEqual([])
expect(clearLinearIssueAttributeFacet(sample, 'assignee').assignee).toBeNull()
expect(clearLinearIssueAttributeFacet(sample, 'labels').labelIds).toEqual([])
})
it('collapses same-named status ids into one pill label', () => {
const pills = linearIssueAttributeFilterPillLabels({
value: { stateIds: ['be-todo', 'fe-todo'], priorities: [], assignee: null, labelIds: [] },
stateNamesById: new Map([
['be-todo', 'Todo'],
['fe-todo', 'Todo']
]),
memberNamesById: new Map(),
labelNamesById: new Map(),
statusOptions: [{ key: 'be-todo', primary: 'Todo', ids: ['be-todo', 'fe-todo'] }],
labelOptions: [],
statusTruncated: false,
labelsTruncated: false
})
expect(pills[0]?.value).toBe('Todo')
})
it('builds pill labels from metadata maps', () => {
const pills = linearIssueAttributeFilterPillLabels({
value: sample,
stateNamesById: new Map([
['s1', 'Todo'],
['s2', 'In Progress']
]),
memberNamesById: new Map(),
labelNamesById: new Map([['l1', 'Bug']]),
statusOptions: [],
labelOptions: [],
statusTruncated: false,
labelsTruncated: false
})
expect(pills.map((p) => p.key)).toEqual(['status', 'priority', 'assignee', 'labels'])
expect(pills[0]?.value).toContain('Todo')
expect(pills[2]?.value).toMatch(/Unassigned/i)
expect(pills[3]?.value).toBe('Bug')
})
})
describe('LinearIssueAttributeFilterDropdowns', () => {
it('keeps metadata lazy while closed with only static filter labels', () => {
const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' }
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
act(() => {
root.render(
<LinearIssueAttributeFilterDropdowns
value={{
stateIds: [],
priorities: [1],
assignee: { kind: 'unassigned' },
labelIds: []
}}
onChange={() => undefined}
workspaceId="workspace-1"
primaryTeam={team}
selectedTeamIds={[]}
availableTeams={[team]}
teamsSettled
/>
)
})
expect(metadataMocks.useTeamsStates).toHaveBeenCalledWith([], undefined, null)
expect(metadataMocks.useTeamsLabels).toHaveBeenCalledWith([], undefined, null)
expect(metadataMocks.useTeamsMembers).toHaveBeenCalledWith([], undefined, null)
})
it('keeps readable metadata names available after the popover closes', () => {
const value: LinearIssueAttributeFilter = {
stateIds: ['state-1'],
priorities: [],
assignee: { kind: 'user', id: 'member-1' },
labelIds: ['label-1']
}
const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' }
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
act(() => {
root.render(
<LinearIssueAttributeFilterDropdowns
value={value}
onChange={() => undefined}
workspaceId="workspace-1"
primaryTeam={team}
selectedTeamIds={[]}
availableTeams={[team]}
teamsSettled
/>
)
})
expect(container.textContent).toContain('Todo')
expect(container.textContent).toContain('Ada Lovelace')
expect(container.textContent).toContain('Bug')
expect(container.textContent).not.toContain('state-1')
expect(metadataMocks.useTeamsStates).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1')
expect(metadataMocks.useTeamsLabels).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1')
expect(metadataMocks.useTeamsMembers).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1')
})
it.each([['all'], [null]])(
'offers the workspace picker hint instead of filter sections for workspaceId %s',
(workspaceId) => {
const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' }
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
act(() => {
root.render(
<LinearIssueAttributeFilterDropdowns
value={{ stateIds: [], priorities: [], assignee: null, labelIds: [] }}
onChange={() => undefined}
workspaceId={workspaceId}
primaryTeam={team}
selectedTeamIds={[]}
availableTeams={[team]}
teamsSettled
/>
)
})
const trigger = container.querySelector('button')
act(() => {
trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(document.body.textContent).toContain('Select one workspace')
expect(metadataMocks.useTeamsStates).toHaveBeenCalledWith([], undefined, null)
}
)
// Wait for the full team fetch; pruning against issue-scraped teams would permanently discard restored facets.
it('prunes unknown facet ids only once teams settle on the mounted component', () => {
const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' }
const onChange = vi.fn()
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
const renderWith = (teamsSettled: boolean): void => {
act(() => {
root.render(
<LinearIssueAttributeFilterDropdowns
value={{
stateIds: ['state-from-another-team'],
priorities: [],
assignee: null,
labelIds: []
}}
onChange={onChange}
workspaceId="workspace-1"
primaryTeam={team}
selectedTeamIds={[]}
availableTeams={[team]}
teamsSettled={teamsSettled}
/>
)
})
}
renderWith(false)
expect(onChange).not.toHaveBeenCalled()
renderWith(true)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ stateIds: [], labelIds: [], assignee: null })
)
})
})
const multiTeamStates = [
{ id: 'be-backlog', name: 'Backlog', type: 'backlog' },
{ id: 'be-todo', name: 'Todo', type: 'unstarted' },
{ id: 'fe-backlog', name: 'Backlog', type: 'backlog' },
{ id: 'fe-todo', name: 'Todo', type: 'unstarted' }
]
const teamBe: LinearTeam = { id: 'team-be', name: 'Backend', key: 'BE' }
const teamFe: LinearTeam = { id: 'team-fe', name: 'Frontend', key: 'FE' }
@@ -326,159 +119,7 @@ function pickerRowsNamed(name: string): HTMLElement[] {
)
}
describe('LinearIssueAttributeFilterDropdowns status options across teams', () => {
it('lists one row per status name instead of one per team state id', () => {
metadataMocks.useTeamsStates.mockImplementation(() => ({
data: multiTeamStates,
loading: false,
error: null
}))
renderDropdowns(emptyFilter)
openSectionNamed('Status')
expect(pickerRowsNamed('Todo')).toHaveLength(1)
expect(pickerRowsNamed('Backlog')).toHaveLength(1)
})
it('selects every team state id behind the picked status name', () => {
metadataMocks.useTeamsStates.mockImplementation(() => ({
data: multiTeamStates,
loading: false,
error: null
}))
const { onChange } = renderDropdowns(emptyFilter)
openSectionNamed('Status')
act(() => {
pickerRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ stateIds: ['be-todo', 'fe-todo'] })
)
})
it('keeps the expanded status selection within the transport id cap', () => {
const overCap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + 20
metadataMocks.useTeamsStates.mockImplementation(() => ({
data: Array.from({ length: overCap }, (_unused, index) => ({
id: `team-${index}-todo`,
name: 'Todo',
type: 'unstarted'
})),
loading: false,
error: null
}))
const { onChange } = renderDropdowns(emptyFilter)
openSectionNamed('Status')
act(() => {
pickerRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onChange.mock.calls[0]?.[0].stateIds).toHaveLength(
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
)
})
})
function sameNamedMetadata(name: string, count: number): { id: string; name: string }[] {
return Array.from({ length: count }, (_unused, index) => ({ id: `team-${index}-${name}`, name }))
}
describe('LinearIssueAttributeFilterDropdowns transport-cap coverage notice', () => {
it('reports the team statuses left out once a picked status exceeds the id cap', () => {
const overCap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + 20
metadataMocks.useTeamsStates.mockImplementation(() => ({
data: sameNamedMetadata('todo', overCap),
loading: false,
error: null
}))
const { rerender, onChange } = renderDropdowns(emptyFilter)
openSectionNamed('Status')
act(() => {
pickerRowsNamed('todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
const bounded = onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter
expect(bounded.stateIds).toHaveLength(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS)
rerender(bounded)
expect(pickerRowsNamed('todo')).toHaveLength(1)
expect(document.body.textContent).toContain(
`Filtering ${LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS} of ${overCap} team statuses`
)
})
it('reports the shrunken coverage of a picked status when a second status is added', () => {
const perStatus = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + 20
metadataMocks.useTeamsStates.mockImplementation(() => ({
data: [...sameNamedMetadata('todo', perStatus), ...sameNamedMetadata('doing', perStatus)],
loading: false,
error: null
}))
const { rerender, onChange } = renderDropdowns(emptyFilter)
openSectionNamed('Status')
act(() => {
pickerRowsNamed('todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter)
act(() => {
pickerRowsNamed('doing')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
rerender(onChange.mock.calls[1]?.[0] as LinearIssueAttributeFilter)
expect(document.body.textContent).toContain(
`Filtering ${LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS} of ${perStatus * 2} team statuses`
)
})
it('stays silent while every team id behind the picked status is applied', () => {
metadataMocks.useTeamsStates.mockImplementation(() => ({
data: multiTeamStates,
loading: false,
error: null
}))
const { rerender, onChange } = renderDropdowns(emptyFilter)
openSectionNamed('Status')
act(() => {
pickerRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter)
expect(document.body.textContent).not.toContain('team statuses')
openSectionNamed('Back')
expect(document.body.textContent).not.toContain('partial')
})
it('flags the trimmed status filter in the section menu and the pill', () => {
const overCap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + 20
metadataMocks.useTeamsStates.mockImplementation(() => ({
data: sameNamedMetadata('todo', overCap),
loading: false,
error: null
}))
const { rerender, onChange } = renderDropdowns(emptyFilter)
openSectionNamed('Status')
act(() => {
pickerRowsNamed('todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter)
openSectionNamed('Back')
expect(document.body.textContent).toContain('1 selected')
expect(document.body.textContent).toContain('· partial')
// Use a keyboard-reachable, named marker; a title alone cannot provide accessible coverage feedback.
const pillMarkers = [...document.body.querySelectorAll('button')].filter(
(button) => button.textContent === 'partial'
)
expect(pillMarkers).toHaveLength(1)
expect(pillMarkers[0]?.getAttribute('data-slot')).toBe('tooltip-trigger')
})
it('keeps and counts a picked status whose ids all sort past the cap', () => {
const cap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
metadataMocks.useTeamsStates.mockImplementation(() => ({
@@ -509,64 +150,6 @@ describe('LinearIssueAttributeFilterDropdowns transport-cap coverage notice', ()
expect(bounded.stateIds).toContain('z-000')
expect(document.body.textContent).toContain(`Filtering ${cap} of ${cap + 1} team statuses`)
})
it('says the status filter is full instead of claiming coverage it cannot have', () => {
const cap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
const states = Array.from({ length: cap + 1 }, (_unused, index) => ({
id: `s-${String(index).padStart(3, '0')}`,
name: `Status ${String(index).padStart(3, '0')}`
}))
metadataMocks.useTeamsStates.mockImplementation(() => ({
data: states,
loading: false,
error: null
}))
const { rerender, onChange } = renderDropdowns({
...emptyFilter,
stateIds: states.slice(0, cap).map((state) => state.id)
})
openSectionNamed('Status')
act(() => {
pickerRowsNamed(`Status ${String(cap).padStart(3, '0')}`)[0]?.dispatchEvent(
new MouseEvent('click', { bubbles: true })
)
})
const bounded = onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter
expect(bounded.stateIds).toHaveLength(cap)
rerender(bounded)
expect(document.body.textContent).toContain(
`Filtering the most this can carry: ${cap} team statuses`
)
openSectionNamed('Back')
expect(document.body.textContent).toContain(`${cap} selected`)
expect(document.body.textContent).toContain('· partial')
})
it('reports the team labels left out once a picked label exceeds the id cap', () => {
const overCap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS + 20
metadataMocks.useTeamsLabels.mockImplementation(() => ({
data: sameNamedMetadata('bug', overCap),
loading: false,
error: null
}))
const { rerender, onChange } = renderDropdowns(emptyFilter)
openSectionNamed('Labels')
act(() => {
pickerRowsNamed('bug')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
const bounded = onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter
expect(bounded.labelIds).toHaveLength(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS)
rerender(bounded)
expect(document.body.textContent).toContain(
`Filtering ${LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS} of ${overCap} team labels`
)
})
})
// Exactly the cap is complete here; bounded ids alone cannot prove that truncation happened.
@@ -678,39 +261,6 @@ describe('LinearIssueAttributeFilterDropdowns coverage at exactly the transport
expect(document.body.textContent).toContain('Filtering the most this can carry')
})
it('keeps the labels truncation when an unrelated facet is picked', () => {
const labelCap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS
const labels = Array.from({ length: labelCap + 1 }, (_unused, index) => ({
id: `l-${String(index).padStart(3, '0')}`,
name: rowNamed(index)
}))
metadataMocks.useTeamsLabels.mockImplementation(() => ({
data: labels,
loading: false,
error: null
}))
const { rerender, onChange } = renderDropdowns({
...emptyFilter,
labelIds: labels.slice(0, labelCap).map((label) => label.id)
})
openSectionNamed('Labels')
clickRow(rowNamed(labelCap))
rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter)
expect(document.body.textContent).toContain('Filtering the most this can carry')
openSectionNamed('Back')
openSectionNamed('Priority')
clickRow('Urgent')
const withPriority = onChange.mock.calls[1]?.[0] as LinearIssueAttributeFilter
rerender(withPriority)
expect(withPriority.labelIds).toHaveLength(labelCap)
openSectionNamed('Back')
openSectionNamed('Labels')
expect(document.body.textContent).toContain('Filtering the most this can carry')
})
it('drops the recorded truncation when the facet is cleared', () => {
const states = Array.from({ length: cap + 1 }, (_unused, index) => ({
id: `s-${String(index).padStart(3, '0')}`,
@@ -9,7 +9,7 @@ import {
import { act } from 'react'
import { expect, it, vi, type Mock } from 'vitest'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeRuntimeModal'
import { chatBox } from './native-chat-resume-on-restart-modal.test-support'
import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment'
import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping'
import type { AgentSessionStatusSummary } from '../../../shared/agent-session/agent-session-wire'
@@ -66,29 +66,6 @@ function emitPhase(
})
}
it('resumes a 21-chat selection with one action and no redundant listing', async () => {
const candidates = Array.from({ length: 21 }, (_, index) => ({
...offered[0]!,
sessionId: `chat-${index}`
}))
rpc.mockImplementation(async (_target, method) =>
method === 'agentSession.restartResumable'
? { sessions: candidates, failed: [] }
: {
sessions: [],
failed: [],
continued: candidates.map(({ sessionId }) => ({ sessionId, outcome: 'continued' }))
}
)
await refreshNativeChatRestartOffer()
await continueNativeChatRestartOffer(candidates.map(({ sessionId }) => sessionId))
expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([
['agentSession.restartResumable', undefined],
['agentSession.restartContinue', { sessionIds: candidates.map(({ sessionId }) => sessionId) }]
])
expect(toasts()).toEqual([['Resumed 21 chats']])
})
it('uses the host unconfirmed result after a lost bulk reply without reporting a failure', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
let reads = 0
@@ -119,30 +96,6 @@ it('uses the host unconfirmed result after a lost bulk reply without reporting a
}
})
it('publishes a refusal reply without needing a second host read', async () => {
let reads = 0
rpc.mockImplementation(async (_target, method) => {
if (method === 'agentSession.restartResumable') {
if (reads++ > 0) {
throw new Error('listing unavailable')
}
return { sessions: offered, failed: [] }
}
return {
sessions: [offered[1]],
failed: [failure('a')],
continued: [{ sessionId: 'a', outcome: 'refused' }]
}
})
await mount(<NativeChatResumeOnRestartModal />)
await act(async () => chatBox('b').click())
await act(async () => button('Resume 1 chat').click())
expect(reads).toBe(1)
expect(offerIds()).toEqual(['b'])
expect(getNativeChatRestartOffer().failed.map((row) => row.sessionId)).toEqual(['a'])
expect(toasts()).toEqual([['1 chat couldn’t be resumed']])
})
it('keeps the run in flight after every feed verdict until the fallback list is published', async () => {
const answer = Promise.withResolvers<{
continued: { sessionId: string; outcome: 'continued' }[]