Feat/language detection (#31)
* feat: implementation of language detection * run utility scripts in tasks * fix: addiitonal fixes for background utilities * feat: toast() also for utility scripts --------- Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
@@ -79,6 +79,9 @@ describe('ScriptsView', () => {
|
||||
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||
}),
|
||||
getAll: vi.fn(),
|
||||
startTask: vi.fn().mockResolvedValue(undefined),
|
||||
completeTask: vi.fn().mockResolvedValue(undefined),
|
||||
failTask: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -246,17 +249,18 @@ describe('ScriptsView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(executeMock).toHaveBeenCalledWith('print("hello")', {
|
||||
expect(executeMock).toHaveBeenCalledWith('print("hello")', expect.objectContaining({
|
||||
cacheKey: expect.stringMatching(/^script-1:1:/),
|
||||
entrypoint: 'render',
|
||||
});
|
||||
timeoutMs: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const state = useAppStore.getState();
|
||||
expect(state.panelVisible).toBe(false);
|
||||
expect(state.panelActiveTab).toBe('tasks');
|
||||
expect(state.panelOutputEntries.length).toBeGreaterThan(0);
|
||||
expect(state.panelOutputEntries[state.panelOutputEntries.length - 1].message).toContain('hello');
|
||||
expect(state.panelOutputEntries.some(e => e.message.includes('2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('checks syntax manually and writes editor markers for syntax errors', async () => {
|
||||
@@ -360,4 +364,77 @@ describe('ScriptsView', () => {
|
||||
expect(useAppStore.getState().tabs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('runs utility script without timeout and creates a task', async () => {
|
||||
const startTaskMock = vi.fn().mockResolvedValue(undefined);
|
||||
const completeTaskMock = vi.fn().mockResolvedValue(undefined);
|
||||
(window as any).electronAPI.scripts.startTask = startTaskMock;
|
||||
(window as any).electronAPI.scripts.completeTask = completeTaskMock;
|
||||
(window as any).electronAPI.scripts.failTask = vi.fn();
|
||||
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
await screen.findByLabelText('Script Content');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(executeMock).toHaveBeenCalledWith('print("hello")', expect.objectContaining({
|
||||
timeoutMs: 0,
|
||||
}));
|
||||
expect(startTaskMock).toHaveBeenCalledWith(expect.stringContaining('script-'), 'Hello Script');
|
||||
expect(completeTaskMock).toHaveBeenCalledWith(expect.stringContaining('script-'));
|
||||
});
|
||||
});
|
||||
|
||||
it('reports failure to task manager when utility script errors', async () => {
|
||||
executeMock.mockRejectedValueOnce(new Error('Script crashed'));
|
||||
const startTaskMock = vi.fn().mockResolvedValue(undefined);
|
||||
const failTaskMock = vi.fn().mockResolvedValue(undefined);
|
||||
(window as any).electronAPI.scripts.startTask = startTaskMock;
|
||||
(window as any).electronAPI.scripts.completeTask = vi.fn();
|
||||
(window as any).electronAPI.scripts.failTask = failTaskMock;
|
||||
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
await screen.findByLabelText('Script Content');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(failTaskMock).toHaveBeenCalledWith(expect.stringContaining('script-'), 'Script crashed');
|
||||
});
|
||||
});
|
||||
|
||||
it('runs macro/transform scripts without timeout but no task', async () => {
|
||||
(window as any).electronAPI.scripts.get = vi.fn().mockResolvedValue({
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
slug: 'hello-script',
|
||||
title: 'Hello Script',
|
||||
kind: 'macro',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 1,
|
||||
filePath: '/tmp/hello-script.py',
|
||||
content: 'print("hello")',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const startTaskMock = vi.fn();
|
||||
(window as any).electronAPI.scripts.startTask = startTaskMock;
|
||||
(window as any).electronAPI.scripts.completeTask = vi.fn();
|
||||
(window as any).electronAPI.scripts.failTask = vi.fn();
|
||||
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
await screen.findByLabelText('Script Content');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(executeMock).toHaveBeenCalledWith('print("hello")', expect.objectContaining({
|
||||
timeoutMs: 0,
|
||||
}));
|
||||
expect(startTaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -502,4 +502,115 @@ describe('PythonRuntimeManager', () => {
|
||||
worker.emitMessage({ type: 'runResult', requestId: runRequest.requestId, result: 'done' });
|
||||
await expect(runPromise).resolves.toEqual({ result: 'done', stdout: '' });
|
||||
});
|
||||
|
||||
it('does not time out when timeoutMs is 0', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.execute('long_running()', { timeoutMs: 0 });
|
||||
await Promise.resolve();
|
||||
|
||||
// Advance time well past any default timeout — script must still be pending
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(worker.terminated).toBe(false);
|
||||
|
||||
const request = worker.postedMessages[0] as { requestId: string };
|
||||
worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: 'done' });
|
||||
|
||||
await expect(runPromise).resolves.toEqual({ result: 'done', stdout: '' });
|
||||
});
|
||||
|
||||
it('queued inspectEntrypoints with timeoutMs 0 does not kill running execute', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
// Start a long-running execute with no timeout
|
||||
const runPromise = manager.execute('long_running()', { timeoutMs: 0 });
|
||||
await Promise.resolve();
|
||||
|
||||
// Queue inspectEntrypoints (default timeout) while execute is running
|
||||
const inspectPromise = manager.inspectEntrypoints('def render(): pass');
|
||||
await Promise.resolve();
|
||||
|
||||
// Advance past the default 5000ms timeout
|
||||
vi.advanceTimersByTime(6000);
|
||||
|
||||
// Worker must still be alive — the queued inspect must not kill it
|
||||
expect(worker.terminated).toBe(false);
|
||||
|
||||
// Finish the execute
|
||||
const runRequest = worker.postedMessages[0] as { requestId: string };
|
||||
worker.emitMessage({ type: 'runResult', requestId: runRequest.requestId, result: 'done' });
|
||||
await expect(runPromise).resolves.toEqual({ result: 'done', stdout: '' });
|
||||
|
||||
// Now the inspect request dispatches — respond to it
|
||||
await Promise.resolve();
|
||||
const inspectRequest = worker.postedMessages[1] as { requestId: string };
|
||||
worker.emitMessage({ type: 'entrypoints', requestId: inspectRequest.requestId, entrypoints: ['render'] });
|
||||
await expect(inspectPromise).resolves.toEqual(['render']);
|
||||
});
|
||||
|
||||
it('calls onStdout callback for each stdout chunk during execution', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const stdoutChunks: string[] = [];
|
||||
const runPromise = manager.execute('print("a")\nprint("b")', {
|
||||
onStdout: (chunk) => { stdoutChunks.push(chunk); },
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
const request = worker.postedMessages[0] as { requestId: string };
|
||||
worker.emitMessage({ type: 'stdout', requestId: request.requestId, chunk: 'a\n' });
|
||||
worker.emitMessage({ type: 'stdout', requestId: request.requestId, chunk: 'b\n' });
|
||||
worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: '' });
|
||||
|
||||
const result = await runPromise;
|
||||
expect(stdoutChunks).toEqual(['a\n', 'b\n']);
|
||||
expect(result.stdout).toBe('a\nb\n');
|
||||
});
|
||||
|
||||
it('calls onToast handler when worker sends a toast message', async () => {
|
||||
const worker = new MockWorker();
|
||||
const toasts: Array<{ message: string; toastType?: string }> = [];
|
||||
const manager = new PythonRuntimeManager(
|
||||
() => worker as unknown as Worker,
|
||||
{
|
||||
onToast: (message, toastType) => { toasts.push({ message, toastType }); },
|
||||
}
|
||||
);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.execute('toast("hello")');
|
||||
await Promise.resolve();
|
||||
|
||||
const request = worker.postedMessages[0] as { requestId: string };
|
||||
worker.emitMessage({ type: 'toast', message: 'hello', toastType: 'success' });
|
||||
worker.emitMessage({ type: 'toast', message: 'oops', toastType: 'error' });
|
||||
worker.emitMessage({ type: 'toast', message: 'note' });
|
||||
|
||||
expect(toasts).toEqual([
|
||||
{ message: 'hello', toastType: 'success' },
|
||||
{ message: 'oops', toastType: 'error' },
|
||||
{ message: 'note', toastType: undefined },
|
||||
]);
|
||||
|
||||
worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: '' });
|
||||
await expect(runPromise).resolves.toEqual({ result: '', stdout: '' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,8 +37,9 @@ describe('generateApiDocumentationMarkdownV1', () => {
|
||||
expect(markdown).toContain('## publish');
|
||||
expect(markdown).toContain('### publish.uploadSite');
|
||||
expect(markdown).toContain('- [publish](#publish)');
|
||||
// chat namespace should not be present
|
||||
expect(markdown).not.toContain('## chat');
|
||||
// chat namespace now contains detectPostLanguage
|
||||
expect(markdown).toContain('## chat');
|
||||
expect(markdown).toContain('### chat.detectPostLanguage');
|
||||
});
|
||||
|
||||
it('includes a dedicated Data Structures section with core object shapes', () => {
|
||||
|
||||
@@ -59,15 +59,15 @@ describe('pythonApiContractV1', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not include chat namespace (removed in v1.7.0)', () => {
|
||||
it('only exposes detectPostLanguage from chat namespace', () => {
|
||||
const methodNames = listPythonApiMethodNames();
|
||||
const chatMethods = methodNames.filter((m) => m.startsWith('chat.'));
|
||||
expect(chatMethods).toHaveLength(0);
|
||||
expect(chatMethods).toEqual(['chat.detectPostLanguage']);
|
||||
});
|
||||
|
||||
it('contains semantic version metadata for compatibility checks', () => {
|
||||
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
||||
version: '1.9.0',
|
||||
version: '1.10.0',
|
||||
generatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
@@ -100,7 +100,8 @@ describe('generatePythonApiModuleV1', () => {
|
||||
expect(moduleCode).toContain('async def upload_site(self, credentials):');
|
||||
expect(moduleCode).toContain('class BdsApi:');
|
||||
expect(moduleCode).toContain('bds = BdsApi(_transport)');
|
||||
expect(moduleCode).not.toContain('class ChatApi:');
|
||||
expect(moduleCode).toContain('class ChatApi:');
|
||||
expect(moduleCode).toContain('async def detect_post_language(self, title, content):');
|
||||
});
|
||||
|
||||
it('escapes python keyword method names to valid identifiers', () => {
|
||||
|
||||
Reference in New Issue
Block a user