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:
@@ -20,6 +20,7 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
content: overrides.content ?? `# ${title}\n\nBody`,
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
language: overrides.language,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt,
|
||||
@@ -155,4 +156,31 @@ describe('GenerationSitemapFeedService', () => {
|
||||
expect(result.rssXml).toBe('');
|
||||
expect(result.atomXml).toBe('');
|
||||
});
|
||||
|
||||
it('includes per-post language in RSS dc:language and Atom xml:lang', () => {
|
||||
const publishedPosts = [
|
||||
makePost({ id: '1', slug: 'post-en', title: 'English', language: 'en' }),
|
||||
makePost({ id: '2', slug: 'post-de', title: 'German', language: 'de' }),
|
||||
makePost({ id: '3', slug: 'post-no-lang', title: 'Default' }),
|
||||
];
|
||||
|
||||
const result = buildSitemapAndFeeds({
|
||||
baseUrl: 'https://example.com',
|
||||
projectName: 'Test Blog',
|
||||
maxPostsPerPage: 10,
|
||||
publishedPosts,
|
||||
publishedListPosts: publishedPosts,
|
||||
postIndex: buildIndex(publishedPosts),
|
||||
includeFeeds: true,
|
||||
});
|
||||
|
||||
// RSS should have dc:language per item
|
||||
expect(result.rssXml).toContain('xmlns:dc=');
|
||||
expect(result.rssXml).toContain('<dc:language>en</dc:language>');
|
||||
expect(result.rssXml).toContain('<dc:language>de</dc:language>');
|
||||
|
||||
// Atom should have xml:lang on entries with language
|
||||
expect(result.atomXml).toContain('xml:lang="en"');
|
||||
expect(result.atomXml).toContain('xml:lang="de"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -299,6 +299,89 @@ Content here`);
|
||||
expect(result?.differences.categories?.fileValue).toEqual(['cat1']);
|
||||
});
|
||||
|
||||
it('should detect language differences between DB and file', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
language: 'en',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
language: fr
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.language).toBeDefined();
|
||||
expect(result?.differences.language?.dbValue).toBe('en');
|
||||
expect(result?.differences.language?.fileValue).toBe('fr');
|
||||
});
|
||||
|
||||
it('should detect missing language in file when DB has language', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
language: 'de',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.language).toBeDefined();
|
||||
expect(result?.differences.language?.dbValue).toBe('de');
|
||||
expect(result?.differences.language?.fileValue).toBe('');
|
||||
});
|
||||
|
||||
it('should return hasDifferences=false when metadata matches', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
@@ -553,6 +636,47 @@ Content here`);
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sync language field from file to database', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
language: 'en',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: published
|
||||
language: fr
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
await engine.syncFileToDb(['post-1'], 'language');
|
||||
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
// Verify the set call includes language
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
expect(setCall.language).toBe('fr');
|
||||
});
|
||||
|
||||
it('should report progress on first and final items based on cadence', async () => {
|
||||
const postIds = Array.from({ length: 11 }, (_, i) => `post-${i + 1}`);
|
||||
|
||||
|
||||
@@ -163,6 +163,22 @@ describe('PostEngine', () => {
|
||||
|
||||
// Reset the mock implementations
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
|
||||
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
|
||||
values: vi.fn((data: any) => {
|
||||
if (data && data.id) {
|
||||
mockPosts.set(data.id, data);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}) as any);
|
||||
vi.mocked(mockLocalDb.update).mockImplementation(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn(() => Promise.resolve()),
|
||||
})),
|
||||
}) as any);
|
||||
vi.mocked(mockLocalDb.delete).mockImplementation(() => ({
|
||||
where: vi.fn(() => Promise.resolve()),
|
||||
}) as any);
|
||||
|
||||
// Reset fs implementations to use mockFiles map (fixes test leakage from other tests)
|
||||
vi.mocked(fs.readFile).mockImplementation(createDefaultFsReadFile(mockFiles) as any);
|
||||
@@ -783,6 +799,94 @@ Original content`);
|
||||
expect(result?.content).toBe('New draft content');
|
||||
});
|
||||
|
||||
it('should auto-transition published post to draft when language changes', async () => {
|
||||
const created = await postEngine.createPost({ title: 'Language Draft Test' });
|
||||
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockResolvedValue({
|
||||
id: created.id,
|
||||
projectId: created.projectId,
|
||||
title: created.title,
|
||||
slug: created.slug,
|
||||
status: 'published',
|
||||
content: null,
|
||||
filePath: '/mock/published-lang.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
}),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockFiles.set('/mock/published-lang.md', `---
|
||||
id: ${created.id}
|
||||
projectId: default
|
||||
title: ${created.title}
|
||||
slug: ${created.slug}
|
||||
status: published
|
||||
createdAt: ${created.createdAt.toISOString()}
|
||||
updatedAt: ${created.updatedAt.toISOString()}
|
||||
tags: []
|
||||
categories: []
|
||||
---
|
||||
Original content`);
|
||||
|
||||
const result = await postEngine.updatePost(created.id, { language: 'fr' });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.status).toBe('draft');
|
||||
expect(result?.language).toBe('fr');
|
||||
});
|
||||
|
||||
it('should auto-transition published post to draft when author changes', async () => {
|
||||
const created = await postEngine.createPost({ title: 'Author Draft Test' });
|
||||
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockResolvedValue({
|
||||
id: created.id,
|
||||
projectId: created.projectId,
|
||||
title: created.title,
|
||||
slug: created.slug,
|
||||
status: 'published',
|
||||
content: null,
|
||||
filePath: '/mock/published-author.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
}),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockFiles.set('/mock/published-author.md', `---
|
||||
id: ${created.id}
|
||||
projectId: default
|
||||
title: ${created.title}
|
||||
slug: ${created.slug}
|
||||
status: published
|
||||
createdAt: ${created.createdAt.toISOString()}
|
||||
updatedAt: ${created.updatedAt.toISOString()}
|
||||
tags: []
|
||||
categories: []
|
||||
---
|
||||
Original content`);
|
||||
|
||||
const result = await postEngine.updatePost(created.id, { author: 'New Author' });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.status).toBe('draft');
|
||||
expect(result?.author).toBe('New Author');
|
||||
});
|
||||
|
||||
it('should update tags and categories', async () => {
|
||||
const created = await postEngine.createPost({
|
||||
title: 'Tag Update Test',
|
||||
@@ -3301,4 +3405,106 @@ Content with [link](/posts/other-post)`);
|
||||
expect(result.processedFiles).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Post Language', () => {
|
||||
it('should create a post with no language by default', async () => {
|
||||
const post = await postEngine.createPost({ title: 'No Language' });
|
||||
expect(post.language).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create a post with explicit language', async () => {
|
||||
const post = await postEngine.createPost({ title: 'German Post', language: 'de' });
|
||||
expect(post.language).toBe('de');
|
||||
});
|
||||
|
||||
it('should update post language', async () => {
|
||||
const post = await postEngine.createPost({ title: 'Lang Update' });
|
||||
|
||||
// Mock getPost to return the created post
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.get = vi.fn().mockResolvedValue({
|
||||
...mockPosts.get(post.id),
|
||||
tags: JSON.stringify([]),
|
||||
categories: JSON.stringify([]),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
const updated = await postEngine.updatePost(post.id, { language: 'fr' });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.language).toBe('fr');
|
||||
});
|
||||
|
||||
it('should include language in frontmatter when publishing', async () => {
|
||||
const post = await postEngine.createPost({ title: 'Publish Lang', language: 'es' });
|
||||
const postId = post.id;
|
||||
|
||||
// Verify the post was stored in the mock DB
|
||||
const stored = mockPosts.get(postId);
|
||||
expect(stored).toBeDefined();
|
||||
|
||||
// The mock DB stores posts via insert; publishPost calls getPost internally,
|
||||
// which needs DB select to return the post with content (draft).
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.get = vi.fn().mockImplementation(() => {
|
||||
const s = mockPosts.get(postId);
|
||||
if (!s) return Promise.resolve(undefined);
|
||||
return Promise.resolve(s);
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
const result = await postEngine.publishPost(postId);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
// Check that the written file contains language in frontmatter
|
||||
const writtenFiles = Array.from(mockFiles.entries());
|
||||
const postFile = writtenFiles.find(([p]) => p.endsWith('.md'));
|
||||
expect(postFile).toBeDefined();
|
||||
expect(postFile![1]).toContain('language: es');
|
||||
});
|
||||
|
||||
it('should read language from frontmatter in published posts', async () => {
|
||||
const filePath = '/mock/data/posts/2025/01/lang-test.md';
|
||||
mockFiles.set(filePath, [
|
||||
'---',
|
||||
'id: lang-test-post',
|
||||
'title: Language Test',
|
||||
'slug: lang-test',
|
||||
'status: published',
|
||||
'language: it',
|
||||
'createdAt: 2025-01-15T10:00:00.000Z',
|
||||
'updatedAt: 2025-01-15T10:00:00.000Z',
|
||||
'tags: []',
|
||||
'categories: []',
|
||||
'---',
|
||||
'Content here',
|
||||
].join('\n'));
|
||||
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.get = vi.fn().mockResolvedValue({
|
||||
id: 'lang-test-post',
|
||||
projectId: 'default',
|
||||
title: 'Language Test',
|
||||
slug: 'lang-test',
|
||||
content: null,
|
||||
status: 'published',
|
||||
language: 'it',
|
||||
createdAt: new Date('2025-01-15T10:00:00.000Z'),
|
||||
updatedAt: new Date('2025-01-15T10:00:00.000Z'),
|
||||
filePath,
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
const post = await postEngine.getPost('lang-test-post');
|
||||
expect(post).not.toBeNull();
|
||||
expect(post!.language).toBe('it');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,6 +340,120 @@ describe('TaskManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskManager External Tasks', () => {
|
||||
let taskManager: TaskManager;
|
||||
|
||||
beforeEach(() => {
|
||||
taskManager = new TaskManager();
|
||||
resetMockCounters();
|
||||
});
|
||||
|
||||
it('should create an external task in running state', () => {
|
||||
taskManager.startExternalTask('ext-1', 'Language detection');
|
||||
|
||||
const status = taskManager.getTaskStatus('ext-1');
|
||||
expect(status).toBeDefined();
|
||||
expect(status?.status).toBe('running');
|
||||
expect(status?.name).toBe('Language detection');
|
||||
expect(status?.progress).toBe(0);
|
||||
});
|
||||
|
||||
it('should emit taskCreated and taskStarted for external tasks', () => {
|
||||
const createdHandler = vi.fn();
|
||||
const startedHandler = vi.fn();
|
||||
taskManager.on('taskCreated', createdHandler);
|
||||
taskManager.on('taskStarted', startedHandler);
|
||||
|
||||
taskManager.startExternalTask('ext-2', 'Script run');
|
||||
|
||||
expect(createdHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-2', status: 'running' }));
|
||||
expect(startedHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-2', status: 'running' }));
|
||||
});
|
||||
|
||||
it('should update progress on an external task', () => {
|
||||
const progressHandler = vi.fn();
|
||||
taskManager.on('taskProgress', progressHandler);
|
||||
|
||||
taskManager.startExternalTask('ext-3', 'Detect languages');
|
||||
taskManager.updateExternalTaskProgress('ext-3', 50, 'Halfway done');
|
||||
|
||||
const status = taskManager.getTaskStatus('ext-3');
|
||||
expect(status?.progress).toBe(50);
|
||||
expect(status?.message).toBe('Halfway done');
|
||||
expect(progressHandler).toHaveBeenCalledWith(expect.objectContaining({
|
||||
taskId: 'ext-3',
|
||||
progress: 50,
|
||||
message: 'Halfway done',
|
||||
}));
|
||||
});
|
||||
|
||||
it('should complete an external task', () => {
|
||||
const completedHandler = vi.fn();
|
||||
taskManager.on('taskCompleted', completedHandler);
|
||||
|
||||
taskManager.startExternalTask('ext-4', 'Run utility');
|
||||
taskManager.completeExternalTask('ext-4');
|
||||
|
||||
const status = taskManager.getTaskStatus('ext-4');
|
||||
expect(status?.status).toBe('completed');
|
||||
expect(status?.progress).toBe(100);
|
||||
expect(status?.endTime).toBeInstanceOf(Date);
|
||||
expect(completedHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-4', status: 'completed' }));
|
||||
});
|
||||
|
||||
it('should fail an external task', () => {
|
||||
const failedHandler = vi.fn();
|
||||
taskManager.on('taskFailed', failedHandler);
|
||||
|
||||
taskManager.startExternalTask('ext-5', 'Run utility');
|
||||
taskManager.failExternalTask('ext-5', 'Script crashed');
|
||||
|
||||
const status = taskManager.getTaskStatus('ext-5');
|
||||
expect(status?.status).toBe('failed');
|
||||
expect(status?.error).toBe('Script crashed');
|
||||
expect(status?.endTime).toBeInstanceOf(Date);
|
||||
expect(failedHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-5', status: 'failed' }));
|
||||
});
|
||||
|
||||
it('should ignore updates to non-existent external tasks', () => {
|
||||
// These should not throw
|
||||
taskManager.updateExternalTaskProgress('nope', 50, 'test');
|
||||
taskManager.completeExternalTask('nope');
|
||||
taskManager.failExternalTask('nope', 'error');
|
||||
});
|
||||
|
||||
it('should include external tasks in getAllTasks and getRunningTasks', () => {
|
||||
taskManager.startExternalTask('ext-6', 'Running script');
|
||||
|
||||
expect(taskManager.getAllTasks()).toHaveLength(1);
|
||||
expect(taskManager.getRunningTasks()).toHaveLength(1);
|
||||
|
||||
taskManager.completeExternalTask('ext-6');
|
||||
|
||||
expect(taskManager.getAllTasks()).toHaveLength(1);
|
||||
expect(taskManager.getRunningTasks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should allow cancellation of external tasks', () => {
|
||||
taskManager.startExternalTask('ext-7', 'Long script');
|
||||
|
||||
const cancelled = taskManager.cancelTask('ext-7');
|
||||
expect(cancelled).toBe(true);
|
||||
|
||||
const status = taskManager.getTaskStatus('ext-7');
|
||||
expect(status?.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('should be clearable like regular tasks', () => {
|
||||
taskManager.startExternalTask('ext-8', 'Script');
|
||||
taskManager.completeExternalTask('ext-8');
|
||||
|
||||
expect(taskManager.getAllTasks()).toHaveLength(1);
|
||||
taskManager.clearCompletedTasks();
|
||||
expect(taskManager.getAllTasks()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskManager Concurrency', () => {
|
||||
let taskManager: TaskManager;
|
||||
const MAX_CONCURRENT = 3;
|
||||
|
||||
@@ -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