mirror of
https://github.com/echore/vault-autopilot.git
synced 2026-07-22 08:34:00 +00:00
platform/published now go through yamlString (a crafted value could inject frontmatter keys; a legit value with a colon broke parsing). Untrusted titles are newline/backtick-stripped before body headings, transcripts backtick-stripped (with Dataview JS enabled an injected dataviewjs fence executes on render). Filenames also drop backticks. clip-validate gains type-only checks for optional string fields — verified against the extension contract tests, which pass unmodified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
688 lines
33 KiB
TypeScript
688 lines
33 KiB
TypeScript
import { routeClip, VaultOps } from '../src/clip-router';
|
|
import { makeSerialQueue } from '../src/util';
|
|
import { ClipRule, ScreenshotClipRule, ThumbnailClipRule } from '../src/types';
|
|
import { ClipPayload } from '../src/server';
|
|
import { setLanguage } from '../src/i18n';
|
|
|
|
beforeEach(() => setLanguage('zh'));
|
|
afterAll(() => setLanguage('en'));
|
|
|
|
function makeVaultOps(): jest.Mocked<VaultOps> {
|
|
return {
|
|
ensureFolder: jest.fn().mockResolvedValue(undefined),
|
|
createBinary: jest.fn().mockResolvedValue(undefined),
|
|
create: jest.fn().mockResolvedValue(undefined),
|
|
readFileSync: jest.fn().mockReturnValue('# SOP\nAnalyze this.'),
|
|
downloadUrl: jest.fn().mockResolvedValue(new ArrayBuffer(8)),
|
|
fileExists: jest.fn().mockReturnValue(false),
|
|
listMarkdownFiles: jest.fn().mockReturnValue([]),
|
|
read: jest.fn().mockResolvedValue(''),
|
|
modify: jest.fn().mockResolvedValue(undefined),
|
|
getFrontmatter: jest.fn().mockReturnValue(null),
|
|
};
|
|
}
|
|
|
|
const thumbnailClipRule: ThumbnailClipRule = {
|
|
sopPath: '/thumb-sop.md', outputFolder: 'Content Creation/Great Videos',
|
|
thumbnailFolder: 'Assets/Great Videos',
|
|
};
|
|
const screenshotClipRule: ScreenshotClipRule = {
|
|
sopPath: '/ss-sop.md', outputFolder: 'Screenshots', framesFolder: 'Assets/images',
|
|
};
|
|
const hookClipRule: ClipRule = {
|
|
sopPath: '/hook-sop.md', outputFolder: 'Hooks', maxFrames: 5, framesFolder: 'Assets/images',
|
|
};
|
|
const keyframeClipRule: ClipRule = {
|
|
sopPath: '/kf-sop.md', outputFolder: 'Keyframes', maxFrames: 5, framesFolder: 'Assets/images',
|
|
};
|
|
const clipRules = { thumbnail: thumbnailClipRule, screenshot: screenshotClipRule, hook: hookClipRule, keyframe: keyframeClipRule };
|
|
|
|
// ── thumbnail ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('routeClip — thumbnail', () => {
|
|
const payload: ClipPayload = {
|
|
mode: 'thumbnail',
|
|
platform: 'youtube',
|
|
video_id: 'abc123',
|
|
video_url: 'https://www.youtube.com/watch?v=abc123',
|
|
thumbnail_url: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
|
|
title: 'How to Get Rich on Easy Mode',
|
|
channel: 'Ali Abdaal',
|
|
channel_handle: '@aliabdaal',
|
|
views: '27.8万',
|
|
captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
|
|
test('manual: downloads thumbnail and creates note with correct frontmatter', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
await routeClip(payload, clipRules, vaultOps);
|
|
expect(vaultOps.downloadUrl).toHaveBeenCalledWith(payload.thumbnail_url);
|
|
expect(vaultOps.createBinary).toHaveBeenCalledWith(
|
|
'Assets/Great Videos/abc123.webp',
|
|
expect.any(ArrayBuffer),
|
|
);
|
|
const [notePath, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
|
expect(notePath).toBe('Content Creation/Great Videos/Ali Abdaal - How to Get Rich on Easy Mode.md');
|
|
expect(noteContent).toContain('type: video');
|
|
expect(noteContent).toContain('video_id: "abc123"');
|
|
expect(noteContent).toContain('channel: "Ali Abdaal"');
|
|
expect(noteContent).toContain('dimensions: [封面标题]');
|
|
expect(noteContent).toContain('![[abc123.webp]]');
|
|
expect(noteContent).toContain('## 🖼️ 封面标题');
|
|
});
|
|
|
|
test('note filename omits the author segment when channel is empty', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const noChannel = { ...payload, channel: '' } as ClipPayload;
|
|
const result = await routeClip(noChannel, clipRules, vaultOps);
|
|
expect(result.notePath).toBe('Content Creation/Great Videos/How to Get Rich on Easy Mode.md');
|
|
});
|
|
|
|
test('non-YT/Bili platform: no youtube embed, no channel/views in frontmatter, image in cover', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const generic: ClipPayload = {
|
|
mode: 'thumbnail',
|
|
platform: 'other',
|
|
video_id: 'twitter-com-i-status-123',
|
|
video_url: 'https://twitter.com/u/status/123',
|
|
thumbnail_url: 'https://pbs.twimg.com/x.jpg',
|
|
title: 'A Tweet Video',
|
|
source_name: 'Twitter',
|
|
channel: null,
|
|
views: null,
|
|
captured_at: '2026-06-27T00:00:00Z',
|
|
};
|
|
await routeClip(generic, clipRules, vaultOps);
|
|
const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
|
expect(noteContent).not.toContain('youtube.com/embed');
|
|
expect(noteContent).not.toContain('channel: "');
|
|
expect(noteContent).toContain('![[twitter-com-i-status-123.webp]]');
|
|
expect(noteContent).toContain('## 🖼️ 封面标题');
|
|
});
|
|
});
|
|
|
|
// ── screenshot ────────────────────────────────────────────────────────────────
|
|
|
|
describe('routeClip — screenshot', () => {
|
|
test('manual: saves images to framesFolder and creates template note', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const payload: ClipPayload = {
|
|
mode: 'screenshot',
|
|
images: [Buffer.from('pixels').toString('base64'), Buffer.from('pixels2').toString('base64')],
|
|
url: 'https://x.com',
|
|
title: 'My Screenshot',
|
|
};
|
|
const result = await routeClip(payload, clipRules, vaultOps);
|
|
expect(result.notePath).toMatch(/^Screenshots\/screenshot-.+\.md$/);
|
|
expect(vaultOps.createBinary).toHaveBeenCalledTimes(2);
|
|
expect(vaultOps.createBinary).toHaveBeenCalledWith(
|
|
expect.stringContaining('Assets/images/'),
|
|
expect.any(ArrayBuffer),
|
|
);
|
|
expect(vaultOps.create).toHaveBeenCalledWith(
|
|
expect.stringMatching(/Screenshots\/screenshot-.+\.md/),
|
|
expect.stringContaining('# Screenshot — My Screenshot'),
|
|
);
|
|
expect(vaultOps.create).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.stringContaining('![['),
|
|
);
|
|
});
|
|
|
|
test('backward compat: old image field is normalized to images array', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const payload = {
|
|
mode: 'screenshot' as const,
|
|
image: Buffer.from('pixels').toString('base64'),
|
|
url: 'https://x.com',
|
|
title: 'Old Format',
|
|
} as unknown as ClipPayload;
|
|
await routeClip(payload, clipRules, vaultOps);
|
|
expect(vaultOps.createBinary).toHaveBeenCalledTimes(1);
|
|
expect(vaultOps.create).toHaveBeenCalledWith(
|
|
expect.stringMatching(/Screenshots\/screenshot-.+\.md/),
|
|
expect.stringContaining('# Screenshot — Old Format'),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('routeClip — screenshot (English)', () => {
|
|
beforeEach(() => setLanguage('en'));
|
|
|
|
test('template note is written in English', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const payload: ClipPayload = {
|
|
mode: 'screenshot',
|
|
images: [Buffer.from('pixels').toString('base64')],
|
|
url: 'https://x.com',
|
|
title: 'My Screenshot',
|
|
};
|
|
await routeClip(payload, clipRules, vaultOps);
|
|
expect(vaultOps.create).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.stringContaining('Source: https://x.com'),
|
|
);
|
|
expect(vaultOps.create).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.stringContaining('## Notes'),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── manual mode ───────────────────────────────────────────────────────────────
|
|
|
|
describe('routeClip — manual mode (hook)', () => {
|
|
const manualHookRule: ClipRule = {
|
|
sopPath: '', outputFolder: 'Hooks', maxFrames: 3, framesFolder: 'Assets/images',
|
|
};
|
|
const manualClipRules = { thumbnail: thumbnailClipRule, screenshot: screenshotClipRule, hook:manualHookRule, keyframe: keyframeClipRule };
|
|
|
|
test('saves sampled frames as PNG files', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const frames = Array.from({ length: 6 }, (_, i) => Buffer.from(`frame${i}`).toString('base64'));
|
|
const payload: ClipPayload = {
|
|
mode: 'hook', frames, video_title: 'Test Hook',
|
|
url: 'https://youtube.com/watch?v=abc', captured_at: '2026-05-30T18:00:00Z',
|
|
};
|
|
await routeClip(payload, manualClipRules, vaultOps);
|
|
expect(vaultOps.createBinary).toHaveBeenCalledTimes(3);
|
|
expect(vaultOps.createBinary).toHaveBeenCalledWith(
|
|
expect.stringMatching(/Assets\/images\/hook-.+-f01\.jpg/),
|
|
expect.any(ArrayBuffer),
|
|
);
|
|
});
|
|
|
|
test('writes merged note in Great Videos with ## 🎬 内容 section, frame embeds, transcript', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const payload: ClipPayload = {
|
|
mode: 'hook',
|
|
frames: [Buffer.from('f1').toString('base64'), Buffer.from('f2').toString('base64')],
|
|
video_title: 'My Hook', transcript: 'Hello world',
|
|
url: 'https://youtube.com/watch?v=abc', captured_at: '2026-05-30T18:00:00Z',
|
|
};
|
|
await routeClip(payload, manualClipRules, vaultOps);
|
|
const [notePath, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
|
// Hook now goes into the Great Videos (thumbnail outputFolder) as a merged note
|
|
expect(notePath).toMatch(/Content Creation\/Great Videos\/.+\.md/);
|
|
expect(noteContent).toContain('## 🎬 内容');
|
|
expect(noteContent).toContain('<iframe');
|
|
expect(noteContent).toContain('youtube.com/embed/');
|
|
expect(noteContent).toContain('[Image #1]');
|
|
expect(noteContent).toContain('![[');
|
|
expect(noteContent).toContain('Hello world');
|
|
});
|
|
});
|
|
|
|
describe('routeClip — manual mode (keyframe)', () => {
|
|
const manualKeyframeRule: ClipRule = {
|
|
sopPath: '', outputFolder: 'Keyframes', maxFrames: 5, framesFolder: 'Assets/images',
|
|
};
|
|
const manualClipRules = { thumbnail: thumbnailClipRule, screenshot: screenshotClipRule, hook:hookClipRule, keyframe: manualKeyframeRule };
|
|
|
|
test('writes merged note in Great Videos with ## ✨ 动效 section, time range embed', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const payload: ClipPayload = {
|
|
mode: 'keyframe',
|
|
frames: [Buffer.from('f1').toString('base64')],
|
|
video_title: 'My Video', url: 'https://youtube.com/watch?v=xyz',
|
|
time_range: { start: 30, end: 45 }, captured_at: '2026-05-30T18:00:00Z',
|
|
};
|
|
await routeClip(payload, manualClipRules, vaultOps);
|
|
const [notePath, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
|
// Keyframe now goes into Great Videos as a merged note
|
|
expect(notePath).toMatch(/Content Creation\/Great Videos\/.+\.md/);
|
|
expect(noteContent).toContain('## ✨ 动效');
|
|
expect(noteContent).toContain('<iframe');
|
|
expect(noteContent).toContain('start=30');
|
|
});
|
|
});
|
|
|
|
// ── V2: append to existing note ───────────────────────────────────────────────
|
|
|
|
describe('routeClip — append to existing Great Videos note', () => {
|
|
const existingNote = [
|
|
`---`,
|
|
`type: video`,
|
|
`video_id: "abc123"`,
|
|
`dimensions: [封面标题]`,
|
|
`---`,
|
|
``,
|
|
`# My Video`,
|
|
``,
|
|
`## 🖼️ 封面标题`,
|
|
`分析内容`,
|
|
].join('\n');
|
|
|
|
test('hook: finds existing note by video_id and appends ## 🎬 内容 section', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
(vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue(['Content Creation/Great Videos/note.md']);
|
|
(vaultOps.read as jest.Mock).mockResolvedValue(existingNote);
|
|
const manualHookRule = { ...hookClipRule, sopPath: '' };
|
|
const payload: ClipPayload = {
|
|
mode: 'hook',
|
|
frames: [Buffer.from('f1').toString('base64')],
|
|
video_title: 'My Video',
|
|
url: 'https://www.youtube.com/watch?v=abc123',
|
|
captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
await routeClip(payload, { ...clipRules, hook: manualHookRule }, vaultOps);
|
|
expect(vaultOps.modify).toHaveBeenCalledWith(
|
|
'Content Creation/Great Videos/note.md',
|
|
expect.stringContaining('## 🎬 内容'),
|
|
);
|
|
const [, modifiedContent] = (vaultOps.modify as jest.Mock).mock.calls[0];
|
|
expect(modifiedContent).toContain('dimensions: [封面标题, 内容]');
|
|
expect(modifiedContent).toContain('[Image #1]');
|
|
expect(vaultOps.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('keyframe: finds existing note and appends ## ✨ 动效 section', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
(vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue(['Content Creation/Great Videos/note.md']);
|
|
(vaultOps.read as jest.Mock).mockResolvedValue(existingNote);
|
|
const manualKeyframeRule = { ...keyframeClipRule, sopPath: '' };
|
|
const payload: ClipPayload = {
|
|
mode: 'keyframe',
|
|
frames: [Buffer.from('f1').toString('base64')],
|
|
video_title: 'My Video',
|
|
url: 'https://www.youtube.com/watch?v=abc123',
|
|
time_range: { start: 10, end: 20 },
|
|
captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
await routeClip(payload, { ...clipRules, keyframe: manualKeyframeRule }, vaultOps);
|
|
const [, modifiedContent] = (vaultOps.modify as jest.Mock).mock.calls[0];
|
|
expect(modifiedContent).toContain('## ✨ 动效');
|
|
expect(modifiedContent).toContain('dimensions: [封面标题, 动效]');
|
|
});
|
|
|
|
test('append returns the existing note path (for obsidian deep link)', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
(vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue(['Content Creation/Great Videos/note.md']);
|
|
(vaultOps.read as jest.Mock).mockResolvedValue(existingNote);
|
|
const manualHookRule = { ...hookClipRule, sopPath: '' };
|
|
const payload: ClipPayload = {
|
|
mode: 'hook',
|
|
frames: [Buffer.from('f1').toString('base64')],
|
|
video_title: 'My Video',
|
|
url: 'https://www.youtube.com/watch?v=abc123',
|
|
captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
const result = await routeClip(payload, { ...clipRules, hook: manualHookRule }, vaultOps);
|
|
expect(result.notePath).toBe('Content Creation/Great Videos/note.md');
|
|
});
|
|
|
|
test('finds a note whose frontmatter was re-serialized by Obsidian (unquoted video_id)', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
(vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue(['Content Creation/Great Videos/note.md']);
|
|
(vaultOps.read as jest.Mock).mockResolvedValue(existingNote.replace('video_id: "abc123"', 'video_id: abc123'));
|
|
const payload: ClipPayload = {
|
|
mode: 'hook',
|
|
frames: [Buffer.from('f1').toString('base64')],
|
|
video_title: 'My Video',
|
|
url: 'https://www.youtube.com/watch?v=abc123',
|
|
captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
await routeClip(payload, { ...clipRules, hook: { ...hookClipRule, sopPath: '' } }, vaultOps);
|
|
expect(vaultOps.modify).toHaveBeenCalled();
|
|
expect(vaultOps.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('finds a note via metadataCache frontmatter without a content scan', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
(vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue(['Content Creation/Great Videos/note.md']);
|
|
(vaultOps.getFrontmatter as jest.Mock).mockReturnValue({ video_id: 'abc123' });
|
|
(vaultOps.read as jest.Mock).mockResolvedValue(existingNote);
|
|
const payload: ClipPayload = {
|
|
mode: 'hook',
|
|
frames: [Buffer.from('f1').toString('base64')],
|
|
video_title: 'My Video',
|
|
url: 'https://www.youtube.com/watch?v=abc123',
|
|
captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
await routeClip(payload, { ...clipRules, hook: { ...hookClipRule, sopPath: '' } }, vaultOps);
|
|
expect(vaultOps.modify).toHaveBeenCalled();
|
|
expect(vaultOps.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('cached non-matching frontmatter skips the file without reading it', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
(vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue(['Content Creation/Great Videos/other.md']);
|
|
(vaultOps.getFrontmatter as jest.Mock).mockReturnValue({ video_id: 'zzz999' });
|
|
const payload: ClipPayload = {
|
|
mode: 'hook',
|
|
frames: [Buffer.from('f1').toString('base64')],
|
|
video_title: 'My Video',
|
|
url: 'https://www.youtube.com/watch?v=abc123',
|
|
captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
await routeClip(payload, { ...clipRules, hook: { ...hookClipRule, sopPath: '' } }, vaultOps);
|
|
expect(vaultOps.read).not.toHaveBeenCalled();
|
|
expect(vaultOps.create).toHaveBeenCalled();
|
|
});
|
|
|
|
test('URL-based video keys with regex metacharacters match without crashing', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
(vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue(['Content Creation/Great Videos/tweet.md']);
|
|
(vaultOps.read as jest.Mock).mockResolvedValue(existingNote.replace('video_id: "abc123"', 'video_id: https://x.com/u/status/123'));
|
|
const payload: ClipPayload = {
|
|
mode: 'hook',
|
|
frames: [Buffer.from('f1').toString('base64')],
|
|
video_title: 'Tweet Video',
|
|
url: 'https://x.com/u/status/123?s=20',
|
|
captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
await routeClip(payload, { ...clipRules, hook: { ...hookClipRule, sopPath: '' } }, vaultOps);
|
|
expect(vaultOps.modify).toHaveBeenCalled();
|
|
expect(vaultOps.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('no existing note: creates new note in Great Videos with ## 🎬 内容 section', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
(vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue([]);
|
|
const manualHookRule = { ...hookClipRule, sopPath: '' };
|
|
const payload: ClipPayload = {
|
|
mode: 'hook',
|
|
frames: [Buffer.from('f1').toString('base64')],
|
|
video_title: 'New Video',
|
|
url: 'https://www.youtube.com/watch?v=newvid',
|
|
captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
await routeClip(payload, { ...clipRules, hook: manualHookRule }, vaultOps);
|
|
expect(vaultOps.modify).not.toHaveBeenCalled();
|
|
expect(vaultOps.create).toHaveBeenCalledWith(
|
|
expect.stringContaining('Content Creation/Great Videos/'),
|
|
expect.stringContaining('## 🎬 内容'),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── unified video note (manual) ───────────────────────────────────────────────
|
|
|
|
describe('routeClip — unified video note (manual)', () => {
|
|
const manual = {
|
|
thumbnail: { ...thumbnailClipRule },
|
|
screenshot: screenshotClipRule,
|
|
hook: { ...hookClipRule, sopPath: '' },
|
|
keyframe: { ...keyframeClipRule, sopPath: '' },
|
|
};
|
|
function vaultWithStore() {
|
|
const store: Record<string, string> = {};
|
|
const v = makeVaultOps();
|
|
(v.create as jest.Mock).mockImplementation(async (p: string, c: string) => { store[p] = c; });
|
|
(v.modify as jest.Mock).mockImplementation(async (p: string, c: string) => { store[p] = c; });
|
|
(v.read as jest.Mock).mockImplementation(async (p: string) => store[p] ?? '');
|
|
(v.listMarkdownFiles as jest.Mock).mockImplementation(() => Object.keys(store));
|
|
return { v, store };
|
|
}
|
|
const hookPayload = { mode: 'hook' as const, frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', captured_at: '2026-06-28T00:00:00Z' };
|
|
const kfPayload = (start: number, end: number) => ({ mode: 'keyframe' as const, frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', time_range: { start, end }, captured_at: '2026-06-28T00:00:00Z' });
|
|
|
|
test('hook then keyframe land in ONE note ordered 内容 before 动效', async () => {
|
|
const { v, store } = vaultWithStore();
|
|
await routeClip(hookPayload, manual, v);
|
|
await routeClip(kfPayload(45, 52), manual, v);
|
|
const paths = Object.keys(store);
|
|
expect(paths.length).toBe(1);
|
|
const note = store[paths[0]];
|
|
expect(note.indexOf('## 🎬 内容')).toBeLessThan(note.indexOf('## ✨ 动效'));
|
|
expect(note).toContain('dimensions: [内容, 动效]');
|
|
});
|
|
|
|
test('re-capturing hook returns a notice and does not duplicate', async () => {
|
|
const { v, store } = vaultWithStore();
|
|
await routeClip(hookPayload, manual, v);
|
|
const r = await routeClip(hookPayload, manual, v);
|
|
expect(r.notice).toContain('已存在');
|
|
const note = store[Object.keys(store)[0]];
|
|
expect((note.match(/## 🎬 内容/g) || []).length).toBe(1);
|
|
});
|
|
|
|
test('Xiaohongshu hook + keyframe merge into ONE note (link fallback embed)', async () => {
|
|
const { v, store } = vaultWithStore();
|
|
const url = 'https://www.xiaohongshu.com/explore/xhs123abc';
|
|
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'XHS', url, captured_at: '2026-06-28T00:00:00Z' }, manual, v);
|
|
await routeClip({ mode: 'keyframe', frames: ['Zg=='], video_title: 'XHS', url, time_range: { start: 5, end: 9 }, captured_at: '2026-06-28T00:00:00Z' }, manual, v);
|
|
expect(Object.keys(store).length).toBe(1);
|
|
const note = store[Object.keys(store)[0]];
|
|
expect(note).toContain('platform: "xiaohongshu"');
|
|
expect(note).toContain('video_id: "xhs123abc"');
|
|
expect(note.indexOf('## 🎬 内容')).toBeLessThan(note.indexOf('## ✨ 动效'));
|
|
expect(note).toContain('[▶ 跳转原视频]'); // no inline player for xhs
|
|
expect(note).not.toContain('youtube.com/embed');
|
|
});
|
|
|
|
test('any platform merges by URL: Twitter hook + keyframe → one note', async () => {
|
|
const { v, store } = vaultWithStore();
|
|
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Tweet', url: 'https://x.com/u/status/123?s=20', captured_at: '2026-06-28T00:00:00Z' }, manual, v);
|
|
await routeClip({ mode: 'keyframe', frames: ['Zg=='], video_title: 'Tweet', url: 'https://x.com/u/status/123', time_range: { start: 5, end: 9 }, captured_at: '2026-06-28T00:00:00Z' }, manual, v);
|
|
expect(Object.keys(store).length).toBe(1);
|
|
const note = store[Object.keys(store)[0]];
|
|
expect(note).toContain('video_id: "https://x.com/u/status/123"');
|
|
expect(note.indexOf('## 🎬 内容')).toBeLessThan(note.indexOf('## ✨ 动效'));
|
|
});
|
|
|
|
test('hook with cover_url saves the gallery cover at <video_id>.webp', async () => {
|
|
const { v } = vaultWithStore();
|
|
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', cover_url: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg', captured_at: '2026-06-28T00:00:00Z' } as ClipPayload, manual, v);
|
|
expect(v.createBinary).toHaveBeenCalledWith('Assets/Great Videos/abc123.webp', expect.any(ArrayBuffer));
|
|
});
|
|
|
|
test('cover is not re-downloaded when it already exists', async () => {
|
|
const { v } = vaultWithStore();
|
|
(v.fileExists as jest.Mock).mockReturnValue(true);
|
|
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', cover_url: 'https://x/cover.jpg', captured_at: '2026-06-28T00:00:00Z' } as ClipPayload, manual, v);
|
|
expect(v.createBinary).not.toHaveBeenCalledWith('Assets/Great Videos/abc123.webp', expect.anything());
|
|
});
|
|
|
|
test('screenshot folds into the existing video note; otherwise standalone', async () => {
|
|
const { v, store } = vaultWithStore();
|
|
const url = 'https://www.youtube.com/watch?v=abc123';
|
|
await routeClip(hookPayload, manual, v); // creates the video note
|
|
await routeClip({ mode: 'screenshot', images: ['Zg=='], url, title: 'Bee' }, manual, v);
|
|
expect(Object.keys(store).length).toBe(1); // merged, not a new note
|
|
const note = store[Object.keys(store)[0]];
|
|
expect(note).toContain('## 📸 截图');
|
|
expect(note).toContain('dimensions: [内容, 截图]');
|
|
|
|
// a screenshot on an un-studied plain webpage stays standalone
|
|
await routeClip({ mode: 'screenshot', images: ['Zg=='], url: 'https://example.com/random', title: 'Rand' }, manual, v);
|
|
expect(Object.keys(store).length).toBe(2);
|
|
});
|
|
|
|
test('two different videos with the same author+title get distinct filenames', async () => {
|
|
const { v, store } = vaultWithStore();
|
|
const base = { mode: 'hook' as const, frames: ['Zg=='], video_title: 'Same Title', channel: 'Ch', captured_at: '2026-06-28T00:00:00Z' };
|
|
await routeClip({ ...base, url: 'https://www.youtube.com/watch?v=vid1' } as ClipPayload, manual, v);
|
|
await routeClip({ ...base, url: 'https://www.youtube.com/watch?v=vid2' } as ClipPayload, manual, v);
|
|
expect(Object.keys(store).length).toBe(2); // no overwrite / no crash
|
|
});
|
|
|
|
test('screenshot FIRST on a video page anchors the note (any order)', async () => {
|
|
const { v, store } = vaultWithStore();
|
|
const url = 'https://www.youtube.com/watch?v=zzz999';
|
|
await routeClip({ mode: 'screenshot', images: ['Zg=='], url, title: 'Vid - YouTube' }, manual, v);
|
|
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Vid', url, captured_at: '2026-06-28T00:00:00Z' }, manual, v);
|
|
expect(Object.keys(store).length).toBe(1); // screenshot anchored it, hook merged in
|
|
const note = store[Object.keys(store)[0]];
|
|
expect(note).toContain('## 📸 截图');
|
|
expect(note).toContain('## 🎬 内容');
|
|
expect(note.indexOf('## 🎬 内容')).toBeLessThan(note.indexOf('## 📸 截图'));
|
|
});
|
|
|
|
describe('English output', () => {
|
|
beforeEach(() => setLanguage('en'));
|
|
|
|
test('re-capture notice is English and names the section label', async () => {
|
|
const { v } = vaultWithStore();
|
|
await routeClip(hookPayload, manual, v);
|
|
const r = await routeClip(hookPayload, manual, v);
|
|
expect(r.notice).toContain('"Content" already exists');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── built-in SOP fallback ─────────────────────────────────────────────────────
|
|
|
|
describe('routeClip — built-in SOP fallback', () => {
|
|
const payload: ClipPayload = {
|
|
mode: 'thumbnail',
|
|
platform: 'youtube',
|
|
video_id: 'abc123',
|
|
video_url: 'https://www.youtube.com/watch?v=abc123',
|
|
thumbnail_url: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
|
|
title: 'How to Get Rich on Easy Mode',
|
|
channel: 'Ali Abdaal',
|
|
channel_handle: '@aliabdaal',
|
|
views: '27.8万',
|
|
captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
|
|
test('empty sopPath uses the provided built-in SOP content', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const rules = { ...clipRules, thumbnail: { ...thumbnailClipRule, sopPath: '' } };
|
|
await routeClip(payload, rules, vaultOps, { thumbnail: '# Built-in cover SOP\ncheck contrast' });
|
|
const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
|
expect(noteContent).toContain('# Built-in cover SOP');
|
|
expect(vaultOps.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('a configured sopPath wins over the built-in', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
await routeClip(payload, clipRules, vaultOps, { thumbnail: '# Built-in cover SOP' });
|
|
const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
|
expect(noteContent).toContain('# SOP');
|
|
expect(noteContent).toContain('Analyze this.');
|
|
expect(noteContent).not.toContain('# Built-in cover SOP');
|
|
});
|
|
|
|
test('empty sopPath and no built-in stays material-only', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const rules = { ...clipRules, thumbnail: { ...thumbnailClipRule, sopPath: '' } };
|
|
await routeClip(payload, rules, vaultOps, {});
|
|
const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
|
expect(noteContent).not.toContain('Built-in');
|
|
expect(vaultOps.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('a broken custom path falls back to the built-in instead of failing silent', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
(vaultOps.readFileSync as jest.Mock).mockImplementation(() => { throw new Error('missing'); });
|
|
await routeClip(payload, clipRules, vaultOps, { thumbnail: '# Built-in cover SOP' });
|
|
const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
|
expect(noteContent).toContain('# Built-in cover SOP');
|
|
});
|
|
});
|
|
|
|
// ── injection hardening ───────────────────────────────────────────────────────
|
|
|
|
describe('routeClip — untrusted title hardening', () => {
|
|
test('standalone screenshot note title cannot open a code fence', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const payload: ClipPayload = {
|
|
mode: 'screenshot', images: [Buffer.from('img').toString('base64')],
|
|
url: 'https://example.com/page', title: 'T\n```dataviewjs\nboom',
|
|
};
|
|
await routeClip(payload, clipRules, vaultOps);
|
|
const [, content] = (vaultOps.create as jest.Mock).mock.calls[0];
|
|
expect(content).not.toContain('```dataviewjs');
|
|
});
|
|
});
|
|
|
|
// ── concurrent clips ──────────────────────────────────────────────────────────
|
|
|
|
describe('routeClip — concurrent clips for the same video', () => {
|
|
function makeStoreVaultOps() {
|
|
const files = new Map<string, string>();
|
|
const delay = () => new Promise((r) => setTimeout(r, 10));
|
|
const ops = makeVaultOps();
|
|
(ops.listMarkdownFiles as jest.Mock).mockImplementation(() => Array.from(files.keys()));
|
|
(ops.read as jest.Mock).mockImplementation(async (p: string) => { await delay(); return files.get(p) ?? ''; });
|
|
(ops.create as jest.Mock).mockImplementation(async (p: string, c: string) => { files.set(p, c); });
|
|
(ops.modify as jest.Mock).mockImplementation(async (p: string, c: string) => { await delay(); files.set(p, c); });
|
|
return { ops, files };
|
|
}
|
|
|
|
const existing = [
|
|
`---`, `type: video`, `video_id: "abc123"`, `dimensions: [封面标题]`, `---`,
|
|
``, `# My Video`, ``, `## 🖼️ 封面标题`, `分析内容`,
|
|
].join('\n');
|
|
|
|
const hookPayload: ClipPayload = {
|
|
mode: 'hook', frames: [Buffer.from('f1').toString('base64')], video_title: 'My Video',
|
|
url: 'https://www.youtube.com/watch?v=abc123', captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
const kfPayload: ClipPayload = {
|
|
mode: 'keyframe', frames: [Buffer.from('f2').toString('base64')], video_title: 'My Video',
|
|
url: 'https://www.youtube.com/watch?v=abc123', time_range: { start: 10, end: 20 },
|
|
captured_at: '2026-05-31T00:00:00Z',
|
|
};
|
|
const rules = { ...clipRules, hook: { ...hookClipRule, sopPath: '' }, keyframe: { ...keyframeClipRule, sopPath: '' } };
|
|
|
|
test('two clips fired together both land when serialized through the queue', async () => {
|
|
const { ops, files } = makeStoreVaultOps();
|
|
files.set('Content Creation/Great Videos/note.md', existing);
|
|
const enqueue = makeSerialQueue();
|
|
await Promise.all([
|
|
enqueue(() => routeClip(hookPayload, rules, ops)),
|
|
enqueue(() => routeClip(kfPayload, rules, ops)),
|
|
]);
|
|
const note = files.get('Content Creation/Great Videos/note.md')!;
|
|
expect(note).toContain('## 🎬 内容');
|
|
expect(note).toContain('## ✨ 动效');
|
|
});
|
|
|
|
test('two clips for the same NEW video create one note, not a create collision', async () => {
|
|
const { ops, files } = makeStoreVaultOps();
|
|
const enqueue = makeSerialQueue();
|
|
await Promise.all([
|
|
enqueue(() => routeClip(hookPayload, rules, ops)),
|
|
enqueue(() => routeClip(kfPayload, rules, ops)),
|
|
]);
|
|
const notes = Array.from(files.keys()).filter((p) => p.endsWith('.md'));
|
|
expect(notes).toHaveLength(1);
|
|
const note = files.get(notes[0])!;
|
|
expect(note).toContain('## 🎬 内容');
|
|
expect(note).toContain('## ✨ 动效');
|
|
});
|
|
|
|
test('a failing clip does not block the next one in the queue', async () => {
|
|
const { ops, files } = makeStoreVaultOps();
|
|
files.set('Content Creation/Great Videos/note.md', existing);
|
|
const enqueue = makeSerialQueue();
|
|
const bad = enqueue(() => Promise.reject(new Error('boom')));
|
|
const good = enqueue(() => routeClip(kfPayload, rules, ops));
|
|
await expect(bad).rejects.toThrow('boom');
|
|
await good;
|
|
expect(files.get('Content Creation/Great Videos/note.md')!).toContain('## ✨ 动效');
|
|
});
|
|
});
|
|
|
|
// ── image binary integrity ────────────────────────────────────────────────────
|
|
// Small Buffer.from() results are views into Node's shared 8 KB pool; writing
|
|
// `.buffer` raw would persist the whole pool (with unrelated memory) as the image.
|
|
|
|
describe('routeClip — image binary integrity', () => {
|
|
test('screenshot image writes exactly its own bytes, not the shared pool', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const payload: ClipPayload = {
|
|
mode: 'screenshot', images: [Buffer.from('pixels').toString('base64')],
|
|
url: 'https://x.com', title: 'S',
|
|
};
|
|
await routeClip(payload, clipRules, vaultOps);
|
|
const ab = (vaultOps.createBinary as jest.Mock).mock.calls[0][1] as ArrayBuffer;
|
|
expect(ab.byteLength).toBe(6);
|
|
expect(Buffer.from(ab).toString()).toBe('pixels');
|
|
});
|
|
|
|
test('hook frame writes exactly its own bytes, not the shared pool', async () => {
|
|
const vaultOps = makeVaultOps();
|
|
const payload: ClipPayload = {
|
|
mode: 'hook', frames: [Buffer.from('f1').toString('base64')], video_title: 'T',
|
|
url: 'https://youtube.com/watch?v=abc', captured_at: '2026-05-30T18:00:00Z',
|
|
};
|
|
await routeClip(payload, clipRules, vaultOps);
|
|
const ab = (vaultOps.createBinary as jest.Mock).mock.calls[0][1] as ArrayBuffer;
|
|
expect(ab.byteLength).toBe(2);
|
|
expect(Buffer.from(ab).toString()).toBe('f1');
|
|
});
|
|
});
|