Skip to content
Open
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
97 changes: 97 additions & 0 deletions e2e/search-dnd.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// E2E: drag & drop from Search results. Uses synthetic HTML5 DragEvents with a
// shared DataTransfer (the only way to exercise the application/cate-file MIME
// payload — Playwright's mouse drag produces an empty dataTransfer). Dispatches
// dragstart on the real Search row (so SearchResultsTree populates the payload)
// then drop on the target, exercising the full source→target chain.

import { test, expect, type Page } from '@playwright/test'
import path from 'node:path'
import { launchApp, closeApp, type LaunchResult } from './fixtures/electron-app'

const REPO_ROOT = path.resolve(__dirname, '..')

async function openSearch(page: Page) {
await page.evaluate((root) => window.__cateE2E!.setWorkspaceRoot(root), REPO_ROOT)
await page.evaluate(() => window.__cateE2E!.openSidebarView('search'))
const input = page.locator('input[aria-label="Search"]')
await input.waitFor({ state: 'visible', timeout: 10_000 })
await input.fill('registerSearchHandlers')
await expect.poll(
async () => page.evaluate(() => window.__cateE2E!.getSearchSnapshot().status),
{ timeout: 12_000 },
).toBe('done')
}

/** Dispatch dragstart on a Search row, then drop it on a target selector. */
async function dragRowToTarget(page: Page, rowTestId: string, targetSelector: string) {
await page.evaluate(
({ rowTestId, targetSelector }) => {
const row = document.querySelector(`[data-testid="${rowTestId}"]`)
const target = document.querySelector(targetSelector)
if (!row || !target) throw new Error(`missing row(${rowTestId}) or target(${targetSelector})`)
const dt = new DataTransfer()
row.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt }))
const rect = target.getBoundingClientRect()
const opts: DragEventInit = {
bubbles: true,
cancelable: true,
dataTransfer: dt,
clientX: rect.x + rect.width / 2,
clientY: rect.y + rect.height / 2,
}
target.dispatchEvent(new DragEvent('dragenter', opts))
target.dispatchEvent(new DragEvent('dragover', opts))
target.dispatchEvent(new DragEvent('drop', opts))
},
{ rowTestId, targetSelector },
)
}

test.describe('search drag & drop', () => {
let app: LaunchResult
test.beforeEach(async () => {
app = await launchApp()
})
test.afterEach(async () => {
await closeApp(app.electronApp)
})

test('dragging a file result onto the canvas opens a floating editor', async () => {
const page = app.mainWindow
await openSearch(page)
const before = await page.evaluate(() => window.__cateE2E!.nodes().length)

await dragRowToTarget(page, 'search-file', '[data-canvas-panel-id]')

await expect
.poll(async () => page.evaluate(() => window.__cateE2E!.nodes().length), { timeout: 10_000 })
.toBeGreaterThan(before)
})

test('dragging a match line onto the canvas opens it at that line', async () => {
const page = app.mainWindow
await openSearch(page)
const lineNo = Number(
await page.locator('[data-testid="search-line"]').first().getAttribute('data-line'),
)
expect(lineNo).toBeGreaterThan(0)

await dragRowToTarget(page, 'search-line', '[data-canvas-panel-id]')

await expect
.poll(async () => page.evaluate(() => window.__cateE2E!.lastEditorReveal()?.line ?? 0), { timeout: 10_000 })
.toBe(lineNo)
})

test('dragging a file result onto the dock center zone opens an editor tab', async () => {
const page = app.mainWindow
await openSearch(page)
const before = await page.evaluate(() => window.__cateE2E!.editorPaths().length)

await dragRowToTarget(page, 'search-file', '[data-dock-zone="center"]')

await expect
.poll(async () => page.evaluate(() => window.__cateE2E!.editorPaths().length), { timeout: 10_000 })
.toBeGreaterThan(before)
})
})
255 changes: 255 additions & 0 deletions e2e/search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
// E2E: the VS Code-style content Search view, end-to-end against the real
// ripgrep engine. Points the workspace at the repo, opens the Search view, and
// exercises query, match options, filters, dismissal, keyboard nav, and
// open-at-match.

import { test, expect, type Page } from '@playwright/test'
import path from 'node:path'
import { launchApp, closeApp, type LaunchResult } from './fixtures/electron-app'

const REPO_ROOT = path.resolve(__dirname, '..')

type Snapshot = {
query: string
isRegex: boolean
matchCase: boolean
wholeWord: boolean
respectIgnore: boolean
optionsExpanded: boolean
status: string
searchId: string | null
error: string | null
fileCount: number
filePaths: string[]
totalMatches: number
dismissedFiles: number
dismissedLines: number
}

const snap = (page: Page): Promise<Snapshot> =>
page.evaluate(() => window.__cateE2E!.getSearchSnapshot() as unknown) as Promise<Snapshot>

/** Open the Search view rooted at the repo; returns the query input locator. */
async function openSearch(page: Page) {
await page.evaluate((root) => window.__cateE2E!.setWorkspaceRoot(root), REPO_ROOT)
await page.evaluate(() => window.__cateE2E!.openSidebarView('search'))
const input = page.locator('input[aria-label="Search"]')
await input.waitFor({ state: 'visible', timeout: 10_000 })
return input
}

/** Wait until a NEW search (id != prior) has settled. Avoids reading stale
* results from a previous search that is still in the 'done' state. */
async function settle(page: Page, priorSearchId: string | null) {
await expect
.poll(
async () => {
const s = await snap(page)
return s.searchId !== priorSearchId && s.status === 'done'
},
{ timeout: 12_000 },
)
.toBe(true)
}

/** Run the first search for `query` and wait for it to settle. */
async function search(page: Page, input: ReturnType<Page['locator']>, query: string) {
const prior = (await snap(page)).searchId
await input.fill(query)
await settle(page, prior)
}

test.describe('content search', () => {
let app: LaunchResult

test.beforeEach(async () => {
app = await launchApp()
})
test.afterEach(async () => {
await closeApp(app.electronApp)
})

test('searches the repo, highlights matches, and opens a result', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await input.fill('registerSearchHandlers')

await expect(page.getByText(/results in .* files?/i)).toBeVisible({ timeout: 10_000 })
const mark = page.locator('mark', { hasText: 'registerSearchHandlers' }).first()
await expect(mark).toBeVisible({ timeout: 10_000 })

await mark.click()
await expect
.poll(async () => page.evaluate(() => window.__cateE2E!.editorPaths().length), { timeout: 10_000 })
.toBeGreaterThan(0)
})

test('shows "No results" for a query that matches nothing', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await input.fill('zzz_no_such_token_qwerty_12345')
await expect(page.getByText('No results')).toBeVisible({ timeout: 10_000 })
})

test('regex toggle changes literal vs pattern matching', async () => {
const page = app.mainWindow
const input = await openSearch(page)
// Built at runtime so the contiguous literal isn't in this source file
// (otherwise ripgrep would self-match it during the literal search).
const pattern = ['useState', 'useEffect'].join('|')
await search(page, input, pattern) // literal — no such contiguous text
expect((await snap(page)).totalMatches).toBe(0)

const prior = (await snap(page)).searchId
await page.locator('button[aria-label="Use Regular Expression"]').click()
await settle(page, prior)
const s = await snap(page)
expect(s.isRegex).toBe(true)
expect(s.totalMatches).toBeGreaterThan(0) // now matches as an alternation
})

test('invalid regex surfaces an inline error', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await page.locator('button[aria-label="Use Regular Expression"]').click()
await search(page, input, '(unclosed')
expect((await snap(page)).error).toBeTruthy()
await expect(page.locator('.text-red-400')).toBeVisible({ timeout: 5_000 })
})

test('whole-word toggle narrows matches', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await search(page, input, 'use')
const loose = (await snap(page)).totalMatches
expect(loose).toBeGreaterThan(0)

const prior = (await snap(page)).searchId
await page.locator('button[aria-label="Match Whole Word"]').click()
await settle(page, prior)
const s = await snap(page)
expect(s.wholeWord).toBe(true)
expect(s.totalMatches).toBeLessThan(loose) // "useState" etc. no longer match
})

test('match-case toggle flips state and re-runs', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await search(page, input, 'usestate') // lowercase
const loose = (await snap(page)).totalMatches

const prior = (await snap(page)).searchId
await page.locator('button[aria-label="Match Case"]').click()
await settle(page, prior)
const s = await snap(page)
expect(s.matchCase).toBe(true)
expect(s.totalMatches).toBeLessThanOrEqual(loose) // fewer/zero exact-case hits
})

test('files-to-include glob restricts results', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await search(page, input, 'useState')

await page.locator('button[aria-label="Toggle search details"]').click()
const prior = (await snap(page)).searchId
await page.locator('input[aria-label="files to include"]').fill('*.tsx')
await settle(page, prior)
const s = await snap(page)
expect(s.fileCount).toBeGreaterThan(0)
expect(s.filePaths.every((p) => p.endsWith('.tsx'))).toBe(true)
})

test('files-to-exclude glob removes results', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await search(page, input, 'useState')
expect((await snap(page)).filePaths.some((p) => p.endsWith('.tsx'))).toBe(true)

await page.locator('button[aria-label="Toggle search details"]').click()
const prior = (await snap(page)).searchId
await page.locator('input[aria-label="files to exclude"]').fill('*.tsx')
await settle(page, prior)
expect((await snap(page)).filePaths.some((p) => p.endsWith('.tsx'))).toBe(false)
})

test('"use ignore files" gear toggle flips and re-runs', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await search(page, input, 'registerSearchHandlers')
expect((await snap(page)).respectIgnore).toBe(true)

await page.locator('button[aria-label="Toggle search details"]').click()
const prior = (await snap(page)).searchId
await page.locator('button[aria-label="Use Exclude Settings and Ignore Files"]').click()
await settle(page, prior)
const s = await snap(page)
expect(s.respectIgnore).toBe(false)
expect(s.error).toBeNull()
})

test('dismissing a match decrements the count', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await search(page, input, 'registerSearchHandlers')
expect((await snap(page)).totalMatches).toBeGreaterThan(0)

const line = page.locator('[data-testid="search-line"]').first()
await line.hover()
await line.locator('button[title="Dismiss match"]').click()
await expect.poll(async () => (await snap(page)).dismissedLines).toBe(1)
})

test('dismissing a file removes it from results', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await search(page, input, 'registerSearchHandlers')

const file = page.locator('[data-testid="search-file"]').first()
await file.hover()
await file.locator('button[title="Dismiss file"]').click()
await expect.poll(async () => (await snap(page)).dismissedFiles).toBe(1)
})

test('keyboard: ArrowDown + Enter opens the focused match', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await search(page, input, 'registerSearchHandlers')

const tree = page.locator('[data-testid="search-results"]')
await tree.press('ArrowDown') // file row (0) → first match line (1)
// Selection must move to a match line (proves the list owns its arrow keys).
await expect(page.locator('[data-selected="true"]')).toHaveAttribute('data-testid', 'search-line')
await tree.press('Enter')
await expect
.poll(async () => page.evaluate(() => window.__cateE2E!.editorPaths().length), { timeout: 10_000 })
.toBeGreaterThan(0)
})

test('clicking a match opens the editor at that line', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await search(page, input, 'registerSearchHandlers')

const line = page.locator('[data-testid="search-line"]').first()
const lineNo = Number(await line.getAttribute('data-line'))
expect(lineNo).toBeGreaterThan(0)
await line.click()

const reveal = await page.evaluate(() => window.__cateE2E!.lastEditorReveal())
expect(reveal?.line).toBe(lineNo)
})

test('clear button resets the query and results', async () => {
const page = app.mainWindow
const input = await openSearch(page)
await search(page, input, 'useState')
expect((await snap(page)).fileCount).toBeGreaterThan(0)

await page.locator('button[aria-label="Clear search"]').click()
await expect.poll(async () => (await snap(page)).query).toBe('')
const s = await snap(page)
expect(s.fileCount).toBe(0)
expect(s.status).toBe('idle')
})
})
4 changes: 4 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ asarUnpack:
- "node_modules/@earendil-works/**"
- "node_modules/.bin/pi"
- "node_modules/.bin/pi.cmd"
# ripgrep binary for the Search view. The wrapper resolves to a per-platform
# package (@vscode/ripgrep-<os>-<arch>); unpack both so rgPath is a real file.
- "node_modules/@vscode/ripgrep/**"
- "node_modules/@vscode/ripgrep-*/**"
- "node_modules/@anthropic-ai/sdk/**"
- "node_modules/@aws-crypto/crc32/**"
- "node_modules/@aws-crypto/sha256-browser/**"
Expand Down
Loading
Loading