// @vitest-environment jsdom // // The project note: ⌘N opens it, the text lands in /.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 { const c = render().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 { 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).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).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') }) })