feat: python script sync db - files
This commit is contained in:
@@ -237,6 +237,42 @@ describe('GitEngine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChangedScriptFilesBetween', () => {
|
||||
it('returns added, modified, deleted and renamed script file changes from name-status output', async () => {
|
||||
mockRaw.mockResolvedValue([
|
||||
'M', 'scripts/existing.py',
|
||||
'A', 'scripts/new_script.py',
|
||||
'D', 'scripts/removed.py',
|
||||
'R100', 'scripts/old_name.py', 'scripts/new_name.py',
|
||||
'M', 'posts/2026/02/ignored.md',
|
||||
].join('\0'));
|
||||
|
||||
const result = await gitEngine.getChangedScriptFilesBetween('/tmp/project', 'before', 'after');
|
||||
|
||||
expect(mockRaw).toHaveBeenCalledWith([
|
||||
'diff',
|
||||
'--name-status',
|
||||
'--find-renames',
|
||||
'-z',
|
||||
'before..after',
|
||||
'--',
|
||||
'scripts',
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ status: 'modified', path: 'scripts/existing.py' },
|
||||
{ status: 'added', path: 'scripts/new_script.py' },
|
||||
{ status: 'deleted', path: 'scripts/removed.py' },
|
||||
{ status: 'renamed', path: 'scripts/new_name.py', previousPath: 'scripts/old_name.py' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty changes when refs are empty or identical', async () => {
|
||||
expect(await gitEngine.getChangedScriptFilesBetween('/tmp/project', 'same', 'same')).toEqual([]);
|
||||
expect(await gitEngine.getChangedScriptFilesBetween('/tmp/project', ' ', 'after')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommitDiffContent', () => {
|
||||
it('should return commit patch text in diff content shape', async () => {
|
||||
mockShow.mockResolvedValue([
|
||||
|
||||
@@ -55,6 +55,23 @@ vi.mock('uuid', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readdir: vi.fn(async (dirPath: string, options?: { withFileTypes?: boolean }) => {
|
||||
if (options?.withFileTypes) {
|
||||
const files = Array.from((globalThis as any).__mockScriptFiles.keys()) as string[];
|
||||
const names = files
|
||||
.filter((filePath) => filePath.startsWith(`${dirPath}/`))
|
||||
.map((filePath) => filePath.slice(dirPath.length + 1))
|
||||
.filter((name) => !name.includes('/'));
|
||||
|
||||
return names.map((name) => ({
|
||||
name,
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}),
|
||||
readFile: vi.fn(async (filePath: string) => {
|
||||
const value = (globalThis as any).__mockScriptFiles.get(filePath);
|
||||
if (typeof value !== 'string') {
|
||||
@@ -175,4 +192,98 @@ describe('ScriptEngine', () => {
|
||||
expect(loaded?.title).toBe('Metadata Test');
|
||||
expect(loaded?.entrypoint).toBe('render');
|
||||
});
|
||||
|
||||
it('rebuilds scripts from filesystem and applies external file metadata/content', async () => {
|
||||
const scriptPath = '/mock/userData/projects/default/scripts/external_transform.py';
|
||||
mockFiles.set(scriptPath, [
|
||||
'"""',
|
||||
'---',
|
||||
'id: "external-script-id"',
|
||||
'projectId: "default"',
|
||||
'slug: "external_transform"',
|
||||
'title: "External Transform"',
|
||||
'kind: "transform"',
|
||||
'entrypoint: "transform"',
|
||||
'enabled: false',
|
||||
'version: 3',
|
||||
'createdAt: "2026-02-20T10:00:00.000Z"',
|
||||
'updatedAt: "2026-02-21T11:00:00.000Z"',
|
||||
'---',
|
||||
'"""',
|
||||
'def transform(context):',
|
||||
' return context',
|
||||
].join('\n'));
|
||||
|
||||
await scriptEngine.rebuildDatabaseFromFiles();
|
||||
|
||||
const all = await scriptEngine.getAllScripts();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].id).toBe('external-script-id');
|
||||
expect(all[0].slug).toBe('external_transform');
|
||||
expect(all[0].kind).toBe('transform');
|
||||
expect(all[0].entrypoint).toBe('transform');
|
||||
expect(all[0].enabled).toBe(false);
|
||||
expect(all[0].version).toBe(3);
|
||||
expect(all[0].title).toBe('External Transform');
|
||||
expect(all[0].content).toContain('def transform(context):');
|
||||
});
|
||||
|
||||
it('reconciles git changes for scripts (modify/add/delete)', async () => {
|
||||
const created = await scriptEngine.createScript({
|
||||
title: 'Render Hero',
|
||||
kind: 'macro',
|
||||
content: 'def render(context):\n return {"html": "<h1>Hi</h1>"}',
|
||||
});
|
||||
|
||||
const existingPath = '/repo/scripts/render_hero.py';
|
||||
mockFiles.set(existingPath, [
|
||||
'"""',
|
||||
'---',
|
||||
`id: "${created.id}"`,
|
||||
'projectId: "default"',
|
||||
'slug: "render_hero"',
|
||||
'title: "Render Hero Updated Outside"',
|
||||
'kind: "macro"',
|
||||
'entrypoint: "render"',
|
||||
'enabled: true',
|
||||
'version: 8',
|
||||
'createdAt: "2026-02-20T10:00:00.000Z"',
|
||||
'updatedAt: "2026-02-21T11:00:00.000Z"',
|
||||
'---',
|
||||
'"""',
|
||||
'def render(context):',
|
||||
' return {"html": "<h1>Outside</h1>"}',
|
||||
].join('\n'));
|
||||
|
||||
const addedPath = '/repo/scripts/new_transform.py';
|
||||
mockFiles.set(addedPath, [
|
||||
'"""',
|
||||
'---',
|
||||
'id: "added-script-id"',
|
||||
'projectId: "default"',
|
||||
'slug: "new_transform"',
|
||||
'title: "New Transform"',
|
||||
'kind: "transform"',
|
||||
'entrypoint: "transform"',
|
||||
'enabled: true',
|
||||
'version: 1',
|
||||
'createdAt: "2026-02-22T10:00:00.000Z"',
|
||||
'updatedAt: "2026-02-22T11:00:00.000Z"',
|
||||
'---',
|
||||
'"""',
|
||||
'def transform(context):',
|
||||
' return context',
|
||||
].join('\n'));
|
||||
|
||||
const result = await scriptEngine.reconcileScriptsFromGitChanges('/repo', [
|
||||
{ status: 'modified', path: 'scripts/render_hero.py' },
|
||||
{ status: 'added', path: 'scripts/new_transform.py' },
|
||||
{ status: 'deleted', path: 'scripts/render_hero.py' },
|
||||
]);
|
||||
|
||||
expect(result.updated).toBe(1);
|
||||
expect(result.created).toBe(1);
|
||||
expect(result.deleted).toBe(1);
|
||||
expect(result.processedFiles).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,12 +166,15 @@ const mockScriptEngine = {
|
||||
deleteScript: vi.fn(),
|
||||
getScript: vi.fn(),
|
||||
getAllScripts: vi.fn(),
|
||||
rebuildDatabaseFromFiles: vi.fn(),
|
||||
reconcileScriptsFromGitChanges: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGitEngine = {
|
||||
checkAvailability: vi.fn(),
|
||||
getHeadCommit: vi.fn(),
|
||||
getChangedPostFilesBetween: vi.fn(),
|
||||
getChangedScriptFilesBetween: vi.fn(),
|
||||
getRepoState: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
getDiff: vi.fn(),
|
||||
@@ -575,12 +578,21 @@ describe('IPC Handlers', () => {
|
||||
{ status: 'modified', path: 'posts/2026/02/existing.md' },
|
||||
{ status: 'added', path: 'posts/2026/02/new-post.md' },
|
||||
]);
|
||||
mockGitEngine.getChangedScriptFilesBetween.mockResolvedValue([
|
||||
{ status: 'modified', path: 'scripts/transform.py' },
|
||||
]);
|
||||
mockPostEngine.reconcilePublishedPostsFromGitChanges.mockResolvedValue({
|
||||
created: 1,
|
||||
updated: 1,
|
||||
deleted: 0,
|
||||
processedFiles: 2,
|
||||
});
|
||||
mockScriptEngine.reconcileScriptsFromGitChanges.mockResolvedValue({
|
||||
created: 0,
|
||||
updated: 1,
|
||||
deleted: 0,
|
||||
processedFiles: 1,
|
||||
});
|
||||
|
||||
const result = await invokeHandler('git:pull', '/repo');
|
||||
|
||||
@@ -588,10 +600,14 @@ describe('IPC Handlers', () => {
|
||||
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
||||
expect(mockGitEngine.getHeadCommit).toHaveBeenNthCalledWith(2, '/repo');
|
||||
expect(mockGitEngine.getChangedPostFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head');
|
||||
expect(mockGitEngine.getChangedScriptFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head');
|
||||
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).toHaveBeenCalledWith('/repo', [
|
||||
{ status: 'modified', path: 'posts/2026/02/existing.md' },
|
||||
{ status: 'added', path: 'posts/2026/02/new-post.md' },
|
||||
]);
|
||||
expect(mockScriptEngine.reconcileScriptsFromGitChanges).toHaveBeenCalledWith('/repo', [
|
||||
{ status: 'modified', path: 'scripts/transform.py' },
|
||||
]);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -603,7 +619,9 @@ describe('IPC Handlers', () => {
|
||||
|
||||
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
||||
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
||||
expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled();
|
||||
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
||||
expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: false, code: 'conflict' });
|
||||
});
|
||||
|
||||
@@ -617,7 +635,9 @@ describe('IPC Handlers', () => {
|
||||
|
||||
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
||||
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
||||
expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled();
|
||||
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
||||
expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -2712,6 +2732,23 @@ describe('IPC Handlers', () => {
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scripts:rebuildFromFiles', () => {
|
||||
it('should set project context and trigger ScriptEngine rebuild', async () => {
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue({
|
||||
id: 'project-1',
|
||||
dataPath: '/external/data',
|
||||
});
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data');
|
||||
mockScriptEngine.rebuildDatabaseFromFiles.mockResolvedValue(undefined);
|
||||
|
||||
const result = await invokeHandler('scripts:rebuildFromFiles');
|
||||
|
||||
expect(mockScriptEngine.setProjectContext).toHaveBeenCalledWith('project-1', '/resolved/project-data');
|
||||
expect(mockScriptEngine.rebuildDatabaseFromFiles).toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Error Handling ============
|
||||
|
||||
@@ -158,6 +158,26 @@ describe('SettingsView Diff Preferences', () => {
|
||||
expect((articleShowTitle as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it('triggers scripts rebuild from data maintenance section', async () => {
|
||||
const rebuildScriptsMock = vi.fn().mockResolvedValue(undefined);
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
scripts: {
|
||||
...(window as any).electronAPI?.scripts,
|
||||
rebuildFromFiles: rebuildScriptsMock,
|
||||
},
|
||||
};
|
||||
|
||||
render(<SettingsView />);
|
||||
|
||||
const rebuildScriptsButton = await screen.findByRole('button', { name: /rebuild scripts/i });
|
||||
fireEvent.click(rebuildScriptsButton);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(rebuildScriptsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('persists category settings changes via project metadata update', async () => {
|
||||
render(<SettingsView />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user