diff --git a/src/renderer/components/ScriptsView/ScriptsView.tsx b/src/renderer/components/ScriptsView/ScriptsView.tsx
index 996be30..68cd6d4 100644
--- a/src/renderer/components/ScriptsView/ScriptsView.tsx
+++ b/src/renderer/components/ScriptsView/ScriptsView.tsx
@@ -363,11 +363,27 @@ export const ScriptsView: React.FC
= ({ scriptId }) => {
setIsRunning(true);
+ const isUtility = kind === 'utility';
+ const taskId = isUtility ? `script-${script.id}-${Date.now()}` : undefined;
+
+ if (isUtility && taskId) {
+ await window.electronAPI?.scripts.startTask(taskId, title || script.title);
+ }
+
try {
const runtimeManager = getPythonRuntimeManager();
const result = await runtimeManager.execute(scriptContent, {
cacheKey: buildCacheKey(script, scriptContent),
entrypoint,
+ timeoutMs: 0,
+ onStdout: (chunk: string) => {
+ appendPanelOutputEntry({
+ id: `output-${Date.now()}-stdout-stream`,
+ message: chunk,
+ createdAt: new Date().toISOString(),
+ kind: 'stdout',
+ });
+ },
});
const now = new Date().toISOString();
@@ -380,21 +396,21 @@ export const ScriptsView: React.FC = ({ scriptId }) => {
});
}
- if (result.stdout.trim().length > 0) {
- appendPanelOutputEntry({
- id: `output-${Date.now()}-stdout`,
- message: result.stdout,
- createdAt: now,
- kind: 'stdout',
- });
+ if (isUtility && taskId) {
+ await window.electronAPI?.scripts.completeTask(taskId);
}
} catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
appendPanelOutputEntry({
id: `output-${Date.now()}-error`,
- message: error instanceof Error ? error.message : String(error),
+ message: errorMessage,
createdAt: new Date().toISOString(),
kind: 'error',
});
+
+ if (isUtility && taskId) {
+ await window.electronAPI?.scripts.failTask(taskId, errorMessage);
+ }
} finally {
setIsRunning(false);
}
diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json
index 8d1b72e..1815cbf 100644
--- a/src/renderer/i18n/locales/de.json
+++ b/src/renderer/i18n/locales/de.json
@@ -517,6 +517,13 @@
"editor.field.content": "Inhalt",
"editor.field.template": "Vorlage",
"editor.field.templateDefault": "Standard",
+ "editor.field.language": "Sprache",
+ "editor.field.languageDefault": "Projektstandard",
+ "language.en": "Englisch",
+ "language.de": "Deutsch",
+ "language.fr": "Französisch",
+ "language.it": "Italienisch",
+ "language.es": "Spanisch",
"editor.placeholder.tags": "Tags hinzufügen...",
"editor.placeholder.author": "Autorenname",
"editor.placeholder.categories": "Kategorien hinzufügen...",
@@ -879,6 +886,10 @@
"editor.media.quickActions.button": "⚡ Schnellaktionen",
"editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen",
"editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor",
+ "editor.post.quickActions.detectLanguageDescription": "Sprache mit KI erkennen",
+ "editor.post.quickActions.detecting": "Erkennung…",
+ "editor.post.quickActions.languageDetected": "Sprache erkannt",
+ "editor.post.quickActions.detectLanguageFailed": "Spracherkennung fehlgeschlagen",
"editor.media.replaceFile": "Datei ersetzen",
"editor.media.field.fileName": "Dateiname",
"editor.media.field.type": "Typ",
diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json
index db01d5a..15d1393 100644
--- a/src/renderer/i18n/locales/en.json
+++ b/src/renderer/i18n/locales/en.json
@@ -517,6 +517,13 @@
"editor.field.content": "Content",
"editor.field.template": "Template",
"editor.field.templateDefault": "Default",
+ "editor.field.language": "Language",
+ "editor.field.languageDefault": "Project default",
+ "language.en": "English",
+ "language.de": "German",
+ "language.fr": "French",
+ "language.it": "Italian",
+ "language.es": "Spanish",
"editor.placeholder.tags": "Add tags...",
"editor.placeholder.author": "Author name",
"editor.placeholder.categories": "Add categories...",
@@ -879,6 +886,10 @@
"editor.media.quickActions.button": "⚡ Quick Actions",
"editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption",
"editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata",
+ "editor.post.quickActions.detectLanguageDescription": "Detect language using AI",
+ "editor.post.quickActions.detecting": "Detecting…",
+ "editor.post.quickActions.languageDetected": "Language detected",
+ "editor.post.quickActions.detectLanguageFailed": "Language detection failed",
"editor.media.replaceFile": "Replace File",
"editor.media.field.fileName": "File Name",
"editor.media.field.type": "Type",
diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json
index cc8e30f..35072a5 100644
--- a/src/renderer/i18n/locales/es.json
+++ b/src/renderer/i18n/locales/es.json
@@ -517,6 +517,13 @@
"editor.field.content": "Contenido",
"editor.field.template": "Plantilla",
"editor.field.templateDefault": "Predeterminada",
+ "editor.field.language": "Idioma",
+ "editor.field.languageDefault": "Predeterminado del proyecto",
+ "language.en": "Inglés",
+ "language.de": "Alemán",
+ "language.fr": "Francés",
+ "language.it": "Italiano",
+ "language.es": "Español",
"editor.placeholder.tags": "Agregar etiquetas...",
"editor.placeholder.author": "Nombre del autor",
"editor.placeholder.categories": "Agregar categorías...",
@@ -879,6 +886,10 @@
"editor.media.quickActions.button": "✨ Analizar con IA",
"editor.media.quickActions.aiTitle": "Título sugerido por IA",
"editor.media.quickActions.aiDescription": "Genera automáticamente título, texto alternativo y pie de foto.",
+ "editor.post.quickActions.detectLanguageDescription": "Detectar idioma con IA",
+ "editor.post.quickActions.detecting": "Detectando…",
+ "editor.post.quickActions.languageDetected": "Idioma detectado",
+ "editor.post.quickActions.detectLanguageFailed": "Error al detectar el idioma",
"editor.media.replaceFile": "Reemplazar archivo",
"editor.media.field.fileName": "Nombre de archivo",
"editor.media.field.type": "Tipo",
diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json
index d17cf67..8493a23 100644
--- a/src/renderer/i18n/locales/fr.json
+++ b/src/renderer/i18n/locales/fr.json
@@ -517,6 +517,13 @@
"editor.field.content": "Contenu",
"editor.field.template": "Modèle",
"editor.field.templateDefault": "Par défaut",
+ "editor.field.language": "Langue",
+ "editor.field.languageDefault": "Par défaut du projet",
+ "language.en": "Anglais",
+ "language.de": "Allemand",
+ "language.fr": "Français",
+ "language.it": "Italien",
+ "language.es": "Espagnol",
"editor.placeholder.tags": "Ajouter des étiquettes...",
"editor.placeholder.author": "Nom de l’auteur",
"editor.placeholder.categories": "Ajouter des catégories...",
@@ -879,6 +886,10 @@
"editor.media.quickActions.button": "✨ Analyser avec l’IA",
"editor.media.quickActions.aiTitle": "Titre suggéré par l’IA",
"editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.",
+ "editor.post.quickActions.detectLanguageDescription": "Détecter la langue avec l'IA",
+ "editor.post.quickActions.detecting": "Détection…",
+ "editor.post.quickActions.languageDetected": "Langue détectée",
+ "editor.post.quickActions.detectLanguageFailed": "Échec de la détection de la langue",
"editor.media.replaceFile": "Remplacer le fichier",
"editor.media.field.fileName": "Nom du fichier",
"editor.media.field.type": "Type",
diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json
index a8c1f1d..3d1cc1f 100644
--- a/src/renderer/i18n/locales/it.json
+++ b/src/renderer/i18n/locales/it.json
@@ -517,6 +517,13 @@
"editor.field.content": "Contenuto",
"editor.field.template": "Modello",
"editor.field.templateDefault": "Predefinito",
+ "editor.field.language": "Lingua",
+ "editor.field.languageDefault": "Predefinito del progetto",
+ "language.en": "Inglese",
+ "language.de": "Tedesco",
+ "language.fr": "Francese",
+ "language.it": "Italiano",
+ "language.es": "Spagnolo",
"editor.placeholder.tags": "Aggiungi tag...",
"editor.placeholder.author": "Nome autore",
"editor.placeholder.categories": "Aggiungi categorie...",
@@ -879,6 +886,10 @@
"editor.media.quickActions.button": "✨ Analizza con IA",
"editor.media.quickActions.aiTitle": "Titolo suggerito dall’IA",
"editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.",
+ "editor.post.quickActions.detectLanguageDescription": "Rileva la lingua con l'IA",
+ "editor.post.quickActions.detecting": "Rilevamento…",
+ "editor.post.quickActions.languageDetected": "Lingua rilevata",
+ "editor.post.quickActions.detectLanguageFailed": "Rilevamento lingua non riuscito",
"editor.media.replaceFile": "Sostituisci file",
"editor.media.field.fileName": "Nome file",
"editor.media.field.type": "Tipo",
diff --git a/src/renderer/python/PythonRuntimeManager.ts b/src/renderer/python/PythonRuntimeManager.ts
index 895693f..6de0d0a 100644
--- a/src/renderer/python/PythonRuntimeManager.ts
+++ b/src/renderer/python/PythonRuntimeManager.ts
@@ -3,12 +3,22 @@ import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol
import type { PythonSyntaxError } from './runtimeProtocol';
import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1';
import { invokePythonApiMethodV1 } from './pythonApiInvokerV1';
+import { showToast } from '../components/Toast';
type WorkerFactory = () => Worker;
type PythonApiInvoker = (method: string, args: unknown) => Promise;
+type ToastHandler = (message: string, toastType?: string) => void;
+
+const TOAST_TYPES = new Set(['success', 'error', 'info']);
+
+function defaultToastHandler(message: string, toastType?: string): void {
+ const resolvedType = (toastType && TOAST_TYPES.has(toastType) ? toastType : 'info') as 'success' | 'error' | 'info';
+ showToast[resolvedType](message);
+}
interface PythonRuntimeManagerOptions {
invokeApiCall?: PythonApiInvoker;
+ onToast?: ToastHandler;
}
interface InitializeDeferred {
@@ -22,6 +32,8 @@ interface PendingRun {
resolve: (value: PythonRunResult | PythonMacroV1Result | string[] | PythonSyntaxCheckResult) => void;
reject: (error: Error) => void;
timeoutId: ReturnType | null;
+ timeoutMs: number;
+ onStdout?: (chunk: string) => void;
}
export interface PythonRunResult {
@@ -33,6 +45,7 @@ export interface PythonExecuteOptions {
timeoutMs?: number;
cacheKey?: string;
entrypoint?: string;
+ onStdout?: (chunk: string) => void;
}
export interface PythonMacroSourceOptions {
@@ -65,12 +78,14 @@ export class PythonRuntimeManager {
private activeRequestId: string | null = null;
private requestCounter = 0;
private readonly invokeApiCall: PythonApiInvoker;
+ private readonly onToast: ToastHandler;
constructor(
private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker,
options: PythonRuntimeManagerOptions = {}
) {
this.invokeApiCall = options.invokeApiCall ?? invokePythonApiMethodV1;
+ this.onToast = options.onToast ?? defaultToastHandler;
}
initialize(): Promise {
@@ -116,18 +131,14 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise((resolve, reject) => {
- const timeoutId = setTimeout(() => {
- this.pendingRuns.delete(requestId);
- this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
- reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
- }, timeoutMs);
-
this.pendingRuns.set(requestId, {
kind: 'run',
stdout: '',
resolve: (value) => resolve(value as PythonRunResult),
reject,
- timeoutId,
+ timeoutId: null,
+ timeoutMs,
+ onStdout: options?.onStdout,
});
const message: PythonWorkerRequest = {
@@ -155,18 +166,13 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise((resolve, reject) => {
- const timeoutId = setTimeout(() => {
- this.pendingRuns.delete(requestId);
- this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
- reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
- }, timeoutMs);
-
this.pendingRuns.set(requestId, {
kind: 'macro-v1',
stdout: '',
resolve: (value) => resolve(value as PythonMacroV1Result),
reject,
- timeoutId,
+ timeoutId: null,
+ timeoutMs,
});
const message: PythonWorkerRequest = {
@@ -194,18 +200,13 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise((resolve, reject) => {
- const timeoutId = setTimeout(() => {
- this.pendingRuns.delete(requestId);
- this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
- reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
- }, timeoutMs);
-
this.pendingRuns.set(requestId, {
kind: 'inspect-entrypoints',
stdout: '',
resolve: (value) => resolve(value as string[]),
reject,
- timeoutId,
+ timeoutId: null,
+ timeoutMs,
});
const message: PythonWorkerRequest = {
@@ -230,18 +231,13 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise((resolve, reject) => {
- const timeoutId = setTimeout(() => {
- this.pendingRuns.delete(requestId);
- this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
- reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
- }, timeoutMs);
-
this.pendingRuns.set(requestId, {
kind: 'syntax-check',
stdout: '',
resolve: (value) => resolve(value as PythonSyntaxCheckResult),
reject,
- timeoutId,
+ timeoutId: null,
+ timeoutMs,
});
const message: PythonWorkerRequest = {
@@ -282,6 +278,11 @@ export class PythonRuntimeManager {
return;
}
+ if (payload.type === 'toast') {
+ this.onToast(payload.message, payload.toastType);
+ return;
+ }
+
const pendingRun = this.pendingRuns.get(payload.requestId);
if (!pendingRun) {
if (this.activeRequestId === payload.requestId && payload.type !== 'stdout') {
@@ -293,6 +294,7 @@ export class PythonRuntimeManager {
if (payload.type === 'stdout') {
pendingRun.stdout += payload.chunk;
+ pendingRun.onStdout?.(payload.chunk);
return;
}
@@ -440,6 +442,7 @@ export class PythonRuntimeManager {
}
this.activeRequestId = request.requestId;
+ this.startTimeoutForRequest(request.requestId);
this.worker.postMessage(request);
}
@@ -454,9 +457,23 @@ export class PythonRuntimeManager {
}
this.activeRequestId = nextRequest.requestId;
+ this.startTimeoutForRequest(nextRequest.requestId);
this.worker.postMessage(nextRequest);
}
+ private startTimeoutForRequest(requestId: string): void {
+ const pendingRun = this.pendingRuns.get(requestId);
+ if (!pendingRun || pendingRun.timeoutMs <= 0) {
+ return;
+ }
+
+ pendingRun.timeoutId = setTimeout(() => {
+ this.pendingRuns.delete(requestId);
+ this.resetRuntime(`Python script execution timed out after ${pendingRun.timeoutMs}ms`);
+ pendingRun.reject(new Error(`Python script execution timed out after ${pendingRun.timeoutMs}ms`));
+ }, pendingRun.timeoutMs);
+ }
+
private finishRequest(requestId: string): void {
if (this.activeRequestId === requestId) {
this.activeRequestId = null;
diff --git a/src/renderer/python/pythonApiInvokerV1.ts b/src/renderer/python/pythonApiInvokerV1.ts
index 5880c30..d96665a 100644
--- a/src/renderer/python/pythonApiInvokerV1.ts
+++ b/src/renderer/python/pythonApiInvokerV1.ts
@@ -76,6 +76,7 @@ export async function invokePythonApiMethodV1(method: string, args: unknown): Pr
}
const normalizedArgs = asRecord(args);
+
const electronApi = getElectronApi();
const [namespace, member] = contract.method.split('.');
if (!namespace || !member) {
diff --git a/src/renderer/python/pythonRuntime.worker.ts b/src/renderer/python/pythonRuntime.worker.ts
index 63c125b..c49f2b1 100644
--- a/src/renderer/python/pythonRuntime.worker.ts
+++ b/src/renderer/python/pythonRuntime.worker.ts
@@ -344,11 +344,22 @@ async function bootstrapRuntime(): Promise {
},
});
+ runtime.globals.set('__bds_push_toast', (message: unknown, toastType?: unknown) => {
+ postRuntimeMessage({
+ type: 'toast',
+ message: String(message ?? ''),
+ ...(typeof toastType === 'string' && toastType.length > 0 ? { toastType } : {}),
+ });
+ });
+
runtime.globals.set('__bds_api_module_source', generatePythonApiModuleV1());
await runtime.runPythonAsync(`
import sys
import types
+def toast(message, type="info"):
+ __bds_push_toast(str(message), str(type))
+
__bds_api_module = types.ModuleType("bds_api")
exec(__bds_api_module_source, __bds_api_module.__dict__)
diff --git a/src/renderer/python/runtimeProtocol.ts b/src/renderer/python/runtimeProtocol.ts
index 531e640..a49ce24 100644
--- a/src/renderer/python/runtimeProtocol.ts
+++ b/src/renderer/python/runtimeProtocol.ts
@@ -55,4 +55,5 @@ export type PythonWorkerMessage =
| { type: 'entrypoints'; requestId: string; entrypoints: string[] }
| { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] }
| { type: 'macroResult'; requestId: string; result: MacroResultV1 }
- | { type: 'runError'; requestId: string; error: string };
+ | { type: 'runError'; requestId: string; error: string }
+ | { type: 'toast'; message: string; toastType?: string };
diff --git a/tests/engine/GenerationSitemapFeedService.test.ts b/tests/engine/GenerationSitemapFeedService.test.ts
index b6c802d..c31c7a4 100644
--- a/tests/engine/GenerationSitemapFeedService.test.ts
+++ b/tests/engine/GenerationSitemapFeedService.test.ts
@@ -20,6 +20,7 @@ function makePost(overrides: Partial = {}): 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('en');
+ expect(result.rssXml).toContain('de');
+
+ // Atom should have xml:lang on entries with language
+ expect(result.atomXml).toContain('xml:lang="en"');
+ expect(result.atomXml).toContain('xml:lang="de"');
+ });
});
diff --git a/tests/engine/MetadataDiffEngine.test.ts b/tests/engine/MetadataDiffEngine.test.ts
index a556818..922e0c1 100644
--- a/tests/engine/MetadataDiffEngine.test.ts
+++ b/tests/engine/MetadataDiffEngine.test.ts
@@ -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}`);
diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts
index cbfffcf..e614ce7 100644
--- a/tests/engine/PostEngine.test.ts
+++ b/tests/engine/PostEngine.test.ts
@@ -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');
+ });
+ });
});
diff --git a/tests/engine/TaskManager.test.ts b/tests/engine/TaskManager.test.ts
index 29f539d..bfa3a55 100644
--- a/tests/engine/TaskManager.test.ts
+++ b/tests/engine/TaskManager.test.ts
@@ -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;
diff --git a/tests/renderer/components/ScriptsView.test.tsx b/tests/renderer/components/ScriptsView.test.tsx
index a4f440a..5f703aa 100644
--- a/tests/renderer/components/ScriptsView.test.tsx
+++ b/tests/renderer/components/ScriptsView.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+ });
+ });
});
diff --git a/tests/renderer/python/PythonRuntimeManager.test.ts b/tests/renderer/python/PythonRuntimeManager.test.ts
index c2ea96c..e1d9f6a 100644
--- a/tests/renderer/python/PythonRuntimeManager.test.ts
+++ b/tests/renderer/python/PythonRuntimeManager.test.ts
@@ -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: '' });
+ });
});
diff --git a/tests/renderer/python/generateApiDocumentationMarkdownV1.test.ts b/tests/renderer/python/generateApiDocumentationMarkdownV1.test.ts
index d1578f4..676ad34 100644
--- a/tests/renderer/python/generateApiDocumentationMarkdownV1.test.ts
+++ b/tests/renderer/python/generateApiDocumentationMarkdownV1.test.ts
@@ -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', () => {
diff --git a/tests/renderer/python/pythonApiContractV1.test.ts b/tests/renderer/python/pythonApiContractV1.test.ts
index ba1347a..d9273bf 100644
--- a/tests/renderer/python/pythonApiContractV1.test.ts
+++ b/tests/renderer/python/pythonApiContractV1.test.ts
@@ -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', () => {