update
This commit is contained in:
89
test/app-interactions.test.tsx
Normal file
89
test/app-interactions.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
vi.mock('../src/renderer/src/terminals', () => {
|
||||
let n = 0
|
||||
return { Terminal: () => null, lid: () => ++n }
|
||||
})
|
||||
|
||||
import { App } from '../src/renderer/src/App'
|
||||
import { ProjectProvider } from '../src/renderer/src/project'
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
afterEach(() => { cleanup(); localStorage.clear() })
|
||||
|
||||
function renderApp(): HTMLElement {
|
||||
return render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
}
|
||||
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.includes(text))
|
||||
}
|
||||
|
||||
describe('Pass on to Agent', () => {
|
||||
it('inserts "<note> <path:line>" via the agentPaste event', async () => {
|
||||
const received: string[] = []
|
||||
const handler = (e: Event): void => { received.push((e as CustomEvent<string>).detail) }
|
||||
window.addEventListener('agentPaste', handler)
|
||||
try {
|
||||
const c = renderApp()
|
||||
const treeRow = await waitFor(() => {
|
||||
const r = find(c, '.tree-row', 'store.js')
|
||||
if (!r) throw new Error('tree not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(treeRow)
|
||||
const ta = await waitFor(() => {
|
||||
const t = c.querySelector<HTMLTextAreaElement>('.ce-ta')
|
||||
if (!t) throw new Error('editor not ready')
|
||||
return t
|
||||
})
|
||||
fireEvent.contextMenu(ta)
|
||||
const pass = await waitFor(() => {
|
||||
const item = find(c, '.ctx-item', 'Pass on to Agent')
|
||||
if (!item) throw new Error('menu not open')
|
||||
return item
|
||||
})
|
||||
fireEvent.click(pass)
|
||||
const input = await waitFor(() => {
|
||||
const i = c.querySelector<HTMLInputElement>('.pass-input')
|
||||
if (!i) throw new Error('popup not open')
|
||||
return i
|
||||
})
|
||||
fireEvent.change(input, { target: { value: 'look here' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => expect(received.length).toBeGreaterThan(0))
|
||||
expect(received[0]).toBe('look here public/assets/store.js:1')
|
||||
} finally {
|
||||
window.removeEventListener('agentPaste', handler)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stage + commit', () => {
|
||||
it('stages a file, commits with a message, and toasts', async () => {
|
||||
const c = renderApp()
|
||||
const row = await waitFor(() => {
|
||||
const r = find(c, '.git-row', 'UserController.php')
|
||||
if (!r) throw new Error('git not ready')
|
||||
return r
|
||||
})
|
||||
const stageBtn = row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!
|
||||
fireEvent.click(stageBtn)
|
||||
|
||||
// commit button reflects the staged count once a file is staged
|
||||
await waitFor(() => expect(find(c, '.commit-btn', 'Commit')?.textContent).toMatch(/Commit\s*\d/))
|
||||
|
||||
const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')!
|
||||
fireEvent.change(msg, { target: { value: 'wire up balance' } })
|
||||
const commitBtn = find(c, '.commit-btn', 'Commit') as HTMLButtonElement
|
||||
expect(commitBtn.disabled).toBe(false)
|
||||
fireEvent.click(commitBtn)
|
||||
|
||||
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
93
test/app.test.tsx
Normal file
93
test/app.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
// The terminals use xterm + ResizeObserver, which don't belong in a jsdom unit
|
||||
// test. Stub them — the rest of the workbench renders for real against the mock.
|
||||
vi.mock('../src/renderer/src/terminals', () => {
|
||||
let n = 0
|
||||
return { Terminal: () => null, lid: () => ++n }
|
||||
})
|
||||
|
||||
import { App } from '../src/renderer/src/App'
|
||||
import { ProjectProvider } from '../src/renderer/src/project'
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom gaps used by the tree/tab code.
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear() })
|
||||
|
||||
function renderApp(): HTMLElement {
|
||||
const { container } = render(
|
||||
<ProjectProvider>
|
||||
<App />
|
||||
</ProjectProvider>,
|
||||
)
|
||||
return container
|
||||
}
|
||||
|
||||
function rowWithText(container: HTMLElement, selector: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(selector)).find((el) => el.textContent?.includes(text))
|
||||
}
|
||||
|
||||
describe('App (mock data, jsdom)', () => {
|
||||
it('renders the four-column workbench with the git change list', async () => {
|
||||
const c = renderApp()
|
||||
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
|
||||
expect(c.querySelector('.workbench')).toBeTruthy()
|
||||
expect(c.textContent).toContain('Source Control')
|
||||
expect(c.textContent).toContain('Explorer')
|
||||
// no file open yet
|
||||
expect(c.textContent).toContain('No file open')
|
||||
})
|
||||
|
||||
it('opens a changed file from the git panel into a diff tab', async () => {
|
||||
const c = renderApp()
|
||||
const row = await waitFor(() => {
|
||||
const r = rowWithText(c, '.git-row', 'UserController.php')
|
||||
if (!r) throw new Error('row not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
await waitFor(() => expect(rowWithText(c, '.tab', 'UserController.php')).toBeTruthy())
|
||||
// changed file → diff toolbar with a status word
|
||||
expect(c.querySelector('.diff-bar')?.textContent).toContain('Modified')
|
||||
})
|
||||
|
||||
it('makes an edited buffer dirty (tab dot)', async () => {
|
||||
const c = renderApp()
|
||||
const treeRow = await waitFor(() => {
|
||||
const r = rowWithText(c, '.tree-row', 'store.js')
|
||||
if (!r) throw new Error('tree not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(treeRow) // unchanged file → editable "code" mode
|
||||
const ta = await waitFor(() => {
|
||||
const t = c.querySelector<HTMLTextAreaElement>('.ce-ta')
|
||||
if (!t) throw new Error('editor not ready')
|
||||
return t
|
||||
})
|
||||
expect(c.querySelector('.tab.dirtyclose')).toBeNull()
|
||||
fireEvent.change(ta, { target: { value: '// edited\n' } })
|
||||
await waitFor(() => expect(c.querySelector('.tab.dirtyclose')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('searches file contents from the search modal', async () => {
|
||||
const c = renderApp()
|
||||
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
|
||||
const searchBtn = rowWithText(c, '.tb-btn', 'Search')!
|
||||
fireEvent.click(searchBtn)
|
||||
const modal = await waitFor(() => {
|
||||
const m = c.querySelector('.search-modal')
|
||||
if (!m) throw new Error('modal not open')
|
||||
return m as HTMLElement
|
||||
})
|
||||
const input = within(modal).getByPlaceholderText(/Search content/i)
|
||||
fireEvent.change(input, { target: { value: 'balance' } })
|
||||
await waitFor(() => expect(modal.querySelectorAll('.sr-file').length).toBeGreaterThan(0))
|
||||
})
|
||||
})
|
||||
54
test/config.test.ts
Normal file
54
test/config.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { DEFAULTS, getConfig, getThemeCss, resolveConfig } from '../src/main/config'
|
||||
|
||||
let dir = ''
|
||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
||||
|
||||
describe('resolveConfig', () => {
|
||||
it('creates config.default.json + theme.css and yields defaults for a fresh project', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
|
||||
const def = JSON.parse(await readFile(join(dir, '.helder/config.default.json'), 'utf8'))
|
||||
expect(def).toEqual(DEFAULTS)
|
||||
const theme = await readFile(join(dir, '.helder/theme.css'), 'utf8')
|
||||
expect(theme).toContain('--code-font')
|
||||
expect(getConfig().ai.command).toBe('claude')
|
||||
expect(getThemeCss()).toContain('Helder theme')
|
||||
})
|
||||
|
||||
it('deep-merges a sparse config.json over defaults', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
await writeFile(join(dir, '.helder/config.json'),
|
||||
JSON.stringify({ ai: { command: 'claude --model opus' }, editor: { tabSize: 2 } }))
|
||||
await resolveConfig(dir)
|
||||
|
||||
const c = getConfig()
|
||||
expect(c.ai.command).toBe('claude --model opus') // overridden
|
||||
expect(c.ai.autoLaunch).toBe(true) // default kept
|
||||
expect(c.editor.tabSize).toBe(2) // overridden
|
||||
expect(c.editor.autoSave).toBe(false) // default kept
|
||||
expect(c.git.confirmDiscard).toBe(true) // default kept
|
||||
})
|
||||
|
||||
it('always regenerates config.default.json with full built-in defaults', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
await writeFile(join(dir, '.helder/config.json'), JSON.stringify({ ai: { command: 'x' } }))
|
||||
await resolveConfig(dir)
|
||||
const def = JSON.parse(await readFile(join(dir, '.helder/config.default.json'), 'utf8'))
|
||||
expect(def.ai.command).toBe('claude')
|
||||
})
|
||||
|
||||
it('does not overwrite an existing theme.css', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
await writeFile(join(dir, '.helder/theme.css'), ':root{--accent:#ff0000}')
|
||||
await resolveConfig(dir)
|
||||
expect(getThemeCss()).toContain('#ff0000')
|
||||
})
|
||||
})
|
||||
71
test/diff.test.ts
Normal file
71
test/diff.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildDiff, makeDiff } from '../src/renderer/src/diff'
|
||||
|
||||
describe('buildDiff', () => {
|
||||
it('reports no changes for identical text', () => {
|
||||
const d = buildDiff('a\nb\nc\n', 'a\nb\nc\n')
|
||||
expect(d.add).toBe(0)
|
||||
expect(d.del).toBe(0)
|
||||
expect(d.rows.every((r) => r.sign === ' ')).toBe(true)
|
||||
})
|
||||
|
||||
it('counts a single changed line as one add + one del', () => {
|
||||
const d = buildDiff('a\nb\nc', 'a\nB\nc')
|
||||
expect(d.add).toBe(1)
|
||||
expect(d.del).toBe(1)
|
||||
const signs = d.rows.map((r) => r.sign).join('')
|
||||
expect(signs).toContain('-')
|
||||
expect(signs).toContain('+')
|
||||
})
|
||||
|
||||
it('treats an empty original as all additions (new file)', () => {
|
||||
const d = buildDiff('', 'x\ny')
|
||||
expect(d.add).toBe(2)
|
||||
expect(d.del).toBe(0)
|
||||
expect(d.left).toHaveLength(0)
|
||||
expect(d.right).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('treats an empty updated as all deletions (deleted file)', () => {
|
||||
const d = buildDiff('x\ny\nz', '')
|
||||
expect(d.del).toBe(3)
|
||||
expect(d.add).toBe(0)
|
||||
expect(d.right).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('marks the changed line on both sides', () => {
|
||||
const d = buildDiff('keep\nold\nkeep', 'keep\nnew\nkeep')
|
||||
expect(d.left.find((l) => l.text === 'old')?.mark).toBe('del')
|
||||
expect(d.right.find((l) => l.text === 'new')?.mark).toBe('add')
|
||||
expect(d.left.find((l) => l.text === 'keep')?.mark).toBeNull()
|
||||
})
|
||||
|
||||
it('aligns split rows: same lines pair, changes stack into the gap', () => {
|
||||
const d = buildDiff('a\nold\nb', 'a\nnew\nb')
|
||||
// every split row has at least one side
|
||||
expect(d.split.every((r) => r.l || r.r)).toBe(true)
|
||||
// the matched 'a' and 'b' lines pair on both sides
|
||||
const paired = d.split.filter((r) => r.l && r.r && r.l.text === r.r.text)
|
||||
expect(paired.map((r) => r.l!.text)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('ignores a single trailing newline difference', () => {
|
||||
const d = buildDiff('a\nb', 'a\nb\n')
|
||||
expect(d.add).toBe(0)
|
||||
expect(d.del).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeDiff', () => {
|
||||
it('flags added / deleted and carries the text pair', () => {
|
||||
const added = makeDiff('A', '', 'hi')
|
||||
expect(added.added).toBe(true)
|
||||
expect(added.deleted).toBe(false)
|
||||
expect(added.original).toBe('')
|
||||
expect(added.updated).toBe('hi')
|
||||
|
||||
const deleted = makeDiff('D', 'bye', '')
|
||||
expect(deleted.deleted).toBe(true)
|
||||
expect(deleted.added).toBe(false)
|
||||
})
|
||||
})
|
||||
57
test/editor-modes.test.tsx
Normal file
57
test/editor-modes.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
vi.mock('../src/renderer/src/terminals', () => {
|
||||
let n = 0
|
||||
return { Terminal: () => null, lid: () => ++n }
|
||||
})
|
||||
|
||||
import { App } from '../src/renderer/src/App'
|
||||
import { ProjectProvider } from '../src/renderer/src/project'
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
afterEach(() => { cleanup(); localStorage.clear() })
|
||||
|
||||
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.trim() === text)
|
||||
}
|
||||
|
||||
async function openChanged(): Promise<HTMLElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
const row = await waitFor(() => {
|
||||
const r = Array.from(c.querySelectorAll<HTMLElement>('.git-row')).find((el) => el.textContent?.includes('UserController.php'))
|
||||
if (!r) throw new Error('git not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||
return c
|
||||
}
|
||||
|
||||
describe('Editor view modes', () => {
|
||||
it('Updated mode is an editable buffer; Original is read-only', async () => {
|
||||
const c = await openChanged()
|
||||
fireEvent.click(find(c, '.seg button', 'Updated')!)
|
||||
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
|
||||
|
||||
fireEvent.click(find(c, '.seg button', 'Original')!)
|
||||
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeNull())
|
||||
expect(c.querySelector('.editor .ln-row')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Split opens a full-screen two-pane overlay and Esc collapses it', async () => {
|
||||
const c = await openChanged()
|
||||
fireEvent.click(c.querySelector('.split-btn')!)
|
||||
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
|
||||
expect(c.querySelector('.split-pane.left')).toBeTruthy()
|
||||
expect(c.querySelector('.split-pane.right')).toBeTruthy()
|
||||
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeNull())
|
||||
})
|
||||
})
|
||||
78
test/fs.test.ts
Normal file
78
test/fs.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { simpleGit } from 'simple-git'
|
||||
import { buildTreeFromPaths, readAll, readProjectFile, readTree, writeProjectFile } from '../src/main/fs-service'
|
||||
|
||||
let dir = ''
|
||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
||||
|
||||
async function fixture(): Promise<string> {
|
||||
const d = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await mkdir(join(d, 'src'), { recursive: true })
|
||||
await mkdir(join(d, 'node_modules/pkg'), { recursive: true })
|
||||
await mkdir(join(d, '.git'), { recursive: true })
|
||||
await writeFile(join(d, 'src', 'a.ts'), 'export const a = 1\n')
|
||||
await writeFile(join(d, 'README.md'), '# hi\n')
|
||||
await writeFile(join(d, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n')
|
||||
await writeFile(join(d, '.git', 'HEAD'), 'ref: refs/heads/main\n')
|
||||
await writeFile(join(d, 'logo.bin'), Buffer.from([0x00, 0x01, 0x02, 0x00, 0x99]))
|
||||
return d
|
||||
}
|
||||
|
||||
describe('readTree', () => {
|
||||
it('lists dirs before files, ignoring node_modules and .git', async () => {
|
||||
dir = await fixture()
|
||||
const tree = await readTree(dir)
|
||||
const top = (tree.children || []).map((c) => c.name)
|
||||
expect(top).not.toContain('node_modules')
|
||||
expect(top).not.toContain('.git')
|
||||
expect(top).toContain('src')
|
||||
// dirs first
|
||||
expect(top.indexOf('src')).toBeLessThan(top.indexOf('README.md'))
|
||||
expect(top.indexOf('src')).toBeLessThan(top.indexOf('logo.bin'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('readAll', () => {
|
||||
it('indexes text files, skipping ignored dirs and binary files', async () => {
|
||||
dir = await fixture()
|
||||
const files = await readAll(dir)
|
||||
const keys = Object.keys(files)
|
||||
expect(keys).toContain('src/a.ts')
|
||||
expect(keys).toContain('README.md')
|
||||
expect(keys.some((k) => k.includes('node_modules'))).toBe(false)
|
||||
expect(keys).not.toContain('logo.bin') // binary skipped
|
||||
expect(files['src/a.ts']).toContain('export const a')
|
||||
})
|
||||
|
||||
it('honors .gitignore in a repo (files.followGitignore default on)', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await simpleGit(dir).init()
|
||||
await writeFile(join(dir, '.gitignore'), 'secret.txt\n')
|
||||
await writeFile(join(dir, 'secret.txt'), 'shh')
|
||||
await writeFile(join(dir, 'keep.txt'), 'ok')
|
||||
const keys = Object.keys(await readAll(dir))
|
||||
expect(keys).toContain('keep.txt')
|
||||
expect(keys).not.toContain('secret.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTreeFromPaths', () => {
|
||||
it('nests paths with dirs before files, alphabetical', () => {
|
||||
const t = buildTreeFromPaths('proj', ['src/b.ts', 'src/a.ts', 'README.md', 'src/util/x.ts'])
|
||||
expect((t.children || []).map((c) => c.name)).toEqual(['src', 'README.md'])
|
||||
const src = (t.children || []).find((c) => c.name === 'src')!
|
||||
expect((src.children || []).map((c) => c.name)).toEqual(['util', 'a.ts', 'b.ts'])
|
||||
expect((src.children || []).find((c) => c.name === 'util')!.path).toBe('src/util')
|
||||
})
|
||||
})
|
||||
|
||||
describe('read/write round-trip', () => {
|
||||
it('writes then reads the same content', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await writeProjectFile(dir, 'note.txt', 'hello world\n')
|
||||
expect(await readProjectFile(dir, 'note.txt')).toBe('hello world\n')
|
||||
})
|
||||
})
|
||||
25
test/fuzzy.test.ts
Normal file
25
test/fuzzy.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fuzzy } from '../src/renderer/src/fuzzy'
|
||||
|
||||
describe('fuzzy', () => {
|
||||
it('matches greedily from the left (first c, then a, then t)', () => {
|
||||
expect(fuzzy('cat', 'concatenate')).toEqual([0, 4, 5])
|
||||
})
|
||||
|
||||
it('matches a non-contiguous subsequence', () => {
|
||||
expect(fuzzy('uc', 'UserController')).toEqual([0, 4])
|
||||
})
|
||||
|
||||
it('is case-insensitive (skips the e to reach r)', () => {
|
||||
expect(fuzzy('USR', 'user')).toEqual([0, 1, 3])
|
||||
})
|
||||
|
||||
it('returns null when not a subsequence', () => {
|
||||
expect(fuzzy('xyz', 'abc')).toBeNull()
|
||||
expect(fuzzy('ca', 'abc')).toBeNull() // order matters
|
||||
})
|
||||
|
||||
it('returns an empty index array for an empty query', () => {
|
||||
expect(fuzzy('', 'anything')).toEqual([])
|
||||
})
|
||||
})
|
||||
105
test/git.test.ts
Normal file
105
test/git.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { simpleGit } from 'simple-git'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { classify, discard, load, stage } from '../src/main/git-service'
|
||||
|
||||
describe('classify', () => {
|
||||
it('reads the index code as staged, working code as unstaged', () => {
|
||||
expect(classify('M', ' ')).toEqual({ letter: 'M', staged: true })
|
||||
expect(classify(' ', 'M')).toEqual({ letter: 'M', staged: false })
|
||||
expect(classify('A', ' ')).toEqual({ letter: 'A', staged: true })
|
||||
expect(classify('D', ' ')).toEqual({ letter: 'D', staged: true })
|
||||
expect(classify('R', ' ')).toEqual({ letter: 'R', staged: true })
|
||||
})
|
||||
it('treats untracked as a new (A) unstaged file', () => {
|
||||
expect(classify('?', '?')).toEqual({ letter: 'A', staged: false })
|
||||
})
|
||||
it('maps unmerged (U) to modified', () => {
|
||||
expect(classify('U', 'U').letter).toBe('M')
|
||||
})
|
||||
})
|
||||
|
||||
describe('load (integration against a temp repo)', () => {
|
||||
let dir = ''
|
||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
||||
|
||||
async function repo(): Promise<string> {
|
||||
const d = await mkdtemp(join(tmpdir(), 'helder-git-'))
|
||||
const g = simpleGit(d)
|
||||
await g.init()
|
||||
await g.addConfig('user.email', 't@example.com')
|
||||
await g.addConfig('user.name', 'Test')
|
||||
await g.addConfig('commit.gpgsign', 'false')
|
||||
await writeFile(join(d, 'a.txt'), '1\n2\n3\n')
|
||||
await g.add('.')
|
||||
await g.commit('init')
|
||||
return d
|
||||
}
|
||||
|
||||
it('returns null for a non-repo directory', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-nogit-'))
|
||||
expect(await load(dir)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a modified file with HEAD-vs-worktree text', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\nX\n3\n')
|
||||
const res = await load(dir)
|
||||
expect(res).not.toBeNull()
|
||||
expect(res!.branch).toBeTruthy()
|
||||
const a = res!.changes.find((c) => c.path === 'a.txt')
|
||||
expect(a?.status).toBe('M')
|
||||
expect(a?.staged).toBe(false)
|
||||
expect(a?.original).toBe('1\n2\n3\n')
|
||||
expect(a?.updated).toBe('1\nX\n3\n')
|
||||
})
|
||||
|
||||
it('reports an untracked file as new (A), original empty', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'new.txt'), 'fresh\n')
|
||||
const res = await load(dir)
|
||||
const n = res!.changes.find((c) => c.path === 'new.txt')
|
||||
expect(n?.status).toBe('A')
|
||||
expect(n?.staged).toBe(false)
|
||||
expect(n?.original).toBe('')
|
||||
expect(n?.updated).toBe('fresh\n')
|
||||
})
|
||||
|
||||
it('reflects staging', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\n2\n3\n4\n')
|
||||
await stage(dir, ['a.txt'])
|
||||
const res = await load(dir)
|
||||
expect(res!.changes.find((c) => c.path === 'a.txt')?.staged).toBe(true)
|
||||
})
|
||||
|
||||
it('discard reverts a modified tracked file to HEAD', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\nCHANGED\n3\n')
|
||||
await discard(dir, ['a.txt'])
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('1\n2\n3\n')
|
||||
const res = await load(dir)
|
||||
expect(res!.changes.find((c) => c.path === 'a.txt')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('discard removes a new (untracked) file from disk', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'new.txt'), 'fresh\n')
|
||||
await discard(dir, ['new.txt'])
|
||||
expect(existsSync(join(dir, 'new.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('discard removes a staged-new file', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'staged-new.txt'), 'x\n')
|
||||
await stage(dir, ['staged-new.txt'])
|
||||
await discard(dir, ['staged-new.txt'])
|
||||
expect(existsSync(join(dir, 'staged-new.txt'))).toBe(false)
|
||||
const res = await load(dir)
|
||||
expect(res!.changes.find((c) => c.path === 'staged-new.txt')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
60
test/highlight.test.ts
Normal file
60
test/highlight.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { HL } from '../src/renderer/src/highlight'
|
||||
|
||||
describe('HL.ext', () => {
|
||||
it('extracts a normal extension', () => {
|
||||
expect(HL.ext('src/a/b.php')).toBe('php')
|
||||
expect(HL.ext('x.TSX')).toBe('tsx')
|
||||
})
|
||||
it('treats dotfiles like .env specially', () => {
|
||||
expect(HL.ext('.env')).toBe('env')
|
||||
expect(HL.ext('config/.env.local')).toBe('env')
|
||||
})
|
||||
it('returns empty for no extension', () => {
|
||||
expect(HL.ext('Makefile')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HL.langFor / langLabel', () => {
|
||||
it('maps known extensions to Prism languages', () => {
|
||||
expect(HL.langFor('a.php')).toBe('php')
|
||||
expect(HL.langFor('a.ts')).toBe('typescript')
|
||||
expect(HL.langFor('a.py')).toBe('python')
|
||||
expect(HL.langFor('a.unknownext')).toBeNull()
|
||||
})
|
||||
it('produces human labels', () => {
|
||||
expect(HL.langLabel('a.php')).toBe('PHP')
|
||||
expect(HL.langLabel('a.tsx')).toBe('TypeScript')
|
||||
expect(HL.langLabel('Makefile')).toBe('Plain Text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HL.iconFor', () => {
|
||||
it('uses name-specific icons', () => {
|
||||
expect(HL.iconFor('composer.json').t).toBe('co')
|
||||
expect(HL.iconFor('package.json').t).toBe('pk')
|
||||
})
|
||||
it('falls back to extension icons', () => {
|
||||
expect(HL.iconFor('x.php').c).toBe('#a78bdb')
|
||||
})
|
||||
it('falls back to first two letters for unknown types', () => {
|
||||
expect(HL.iconFor('weird.zzz').t).toBe('we')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HL.escapeHtml', () => {
|
||||
it('escapes html-significant characters', () => {
|
||||
expect(HL.escapeHtml('<a> & </a>')).toBe('<a> & </a>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HL.hlText (Prism)', () => {
|
||||
it('wraps php keywords in token spans (markup-templating loaded first)', () => {
|
||||
const out = HL.hlText('<?php class A {}', 'php')
|
||||
expect(out).toContain('token')
|
||||
expect(out).toContain('class')
|
||||
})
|
||||
it('escapes when no grammar is available', () => {
|
||||
expect(HL.hlText('<x>', null)).toBe('<x>')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user