Bug: clicking the floating button after the first time caused the panel content to multiply — every hide+show cycle appended a new prompt while the previous one stayed in the DOM. Root cause: the prompt's Lumino parent is null (it's attached to a raw HTMLDivElement, not a Widget, so neither appendChild nor Widget.attach(widget, HTMLElement) sets a Lumino parent). The defensive DOM cleanup in _hidePrompt now removes the prompt's node explicitly before nulling the reference, so no residual nodes linger across show+hide cycles. Also use Widget.attach in _showPrompt for the before-attach / after-attach lifecycle messages the prompt may rely on. Added a regression test that toggles 5 times and asserts the panel contains exactly one .opencode-inline-prompt node. Files: - src/components/opencode_floating_panel.ts — defensive cleanup + attach - src/__tests__/opencode_floating_panel.spec.ts — regression test
1548 lines
52 KiB
TypeScript
1548 lines
52 KiB
TypeScript
/**
|
|
* Unit tests for OpenCodeFloatingPanel (global AI button + panel) and OpenCodeInlinePrompt
|
|
* (inline prompt inside the floating panel). v4: no settings — the model
|
|
* picker is fully dynamic; default selection is the first provider/model.
|
|
*
|
|
* Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the
|
|
* test boundary so the real ESM packages are not loaded.
|
|
*/
|
|
jest.mock('@jupyterlab/application', () => {
|
|
class JupyterFrontEnd {
|
|
public shell: any = {
|
|
currentWidget: null,
|
|
currentChanged: {
|
|
connect: jest.fn(),
|
|
disconnect: jest.fn()
|
|
}
|
|
};
|
|
}
|
|
return { JupyterFrontEnd };
|
|
});
|
|
|
|
jest.mock('@jupyterlab/cells', () => ({
|
|
CodeCell: class CodeCell {}
|
|
}));
|
|
|
|
jest.mock('@jupyterlab/apputils', () => ({
|
|
Notification: {
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
success: jest.fn(),
|
|
warning: jest.fn()
|
|
}
|
|
}));
|
|
|
|
jest.mock('@lumino/widgets', () => {
|
|
class Widget {
|
|
public node: HTMLElement = document.createElement('div');
|
|
public id = '';
|
|
public parent: Widget | null = null;
|
|
private _isDisposed = false;
|
|
public get isDisposed(): boolean {
|
|
return this._isDisposed;
|
|
}
|
|
public addClass(cls: string): void {
|
|
this.node.classList.add(cls);
|
|
}
|
|
public dispose(): void {
|
|
this._isDisposed = true;
|
|
if (this.node.parentNode) {
|
|
this.node.parentNode.removeChild(this.node);
|
|
}
|
|
}
|
|
public static attach(widget: Widget, host: HTMLElement): void {
|
|
host.appendChild(widget.node);
|
|
}
|
|
public onAfterAttach(_msg: any): void {
|
|
// no-op
|
|
}
|
|
}
|
|
return { Widget };
|
|
});
|
|
|
|
jest.mock('marked', () => ({
|
|
__esModule: true,
|
|
marked: {
|
|
// Minimal but realistic mock: produce a real <pre><code> for
|
|
// fenced code blocks (so the per-code-block toolbar
|
|
// post-processing can find them) and <h1>/<p> for headings/text.
|
|
// Not a full markdown renderer — just enough for the prompt tests.
|
|
parse: (md: string) => {
|
|
let html = '';
|
|
let inFence = false;
|
|
let fenceLang = '';
|
|
let fenceBuf: string[] = [];
|
|
const lines = md.split('\n');
|
|
for (const line of lines) {
|
|
const open = !inFence && /^```(\w*)\s*$/.exec(line);
|
|
const close = inFence && /^```\s*$/.test(line);
|
|
if (open) {
|
|
inFence = true;
|
|
fenceLang = open[1] || '';
|
|
fenceBuf = [];
|
|
continue;
|
|
}
|
|
if (close) {
|
|
html +=
|
|
'<pre><code class="language-' +
|
|
fenceLang +
|
|
'">' +
|
|
fenceBuf.join('\n') +
|
|
'</code></pre>';
|
|
inFence = false;
|
|
continue;
|
|
}
|
|
if (inFence) {
|
|
fenceBuf.push(line);
|
|
continue;
|
|
}
|
|
if (/^# /.test(line)) {
|
|
html += '<h1>' + line.slice(2) + '</h1>';
|
|
} else if (line.trim()) {
|
|
html += '<p>' + line + '</p>';
|
|
}
|
|
}
|
|
return html || '<p>' + md + '</p>';
|
|
}
|
|
}
|
|
}));
|
|
|
|
// jsdom doesn't implement navigator.clipboard; stub it so the copy
|
|
// button's happy path can be exercised in tests.
|
|
beforeAll(() => {
|
|
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
|
value: { writeText: jest.fn().mockResolvedValue(undefined) },
|
|
configurable: true
|
|
});
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Each test starts with a clean selection store so persistence tests
|
|
// are deterministic.
|
|
localStorage.clear();
|
|
});
|
|
|
|
// Mock the network-touching API module so jsdom tests don't try to hit
|
|
// a real notebook server (and to keep the refresh-history logic decoupled
|
|
// from the network). The cell_actions tests focus on widget behavior.
|
|
jest.mock('../api/opencode_client', () => ({
|
|
callOpenCodeEdit: jest.fn(),
|
|
callOpenCodeProviders: jest.fn(),
|
|
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] }),
|
|
callOpenCodeReplyPermission: jest.fn().mockResolvedValue({ ok: true }),
|
|
callOpenCodeReplyQuestion: jest.fn().mockResolvedValue({ ok: true }),
|
|
callOpenCodeListAllSessions: jest.fn().mockResolvedValue({ sessions: [] }),
|
|
callOpenCodeCreateNotebookSession: jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
notebookPath: '',
|
|
sessionId: 'newly-created',
|
|
activeSessionId: 'newly-created',
|
|
}),
|
|
callOpenCodeSetActiveSession: jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
notebookPath: '',
|
|
activeSessionId: '',
|
|
}),
|
|
callOpenCodeBindExistingSession: jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
notebookPath: '',
|
|
sessionId: 'sess-B',
|
|
}),
|
|
subscribeOpenCodeEvents: jest.fn().mockReturnValue({ close: jest.fn() })
|
|
}));
|
|
|
|
import {
|
|
OpenCodeFloatingPanel
|
|
} from '../components/opencode_floating_panel';
|
|
import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
|
|
import { CodeCell } from '@jupyterlab/cells';
|
|
import {
|
|
callOpenCodeEdit,
|
|
callOpenCodeListAllSessions,
|
|
callOpenCodeCreateNotebookSession,
|
|
callOpenCodeSetActiveSession,
|
|
callOpenCodeBindExistingSession,
|
|
callOpenCodeSessionMessages,
|
|
subscribeOpenCodeEvents
|
|
} from '../api/opencode_client';
|
|
import { Notification } from '@jupyterlab/apputils';
|
|
import { Widget } from '@lumino/widgets';
|
|
const mockedCallOpenCodeEdit = callOpenCodeEdit as jest.MockedFunction<
|
|
typeof callOpenCodeEdit
|
|
>;
|
|
const mockedSubscribeOpenCodeEvents =
|
|
subscribeOpenCodeEvents as jest.MockedFunction<
|
|
typeof subscribeOpenCodeEvents
|
|
>;
|
|
const mockedListAllSessions = callOpenCodeListAllSessions as jest.MockedFunction<
|
|
typeof callOpenCodeListAllSessions
|
|
>;
|
|
const mockedCreateSession = callOpenCodeCreateNotebookSession as jest.MockedFunction<
|
|
typeof callOpenCodeCreateNotebookSession
|
|
>;
|
|
const mockedSetActiveSession = callOpenCodeSetActiveSession as jest.MockedFunction<
|
|
typeof callOpenCodeSetActiveSession
|
|
>;
|
|
const mockedBindExistingSession = callOpenCodeBindExistingSession as jest.MockedFunction<
|
|
typeof callOpenCodeBindExistingSession
|
|
>;
|
|
const mockedSessionMessages = callOpenCodeSessionMessages as jest.MockedFunction<
|
|
typeof callOpenCodeSessionMessages
|
|
>;
|
|
const mockedNotification = Notification as jest.Mocked<typeof Notification>;
|
|
|
|
function makeFakeApp(notebookPath: string | null, activeCell: any | null): any {
|
|
const currentChangedSlot: any[] = [];
|
|
return {
|
|
shell: {
|
|
currentWidget: notebookPath
|
|
? {
|
|
context: { path: notebookPath },
|
|
content: { activeCell }
|
|
}
|
|
: null,
|
|
currentChanged: {
|
|
connect: (_slot: any) => {
|
|
currentChangedSlot.push(_slot);
|
|
},
|
|
disconnect: jest.fn()
|
|
},
|
|
_fireCurrentChanged() {
|
|
for (const s of currentChangedSlot) {
|
|
s();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function makeFakeOutputs(items: any[]): any {
|
|
return {
|
|
get length() {
|
|
return items.length;
|
|
},
|
|
get(i: number) {
|
|
return items[i];
|
|
}
|
|
};
|
|
}
|
|
|
|
function makeFakeCell(
|
|
source: string,
|
|
errorOutputs: any[] = [],
|
|
notebookPath = 'foo.ipynb',
|
|
cellIndex = 0,
|
|
totalCells = 1
|
|
): CodeCell {
|
|
const model: any = {
|
|
id: 'cell-test',
|
|
type: 'code',
|
|
sharedModel: {
|
|
getSource: () => source,
|
|
setSource: jest.fn()
|
|
},
|
|
outputs: makeFakeOutputs(errorOutputs),
|
|
contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
|
|
stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
|
|
};
|
|
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
|
(cell as any).model = model;
|
|
(cell as any).node = document.createElement('div');
|
|
// Minimal editor stub: cursor always at (line 0, column 0) so the
|
|
// "insert at cursor" tests produce a deterministic offset.
|
|
(cell as any).editor = {
|
|
getCursorPosition: () => ({ line: 0, column: 0 })
|
|
};
|
|
|
|
const notebook: any = { widgets: [] as CodeCell[] };
|
|
const panel: any = {
|
|
context: { path: notebookPath },
|
|
content: notebook
|
|
};
|
|
for (let i = 0; i < cellIndex; i++) {
|
|
notebook.widgets.push({
|
|
model: { sharedModel: { getSource: () => '' } }
|
|
} as any);
|
|
}
|
|
notebook.widgets.push(cell);
|
|
for (let i = cellIndex + 1; i < totalCells; i++) {
|
|
notebook.widgets.push({
|
|
model: { sharedModel: { getSource: () => '' } }
|
|
} as any);
|
|
}
|
|
|
|
Object.defineProperty(cell, 'parent', {
|
|
value: notebook,
|
|
configurable: true
|
|
});
|
|
Object.defineProperty(notebook, 'parent', {
|
|
value: panel,
|
|
configurable: true
|
|
});
|
|
|
|
return cell as CodeCell;
|
|
}
|
|
|
|
describe('OpenCodeFloatingPanel', () => {
|
|
it('allows server settings to be injected', () => {
|
|
|
|
});
|
|
|
|
it('constructs with a single round floating button and no visible panel', () => {
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
expect(panel.node.querySelector('.opencode-floating-button')).not.toBeNull();
|
|
const panelEl = panel.node.querySelector('.opencode-floating-panel') as HTMLElement;
|
|
expect(panelEl.hidden).toBe(true);
|
|
});
|
|
|
|
it('clicking the button shows the panel and attaches the prompt', () => {
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
const panelEl = panel.node.querySelector('.opencode-floating-panel') as HTMLElement;
|
|
expect(panelEl.hidden).toBe(false);
|
|
expect(panelEl.querySelector('.opencode-inline-prompt')).not.toBeNull();
|
|
expect((panel as any)._prompt).not.toBeNull();
|
|
});
|
|
|
|
it('clicking the button again hides the panel and disposes the prompt', () => {
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
panel.toggle();
|
|
const panelEl = panel.node.querySelector('.opencode-floating-panel') as HTMLElement;
|
|
expect(panelEl.hidden).toBe(true);
|
|
expect((panel as any)._prompt).toBeNull();
|
|
});
|
|
|
|
it('hide+show cycles do not accumulate prompt nodes in the panel (regression)', () => {
|
|
// Bug: using _panelEl.appendChild(prompt.node) to attach the
|
|
// prompt left the prompt's Lumino parent unset, so prompt.dispose()
|
|
// in _hidePrompt did not remove the DOM node. Each hide+show
|
|
// cycle would append a new prompt while the old one stayed in the
|
|
// DOM — the user saw the panel content multiply on every click.
|
|
// Fix: use Widget.attach so Lumino tracks the parent and dispose
|
|
// can clean up.
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
for (let i = 0; i < 5; i++) {
|
|
panel.toggle(); // show
|
|
panel.toggle(); // hide
|
|
}
|
|
panel.toggle(); // show one more time to inspect
|
|
const panelEl = panel.node.querySelector('.opencode-floating-panel') as HTMLElement;
|
|
// Exactly one prompt in the panel, regardless of how many
|
|
// show/hide cycles happened.
|
|
const prompts = panelEl.querySelectorAll('.opencode-inline-prompt');
|
|
expect(prompts.length).toBe(1);
|
|
});
|
|
|
|
it('with no currentWidget, the prompt renders with the input disabled', () => {
|
|
const app = makeFakeApp(null, null);
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
const prompt = (panel as any)._prompt;
|
|
expect(prompt).not.toBeNull();
|
|
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
|
|
it('with a currentWidget but no active cell, the insert-cell-source button is disabled', () => {
|
|
const app = makeFakeApp('foo.ipynb', null);
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
const prompt = (panel as any)._prompt;
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-btn-insert-cell'
|
|
) as HTMLButtonElement;
|
|
expect(insertBtn.disabled).toBe(true);
|
|
});
|
|
|
|
it('with a currentWidget AND active cell, the insert button is enabled and clicking it appends the source', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const app = makeFakeApp('foo.ipynb', cell);
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
const prompt = (panel as any)._prompt;
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-btn-insert-cell'
|
|
) as HTMLButtonElement;
|
|
expect(insertBtn.disabled).toBe(false);
|
|
insertBtn.click();
|
|
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
|
expect(textarea.value).toContain('x = 1');
|
|
});
|
|
|
|
it('onSubmit: posts to /edit, binds SSE, does NOT modify the cell source, clears input', async () => {
|
|
|
|
mockedCallOpenCodeEdit.mockResolvedValue({
|
|
ok: true,
|
|
sessionId: 'ses-abc',
|
|
notebookPath: 'foo.ipynb'
|
|
});
|
|
const subscribeCalls: any[] = [];
|
|
mockedSubscribeOpenCodeEvents.mockImplementation(
|
|
((sid: string, _s: any, handlers: any) => {
|
|
subscribeCalls.push({ sid, handlers });
|
|
return { close: jest.fn() };
|
|
}) as any
|
|
);
|
|
|
|
const cell = makeFakeCell('x = 1');
|
|
const app = makeFakeApp('foo.ipynb', cell);
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
|
|
const prompt = (panel as any)._prompt;
|
|
const textarea = prompt._textarea as HTMLTextAreaElement;
|
|
textarea.value = 'make it faster';
|
|
(prompt._sendBtn as HTMLButtonElement).click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
|
|
expect(mockedCallOpenCodeEdit).toHaveBeenCalledTimes(1);
|
|
expect(mockedCallOpenCodeEdit.mock.calls[0][0].prompt).toBe('make it faster');
|
|
expect(mockedCallOpenCodeEdit.mock.calls[0][0].context.notebookPath).toBe('foo.ipynb');
|
|
|
|
expect(subscribeCalls).toHaveLength(1);
|
|
expect(subscribeCalls[0].sid).toBe('ses-abc');
|
|
expect(typeof subscribeCalls[0].handlers.onEvent).toBe('function');
|
|
|
|
expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
|
|
expect(textarea.value).toBe('');
|
|
});
|
|
|
|
it('SSE idle event closes the stream and re-enables the input', async () => {
|
|
|
|
mockedCallOpenCodeEdit.mockResolvedValue({
|
|
ok: true,
|
|
sessionId: 'ses-1',
|
|
notebookPath: 'foo.ipynb'
|
|
});
|
|
const fakeSse = { close: jest.fn() };
|
|
let captured: any = null;
|
|
mockedSubscribeOpenCodeEvents.mockImplementation(
|
|
((_sid: string, _s: any, handlers: any) => {
|
|
captured = handlers;
|
|
return fakeSse;
|
|
}) as any
|
|
);
|
|
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
|
|
const prompt = (panel as any)._prompt;
|
|
(prompt._textarea as HTMLTextAreaElement).value = 'go';
|
|
(prompt._sendBtn as HTMLButtonElement).click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
|
|
captured.onEvent({
|
|
type: 'session.idle',
|
|
properties: { sessionID: 'ses-1' }
|
|
});
|
|
expect(fakeSse.close).toHaveBeenCalled();
|
|
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
|
|
it('SSE error is logged but does not break the input', async () => {
|
|
|
|
mockedCallOpenCodeEdit.mockResolvedValue({
|
|
ok: true,
|
|
sessionId: 'ses-1',
|
|
notebookPath: 'foo.ipynb'
|
|
});
|
|
let captured: any = null;
|
|
mockedSubscribeOpenCodeEvents.mockImplementation(
|
|
((_sid: string, _s: any, handlers: any) => {
|
|
captured = handlers;
|
|
return { close: jest.fn() };
|
|
}) as any
|
|
);
|
|
|
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
|
|
const prompt = (panel as any)._prompt;
|
|
(prompt._textarea as HTMLTextAreaElement).value = 'go';
|
|
(prompt._sendBtn as HTMLButtonElement).click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
|
|
captured.onError(new Error('sse timeout'));
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'opencode_bridge: SSE error',
|
|
expect.any(Error)
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('onSubmit failure: re-enables input and shows an error notification', async () => {
|
|
|
|
mockedCallOpenCodeEdit.mockResolvedValue({
|
|
ok: false,
|
|
error: 'opencode down'
|
|
});
|
|
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
|
|
const prompt = (panel as any)._prompt;
|
|
const textarea = prompt._textarea as HTMLTextAreaElement;
|
|
textarea.value = 'go';
|
|
(prompt._sendBtn as HTMLButtonElement).click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
|
|
expect(mockedNotification.error).toHaveBeenCalledWith('OpenCode 错误: opencode down');
|
|
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
|
|
it('shell currentChanged hides and re-shows the panel to refresh context', () => {
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
expect((panel as any)._prompt).not.toBeNull();
|
|
|
|
const oldPrompt = (panel as any)._prompt;
|
|
(app.shell as any)._fireCurrentChanged();
|
|
const newPrompt = (panel as any)._prompt;
|
|
expect(newPrompt).not.toBeNull();
|
|
expect(newPrompt).not.toBe(oldPrompt);
|
|
});
|
|
|
|
it('dispose: closes SSE, disposes prompt, disconnects shell signal', async () => {
|
|
|
|
mockedCallOpenCodeEdit.mockResolvedValue({
|
|
ok: true,
|
|
sessionId: 'ses-1',
|
|
notebookPath: 'foo.ipynb'
|
|
});
|
|
const fakeSse = { close: jest.fn() };
|
|
mockedSubscribeOpenCodeEvents.mockImplementation(
|
|
((_sid: string, _s: any, _handlers: any) => {
|
|
return fakeSse;
|
|
}) as any
|
|
);
|
|
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
|
|
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
|
|
const prompt = (panel as any)._prompt;
|
|
(prompt._textarea as HTMLTextAreaElement).value = 'go';
|
|
(prompt._sendBtn as HTMLButtonElement).click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
|
|
panel.dispose();
|
|
expect(fakeSse.close).toHaveBeenCalled();
|
|
expect(prompt.isDisposed).toBe(true);
|
|
expect(app.shell.currentChanged.disconnect).toHaveBeenCalled();
|
|
});
|
|
|
|
it('renders provider and model selects when providers are supplied', () => {
|
|
const providers = {
|
|
providers: [
|
|
{
|
|
id: 'anthropic',
|
|
name: 'Anthropic',
|
|
models: {
|
|
'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' },
|
|
'claude-haiku-4-5': { id: 'claude-haiku-4-5' }
|
|
}
|
|
}
|
|
],
|
|
default: { anthropic: 'claude-sonnet-4-20250514' }
|
|
};
|
|
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
|
|
const panel = new OpenCodeFloatingPanel({
|
|
app,
|
|
serverSettings: {} as any,
|
|
providers: providers as any
|
|
});
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
|
|
const prompt = (panel as any)._prompt;
|
|
const providerSel = prompt.node.querySelector(
|
|
'.opencode-provider-select'
|
|
) as HTMLSelectElement;
|
|
const modelSel = prompt.node.querySelector(
|
|
'.opencode-model-select'
|
|
) as HTMLSelectElement;
|
|
expect(providerSel).not.toBeNull();
|
|
expect(modelSel).not.toBeNull();
|
|
expect(providerSel.value).toBe('anthropic');
|
|
expect(modelSel.value).toBe('claude-sonnet-4-20250514');
|
|
|
|
});
|
|
|
|
it('renders the session selector when onListSessions is provided', async () => {
|
|
mockedListAllSessions.mockResolvedValue({
|
|
ok: true,
|
|
sessions: [
|
|
{ id: 'sess-1', title: 'old chat' },
|
|
{ id: 'sess-2', title: 'current chat' }
|
|
]
|
|
});
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb'));
|
|
const panel = new OpenCodeFloatingPanel({
|
|
app,
|
|
serverSettings: {} as any,
|
|
providers: null
|
|
});
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
const prompt = (panel as any)._prompt;
|
|
|
|
const row = prompt.node.querySelector('.opencode-prompt-sessions');
|
|
expect(row).not.toBeNull();
|
|
const select = prompt.node.querySelector('.opencode-session-select');
|
|
expect(select).not.toBeNull();
|
|
const newBtn = prompt.node.querySelector('.opencode-session-btn-new');
|
|
const refreshBtn = prompt.node.querySelector('.opencode-session-btn-refresh');
|
|
expect(newBtn).not.toBeNull();
|
|
expect(refreshBtn).not.toBeNull();
|
|
});
|
|
|
|
it('initialize() fetches and populates the session list', async () => {
|
|
mockedListAllSessions.mockResolvedValue({
|
|
ok: true,
|
|
sessions: [
|
|
{ id: 'sess-A', title: 'foo' },
|
|
{ id: 'sess-B', title: 'bar' }
|
|
]
|
|
});
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb'));
|
|
const panel = new OpenCodeFloatingPanel({
|
|
app,
|
|
serverSettings: {} as any,
|
|
providers: null
|
|
});
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(mockedListAllSessions).toHaveBeenCalled();
|
|
const prompt = (panel as any)._prompt;
|
|
const select = prompt.node.querySelector(
|
|
'.opencode-session-select'
|
|
) as HTMLSelectElement;
|
|
const labels = Array.from(select.options).map(o => o.textContent);
|
|
expect(labels.length).toBe(3);
|
|
});
|
|
|
|
it('clicking + 新建 creates a new session, binds, and reloads history', async () => {
|
|
mockedListAllSessions.mockResolvedValue({ ok: true, sessions: [] });
|
|
mockedCreateSession.mockResolvedValue({
|
|
ok: true,
|
|
notebookPath: 'foo.ipynb',
|
|
sessionId: 'fresh-sid',
|
|
activeSessionId: 'fresh-sid'
|
|
});
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb'));
|
|
const panel = new OpenCodeFloatingPanel({
|
|
app,
|
|
serverSettings: {} as any,
|
|
providers: null
|
|
});
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
const prompt = (panel as any)._prompt;
|
|
const newBtn = prompt.node.querySelector(
|
|
'.opencode-session-btn-new'
|
|
) as HTMLButtonElement;
|
|
newBtn.click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(mockedCreateSession).toHaveBeenCalled();
|
|
expect((prompt as any)._sessionId).toBe('fresh-sid');
|
|
});
|
|
|
|
it('switching session: bind_existing is called, SSE is closed, history is reloaded', async () => {
|
|
mockedListAllSessions.mockResolvedValue({
|
|
ok: true,
|
|
sessions: [
|
|
{ id: 'sess-A', title: 'first' },
|
|
{ id: 'sess-B', title: 'second' }
|
|
]
|
|
});
|
|
mockedBindExistingSession.mockResolvedValue({
|
|
ok: true,
|
|
notebookPath: 'foo.ipynb',
|
|
sessionId: 'sess-B'
|
|
});
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb'));
|
|
const panel = new OpenCodeFloatingPanel({
|
|
app,
|
|
serverSettings: {} as any,
|
|
providers: null
|
|
});
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
const prompt = (panel as any)._prompt;
|
|
prompt.setSessionId('sess-A');
|
|
mockedSessionMessages.mockClear();
|
|
mockedSessionMessages.mockResolvedValue({
|
|
messages: [{ role: 'user', content: 'from sess-B' }]
|
|
});
|
|
const select = prompt.node.querySelector(
|
|
'.opencode-session-select'
|
|
) as HTMLSelectElement;
|
|
select.value = 'sess-B';
|
|
select.dispatchEvent(new Event('change'));
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(mockedBindExistingSession).toHaveBeenCalled();
|
|
expect(mockedSetActiveSession).not.toHaveBeenCalled();
|
|
expect((prompt as any)._sessionId).toBe('sess-B');
|
|
expect(mockedSessionMessages).toHaveBeenCalledWith(
|
|
'foo.ipynb',
|
|
expect.anything()
|
|
);
|
|
const userMsg = prompt.node.querySelector(
|
|
'.opencode-msg-user'
|
|
) as HTMLElement;
|
|
expect(userMsg.textContent).toBe('from sess-B');
|
|
});
|
|
|
|
it('switching session rolls back the dropdown + shows a Notification.error when bind fails', async () => {
|
|
mockedListAllSessions.mockResolvedValue({
|
|
ok: true,
|
|
sessions: [
|
|
{ id: 'sess-A', title: 'first' },
|
|
{ id: 'sess-B', title: 'second' }
|
|
]
|
|
});
|
|
mockedBindExistingSession.mockResolvedValue({
|
|
ok: false,
|
|
notebookPath: 'foo.ipynb',
|
|
error: 'upstream down'
|
|
});
|
|
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb'));
|
|
const panel = new OpenCodeFloatingPanel({
|
|
app,
|
|
serverSettings: {} as any,
|
|
providers: null
|
|
});
|
|
Widget.attach(panel, document.body);
|
|
panel.toggle();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
const prompt = (panel as any)._prompt;
|
|
prompt.setSessionId('sess-A');
|
|
const select = prompt.node.querySelector(
|
|
'.opencode-session-select'
|
|
) as HTMLSelectElement;
|
|
select.value = 'sess-B';
|
|
select.dispatchEvent(new Event('change'));
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(select.value).toBe('sess-A');
|
|
expect((prompt as any)._sessionId).toBe('sess-A');
|
|
expect(mockedNotification.error).toHaveBeenCalledWith('切换会话失败');
|
|
});
|
|
});
|
|
|
|
describe('OpenCodeInlinePrompt', () => {
|
|
it('renders a textarea, a send, a cancel, and an insert-cell-source button', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
expect(prompt.node.querySelector('textarea')).not.toBeNull();
|
|
// 3 buttons: 发送, 取消, 📋 插入单元格内容
|
|
expect(prompt.node.querySelectorAll('button').length).toBe(3);
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-btn-insert-cell'
|
|
) as HTMLButtonElement;
|
|
expect(insertBtn).not.toBeNull();
|
|
expect(insertBtn.textContent).toContain('插入单元格内容');
|
|
});
|
|
|
|
it('"📋 插入单元格内容" appends the cell source as a markdown code block to the textarea', () => {
|
|
const cell = makeFakeCell('print("hello")\nprint("world")');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-btn-insert-cell'
|
|
) as HTMLButtonElement;
|
|
insertBtn.click();
|
|
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
|
// Inserted as a fenced code block (the fake cell's type is 'code'
|
|
// so the fence info string is whatever the cell reports — we just
|
|
// assert a fence + the source content, not a specific language).
|
|
expect(textarea.value).toMatch(/```\w*\n/);
|
|
expect(textarea.value).toContain('print("hello")');
|
|
expect(textarea.value).toContain('print("world")');
|
|
expect(textarea.value.trim().endsWith('```')).toBe(true);
|
|
});
|
|
|
|
it('"📋 插入单元格内容" appends to existing textarea content (with a leading newline if needed)', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
|
textarea.value = 'Explain:'; // no trailing newline
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-btn-insert-cell'
|
|
) as HTMLButtonElement;
|
|
insertBtn.click();
|
|
// The original prefix is preserved at the start.
|
|
expect(textarea.value.startsWith('Explain:')).toBe(true);
|
|
// The code block was inserted; the user prompt + the code block
|
|
// are now both present.
|
|
expect(textarea.value).toContain('x = 1');
|
|
expect(textarea.value).toMatch(/```\w*[\s\S]*x = 1/);
|
|
// The cursor was moved to the end.
|
|
expect(textarea.selectionStart).toBe(textarea.value.length);
|
|
});
|
|
|
|
it('"📋 插入单元格内容" is a no-op when the cell is empty', () => {
|
|
const cell = makeFakeCell('');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-btn-insert-cell'
|
|
) as HTMLButtonElement;
|
|
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
|
insertBtn.click();
|
|
// Textarea is still empty.
|
|
expect(textarea.value).toBe('');
|
|
});
|
|
|
|
it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
const history = prompt.node.querySelector(
|
|
'.opencode-inline-prompt .opencode-inline-history'
|
|
) as HTMLElement;
|
|
expect(history).not.toBeNull();
|
|
// Initially empty (no messages yet).
|
|
expect(history.children.length).toBe(0);
|
|
|
|
prompt.setMessages([
|
|
{ role: 'user', content: 'fix the bug' },
|
|
{
|
|
role: 'assistant',
|
|
content: '# Here you go\n\n```python\nx = 1\n```'
|
|
}
|
|
]);
|
|
expect(history.children.length).toBe(2);
|
|
const userMsg = history.querySelector('.opencode-msg-user') as HTMLElement;
|
|
const asstMsg = history.querySelector('.opencode-msg-assistant') as HTMLElement;
|
|
expect(userMsg).not.toBeNull();
|
|
// User messages are plain text (not rendered as HTML).
|
|
expect(userMsg.textContent).toBe('fix the bug');
|
|
expect(userMsg.innerHTML).not.toContain('<');
|
|
// Assistant content: the marked mock produces a real <h1> + <pre>.
|
|
expect(asstMsg).not.toBeNull();
|
|
expect(asstMsg.innerHTML).toContain('<h1>Here you go</h1>');
|
|
expect(asstMsg.innerHTML).toContain('<pre>');
|
|
});
|
|
|
|
it('renders Copy/Insert/Replace buttons INSIDE each code block (not above the message)', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
// User message: no toolbar.
|
|
prompt.setMessages([{ role: 'user', content: 'help' }]);
|
|
expect(
|
|
prompt.node.querySelector('.opencode-msg-user .opencode-code-toolbar')
|
|
).toBeNull();
|
|
|
|
// Assistant with a fenced code block: toolbar inside .opencode-code-block.
|
|
prompt.setMessages([
|
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
|
]);
|
|
const asst = prompt.node.querySelector(
|
|
'.opencode-msg-assistant'
|
|
) as HTMLElement;
|
|
// The message-level toolbar must NOT exist.
|
|
expect(asst.querySelector('.opencode-msg-toolbar')).toBeNull();
|
|
// The per-code-block wrapper + toolbar.
|
|
const block = asst.querySelector('.opencode-code-block') as HTMLElement;
|
|
expect(block).not.toBeNull();
|
|
const toolbar = block.querySelector('.opencode-code-toolbar') as HTMLElement;
|
|
expect(toolbar).not.toBeNull();
|
|
const buttons = toolbar.querySelectorAll('button');
|
|
expect(buttons.length).toBe(3);
|
|
expect(buttons[0].textContent).toContain('复制');
|
|
expect(buttons[1].textContent).toContain('插入');
|
|
expect(buttons[2].textContent).toContain('替换');
|
|
// The wrapped <pre> is inside the block, below the toolbar.
|
|
expect(block.querySelector('pre')).not.toBeNull();
|
|
});
|
|
|
|
it('assistant with NO code fences has no toolbar', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setMessages([
|
|
{ role: 'assistant', content: 'no code here, just an explanation' }
|
|
]);
|
|
const asst = prompt.node.querySelector(
|
|
'.opencode-msg-assistant'
|
|
) as HTMLElement;
|
|
expect(asst.querySelector('.opencode-code-toolbar')).toBeNull();
|
|
expect(asst.querySelector('.opencode-code-block')).toBeNull();
|
|
});
|
|
|
|
it('Replace button overwrites the cell source with the code block content', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setMessages([
|
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
|
]);
|
|
const replaceBtn = prompt.node.querySelector(
|
|
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(3)'
|
|
) as HTMLButtonElement;
|
|
replaceBtn.click();
|
|
|
|
const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
|
|
expect(setSource).toHaveBeenCalledTimes(1);
|
|
// The button acts on the code block's text, not the whole markdown
|
|
// (no surrounding ```fences```).
|
|
expect(setSource).toHaveBeenCalledWith('print(1)');
|
|
});
|
|
|
|
it('Insert button splices the code block at the editor cursor (offset 0)', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setMessages([
|
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
|
]);
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(2)'
|
|
) as HTMLButtonElement;
|
|
insertBtn.click();
|
|
|
|
const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
|
|
expect(setSource).toHaveBeenCalledTimes(1);
|
|
// cursor at (0,0) with source "x = 1" -> code "print(1)" inserted at 0
|
|
expect(setSource).toHaveBeenCalledWith('print(1)x = 1');
|
|
});
|
|
|
|
it('Copy button writes the code block content to the clipboard', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setMessages([
|
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
|
]);
|
|
const copyBtn = prompt.node.querySelector(
|
|
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(1)'
|
|
) as HTMLButtonElement;
|
|
copyBtn.click();
|
|
|
|
const writeText = (navigator.clipboard.writeText as unknown as jest.Mock);
|
|
expect(writeText).toHaveBeenCalledWith('print(1)');
|
|
});
|
|
|
|
it('Enter in the textarea submits; Shift+Enter does not', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const onSubmit = jest.fn();
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit,
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setMessages([
|
|
{ role: 'user', content: '' },
|
|
{ role: 'assistant', content: 'noop' }
|
|
]);
|
|
const textarea = prompt.node.querySelector(
|
|
'textarea'
|
|
) as HTMLTextAreaElement;
|
|
textarea.value = 'fix the bug';
|
|
|
|
// Plain Enter submits with the current text + the current
|
|
// provider/model selection (both default to "" / first when null).
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Enter',
|
|
bubbles: true,
|
|
cancelable: true
|
|
})
|
|
);
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith('fix the bug', undefined, undefined);
|
|
|
|
// Shift+Enter must NOT submit (chat convention: newline).
|
|
onSubmit.mockClear();
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Enter',
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
cancelable: true
|
|
})
|
|
);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
|
|
// Ctrl+Enter also must not submit (newline).
|
|
onSubmit.mockClear();
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Enter',
|
|
ctrlKey: true,
|
|
bubbles: true,
|
|
cancelable: true
|
|
})
|
|
);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('applyEvent: text deltas accumulate into a single assistant element (real-time streaming)', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
|
|
// Many deltas — they must all append to the SAME DOM node, not
|
|
// create a new one each time (that would defeat real-time rendering).
|
|
const deltas = ['hel', 'lo', ' ', 'wor', 'ld', '!', ' ', 'It works.'];
|
|
for (const d of deltas) {
|
|
prompt.applyEvent({
|
|
type: 'session.next.text.delta',
|
|
properties: { sessionID: 's1', delta: d }
|
|
});
|
|
}
|
|
const all = prompt.node.querySelectorAll(
|
|
'.opencode-inline-prompt .opencode-msg-assistant'
|
|
);
|
|
// Exactly one element — the streaming text is appended, not forked.
|
|
expect(all.length).toBe(1);
|
|
expect(all[0].textContent).toBe('hello world! It works.');
|
|
});
|
|
|
|
it('applyEvent: unrecognized event types are logged but never throw', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
prompt.setSessionId('s1');
|
|
// An event type the dispatcher doesn't recognize.
|
|
expect(() =>
|
|
prompt.applyEvent({
|
|
type: 'some.future.event',
|
|
properties: { sessionID: 's1', payload: 'whatever' }
|
|
})
|
|
).not.toThrow();
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('unrecognized SSE event type'),
|
|
'some.future.event',
|
|
expect.anything()
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
it('applyEvent: streaming shows raw text in real-time, then renders to HTML on session.idle', () => {
|
|
// Regression: a previous version ran marked.parse on every text
|
|
// delta. That produced glitchy partial-render states (e.g. an
|
|
// opening ```py fence with no closing fence yet is a different
|
|
// DOM shape than the complete code block, so the toolbar
|
|
// post-processing flickered in and out between deltas). The fix
|
|
// is to keep the streaming path dumb (append raw text only) and
|
|
// run the markdown render exactly once at the end of the turn
|
|
// (session.idle). The user sees real-time text grow during the
|
|
// stream, and a clean rendered view the moment the turn ends —
|
|
// matching what setMessages would produce from the stored history.
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
const fullSource =
|
|
'Here is the fix:\n\n```python\nimport pandas as pd\nimport numpy as np\n```\n';
|
|
// Stream every delta of the source.
|
|
for (const d of fullSource.split(/(?=H|```|\n)/)) {
|
|
if (!d) continue;
|
|
prompt.applyEvent({
|
|
type: 'session.next.text.delta',
|
|
properties: { sessionID: 's1', delta: d }
|
|
});
|
|
}
|
|
// During streaming: raw text, NO markdown render yet.
|
|
const asstDuring = prompt.node.querySelector(
|
|
'.opencode-msg-assistant'
|
|
) as HTMLElement;
|
|
expect(asstDuring.textContent).toBe(fullSource);
|
|
// The ```fence``` characters are present as literal text — the
|
|
// user is looking at the source, not the rendered view.
|
|
expect(asstDuring.textContent).toContain('```python');
|
|
// No <pre> element yet (no markdown render was attempted).
|
|
expect(asstDuring.querySelector('pre')).toBeNull();
|
|
// No toolbar yet either.
|
|
expect(asstDuring.querySelector('.opencode-code-toolbar')).toBeNull();
|
|
// Still a single assistant element (no forks per delta).
|
|
expect(
|
|
prompt.node.querySelectorAll(
|
|
'.opencode-msg-assistant'
|
|
).length
|
|
).toBe(1);
|
|
|
|
// Turn end -> session.idle triggers the final markdown render
|
|
// (via _resetStreamPointers, which the prompt invokes before
|
|
// calling onStreamEnd).
|
|
prompt.applyEvent({
|
|
type: 'session.idle',
|
|
properties: { sessionID: 's1' }
|
|
});
|
|
|
|
// After idle: the assistant message is now a fully rendered
|
|
// markdown DOM, identical to what setMessages would produce.
|
|
const pre = prompt.node.querySelector(
|
|
'.opencode-msg-assistant .opencode-msg-content pre'
|
|
) as HTMLElement;
|
|
expect(pre).not.toBeNull();
|
|
const code = pre.querySelector('code') as HTMLElement;
|
|
expect(code.textContent).toBe('import pandas as pd\nimport numpy as np');
|
|
// The Copy / Insert / Replace toolbar is attached to the <pre>
|
|
// block on the final render, same as in setMessages.
|
|
const toolbar = pre.parentElement!.querySelector(
|
|
'.opencode-code-toolbar'
|
|
) as HTMLElement;
|
|
expect(toolbar).not.toBeNull();
|
|
expect(toolbar.querySelectorAll('button').length).toBe(3);
|
|
});
|
|
|
|
it('applyEvent: message.part.delta with field=text routes to the assistant message', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'message.part.delta',
|
|
properties: { sessionID: 's1', field: 'text', delta: 'world' }
|
|
});
|
|
const asst = prompt.node.querySelector(
|
|
'.opencode-inline-prompt .opencode-msg-assistant'
|
|
) as HTMLElement;
|
|
expect(asst.textContent).toBe('world');
|
|
});
|
|
|
|
it('applyEvent: reasoning deltas render into a collapsible green block', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'session.next.reasoning.delta',
|
|
properties: { sessionID: 's1', delta: 'thinking...' }
|
|
});
|
|
const block = prompt.node.querySelector(
|
|
'.opencode-inline-prompt .opencode-msg-block-reasoning'
|
|
) as HTMLElement;
|
|
expect(block).not.toBeNull();
|
|
const content = block.querySelector(
|
|
'.opencode-msg-block-content'
|
|
) as HTMLElement;
|
|
expect(content.textContent).toBe('thinking...');
|
|
});
|
|
|
|
it('applyEvent: tool-call lifecycle creates and updates a tool block', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'session.next.tool.called',
|
|
properties: { sessionID: 's1', name: 'bash' }
|
|
});
|
|
prompt.applyEvent({
|
|
type: 'session.next.tool.input.delta',
|
|
properties: { sessionID: 's1', delta: '{"cmd":' }
|
|
});
|
|
prompt.applyEvent({
|
|
type: 'session.next.tool.success',
|
|
properties: { sessionID: 's1', result: { ok: true } }
|
|
});
|
|
const block = prompt.node.querySelector(
|
|
'.opencode-inline-prompt .opencode-msg-block-tool'
|
|
) as HTMLElement;
|
|
expect(block).not.toBeNull();
|
|
const summary = block.querySelector('summary') as HTMLElement;
|
|
expect(summary.textContent).toContain('bash');
|
|
const content = block.querySelector(
|
|
'.opencode-msg-block-content'
|
|
) as HTMLElement;
|
|
expect(content.textContent).toContain('{"cmd":');
|
|
expect(content.textContent).toContain('"ok": true');
|
|
});
|
|
|
|
it('applyEvent: session.idle resets stream pointers (next text delta starts a fresh assistant element)', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'session.next.text.delta',
|
|
properties: { sessionID: 's1', delta: 'turn1' }
|
|
});
|
|
prompt.applyEvent({
|
|
type: 'session.idle',
|
|
properties: { sessionID: 's1' }
|
|
});
|
|
prompt.applyEvent({
|
|
type: 'session.next.text.delta',
|
|
properties: { sessionID: 's1', delta: 'turn2' }
|
|
});
|
|
const asstBlocks = prompt.node.querySelectorAll(
|
|
'.opencode-inline-prompt .opencode-msg-assistant'
|
|
);
|
|
expect(asstBlocks.length).toBe(2);
|
|
expect(asstBlocks[0].textContent).toBe('turn1');
|
|
expect(asstBlocks[1].textContent).toBe('turn2');
|
|
});
|
|
|
|
it('applyEvent: text deltas pass through regardless of sessionID match (no client-side filter for content events)', () => {
|
|
// Regression: the SSE filter used to drop content events whose
|
|
// sessionID didn't match the prompt's _sessionId. After a session
|
|
// switch, this caused "no reply" because events for the new
|
|
// session came in with a sessionID the prompt wasn't tracking.
|
|
// Fix: only permission/question events are strict (so the user
|
|
// can't reply to a different session's prompt); content events
|
|
// (text, reasoning, tool, idle) always pass through.
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'session.next.text.delta',
|
|
properties: { sessionID: 's2', delta: 'still rendered' }
|
|
});
|
|
const asst = prompt.node.querySelector(
|
|
'.opencode-msg-assistant'
|
|
) as HTMLElement;
|
|
expect(asst).not.toBeNull();
|
|
expect(asst.textContent).toBe('still rendered');
|
|
});
|
|
|
|
it('applyEvent: permission.asked IS still dropped for a different session', () => {
|
|
// The cross-session filter is strict ONLY for permission.asked /
|
|
// question.asked so the user can't accidentally reply to another
|
|
// session's prompt.
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'permission.asked',
|
|
properties: { sessionID: 's2', id: 'perm-X', description: 'rm -rf' }
|
|
});
|
|
const block = prompt.node.querySelector(
|
|
'.opencode-msg-block-interaction'
|
|
);
|
|
expect(block).toBeNull();
|
|
});
|
|
|
|
it('applyEvent: permission.asked creates a permission block with 3 buttons', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'permission.asked',
|
|
properties: { sessionID: 's1', id: 'perm-1', description: 'rm -rf' }
|
|
});
|
|
const block = prompt.node.querySelector(
|
|
'.opencode-inline-prompt .opencode-msg-block-interaction'
|
|
) as HTMLElement;
|
|
expect(block).not.toBeNull();
|
|
expect(block.id).toBe('opencode-perm-perm-1');
|
|
expect(block.textContent).toContain('rm -rf');
|
|
// Three buttons: once / always / reject.
|
|
const buttons = block.querySelectorAll('button');
|
|
expect(buttons.length).toBe(3);
|
|
expect(buttons[0].textContent).toContain('Once');
|
|
expect(buttons[1].textContent).toContain('Always');
|
|
expect(buttons[2].textContent).toContain('Reject');
|
|
});
|
|
|
|
it('applyEvent: clicking a permission button calls onPermissionReply and shows status', async () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const onPermissionReply = jest.fn().mockResolvedValue(true);
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
onPermissionReply,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'permission.asked',
|
|
properties: { sessionID: 's1', id: 'perm-9', description: 'sudo' }
|
|
});
|
|
const rejectBtn = prompt.node.querySelector(
|
|
'.opencode-perm-btn-reject'
|
|
) as HTMLButtonElement;
|
|
rejectBtn.click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(onPermissionReply).toHaveBeenCalledWith('perm-9', 'reject');
|
|
// The button group now shows the success status.
|
|
const block = prompt.node.querySelector(
|
|
'#opencode-perm-perm-9'
|
|
) as HTMLElement;
|
|
expect(block.textContent).toContain('已拒绝');
|
|
});
|
|
|
|
it('applyEvent: question.asked creates a question block with input + submit', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'question.asked',
|
|
properties: { sessionID: 's1', id: 'q-1', question: 'which env?' }
|
|
});
|
|
const block = prompt.node.querySelector(
|
|
'#opencode-q-q-1'
|
|
) as HTMLElement;
|
|
expect(block).not.toBeNull();
|
|
expect(block.textContent).toContain('which env?');
|
|
expect(block.querySelector('input.opencode-q-input')).not.toBeNull();
|
|
expect(block.querySelector('button.opencode-q-submit')).not.toBeNull();
|
|
});
|
|
|
|
it('applyEvent: question submit calls onQuestionReply with the typed answer', async () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const onQuestionReply = jest.fn().mockResolvedValue(true);
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
onQuestionReply,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'question.asked',
|
|
properties: { sessionID: 's1', id: 'q-2', question: 'which version?' }
|
|
});
|
|
const input = prompt.node.querySelector(
|
|
'.opencode-q-input'
|
|
) as HTMLInputElement;
|
|
const submit = prompt.node.querySelector(
|
|
'.opencode-q-submit'
|
|
) as HTMLButtonElement;
|
|
input.value = 'use 3.12';
|
|
submit.click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(onQuestionReply).toHaveBeenCalledWith('q-2', 'use 3.12');
|
|
const block = prompt.node.querySelector('#opencode-q-q-2') as HTMLElement;
|
|
expect(block.textContent).toContain('已回答');
|
|
});
|
|
|
|
it('applyEvent: question submit with empty input is a no-op', async () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const onQuestionReply = jest.fn();
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
onQuestionReply,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'question.asked',
|
|
properties: { sessionID: 's1', id: 'q-3', question: 'color?' }
|
|
});
|
|
const submit = prompt.node.querySelector(
|
|
'.opencode-q-submit'
|
|
) as HTMLButtonElement;
|
|
submit.click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(onQuestionReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('applyEvent: system events (server.* etc.) are NOT rendered', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt({
|
|
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
getActiveCell: () => cell
|
|
});
|
|
prompt.setSessionId('s1');
|
|
const systemTypes = [
|
|
'server.connected',
|
|
'workspace.updated',
|
|
'lsp.updated',
|
|
'mcp.tools.changed',
|
|
'pty.created',
|
|
'session.next.agent.switched',
|
|
'session.next.model.switched',
|
|
'file.edited'
|
|
];
|
|
for (const t of systemTypes) {
|
|
prompt.applyEvent({ type: t, properties: { sessionID: 's1' } });
|
|
}
|
|
// No system lines, no message blocks, nothing rendered.
|
|
expect(
|
|
prompt.node.querySelector('.opencode-msg-system')
|
|
).toBeNull();
|
|
expect(
|
|
prompt.node.querySelectorAll('.opencode-msg').length
|
|
).toBe(0);
|
|
expect(
|
|
prompt.node.querySelectorAll('.opencode-msg-block').length
|
|
).toBe(0);
|
|
});
|
|
});
|