Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions e2e/spec/installed-search-count.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import type { Page } from '@playwright/test'

import type { Settings } from '@/shared/settings'

import { test, expect } from '../fixtures/electron-app'
import { readSettingsFile, writeSettingsFile } from '../helpers/settings-file'

const SEARCH_COUNT_SKILLS = [
{ name: 'alpha-count-e2e', source: 'laststance/skills' },
{ name: 'beta-count-e2e', source: 'laststance/skills' },
{ name: 'gamma-count-e2e', source: 'pbakaus/impeccable' },
]

type SearchCountDisplaySetting = Settings['installedSearchCountDisplay']
type IsolatedHomeUse = (home: string) => Promise<void>

/**
* Write one source skill folder that the real scanner will count after app launch.
* @param home - Isolated E2E HOME used by the Electron fixture.
* @param skillName - Folder name and SKILL.md title for the staged skill.
* @returns void after the source skill exists on disk.
* @example
* stageSourceSkill('/tmp/home', 'alpha-count-e2e')
*/
function stageSourceSkill(home: string, skillName: string): void {
const sourcePath = join(home, '.agents', 'skills', skillName)
mkdirSync(sourcePath, { recursive: true })
writeFileSync(
join(sourcePath, 'SKILL.md'),
`---\nname: ${skillName}\ndescription: ${skillName} description\n---\n# ${skillName}\n`,
'utf8',
)
}

/**
* Write the skills CLI lockfile so the real scanner exposes deterministic repo facets.
* @param home - Isolated E2E HOME used by the Electron fixture.
* @returns void after `.skill-lock.json` maps each staged skill to its repo.
* @example
* stageSkillLock('/tmp/home')
*/
function stageSkillLock(home: string): void {
const lockPath = join(home, '.agents', '.skill-lock.json')
const skills = Object.fromEntries(
SEARCH_COUNT_SKILLS.map((skill) => [
skill.name,
{
source: skill.source,
sourceType: 'github',
sourceUrl: `https://github.com/${skill.source}.git`,
},
]),
)

writeFileSync(lockPath, JSON.stringify({ skills }, null, 2), 'utf8')
}

/**
* Stage the complete Installed-count HOME before Electron starts scanning.
* @param home - Isolated E2E HOME used by the Electron fixture.
* @returns void after source skills and repo metadata are on disk.
* @example
* stageInstalledCountHome('/tmp/home')
*/
function stageInstalledCountHome(home: string): void {
mkdirSync(join(home, '.agents', 'skills'), { recursive: true })
for (const skill of SEARCH_COUNT_SKILLS) {
stageSourceSkill(home, skill.name)
}
stageSkillLock(home)
}

/**
* Provide an isolated HOME with only the three Installed-count skills staged.
* @param use - Playwright fixture continuation that launches Electron after setup.
* @param display - Optional persisted count placement to write before launch.
* @returns Promise that resolves after the fixture HOME is cleaned up.
* @example
* await useInstalledCountHome(use, 'inline')
*/
async function useInstalledCountHome(
use: IsolatedHomeUse,
display?: SearchCountDisplaySetting,
): Promise<void> {
const home = realpathSync.native(
mkdtempSync(join(tmpdir(), 'skills-desktop-e2e-search-count-')),
)
try {
stageInstalledCountHome(home)
if (display) {
writeSettingsFile(home, { installedSearchCountDisplay: display })
}
await use(home)
} finally {
rmSync(home, { recursive: true, force: true })
}
}

/**
* Dispatch Installed search and repo filters in one renderer transaction.
* @param page - Electron renderer page under test.
* @param filters - Search query and selected repository ids to apply.
* @returns Promise that resolves once Redux has the requested filter state.
* @example
* await applyInstalledFilters(appWindow, { query: 'missing', sources: [] })
*/
async function applyInstalledFilters(
page: Page,
filters: { query: string; sources: string[] },
): Promise<void> {
await page.evaluate(({ query, sources }) => {
const store = window.__store__
if (!store) throw new Error('window.__store__ is not exposed')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
store.dispatch({
type: 'ui/setSearchQuery',
payload: query,
})
store.dispatch({
type: 'ui/setSelectedSources',
payload: sources,
})
}, filters)
}

const installedCountTest = test.extend<{ isolatedHome: string }>({
// eslint-disable-next-line no-empty-pattern
isolatedHome: async ({}, use) => {
await useInstalledCountHome(use)
},
})

const inlineInstalledCountTest = test.extend<{ isolatedHome: string }>({
// eslint-disable-next-line no-empty-pattern
isolatedHome: async ({}, use) => {
await useInstalledCountHome(use, 'inline')
},
})

installedCountTest(
'Installed tab badge tracks the current visible count and Marketplace stays count-free',
async ({ appWindow }) => {
// Arrange / Assert
await expect(
appWindow.getByRole('tab', {
name: /^Installed, 3 skills visible$/,
}),
).toBeVisible()
await expect(
appWindow.getByRole('tab', { name: /^Marketplace$/ }),
).toBeVisible()

// Act
await applyInstalledFilters(appWindow, {
query: 'alpha-count',
sources: [],
})

// Assert
await expect(
appWindow.getByRole('tab', {
name: /^Installed, 1 skill visible$/,
}),
).toBeVisible()

// Act
await applyInstalledFilters(appWindow, {
query: '',
sources: ['pbakaus/impeccable'],
})

// Assert
await expect(
appWindow.getByRole('tab', {
name: /^Installed, 1 skill visible$/,
}),
).toBeVisible()

// Act
await applyInstalledFilters(appWindow, {
query: 'missing-count-e2e',
sources: [],
})

// Assert
await expect(
appWindow.getByRole('tab', {
name: /^Installed, 0 skills visible$/,
}),
).toBeVisible()
await expect(
appWindow.getByRole('tab', { name: /^Marketplace$/ }),
).toBeVisible()
},
)

inlineInstalledCountTest(
'persisted inline mode moves the count into the toolbar and removes the tab badge',
async ({ appWindow, isolatedHome }) => {
// Arrange / Assert
await appWindow.waitForFunction(() => {
const store = window.__store__
if (!store) return false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const state = store.getState() as {
settings?: { installedSearchCountDisplay?: string }
}
return state.settings?.installedSearchCountDisplay === 'inline'
})
await expect(
appWindow.getByRole('tab', { name: /^Installed$/ }),
).toBeVisible()
await expect(
appWindow.getByRole('tab', {
name: /^Installed, 3 skills visible$/,
}),
).toHaveCount(0)
await expect(
appWindow
.locator('[aria-live="polite"]')
.filter({ hasText: /^3 skills$/ }),
).toBeVisible()

const persisted = readSettingsFile(isolatedHome) as {
installedSearchCountDisplay?: string
} | null
expect(persisted?.installedSearchCountDisplay).toBe('inline')
},
)
26 changes: 26 additions & 0 deletions src/main/ipc/ipc-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,13 +535,31 @@ describe('settings:set lockstep with SettingsSchema', () => {
expect(schema.safeParse([{ autoDownloadUpdates: true }]).success).toBe(true)
})

it('lets the user persist the Installed search count display placement', () => {
// Arrange / Act / Assert
expect(
schema.safeParse([{ installedSearchCountDisplay: 'inline' }]).success,
).toBe(true)
expect(
schema.safeParse([{ installedSearchCountDisplay: 'tab' }]).success,
).toBe(true)
})

it('blocks a non-boolean auto-download toggle from reaching disk', () => {
// Arrange / Act / Assert
expect(schema.safeParse([{ autoDownloadUpdates: 'yes' }]).success).toBe(
false,
)
})

it('blocks an unknown Installed search count display placement from reaching disk', () => {
// Arrange / Act / Assert
expect(
schema.safeParse([{ installedSearchCountDisplay: 'marketplace' }])
.success,
).toBe(false)
})

it('blocks an unknown terminal preset from reaching disk', () => {
// Arrange / Act / Assert
expect(
Expand Down Expand Up @@ -630,6 +648,14 @@ describe('settings:set lockstep with SettingsSchema', () => {
expect('autoDownloadUpdates' in parsed[0]).toBe(false)
})

it('does not wipe a persisted Installed search count placement when an unrelated setting is saved', () => {
// Arrange / Act
const parsed = schema.parse([{ defaultSkillTab: 'info' }]) as [object]

// Assert
expect('installedSearchCountDisplay' in parsed[0]).toBe(false)
})

it('lets the user persist an explicit hiddenAgentIds list', () => {
// Arrange / Act / Assert
expect(
Expand Down
6 changes: 6 additions & 0 deletions src/main/ipc/ipc-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from 'zod'
import { AGENT_IDS, TERMINAL_APP_IDS } from '@/shared/constants'
import type { IpcInvokeChannel } from '@/shared/ipc-contract'
import {
INSTALLED_SEARCH_COUNT_DISPLAY_OPTIONS,
SettingsSchema,
WINDOW_BACKGROUND_BLUR_RADIUS_SCHEMA,
} from '@/shared/settings'
Expand Down Expand Up @@ -360,6 +361,11 @@ export const IPC_ARG_SCHEMAS: Partial<Record<IpcInvokeChannel, z.ZodTuple>> = {
// unrelated partial settings writes do not reset blur to zero.
windowBackgroundBlurRadius:
WINDOW_BACKGROUND_BLUR_RADIUS_SCHEMA.optional(),
// Search-count display preference. Keep this as a non-defaulting enum
// so unrelated partial settings writes do not reset the user's choice.
installedSearchCountDisplay: z
.enum(INSTALLED_SEARCH_COUNT_DISPLAY_OPTIONS)
.optional(),
// Strict z.enum here — renderers should only ever emit valid ids.
// Intentionally NOT chained off `SettingsSchema.shape.hiddenAgentIds`:
// that field carries a `.default([])` for forgiving disk reads, and
Expand Down
10 changes: 2 additions & 8 deletions src/main/services/settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'

import type { Settings } from '@/shared/settings'
import { DEFAULT_SETTINGS, type Settings } from '@/shared/settings'

import { areSettingsEqual } from './settings'

Expand All @@ -13,13 +13,7 @@ import { areSettingsEqual } from './settings'
* the saved dimensions are identical.
*/
describe('areSettingsEqual', () => {
const baseSettings: Settings = {
defaultSkillTab: 'files',
preferredTerminal: 'terminal',
windowBackgroundBlurRadius: 0,
hiddenAgentIds: [],
autoDownloadUpdates: false,
}
const baseSettings: Settings = DEFAULT_SETTINGS

it('treats two settings with identical primitive fields as unchanged so no redundant save fires', () => {
// Arrange
Expand Down
24 changes: 24 additions & 0 deletions src/renderer/settings/sections/Appearance.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ async function createStore(
}

describe('Settings → Appearance', () => {
it('persists Toolbar text when the Installed search count display is changed', async () => {
// Arrange
const store = await createStore()
const { Appearance } = await import('./Appearance')
const screen = await render(
<Provider store={store}>
<Appearance />
</Provider>,
)

// Act
await screen.getByRole('radio', { name: /Toolbar text/i }).click()

// Assert
await expect.poll(() => mockSettingsSet.mock.calls.length).toBe(1)
expect(mockSettingsSet).toHaveBeenCalledWith({
installedSearchCountDisplay: 'inline',
})
const settingsState = store.getState() as {
settings: typeof DEFAULT_SETTINGS
}
expect(settingsState.settings.installedSearchCountDisplay).toBe('inline')
})

it('persists the new window blur radius when the opacity slider moves', async () => {
// Arrange
const store = await createStore()
Expand Down
Loading
Loading