feat: added syntax check

This commit is contained in:
2026-02-23 21:38:12 +01:00
parent e68e845e70
commit 7cc2f7cab2
14 changed files with 507 additions and 10 deletions

View File

@@ -52,3 +52,9 @@
font-size: 12px; font-size: 12px;
border-radius: 4px; border-radius: 4px;
} }
.scripts-check-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import MonacoEditor from '@monaco-editor/react'; import MonacoEditor, { type Monaco } from '@monaco-editor/react';
import type { ScriptData } from '../../../main/shared/electronApi'; import type { ScriptData } from '../../../main/shared/electronApi';
import { useAppStore } from '../../store'; import { useAppStore } from '../../store';
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent } from '../../utils'; import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent } from '../../utils';
@@ -8,6 +8,28 @@ import { useI18n } from '../../i18n';
import { showToast } from '../Toast'; import { showToast } from '../Toast';
import './ScriptsView.css'; import './ScriptsView.css';
type ScriptMonacoEditor = {
getModel: () => unknown;
};
type ScriptMonacoRuntime = {
editor: {
setModelMarkers: (model: unknown, owner: string, markers: Array<{
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
message: string;
severity: number;
}>) => void;
};
MarkerSeverity: {
Error: number;
};
};
const SCRIPT_SYNTAX_MARKER_OWNER = 'scripts-python-syntax';
const UI_DATE_LOCALE: Record<string, string> = { const UI_DATE_LOCALE: Record<string, string> = {
en: 'en-US', en: 'en-US',
de: 'de-DE', de: 'de-DE',
@@ -35,6 +57,9 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false); const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isCheckingSyntax, setIsCheckingSyntax] = useState(false);
const editorRef = useRef<ScriptMonacoEditor | null>(null);
const monacoRef = useRef<ScriptMonacoRuntime | null>(null);
const buildCacheKey = (scriptMeta: Pick<ScriptData, 'id' | 'version'>, content: string): string => { const buildCacheKey = (scriptMeta: Pick<ScriptData, 'id' | 'version'>, content: string): string => {
let hash = 0; let hash = 0;
@@ -57,6 +82,87 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
return normalized || 'script'; return normalized || 'script';
}; };
const applySyntaxMarkers = useCallback((errors: Array<{
line: number;
column: number;
endLine: number;
endColumn: number;
message: string;
}>) => {
const model = editorRef.current?.getModel?.();
const monacoRuntime = monacoRef.current;
if (!model || !monacoRuntime) {
return;
}
const markers = errors.map((error) => {
const startLineNumber = Math.max(1, Math.floor(error.line || 1));
const startColumn = Math.max(1, Math.floor(error.column || 1));
const endLineNumber = Math.max(startLineNumber, Math.floor(error.endLine || startLineNumber));
const fallbackEndColumn = startColumn + 1;
const endColumn = Math.max(startColumn, Math.floor(error.endColumn || fallbackEndColumn));
return {
startLineNumber,
startColumn,
endLineNumber,
endColumn,
message: error.message,
severity: monacoRuntime.MarkerSeverity.Error,
};
});
monacoRuntime.editor.setModelMarkers(model, SCRIPT_SYNTAX_MARKER_OWNER, markers);
}, []);
const handleEditorDidMount = useCallback((editor: unknown, monaco: Monaco) => {
editorRef.current = editor as ScriptMonacoEditor;
monacoRef.current = monaco as unknown as ScriptMonacoRuntime;
}, []);
const handleCheckSyntax = useCallback(async (options: { notify: boolean } = { notify: true }): Promise<boolean> => {
if (!script || isCheckingSyntax) {
return false;
}
setIsCheckingSyntax(true);
try {
const runtimeManager = getPythonRuntimeManager();
const syntax = await runtimeManager.syntaxCheck(scriptContent, {
cacheKey: buildCacheKey(script, scriptContent),
});
applySyntaxMarkers(syntax.errors);
if (syntax.errors.length > 0) {
if (options.notify) {
showToast.error(t('scripts.syntax.invalid', { count: syntax.errors.length }));
}
return false;
}
if (options.notify) {
showToast.success(t('scripts.syntax.valid'));
}
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
appendPanelOutputEntry({
id: `output-${Date.now()}-syntax-error`,
message,
createdAt: new Date().toISOString(),
kind: 'error',
});
if (options.notify) {
showToast.error(t('scripts.syntax.checkFailed'));
}
return false;
} finally {
setIsCheckingSyntax(false);
}
}, [appendPanelOutputEntry, applySyntaxMarkers, isCheckingSyntax, script, scriptContent, t]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -162,6 +268,11 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
setIsSaving(true); setIsSaving(true);
try { try {
const isSyntaxValid = await handleCheckSyntax({ notify: true });
if (!isSyntaxValid) {
return;
}
const runtimeManager = getPythonRuntimeManager(); const runtimeManager = getPythonRuntimeManager();
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(scriptContent, { const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(scriptContent, {
cacheKey: buildCacheKey(script, scriptContent), cacheKey: buildCacheKey(script, scriptContent),
@@ -279,10 +390,6 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
kind: 'error', kind: 'error',
}); });
} finally { } finally {
useAppStore.setState({
panelVisible: true,
panelActiveTab: 'output',
});
setIsRunning(false); setIsRunning(false);
} }
}; };
@@ -312,6 +419,16 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
> >
{t('scripts.run')} {t('scripts.run')}
</button> </button>
<button
type="button"
className="scripts-check-button"
onClick={() => {
void handleCheckSyntax({ notify: true });
}}
disabled={!script || isCheckingSyntax || isSaving}
>
{isCheckingSyntax ? t('scripts.syntax.checking') : t('scripts.syntax.check')}
</button>
<button <button
type="button" type="button"
className="secondary danger" className="secondary danger"
@@ -408,6 +525,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
theme="vs-dark" theme="vs-dark"
value={scriptContent} value={scriptContent}
onChange={(value) => setScriptContent(value || '')} onChange={(value) => setScriptContent(value || '')}
onMount={handleEditorDidMount}
options={{ options={{
minimap: { enabled: false }, minimap: { enabled: false },
wordWrap: 'on', wordWrap: 'on',

View File

@@ -434,6 +434,11 @@
"scripts.entrypoint.main": "main", "scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "Keine Funktionen gefunden", "scripts.entrypoint.none": "Keine Funktionen gefunden",
"scripts.field.enabled": "Aktiviert", "scripts.field.enabled": "Aktiviert",
"scripts.syntax.check": "Syntax prüfen",
"scripts.syntax.checking": "Prüfe...",
"scripts.syntax.valid": "Python-Syntax ist gültig",
"scripts.syntax.invalid": "Python-Syntaxfehler: {count}",
"scripts.syntax.checkFailed": "Python-Syntaxprüfung fehlgeschlagen",
"scripts.kind.utility": "utility", "scripts.kind.utility": "utility",
"scripts.kind.macro": "macro", "scripts.kind.macro": "macro",
"scripts.kind.transform": "transform", "scripts.kind.transform": "transform",

View File

@@ -434,6 +434,11 @@
"scripts.entrypoint.main": "main", "scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "No functions found", "scripts.entrypoint.none": "No functions found",
"scripts.field.enabled": "Enabled", "scripts.field.enabled": "Enabled",
"scripts.syntax.check": "Check Syntax",
"scripts.syntax.checking": "Checking...",
"scripts.syntax.valid": "Python syntax is valid",
"scripts.syntax.invalid": "Python syntax errors: {count}",
"scripts.syntax.checkFailed": "Python syntax check failed",
"scripts.kind.utility": "utility", "scripts.kind.utility": "utility",
"scripts.kind.macro": "macro", "scripts.kind.macro": "macro",
"scripts.kind.transform": "transform", "scripts.kind.transform": "transform",

View File

@@ -434,6 +434,11 @@
"scripts.entrypoint.main": "main", "scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "No se encontraron funciones", "scripts.entrypoint.none": "No se encontraron funciones",
"scripts.field.enabled": "Habilitado", "scripts.field.enabled": "Habilitado",
"scripts.syntax.check": "Comprobar sintaxis",
"scripts.syntax.checking": "Comprobando...",
"scripts.syntax.valid": "La sintaxis de Python es válida",
"scripts.syntax.invalid": "Errores de sintaxis de Python: {count}",
"scripts.syntax.checkFailed": "La comprobación de sintaxis de Python falló",
"scripts.kind.utility": "utility", "scripts.kind.utility": "utility",
"scripts.kind.macro": "macro", "scripts.kind.macro": "macro",
"scripts.kind.transform": "transform", "scripts.kind.transform": "transform",

View File

@@ -434,6 +434,11 @@
"scripts.entrypoint.main": "main", "scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "Aucune fonction trouvée", "scripts.entrypoint.none": "Aucune fonction trouvée",
"scripts.field.enabled": "Activé", "scripts.field.enabled": "Activé",
"scripts.syntax.check": "Vérifier la syntaxe",
"scripts.syntax.checking": "Vérification...",
"scripts.syntax.valid": "La syntaxe Python est valide",
"scripts.syntax.invalid": "Erreurs de syntaxe Python : {count}",
"scripts.syntax.checkFailed": "Échec de la vérification de la syntaxe Python",
"scripts.kind.utility": "utility", "scripts.kind.utility": "utility",
"scripts.kind.macro": "macro", "scripts.kind.macro": "macro",
"scripts.kind.transform": "transform", "scripts.kind.transform": "transform",

View File

@@ -434,6 +434,11 @@
"scripts.entrypoint.main": "main", "scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "Nessuna funzione trovata", "scripts.entrypoint.none": "Nessuna funzione trovata",
"scripts.field.enabled": "Abilitato", "scripts.field.enabled": "Abilitato",
"scripts.syntax.check": "Controlla sintassi",
"scripts.syntax.checking": "Controllo...",
"scripts.syntax.valid": "La sintassi Python è valida",
"scripts.syntax.invalid": "Errori di sintassi Python: {count}",
"scripts.syntax.checkFailed": "Controllo della sintassi Python non riuscito",
"scripts.kind.utility": "utility", "scripts.kind.utility": "utility",
"scripts.kind.macro": "macro", "scripts.kind.macro": "macro",
"scripts.kind.transform": "transform", "scripts.kind.transform": "transform",

View File

@@ -1,5 +1,6 @@
import { createPythonRuntimeWorker } from './createPythonRuntimeWorker'; import { createPythonRuntimeWorker } from './createPythonRuntimeWorker';
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol'; import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
import type { PythonSyntaxError } from './runtimeProtocol';
import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1'; import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1';
type WorkerFactory = () => Worker; type WorkerFactory = () => Worker;
@@ -10,9 +11,9 @@ interface InitializeDeferred {
} }
interface PendingRun { interface PendingRun {
kind: 'run' | 'macro-v1' | 'inspect-entrypoints'; kind: 'run' | 'macro-v1' | 'inspect-entrypoints' | 'syntax-check';
stdout: string; stdout: string;
resolve: (value: PythonRunResult | PythonMacroV1Result | string[]) => void; resolve: (value: PythonRunResult | PythonMacroV1Result | string[] | PythonSyntaxCheckResult) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout> | null; timeoutId: ReturnType<typeof setTimeout> | null;
} }
@@ -43,6 +44,10 @@ export interface PythonMacroV1Result {
stdout: string; stdout: string;
} }
export interface PythonSyntaxCheckResult {
errors: PythonSyntaxError[];
}
export class PythonRuntimeManager { export class PythonRuntimeManager {
private worker: Worker | null = null; private worker: Worker | null = null;
private initializingPromise: Promise<void> | null = null; private initializingPromise: Promise<void> | null = null;
@@ -197,6 +202,42 @@ export class PythonRuntimeManager {
}); });
} }
async syntaxCheck(code: string, options?: PythonExecuteOptions): Promise<PythonSyntaxCheckResult> {
await this.initialize();
if (!this.worker || !this.ready) {
throw new Error('Python runtime is not ready');
}
const requestId = this.nextRequestId();
const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise<PythonSyntaxCheckResult>((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,
});
const message: PythonWorkerRequest = {
type: 'syntaxCheck',
requestId,
code,
cacheKey: options?.cacheKey,
};
this.worker!.postMessage(message);
});
}
isReady(): boolean { isReady(): boolean {
return this.ready; return this.ready;
} }
@@ -252,6 +293,15 @@ export class PythonRuntimeManager {
return; return;
} }
if (payload.type === 'syntaxResult') {
if (pendingRun.kind !== 'syntax-check') {
pendingRun.reject(new Error('Invalid response type for pending syntax check request'));
return;
}
pendingRun.resolve({ errors: payload.errors });
return;
}
if (payload.type === 'macroResult') { if (payload.type === 'macroResult') {
if (pendingRun.kind !== 'macro-v1') { if (pendingRun.kind !== 'macro-v1') {
pendingRun.reject(new Error('Invalid response type for pending run request')); pendingRun.reject(new Error('Invalid response type for pending run request'));

View File

@@ -2,6 +2,7 @@ import { loadPyodide, type PyodideInterface } from 'pyodide';
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol'; import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1'; import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
import { resolvePyodideIndexURL } from './pyodideAssetUrl'; import { resolvePyodideIndexURL } from './pyodideAssetUrl';
import { runPythonSyntaxCheck } from './pythonSyntaxCheck';
let runtime: PyodideInterface | null = null; let runtime: PyodideInterface | null = null;
let activeRequestId: string | null = null; let activeRequestId: string | null = null;
@@ -181,6 +182,39 @@ json.dumps(__bds_entrypoints)
} }
} }
async function syntaxCheck(request: PythonWorkerRequest): Promise<void> {
if (request.type !== 'syntaxCheck') {
return;
}
if (!runtime) {
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is not ready' });
return;
}
if (activeRequestId) {
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is busy' });
return;
}
activeRequestId = request.requestId;
try {
const errors = await runPythonSyntaxCheck(runtime, request.code);
postRuntimeMessage({
type: 'syntaxResult',
requestId: request.requestId,
errors,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
} finally {
activeRequestId = null;
}
}
async function bootstrapRuntime(): Promise<void> { async function bootstrapRuntime(): Promise<void> {
try { try {
const indexURL = resolvePyodideIndexURL(import.meta.url); const indexURL = resolvePyodideIndexURL(import.meta.url);
@@ -217,6 +251,11 @@ self.onmessage = (event: MessageEvent<PythonWorkerRequest>) => {
if (request.type === 'inspectEntrypoints') { if (request.type === 'inspectEntrypoints') {
void inspectEntrypoints(request); void inspectEntrypoints(request);
return;
}
if (request.type === 'syntaxCheck') {
void syntaxCheck(request);
} }
}; };

View File

@@ -0,0 +1,73 @@
import type { PyodideInterface } from 'pyodide';
import type { PythonSyntaxError } from './runtimeProtocol';
const SYNTAX_CHECK_SCRIPT = [
'import ast',
'import json',
'',
'__bds_syntax_errors = []',
'try:',
' ast.parse(__bds_syntax_source)',
'except SyntaxError as exc:',
' line = exc.lineno or 1',
' column = exc.offset or 1',
' end_line = getattr(exc, "end_lineno", None) or line',
' end_column = getattr(exc, "end_offset", None) or (column + 1)',
' __bds_syntax_errors.append({',
' "line": line,',
' "column": column,',
' "endLine": end_line,',
' "endColumn": end_column,',
' "message": exc.msg or "invalid syntax",',
' })',
'__bds_syntax_result_json = json.dumps({"errors": __bds_syntax_errors})',
'__bds_syntax_result_json',
].join('\n');
function toResultString(result: unknown): string {
if (typeof result === 'string') {
return result;
}
if (result === null || result === undefined) {
return '';
}
return String(result);
}
function parseSyntaxErrors(rawJsonResult: unknown): PythonSyntaxError[] {
const rawText = toResultString(rawJsonResult).trim();
if (rawText.length === 0) {
throw new Error('Python syntax check returned no JSON result');
}
const parsed = JSON.parse(rawText) as { errors?: unknown };
if (!Array.isArray(parsed.errors)) {
return [];
}
return parsed.errors
.filter((item): item is PythonSyntaxError => {
return item
&& typeof item === 'object'
&& Number.isFinite((item as { line?: number }).line)
&& Number.isFinite((item as { column?: number }).column)
&& Number.isFinite((item as { endLine?: number }).endLine)
&& Number.isFinite((item as { endColumn?: number }).endColumn)
&& typeof (item as { message?: unknown }).message === 'string';
})
.map((item) => ({
line: Math.max(1, Math.floor(item.line)),
column: Math.max(1, Math.floor(item.column)),
endLine: Math.max(1, Math.floor(item.endLine)),
endColumn: Math.max(1, Math.floor(item.endColumn)),
message: item.message,
}));
}
export async function runPythonSyntaxCheck(runtime: PyodideInterface, sourceCode: string): Promise<PythonSyntaxError[]> {
runtime.globals.set('__bds_syntax_source', sourceCode);
const rawJsonResult = await runtime.runPythonAsync(SYNTAX_CHECK_SCRIPT);
return parseSyntaxErrors(rawJsonResult);
}

View File

@@ -20,13 +20,28 @@ export type PythonWorkerRequest =
requestId: string; requestId: string;
code: string; code: string;
cacheKey?: string; cacheKey?: string;
}
| {
type: 'syntaxCheck';
requestId: string;
code: string;
cacheKey?: string;
}; };
export interface PythonSyntaxError {
line: number;
column: number;
endLine: number;
endColumn: number;
message: string;
}
export type PythonWorkerMessage = export type PythonWorkerMessage =
| { type: 'ready' } | { type: 'ready' }
| { type: 'error'; error: string } | { type: 'error'; error: string }
| { type: 'stdout'; requestId: string; chunk: string } | { type: 'stdout'; requestId: string; chunk: string }
| { type: 'runResult'; requestId: string; result: string } | { type: 'runResult'; requestId: string; result: string }
| { type: 'entrypoints'; requestId: string; entrypoints: string[] } | { type: 'entrypoints'; requestId: string; entrypoints: string[] }
| { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] }
| { type: 'macroResult'; requestId: string; result: MacroResultV1 } | { type: 'macroResult'; requestId: string; result: MacroResultV1 }
| { type: 'runError'; requestId: string; error: string }; | { type: 'runError'; requestId: string; error: string };

View File

@@ -6,15 +6,31 @@ import { useAppStore } from '../../../src/renderer/store';
const executeMock = vi.fn(); const executeMock = vi.fn();
const inspectEntrypointsMock = vi.fn(); const inspectEntrypointsMock = vi.fn();
const syntaxCheckMock = vi.fn();
const monacoPropsSpy = vi.fn(); const monacoPropsSpy = vi.fn();
const setModelMarkersMock = vi.fn();
vi.mock('@monaco-editor/react', () => ({ vi.mock('@monaco-editor/react', () => ({
default: (props: { default: (props: {
value?: string; value?: string;
onChange?: (value?: string) => void; onChange?: (value?: string) => void;
language?: string; language?: string;
onMount?: (editor: unknown, monaco: unknown) => void;
}) => { }) => {
monacoPropsSpy(props); monacoPropsSpy(props);
props.onMount?.(
{
getModel: () => ({ uri: 'inmemory://script.py' }),
},
{
editor: {
setModelMarkers: setModelMarkersMock,
},
MarkerSeverity: {
Error: 8,
},
},
);
return ( return (
<textarea <textarea
aria-label="Script Content" aria-label="Script Content"
@@ -29,6 +45,7 @@ vi.mock('../../../src/renderer/python/runtimeManagerInstance', () => ({
getPythonRuntimeManager: () => ({ getPythonRuntimeManager: () => ({
execute: executeMock, execute: executeMock,
inspectEntrypoints: inspectEntrypointsMock, inspectEntrypoints: inspectEntrypointsMock,
syntaxCheck: syntaxCheckMock,
}), }),
})); }));
@@ -38,6 +55,7 @@ describe('ScriptsView', () => {
executeMock.mockResolvedValue({ result: '2', stdout: 'hello\n' }); executeMock.mockResolvedValue({ result: '2', stdout: 'hello\n' });
inspectEntrypointsMock.mockResolvedValue(['render', 'helper']); inspectEntrypointsMock.mockResolvedValue(['render', 'helper']);
syntaxCheckMock.mockResolvedValue({ errors: [] });
(window as any).electronAPI = { (window as any).electronAPI = {
...(window as any).electronAPI, ...(window as any).electronAPI,
@@ -232,12 +250,81 @@ describe('ScriptsView', () => {
}); });
const state = useAppStore.getState(); const state = useAppStore.getState();
expect(state.panelVisible).toBe(true); expect(state.panelVisible).toBe(false);
expect(state.panelActiveTab).toBe('output'); expect(state.panelActiveTab).toBe('tasks');
expect(state.panelOutputEntries.length).toBeGreaterThan(0); expect(state.panelOutputEntries.length).toBeGreaterThan(0);
expect(state.panelOutputEntries[state.panelOutputEntries.length - 1].message).toContain('hello'); expect(state.panelOutputEntries[state.panelOutputEntries.length - 1].message).toContain('hello');
}); });
it('checks syntax manually and writes editor markers for syntax errors', async () => {
syntaxCheckMock.mockResolvedValueOnce({
errors: [
{
line: 3,
column: 5,
endLine: 3,
endColumn: 10,
message: 'invalid syntax',
},
],
});
render(<ScriptsView scriptId="script-1" />);
await screen.findByLabelText('Script Content');
fireEvent.click(screen.getByRole('button', { name: 'Check Syntax' }));
await vi.waitFor(() => {
expect(syntaxCheckMock).toHaveBeenCalledWith('print("hello")', {
cacheKey: expect.stringMatching(/^script-1:1:/),
});
expect(setModelMarkersMock).toHaveBeenCalledWith(
expect.anything(),
'scripts-python-syntax',
[
expect.objectContaining({
startLineNumber: 3,
startColumn: 5,
endLineNumber: 3,
endColumn: 10,
message: 'invalid syntax',
}),
],
);
});
});
it('runs syntax check automatically on save before updating script', async () => {
const updateMock = vi.fn().mockResolvedValue({
id: 'script-1',
projectId: 'default',
slug: 'hello_script',
title: 'Hello Script',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 2,
filePath: '/tmp/hello-script.py',
content: 'print("hello")',
createdAt: '2026-02-22T00:00:00.000Z',
updatedAt: '2026-02-22T00:01:00.000Z',
});
(window as any).electronAPI.scripts.update = updateMock;
render(<ScriptsView scriptId="script-1" />);
const titleInput = await screen.findByLabelText('Title');
fireEvent.change(titleInput, { target: { value: 'Hello Script Updated' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Script' }));
await vi.waitFor(() => {
expect(syntaxCheckMock).toHaveBeenCalledWith('print("hello")', {
cacheKey: expect.stringMatching(/^script-1:1:/),
});
expect(updateMock).toHaveBeenCalled();
});
});
it('runs selected non-main entrypoint function', async () => { it('runs selected non-main entrypoint function', async () => {
render(<ScriptsView scriptId="script-1" />); render(<ScriptsView scriptId="script-1" />);

View File

@@ -134,6 +134,47 @@ describe('PythonRuntimeManager', () => {
await expect(inspectPromise).resolves.toEqual(['render', 'helper']); await expect(inspectPromise).resolves.toEqual(['render', 'helper']);
}); });
it('checks syntax and returns structured syntax errors', 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 syntaxPromise = manager.syntaxCheck('def broken(:\n pass');
await Promise.resolve();
const request = worker.postedMessages[0] as { type: string; requestId: string; code: string };
expect(request.type).toBe('syntaxCheck');
worker.emitMessage({
type: 'syntaxResult',
requestId: request.requestId,
errors: [
{
line: 1,
column: 11,
endLine: 1,
endColumn: 12,
message: 'invalid syntax',
},
],
});
await expect(syntaxPromise).resolves.toEqual({
errors: [
{
line: 1,
column: 11,
endLine: 1,
endColumn: 12,
message: 'invalid syntax',
},
],
});
});
it('rejects when runtime returns run error', async () => { it('rejects when runtime returns run error', async () => {
const worker = new MockWorker(); const worker = new MockWorker();
const manager = new PythonRuntimeManager(() => worker as unknown as Worker); const manager = new PythonRuntimeManager(() => worker as unknown as Worker);

View File

@@ -0,0 +1,43 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { loadPyodide, type PyodideInterface } from 'pyodide';
import { runPythonSyntaxCheck } from '../../../src/renderer/python/pythonSyntaxCheck';
describe('pythonSyntaxCheck integration (real pyodide)', () => {
let runtime: PyodideInterface;
beforeAll(async () => {
runtime = await loadPyodide();
}, 60000);
it('returns no errors for syntactically valid python', async () => {
const source = [
'def normalize_blogmark(post):',
' title = (post.get("title") or "").strip()',
' if title:',
' post["title"] = title',
' return post',
'',
].join('\n');
const errors = await runPythonSyntaxCheck(runtime, source);
expect(errors).toEqual([]);
});
it('returns structured syntax diagnostics for invalid python', async () => {
const source = [
'def normalize_blogmark(post):',
' if post.get("title")',
' return post',
'',
].join('\n');
const errors = await runPythonSyntaxCheck(runtime, source);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toEqual(expect.objectContaining({
line: 2,
message: expect.any(String),
}));
});
});