feat: more work on python scriptiong basics
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
- [Working with pages](#working-with-pages)
|
||||
- [Working with media](#working-with-media)
|
||||
- [Using macros](#using-macros)
|
||||
- [Using scripting (early access)](#using-scripting-early-access)
|
||||
- [Organizing with tags](#organizing-with-tags)
|
||||
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
|
||||
- [Using Git (Source Control)](#using-git-source-control)
|
||||
@@ -196,6 +197,24 @@ Use macros when you need reusable rich blocks (for example embedded videos, medi
|
||||
|
||||
---
|
||||
|
||||
## Using scripting (early access)
|
||||
|
||||
The scripting feature is an incremental capability and should currently be treated as early access. Scripts are stored as Python files in the project filesystem, while script metadata is tracked in the project database and embedded in the file metadata docstring block. This keeps scripts portable and inspectable while still allowing reliable indexing in the app.
|
||||
|
||||
Each script exposes an **Entrypoint** selector. bDS always provides a synthetic `main` entrypoint. Selecting `main` runs the full script body as before. In addition, bDS inspects your script to list top-level Python function names, which can be selected as entrypoints for upcoming execution modes and integrations.
|
||||
|
||||
At this stage, scripting is intended for controlled project workflows where scripts interact with application-provided tools. Keep scripts versioned through your normal Git workflow, review changes carefully, and prefer small, explicit scripts over monolithic utility files.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Scripting is available and intentionally evolving in small steps.
|
||||
- `main` is always available and preserves whole-script execution behavior.
|
||||
- Script files and metadata remain filesystem-friendly and Git-reviewable.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Organizing with tags
|
||||
|
||||
Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer serve users. The Tags section exists to keep taxonomy useful and prevent search and filtering quality from degrading.
|
||||
|
||||
@@ -63,9 +63,6 @@ export class ScriptEngine extends EventEmitter {
|
||||
const scriptId = uuidv4();
|
||||
const filePath = this.getScriptFilePath(uniqueSlug);
|
||||
|
||||
await fs.mkdir(this.getScriptsDir(), { recursive: true });
|
||||
await fs.writeFile(filePath, input.content, 'utf-8');
|
||||
|
||||
const row: NewScript = {
|
||||
id: scriptId,
|
||||
projectId: this.currentProjectId,
|
||||
@@ -80,6 +77,9 @@ export class ScriptEngine extends EventEmitter {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await fs.mkdir(this.getScriptsDir(), { recursive: true });
|
||||
await fs.writeFile(filePath, this.serializeScriptFile(row as Script, input.content), 'utf-8');
|
||||
|
||||
await getDatabase().getLocal().insert(scripts).values(row);
|
||||
|
||||
const created = await this.toScriptData(row as Script);
|
||||
@@ -108,20 +108,39 @@ export class ScriptEngine extends EventEmitter {
|
||||
await fs.rename(existing.filePath, nextFilePath);
|
||||
}
|
||||
|
||||
if (typeof updates.content === 'string') {
|
||||
await fs.writeFile(nextFilePath, updates.content, 'utf-8');
|
||||
}
|
||||
const nextTitle = updates.title ?? existing.title;
|
||||
const nextKind = updates.kind ?? existing.kind;
|
||||
const nextEntrypoint = updates.entrypoint ?? existing.entrypoint;
|
||||
const nextEnabled = updates.enabled ?? existing.enabled;
|
||||
const nextVersion = existing.version + 1;
|
||||
const nextContent = typeof updates.content === 'string'
|
||||
? updates.content
|
||||
: await this.readScriptBody(nextFilePath);
|
||||
|
||||
const nextRow = {
|
||||
...existing,
|
||||
title: nextTitle,
|
||||
slug: nextSlug,
|
||||
kind: nextKind,
|
||||
entrypoint: nextEntrypoint,
|
||||
enabled: nextEnabled,
|
||||
filePath: nextFilePath,
|
||||
version: nextVersion,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await fs.writeFile(nextFilePath, this.serializeScriptFile(nextRow, nextContent), 'utf-8');
|
||||
|
||||
await getDatabase().getLocal()
|
||||
.update(scripts)
|
||||
.set({
|
||||
title: updates.title ?? existing.title,
|
||||
title: nextTitle,
|
||||
slug: nextSlug,
|
||||
kind: updates.kind ?? existing.kind,
|
||||
entrypoint: updates.entrypoint ?? existing.entrypoint,
|
||||
enabled: updates.enabled ?? existing.enabled,
|
||||
kind: nextKind,
|
||||
entrypoint: nextEntrypoint,
|
||||
enabled: nextEnabled,
|
||||
filePath: nextFilePath,
|
||||
version: existing.version + 1,
|
||||
version: nextVersion,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(scripts.id, existing.id), eq(scripts.projectId, this.currentProjectId)));
|
||||
@@ -187,15 +206,7 @@ export class ScriptEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
private async toScriptData(row: Script): Promise<ScriptData> {
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(row.filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
const fsError = error as NodeJS.ErrnoException;
|
||||
if (fsError.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const content = await this.readScriptBody(row.filePath);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -256,6 +267,57 @@ export class ScriptEngine extends EventEmitter {
|
||||
|
||||
return `${baseSlug}_${suffix}`;
|
||||
}
|
||||
|
||||
private serializeScriptFile(row: Pick<Script, 'id' | 'projectId' | 'slug' | 'title' | 'kind' | 'entrypoint' | 'enabled' | 'version' | 'createdAt' | 'updatedAt'>, content: string): string {
|
||||
const lines = [
|
||||
'"""',
|
||||
'---',
|
||||
`id: ${this.toYamlString(row.id)}`,
|
||||
`projectId: ${this.toYamlString(row.projectId)}`,
|
||||
`slug: ${this.toYamlString(row.slug)}`,
|
||||
`title: ${this.toYamlString(row.title)}`,
|
||||
`kind: ${this.toYamlString(row.kind)}`,
|
||||
`entrypoint: ${this.toYamlString(row.entrypoint)}`,
|
||||
`enabled: ${row.enabled ? 'true' : 'false'}`,
|
||||
`version: ${row.version}`,
|
||||
`createdAt: ${this.toYamlString(row.createdAt.toISOString())}`,
|
||||
`updatedAt: ${this.toYamlString(row.updatedAt.toISOString())}`,
|
||||
'---',
|
||||
'"""',
|
||||
content,
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private toYamlString(value: string): string {
|
||||
const escaped = value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
private parseScriptBody(rawContent: string): string {
|
||||
const frontmatterDocstringPattern = /^(?:"""|''')\r?\n---\r?\n[\s\S]*?\r?\n---\r?\n(?:"""|''')\r?\n?/;
|
||||
if (!frontmatterDocstringPattern.test(rawContent)) {
|
||||
return rawContent;
|
||||
}
|
||||
|
||||
return rawContent.replace(frontmatterDocstringPattern, '');
|
||||
}
|
||||
|
||||
private async readScriptBody(filePath: string): Promise<string> {
|
||||
try {
|
||||
const rawContent = await fs.readFile(filePath, 'utf-8');
|
||||
return this.parseScriptBody(rawContent);
|
||||
} catch (error) {
|
||||
const fsError = error as NodeJS.ErrnoException;
|
||||
if (fsError.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let scriptEngineInstance: ScriptEngine | null = null;
|
||||
|
||||
@@ -28,12 +28,26 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
const [slug, setSlug] = useState('');
|
||||
const [kind, setKind] = useState<ScriptData['kind']>('utility');
|
||||
const [entrypoint, setEntrypoint] = useState('render');
|
||||
const [availableEntrypoints, setAvailableEntrypoints] = useState<string[]>([]);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [scriptContent, setScriptContent] = useState('');
|
||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const buildCacheKey = (scriptMeta: Pick<ScriptData, 'id' | 'version'>, content: string): string => {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
hash = ((hash << 5) - hash + content.charCodeAt(index)) | 0;
|
||||
}
|
||||
return `${scriptMeta.id}:${scriptMeta.version}:${Math.abs(hash).toString(36)}`;
|
||||
};
|
||||
|
||||
const withMainEntrypoint = (entrypoints: string[]): string[] => {
|
||||
const filtered = entrypoints.filter((name) => name !== 'main');
|
||||
return ['main', ...filtered];
|
||||
};
|
||||
|
||||
const toFunctionSlug = (value: string) => {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
@@ -45,6 +59,33 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const refreshEntrypoints = async (content: string, scriptMeta: ScriptData) => {
|
||||
try {
|
||||
const runtimeManager = getPythonRuntimeManager();
|
||||
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(content, {
|
||||
cacheKey: buildCacheKey(scriptMeta, content),
|
||||
});
|
||||
const available = withMainEntrypoint(discoveredEntrypoints);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableEntrypoints(available);
|
||||
|
||||
const preferredEntrypoint = available.includes(scriptMeta.entrypoint)
|
||||
? scriptMeta.entrypoint
|
||||
: 'main';
|
||||
setEntrypoint(preferredEntrypoint);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setAvailableEntrypoints(['main']);
|
||||
setEntrypoint('main');
|
||||
}
|
||||
};
|
||||
|
||||
const loadScript = async () => {
|
||||
if (!scriptId) {
|
||||
setScript(null);
|
||||
@@ -52,6 +93,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
setSlug('');
|
||||
setKind('utility');
|
||||
setEntrypoint('render');
|
||||
setAvailableEntrypoints(['main']);
|
||||
setEnabled(true);
|
||||
setScriptContent('');
|
||||
setIsSlugManuallyEdited(false);
|
||||
@@ -65,6 +107,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
setSlug('');
|
||||
setKind('utility');
|
||||
setEntrypoint('render');
|
||||
setAvailableEntrypoints(['main']);
|
||||
setEnabled(true);
|
||||
setScriptContent('');
|
||||
setIsSlugManuallyEdited(false);
|
||||
@@ -80,6 +123,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
setScriptContent(item.content || '');
|
||||
const normalizedExisting = toFunctionSlug(item.slug || item.title || '');
|
||||
setIsSlugManuallyEdited(normalizedExisting !== toFunctionSlug(item.title || ''));
|
||||
await refreshEntrypoints(item.content || '', item);
|
||||
};
|
||||
|
||||
void loadScript();
|
||||
@@ -117,11 +161,24 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const runtimeManager = getPythonRuntimeManager();
|
||||
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(scriptContent, {
|
||||
cacheKey: buildCacheKey(script, scriptContent),
|
||||
});
|
||||
const available = withMainEntrypoint(discoveredEntrypoints);
|
||||
|
||||
const normalizedEntrypoint = available.includes(entrypoint)
|
||||
? entrypoint
|
||||
: 'main';
|
||||
|
||||
setAvailableEntrypoints(available);
|
||||
setEntrypoint(normalizedEntrypoint);
|
||||
|
||||
const updated = await window.electronAPI?.scripts.update(script.id, {
|
||||
title,
|
||||
slug,
|
||||
kind,
|
||||
entrypoint,
|
||||
entrypoint: normalizedEntrypoint,
|
||||
enabled,
|
||||
content: scriptContent,
|
||||
});
|
||||
@@ -135,6 +192,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
setSlug(toFunctionSlug(updated.slug || updated.title || ''));
|
||||
setKind(updated.kind || 'utility');
|
||||
setEntrypoint(updated.entrypoint || 'render');
|
||||
setAvailableEntrypoints(available);
|
||||
setEnabled(updated.enabled ?? true);
|
||||
setScriptContent(updated.content || '');
|
||||
const normalizedExisting = toFunctionSlug(updated.slug || updated.title || '');
|
||||
@@ -193,7 +251,10 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
|
||||
try {
|
||||
const runtimeManager = getPythonRuntimeManager();
|
||||
const result = await runtimeManager.execute(scriptContent);
|
||||
const result = await runtimeManager.execute(scriptContent, {
|
||||
cacheKey: buildCacheKey(script, scriptContent),
|
||||
entrypoint,
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (result.result.trim().length > 0) {
|
||||
@@ -307,13 +368,16 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label htmlFor="script-entrypoint">{t('scripts.field.entrypoint')}</label>
|
||||
<input
|
||||
<select
|
||||
id="script-entrypoint"
|
||||
type="text"
|
||||
value={entrypoint}
|
||||
onChange={(event) => setEntrypoint(event.target.value)}
|
||||
disabled={!script}
|
||||
/>
|
||||
>
|
||||
{availableEntrypoints.map((name) => (
|
||||
<option key={name} value={name}>{name === 'main' ? t('scripts.entrypoint.main') : name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="editor-field scripts-enabled-field">
|
||||
<label htmlFor="script-enabled">
|
||||
|
||||
@@ -425,6 +425,8 @@
|
||||
"scripts.content": "Skriptinhalt",
|
||||
"scripts.field.kind": "Typ",
|
||||
"scripts.field.entrypoint": "Einstiegspunkt",
|
||||
"scripts.entrypoint.main": "main",
|
||||
"scripts.entrypoint.none": "Keine Funktionen gefunden",
|
||||
"scripts.field.enabled": "Aktiviert",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
|
||||
@@ -425,6 +425,8 @@
|
||||
"scripts.content": "Script Content",
|
||||
"scripts.field.kind": "Kind",
|
||||
"scripts.field.entrypoint": "Entrypoint",
|
||||
"scripts.entrypoint.main": "main",
|
||||
"scripts.entrypoint.none": "No functions found",
|
||||
"scripts.field.enabled": "Enabled",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
|
||||
@@ -425,6 +425,8 @@
|
||||
"scripts.content": "Contenido del script",
|
||||
"scripts.field.kind": "Tipo",
|
||||
"scripts.field.entrypoint": "Punto de entrada",
|
||||
"scripts.entrypoint.main": "main",
|
||||
"scripts.entrypoint.none": "No se encontraron funciones",
|
||||
"scripts.field.enabled": "Habilitado",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
|
||||
@@ -425,6 +425,8 @@
|
||||
"scripts.content": "Contenu du script",
|
||||
"scripts.field.kind": "Type",
|
||||
"scripts.field.entrypoint": "Point d’entrée",
|
||||
"scripts.entrypoint.main": "main",
|
||||
"scripts.entrypoint.none": "Aucune fonction trouvée",
|
||||
"scripts.field.enabled": "Activé",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
|
||||
@@ -425,6 +425,8 @@
|
||||
"scripts.content": "Contenuto script",
|
||||
"scripts.field.kind": "Tipo",
|
||||
"scripts.field.entrypoint": "Punto di ingresso",
|
||||
"scripts.entrypoint.main": "main",
|
||||
"scripts.entrypoint.none": "Nessuna funzione trovata",
|
||||
"scripts.field.enabled": "Abilitato",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
|
||||
@@ -10,9 +10,9 @@ interface InitializeDeferred {
|
||||
}
|
||||
|
||||
interface PendingRun {
|
||||
kind: 'run' | 'macro-v1';
|
||||
kind: 'run' | 'macro-v1' | 'inspect-entrypoints';
|
||||
stdout: string;
|
||||
resolve: (value: PythonRunResult | PythonMacroV1Result) => void;
|
||||
resolve: (value: PythonRunResult | PythonMacroV1Result | string[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
@@ -24,6 +24,18 @@ export interface PythonRunResult {
|
||||
|
||||
export interface PythonExecuteOptions {
|
||||
timeoutMs?: number;
|
||||
cacheKey?: string;
|
||||
entrypoint?: string;
|
||||
}
|
||||
|
||||
export interface PythonMacroSourceOptions {
|
||||
kind: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface PythonMacroRenderOptions extends PythonExecuteOptions {
|
||||
macroHook?: string;
|
||||
macroSource?: PythonMacroSourceOptions;
|
||||
}
|
||||
|
||||
export interface PythonMacroV1Result {
|
||||
@@ -102,14 +114,17 @@ export class PythonRuntimeManager {
|
||||
type: 'run',
|
||||
requestId,
|
||||
code,
|
||||
cacheKey: options?.cacheKey,
|
||||
entrypoint: options?.entrypoint,
|
||||
};
|
||||
|
||||
this.worker!.postMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
async renderMacroV1(code: string, context: unknown, options?: PythonExecuteOptions): Promise<PythonMacroV1Result> {
|
||||
const validatedContext = parseMacroContextV1(context);
|
||||
async renderMacroV1(code: string, context: unknown, options?: PythonMacroRenderOptions): Promise<PythonMacroV1Result> {
|
||||
const contextWithMetadata = this.withMacroEnvMetadata(context, options);
|
||||
const validatedContext = parseMacroContextV1(contextWithMetadata);
|
||||
await this.initialize();
|
||||
|
||||
if (!this.worker || !this.ready) {
|
||||
@@ -139,6 +154,43 @@ export class PythonRuntimeManager {
|
||||
requestId,
|
||||
code,
|
||||
context: validatedContext,
|
||||
cacheKey: options?.cacheKey,
|
||||
};
|
||||
|
||||
this.worker!.postMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
async inspectEntrypoints(code: string, options?: PythonExecuteOptions): Promise<string[]> {
|
||||
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<string[]>((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,
|
||||
});
|
||||
|
||||
const message: PythonWorkerRequest = {
|
||||
type: 'inspectEntrypoints',
|
||||
requestId,
|
||||
code,
|
||||
cacheKey: options?.cacheKey,
|
||||
};
|
||||
|
||||
this.worker!.postMessage(message);
|
||||
@@ -191,6 +243,15 @@ export class PythonRuntimeManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'entrypoints') {
|
||||
if (pendingRun.kind !== 'inspect-entrypoints') {
|
||||
pendingRun.reject(new Error('Invalid response type for pending run request'));
|
||||
return;
|
||||
}
|
||||
pendingRun.resolve(payload.entrypoints);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'macroResult') {
|
||||
if (pendingRun.kind !== 'macro-v1') {
|
||||
pendingRun.reject(new Error('Invalid response type for pending run request'));
|
||||
@@ -255,4 +316,36 @@ export class PythonRuntimeManager {
|
||||
this.requestCounter += 1;
|
||||
return `req-${this.requestCounter}`;
|
||||
}
|
||||
|
||||
private withMacroEnvMetadata(context: unknown, options?: PythonMacroRenderOptions): unknown {
|
||||
if (!options?.macroHook && !options?.macroSource) {
|
||||
return context;
|
||||
}
|
||||
|
||||
if (!context || typeof context !== 'object' || Array.isArray(context)) {
|
||||
return context;
|
||||
}
|
||||
|
||||
const contextRecord = context as Record<string, unknown>;
|
||||
const envValue = contextRecord.env;
|
||||
if (!envValue || typeof envValue !== 'object' || Array.isArray(envValue)) {
|
||||
return context;
|
||||
}
|
||||
|
||||
const envRecord = envValue as Record<string, unknown>;
|
||||
const nextEnv: Record<string, unknown> = { ...envRecord };
|
||||
|
||||
if (nextEnv.hook === undefined && options.macroHook !== undefined) {
|
||||
nextEnv.hook = options.macroHook;
|
||||
}
|
||||
|
||||
if (nextEnv.source === undefined && options.macroSource !== undefined) {
|
||||
nextEnv.source = options.macroSource;
|
||||
}
|
||||
|
||||
return {
|
||||
...contextRecord,
|
||||
env: nextEnv,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ export const macroContextV1Schema = z
|
||||
.object({
|
||||
isPreview: z.boolean(),
|
||||
mainLanguage: z.string().min(1).optional(),
|
||||
hook: z.string().min(1).optional(),
|
||||
source: z
|
||||
.object({
|
||||
kind: z.string().min(1),
|
||||
id: z.string().min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
params: z.record(z.string(), jsonValueSchema).optional(),
|
||||
|
||||
34
src/renderer/python/macroRenderOptions.ts
Normal file
34
src/renderer/python/macroRenderOptions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { PythonMacroRenderOptions, PythonMacroSourceOptions } from './PythonRuntimeManager';
|
||||
|
||||
export interface MacroRenderOptionInput {
|
||||
timeoutMs?: number;
|
||||
cacheKey?: string;
|
||||
hook?: string;
|
||||
source?: PythonMacroSourceOptions;
|
||||
}
|
||||
|
||||
export function createMacroRenderOptions(input?: MacroRenderOptionInput): PythonMacroRenderOptions {
|
||||
if (!input) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const options: PythonMacroRenderOptions = {};
|
||||
|
||||
if (input.timeoutMs !== undefined) {
|
||||
options.timeoutMs = input.timeoutMs;
|
||||
}
|
||||
|
||||
if (input.cacheKey !== undefined) {
|
||||
options.cacheKey = input.cacheKey;
|
||||
}
|
||||
|
||||
if (input.hook !== undefined) {
|
||||
options.macroHook = input.hook;
|
||||
}
|
||||
|
||||
if (input.source !== undefined) {
|
||||
options.macroSource = input.source;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -19,6 +19,28 @@ function toResultString(result: unknown): string {
|
||||
return String(result);
|
||||
}
|
||||
|
||||
async function runPythonCode(code: string, cacheKey?: string): Promise<unknown> {
|
||||
if (!runtime) {
|
||||
throw new Error('Python runtime is not ready');
|
||||
}
|
||||
|
||||
if (!cacheKey) {
|
||||
return runtime.runPythonAsync(code);
|
||||
}
|
||||
|
||||
runtime.globals.set('__bds_source_code', code);
|
||||
runtime.globals.set('__bds_cache_key', cacheKey);
|
||||
|
||||
return runtime.runPythonAsync(`
|
||||
__bds_compiled_cache = globals().setdefault("__bds_compiled_cache", {})
|
||||
__bds_compiled_code = __bds_compiled_cache.get(__bds_cache_key)
|
||||
if __bds_compiled_code is None:
|
||||
__bds_compiled_code = compile(__bds_source_code, f"<bds:{__bds_cache_key}>", "exec")
|
||||
__bds_compiled_cache[__bds_cache_key] = __bds_compiled_code
|
||||
exec(__bds_compiled_code, globals(), globals())
|
||||
`);
|
||||
}
|
||||
|
||||
async function runScript(request: PythonWorkerRequest): Promise<void> {
|
||||
if (request.type !== 'run') {
|
||||
return;
|
||||
@@ -37,7 +59,22 @@ async function runScript(request: PythonWorkerRequest): Promise<void> {
|
||||
activeRequestId = request.requestId;
|
||||
|
||||
try {
|
||||
const result = await runtime.runPythonAsync(request.code);
|
||||
let result: unknown;
|
||||
if (request.entrypoint && request.entrypoint !== 'main') {
|
||||
await runPythonCode(request.code, request.cacheKey);
|
||||
runtime.globals.set('__bds_selected_entrypoint', request.entrypoint);
|
||||
result = await runtime.runPythonAsync(`
|
||||
__bds_target = globals().get(__bds_selected_entrypoint)
|
||||
if __bds_target is None:
|
||||
raise NameError(f"Entrypoint '{__bds_selected_entrypoint}' not found")
|
||||
if not callable(__bds_target):
|
||||
raise TypeError(f"Entrypoint '{__bds_selected_entrypoint}' is not callable")
|
||||
__bds_target()
|
||||
`);
|
||||
} else {
|
||||
result = await runPythonCode(request.code, request.cacheKey);
|
||||
}
|
||||
|
||||
postRuntimeMessage({
|
||||
type: 'runResult',
|
||||
requestId: request.requestId,
|
||||
@@ -72,7 +109,7 @@ async function runMacroV1(request: PythonWorkerRequest): Promise<void> {
|
||||
const validatedContext = parseMacroContextV1(request.context);
|
||||
runtime.globals.set('__bds_context_v1', validatedContext);
|
||||
|
||||
await runtime.runPythonAsync(request.code);
|
||||
await runPythonCode(request.code, request.cacheKey);
|
||||
|
||||
const rawJsonResult = await runtime.runPythonAsync(`
|
||||
import json
|
||||
@@ -93,6 +130,56 @@ json.dumps(render(__bds_context_v1))
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectEntrypoints(request: PythonWorkerRequest): Promise<void> {
|
||||
if (request.type !== 'inspectEntrypoints') {
|
||||
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 {
|
||||
runtime.globals.set('__bds_entrypoints_source', request.code);
|
||||
const rawJsonResult = await runtime.runPythonAsync(`
|
||||
import ast
|
||||
import json
|
||||
|
||||
__bds_entrypoints_tree = ast.parse(__bds_entrypoints_source)
|
||||
__bds_entrypoints = []
|
||||
for __bds_node in __bds_entrypoints_tree.body:
|
||||
if isinstance(__bds_node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not __bds_node.name.startswith('_'):
|
||||
__bds_entrypoints.append(__bds_node.name)
|
||||
|
||||
json.dumps(__bds_entrypoints)
|
||||
`);
|
||||
|
||||
const parsed = JSON.parse(toResultString(rawJsonResult));
|
||||
const entrypoints = Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
|
||||
postRuntimeMessage({
|
||||
type: 'entrypoints',
|
||||
requestId: request.requestId,
|
||||
entrypoints,
|
||||
});
|
||||
} 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> {
|
||||
try {
|
||||
runtime = await loadPyodide({
|
||||
@@ -122,6 +209,11 @@ self.onmessage = (event: MessageEvent<PythonWorkerRequest>) => {
|
||||
|
||||
if (request.type === 'renderMacroV1') {
|
||||
void runMacroV1(request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.type === 'inspectEntrypoints') {
|
||||
void inspectEntrypoints(request);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,12 +5,21 @@ export type PythonWorkerRequest =
|
||||
type: 'run';
|
||||
requestId: string;
|
||||
code: string;
|
||||
cacheKey?: string;
|
||||
entrypoint?: string;
|
||||
}
|
||||
| {
|
||||
type: 'renderMacroV1';
|
||||
requestId: string;
|
||||
code: string;
|
||||
context: MacroContextV1;
|
||||
cacheKey?: string;
|
||||
}
|
||||
| {
|
||||
type: 'inspectEntrypoints';
|
||||
requestId: string;
|
||||
code: string;
|
||||
cacheKey?: string;
|
||||
};
|
||||
|
||||
export type PythonWorkerMessage =
|
||||
@@ -18,5 +27,6 @@ export type PythonWorkerMessage =
|
||||
| { type: 'error'; error: string }
|
||||
| { type: 'stdout'; requestId: string; chunk: string }
|
||||
| { type: 'runResult'; requestId: string; result: string }
|
||||
| { type: 'entrypoints'; requestId: string; entrypoints: string[] }
|
||||
| { type: 'macroResult'; requestId: string; result: MacroResultV1 }
|
||||
| { type: 'runError'; requestId: string; error: string };
|
||||
|
||||
@@ -102,7 +102,13 @@ describe('ScriptEngine', () => {
|
||||
|
||||
expect(created.slug).toBe('render_hero');
|
||||
expect(mockScripts.has(created.id)).toBe(true);
|
||||
expect(mockFiles.get('/mock/userData/projects/default/scripts/render_hero.py')).toContain('def render');
|
||||
const persistedFile = mockFiles.get('/mock/userData/projects/default/scripts/render_hero.py') || '';
|
||||
expect(persistedFile).toContain('---');
|
||||
expect(persistedFile).toContain('title: "Render Hero"');
|
||||
expect(persistedFile).toContain('kind: "macro"');
|
||||
expect(persistedFile).toContain('entrypoint: "render"');
|
||||
expect(persistedFile).toContain('def render');
|
||||
expect(created.content).toBe('def render(context):\n return {"html": "<h1>Hi</h1>"}');
|
||||
});
|
||||
|
||||
it('updates script metadata and file content', async () => {
|
||||
@@ -155,4 +161,18 @@ describe('ScriptEngine', () => {
|
||||
expect(mockScripts.has(created.id)).toBe(false);
|
||||
expect(mockFiles.has('/mock/userData/projects/default/scripts/delete_me.py')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps script content clean when file contains metadata docstring frontmatter', async () => {
|
||||
const created = await scriptEngine.createScript({
|
||||
title: 'Metadata Test',
|
||||
kind: 'utility',
|
||||
content: 'print("hello")',
|
||||
});
|
||||
|
||||
const loaded = await scriptEngine.getScript(created.id);
|
||||
|
||||
expect(loaded?.content).toBe('print("hello")');
|
||||
expect(loaded?.title).toBe('Metadata Test');
|
||||
expect(loaded?.entrypoint).toBe('render');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ScriptsView } from '../../../src/renderer/components/ScriptsView/Script
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
const executeMock = vi.fn();
|
||||
const inspectEntrypointsMock = vi.fn();
|
||||
const monacoPropsSpy = vi.fn();
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
@@ -27,6 +28,7 @@ vi.mock('@monaco-editor/react', () => ({
|
||||
vi.mock('../../../src/renderer/python/runtimeManagerInstance', () => ({
|
||||
getPythonRuntimeManager: () => ({
|
||||
execute: executeMock,
|
||||
inspectEntrypoints: inspectEntrypointsMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -35,6 +37,7 @@ describe('ScriptsView', () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
executeMock.mockResolvedValue({ result: '2', stdout: 'hello\n' });
|
||||
inspectEntrypointsMock.mockResolvedValue(['render', 'helper']);
|
||||
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
@@ -106,6 +109,59 @@ describe('ScriptsView', () => {
|
||||
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads available entrypoints from script functions', async () => {
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
const entrypointSelect = await screen.findByLabelText('Entrypoint') as HTMLSelectElement;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(inspectEntrypointsMock).toHaveBeenCalledWith('print("hello")', {
|
||||
cacheKey: expect.stringMatching(/^script-1:1:/),
|
||||
});
|
||||
});
|
||||
|
||||
expect(Array.from(entrypointSelect.options).map((option) => option.value)).toEqual(['main', 'render', 'helper']);
|
||||
expect(entrypointSelect.value).toBe('render');
|
||||
});
|
||||
|
||||
it('always exposes main entrypoint and falls back to it when no functions are discovered', async () => {
|
||||
inspectEntrypointsMock.mockResolvedValueOnce([]);
|
||||
|
||||
const updateMock = vi.fn().mockResolvedValue({
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
slug: 'hello_script',
|
||||
title: 'Hello Script',
|
||||
kind: 'utility',
|
||||
entrypoint: 'main',
|
||||
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 entrypointSelect = await screen.findByLabelText('Entrypoint') as HTMLSelectElement;
|
||||
await vi.waitFor(() => {
|
||||
expect(Array.from(entrypointSelect.options).map((option) => option.value)).toEqual(['main']);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalledWith(
|
||||
'script-1',
|
||||
expect.objectContaining({
|
||||
entrypoint: 'main',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('saves renamed script metadata and content', async () => {
|
||||
const updateMock = vi.fn().mockResolvedValue({
|
||||
id: 'script-1',
|
||||
@@ -169,7 +225,10 @@ describe('ScriptsView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(executeMock).toHaveBeenCalledWith('print("hello")');
|
||||
expect(executeMock).toHaveBeenCalledWith('print("hello")', {
|
||||
cacheKey: expect.stringMatching(/^script-1:1:/),
|
||||
entrypoint: 'render',
|
||||
});
|
||||
});
|
||||
|
||||
const state = useAppStore.getState();
|
||||
@@ -179,6 +238,20 @@ describe('ScriptsView', () => {
|
||||
expect(state.panelOutputEntries[state.panelOutputEntries.length - 1].message).toContain('hello');
|
||||
});
|
||||
|
||||
it('runs selected non-main entrypoint function', async () => {
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
const entrypointSelect = await screen.findByLabelText('Entrypoint');
|
||||
fireEvent.change(entrypointSelect, { target: { value: 'helper' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(executeMock).toHaveBeenCalledWith('print("hello")', expect.objectContaining({
|
||||
entrypoint: 'helper',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes script from editor action', async () => {
|
||||
const deleteMock = vi.fn().mockResolvedValue(true);
|
||||
(window as any).electronAPI.scripts.delete = deleteMock;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PythonRuntimeManager } from '../../../src/renderer/python/PythonRuntimeManager';
|
||||
import { createMacroRenderOptions } from '../../../src/renderer/python/macroRenderOptions';
|
||||
|
||||
class MockWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
@@ -81,6 +82,58 @@ describe('PythonRuntimeManager', () => {
|
||||
await expect(runPromise).resolves.toEqual({ result: '2', stdout: 'hello\n' });
|
||||
});
|
||||
|
||||
it('forwards compile cache key in execute request options', 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('value = 1', { cacheKey: 'script-1:3' });
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { requestId: string; cacheKey?: string };
|
||||
expect(request.cacheKey).toBe('script-1:3');
|
||||
|
||||
worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: '' });
|
||||
await expect(runPromise).resolves.toEqual({ result: '', stdout: '' });
|
||||
});
|
||||
|
||||
it('forwards selected entrypoint in execute request options', 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('def helper():\n return 42', { entrypoint: 'helper' });
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { requestId: string; entrypoint?: string };
|
||||
expect(request.entrypoint).toBe('helper');
|
||||
|
||||
worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: '42' });
|
||||
await expect(runPromise).resolves.toEqual({ result: '42', stdout: '' });
|
||||
});
|
||||
|
||||
it('inspects script and returns available function names', 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 inspectPromise = manager.inspectEntrypoints('def render(context):\n return {}\n\ndef helper():\n return 1');
|
||||
await Promise.resolve();
|
||||
|
||||
const request = worker.postedMessages[0] as { type: string; requestId: string; code: string };
|
||||
expect(request.type).toBe('inspectEntrypoints');
|
||||
|
||||
worker.emitMessage({ type: 'entrypoints', requestId: request.requestId, entrypoints: ['render', 'helper'] });
|
||||
await expect(inspectPromise).resolves.toEqual(['render', 'helper']);
|
||||
});
|
||||
|
||||
it('rejects when runtime returns run error', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
@@ -171,6 +224,116 @@ describe('PythonRuntimeManager', () => {
|
||||
await expect(runPromise).resolves.toEqual({ result: { html: '<p>ok</p>' }, stdout: 'rendering\n' });
|
||||
});
|
||||
|
||||
it('accepts optional env hook and source metadata for macro 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 runPromise = manager.renderMacroV1('def render(context):\n return {"html": "<p>ok</p>"}', {
|
||||
env: {
|
||||
isPreview: true,
|
||||
hook: 'post:render',
|
||||
source: {
|
||||
kind: 'post',
|
||||
id: 'post-1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as {
|
||||
requestId: string;
|
||||
context: { env: { hook?: string; source?: { kind: string; id?: string } } };
|
||||
};
|
||||
expect(request.context.env.hook).toBe('post:render');
|
||||
expect(request.context.env.source).toEqual({ kind: 'post', id: 'post-1' });
|
||||
|
||||
worker.emitMessage({ type: 'macroResult', requestId: request.requestId, result: { html: '<p>ok</p>' } });
|
||||
await expect(runPromise).resolves.toEqual({ result: { html: '<p>ok</p>' }, stdout: '' });
|
||||
});
|
||||
|
||||
it('injects env hook and source metadata from macro execution options', 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.renderMacroV1(
|
||||
'def render(context):\n return {"html": "<p>ok</p>"}',
|
||||
{
|
||||
env: {
|
||||
isPreview: true,
|
||||
},
|
||||
},
|
||||
createMacroRenderOptions({
|
||||
hook: 'preview:macro',
|
||||
source: {
|
||||
kind: 'post',
|
||||
id: 'post-77',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as {
|
||||
requestId: string;
|
||||
context: { env: { hook?: string; source?: { kind: string; id?: string } } };
|
||||
};
|
||||
|
||||
expect(request.context.env.hook).toBe('preview:macro');
|
||||
expect(request.context.env.source).toEqual({ kind: 'post', id: 'post-77' });
|
||||
|
||||
worker.emitMessage({ type: 'macroResult', requestId: request.requestId, result: { html: '<p>ok</p>' } });
|
||||
await expect(runPromise).resolves.toEqual({ result: { html: '<p>ok</p>' }, stdout: '' });
|
||||
});
|
||||
|
||||
it('preserves explicit env hook and source over macro execution options', 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.renderMacroV1(
|
||||
'def render(context):\n return {"html": "<p>ok</p>"}',
|
||||
{
|
||||
env: {
|
||||
isPreview: true,
|
||||
hook: 'explicit:hook',
|
||||
source: {
|
||||
kind: 'page',
|
||||
id: 'page-9',
|
||||
},
|
||||
},
|
||||
},
|
||||
createMacroRenderOptions({
|
||||
hook: 'preview:macro',
|
||||
source: {
|
||||
kind: 'post',
|
||||
id: 'post-77',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as {
|
||||
requestId: string;
|
||||
context: { env: { hook?: string; source?: { kind: string; id?: string } } };
|
||||
};
|
||||
|
||||
expect(request.context.env.hook).toBe('explicit:hook');
|
||||
expect(request.context.env.source).toEqual({ kind: 'page', id: 'page-9' });
|
||||
|
||||
worker.emitMessage({ type: 'macroResult', requestId: request.requestId, result: { html: '<p>ok</p>' } });
|
||||
await expect(runPromise).resolves.toEqual({ result: { html: '<p>ok</p>' }, stdout: '' });
|
||||
});
|
||||
|
||||
it('rejects macro execution when worker result violates ABI schema', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
41
tests/renderer/python/abiV1.test.ts
Normal file
41
tests/renderer/python/abiV1.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseMacroContextV1 } from '../../../src/renderer/python/abiV1';
|
||||
|
||||
describe('macroContextV1Schema', () => {
|
||||
it('accepts optional env hook and source metadata', () => {
|
||||
const parsed = parseMacroContextV1({
|
||||
env: {
|
||||
isPreview: true,
|
||||
mainLanguage: 'en',
|
||||
hook: 'post:render',
|
||||
source: {
|
||||
kind: 'post',
|
||||
id: 'post-1',
|
||||
},
|
||||
},
|
||||
params: {
|
||||
title: 'Hello',
|
||||
},
|
||||
data: {
|
||||
post: {
|
||||
id: 'post-1',
|
||||
slug: 'hello',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.env.hook).toBe('post:render');
|
||||
expect(parsed.env.source).toEqual({ kind: 'post', id: 'post-1' });
|
||||
});
|
||||
|
||||
it('rejects unknown env fields', () => {
|
||||
expect(() =>
|
||||
parseMacroContextV1({
|
||||
env: {
|
||||
isPreview: true,
|
||||
unknown: 'value',
|
||||
},
|
||||
})
|
||||
).toThrow('Invalid macro context');
|
||||
});
|
||||
});
|
||||
30
tests/renderer/python/macroRenderOptions.test.ts
Normal file
30
tests/renderer/python/macroRenderOptions.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createMacroRenderOptions } from '../../../src/renderer/python/macroRenderOptions';
|
||||
|
||||
describe('createMacroRenderOptions', () => {
|
||||
it('maps hook/source metadata into runtime macro options', () => {
|
||||
const options = createMacroRenderOptions({
|
||||
hook: 'preview:macro',
|
||||
source: {
|
||||
kind: 'post',
|
||||
id: 'post-5',
|
||||
},
|
||||
cacheKey: 'script-1:1:abc',
|
||||
timeoutMs: 4000,
|
||||
});
|
||||
|
||||
expect(options).toEqual({
|
||||
macroHook: 'preview:macro',
|
||||
macroSource: {
|
||||
kind: 'post',
|
||||
id: 'post-5',
|
||||
},
|
||||
cacheKey: 'script-1:1:abc',
|
||||
timeoutMs: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty options when no values are provided', () => {
|
||||
expect(createMacroRenderOptions()).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user