yishentu--claudian
1610 行
48 KiB
TypeScript
1610 行
48 KiB
TypeScript
import '@/providers';
|
|
|
|
import { createMockEl } from '@test/helpers/mockElement';
|
|
import { MarkdownRenderer, Notice } from 'obsidian';
|
|
|
|
import { ProviderRegistry } from '@/core/providers/ProviderRegistry';
|
|
import { type InlineEditContext, InlineEditModal } from '@/features/inline-edit/ui/InlineEditModal';
|
|
import { VaultFolderCache } from '@/shared/mention/VaultMentionCache';
|
|
import * as editorUtils from '@/utils/editor';
|
|
|
|
const mentionDropdownCtor = jest.fn();
|
|
jest.mock('@/shared/mention/MentionDropdownController', () => ({
|
|
MentionDropdownController: function MockMentionDropdownController(...args: any[]) {
|
|
mentionDropdownCtor(...args);
|
|
return {
|
|
handleInputChange: jest.fn(),
|
|
handleKeydown: jest.fn().mockReturnValue(false),
|
|
destroy: jest.fn(),
|
|
};
|
|
},
|
|
}));
|
|
|
|
jest.mock('@/shared/components/SlashCommandDropdown', () => ({
|
|
SlashCommandDropdown: jest.fn().mockImplementation(() => ({
|
|
handleKeydown: jest.fn().mockReturnValue(false),
|
|
destroy: jest.fn(),
|
|
})),
|
|
}));
|
|
|
|
jest.mock('@/utils/externalContextScanner', () => ({
|
|
externalContextScanner: {
|
|
scanPaths: jest.fn().mockReturnValue([]),
|
|
},
|
|
}));
|
|
|
|
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((promiseResolve) => {
|
|
resolve = promiseResolve;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe('InlineEditModal - openAndWait', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('uses editorCallback references first and falls back to view.editor before rejecting', async () => {
|
|
const callbackEditor = {} as any;
|
|
const fallbackEditor = {} as any;
|
|
|
|
const app = {
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {} as any;
|
|
const view = { editor: fallbackEditor } as any;
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValueOnce(undefined)
|
|
.mockReturnValueOnce(undefined);
|
|
|
|
const modal = new InlineEditModal(app, plugin, callbackEditor, view, editContext, 'note.md');
|
|
const result = await modal.openAndWait();
|
|
|
|
expect(result).toEqual({ decision: 'reject' });
|
|
expect(getEditorViewSpy).toHaveBeenNthCalledWith(1, callbackEditor);
|
|
expect(getEditorViewSpy).toHaveBeenNthCalledWith(2, fallbackEditor);
|
|
expect(app.workspace.getActiveViewOfType).not.toHaveBeenCalled();
|
|
|
|
const noticeMock = Notice as unknown as jest.Mock;
|
|
expect(noticeMock).toHaveBeenCalledWith(
|
|
'Inline edit unavailable: could not access the active editor. Try reopening the note.'
|
|
);
|
|
});
|
|
|
|
it('wires mention getCachedVaultFolders through VaultFolderCache.getFolders', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
getFiles: jest.fn().mockReturnValue([]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
codex: [],
|
|
},
|
|
},
|
|
getSdkCommands: jest.fn().mockReturnValue([]),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
const getFoldersSpy = jest
|
|
.spyOn(VaultFolderCache.prototype, 'getFolders')
|
|
.mockReturnValue([{ name: 'src', path: 'src' } as any]);
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md');
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
expect(mentionDropdownCtor).toHaveBeenCalled();
|
|
const callbacks = mentionDropdownCtor.mock.calls[0]?.[2];
|
|
expect(callbacks).toBeDefined();
|
|
expect(callbacks.getCachedVaultFolders()).toEqual([{ name: 'src', path: 'src' }]);
|
|
expect(getFoldersSpy).toHaveBeenCalledTimes(1);
|
|
|
|
widgetRef?.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
|
|
getEditorViewSpy.mockRestore();
|
|
getFoldersSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('uses provider-scoped hidden commands for Codex inline edit dropdowns', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
getFiles: jest.fn().mockReturnValue([]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: ['commit'],
|
|
codex: ['analyze'],
|
|
},
|
|
},
|
|
getConversationSync: jest.fn().mockReturnValue(null),
|
|
getView: jest.fn().mockReturnValue({
|
|
getActiveTab: jest.fn().mockReturnValue({
|
|
providerId: 'codex',
|
|
service: null,
|
|
conversationId: null,
|
|
}),
|
|
}),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
jest.spyOn(editorUtils, 'getEditorView').mockReturnValue(editorView);
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md');
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
const { SlashCommandDropdown } = jest.requireMock('@/shared/components/SlashCommandDropdown');
|
|
const constructorCall = SlashCommandDropdown.mock.calls[0];
|
|
expect(Array.from(constructorCall[3].hiddenCommands)).toEqual(['analyze']);
|
|
|
|
widgetRef?.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('passes the active chat runtime model into inline edit services when available', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
getFiles: jest.fn().mockReturnValue([]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const inlineEditService = {
|
|
cancel: jest.fn(),
|
|
continueConversation: jest.fn(),
|
|
editText: jest.fn(),
|
|
resetConversation: jest.fn(),
|
|
setModelOverride: jest.fn(),
|
|
};
|
|
const providerSpy = jest
|
|
.spyOn(ProviderRegistry, 'createInlineEditService')
|
|
.mockReturnValue(inlineEditService as any);
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
opencode: [],
|
|
},
|
|
},
|
|
getConversationSync: jest.fn().mockReturnValue(null),
|
|
getView: jest.fn().mockReturnValue({
|
|
getActiveTab: jest.fn().mockReturnValue({
|
|
conversationId: null,
|
|
draftModel: 'opencode:openai/gpt-5.4',
|
|
providerId: 'opencode',
|
|
service: {
|
|
getAuxiliaryModel: jest.fn().mockReturnValue('opencode:openai/gpt-5.4'),
|
|
providerId: 'opencode',
|
|
},
|
|
}),
|
|
}),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md');
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
expect(providerSpy).toHaveBeenCalledWith(plugin, 'opencode');
|
|
expect(inlineEditService.setModelOverride).toHaveBeenCalledWith('opencode:openai/gpt-5.4');
|
|
|
|
widgetRef.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
getEditorViewSpy.mockRestore();
|
|
providerSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('passes the bound conversation model into inline edit services before runtime initialization', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
getFiles: jest.fn().mockReturnValue([]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const inlineEditService = {
|
|
cancel: jest.fn(),
|
|
continueConversation: jest.fn(),
|
|
editText: jest.fn(),
|
|
resetConversation: jest.fn(),
|
|
setModelOverride: jest.fn(),
|
|
};
|
|
const providerSpy = jest
|
|
.spyOn(ProviderRegistry, 'createInlineEditService')
|
|
.mockReturnValue(inlineEditService as any);
|
|
const conversation = {
|
|
id: 'conv-1',
|
|
providerId: 'opencode',
|
|
selectedModel: 'opencode:anthropic/claude-sonnet-4',
|
|
};
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
opencode: [],
|
|
},
|
|
},
|
|
getConversationSync: jest.fn().mockReturnValue(conversation),
|
|
getView: jest.fn().mockReturnValue({
|
|
getActiveTab: jest.fn().mockReturnValue({
|
|
conversationId: 'conv-1',
|
|
draftModel: null,
|
|
providerId: 'opencode',
|
|
service: null,
|
|
}),
|
|
}),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md');
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
expect(providerSpy).toHaveBeenCalledWith(plugin, 'opencode');
|
|
expect(inlineEditService.setModelOverride).toHaveBeenCalledWith('opencode:anthropic/claude-sonnet-4');
|
|
|
|
widgetRef.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
getEditorViewSpy.mockRestore();
|
|
providerSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('shows a single notice and degrades gracefully when getFiles throws', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
adapter: { basePath: '/vault' },
|
|
getFiles: jest.fn().mockImplementation(() => {
|
|
throw new Error('vault unavailable');
|
|
}),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
codex: [],
|
|
},
|
|
},
|
|
getSdkCommands: jest.fn().mockReturnValue([]),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0, number: 1 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
|
|
const { externalContextScanner } = jest.requireMock('@/utils/externalContextScanner');
|
|
(externalContextScanner.scanPaths as jest.Mock).mockImplementation((paths: string[]) => {
|
|
if (paths[0] === '/external') {
|
|
return [
|
|
{
|
|
path: '/external/src/app.md',
|
|
name: 'app.md',
|
|
relativePath: 'src/app.md',
|
|
contextRoot: '/external',
|
|
mtime: 1000,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const modal = new InlineEditModal(
|
|
app,
|
|
plugin,
|
|
editor,
|
|
view,
|
|
editContext,
|
|
'note.md',
|
|
() => ['/external']
|
|
);
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
const callbacks = mentionDropdownCtor.mock.calls[0]?.[2];
|
|
expect(callbacks.getCachedVaultFiles()).toEqual([]);
|
|
expect(callbacks.getCachedVaultFiles()).toEqual([]);
|
|
|
|
const editTextMock = jest.fn().mockResolvedValue({
|
|
success: true,
|
|
clarification: 'Need more detail',
|
|
});
|
|
widgetRef.inlineEditService = {
|
|
editText: editTextMock,
|
|
continueConversation: jest.fn(),
|
|
cancel: jest.fn(),
|
|
resetConversation: jest.fn(),
|
|
};
|
|
|
|
widgetRef.inputEl.value = 'Please check @external/src/app.md.';
|
|
await widgetRef.generate();
|
|
|
|
expect(editTextMock).toHaveBeenCalledTimes(1);
|
|
expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['/external/src/app.md']);
|
|
|
|
const noticeMock = Notice as unknown as jest.Mock;
|
|
expect(noticeMock).toHaveBeenCalledTimes(1);
|
|
expect(noticeMock).toHaveBeenCalledWith(
|
|
'Failed to load vault files. Vault @-mentions may be unavailable.'
|
|
);
|
|
|
|
widgetRef.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
getEditorViewSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('parses @mentions into contextFiles at send time without dropdown attachment state', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
adapter: { basePath: '/vault' },
|
|
getFiles: jest.fn().mockReturnValue([
|
|
{ path: 'notes/a.md' },
|
|
{ path: 'notes/b.md' },
|
|
]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
codex: [],
|
|
},
|
|
},
|
|
getSdkCommands: jest.fn().mockReturnValue([]),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0, number: 1 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md');
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
const editTextMock = jest.fn().mockResolvedValue({
|
|
success: true,
|
|
clarification: 'Need more detail',
|
|
});
|
|
widgetRef.inlineEditService = {
|
|
editText: editTextMock,
|
|
continueConversation: jest.fn(),
|
|
cancel: jest.fn(),
|
|
resetConversation: jest.fn(),
|
|
};
|
|
|
|
widgetRef.inputEl.value = 'Please check @notes/a.md and @notes/a.md.';
|
|
await widgetRef.generate();
|
|
|
|
expect(editTextMock).toHaveBeenCalledTimes(1);
|
|
expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['notes/a.md']);
|
|
|
|
widgetRef.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
getEditorViewSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('resolves external context @mentions into contextFiles at send time', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
adapter: { basePath: '/vault' },
|
|
getFiles: jest.fn().mockReturnValue([{ path: 'notes/local.md' }]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
codex: [],
|
|
},
|
|
},
|
|
getSdkCommands: jest.fn().mockReturnValue([]),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0, number: 1 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const { externalContextScanner } = jest.requireMock('@/utils/externalContextScanner');
|
|
(externalContextScanner.scanPaths as jest.Mock).mockImplementation((paths: string[]) => {
|
|
if (paths[0] === '/external') {
|
|
return [
|
|
{
|
|
path: '/external/src/app.md',
|
|
name: 'app.md',
|
|
relativePath: 'src/app.md',
|
|
contextRoot: '/external',
|
|
mtime: 1000,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
const modal = new InlineEditModal(
|
|
app,
|
|
plugin,
|
|
editor,
|
|
view,
|
|
editContext,
|
|
'note.md',
|
|
() => ['/external']
|
|
);
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
const editTextMock = jest.fn().mockResolvedValue({
|
|
success: true,
|
|
clarification: 'Need more detail',
|
|
});
|
|
widgetRef.inlineEditService = {
|
|
editText: editTextMock,
|
|
continueConversation: jest.fn(),
|
|
cancel: jest.fn(),
|
|
resetConversation: jest.fn(),
|
|
};
|
|
|
|
widgetRef.inputEl.value = 'Please check @external/src/app.md.';
|
|
await widgetRef.generate();
|
|
|
|
expect(editTextMock).toHaveBeenCalledTimes(1);
|
|
expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['/external/src/app.md']);
|
|
|
|
widgetRef.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
getEditorViewSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('parses vault @mentions with spaces into contextFiles at send time', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
adapter: { basePath: '/vault' },
|
|
getFiles: jest.fn().mockReturnValue([
|
|
{ path: 'notes/my note.md' },
|
|
]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
codex: [],
|
|
},
|
|
},
|
|
getSdkCommands: jest.fn().mockReturnValue([]),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0, number: 1 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md');
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
const editTextMock = jest.fn().mockResolvedValue({
|
|
success: true,
|
|
clarification: 'Need more detail',
|
|
});
|
|
widgetRef.inlineEditService = {
|
|
editText: editTextMock,
|
|
continueConversation: jest.fn(),
|
|
cancel: jest.fn(),
|
|
resetConversation: jest.fn(),
|
|
};
|
|
|
|
widgetRef.inputEl.value = 'Please check @notes/my note.md.';
|
|
await widgetRef.generate();
|
|
|
|
expect(editTextMock).toHaveBeenCalledTimes(1);
|
|
expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['notes/my note.md']);
|
|
|
|
widgetRef.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
getEditorViewSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('resolves external @mentions when vault has no files', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
adapter: { basePath: '/vault' },
|
|
getFiles: jest.fn().mockReturnValue([]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
codex: [],
|
|
},
|
|
},
|
|
getSdkCommands: jest.fn().mockReturnValue([]),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0, number: 1 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const { externalContextScanner } = jest.requireMock('@/utils/externalContextScanner');
|
|
(externalContextScanner.scanPaths as jest.Mock).mockImplementation((paths: string[]) => {
|
|
if (paths[0] === '/external') {
|
|
return [
|
|
{
|
|
path: '/external/src/my file.md',
|
|
name: 'my file.md',
|
|
relativePath: 'src/my file.md',
|
|
contextRoot: '/external',
|
|
mtime: 1000,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
const modal = new InlineEditModal(
|
|
app,
|
|
plugin,
|
|
editor,
|
|
view,
|
|
editContext,
|
|
'note.md',
|
|
() => ['/external']
|
|
);
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
const editTextMock = jest.fn().mockResolvedValue({
|
|
success: true,
|
|
clarification: 'Need more detail',
|
|
});
|
|
widgetRef.inlineEditService = {
|
|
editText: editTextMock,
|
|
continueConversation: jest.fn(),
|
|
cancel: jest.fn(),
|
|
resetConversation: jest.fn(),
|
|
};
|
|
|
|
widgetRef.inputEl.value = 'Please check @external/src/my file.md.';
|
|
await widgetRef.generate();
|
|
|
|
expect(editTextMock).toHaveBeenCalledTimes(1);
|
|
expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['/external/src/my file.md']);
|
|
|
|
widgetRef.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
getEditorViewSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('renders clarification replies as markdown with the active note path', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
getFiles: jest.fn().mockReturnValue([]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
codex: [],
|
|
},
|
|
mediaFolder: '',
|
|
},
|
|
getSdkCommands: jest.fn().mockReturnValue([]),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0, number: 1 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'math/note.md');
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
(MarkdownRenderer.renderMarkdown as jest.Mock).mockClear();
|
|
widgetRef.showAgentReply('Should this use $Z(f)$?');
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalledWith(
|
|
'Should this use $Z(f)$?',
|
|
expect.anything(),
|
|
'math/note.md',
|
|
plugin
|
|
);
|
|
|
|
widgetRef.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
getEditorViewSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('does not let stale clarification markdown renders overwrite newer replies', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
getFiles: jest.fn().mockReturnValue([]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
codex: [],
|
|
},
|
|
mediaFolder: '',
|
|
},
|
|
getSdkCommands: jest.fn().mockReturnValue([]),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0, number: 1 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
|
|
const firstRender = createDeferred();
|
|
const secondRender = createDeferred();
|
|
(MarkdownRenderer.renderMarkdown as jest.Mock)
|
|
.mockImplementationOnce((markdown: string, container: any) => {
|
|
container.createDiv({ text: markdown });
|
|
return firstRender.promise;
|
|
})
|
|
.mockImplementationOnce((markdown: string, container: any) => {
|
|
container.createDiv({ text: markdown });
|
|
return secondRender.promise;
|
|
});
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'math/note.md');
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
widgetRef.showAgentReply('First clarification');
|
|
widgetRef.showAgentReply('Second clarification');
|
|
|
|
secondRender.resolve();
|
|
await secondRender.promise;
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(widgetRef.agentReplyEl.children).toHaveLength(1);
|
|
expect(widgetRef.agentReplyEl.children[0].textContent).toBe('Second clarification');
|
|
|
|
firstRender.resolve();
|
|
await firstRender.promise;
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(widgetRef.agentReplyEl.children).toHaveLength(1);
|
|
expect(widgetRef.agentReplyEl.children[0].textContent).toBe('Second clarification');
|
|
|
|
widgetRef.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
getEditorViewSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('renders accept and reject controls in the block preview', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
getFiles: jest.fn().mockReturnValue([]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
codex: [],
|
|
},
|
|
mediaFolder: '',
|
|
},
|
|
getSdkCommands: jest.fn().mockReturnValue([]),
|
|
} as any;
|
|
plugin.providerHost = plugin;
|
|
const editor = {} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function') {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0, number: 1 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'cursor',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'math/note.md');
|
|
const resultPromise = modal.openAndWait();
|
|
|
|
const rejectSpy = jest.spyOn(widgetRef, 'reject').mockImplementation(() => {});
|
|
const acceptSpy = jest.spyOn(widgetRef, 'accept').mockImplementation(() => {});
|
|
|
|
const previewEl = widgetRef.createDiffPreviewDOM([
|
|
{ type: 'insert', text: 'Updated text' },
|
|
]);
|
|
const actionBar = previewEl.querySelector('.claudian-inline-preview-actions');
|
|
const actionButtons = previewEl.querySelectorAll('.claudian-inline-preview-action');
|
|
|
|
expect(actionBar).not.toBeNull();
|
|
expect(actionButtons).toHaveLength(2);
|
|
expect(actionButtons[0].textContent).toBe('Reject');
|
|
expect(actionButtons[1].textContent).toBe('Accept');
|
|
|
|
actionButtons[0].click();
|
|
actionButtons[1].click();
|
|
|
|
expect(rejectSpy).toHaveBeenCalledTimes(1);
|
|
expect(acceptSpy).toHaveBeenCalledTimes(1);
|
|
|
|
rejectSpy.mockRestore();
|
|
acceptSpy.mockRestore();
|
|
widgetRef.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
getEditorViewSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it('renders markdown diff documents with block context', async () => {
|
|
const originalDocument = (global as any).document;
|
|
(global as any).document = {
|
|
body: createMockEl('body'),
|
|
createElement: (tagName: string) => createMockEl(tagName),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
try {
|
|
const app = {
|
|
vault: {
|
|
getFiles: jest.fn().mockReturnValue([]),
|
|
getAllLoadedFiles: jest.fn().mockReturnValue([]),
|
|
},
|
|
workspace: {
|
|
getActiveViewOfType: jest.fn(),
|
|
},
|
|
} as any;
|
|
const plugin = {
|
|
settings: {
|
|
hiddenProviderCommands: {
|
|
claude: [],
|
|
codex: [],
|
|
},
|
|
mediaFolder: '',
|
|
},
|
|
getSdkCommands: jest.fn().mockReturnValue([]),
|
|
} as any;
|
|
const oldMarkdown = '```ts\nconst value = 1;\n```';
|
|
const newMarkdown = '```ts\nconst value = 2;\n```';
|
|
plugin.providerHost = plugin;
|
|
const editor = {
|
|
getCursor: jest.fn((which: string) => which === 'from'
|
|
? { line: 0, ch: 0 }
|
|
: { line: 0, ch: 3 }),
|
|
getSelection: jest.fn().mockReturnValue(oldMarkdown),
|
|
} as any;
|
|
const view = { editor } as any;
|
|
|
|
let widgetRef: any = null;
|
|
let diffOps: Array<{ type: string; text: string }> | undefined;
|
|
let hasPreviewText = false;
|
|
const dispatch = jest.fn((transaction: any) => {
|
|
const effects = Array.isArray(transaction?.effects)
|
|
? transaction.effects
|
|
: transaction?.effects
|
|
? [transaction.effects]
|
|
: [];
|
|
for (const effect of effects) {
|
|
if (effect?.value?.diffOps) {
|
|
diffOps = effect.value.diffOps;
|
|
}
|
|
if (effect?.value?.previewText) {
|
|
hasPreviewText = true;
|
|
}
|
|
|
|
const widget = effect?.value?.widget;
|
|
if (widget && typeof widget.createInputDOM === 'function' && !widgetRef) {
|
|
widgetRef = widget;
|
|
widget.createInputDOM();
|
|
}
|
|
}
|
|
});
|
|
const editorView = {
|
|
state: {
|
|
doc: {
|
|
line: jest.fn(() => ({ from: 0 })),
|
|
lineAt: jest.fn(() => ({ from: 0, number: 1 })),
|
|
},
|
|
},
|
|
dispatch,
|
|
dom: {
|
|
ownerDocument: (global as any).document,
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
},
|
|
} as any;
|
|
|
|
const getEditorViewSpy = jest
|
|
.spyOn(editorUtils, 'getEditorView')
|
|
.mockReturnValue(editorView);
|
|
|
|
const editContext: InlineEditContext = {
|
|
mode: 'selection',
|
|
selectedText: oldMarkdown,
|
|
};
|
|
|
|
const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'math/note.md');
|
|
const resultPromise = modal.openAndWait();
|
|
widgetRef.inlineEditService = {
|
|
editText: jest.fn().mockResolvedValue({
|
|
success: true,
|
|
editedText: newMarkdown,
|
|
}),
|
|
continueConversation: jest.fn(),
|
|
cancel: jest.fn(),
|
|
resetConversation: jest.fn(),
|
|
};
|
|
|
|
widgetRef.inputEl.value = 'Improve the statement';
|
|
await widgetRef.generate();
|
|
|
|
expect(diffOps).toEqual([
|
|
{ type: 'equal', text: '```ts\n' },
|
|
{ type: 'delete', text: 'const value = 1;\n' },
|
|
{ type: 'insert', text: 'const value = 2;\n' },
|
|
{ type: 'equal', text: '```' },
|
|
]);
|
|
expect(hasPreviewText).toBe(false);
|
|
|
|
(MarkdownRenderer.renderMarkdown as jest.Mock).mockClear();
|
|
const previewEl = widgetRef.createDiffPreviewDOM(diffOps);
|
|
for (let i = 0; i < 5 && (MarkdownRenderer.renderMarkdown as jest.Mock).mock.calls.length < 2; i++) {
|
|
await Promise.resolve();
|
|
}
|
|
|
|
expect(MarkdownRenderer.renderMarkdown).toHaveBeenNthCalledWith(
|
|
1,
|
|
oldMarkdown,
|
|
expect.anything(),
|
|
'math/note.md',
|
|
plugin
|
|
);
|
|
expect(MarkdownRenderer.renderMarkdown).toHaveBeenNthCalledWith(
|
|
2,
|
|
newMarkdown,
|
|
expect.anything(),
|
|
'math/note.md',
|
|
plugin
|
|
);
|
|
|
|
const diffBlocks = previewEl.querySelectorAll('.claudian-diff-block');
|
|
expect(diffBlocks).toHaveLength(2);
|
|
expect(diffBlocks[0].hasClass('claudian-diff-del')).toBe(true);
|
|
expect(diffBlocks[1].hasClass('claudian-diff-ins')).toBe(true);
|
|
|
|
widgetRef.reject();
|
|
await expect(resultPromise).resolves.toEqual({ decision: 'reject' });
|
|
getEditorViewSpy.mockRestore();
|
|
} finally {
|
|
(global as any).document = originalDocument;
|
|
}
|
|
});
|
|
});
|