83
test/editor-tab.test.tsx
Normal file
83
test/editor-tab.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
// @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 find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.trim() === text)
|
||||
}
|
||||
|
||||
// Open a changed file and switch to the writable buffer.
|
||||
async function openEditor(): Promise<HTMLTextAreaElement> {
|
||||
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())
|
||||
fireEvent.click(find(c, '.seg button', 'Actual')!)
|
||||
return await waitFor(() => {
|
||||
const ta = c.querySelector<HTMLTextAreaElement>('.ce-ta')
|
||||
if (!ta) throw new Error('no buffer')
|
||||
return ta
|
||||
})
|
||||
}
|
||||
|
||||
function tab(ta: HTMLTextAreaElement, shift = false): boolean {
|
||||
return fireEvent.keyDown(ta, { key: 'Tab', shiftKey: shift })
|
||||
}
|
||||
|
||||
describe('Tab in the code editor', () => {
|
||||
it('inserts four spaces at the caret instead of moving focus', async () => {
|
||||
const ta = await openEditor()
|
||||
const before = ta.value
|
||||
ta.setSelectionRange(0, 0)
|
||||
|
||||
// fireEvent returns false when a handler called preventDefault, which is
|
||||
// what stops the browser tabbing focus over to the agent column.
|
||||
expect(tab(ta)).toBe(false)
|
||||
await waitFor(() => expect(ta.value).toBe(' ' + before))
|
||||
})
|
||||
|
||||
it('indents every line a multi-line selection touches', async () => {
|
||||
const ta = await openEditor()
|
||||
const lines = ta.value.split('\n')
|
||||
// Select from inside line 1 to inside line 2.
|
||||
ta.setSelectionRange(1, lines[0].length + 2)
|
||||
|
||||
tab(ta)
|
||||
await waitFor(() => {
|
||||
const now = ta.value.split('\n')
|
||||
expect(now[0]).toBe(' ' + lines[0])
|
||||
expect(now[1]).toBe(' ' + lines[1])
|
||||
expect(now[2]).toBe(lines[2])
|
||||
})
|
||||
})
|
||||
|
||||
it('Shift+Tab outdents the current line', async () => {
|
||||
const ta = await openEditor()
|
||||
ta.setSelectionRange(0, 0)
|
||||
tab(ta)
|
||||
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(true))
|
||||
|
||||
ta.setSelectionRange(6, 6)
|
||||
expect(tab(ta, true)).toBe(false)
|
||||
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(false))
|
||||
})
|
||||
})
|
||||
@@ -41,4 +41,33 @@ describe('renderMarkdown', () => {
|
||||
expect(renderMarkdown('- a\n- b')).toContain('<ul><li>a</li><li>b</li></ul>')
|
||||
expect(renderMarkdown('1. a\n2. b')).toContain('<ol><li>a</li><li>b</li></ol>')
|
||||
})
|
||||
|
||||
it('renders a GFM table with header, body and inline markup', () => {
|
||||
const html = renderMarkdown('| a | b |\n|---|---|\n| 1 | `x` |\n| 2 | **y** |')
|
||||
expect(html).toContain('<table class="md-table">')
|
||||
expect(html).toContain('<thead><tr><th>a</th><th>b</th></tr></thead>')
|
||||
expect(html).toContain('<td>1</td><td><code>x</code></td>')
|
||||
expect(html).toContain('<strong>y</strong>')
|
||||
})
|
||||
|
||||
it('applies column alignment from the delimiter row', () => {
|
||||
const html = renderMarkdown('| l | c | r |\n| :-- | :-: | --: |\n| 1 | 2 | 3 |')
|
||||
expect(html).toContain('<th style="text-align:left">l</th>')
|
||||
expect(html).toContain('<th style="text-align:center">c</th>')
|
||||
expect(html).toContain('<th style="text-align:right">r</th>')
|
||||
expect(html).toContain('<td style="text-align:center">2</td>')
|
||||
})
|
||||
|
||||
it('handles escaped pipes, ragged rows and pipe-less prose after the table', () => {
|
||||
const html = renderMarkdown('| a | b |\n|---|---|\n| x \\| y | 2 |\n| short |\n\nAfter.')
|
||||
expect(html).toContain('<td>x | y</td>')
|
||||
expect(html).toContain('<td>short</td><td></td>')
|
||||
expect(html).toContain('<p>After.</p>')
|
||||
})
|
||||
|
||||
it('leaves a pipe-bearing paragraph alone when no delimiter row follows', () => {
|
||||
const html = renderMarkdown('a | b\nnot a table')
|
||||
expect(html).not.toContain('<table')
|
||||
expect(html).toContain('<p>a | b not a table</p>')
|
||||
})
|
||||
})
|
||||
|
||||
200
test/notes.test.tsx
Normal file
200
test/notes.test.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The project note: ⌘N opens it, the text lands in <project>/.notes.txt, and it
|
||||
// is written whenever the window loses focus.
|
||||
import { afterEach, beforeAll, beforeEach, 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'
|
||||
|
||||
let stored = ''
|
||||
let writes: string[] = []
|
||||
|
||||
function stubBridge(): void {
|
||||
const noop = (): void => {}
|
||||
const off = (): (() => void) => noop
|
||||
;(window as unknown as { helder: unknown }).helder = {
|
||||
platform: 'darwin',
|
||||
clipboard: { writeText: noop, readText: () => '' },
|
||||
project: {
|
||||
current: async () => ({ root: '/repo', name: 'repo' }),
|
||||
open: async () => ({ root: '/repo', name: 'repo' }),
|
||||
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
||||
recent: async () => [],
|
||||
},
|
||||
fs: {
|
||||
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [] }),
|
||||
readDir: async () => [], files: async () => ({}), read: async () => '',
|
||||
imageDataUrl: async () => '', write: async () => {}, delete: async () => {},
|
||||
create: async () => {}, mkdir: async () => {},
|
||||
},
|
||||
shell: { reveal: noop },
|
||||
notes: {
|
||||
read: async () => stored,
|
||||
write: async (t: string) => { stored = t; writes.push(t) },
|
||||
},
|
||||
git: {
|
||||
load: async () => ({ branch: 'main', changes: [] }),
|
||||
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||
},
|
||||
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
||||
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
|
||||
recent: { get: async () => [], set: async () => {} },
|
||||
search: { content: async () => [], files: async () => [] },
|
||||
dialog: { unsavedClose: async () => 'cancel' },
|
||||
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
||||
onProjectChanged: off, onConfigChanged: off, onRefresh: off,
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||
})
|
||||
beforeEach(() => { stored = ''; writes = []; stubBridge() })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
delete (window as unknown as { helder?: unknown }).helder
|
||||
})
|
||||
|
||||
async function boot(): Promise<HTMLElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
|
||||
return c
|
||||
}
|
||||
|
||||
function pressCmdN(): void {
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', metaKey: true })) })
|
||||
}
|
||||
|
||||
/** Open the note. The shortcut is registered in an effect, which React may not
|
||||
* have flushed yet when the first paint lands, so keep pressing until it takes.
|
||||
* A second ⌘N with the overlay already open is a no-op. */
|
||||
async function openNote(c: HTMLElement): Promise<HTMLTextAreaElement> {
|
||||
await waitFor(() => {
|
||||
pressCmdN()
|
||||
if (!c.querySelector('.notes-modal')) throw new Error('note not open')
|
||||
})
|
||||
return c.querySelector('.notes-input') as HTMLTextAreaElement
|
||||
}
|
||||
|
||||
function blurWindow(): void {
|
||||
act(() => { window.dispatchEvent(new Event('blur')) })
|
||||
}
|
||||
|
||||
describe('project note', () => {
|
||||
it('⌘N opens the note overlay', async () => {
|
||||
const c = await boot()
|
||||
expect(c.querySelector('.notes-modal')).toBeNull()
|
||||
await openNote(c)
|
||||
expect(c.querySelector('.notes-modal .notes-file')?.textContent).toBe('.notes.txt')
|
||||
})
|
||||
|
||||
it('loads the note that is already on disk', async () => {
|
||||
stored = 'earlier thoughts\n'
|
||||
const c = await boot()
|
||||
const ta = await openNote(c)
|
||||
await waitFor(() => expect(ta.value).toBe('earlier thoughts\n'))
|
||||
})
|
||||
|
||||
it('writes the note when the window loses focus', async () => {
|
||||
const c = await boot()
|
||||
const ta = await openNote(c)
|
||||
fireEvent.change(ta, { target: { value: 'buy milk' } })
|
||||
|
||||
expect(writes).toEqual([]) // nothing written while typing
|
||||
blurWindow()
|
||||
await waitFor(() => expect(writes).toEqual(['buy milk']))
|
||||
expect(stored).toBe('buy milk')
|
||||
})
|
||||
|
||||
it('does not rewrite the file when nothing changed', async () => {
|
||||
stored = 'unchanged'
|
||||
await boot()
|
||||
await waitFor(() => expect(stored).toBe('unchanged'))
|
||||
blurWindow()
|
||||
blurWindow()
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('Esc closes the note and saves it right away', async () => {
|
||||
const c = await boot()
|
||||
const ta = await openNote(c)
|
||||
fireEvent.change(ta, { target: { value: 'quick capture' } })
|
||||
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||
await waitFor(() => expect(writes).toEqual(['quick capture']))
|
||||
})
|
||||
|
||||
it('⌘P passes the note to the agent, then saves and closes it', async () => {
|
||||
const c = await boot()
|
||||
const ta = await openNote(c)
|
||||
fireEvent.change(ta, { target: { value: 'refactor the policy' } })
|
||||
|
||||
const seen: string[] = []
|
||||
const onPaste = (e: Event): void => { seen.push((e as CustomEvent<string>).detail) }
|
||||
window.addEventListener('agentPaste', onPaste)
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', metaKey: true })) })
|
||||
window.removeEventListener('agentPaste', onPaste)
|
||||
|
||||
expect(seen).toEqual(['refactor the policy'])
|
||||
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||
await waitFor(() => expect(writes).toEqual(['refactor the policy']))
|
||||
})
|
||||
|
||||
it('⌘P does nothing when the note is empty', async () => {
|
||||
const c = await boot()
|
||||
await openNote(c)
|
||||
|
||||
const seen: string[] = []
|
||||
const onPaste = (e: Event): void => { seen.push((e as CustomEvent<string>).detail) }
|
||||
window.addEventListener('agentPaste', onPaste)
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', metaKey: true })) })
|
||||
window.removeEventListener('agentPaste', onPaste)
|
||||
|
||||
expect(seen).toEqual([])
|
||||
expect(c.querySelector('.notes-modal')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('the header shows the pass-to-agent hint', async () => {
|
||||
const c = await boot()
|
||||
await openNote(c)
|
||||
const hint = c.querySelector('.notes-modal .notes-hint')
|
||||
expect(hint?.textContent).toContain('To agent')
|
||||
expect(hint?.querySelector('kbd')?.textContent).toBe('⌘P')
|
||||
})
|
||||
|
||||
it('the title bar carries the ⌘N note action', async () => {
|
||||
const c = await boot()
|
||||
const btn = [...c.querySelectorAll('.titlebar .tb-btn')]
|
||||
.find((b) => b.textContent?.includes('Note')) as HTMLButtonElement | undefined
|
||||
expect(btn?.querySelector('kbd')?.textContent).toBe('⌘N')
|
||||
expect(btn?.className).not.toContain('on')
|
||||
|
||||
act(() => { btn?.click() })
|
||||
await waitFor(() => expect(c.querySelector('.notes-modal')).not.toBeNull())
|
||||
// The action reads as "on" while the note is open, like the other toggles.
|
||||
expect(btn?.className).toContain('on')
|
||||
})
|
||||
|
||||
it('keeps the text when reopened', async () => {
|
||||
const c = await boot()
|
||||
const ta = await openNote(c)
|
||||
fireEvent.change(ta, { target: { value: 'still here' } })
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||
|
||||
const again = await openNote(c)
|
||||
expect(again.value).toBe('still here')
|
||||
})
|
||||
})
|
||||
105
test/titlebar.test.tsx
Normal file
105
test/titlebar.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The title bar: borderless actions that go accent when on, and the fullscreen
|
||||
// shift (macOS hides the traffic lights, so the project name moves to the edge).
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, 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'
|
||||
|
||||
/** Fires the fullscreen callbacks the App subscribed to. */
|
||||
let fullscreenCbs: ((on: boolean) => void)[] = []
|
||||
|
||||
function stubBridge(): void {
|
||||
const noop = (): void => {}
|
||||
const off = (): (() => void) => noop
|
||||
;(window as unknown as { helder: unknown }).helder = {
|
||||
platform: 'darwin',
|
||||
clipboard: { writeText: noop, readText: () => '' },
|
||||
project: {
|
||||
current: async () => ({ root: '/repo', name: 'repo' }),
|
||||
open: async () => ({ root: '/repo', name: 'repo' }),
|
||||
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
||||
recent: async () => [],
|
||||
},
|
||||
fs: {
|
||||
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [] }),
|
||||
readDir: async () => [], files: async () => ({}), read: async () => '',
|
||||
imageDataUrl: async () => '', write: async () => {}, delete: async () => {},
|
||||
create: async () => {}, mkdir: async () => {},
|
||||
},
|
||||
shell: { reveal: noop },
|
||||
notes: { read: async () => '', write: async () => {} },
|
||||
git: {
|
||||
load: async () => ({ branch: 'main', changes: [] }),
|
||||
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||
},
|
||||
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
||||
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
|
||||
recent: { get: async () => [], set: async () => {} },
|
||||
search: { content: async () => [], files: async () => [] },
|
||||
dialog: { unsavedClose: async () => 'cancel' },
|
||||
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
||||
onFullscreen: (cb: (on: boolean) => void) => {
|
||||
fullscreenCbs.push(cb)
|
||||
return () => { fullscreenCbs = fullscreenCbs.filter((f) => f !== cb) }
|
||||
},
|
||||
onProjectChanged: off, onConfigChanged: off, onRefresh: off,
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||
})
|
||||
beforeEach(() => { fullscreenCbs = []; stubBridge() })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
delete (window as unknown as { helder?: unknown }).helder
|
||||
})
|
||||
|
||||
async function boot(): Promise<HTMLElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
|
||||
return c
|
||||
}
|
||||
|
||||
function setFullscreen(on: boolean): void {
|
||||
act(() => { fullscreenCbs.forEach((cb) => cb(on)) })
|
||||
}
|
||||
|
||||
describe('title bar', () => {
|
||||
it('shifts left in fullscreen and back out again', async () => {
|
||||
const c = await boot()
|
||||
const bar = c.querySelector('.titlebar') as HTMLElement
|
||||
expect(bar.className).not.toContain('fullscreen')
|
||||
|
||||
await waitFor(() => expect(fullscreenCbs.length).toBe(1))
|
||||
setFullscreen(true)
|
||||
expect(bar.className).toContain('fullscreen')
|
||||
|
||||
setFullscreen(false)
|
||||
expect(bar.className).not.toContain('fullscreen')
|
||||
})
|
||||
|
||||
it('shows the state with the accent, not an On/Off badge', async () => {
|
||||
const c = await boot()
|
||||
const hidden = [...c.querySelectorAll<HTMLButtonElement>('.titlebar .tb-btn')]
|
||||
.find((b) => b.textContent?.includes('Hidden')) as HTMLButtonElement
|
||||
expect(c.querySelector('.titlebar .tb-state')).toBeNull()
|
||||
expect(hidden.className).not.toContain('on')
|
||||
|
||||
act(() => { hidden.click() })
|
||||
await waitFor(() => expect(hidden.className).toContain('on'))
|
||||
expect(hidden.textContent).not.toContain('On')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user