mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
test: remove 70 low-value cases and four suites (#34400)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -8772,12 +8772,10 @@
|
||||
"src/renderer/src/components/settings/agent-permission-conversion-persistence.test.tsx": 8787,
|
||||
"src/renderer/src/components/settings/bitbucket-integration-card.test.tsx": 421,
|
||||
"src/renderer/src/components/settings/browser-session-host-selection.test.ts": 43,
|
||||
"src/renderer/src/components/settings/bundled-skill-setup-labels.test.tsx": 3388,
|
||||
"src/renderer/src/components/settings/bundled-skill-setup-result-copy.test.ts": 304,
|
||||
"src/renderer/src/components/settings/chat-naming-persistence.test.tsx": 3654,
|
||||
"src/renderer/src/components/settings/chat-settings-unsaved-guard.test.tsx": 1560,
|
||||
"src/renderer/src/components/settings/cli-install-failure.test.ts": 34,
|
||||
"src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx": 695,
|
||||
"src/renderer/src/components/settings/codex-config-sync-warning.test.ts": 34,
|
||||
"src/renderer/src/components/settings/codex-session-source-home-control.test.tsx": 154,
|
||||
"src/renderer/src/components/settings/developer-permissions-search.test.ts": 178,
|
||||
@@ -9241,8 +9239,6 @@
|
||||
"src/renderer/src/components/skills/skill-install-progress-state.test.tsx": 258,
|
||||
"src/renderer/src/components/skills/skill-install-provider-groups.test.ts": 32,
|
||||
"src/renderer/src/components/skills/skill-install-workspace-choices.test.ts": 55,
|
||||
"src/renderer/src/components/skills/skill-package-checklist-items.test.ts": 123,
|
||||
"src/renderer/src/components/skills/skill-package-install-risk.test.ts": 73,
|
||||
"src/renderer/src/components/skills/skill-share-link.test.ts": 38,
|
||||
"src/renderer/src/components/skills/skill-share-package-selection.test.ts": 48,
|
||||
"src/renderer/src/components/skills/skill-share-preview-summary.test.ts": 149,
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BundledSkillOp } from '../../../../shared/bundled-skill-plan'
|
||||
import {
|
||||
doneResult,
|
||||
planEntry,
|
||||
planOf,
|
||||
planOp
|
||||
} from '../skills/bundled-skill-plan-fixtures.test-support'
|
||||
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
|
||||
import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ plan: vi.fn(), execute: vi.fn(), refresh: vi.fn() }))
|
||||
const TARGET = { kind: 'environment', environmentId: 'agents-host' } as const
|
||||
|
||||
vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
|
||||
useActiveProjectSkillRuntime: () => ({ bundledSkillTarget: TARGET })
|
||||
}))
|
||||
vi.mock('@/hooks/installed-agent-skill-discovery', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
notifyInstalledAgentSkillsChanged: vi.fn()
|
||||
}))
|
||||
vi.mock('@/hooks/useInstalledAgentSkills', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['home'],
|
||||
notifyInstalledAgentSkillsRefreshed: vi.fn(),
|
||||
useInstalledAgentSkillNames: () => ({
|
||||
installed: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
skills: [],
|
||||
refresh: mocks.refresh
|
||||
})
|
||||
}))
|
||||
vi.mock('@/hooks/useSkillFreshness', () => ({ refreshSkillFreshness: vi.fn() }))
|
||||
vi.mock('@/runtime/runtime-bundled-skills-client', () => ({
|
||||
planBundledSkillsOnTarget: mocks.plan,
|
||||
executeBundledSkillsOnTarget: mocks.execute
|
||||
}))
|
||||
|
||||
function operations(
|
||||
name: string,
|
||||
update: boolean,
|
||||
agents: string[],
|
||||
edited = false
|
||||
): BundledSkillOp[] {
|
||||
return [
|
||||
planOp(update ? 'update' : 'keep', `/home/u/.agents/skills/${name}`),
|
||||
planOp('keep', `/home/u/.claude/skills/${name}`, {
|
||||
observed: { kind: 'link', target: `/home/u/.agents/skills/${name}` }
|
||||
}),
|
||||
...agents.map((agent) =>
|
||||
planOp('link', `/home/u/.${agent}/skills/${name}`, {
|
||||
agents: agent === 'cursor' ? ['cursor'] : ['gemini']
|
||||
})
|
||||
),
|
||||
...(edited
|
||||
? [
|
||||
planOp('leave', `/home/u/.factory/skills/${name}`, {
|
||||
reason: 'edited',
|
||||
agents: ['droid'],
|
||||
replaceable: true
|
||||
})
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.plan.mockReset()
|
||||
mocks.execute.mockReset()
|
||||
mocks.refresh.mockReset()
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
for (const surface of ['panel', 'linear'] as const) {
|
||||
describe(`${surface} setup plan labels`, () => {
|
||||
it.each([
|
||||
{
|
||||
update: false,
|
||||
agents: ['cursor'],
|
||||
button: 'Install for Cursor',
|
||||
result: 'Installed for Cursor'
|
||||
},
|
||||
{
|
||||
update: false,
|
||||
agents: ['cursor', 'gemini'],
|
||||
button: 'Install for 2 more agents',
|
||||
result: 'Installed for 2 more agents'
|
||||
},
|
||||
{ update: true, agents: [], button: 'Update', result: 'Updated' },
|
||||
{
|
||||
update: true,
|
||||
agents: ['cursor'],
|
||||
button: 'Update',
|
||||
result: 'Updated · Installed for Cursor'
|
||||
},
|
||||
{
|
||||
update: true,
|
||||
agents: ['cursor', 'gemini'],
|
||||
button: 'Update',
|
||||
result: 'Updated · Installed for 2 more agents'
|
||||
}
|
||||
])('labels $button and reports $result', async ({ update, agents, button, result }) => {
|
||||
const name = surface === 'panel' ? 'orca-cli' : 'orca-linear'
|
||||
const plan = planOf([planEntry(name, operations(name, update, agents))])
|
||||
mocks.plan.mockResolvedValue({ kind: 'plan', plan })
|
||||
mocks.execute.mockResolvedValue(doneResult(plan))
|
||||
render(
|
||||
surface === 'panel' ? (
|
||||
<AgentSkillSetupPanel
|
||||
title="CLI skill"
|
||||
description={null}
|
||||
skillNames={[name]}
|
||||
installed
|
||||
loading={false}
|
||||
error={null}
|
||||
onRecheck={mocks.refresh}
|
||||
/>
|
||||
) : (
|
||||
<LinearAgentSkillInstallCta settings={null} />
|
||||
)
|
||||
)
|
||||
fireEvent.click(await screen.findByRole('button', { name: button }))
|
||||
expect(await screen.findByText(result, { exact: true })).toBeDefined()
|
||||
expect(mocks.plan).toHaveBeenCalledWith(TARGET, { names: [name] })
|
||||
expect(mocks.execute).toHaveBeenCalledWith(TARGET, plan.planDigest, { names: [name] })
|
||||
})
|
||||
|
||||
it('shows the current verdict without an install button when every copy is current', async () => {
|
||||
const name = surface === 'panel' ? 'orca-cli' : 'orca-linear'
|
||||
mocks.plan.mockResolvedValue({
|
||||
kind: 'plan',
|
||||
plan: planOf([planEntry(name, operations(name, false, []))])
|
||||
})
|
||||
render(
|
||||
surface === 'panel' ? (
|
||||
<AgentSkillSetupPanel
|
||||
title="CLI skill"
|
||||
description={null}
|
||||
skillNames={[name]}
|
||||
installed
|
||||
loading={false}
|
||||
error={null}
|
||||
onRecheck={mocks.refresh}
|
||||
/>
|
||||
) : (
|
||||
<LinearAgentSkillInstallCta settings={null} />
|
||||
)
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: /^(Install|Update)/ })).toBeNull()
|
||||
)
|
||||
expect(
|
||||
await screen.findByText(surface === 'panel' ? 'Up to date' : 'Installed', { exact: true })
|
||||
).toBeDefined()
|
||||
expect(mocks.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps the edited-copy suffix after the mixed result', async () => {
|
||||
const plan = planOf([planEntry('orca-cli', operations('orca-cli', true, ['cursor'], true))])
|
||||
mocks.plan.mockResolvedValue({ kind: 'plan', plan })
|
||||
mocks.execute.mockResolvedValue(doneResult(plan))
|
||||
render(
|
||||
<AgentSkillSetupPanel
|
||||
title="CLI skill"
|
||||
description={null}
|
||||
skillNames={['orca-cli']}
|
||||
installed
|
||||
loading={false}
|
||||
error={null}
|
||||
onRecheck={mocks.refresh}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Update' }))
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Updated · Installed for Cursor · Update skipped in Droid: Skill edited outside Orca'
|
||||
)
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ agents: ['cursor'], result: 'Installed for Cursor' },
|
||||
{ agents: ['cursor', 'gemini'], result: 'Installed for 2 agents' }
|
||||
])('uses first-install wording for $result', async ({ agents, result }) => {
|
||||
const plan = planOf([
|
||||
planEntry('orca-cli', [
|
||||
planOp('write', '/home/u/.agents/skills/orca-cli'),
|
||||
...agents.map((agent) =>
|
||||
planOp('link', `/home/u/.${agent}/skills/orca-cli`, {
|
||||
agents: agent === 'cursor' ? ['cursor'] : ['gemini']
|
||||
})
|
||||
)
|
||||
])
|
||||
])
|
||||
mocks.plan.mockResolvedValue({ kind: 'plan', plan })
|
||||
mocks.execute.mockResolvedValue(doneResult(plan))
|
||||
render(
|
||||
<AgentSkillSetupPanel
|
||||
title="CLI skill"
|
||||
description={null}
|
||||
skillNames={['orca-cli']}
|
||||
installed={false}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRecheck={mocks.refresh}
|
||||
/>
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Install' }).hasAttribute('disabled')).toBe(false)
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
|
||||
expect(await screen.findByText(result, { exact: true })).toBeDefined()
|
||||
})
|
||||
@@ -1,129 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import {
|
||||
GitHubIntegrationCard,
|
||||
GitLabIntegrationCard
|
||||
} from './cli-source-control-integration-cards'
|
||||
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
type StoreState = {
|
||||
settings: { activeRuntimeEnvironmentId: string | null }
|
||||
openSettingsPage: () => void
|
||||
openSettingsTarget: (target: { pane: string; repoId: string | null }) => void
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
store: { current: null as StoreState | null },
|
||||
preflight: {
|
||||
statuses: {
|
||||
ghStatus: 'connected',
|
||||
glabStatus: 'connected',
|
||||
bitbucketStatus: 'not-configured',
|
||||
azureDevOpsStatus: 'not-configured',
|
||||
giteaStatus: 'not-configured',
|
||||
bitbucketAccount: null,
|
||||
azureDevOpsAccount: null,
|
||||
giteaAccount: null
|
||||
},
|
||||
unavailable: false,
|
||||
refresh: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: StoreState) => unknown) => {
|
||||
if (!mocks.store.current) {
|
||||
throw new Error('Store state was not installed')
|
||||
}
|
||||
return selector(mocks.store.current)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./source-control-preflight-card-status', () => ({
|
||||
usePreflightCardStatuses: () => mocks.preflight
|
||||
}))
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
async function renderCard(card: React.ReactNode): Promise<HTMLDivElement> {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root?.render(card)
|
||||
})
|
||||
return container
|
||||
}
|
||||
|
||||
describe('CLI source-control integration card account scope', () => {
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
}
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
mocks.store.current = null
|
||||
mocks.preflight.statuses.ghStatus = 'connected'
|
||||
mocks.preflight.statuses.glabStatus = 'connected'
|
||||
mocks.preflight.unavailable = false
|
||||
mocks.preflight.refresh.mockClear()
|
||||
})
|
||||
|
||||
it('shows local-client ownership for connected GitHub CLI credentials', async () => {
|
||||
const openSettingsPage = vi.fn()
|
||||
const openSettingsTarget = vi.fn()
|
||||
mocks.store.current = {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
openSettingsPage,
|
||||
openSettingsTarget
|
||||
}
|
||||
|
||||
const rendered = await renderCard(<GitHubIntegrationCard />)
|
||||
|
||||
expect(rendered.textContent).toContain('GitHub')
|
||||
expect(rendered.textContent).toContain('Connected')
|
||||
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.'
|
||||
)
|
||||
await act(async () => {
|
||||
Array.from(rendered.querySelectorAll('button'))
|
||||
.find((button) => button.textContent === 'Open Remote Servers')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(openSettingsPage).toHaveBeenCalledTimes(1)
|
||||
expect(openSettingsTarget).toHaveBeenCalledWith({
|
||||
pane: 'servers',
|
||||
repoId: null,
|
||||
sectionId: 'default-runtime'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows remote-server ownership for GitLab CLI credential checks', async () => {
|
||||
mocks.store.current = {
|
||||
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
|
||||
openSettingsPage: vi.fn(),
|
||||
openSettingsTarget: vi.fn()
|
||||
}
|
||||
mocks.preflight.statuses.glabStatus = 'not-authenticated'
|
||||
|
||||
const rendered = await renderCard(<GitLabIntegrationCard />)
|
||||
|
||||
expect(rendered.textContent).toContain('GitLab')
|
||||
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.'
|
||||
)
|
||||
expect(rendered.textContent).toContain('glab auth login')
|
||||
})
|
||||
})
|
||||
@@ -108,43 +108,6 @@ describe('LinearAgentSkillInstallCta', () => {
|
||||
container = null
|
||||
})
|
||||
|
||||
it('offers Install and the explanation when the skill is missing', async () => {
|
||||
const rendered = await renderCta()
|
||||
|
||||
expect(rendered.textContent).toContain('Agent skill:')
|
||||
expect(rendered.textContent).toContain('orca-linear')
|
||||
expect(rendered.textContent).toContain('Not installed')
|
||||
expect(rendered.textContent).toContain(
|
||||
'Full guided setup (connect + skill + visibility) is under Settings → Task Sources.'
|
||||
)
|
||||
expect(rendered.textContent).toContain('Install skill')
|
||||
expect(rendered.textContent).not.toContain('Copy command')
|
||||
})
|
||||
|
||||
it("installs the skill directly on the agents' host", async () => {
|
||||
const rendered = await renderCta()
|
||||
|
||||
await act(async () => {
|
||||
Array.from(rendered.querySelectorAll('button'))
|
||||
.find((button) => button.textContent === 'Install skill')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(mocks.plan).toHaveBeenCalledWith({ kind: 'local' }, { names: ['orca-linear'] })
|
||||
expect(rendered.textContent).toContain('Update Orca on the server to install its skills.')
|
||||
})
|
||||
|
||||
it('shows a subtle confirmation and Update when installed', async () => {
|
||||
mocks.skillState.installed = true
|
||||
mocks.skillState.skills = [discoveredSkill({ name: 'orca-linear' })]
|
||||
|
||||
const rendered = await renderCta()
|
||||
|
||||
expect(rendered.textContent).toContain('Installed')
|
||||
expect(rendered.textContent).toContain('Update')
|
||||
expect(rendered.textContent).not.toContain('Not installed')
|
||||
})
|
||||
|
||||
it('updates through the legacy skill name when only linear-tickets is installed', async () => {
|
||||
mocks.skillState.installed = true
|
||||
mocks.skillState.skills = [
|
||||
@@ -166,16 +129,6 @@ describe('LinearAgentSkillInstallCta', () => {
|
||||
expect(mocks.plan).toHaveBeenCalledWith({ kind: 'local' }, { names: ['linear-tickets'] })
|
||||
})
|
||||
|
||||
it('notes that remote agent environments need their own setup', async () => {
|
||||
const rendered = await renderCta({
|
||||
settings: { activeRuntimeEnvironmentId: 'runtime-1', terminalWindowsShell: '' }
|
||||
})
|
||||
|
||||
expect(rendered.textContent).toContain(
|
||||
'This installs host setup; remote agent environments may need separate setup.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the resolved WSL project on the old command flow', async () => {
|
||||
mocks.agentRuntime = { runtime: 'wsl', wslDistro: 'Debian', label: 'WSL Debian' }
|
||||
const rendered = await renderCta()
|
||||
@@ -189,31 +142,4 @@ describe('LinearAgentSkillInstallCta', () => {
|
||||
expect.objectContaining({ discoveryTarget: { runtime: 'wsl', wslDistro: 'Debian' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('uses project host discovery even when legacy global settings still prefer WSL', async () => {
|
||||
mocks.agentRuntime = { runtime: 'host', label: 'Windows' }
|
||||
await renderCta({
|
||||
settings: {
|
||||
localAgentRuntime: 'wsl',
|
||||
localAgentWslDistro: 'Ubuntu',
|
||||
terminalWindowsShell: 'wsl.exe'
|
||||
}
|
||||
})
|
||||
expect(mocks.useInstalledAgentSkillNames).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ discoveryTarget: undefined })
|
||||
)
|
||||
})
|
||||
|
||||
it('re-checks the skill scan on demand', async () => {
|
||||
const rendered = await renderCta()
|
||||
|
||||
await act(async () => {
|
||||
Array.from(rendered.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('Re-check'))
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(mocks.skillState.refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
+2
-101
@@ -4,17 +4,9 @@ import { act, render, cleanup, fireEvent } from '@testing-library/react'
|
||||
import '../../__fixtures__/settings-persistence-test-fixture'
|
||||
import React from 'react'
|
||||
import type { TuiAgent } from '../../../../../../shared/tui-agent'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { normalizeRepoSourceControlAiOverrides } from '../../../../../../shared/source-control-ai-repo-settings'
|
||||
import type {
|
||||
RepoSourceControlAiOverrides,
|
||||
SourceControlAiSettings
|
||||
} from '../../../../../../shared/source-control-ai-types'
|
||||
import {
|
||||
SOURCE_CONTROL_ACTION_IDS,
|
||||
setSourceControlActionAgentDefault,
|
||||
type SourceControlActionId
|
||||
} from '../../../../../../shared/source-control-ai-actions'
|
||||
import type { RepoSourceControlAiOverrides } from '../../../../../../shared/source-control-ai-types'
|
||||
import { setSourceControlActionAgentDefault } from '../../../../../../shared/source-control-ai-actions'
|
||||
import {
|
||||
computeActionDirtyById,
|
||||
dropRepoLegacyInstructionForAction,
|
||||
@@ -29,7 +21,6 @@ import {
|
||||
import { sourceControlActionRecipeMatchesTarget } from '../../../right-sidebar/source-control-action-recipe-match'
|
||||
import { SourceControlActionAgentPicker } from '../../SourceControlActionAgentPicker'
|
||||
import { RepositorySourceControlAiActionRows } from './RepositorySourceControlAiActionRows'
|
||||
import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement'
|
||||
import { SourceControlAiActionRecipeDefaults } from '../../SourceControlAiActionRecipeDefaults'
|
||||
import { useAppStore } from '../../../../store/index'
|
||||
import { TooltipProvider } from '../../../ui/tooltip'
|
||||
@@ -103,24 +94,6 @@ describe('saved chat-or-CLI pick', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('RepositorySourceControlAiEnablement', () => {
|
||||
it('describes repo enablement as Source Control AI action visibility', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(RepositorySourceControlAiEnablement, {
|
||||
value: false,
|
||||
source: { enabled: true } as SourceControlAiSettings,
|
||||
onChange: () => {}
|
||||
})
|
||||
)
|
||||
|
||||
expect(markup).toContain('Show Source Control AI actions')
|
||||
expect(markup).toContain('Controls whether Source Control AI buttons are shown')
|
||||
expect(markup).toContain('separate features follows those features' settings')
|
||||
expect(markup).toContain('Show')
|
||||
expect(markup).not.toContain('Source Control AI enabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RepositorySourceControlAiActionRows', () => {
|
||||
it.each([
|
||||
{ agent: 'codex', detected: ['codex'], surface: undefined },
|
||||
@@ -270,67 +243,6 @@ describe('RepositorySourceControlAiActionRows', () => {
|
||||
expect(onEdit).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([undefined, null, '', '--repo'])(
|
||||
'renders save controls and argument ownership for %s',
|
||||
(repoAgentArgs) => {
|
||||
const actionDirtyById = Object.fromEntries(
|
||||
SOURCE_CONTROL_ACTION_IDS.map((actionId) => [actionId, actionId === 'fixCommitFailure'])
|
||||
) as Record<SourceControlActionId, boolean>
|
||||
const source: SourceControlAiSettings = {
|
||||
enabled: true,
|
||||
agentId: null,
|
||||
selectedModelByAgent: {},
|
||||
selectedThinkingByModel: {},
|
||||
customAgentCommand: '',
|
||||
instructionsByOperation: {},
|
||||
actions: { fixCommitFailure: { agentId: null, agentArgs: '--global' } }
|
||||
}
|
||||
|
||||
const rows = React.createElement(RepositorySourceControlAiActionRows, {
|
||||
repoId: 'repo-1',
|
||||
repoAi: {
|
||||
actionOverrides: {
|
||||
fixCommitFailure: {
|
||||
agentId: null,
|
||||
commandInputTemplate: '{basePrompt}\n\nrepo',
|
||||
...(repoAgentArgs !== undefined ? { agentArgs: repoAgentArgs } : {})
|
||||
}
|
||||
}
|
||||
},
|
||||
source,
|
||||
defaultTuiAgent: 'codex',
|
||||
savingActionIds: {},
|
||||
actionDirtyById,
|
||||
onActionModeChange: () => {},
|
||||
onActionAgentChange: () => {},
|
||||
onActionLaunchPreferencesChange: () => {},
|
||||
onActionTemplateChange: () => {},
|
||||
onActionAgentArgsChange: () => {},
|
||||
onAppendVariable: () => {},
|
||||
onActionDiscard: () => {},
|
||||
onActionSave: () => {}
|
||||
})
|
||||
const markup = renderToStaticMarkup(React.createElement(TooltipProvider, null, rows))
|
||||
|
||||
expect(markup).toContain('Commit failure fixes')
|
||||
expect(markup).toContain('Push failure fixes')
|
||||
expect(markup).toContain('Unsaved changes')
|
||||
expect(markup).toContain('Discard')
|
||||
expect(markup).toContain('Save')
|
||||
expect(markup).toContain('repo-repo-1-source-control-ai-fixCommitFailure')
|
||||
const card = new DOMParser()
|
||||
.parseFromString(markup, 'text/html')
|
||||
.getElementById('repo-repo-1-source-control-ai-fixCommitFailure')
|
||||
expect(card?.textContent).toContain(
|
||||
repoAgentArgs === undefined
|
||||
? 'Global custom args'
|
||||
: repoAgentArgs
|
||||
? 'Repository custom args'
|
||||
: 'No args'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('dropRepoLegacyInstructionForAction', () => {
|
||||
@@ -353,17 +265,6 @@ describe('dropRepoLegacyInstructionForAction', () => {
|
||||
commandInputTemplate: '{basePrompt}\n\nUse PR style.'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves launch action recipes alone because they have no legacy instruction key', () => {
|
||||
const value = {
|
||||
instructionsByOperation: { commitMessage: 'Use repo style.' },
|
||||
actionOverrides: {
|
||||
fixChecks: { commandInputTemplate: '{basePrompt}' }
|
||||
}
|
||||
}
|
||||
|
||||
expect(dropRepoLegacyInstructionForAction(value, 'fixChecks')).toBe(value)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['', '--model raw'])(
|
||||
|
||||
-13
@@ -443,19 +443,6 @@ describe('editing one repository action', () => {
|
||||
expect(set().launchPreferences).toBeUndefined()
|
||||
})
|
||||
|
||||
it('makes the copied model owned by the action, so it outranks the raw model it yielded to', () => {
|
||||
const legacy = { actionOverrides: { fixCommitFailure: { agentArgs: '--model gpt-5.6-sol' } } }
|
||||
const stored = {
|
||||
settings,
|
||||
repo: { sourceControlAi: legacy },
|
||||
actionId: 'fixCommitFailure'
|
||||
} as const
|
||||
expect(resolveSourceControlActionRecipe(stored).launchPreferencesYieldToAgentArgs).toBe(true)
|
||||
const edited = reload(editPrompt(legacy, 'fixCommitFailure'), 'fixCommitFailure')
|
||||
expect(edited).toMatchObject({ commandInputTemplate: 'mine', launchPreferences: terra })
|
||||
expect(edited.launchPreferencesYieldToAgentArgs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('saves a prompt edit without authoring args the user never typed or touching a sibling', async () => {
|
||||
const sibling = { agentArgs: '--repo' }
|
||||
const cleared = {
|
||||
|
||||
-24
@@ -38,14 +38,6 @@ describe('createRepoAiPersistQueue', () => {
|
||||
h = makeQueue()
|
||||
})
|
||||
|
||||
it('persists a changed value and stores the normalized saved result', async () => {
|
||||
h.updateRepo.mockResolvedValue(true)
|
||||
const ok = await h.queue.persistTransform((base) => ({ ...base, enabled: true }))
|
||||
expect(ok).toBe(true)
|
||||
expect(h.updateRepo).toHaveBeenCalledTimes(1)
|
||||
expect(h.getPersisted()).toMatchObject({ enabled: true })
|
||||
})
|
||||
|
||||
it('skips the backend write when the transform is a no-op', async () => {
|
||||
h.updateRepo.mockResolvedValue(true)
|
||||
const ok = await h.queue.persistTransform((base) => base)
|
||||
@@ -128,20 +120,4 @@ describe('createRepoAiPersistQueue', () => {
|
||||
// Completing a stale write must not seed the new repo's local persisted base.
|
||||
expect(h.setPersisted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not apply setPersisted when the repo switches during an in-flight updateRepo', async () => {
|
||||
let release: (ok: boolean) => void = () => {}
|
||||
h.updateRepo.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
const pending = h.queue.persistTransform((base) => ({ ...base, enabled: true }))
|
||||
h.setRepoId('repo-2')
|
||||
release(true)
|
||||
await expect(pending).resolves.toBe(true)
|
||||
expect(h.setPersisted).not.toHaveBeenCalled()
|
||||
expect(h.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,36 +4,6 @@ import { resolveSettingsHostScope } from './settings-host-scope'
|
||||
const saved = ['env-a', 'env-b']
|
||||
|
||||
describe('resolveSettingsHostScope', () => {
|
||||
it('starts on the default host setting until the user picks one', () => {
|
||||
expect(
|
||||
resolveSettingsHostScope({
|
||||
choice: null,
|
||||
defaultEnvironmentId: ' env-a ',
|
||||
savedEnvironmentIds: saved,
|
||||
catalogHydrated: true
|
||||
})
|
||||
).toEqual({ target: { kind: 'environment', environmentId: 'env-a' }, available: true })
|
||||
})
|
||||
|
||||
it('keeps the picked host when the default host setting changes', () => {
|
||||
expect(
|
||||
resolveSettingsHostScope({
|
||||
choice: { kind: 'local' },
|
||||
defaultEnvironmentId: 'env-a',
|
||||
savedEnvironmentIds: saved,
|
||||
catalogHydrated: true
|
||||
})
|
||||
).toEqual({ target: { kind: 'local' }, available: true })
|
||||
expect(
|
||||
resolveSettingsHostScope({
|
||||
choice: { kind: 'environment', environmentId: 'env-b' },
|
||||
defaultEnvironmentId: null,
|
||||
savedEnvironmentIds: saved,
|
||||
catalogHydrated: true
|
||||
})
|
||||
).toEqual({ target: { kind: 'environment', environmentId: 'env-b' }, available: true })
|
||||
})
|
||||
|
||||
it('marks a removed server unavailable instead of falling back to this computer', () => {
|
||||
expect(
|
||||
resolveSettingsHostScope({
|
||||
|
||||
@@ -1,44 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KeybindingDefinition } from '../../../../shared/keybindings'
|
||||
import type { ActivePluginCommand } from '@/store/plugin-panels'
|
||||
import { buildShortcutDefinitionCatalog } from './shortcut-definition-catalog'
|
||||
import { groupDefinitions } from './shortcut-groups'
|
||||
|
||||
const pluginDefinition: KeybindingDefinition = {
|
||||
id: 'plugin:orca-samples.tasks/open',
|
||||
title: 'Open Tasks — Tasks',
|
||||
group: 'Plugins',
|
||||
scope: 'global',
|
||||
searchKeywords: ['plugin', 'tasks'],
|
||||
defaultBindings: {
|
||||
darwin: ['Mod+Alt+T'],
|
||||
linux: ['Mod+Alt+T'],
|
||||
win32: ['Mod+Alt+T']
|
||||
}
|
||||
}
|
||||
|
||||
describe('shortcut groups', () => {
|
||||
it('includes dynamic plugin command definitions in Settings', () => {
|
||||
expect(groupDefinitions([], [pluginDefinition])).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'Plugins',
|
||||
items: [pluginDefinition]
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes the agent dashboard toggle as a customizable Global row', () => {
|
||||
const global = groupDefinitions([]).find((group) => group.title === 'Global')
|
||||
|
||||
expect(global?.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'dashboard.toggle', title: 'Toggle Agent Dashboard' })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a plugin default that shadows a built-in shortcut', () => {
|
||||
const command: ActivePluginCommand = {
|
||||
pluginKey: 'orca-samples.tasks',
|
||||
|
||||
@@ -115,36 +115,6 @@ function renderSurface(
|
||||
)
|
||||
}
|
||||
|
||||
for (const surface of ['panel', 'guided-panel', 'linear', 'browser', 'capabilities'] as const) {
|
||||
describe(`${surface} blocked setup`, () => {
|
||||
describe.each([
|
||||
[
|
||||
'update-server',
|
||||
'Update Orca on the server to install its skills.',
|
||||
'Update Orca on the server'
|
||||
],
|
||||
['connect-host', 'Connect to this SSH host to install its skills.', 'Connect the SSH host']
|
||||
] as const)('%s', (reason, message, pill) => {
|
||||
it.each(surface === 'browser' || surface === 'capabilities' ? [false] : [false, true])(
|
||||
'explains the prerequisite before any click (installed: %s)',
|
||||
async (installed) => {
|
||||
mocks.installed = installed
|
||||
mocks.plan.mockResolvedValue({ kind: 'unsupported', reason })
|
||||
renderSurface(surface)
|
||||
|
||||
expect(await screen.findByText(message)).toBeDefined()
|
||||
expect(screen.queryByRole('button', { name: /^(Install|Update|Replace)/ })).toBeNull()
|
||||
expect(screen.queryByText('Check failed')).toBeNull()
|
||||
if (surface === 'panel' || surface === 'guided-panel' || surface === 'linear') {
|
||||
expect(screen.getByText(pill, { exact: true })).toBeDefined()
|
||||
}
|
||||
expect(mocks.execute).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const surface of ['panel', 'linear', 'browser', 'capabilities'] as const) {
|
||||
describe(`${surface} blocked setup`, () => {
|
||||
it.each([false, true])(
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '@/runtime/host-structured-agents'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import { SourceControlActionRecipeRow } from './SourceControlActionRecipeRow'
|
||||
import { shouldShowSourceControlActionArguments } from './source-control-action-arguments-visibility'
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: vi.fn(async () => ({ agents: [{ agent: 'grok', capabilities: {} }] }))
|
||||
@@ -67,27 +66,6 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(resetHostStructuredAgentsForTests)
|
||||
|
||||
it.each([
|
||||
['chat on', 'claude', true, undefined, false],
|
||||
['chat off', 'claude', false, undefined, true],
|
||||
['provider off', 'claude', true, { claude: false }, true],
|
||||
['no chat support', 'goose', true, undefined, true]
|
||||
] as const)('%s: shows Arguments only for the CLI', (_name, agent, chat, providers, visible) => {
|
||||
configure({ experimentalNativeChat: chat, nativeChatProviders: providers })
|
||||
render(<Row agent={agent} />)
|
||||
expect(Boolean(screen.queryByRole('textbox', { name: 'Arguments' }))).toBe(visible)
|
||||
expect(screen.getByText('Command template')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides an unresolved launch even with chat off, but always shows text action Arguments', () => {
|
||||
configure({ experimentalNativeChat: false })
|
||||
useAppStore.setState({ localDetectedAgentIdsByContext: {} })
|
||||
const view = render(<Row />)
|
||||
expect(screen.queryByRole('textbox', { name: 'Arguments' })).toBeNull()
|
||||
view.rerender(<Row actionId="commitMessage" />)
|
||||
expect(screen.getByDisplayValue('--saved')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the draft and chip while Model/Effort controls stay hidden', () => {
|
||||
configure({ experimentalNativeChat: false })
|
||||
render(<Row />)
|
||||
@@ -124,22 +102,3 @@ it('reacts to the real host registry when another agent gains chat support', asy
|
||||
)
|
||||
expect(screen.queryByRole('textbox', { name: 'Arguments' })).toBeNull()
|
||||
})
|
||||
|
||||
it('honors an explicit future surface only after an agent resolves', () => {
|
||||
const input = {
|
||||
agent: 'claude',
|
||||
settings: { experimentalNativeChat: true },
|
||||
chatAgents: ['claude']
|
||||
} as const
|
||||
expect(shouldShowSourceControlActionArguments({ ...input, launchKind: 'terminal' })).toBe(true)
|
||||
expect(
|
||||
shouldShowSourceControlActionArguments({
|
||||
...input,
|
||||
settings: { experimentalNativeChat: false },
|
||||
launchKind: 'chat'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldShowSourceControlActionArguments({ ...input, agent: null, launchKind: 'terminal' })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS,
|
||||
createLinearAgentSkillSetupActivationId,
|
||||
getExistingLinearAgentSkillSetupReminderState,
|
||||
getLinearAgentSkillSetupReminderState,
|
||||
getLinearAgentSkillSetupReminderStateCountForTests,
|
||||
@@ -77,13 +76,4 @@ describe('linear agent skill setup reminders', () => {
|
||||
expect(getExistingLinearAgentSkillSetupReminderState('missing')).toBeUndefined()
|
||||
expect(getLinearAgentSkillSetupReminderStateCountForTests()).toBe(0)
|
||||
})
|
||||
|
||||
it('resets activation ids with reminder state for tests', () => {
|
||||
expect(createLinearAgentSkillSetupActivationId()).toBe('linear-agent-skill-setup-0')
|
||||
expect(createLinearAgentSkillSetupActivationId()).toBe('linear-agent-skill-setup-1')
|
||||
|
||||
resetLinearAgentSkillSetupReminderState()
|
||||
|
||||
expect(createLinearAgentSkillSetupActivationId()).toBe('linear-agent-skill-setup-0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,148 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { SkillDeletePlan } from '../../../../shared/skill-delete-contract'
|
||||
import {
|
||||
skillDeleteBlockedLines,
|
||||
skillDeletePlacementSummary,
|
||||
skillDeleteResultLines,
|
||||
skillDeleteRetainedSourceLines
|
||||
} from './skill-delete-copy'
|
||||
import { skillDeletePlacementSummary } from './skill-delete-copy'
|
||||
|
||||
function plan(skills: SkillDeletePlan['skills']): SkillDeletePlan {
|
||||
return { operationId: 'op', skills }
|
||||
}
|
||||
|
||||
describe('skillDeletePlacementSummary', () => {
|
||||
it('summarizes folders, links, and the roots they sit in', () => {
|
||||
const summary = skillDeletePlacementSummary(
|
||||
plan([
|
||||
{
|
||||
id: 'a',
|
||||
name: 'demo',
|
||||
canonicalPath: '/root/demo/SKILL.md',
|
||||
placements: [
|
||||
{
|
||||
path: '/home/.agents/skills/demo',
|
||||
kind: 'canonical',
|
||||
rootLabel: 'Agent skills home'
|
||||
},
|
||||
{ path: '/home/.claude/skills/demo', kind: 'alias-dir', rootLabel: 'Claude Code home' }
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
expect(summary).toContain('1')
|
||||
expect(summary).toContain('Agent skills home')
|
||||
expect(summary).toContain('Claude Code home')
|
||||
})
|
||||
|
||||
it('drops the zero half so a link-only delete never reads "0 folders"', () => {
|
||||
const summary = skillDeletePlacementSummary(
|
||||
plan([
|
||||
{
|
||||
id: 'a',
|
||||
name: 'demo',
|
||||
canonicalPath: '/home/.local/share/devex/skills/demo/SKILL.md',
|
||||
placements: [
|
||||
{ path: '/repo/.agents/skills/demo', kind: 'alias-dir', rootLabel: 'Repo app .agents' }
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
expect(summary).not.toContain('0')
|
||||
expect(summary).toContain('link')
|
||||
expect(summary).not.toContain('folder')
|
||||
})
|
||||
|
||||
it('is null when the plan removes nothing', () => {
|
||||
expect(
|
||||
skillDeletePlacementSummary(
|
||||
plan([
|
||||
{
|
||||
id: 'a',
|
||||
name: 'demo',
|
||||
canonicalPath: '/elsewhere/demo/SKILL.md',
|
||||
placements: [],
|
||||
blocked: 'unowned'
|
||||
}
|
||||
])
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skillDeleteRetainedSourceLines', () => {
|
||||
it('says where the content stays when only links are removed', () => {
|
||||
const lines = skillDeleteRetainedSourceLines(
|
||||
plan([
|
||||
{
|
||||
id: 'a',
|
||||
name: 'demo',
|
||||
canonicalPath: '/home/.local/share/devex/skills/demo/SKILL.md',
|
||||
placements: [
|
||||
{ path: '/repo/.agents/skills/demo', kind: 'alias-dir', rootLabel: 'Repo app .agents' }
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0]).toContain('/home/.local/share/devex/skills/demo')
|
||||
})
|
||||
|
||||
it('stays silent when a real folder is being removed', () => {
|
||||
expect(
|
||||
skillDeleteRetainedSourceLines(
|
||||
plan([
|
||||
{
|
||||
id: 'a',
|
||||
name: 'demo',
|
||||
canonicalPath: '/home/.agents/skills/demo/SKILL.md',
|
||||
placements: [
|
||||
{
|
||||
path: '/home/.agents/skills/demo',
|
||||
kind: 'canonical',
|
||||
rootLabel: 'Agent skills home'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('skillDeleteBlockedLines', () => {
|
||||
it('groups by typed reason rather than one merged skipped line', () => {
|
||||
const lines = skillDeleteBlockedLines(
|
||||
plan([
|
||||
{ id: 'a', name: 'a', canonicalPath: '/p', placements: [], blocked: 'bundled' },
|
||||
{ id: 'b', name: 'b', canonicalPath: '/p', placements: [], blocked: 'bundled' },
|
||||
{ id: 'c', name: 'c', canonicalPath: '/p', placements: [], blocked: 'plugin' }
|
||||
])
|
||||
)
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines.some((line) => line.includes('2') && line.includes('Bundled'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skillDeleteResultLines', () => {
|
||||
it('omits deleted rows and keeps one line per distinct outcome', () => {
|
||||
const lines = skillDeleteResultLines([
|
||||
{ id: 'a', name: 'a', status: 'deleted', removedPaths: ['/x'] },
|
||||
{ id: 'b', name: 'b', status: 'skipped', blocked: 'unowned', removedPaths: [] },
|
||||
{ id: 'c', name: 'c', status: 'skipped', blocked: 'unowned', removedPaths: [] },
|
||||
{ id: 'd', name: 'd', status: 'busy', removedPaths: [] }
|
||||
])
|
||||
expect(lines.map((line) => line.key)).toEqual(['skipped:unowned', 'busy'])
|
||||
expect(lines[0].label).toContain('2')
|
||||
})
|
||||
|
||||
it('renders nothing when every skill was deleted', () => {
|
||||
expect(
|
||||
skillDeleteResultLines([{ id: 'a', name: 'a', status: 'deleted', removedPaths: ['/x'] }])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts deletion roots once with unchanged case, accent, and number ordering', () => {
|
||||
const labels = ['éclair', 'Eclair', 'item2', 'item10', 'Ångström', 'zebra', 'İstanbul']
|
||||
const expected = [...labels].sort((a, b) =>
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract'
|
||||
import { checklistItemSummary, checklistItemsFromVersion } from './skill-package-checklist-items'
|
||||
|
||||
type ManifestFile = {
|
||||
path: string
|
||||
size: number
|
||||
executable: boolean
|
||||
classification: 'binary' | 'text'
|
||||
sha256: string
|
||||
identitySha256: string
|
||||
}
|
||||
|
||||
function file(path: string, executable = false): ManifestFile {
|
||||
return {
|
||||
path,
|
||||
size: 1024,
|
||||
executable,
|
||||
classification: 'text' as const,
|
||||
sha256: 'a'.repeat(64),
|
||||
identitySha256: 'a'.repeat(64)
|
||||
}
|
||||
}
|
||||
|
||||
function singleVersion(): SkillCloudVersion {
|
||||
return {
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_1',
|
||||
name: 'published-name',
|
||||
description: 'Published description',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 128,
|
||||
createdAt: '2026-08-11T00:00:00.000Z',
|
||||
releaseNotes: '',
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_1',
|
||||
name: 'manifest-name',
|
||||
description: 'Manifest description',
|
||||
createdAt: '2026-08-11T00:00:00.000Z',
|
||||
files: [file('SKILL.md')],
|
||||
packageDigest: 'a'.repeat(64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('checklistItemsFromVersion', () => {
|
||||
it('turns a single-skill package into a one-row checklist', () => {
|
||||
expect(checklistItemsFromVersion(singleVersion())).toEqual([
|
||||
{
|
||||
id: 'manifest-name',
|
||||
name: 'published-name',
|
||||
description: 'Published description',
|
||||
files: [file('SKILL.md')]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps each bundled skill as its own row', () => {
|
||||
const bundled = {
|
||||
...singleVersion(),
|
||||
manifest: {
|
||||
schemaVersion: 1 as const,
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_1',
|
||||
bundleName: 'shared-skills',
|
||||
description: 'Synthetic bundle description',
|
||||
createdAt: '2026-08-11T00:00:00.000Z',
|
||||
bundleDigest: 'c'.repeat(64),
|
||||
skills: [
|
||||
{
|
||||
id: 'a',
|
||||
name: 'alpha',
|
||||
description: 'Alpha',
|
||||
digest: 'd'.repeat(64),
|
||||
files: [file('SKILL.md')]
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
name: 'beta',
|
||||
description: 'Beta',
|
||||
digest: 'e'.repeat(64),
|
||||
files: [file('SKILL.md')]
|
||||
}
|
||||
]
|
||||
}
|
||||
} as unknown as SkillCloudVersion
|
||||
expect(checklistItemsFromVersion(bundled).map((item) => item.name)).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('checklistItemSummary', () => {
|
||||
it('keeps instruction-only and supporting-file summaries simple', () => {
|
||||
expect(checklistItemSummary([file('SKILL.md')])).toEqual({
|
||||
label: '1 file'
|
||||
})
|
||||
expect(checklistItemSummary([file('SKILL.md'), file('references/guide.md')])).toEqual({
|
||||
label: '2 files'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps runnable and binary classifications out of the row summary', () => {
|
||||
expect(checklistItemSummary([file('SKILL.md'), file('scripts/setup.sh')])).toEqual({
|
||||
label: '2 files'
|
||||
})
|
||||
expect(checklistItemSummary([file('bin/tool', true)])).toEqual({ label: '1 file' })
|
||||
const binary = { ...file('assets/logo.png'), classification: 'binary' as const }
|
||||
expect(checklistItemSummary([file('SKILL.md'), binary])).toEqual({
|
||||
label: '2 files'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isSkillRunnableFile,
|
||||
summarizeSkillInstallRisk,
|
||||
type SkillInstallRiskFile
|
||||
} from './skill-package-install-risk'
|
||||
|
||||
function file(
|
||||
path: string,
|
||||
options: { classification?: 'text' | 'binary'; executable?: boolean } = {}
|
||||
): SkillInstallRiskFile {
|
||||
return {
|
||||
path,
|
||||
classification: options.classification ?? 'text',
|
||||
executable: options.executable ?? false
|
||||
}
|
||||
}
|
||||
|
||||
describe('isSkillRunnableFile', () => {
|
||||
it('recognizes scripts outside the scripts folder and executable files without extensions', () => {
|
||||
expect(isSkillRunnableFile(file('release.py'))).toBe(true)
|
||||
expect(isSkillRunnableFile(file('tools/setup.PS1'))).toBe(true)
|
||||
expect(isSkillRunnableFile(file('bin/team-tool'))).toBe(true)
|
||||
expect(isSkillRunnableFile(file('references/guide.md', { executable: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not label ordinary supporting files as runnable', () => {
|
||||
expect(isSkillRunnableFile(file('references/guide.md'))).toBe(false)
|
||||
expect(isSkillRunnableFile(file('assets/logo.png', { classification: 'binary' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeSkillInstallRisk', () => {
|
||||
const items = [
|
||||
{ id: 'plain', name: 'plain', files: [file('SKILL.md')] },
|
||||
{
|
||||
id: 'docs',
|
||||
name: 'docs',
|
||||
files: [file('SKILL.md'), file('references/guide.md')]
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
name: 'tools',
|
||||
files: [
|
||||
file('SKILL.md'),
|
||||
file('release.py'),
|
||||
file('bin/tool', { classification: 'binary', executable: true })
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
it('separates supporting files from content that needs acknowledgement', () => {
|
||||
expect(summarizeSkillInstallRisk(items, new Set(['docs']))).toMatchObject({
|
||||
selectedSkillCount: 1,
|
||||
additionalFileCount: 1,
|
||||
runnableFileCount: 0,
|
||||
binaryFileCount: 0,
|
||||
cautionFileCount: 0,
|
||||
requiresAcknowledgement: false
|
||||
})
|
||||
})
|
||||
|
||||
it('counts a runnable binary once for the acknowledgement gate', () => {
|
||||
expect(summarizeSkillInstallRisk(items, new Set(['tools']))).toEqual({
|
||||
selectedSkillCount: 1,
|
||||
additionalFileCount: 2,
|
||||
runnableFileCount: 2,
|
||||
binaryFileCount: 1,
|
||||
cautionFileCount: 2,
|
||||
cautionSkillNames: ['tools'],
|
||||
requiresAcknowledgement: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
isWorkspaceSpaceRowReadyToDelete,
|
||||
pruneWorkspaceSpaceSelectedIds,
|
||||
resolveWorkspaceSpaceInspectedWorktreeId,
|
||||
resolveWorkspaceSpaceTreemapZoomWorktreeId,
|
||||
sortWorkspaceSpaceRows
|
||||
resolveWorkspaceSpaceTreemapZoomWorktreeId
|
||||
} from './workspace-space-presentation'
|
||||
import { getWorkspaceSpaceGitStatusRefreshCandidates } from './workspace-space-git-status-order'
|
||||
import { getWorkspaceDecisionDetails } from './workspace-space-decision-details'
|
||||
@@ -228,37 +227,6 @@ describe('workspace space presentation helpers', () => {
|
||||
expect(isWorkspaceSpaceRowReadyToDelete(sshRow, ssh)).toBe(false)
|
||||
})
|
||||
|
||||
it('sorts rows by the selected key and direction', () => {
|
||||
const rows = [
|
||||
row({ worktreeId: 'small', displayName: 'Small', sizeBytes: 10 }),
|
||||
row({ worktreeId: 'large', displayName: 'Large', sizeBytes: 100 }),
|
||||
row({ worktreeId: 'mid', displayName: 'Mid', sizeBytes: 50 })
|
||||
]
|
||||
|
||||
expect(sortWorkspaceSpaceRows(rows, 'size', 'desc').map((item) => item.worktreeId)).toEqual([
|
||||
'large',
|
||||
'mid',
|
||||
'small'
|
||||
])
|
||||
expect(sortWorkspaceSpaceRows(rows, 'name', 'asc').map((item) => item.worktreeId)).toEqual([
|
||||
'large',
|
||||
'mid',
|
||||
'small'
|
||||
])
|
||||
})
|
||||
|
||||
it('filters by search text and deletable status', () => {
|
||||
const rows = [
|
||||
row({ worktreeId: 'a', displayName: 'Frontend Cache', repoDisplayName: 'app' }),
|
||||
row({ worktreeId: 'b', displayName: 'Main', repoDisplayName: 'api', canDelete: false })
|
||||
]
|
||||
|
||||
expect(filterWorkspaceSpaceRows(rows, 'cache', false).map((item) => item.worktreeId)).toEqual([
|
||||
'a'
|
||||
])
|
||||
expect(filterWorkspaceSpaceRows(rows, '', true).map((item) => item.worktreeId)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('rejects oversized pasted filters before reading workspace rows', () => {
|
||||
const oversizedQuery = 'secret-workspace-space'.repeat(WORKSPACE_SPACE_FILTER_QUERY_MAX_BYTES)
|
||||
const rows = [
|
||||
@@ -456,117 +424,6 @@ describe('workspace space presentation helpers', () => {
|
||||
expect(details.reviewLabel).toBeNull()
|
||||
})
|
||||
|
||||
it('hides only a matching suppressed GitHub review from workspace decisions', () => {
|
||||
const matching = getWorkspaceDecisionDetails(
|
||||
row({ branch: 'refs/heads/feature/local' }),
|
||||
decisionInputs({
|
||||
hostedReviewCache: {
|
||||
'local::repo::feature/local': {
|
||||
data: {
|
||||
provider: 'github',
|
||||
number: 12,
|
||||
state: 'open',
|
||||
status: 'success',
|
||||
title: 'Suppressed PR'
|
||||
}
|
||||
}
|
||||
},
|
||||
worktreeMap: new Map([
|
||||
[
|
||||
'wt',
|
||||
worktreeRecord({
|
||||
branch: 'refs/heads/feature/local',
|
||||
linkedPR: null,
|
||||
suppressedGitHubPR: 12
|
||||
})
|
||||
]
|
||||
])
|
||||
})
|
||||
)
|
||||
const different = getWorkspaceDecisionDetails(
|
||||
row({ branch: 'refs/heads/feature/local' }),
|
||||
decisionInputs({
|
||||
hostedReviewCache: {
|
||||
'local::repo::feature/local': {
|
||||
data: {
|
||||
provider: 'github',
|
||||
number: 13,
|
||||
state: 'open',
|
||||
status: 'success',
|
||||
title: 'Different PR'
|
||||
}
|
||||
}
|
||||
},
|
||||
worktreeMap: new Map([
|
||||
[
|
||||
'wt',
|
||||
worktreeRecord({
|
||||
branch: 'refs/heads/feature/local',
|
||||
linkedPR: null,
|
||||
suppressedGitHubPR: 12
|
||||
})
|
||||
]
|
||||
])
|
||||
})
|
||||
)
|
||||
|
||||
expect(matching.reviewLabel).toBeNull()
|
||||
expect(different.reviewLabel).toBe('PR #13 Open, success')
|
||||
})
|
||||
|
||||
it('preserves explicit links and non-GitHub reviews in workspace decisions', () => {
|
||||
const cachedReview = {
|
||||
number: 12,
|
||||
state: 'open',
|
||||
status: 'success',
|
||||
title: 'Review'
|
||||
}
|
||||
const explicit = getWorkspaceDecisionDetails(
|
||||
row({ branch: 'refs/heads/feature/local' }),
|
||||
decisionInputs({
|
||||
hostedReviewCache: {
|
||||
'local::repo::feature/local': {
|
||||
data: { ...cachedReview, provider: 'github' }
|
||||
}
|
||||
},
|
||||
worktreeMap: new Map([
|
||||
[
|
||||
'wt',
|
||||
worktreeRecord({
|
||||
branch: 'refs/heads/feature/local',
|
||||
linkedPR: 12,
|
||||
suppressedGitHubPR: 12
|
||||
})
|
||||
]
|
||||
])
|
||||
})
|
||||
)
|
||||
const gitLab = getWorkspaceDecisionDetails(
|
||||
row({ branch: 'refs/heads/feature/local' }),
|
||||
decisionInputs({
|
||||
hostedReviewCache: {
|
||||
'local::repo::feature/local': {
|
||||
data: { ...cachedReview, provider: 'gitlab' }
|
||||
}
|
||||
},
|
||||
worktreeMap: new Map([
|
||||
[
|
||||
'wt',
|
||||
worktreeRecord({
|
||||
branch: 'refs/heads/feature/local',
|
||||
linkedPR: null,
|
||||
suppressedGitHubPR: 12,
|
||||
linkedGitLabMR: 12
|
||||
})
|
||||
]
|
||||
])
|
||||
})
|
||||
)
|
||||
|
||||
expect(explicit.reviewLabel).toBe('PR #12 Open, success')
|
||||
expect(gitLab.reviewLabel).toBe('MR !12 Open, success')
|
||||
})
|
||||
|
||||
it('counts migration-unsupported agent entries by worktree id', () => {
|
||||
const count = countWorkspaceSpaceActiveAgents({
|
||||
worktreeId: 'wt',
|
||||
|
||||
+1
-109
@@ -1,61 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getCandidateFactStatuses,
|
||||
getDirtyGitLabel,
|
||||
shouldShowGitMetadataChip
|
||||
} from './workspace-cleanup-candidate-row-data'
|
||||
import { getCandidateFactStatuses } from './workspace-cleanup-candidate-row-data'
|
||||
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
|
||||
|
||||
describe('workspace cleanup candidate row data', () => {
|
||||
it('shows facts instead of cleanup policy tiers', () => {
|
||||
expect(getCandidateFactStatuses(makeCandidate({ tier: 'ready' }))).toEqual([])
|
||||
expect(getCandidateFactStatuses(makeCandidate({ tier: 'review' }))).toEqual([])
|
||||
expect(getCandidateFactStatuses(makeCandidate({ reasons: ['archived'] }))).toContainEqual(
|
||||
expect.objectContaining({ label: 'Archived' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not duplicate git status blockers as a separate git icon label', () => {
|
||||
const gitStatusError = makeCandidate({
|
||||
blockers: ['git-status-error'],
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: null
|
||||
}
|
||||
})
|
||||
const unknownBase = makeCandidate({
|
||||
blockers: ['unknown-base'],
|
||||
git: {
|
||||
clean: true,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: 1
|
||||
}
|
||||
})
|
||||
|
||||
expect(getDirtyGitLabel(gitStatusError)).toBe('Git status check failed')
|
||||
expect(shouldShowGitMetadataChip(gitStatusError)).toBe(false)
|
||||
expect(getDirtyGitLabel(unknownBase)).toBe('Git status could not be verified')
|
||||
expect(shouldShowGitMetadataChip(unknownBase)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the git metadata chip for ordinary clean rows', () => {
|
||||
expect(
|
||||
shouldShowGitMetadataChip(
|
||||
makeCandidate({
|
||||
git: {
|
||||
clean: true,
|
||||
upstreamAhead: 0,
|
||||
upstreamBehind: 0,
|
||||
checkedAt: 1
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps unpushed risk visible for archived rows', () => {
|
||||
expect(
|
||||
getCandidateFactStatuses(
|
||||
@@ -72,59 +19,4 @@ describe('workspace cleanup candidate row data', () => {
|
||||
)
|
||||
).toContainEqual(expect.objectContaining({ label: 'Unpushed commits' }))
|
||||
})
|
||||
|
||||
it('renders every blocker fact instead of only the first', () => {
|
||||
expect(
|
||||
getCandidateFactStatuses(makeCandidate({ blockers: ['pinned', 'git-status-error'] }))
|
||||
).toEqual([
|
||||
expect.objectContaining({ label: 'Pinned' }),
|
||||
expect.objectContaining({
|
||||
label: 'Git status unavailable',
|
||||
tone: 'destructive'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('suppresses the git metadata chip when the status pill already names git risk', () => {
|
||||
expect(
|
||||
shouldShowGitMetadataChip(
|
||||
makeCandidate({
|
||||
blockers: ['unpushed-commits'],
|
||||
git: {
|
||||
clean: true,
|
||||
upstreamAhead: 2,
|
||||
upstreamBehind: 0,
|
||||
checkedAt: 1
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldShowGitMetadataChip(
|
||||
makeCandidate({
|
||||
tier: 'review',
|
||||
blockers: [],
|
||||
git: {
|
||||
clean: true,
|
||||
upstreamAhead: 2,
|
||||
upstreamBehind: 0,
|
||||
checkedAt: 1
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldShowGitMetadataChip(
|
||||
makeCandidate({
|
||||
blockers: ['dirty-files'],
|
||||
git: {
|
||||
clean: false,
|
||||
upstreamAhead: 0,
|
||||
upstreamBehind: 0,
|
||||
checkedAt: 1
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
-213
@@ -33,108 +33,6 @@ describe('CandidateRow', () => {
|
||||
container = null
|
||||
})
|
||||
|
||||
it('does not present cleanup policy tiers as workspace facts', () => {
|
||||
const candidate = makeCandidate({ tier: 'ready' })
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CandidateRow
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
expanded={false}
|
||||
last
|
||||
lastActivityLabel="1d ago"
|
||||
reviewInfo={{
|
||||
hasReview: false,
|
||||
label: null,
|
||||
provider: null,
|
||||
state: null,
|
||||
title: null
|
||||
}}
|
||||
selected
|
||||
onIgnore={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onToggleExpanded={vi.fn()}
|
||||
onToggleSelected={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(container?.textContent).not.toContain('Ready')
|
||||
expect(container?.textContent).not.toContain('Status unavailable')
|
||||
})
|
||||
|
||||
it('shows factual status, disk size, activity, and git state before expansion', () => {
|
||||
const candidate = makeCandidate({ tier: 'ready' })
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CandidateRow
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
expanded={false}
|
||||
last
|
||||
lastActivityLabel="1d ago"
|
||||
sizeLabel="4.00 MB"
|
||||
workspaceStatusLabel="In progress"
|
||||
reviewInfo={{
|
||||
hasReview: false,
|
||||
label: null,
|
||||
provider: null,
|
||||
state: null,
|
||||
title: null
|
||||
}}
|
||||
selected
|
||||
onIgnore={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onToggleExpanded={vi.fn()}
|
||||
onToggleSelected={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(container?.textContent).toContain('In progress')
|
||||
expect(container?.textContent).toContain('4.00 MB')
|
||||
expect(container?.textContent).toContain('1d')
|
||||
expect(container?.textContent).toContain('Clean')
|
||||
})
|
||||
|
||||
it('uses an external-link action to open the workspace', () => {
|
||||
const candidate = makeCandidate()
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CandidateRow
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
expanded={false}
|
||||
last
|
||||
lastActivityLabel="1d ago"
|
||||
reviewInfo={{
|
||||
hasReview: false,
|
||||
label: null,
|
||||
provider: null,
|
||||
state: null,
|
||||
title: null
|
||||
}}
|
||||
selected
|
||||
onIgnore={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onToggleExpanded={vi.fn()}
|
||||
onToggleSelected={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const openButton = container?.querySelector(
|
||||
`[aria-label="Open ${getWorkspaceCleanupCandidateAccessibleName(candidate)}"]`
|
||||
)
|
||||
expect(openButton?.querySelector('.lucide-external-link')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('hides selection and remove controls while the workspace is already deleting', () => {
|
||||
const candidate = makeCandidate()
|
||||
|
||||
@@ -169,43 +67,6 @@ describe('CandidateRow', () => {
|
||||
expect(container?.querySelector(`[aria-label="Remove ${accessibleName}"]`)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['deleting' as const, 'Deleting…'],
|
||||
['queued' as const, 'Queued for deletion']
|
||||
])('replaces the status pill with the %s state', (deletionPhase, label) => {
|
||||
const candidate = makeCandidate()
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CandidateRow
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
deletionPhase={deletionPhase}
|
||||
expanded={false}
|
||||
last
|
||||
lastActivityLabel="1d ago"
|
||||
removing
|
||||
reviewInfo={{
|
||||
hasReview: false,
|
||||
label: null,
|
||||
provider: null,
|
||||
state: null,
|
||||
title: null
|
||||
}}
|
||||
selected
|
||||
onIgnore={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onToggleExpanded={vi.fn()}
|
||||
onToggleSelected={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(container?.textContent).toContain(label)
|
||||
expect(container?.textContent).not.toContain('Ready')
|
||||
})
|
||||
|
||||
it('visibly and accessibly distinguishes colliding local and SSH rows', () => {
|
||||
const local = makeCandidate({ executionHostId: 'local' })
|
||||
const remote = makeCandidate({ connectionId: 'builder', executionHostId: 'ssh:builder' })
|
||||
@@ -247,78 +108,4 @@ describe('CandidateRow', () => {
|
||||
expect(container?.querySelector(`[aria-label="Select ${remoteName}"]`)).not.toBeNull()
|
||||
expect(container?.querySelector(`[aria-label="Host: builder"]`)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('colors the review pill by state the way the PR page and item dialog do', () => {
|
||||
const states = [
|
||||
{ state: 'merged' as const, label: 'PR #1', hue: 'purple' },
|
||||
{ state: 'closed' as const, label: 'PR #2', hue: 'rose' },
|
||||
{ state: 'open' as const, label: 'PR #3', hue: 'emerald' },
|
||||
{ state: 'draft' as const, label: 'PR #4', hue: 'slate' }
|
||||
]
|
||||
|
||||
for (const { state, label, hue } of states) {
|
||||
const candidate = makeCandidate()
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CandidateRow
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
expanded={false}
|
||||
last
|
||||
lastActivityLabel="1d ago"
|
||||
reviewInfo={{
|
||||
hasReview: true,
|
||||
label,
|
||||
provider: 'github',
|
||||
state,
|
||||
title: 'Some change'
|
||||
}}
|
||||
selected={false}
|
||||
onIgnore={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onToggleExpanded={vi.fn()}
|
||||
onToggleSelected={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const pill = container?.querySelector(`[aria-label*="${label}"]`)
|
||||
expect(pill?.className).toContain(`text-${hue}-`)
|
||||
expect(pill?.className).not.toContain('text-muted-foreground')
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a review with an unfetched state neutral', () => {
|
||||
const candidate = makeCandidate()
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CandidateRow
|
||||
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
|
||||
candidate={candidate}
|
||||
expanded={false}
|
||||
last
|
||||
lastActivityLabel="1d ago"
|
||||
reviewInfo={{
|
||||
hasReview: true,
|
||||
label: 'PR #9',
|
||||
provider: 'github',
|
||||
state: 'unknown',
|
||||
title: null
|
||||
}}
|
||||
selected={false}
|
||||
onIgnore={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onToggleExpanded={vi.fn()}
|
||||
onToggleSelected={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const pill = container?.querySelector('[aria-label*="PR #9"]')
|
||||
expect(pill?.className).toContain('text-muted-foreground')
|
||||
})
|
||||
})
|
||||
|
||||
+1
-230
@@ -3,11 +3,7 @@ import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-iden
|
||||
import {
|
||||
filterWorkspaceCleanupCandidates,
|
||||
getWorkspaceCleanupGitLabel,
|
||||
getWorkspaceCleanupReviewInfo,
|
||||
getWorkspaceCleanupSearchText,
|
||||
hasWorkspaceCleanupLocalContext,
|
||||
sortWorkspaceCleanupCandidates,
|
||||
type WorkspaceCleanupReviewInfo
|
||||
getWorkspaceCleanupReviewInfo
|
||||
} from './workspace-cleanup-presentation'
|
||||
import {
|
||||
DEFAULT_FILTERS,
|
||||
@@ -19,22 +15,6 @@ import {
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
|
||||
describe('workspace cleanup presentation', () => {
|
||||
it('finds hosted review details from renderer state', () => {
|
||||
const cacheKey = getHostedReviewCacheKey('/repo', 'alpha', {}, 'repo-1', undefined)
|
||||
const state = makeState({
|
||||
hostedReviewCache: {
|
||||
[cacheKey]: { data: makeReview(), fetchedAt: NOW }
|
||||
}
|
||||
})
|
||||
|
||||
expect(getWorkspaceCleanupReviewInfo(makeCandidate(), state)).toMatchObject({
|
||||
hasReview: true,
|
||||
label: 'PR #42',
|
||||
state: 'open',
|
||||
title: 'Review alpha cleanup'
|
||||
})
|
||||
})
|
||||
|
||||
it('hides only the matching suppressed GitHub review', () => {
|
||||
const cacheKey = getHostedReviewCacheKey('/repo', 'alpha', {}, 'repo-1', undefined)
|
||||
const matching = makeState({
|
||||
@@ -65,48 +45,6 @@ describe('workspace cleanup presentation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves explicit GitHub links and non-GitHub reviews despite stale suppression', () => {
|
||||
const cacheKey = getHostedReviewCacheKey('/repo', 'alpha', {}, 'repo-1', undefined)
|
||||
const explicit = makeState({
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
...makeState().worktreesByRepo['repo-1']![0]!,
|
||||
linkedPR: 42,
|
||||
suppressedGitHubPR: 42
|
||||
}
|
||||
]
|
||||
},
|
||||
hostedReviewCache: {
|
||||
[cacheKey]: { data: makeReview({ number: 42 }), fetchedAt: NOW }
|
||||
}
|
||||
})
|
||||
const gitLab = makeState({
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
...makeState().worktreesByRepo['repo-1']![0]!,
|
||||
linkedPR: null,
|
||||
suppressedGitHubPR: 42,
|
||||
linkedGitLabMR: 42
|
||||
}
|
||||
]
|
||||
},
|
||||
hostedReviewCache: {
|
||||
[cacheKey]: {
|
||||
data: makeReview({ provider: 'gitlab', number: 42 }),
|
||||
fetchedAt: NOW
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(getWorkspaceCleanupReviewInfo(makeCandidate(), explicit).hasReview).toBe(true)
|
||||
expect(getWorkspaceCleanupReviewInfo(makeCandidate(), gitLab)).toMatchObject({
|
||||
hasReview: true,
|
||||
provider: 'gitlab'
|
||||
})
|
||||
})
|
||||
|
||||
it('host-qualifies same-id repo, worktree, and review cache joins', () => {
|
||||
const base = makeState()
|
||||
const localRepo = { ...base.repos[0]!, executionHostId: 'local' as const }
|
||||
@@ -177,66 +115,6 @@ describe('workspace cleanup presentation', () => {
|
||||
expect(getWorkspaceCleanupReviewInfo(makeCandidate(), state).hasReview).toBe(false)
|
||||
})
|
||||
|
||||
it('filters by time, review, git, and context', () => {
|
||||
const openReview: WorkspaceCleanupReviewInfo = {
|
||||
hasReview: true,
|
||||
label: 'PR #7',
|
||||
state: 'open',
|
||||
provider: 'github',
|
||||
title: 'Review row'
|
||||
}
|
||||
const rows = [
|
||||
makeCandidate({ worktreeId: 'repo-1::/repo/clean', displayName: 'clean' }),
|
||||
makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/dirty-review',
|
||||
displayName: 'dirty-review',
|
||||
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW },
|
||||
lastActivityAt: NOW - 100 * 24 * 60 * 60 * 1000,
|
||||
localContext: {
|
||||
terminalTabCount: 1,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: 0,
|
||||
newestDiffCommentAt: null,
|
||||
retainedDoneAgentCount: 0
|
||||
}
|
||||
}),
|
||||
makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/unknown',
|
||||
displayName: 'unknown',
|
||||
git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null },
|
||||
blockers: ['git-status-error']
|
||||
})
|
||||
]
|
||||
const reviewInfo = new Map<string, WorkspaceCleanupReviewInfo>([
|
||||
['repo-1::/repo/dirty-review', openReview]
|
||||
])
|
||||
|
||||
expect(
|
||||
filterWorkspaceCleanupCandidates(
|
||||
rows,
|
||||
{
|
||||
...DEFAULT_FILTERS,
|
||||
time: '90d',
|
||||
review: 'open-review',
|
||||
git: 'dirty',
|
||||
context: 'has-context'
|
||||
},
|
||||
reviewInfo,
|
||||
NOW
|
||||
).map((row) => row.displayName)
|
||||
).toEqual(['dirty-review'])
|
||||
|
||||
expect(
|
||||
filterWorkspaceCleanupCandidates(
|
||||
rows,
|
||||
{ ...DEFAULT_FILTERS, git: 'unknown' },
|
||||
reviewInfo,
|
||||
NOW
|
||||
).map((row) => row.displayName)
|
||||
).toEqual(['unknown'])
|
||||
})
|
||||
|
||||
it('treats unknown-base clean rows as unknown git risk', () => {
|
||||
const candidate = makeCandidate({
|
||||
git: { clean: true, upstreamAhead: null, upstreamBehind: null, checkedAt: NOW },
|
||||
@@ -261,111 +139,4 @@ describe('workspace cleanup presentation', () => {
|
||||
)
|
||||
).toEqual([candidate])
|
||||
})
|
||||
|
||||
it('search includes repo, branch, path, review, git, and context labels', () => {
|
||||
const candidate = makeCandidate({
|
||||
repoName: 'Search Repo',
|
||||
branch: 'feature/search',
|
||||
path: '/repo/search-target',
|
||||
git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null },
|
||||
localContext: {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 1,
|
||||
diffCommentCount: 0,
|
||||
newestDiffCommentAt: null,
|
||||
retainedDoneAgentCount: 0
|
||||
}
|
||||
})
|
||||
const reviewInfo: WorkspaceCleanupReviewInfo = {
|
||||
hasReview: true,
|
||||
label: 'MR #9',
|
||||
state: 'closed',
|
||||
provider: 'gitlab',
|
||||
title: 'Searchable review'
|
||||
}
|
||||
const text = getWorkspaceCleanupSearchText(candidate, reviewInfo)
|
||||
|
||||
expect(text).toContain('search repo')
|
||||
expect(text).toContain('feature/search')
|
||||
expect(text).toContain('/repo/search-target')
|
||||
expect(text).toContain('mr #9')
|
||||
expect(text).toContain('unknown')
|
||||
expect(text).toContain('has context')
|
||||
})
|
||||
|
||||
it('sorts by activity, name, repo, review, and git', () => {
|
||||
const rows = [
|
||||
makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/bravo',
|
||||
displayName: 'bravo',
|
||||
repoName: 'Repo B',
|
||||
lastActivityAt: NOW - 5,
|
||||
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }
|
||||
}),
|
||||
makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/alpha',
|
||||
displayName: 'alpha',
|
||||
repoName: 'Repo A',
|
||||
lastActivityAt: NOW - 10,
|
||||
git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }
|
||||
}),
|
||||
makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/charlie',
|
||||
displayName: 'charlie',
|
||||
repoName: 'Repo C',
|
||||
lastActivityAt: NOW - 1,
|
||||
git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: NOW },
|
||||
blockers: ['unpushed-commits']
|
||||
})
|
||||
]
|
||||
const reviewInfo = new Map<string, WorkspaceCleanupReviewInfo>([
|
||||
[
|
||||
'repo-1::/repo/charlie',
|
||||
{
|
||||
hasReview: true,
|
||||
label: 'PR #3',
|
||||
state: 'open',
|
||||
provider: 'github',
|
||||
title: null
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
expect(
|
||||
sortWorkspaceCleanupCandidates(rows, 'activity', 'asc').map((row) => row.displayName)
|
||||
).toEqual(['alpha', 'bravo', 'charlie'])
|
||||
expect(
|
||||
sortWorkspaceCleanupCandidates(rows, 'name', 'asc').map((row) => row.displayName)
|
||||
).toEqual(['alpha', 'bravo', 'charlie'])
|
||||
expect(
|
||||
sortWorkspaceCleanupCandidates(rows, 'repo', 'asc').map((row) => row.displayName)
|
||||
).toEqual(['alpha', 'bravo', 'charlie'])
|
||||
expect(
|
||||
sortWorkspaceCleanupCandidates(rows, 'review', 'desc', reviewInfo).map(
|
||||
(row) => row.displayName
|
||||
)
|
||||
).toEqual(['charlie', 'alpha', 'bravo'])
|
||||
expect(
|
||||
sortWorkspaceCleanupCandidates(rows, 'git', 'desc').map((row) => row.displayName)
|
||||
).toEqual(['charlie', 'bravo', 'alpha'])
|
||||
})
|
||||
|
||||
it('detects local context', () => {
|
||||
expect(hasWorkspaceCleanupLocalContext(makeCandidate())).toBe(false)
|
||||
expect(
|
||||
hasWorkspaceCleanupLocalContext(
|
||||
makeCandidate({
|
||||
localContext: {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 1,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: 0,
|
||||
newestDiffCommentAt: null,
|
||||
retainedDoneAgentCount: 0
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,12 +35,6 @@ function render(sort: WorkspaceCleanupSortState, handlers: Record<string, unknow
|
||||
)
|
||||
}
|
||||
|
||||
function sortOption(label: string): HTMLButtonElement | undefined {
|
||||
return [...(container?.querySelectorAll<HTMLButtonElement>('[data-sort-option]') ?? [])].find(
|
||||
(button) => button.textContent?.trim() === label
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkspaceCleanupSortHeader', () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
@@ -57,34 +51,6 @@ describe('WorkspaceCleanupSortHeader', () => {
|
||||
container = null
|
||||
})
|
||||
|
||||
it('shows the active sort in one menu trigger', () => {
|
||||
render({ field: 'size', direction: 'desc' })
|
||||
|
||||
expect(container?.querySelector('[aria-label="Sort by Size"]')?.textContent).toContain(
|
||||
'Sort by Size'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes a menu choice through the sort toggle', () => {
|
||||
const onToggleSortField = vi.fn()
|
||||
render({ field: 'last-activity', direction: 'asc' }, { onToggleSortField })
|
||||
|
||||
act(() => sortOption('Repository')?.click())
|
||||
|
||||
expect(onToggleSortField).toHaveBeenCalledWith('repo')
|
||||
})
|
||||
|
||||
it('select-all reads the query result rather than the rendered page', () => {
|
||||
const onToggleSelectAll = vi.fn()
|
||||
render({ field: 'name', direction: 'asc' }, { onToggleSelectAll })
|
||||
|
||||
const checkbox = container?.querySelector<HTMLButtonElement>('[role="checkbox"]')
|
||||
expect(checkbox?.getAttribute('aria-checked')).toBe('false')
|
||||
act(() => checkbox?.click())
|
||||
|
||||
expect(onToggleSelectAll).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('exposes a partial selection as mixed', () => {
|
||||
render({ field: 'name', direction: 'asc' }, { selectableCount: 3, selectedCount: 1 })
|
||||
|
||||
@@ -92,29 +58,4 @@ describe('WorkspaceCleanupSortHeader', () => {
|
||||
container?.querySelector<HTMLButtonElement>('[role="checkbox"]')?.getAttribute('aria-checked')
|
||||
).toBe('mixed')
|
||||
})
|
||||
it('states how many workspaces select-all would take', () => {
|
||||
render({ field: 'name', direction: 'asc' }, { selectableCount: 12 })
|
||||
|
||||
expect(container?.textContent).toContain('Select all 12 safety-checked workspaces')
|
||||
})
|
||||
|
||||
it('uses singular copy for one safety-checked workspace', () => {
|
||||
render({ field: 'name', direction: 'asc' }, { selectableCount: 1 })
|
||||
|
||||
expect(container?.textContent).toContain('Select 1 safety-checked workspace')
|
||||
})
|
||||
|
||||
it('does not report all-selected when the selected rows are not the selectable ones', () => {
|
||||
const onToggleSelectAll = vi.fn()
|
||||
render(
|
||||
{ field: 'name', direction: 'asc' },
|
||||
{ selectableCount: 3, selectedCount: 0, onToggleSelectAll }
|
||||
)
|
||||
|
||||
const checkbox = container?.querySelector('[role="checkbox"]')
|
||||
expect(checkbox?.getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
act(() => (checkbox as HTMLElement | null)?.click())
|
||||
expect(onToggleSelectAll).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user