Skip to content

Commit 055911b

Browse files
committed
Enhance command harness and add integration tests for file commands
1 parent 6e71b79 commit 055911b

11 files changed

Lines changed: 1258 additions & 21 deletions

packages/core/test/helpers/command-harness.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ export function createCommandHarness() {
1313
registerAllMock,
1414
toastErrorMock,
1515
getRegistered(commandId: string): RegisteredCommand {
16-
const call = registerAllMock.mock.calls.find((c) => c[0]?.command?.id === commandId)?.[0];
17-
if (!call) {
18-
throw new Error(`Command not registered: ${commandId}`);
16+
for (const [registration] of registerAllMock.mock.calls) {
17+
if (registration?.command?.id === commandId) {
18+
return registration as RegisteredCommand;
19+
}
1920
}
20-
return call as RegisteredCommand;
21+
throw new Error(`Command not registered: ${commandId}`);
2122
},
2223
};
2324
}
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { createCommandHarness } from '../helpers/command-harness';
3+
4+
const harness = createCommandHarness();
5+
const activeSelectionSignalMock = { get: vi.fn() };
6+
const loadEditorMock = vi.fn(async () => undefined);
7+
const toastErrorMock = vi.fn();
8+
const toastInfoMock = vi.fn();
9+
const confirmDialogMock = vi.fn(async () => true);
10+
const showDirectoryPickerMock = vi.fn();
11+
const deleteIndexedDbWorkspaceDataMock = vi.fn(async () => true);
12+
13+
vi.mock('../../src/core/commandregistry', () => ({
14+
registerAll: harness.registerAllMock,
15+
}));
16+
vi.mock('../../src/core/appstate', () => ({
17+
activeSelectionSignal: activeSelectionSignalMock,
18+
}));
19+
vi.mock('../../src/core/editorregistry', () => ({
20+
editorRegistry: { loadEditor: loadEditorMock },
21+
}));
22+
vi.mock('../../src/core/toast', () => ({
23+
toastError: toastErrorMock,
24+
toastInfo: toastInfoMock,
25+
}));
26+
vi.mock('../../src/dialogs', () => ({
27+
confirmDialog: confirmDialogMock,
28+
}));
29+
vi.mock('../../src/core/filesys', async () => {
30+
const actual = await vi.importActual<any>('../../src/core/filesys');
31+
return {
32+
...actual,
33+
deleteIndexedDbWorkspaceData: deleteIndexedDbWorkspaceDataMock,
34+
};
35+
});
36+
37+
describe('commands files branch flow', () => {
38+
const folderName = () => `filesb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
39+
40+
beforeEach(() => {
41+
vi.resetModules();
42+
vi.clearAllMocks();
43+
activeSelectionSignalMock.get.mockReturnValue(undefined);
44+
(globalThis as any).window = {
45+
showDirectoryPicker: showDirectoryPickerMock,
46+
};
47+
});
48+
49+
it('disconnect_folder validates selection and handles indexeddb confirmation', async () => {
50+
const { workspaceService } = await import('../../src/core/filesys');
51+
await workspaceService.disconnectWorkspace();
52+
const name = folderName();
53+
await workspaceService.connectFolder({ indexeddb: true, name });
54+
const workspace = await workspaceService.getWorkspace();
55+
const root = (await workspace?.listChildren(false))?.find((r: any) => r.getName() === name);
56+
57+
await import('../../src/commands/files');
58+
const cmd = harness.getRegistered('disconnect_folder');
59+
60+
activeSelectionSignalMock.get.mockReturnValue(undefined);
61+
await cmd.handler?.execute({});
62+
expect(toastErrorMock).toHaveBeenCalledWith('Select a folder root to disconnect.');
63+
64+
activeSelectionSignalMock.get.mockReturnValue(root);
65+
confirmDialogMock.mockResolvedValue(false);
66+
await cmd.handler?.execute({});
67+
expect(deleteIndexedDbWorkspaceDataMock).not.toHaveBeenCalled();
68+
69+
confirmDialogMock.mockResolvedValue(true);
70+
await cmd.handler?.execute({});
71+
expect(deleteIndexedDbWorkspaceDataMock).toHaveBeenCalled();
72+
});
73+
74+
it('load_workspace handles picker errors', async () => {
75+
showDirectoryPickerMock.mockRejectedValue(new Error('denied'));
76+
await import('../../src/commands/files');
77+
const cmd = harness.getRegistered('load_workspace');
78+
await cmd.handler?.execute({});
79+
expect(toastErrorMock).toHaveBeenCalledWith('denied');
80+
});
81+
82+
it('load_workspace connects selected directory handle', async () => {
83+
const { workspaceService } = await import('../../src/core/filesys');
84+
const connectWorkspaceSpy = vi.spyOn(workspaceService, 'connectWorkspace').mockResolvedValue(undefined as any);
85+
const dirHandle = { kind: 'directory', name: 'picked' } as any;
86+
showDirectoryPickerMock.mockResolvedValue(dirHandle);
87+
88+
await import('../../src/commands/files');
89+
const cmd = harness.getRegistered('load_workspace');
90+
await cmd.handler?.execute({});
91+
92+
expect(connectWorkspaceSpy).toHaveBeenCalledWith(dirHandle);
93+
connectWorkspaceSpy.mockRestore();
94+
});
95+
96+
it('connect_opfs and connect_indexeddb handle connect failures', async () => {
97+
const { workspaceService } = await import('../../src/core/filesys');
98+
const spy = vi.spyOn(workspaceService, 'connectFolder').mockRejectedValue(new Error('boom'));
99+
await import('../../src/commands/files');
100+
const opfs = harness.getRegistered('connect_opfs');
101+
const idb = harness.getRegistered('connect_indexeddb');
102+
103+
await opfs.handler?.execute({});
104+
await idb.handler?.execute({ params: { name: 'x' } });
105+
expect(toastErrorMock).toHaveBeenCalledWith('boom');
106+
spy.mockRestore();
107+
});
108+
109+
it('refresh_resource handles selection and missing workspace', async () => {
110+
const { workspaceService } = await import('../../src/core/filesys');
111+
await workspaceService.disconnectWorkspace();
112+
await import('../../src/commands/files');
113+
const refresh = harness.getRegistered('refresh_resource');
114+
115+
const touch = vi.fn();
116+
activeSelectionSignalMock.get.mockReturnValue({ getWorkspace: () => ({ touch }) });
117+
await refresh.handler?.execute({});
118+
expect(touch).toHaveBeenCalled();
119+
120+
activeSelectionSignalMock.get.mockReturnValue(undefined);
121+
await workspaceService.disconnectWorkspace();
122+
await refresh.handler?.execute({});
123+
expect(toastErrorMock).toHaveBeenCalledWith('No workspace selected.');
124+
});
125+
126+
it('refresh_resource touches workspace when no active selection', async () => {
127+
const { workspaceService } = await import('../../src/core/filesys');
128+
await workspaceService.disconnectWorkspace();
129+
await workspaceService.connectFolder({ indexeddb: true, name: folderName() });
130+
const workspace = await workspaceService.getWorkspace();
131+
132+
await import('../../src/commands/files');
133+
const refresh = harness.getRegistered('refresh_resource');
134+
const touchSpy = vi.spyOn(workspace as any, 'touch');
135+
activeSelectionSignalMock.get.mockReturnValue(undefined);
136+
137+
await refresh.handler?.execute({});
138+
expect(touchSpy).toHaveBeenCalled();
139+
touchSpy.mockRestore();
140+
});
141+
142+
it('open_editor uses active selection fallback when path missing', async () => {
143+
await import('../../src/commands/files');
144+
const openEditor = harness.getRegistered('open_editor');
145+
const fileSelection = Object.create((await import('../../src/core/filesys')).File.prototype);
146+
activeSelectionSignalMock.get.mockReturnValue(fileSelection);
147+
await openEditor.handler?.execute({ params: {} });
148+
expect(loadEditorMock).toHaveBeenCalledWith(fileSelection, undefined);
149+
});
150+
151+
it('open_editor resolves by path and passes editor id', async () => {
152+
const { workspaceService } = await import('../../src/core/filesys');
153+
await workspaceService.disconnectWorkspace();
154+
const rootName = folderName();
155+
await workspaceService.connectFolder({ indexeddb: true, name: rootName });
156+
const workspace = await workspaceService.getWorkspace();
157+
const file = await workspace?.getResource(`${rootName}/file.ts`, { create: true });
158+
await (file as any)?.saveContents('const x = 1;');
159+
160+
await import('../../src/commands/files');
161+
const openEditor = harness.getRegistered('open_editor');
162+
await openEditor.handler?.execute({ params: { path: `${rootName}/file.ts`, editorId: 'custom.editor' } });
163+
164+
expect(loadEditorMock).toHaveBeenLastCalledWith(expect.anything(), 'custom.editor');
165+
});
166+
167+
it('disconnect_folder shows toast when disconnect throws', async () => {
168+
const { workspaceService } = await import('../../src/core/filesys');
169+
await workspaceService.disconnectWorkspace();
170+
const name = folderName();
171+
await workspaceService.connectFolder({ indexeddb: true, name });
172+
const workspace = await workspaceService.getWorkspace();
173+
const root = (await workspace?.listChildren(false))?.find((r: any) => r.getName() === name);
174+
const disconnectSpy = vi.spyOn(workspaceService, 'disconnectFolder').mockRejectedValue(new Error('disconnect failed'));
175+
176+
await import('../../src/commands/files');
177+
const cmd = harness.getRegistered('disconnect_folder');
178+
activeSelectionSignalMock.get.mockReturnValue(root);
179+
await cmd.handler?.execute({});
180+
181+
expect(toastErrorMock).toHaveBeenCalledWith('disconnect failed');
182+
disconnectSpy.mockRestore();
183+
});
184+
185+
it('active editor commands handle thrown provider errors and invalid snippet args', async () => {
186+
await import('../../src/commands/files');
187+
const c1 = harness.getRegistered('get_active_editor_content');
188+
const c2 = harness.getRegistered('get_active_editor_selection');
189+
const c3 = harness.getRegistered('get_active_editor_snippet');
190+
191+
const throwingEditor = {
192+
getContent: () => {
193+
throw new Error('x');
194+
},
195+
getSelection: () => {
196+
throw new Error('x');
197+
},
198+
getSnippet: () => null,
199+
getLanguage: () => 'ts',
200+
getFilePath: () => 'x.ts',
201+
};
202+
203+
expect(await c1.handler?.execute({ activeEditor: throwingEditor })).toEqual({
204+
content: null,
205+
filePath: null,
206+
language: null,
207+
});
208+
expect(await c2.handler?.execute({ activeEditor: throwingEditor })).toEqual({
209+
selection: null,
210+
filePath: null,
211+
language: null,
212+
});
213+
expect(await c3.handler?.execute({ activeEditor: throwingEditor, params: { lines: '-1' } })).toEqual({
214+
filePath: null,
215+
language: null,
216+
snippet: null,
217+
cursorLine: null,
218+
});
219+
});
220+
221+
it('active editor commands return null shape for non-provider editors', async () => {
222+
await import('../../src/commands/files');
223+
const c1 = harness.getRegistered('get_active_editor_content');
224+
const c2 = harness.getRegistered('get_active_editor_selection');
225+
const c3 = harness.getRegistered('get_active_editor_snippet');
226+
227+
expect(await c1.handler?.execute({ activeEditor: {} })).toEqual({
228+
content: null,
229+
filePath: null,
230+
language: null,
231+
});
232+
expect(await c2.handler?.execute({ activeEditor: {} })).toEqual({
233+
selection: null,
234+
filePath: null,
235+
language: null,
236+
});
237+
expect(await c3.handler?.execute({ activeEditor: {} })).toEqual({
238+
snippet: null,
239+
filePath: null,
240+
language: null,
241+
cursorLine: null,
242+
});
243+
});
244+
245+
it('active editor snippet handles null and thrown snippet providers', async () => {
246+
await import('../../src/commands/files');
247+
const c3 = harness.getRegistered('get_active_editor_snippet');
248+
const nullSnippetEditor = {
249+
getContent: () => 'c',
250+
getSelection: () => 's',
251+
getSnippet: () => null,
252+
getLanguage: () => 'ts',
253+
getFilePath: () => 'x.ts',
254+
};
255+
const throwSnippetEditor = {
256+
...nullSnippetEditor,
257+
getSnippet: () => {
258+
throw new Error('snippet fail');
259+
},
260+
};
261+
262+
expect(await c3.handler?.execute({ activeEditor: nullSnippetEditor, params: { lines: '2' } })).toEqual({
263+
snippet: null,
264+
filePath: null,
265+
language: null,
266+
cursorLine: null,
267+
});
268+
expect(await c3.handler?.execute({ activeEditor: throwSnippetEditor, params: { lines: '2' } })).toEqual({
269+
snippet: null,
270+
filePath: null,
271+
language: null,
272+
cursorLine: null,
273+
});
274+
});
275+
276+
it('active editor snippet returns data for valid provider', async () => {
277+
await import('../../src/commands/files');
278+
const c3 = harness.getRegistered('get_active_editor_snippet');
279+
const okEditor = {
280+
getContent: () => 'c',
281+
getSelection: () => 's',
282+
getSnippet: () => ({ snippet: 'line1\nline2', cursorLine: 2 }),
283+
getLanguage: () => 'ts',
284+
getFilePath: () => 'src/file.ts',
285+
};
286+
287+
expect(await c3.handler?.execute({ activeEditor: okEditor, params: { lines: '3' } })).toEqual({
288+
snippet: 'line1\nline2',
289+
filePath: 'src/file.ts',
290+
language: 'ts',
291+
cursorLine: 2,
292+
});
293+
});
294+
});

0 commit comments

Comments
 (0)