Implements Notion-style sync blocks: a Tiptap node whose content is shared across N pages. Editing via the Hocuspocus overlay propagates to all instances via Yjs collab + SSE broadcast (EventEmitter2 bus). Server: DB migration, NestJS module (CRUD + BFS cycle detection + broadcast), Hocuspocus persistence extension extended for sync-block-* docs, 3 new RBAC permissions (sync_blocks:create/edit/delete), seeded to Admin/Editor/Member. Client: SyncBlockExtension (Tiptap node), SyncBlockNodeView (NodeView + Mantine Modal overlay + SSE hook), /sync-block slash command, service client. Tests: 32 server Jest + 18 client Vitest, all green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
102 lines
2.8 KiB
TypeScript
102 lines
2.8 KiB
TypeScript
/**
|
|
* Tests for html-extractor.ts helpers.
|
|
*
|
|
* DOMPurify is mocked to return input unchanged so we can test the surrounding
|
|
* logic without needing jsdom + DOMPurify integration.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock DOMPurify before importing the module under test.
|
|
vi.mock('dompurify', () => ({
|
|
default: {
|
|
sanitize: (html: string, _opts: unknown) => html,
|
|
},
|
|
}));
|
|
|
|
// Mock browser APIs used by html-extractor (getPageUrl uses querySelector).
|
|
const linkMock = { href: '' };
|
|
Object.defineProperty(global, 'document', {
|
|
value: {
|
|
title: 'Test Page Title',
|
|
querySelector: vi.fn().mockReturnValue(null),
|
|
createElement: (tag: string) => ({
|
|
innerHTML: '',
|
|
appendChild: function (node: any) {
|
|
this.innerHTML += node.innerHTML ?? '';
|
|
},
|
|
}),
|
|
},
|
|
writable: true,
|
|
});
|
|
|
|
Object.defineProperty(global, 'window', {
|
|
value: {
|
|
location: { href: 'https://example.com/path?q=1' },
|
|
getSelection: vi.fn(),
|
|
},
|
|
writable: true,
|
|
});
|
|
|
|
import {
|
|
sanitizeHtml,
|
|
getPageTitle,
|
|
getPageUrl,
|
|
extractSelection,
|
|
} from '../src/lib/html-extractor';
|
|
|
|
describe('sanitizeHtml', () => {
|
|
it('passes through safe HTML (mocked DOMPurify)', () => {
|
|
const safe = '<p>Hello <strong>world</strong></p>';
|
|
expect(sanitizeHtml(safe)).toBe(safe);
|
|
});
|
|
|
|
it('handles empty string', () => {
|
|
expect(sanitizeHtml('')).toBe('');
|
|
});
|
|
|
|
it('handles string with only whitespace', () => {
|
|
expect(sanitizeHtml(' ')).toBe(' ');
|
|
});
|
|
});
|
|
|
|
describe('getPageTitle', () => {
|
|
it('returns trimmed document.title', () => {
|
|
(global as any).document.title = ' My Article ';
|
|
expect(getPageTitle()).toBe('My Article');
|
|
});
|
|
|
|
it('returns empty string when document.title is blank', () => {
|
|
(global as any).document.title = ' ';
|
|
expect(getPageTitle()).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('getPageUrl', () => {
|
|
it('returns location.href when no canonical link', () => {
|
|
(global as any).document.querySelector = vi.fn().mockReturnValue(null);
|
|
expect(getPageUrl()).toBe('https://example.com/path?q=1');
|
|
});
|
|
|
|
it('returns canonical href when present', () => {
|
|
(global as any).document.querySelector = vi.fn().mockReturnValue({
|
|
href: 'https://example.com/canonical',
|
|
});
|
|
expect(getPageUrl()).toBe('https://example.com/canonical');
|
|
});
|
|
});
|
|
|
|
describe('extractSelection', () => {
|
|
it('returns empty string when selection is collapsed', () => {
|
|
(global as any).window.getSelection = vi.fn().mockReturnValue({
|
|
isCollapsed: true,
|
|
rangeCount: 0,
|
|
});
|
|
expect(extractSelection()).toBe('');
|
|
});
|
|
|
|
it('returns empty string when getSelection returns null', () => {
|
|
(global as any).window.getSelection = vi.fn().mockReturnValue(null);
|
|
expect(extractSelection()).toBe('');
|
|
});
|
|
});
|