314 lines
9.6 KiB
TypeScript
314 lines
9.6 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import type { ScriptData } from '../../src/main/shared/electronApi';
|
|
import {
|
|
BlogmarkTransformService,
|
|
type BlogmarkTransformExecutor,
|
|
type BlogmarkTransformInput,
|
|
type BlogmarkTransformScriptProvider,
|
|
} from '../../src/main/engine/BlogmarkTransformService';
|
|
|
|
function createScript(overrides: Partial<ScriptData>): ScriptData {
|
|
const baseDate = '2026-02-23T00:00:00.000Z';
|
|
return {
|
|
id: 'script-default',
|
|
projectId: 'default',
|
|
slug: 'script_default',
|
|
title: 'Default Script',
|
|
kind: 'transform',
|
|
entrypoint: 'transform',
|
|
enabled: true,
|
|
version: 1,
|
|
filePath: '/tmp/default.py',
|
|
content: 'def transform(payload):\n return payload',
|
|
createdAt: baseDate,
|
|
updatedAt: baseDate,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createInput(overrides: Partial<BlogmarkTransformInput> = {}): BlogmarkTransformInput {
|
|
return {
|
|
post: {
|
|
title: 'Hello',
|
|
content: '[Hello](https://example.com)',
|
|
tags: ['inbox'],
|
|
categories: ['blogmark'],
|
|
},
|
|
context: {
|
|
source: 'blogmark',
|
|
url: 'https://example.com',
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('BlogmarkTransformService', () => {
|
|
it('applies enabled transform scripts sequentially in deterministic order', async () => {
|
|
const scripts: ScriptData[] = [
|
|
createScript({ id: 'b', slug: 'b', updatedAt: '2026-02-23T00:00:01.000Z' }),
|
|
createScript({ id: 'a', slug: 'a', updatedAt: '2026-02-23T00:00:01.000Z' }),
|
|
createScript({ id: 'c', slug: 'c', updatedAt: '2026-02-23T00:00:00.000Z' }),
|
|
];
|
|
|
|
const executionOrder: string[] = [];
|
|
const executor: BlogmarkTransformExecutor = {
|
|
runTransform: vi.fn(async (script, input) => {
|
|
executionOrder.push(script.id);
|
|
return {
|
|
post: {
|
|
...input.post,
|
|
title: `${input.post.title}:${script.id}`,
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
|
|
const provider: BlogmarkTransformScriptProvider = {
|
|
getScripts: vi.fn(async () => scripts),
|
|
};
|
|
|
|
const service = new BlogmarkTransformService({ executor, provider });
|
|
|
|
const result = await service.applyTransforms(createInput());
|
|
|
|
expect(executionOrder).toEqual(['c', 'a', 'b']);
|
|
expect(result.post.title).toBe('Hello:c:a:b');
|
|
expect(result.appliedScriptIds).toEqual(['c', 'a', 'b']);
|
|
expect(result.errors).toEqual([]);
|
|
expect(result.toasts).toEqual([]);
|
|
});
|
|
|
|
it('skips disabled and non-transform scripts', async () => {
|
|
const scripts: ScriptData[] = [
|
|
createScript({ id: 'transform-enabled', kind: 'transform', enabled: true }),
|
|
createScript({ id: 'transform-disabled', kind: 'transform', enabled: false }),
|
|
createScript({ id: 'macro-enabled', kind: 'macro', enabled: true }),
|
|
createScript({ id: 'utility-enabled', kind: 'utility', enabled: true }),
|
|
];
|
|
|
|
const executor: BlogmarkTransformExecutor = {
|
|
runTransform: vi.fn(async (script, input) => ({
|
|
post: {
|
|
...input.post,
|
|
title: `${input.post.title}:${script.id}`,
|
|
},
|
|
})),
|
|
};
|
|
|
|
const service = new BlogmarkTransformService({
|
|
executor,
|
|
provider: { getScripts: async () => scripts },
|
|
});
|
|
|
|
const result = await service.applyTransforms(createInput());
|
|
|
|
expect(result.post.title).toBe('Hello:transform-enabled');
|
|
expect(result.appliedScriptIds).toEqual(['transform-enabled']);
|
|
expect(result.errors).toEqual([]);
|
|
expect(result.toasts).toEqual([]);
|
|
});
|
|
|
|
it('continues with next scripts when one transform fails', async () => {
|
|
const scripts: ScriptData[] = [
|
|
createScript({ id: 'first', slug: 'first' }),
|
|
createScript({ id: 'broken', slug: 'broken' }),
|
|
createScript({ id: 'last', slug: 'last' }),
|
|
];
|
|
|
|
const executor: BlogmarkTransformExecutor = {
|
|
runTransform: vi.fn(async (script, input) => {
|
|
if (script.id === 'broken') {
|
|
throw new Error('boom');
|
|
}
|
|
|
|
return {
|
|
post: {
|
|
...input.post,
|
|
title: `${input.post.title}:${script.id}`,
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
|
|
const service = new BlogmarkTransformService({
|
|
executor,
|
|
provider: { getScripts: async () => scripts },
|
|
});
|
|
|
|
const result = await service.applyTransforms(createInput());
|
|
|
|
expect(result.post.title).toBe('Hello:first:last');
|
|
expect(result.appliedScriptIds).toEqual(['first', 'last']);
|
|
expect(result.errors).toEqual([
|
|
{
|
|
scriptId: 'broken',
|
|
scriptSlug: 'broken',
|
|
message: 'boom',
|
|
},
|
|
]);
|
|
expect(result.toasts).toEqual([]);
|
|
});
|
|
|
|
it('rejects invalid transform result and keeps latest valid post', async () => {
|
|
const scripts: ScriptData[] = [
|
|
createScript({ id: 'valid-1', slug: 'valid-1' }),
|
|
createScript({ id: 'invalid', slug: 'invalid' }),
|
|
createScript({ id: 'valid-2', slug: 'valid-2' }),
|
|
];
|
|
|
|
const executor: BlogmarkTransformExecutor = {
|
|
runTransform: vi.fn(async (script, input) => {
|
|
if (script.id === 'invalid') {
|
|
return {
|
|
post: {
|
|
title: '',
|
|
content: '',
|
|
tags: [],
|
|
categories: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
title: `${input.post.title}:${script.id}`,
|
|
content: input.post.content,
|
|
tags: input.post.tags,
|
|
categories: input.post.categories,
|
|
};
|
|
}),
|
|
};
|
|
|
|
const service = new BlogmarkTransformService({
|
|
executor,
|
|
provider: { getScripts: async () => scripts },
|
|
});
|
|
|
|
const result = await service.applyTransforms(createInput());
|
|
|
|
expect(result.post.title).toBe('Hello:valid-1:valid-2');
|
|
expect(result.appliedScriptIds).toEqual(['valid-1', 'valid-2']);
|
|
expect(result.errors).toEqual([
|
|
{
|
|
scriptId: 'invalid',
|
|
scriptSlug: 'invalid',
|
|
message: 'Transform output validation failed',
|
|
},
|
|
]);
|
|
expect(result.toasts).toEqual([]);
|
|
});
|
|
|
|
it('allows transforms to set multiple categories and add tags', async () => {
|
|
const scripts: ScriptData[] = [
|
|
createScript({ id: 'taxonomy', slug: 'taxonomy' }),
|
|
];
|
|
|
|
const executor: BlogmarkTransformExecutor = {
|
|
runTransform: vi.fn(async (_script, input) => ({
|
|
output: {
|
|
...input.post,
|
|
tags: [...input.post.tags, 'reading-list', 'python'],
|
|
categories: ['link', 'reference'],
|
|
},
|
|
toasts: [],
|
|
})),
|
|
};
|
|
|
|
const service = new BlogmarkTransformService({
|
|
executor,
|
|
provider: { getScripts: async () => scripts },
|
|
});
|
|
|
|
const result = await service.applyTransforms(createInput());
|
|
|
|
expect(result.post.tags).toEqual(['inbox', 'reading-list', 'python']);
|
|
expect(result.post.categories).toEqual(['link', 'reference']);
|
|
});
|
|
|
|
it('collects toast intents emitted by transform scripts', async () => {
|
|
const scripts: ScriptData[] = [
|
|
createScript({ id: 'alpha', slug: 'alpha' }),
|
|
createScript({ id: 'beta', slug: 'beta' }),
|
|
];
|
|
|
|
const executor: BlogmarkTransformExecutor = {
|
|
runTransform: vi.fn(async (_script, input) => ({
|
|
post: input.post,
|
|
toasts: ['Step finished'],
|
|
})),
|
|
};
|
|
|
|
const service = new BlogmarkTransformService({
|
|
executor,
|
|
provider: { getScripts: async () => scripts },
|
|
});
|
|
|
|
const result = await service.applyTransforms(createInput());
|
|
|
|
expect(result.toasts).toEqual(['Step finished', 'Step finished']);
|
|
});
|
|
|
|
it('invokes python transform entrypoint with post payload shape', async () => {
|
|
const globalsStore = new Map<string, unknown>();
|
|
const runPythonAsync = vi.fn(async (code: string) => {
|
|
if (code.includes('json.dumps(_result)')) {
|
|
const payload = JSON.parse(String(globalsStore.get('__bds_transform_payload_json')));
|
|
|
|
if (code.includes('_transform_fn(_payload)')) {
|
|
return JSON.stringify(payload);
|
|
}
|
|
|
|
return JSON.stringify({
|
|
...payload.post,
|
|
title: 'Normalized',
|
|
categories: ['spielelog', 'asides'],
|
|
tags: ['inbox', 'spielen'],
|
|
});
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
vi.doMock('pyodide', () => ({
|
|
loadPyodide: vi.fn(async () => ({
|
|
globals: {
|
|
set: (key: string, value: unknown) => {
|
|
globalsStore.set(key, value);
|
|
},
|
|
},
|
|
runPythonAsync,
|
|
})),
|
|
}));
|
|
|
|
const provider: BlogmarkTransformScriptProvider = {
|
|
getScripts: vi.fn(async () => [
|
|
createScript({
|
|
id: 'pyodide-transform',
|
|
slug: 'pyodide-transform',
|
|
title: 'Pyodide Transform',
|
|
kind: 'transform',
|
|
entrypoint: 'normalize_blogmark',
|
|
content: 'def normalize_blogmark(post):\n return post',
|
|
}),
|
|
]),
|
|
};
|
|
|
|
const service = new BlogmarkTransformService({ provider });
|
|
|
|
const result = await service.applyTransforms(createInput());
|
|
|
|
const transformInvocationCode = runPythonAsync.mock.calls
|
|
.map((call) => call[0])
|
|
.find((code) => typeof code === 'string' && String(code).includes('json.dumps(_result)'));
|
|
|
|
expect(result.post.title).toBe('Normalized');
|
|
expect(result.post.categories).toEqual(['spielelog', 'asides']);
|
|
expect(result.post.tags).toEqual(['inbox', 'spielen']);
|
|
expect(transformInvocationCode).toBeDefined();
|
|
expect(String(transformInvocationCode)).not.toContain('import inspect');
|
|
expect(String(transformInvocationCode)).toContain('\ntry:\n');
|
|
expect(String(transformInvocationCode)).toContain('\nexcept TypeError:\n');
|
|
expect(String(transformInvocationCode)).not.toContain('\n try:\n');
|
|
expect(String(transformInvocationCode)).not.toContain('\n except TypeError:\n');
|
|
});
|
|
});
|