feat: hooked scripts into the blogmark pipeline
This commit is contained in:
249
tests/engine/BlogmarkTransformService.test.ts
Normal file
249
tests/engine/BlogmarkTransformService.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -740,6 +740,17 @@ describe('main bootstrap preview behavior', () => {
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/engine/BlogmarkTransformService', () => ({
|
||||
getBlogmarkTransformService: vi.fn(() => ({
|
||||
applyTransforms: vi.fn(async (input: { post: { title: string; content: string; categories: string[] } }) => ({
|
||||
post: input.post,
|
||||
appliedScriptIds: [],
|
||||
errors: [],
|
||||
toasts: [],
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -802,7 +813,14 @@ describe('main bootstrap preview behavior', () => {
|
||||
|
||||
expect(windows[0]?.webContents.send).toHaveBeenCalledWith(
|
||||
'blogmark:created',
|
||||
expect.objectContaining({ id: 'new-post-id' }),
|
||||
expect.objectContaining({
|
||||
post: expect.objectContaining({ id: 'new-post-id' }),
|
||||
transform: expect.objectContaining({
|
||||
appliedScriptIds: [],
|
||||
errors: [],
|
||||
toasts: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -903,6 +921,17 @@ describe('main bootstrap preview behavior', () => {
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/engine/BlogmarkTransformService', () => ({
|
||||
getBlogmarkTransformService: vi.fn(() => ({
|
||||
applyTransforms: vi.fn(async (input: { post: { title: string; content: string; categories: string[] } }) => ({
|
||||
post: input.post,
|
||||
appliedScriptIds: [],
|
||||
errors: [],
|
||||
toasts: [],
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -971,7 +1000,14 @@ describe('main bootstrap preview behavior', () => {
|
||||
|
||||
expect(windows[0]?.webContents.send).toHaveBeenCalledWith(
|
||||
'blogmark:created',
|
||||
expect.objectContaining({ id: 'queued-post-id' }),
|
||||
expect.objectContaining({
|
||||
post: expect.objectContaining({ id: 'queued-post-id' }),
|
||||
transform: expect.objectContaining({
|
||||
appliedScriptIds: [],
|
||||
errors: [],
|
||||
toasts: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
125
tests/renderer/navigation/blogmarkTransformOutput.test.ts
Normal file
125
tests/renderer/navigation/blogmarkTransformOutput.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildBlogmarkTransformOutputEntries,
|
||||
buildBlogmarkTransformToastNotifications,
|
||||
parseBlogmarkCreatedEventPayload,
|
||||
} from '../../../src/renderer/navigation/blogmarkTransformOutput';
|
||||
|
||||
describe('parseBlogmarkCreatedEventPayload', () => {
|
||||
it('parses legacy payload shape where event value is the post itself', () => {
|
||||
const payload = parseBlogmarkCreatedEventPayload({ id: 'post-1', title: 'Legacy post' });
|
||||
|
||||
expect(payload).toEqual({
|
||||
post: { id: 'post-1', title: 'Legacy post' },
|
||||
transform: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses new payload shape with post and transform metadata', () => {
|
||||
const payload = parseBlogmarkCreatedEventPayload({
|
||||
post: { id: 'post-2', title: 'With transforms' },
|
||||
transform: {
|
||||
appliedScriptIds: ['a', 'b'],
|
||||
errors: [{ scriptId: 'c', scriptSlug: 'c', message: 'boom' }],
|
||||
toasts: ['done'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
post: { id: 'post-2', title: 'With transforms' },
|
||||
transform: {
|
||||
appliedScriptIds: ['a', 'b'],
|
||||
errors: [{ scriptId: 'c', scriptSlug: 'c', message: 'boom' }],
|
||||
toasts: ['done'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBlogmarkTransformOutputEntries', () => {
|
||||
const t = (key: string, values?: Record<string, string | number>) => {
|
||||
if (key === 'app.blogmark.transforms.summary') {
|
||||
return `summary:${values?.applied}:${values?.failed}`;
|
||||
}
|
||||
if (key === 'app.blogmark.transforms.appliedList') {
|
||||
return `applied:${values?.scripts}`;
|
||||
}
|
||||
if (key === 'app.blogmark.transforms.failed') {
|
||||
return `failed:${values?.script}:${values?.message}`;
|
||||
}
|
||||
if (key === 'app.blogmark.transforms.toast') {
|
||||
return `toast:${values?.message}`;
|
||||
}
|
||||
if (key === 'app.blogmark.transforms.errorToast') {
|
||||
return `error-toast:${values?.count}`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
it('returns empty list when no transform info is provided', () => {
|
||||
expect(buildBlogmarkTransformOutputEntries(undefined, t)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns summary and applied list entries for successful transforms', () => {
|
||||
const entries = buildBlogmarkTransformOutputEntries(
|
||||
{
|
||||
appliedScriptIds: ['alpha', 'beta'],
|
||||
errors: [],
|
||||
toasts: [],
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]?.kind).toBe('result');
|
||||
expect(entries[0]?.message).toBe('summary:2:0');
|
||||
expect(entries[1]?.kind).toBe('result');
|
||||
expect(entries[1]?.message).toBe('applied:alpha, beta');
|
||||
});
|
||||
|
||||
it('returns one error entry per failed transform', () => {
|
||||
const entries = buildBlogmarkTransformOutputEntries(
|
||||
{
|
||||
appliedScriptIds: ['alpha'],
|
||||
errors: [
|
||||
{ scriptId: 'broken', scriptSlug: 'broken_slug', message: 'boom' },
|
||||
{ scriptId: 'bad', scriptSlug: 'bad_slug', message: 'invalid output' },
|
||||
],
|
||||
toasts: ['Step finished'],
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
expect(entries).toHaveLength(5);
|
||||
expect(entries[0]?.message).toBe('summary:1:2');
|
||||
expect(entries[1]?.message).toBe('applied:alpha');
|
||||
expect(entries[2]?.kind).toBe('result');
|
||||
expect(entries[2]?.message).toBe('toast:Step finished');
|
||||
expect(entries[3]?.kind).toBe('error');
|
||||
expect(entries[3]?.message).toBe('failed:broken_slug:boom');
|
||||
expect(entries[4]?.kind).toBe('error');
|
||||
expect(entries[4]?.message).toBe('failed:bad_slug:invalid output');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBlogmarkTransformToastNotifications', () => {
|
||||
const t = (key: string, values?: Record<string, string | number>) => {
|
||||
if (key === 'app.blogmark.transforms.errorToast') {
|
||||
return `error-toast:${values?.count}`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
it('returns toast notifications for script toasts and aggregated errors', () => {
|
||||
const notifications = buildBlogmarkTransformToastNotifications({
|
||||
appliedScriptIds: ['alpha'],
|
||||
toasts: ['Saved one item'],
|
||||
errors: [{ scriptId: 'broken', scriptSlug: 'broken_slug', message: 'boom' }],
|
||||
}, t);
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{ kind: 'success', message: 'Saved one item' },
|
||||
{ kind: 'error', message: 'error-toast:1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user