Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858// src/plugin/__tests__/workspace-compat-classes.test.ts/** * Tests for workspace compat-surface alignment classes (Chainlink #341). * * These are stub classes exported from obsidian-compat for API compatibility. * Plugins may: * - `import { WorkspaceSidedock } from 'obsidian'` * - `leaf.parent instanceof WorkspaceSidedock` * * The classes are constructible so instanceof doesn't throw, but any behavior * methods throw with clear messages since Reams uses React-based layout * rather than Obsidian's workspace tree. */import { describe, it, expect, afterEach, vi } from 'vitest'import { WorkspaceItem, WorkspaceParent, WorkspaceSplit, WorkspaceFloating, WorkspaceSidedock, WorkspaceContainer, WorkspaceRoot, WorkspaceWindow, WorkspaceTabs, WorkspaceMobileDrawer, MarkdownPreviewView, MarkdownPreviewRenderer, MarkdownRenderChild, MarkdownRenderer, Events,} from '../obsidian-compat/index'import { clearAllPostProcessors, getPostProcessorCount,} from '../markdown-renderer-impl'import { Plugin } from '../plugin'import { App } from '../app'import { Vault } from '../vault'import { Workspace } from '../workspace'import { FileIndex } from '../../services/file-index'import type { PluginManifest } from '../types'import type { DocumentManager } from '../../services/document-manager'
describe('WorkspaceItem (abstract base class)', () => { // WorkspaceItem is abstract per upstream contract. Tests use WorkspaceSplit // (first concrete class in the hierarchy) to test inherited methods. // // Note: TypeScript's 'abstract' is compile-time only. At runtime, JS allows // instantiation. The type system prevents this in strict TS code; runtime // tests focus on behavior rather than abstract enforcement.
it('has getRoot() that throws with helpful message (via concrete subclass)', () => { const item = new WorkspaceSplit() expect(() => item.getRoot()).toThrow(/not implemented in Reams/) expect(() => item.getRoot()).toThrow(/WorkspaceItem/) })
it('has getContainer() that throws with helpful message (via concrete subclass)', () => { const item = new WorkspaceSplit() expect(() => item.getContainer()).toThrow(/not implemented in Reams/) })
it('error messages reference docs/PLUGIN-AUTHOR-GUIDE.md (via concrete subclass)', () => { const item = new WorkspaceSplit() expect(() => item.getRoot()).toThrow(/PLUGIN-AUTHOR-GUIDE\.md/) })
it('extends Events (via concrete subclass)', () => { const item = new WorkspaceSplit() expect(item instanceof Events).toBe(true) // Verify Events methods exist expect(typeof item.on).toBe('function') expect(typeof item.off).toBe('function') expect(typeof item.trigger).toBe('function') })
it('has abstract parent property implemented in concrete subclass', () => { const item = new WorkspaceSplit() // WorkspaceSplit declares `parent!: WorkspaceParent` (definite assignment) // The property exists but is undefined until assigned expect('parent' in item).toBe(true) })})
describe('WorkspaceParent (abstract, extends WorkspaceItem)', () => { // WorkspaceParent is abstract per upstream contract. Tests use WorkspaceSplit // (first concrete class in the hierarchy) to test inherited behavior. // // Upstream contract (since 0.9.7): // - abstract class // - extends WorkspaceItem // - NO public members (empty class body) // // Note: TypeScript's 'abstract' is compile-time only. At runtime, JS allows // instantiation. The type system prevents this in strict TS code; runtime // tests focus on behavior rather than abstract enforcement.
it('extends WorkspaceItem (via concrete subclass)', () => { const split = new WorkspaceSplit() expect(split instanceof WorkspaceParent).toBe(true) expect(split instanceof WorkspaceItem).toBe(true) })
// NOTE: children and getChildren() are NOT part of upstream Obsidian's // public API for WorkspaceParent. The upstream class is empty.})
describe('WorkspaceSplit (extends WorkspaceParent)', () => { it('is constructible', () => { expect(() => new WorkspaceSplit()).not.toThrow() })
it('extends WorkspaceParent', () => { const split = new WorkspaceSplit() expect(split instanceof WorkspaceParent).toBe(true) expect(split instanceof WorkspaceItem).toBe(true) })
it('is distinct from WorkspaceSidedock and WorkspaceContainer', () => { const split = new WorkspaceSplit() expect(split instanceof WorkspaceSidedock).toBe(false) expect(split instanceof WorkspaceContainer).toBe(false) })
it('has parent property (upstream contract since 0.9.7)', () => { const split = new WorkspaceSplit() expect('parent' in split).toBe(true) })})
describe('WorkspaceFloating (extends WorkspaceParent)', () => { // Upstream contract (since 0.15.2): // - extends WorkspaceParent // - parent: WorkspaceParent
it('is constructible', () => { expect(() => new WorkspaceFloating()).not.toThrow() })
it('extends WorkspaceParent', () => { const floating = new WorkspaceFloating() expect(floating instanceof WorkspaceParent).toBe(true) expect(floating instanceof WorkspaceItem).toBe(true) })
it('is distinct from WorkspaceSplit and WorkspaceSidedock', () => { const floating = new WorkspaceFloating() expect(floating instanceof WorkspaceSplit).toBe(false) expect(floating instanceof WorkspaceSidedock).toBe(false) expect(floating instanceof WorkspaceContainer).toBe(false) })
it('has parent property (upstream contract since 0.15.2)', () => { const floating = new WorkspaceFloating() expect('parent' in floating).toBe(true) })})
describe('WorkspaceSidedock (extends WorkspaceParent)', () => { it('is constructible', () => { expect(() => new WorkspaceSidedock()).not.toThrow() })
it('extends WorkspaceParent', () => { const sidedock = new WorkspaceSidedock() expect(sidedock instanceof WorkspaceParent).toBe(true) expect(sidedock instanceof WorkspaceItem).toBe(true) })
it('has collapse() that throws with helpful message', () => { const sidedock = new WorkspaceSidedock() expect(() => sidedock.collapse()).toThrow(/not implemented in Reams/) expect(() => sidedock.collapse()).toThrow(/WorkspaceSidedock/) })
it('has expand() that throws with helpful message', () => { const sidedock = new WorkspaceSidedock() expect(() => sidedock.expand()).toThrow(/not implemented in Reams/) })
it('has toggle() that throws with helpful message', () => { const sidedock = new WorkspaceSidedock() expect(() => sidedock.toggle()).toThrow(/not implemented in Reams/) })
it('error messages all share the same template', () => { const sidedock = new WorkspaceSidedock() // All three methods produce errors with the same trailing docs link. expect(() => sidedock.collapse()).toThrow(/PLUGIN-AUTHOR-GUIDE\.md#workspace-compat/) expect(() => sidedock.expand()).toThrow(/PLUGIN-AUTHOR-GUIDE\.md#workspace-compat/) expect(() => sidedock.toggle()).toThrow(/PLUGIN-AUTHOR-GUIDE\.md#workspace-compat/) })})
describe('WorkspaceContainer (abstract, extends WorkspaceSplit)', () => { // WorkspaceContainer is abstract in upstream (since 0.15.4) with abstract // `win: Window` and `doc: Document` properties. It extends WorkspaceSplit. // We cannot instantiate it directly, but instanceof checks still work.
it('is abstract (cannot be instantiated directly)', () => { // TypeScript prevents `new WorkspaceContainer()` at compile time. // At runtime, abstract classes in TS still produce a constructor, but // the contract states it's abstract. We verify the export exists. expect(WorkspaceContainer).toBeDefined() expect(typeof WorkspaceContainer).toBe('function') })
it('extends WorkspaceSplit (which extends WorkspaceParent)', () => { // Use prototype chain check since we can't instantiate abstract class expect(WorkspaceContainer.prototype instanceof WorkspaceSplit).toBe(true) expect(WorkspaceContainer.prototype instanceof WorkspaceParent).toBe(true) expect(WorkspaceContainer.prototype instanceof WorkspaceItem).toBe(true) })})
describe('WorkspaceRoot (extends WorkspaceContainer)', () => { // Upstream: WorkspaceRoot extends WorkspaceContainer and provides // concrete win/doc properties for the main window container.
it('is constructible', () => { expect(() => new WorkspaceRoot()).not.toThrow() })
it('extends WorkspaceContainer (and full hierarchy)', () => { const root = new WorkspaceRoot() expect(root instanceof WorkspaceContainer).toBe(true) expect(root instanceof WorkspaceSplit).toBe(true) expect(root instanceof WorkspaceParent).toBe(true) expect(root instanceof WorkspaceItem).toBe(true) })
it('has win property defaulting to global window', () => { const root = new WorkspaceRoot() expect(root.win).toBe(window) })
it('has doc property defaulting to global document', () => { const root = new WorkspaceRoot() expect(root.doc).toBe(document) })
it('allows win/doc to be reassigned', () => { const root = new WorkspaceRoot() const mockWin = { name: 'mock-window' } as unknown as Window const mockDoc = { title: 'mock-doc' } as unknown as Document root.win = mockWin root.doc = mockDoc expect(root.win).toBe(mockWin) expect(root.doc).toBe(mockDoc) })})
describe('WorkspaceWindow (extends WorkspaceContainer)', () => { // Upstream (since 0.15.4): WorkspaceWindow extends WorkspaceContainer // and provides concrete win/doc properties for popout browser windows.
it('is constructible', () => { expect(() => new WorkspaceWindow()).not.toThrow() })
it('extends WorkspaceContainer (and full hierarchy)', () => { const win = new WorkspaceWindow() expect(win instanceof WorkspaceContainer).toBe(true) expect(win instanceof WorkspaceSplit).toBe(true) expect(win instanceof WorkspaceParent).toBe(true) expect(win instanceof WorkspaceItem).toBe(true) })
it('has win property defaulting to global window', () => { const workspaceWin = new WorkspaceWindow() expect(workspaceWin.win).toBe(window) })
it('has doc property defaulting to global document', () => { const workspaceWin = new WorkspaceWindow() expect(workspaceWin.doc).toBe(document) })
it('allows win/doc to be reassigned (for popout window setup)', () => { // In a popout scenario, Reams would create a WorkspaceWindow and then // assign win/doc to the popout's Window and Document objects. const workspaceWin = new WorkspaceWindow() const mockWin = { name: 'mock-window' } as unknown as Window const mockDoc = { title: 'mock-doc' } as unknown as Document workspaceWin.win = mockWin workspaceWin.doc = mockDoc expect(workspaceWin.win).toBe(mockWin) expect(workspaceWin.doc).toBe(mockDoc) })})
describe('WorkspaceTabs (extends WorkspaceParent)', () => { // Upstream: WorkspaceTabs extends WorkspaceParent directly, NOT WorkspaceContainer. // It has a `parent: WorkspaceSplit` property.
it('is constructible', () => { expect(() => new WorkspaceTabs()).not.toThrow() })
it('extends WorkspaceParent (NOT WorkspaceContainer)', () => { const tabs = new WorkspaceTabs() // WorkspaceTabs extends WorkspaceParent directly in upstream expect(tabs instanceof WorkspaceParent).toBe(true) expect(tabs instanceof WorkspaceItem).toBe(true) // It does NOT extend WorkspaceContainer expect(tabs instanceof WorkspaceContainer).toBe(false) })})
describe('WorkspaceMobileDrawer (extends WorkspaceParent)', () => { it('is constructible', () => { expect(() => new WorkspaceMobileDrawer()).not.toThrow() })
it('extends WorkspaceParent (not WorkspaceSidedock)', () => { const drawer = new WorkspaceMobileDrawer() // Upstream contract: extends WorkspaceParent directly, NOT WorkspaceSidedock expect(drawer instanceof WorkspaceSidedock).toBe(false) expect(drawer instanceof WorkspaceParent).toBe(true) expect(drawer instanceof WorkspaceItem).toBe(true) })
it('has required properties', () => { const drawer = new WorkspaceMobileDrawer() // collapsed property with default value expect(drawer.collapsed).toBe(false) // parent property (uninitialized by default) expect('parent' in drawer).toBe(true) })
it('has collapse/expand/toggle methods that throw stub errors', () => { const drawer = new WorkspaceMobileDrawer() expect(() => drawer.collapse()).toThrow(/not implemented in Reams/) expect(() => drawer.expand()).toThrow(/not implemented in Reams/) expect(() => drawer.toggle()).toThrow(/not implemented in Reams/) })})
describe('MarkdownPreviewView', () => { it('is constructible (extends MarkdownRenderChild)', () => { const containerEl = document.createElement('div') expect(() => new MarkdownPreviewView(containerEl)).not.toThrow() })
it('extends MarkdownRenderChild via MarkdownRenderer (matches Obsidian hierarchy)', () => { const containerEl = document.createElement('div') const view = new MarkdownPreviewView(containerEl) // MarkdownPreviewView extends MarkdownRenderer extends MarkdownRenderChild expect(view instanceof MarkdownRenderer).toBe(true) expect(view instanceof MarkdownRenderChild).toBe(true) })
// NOTE: Previous tests for `(containerEl, file, app)` constructor have been // removed. Upstream Obsidian's MarkdownPreviewView inherits its constructor // from MarkdownRenderChild, which takes only `containerEl`. The extra params // were Reams-specific extensions that diverged from the contract. // // The `file` getter returns the internally-stored file (null by default). // The `app` property is inherited from MarkdownRenderer (also null by default // in the stub). These are contract-compliant behaviors.
it('defaults file to null (no file setter in upstream API)', () => { const containerEl = document.createElement('div') const view = new MarkdownPreviewView(containerEl) // Upstream contract declares `get file(): TFile` but doesn't provide // a setter or constructor param. Our stub initializes to null. expect(view.file).toBeNull() })
it('defaults app to null (inherited from MarkdownRenderer)', () => { const containerEl = document.createElement('div') const view = new MarkdownPreviewView(containerEl) // app is inherited from MarkdownRenderer, which initializes it to // null in the stub. Real Obsidian sets this internally. expect(view.app).toBeNull() })
// NOTE: getViewType(), getMode(), getDisplayText() are NOT part of the // upstream Obsidian MarkdownPreviewView API. They were Reams-specific // conveniences that have been removed to align with the contract. // Tests for those methods have been intentionally deleted.
it('has rerender() that throws with helpful message', () => { const containerEl = document.createElement('div') const view = new MarkdownPreviewView(containerEl) expect(() => view.rerender()).toThrow(/not implemented in Reams/) expect(() => view.rerender()).toThrow(/MarkdownPreviewView/) })
it('has applyScroll() that throws with the uniform error template', () => { const containerEl = document.createElement('div') const view = new MarkdownPreviewView(containerEl) expect(() => view.applyScroll(0)).toThrow(/not implemented in Reams/) expect(() => view.applyScroll(0)).toThrow(/PLUGIN-AUTHOR-GUIDE\.md/) })
it('has getScroll() that throws with the uniform error template', () => { const containerEl = document.createElement('div') const view = new MarkdownPreviewView(containerEl) expect(() => view.getScroll()).toThrow(/not implemented in Reams/) })
it('has clear() that throws with the uniform error template', () => { const containerEl = document.createElement('div') const view = new MarkdownPreviewView(containerEl) expect(() => view.clear()).toThrow(/not implemented in Reams/) })
it('has set() with the Obsidian (data, clear) signature that throws', () => { const containerEl = document.createElement('div') const view = new MarkdownPreviewView(containerEl) expect(() => view.set('# hello', true)).toThrow(/not implemented in Reams/) })
it('has get() that throws with the uniform error template', () => { const containerEl = document.createElement('div') const view = new MarkdownPreviewView(containerEl) expect(() => view.get()).toThrow(/not implemented in Reams/) })})
describe('MarkdownPreviewRenderer', () => { // The MarkdownPreviewRenderer surface is the Obsidian-documented one: // static registerPostProcessor / unregisterPostProcessor — NOT renderMarkdown. // (Round-2 adversary review caught the earlier renderMarkdown stub as // a fabricated API.) // // NOTE: registerPostProcessor / unregisterPostProcessor share the global // post-processor registry with Plugin.registerMarkdownPostProcessor. // unregisterPostProcessor removes by function-reference identity across ALL // pluginIds — see the "cross-path unregister" test below.
afterEach(() => { // Clean up any post-processors registered by tests clearAllPostProcessors() })
it('is a class with static methods (matches Obsidian)', () => { expect(typeof MarkdownPreviewRenderer).toBe('function') expect(typeof MarkdownPreviewRenderer.registerPostProcessor).toBe('function') expect(typeof MarkdownPreviewRenderer.unregisterPostProcessor).toBe('function') })
it('does NOT have a renderMarkdown method (that\'s on MarkdownRenderer, not this class)', () => { expect((MarkdownPreviewRenderer as { renderMarkdown?: unknown }).renderMarkdown).toBeUndefined() })
it('registerPostProcessor adds a processor to the global registry', () => { const before = getPostProcessorCount() const processor = (_el: HTMLElement) => { /* no-op */ } MarkdownPreviewRenderer.registerPostProcessor(processor) expect(getPostProcessorCount()).toBe(before + 1) })
it('registerPostProcessor accepts a sortOrder parameter', () => { const before = getPostProcessorCount() const processor = (_el: HTMLElement) => { /* no-op */ } MarkdownPreviewRenderer.registerPostProcessor(processor, 100) expect(getPostProcessorCount()).toBe(before + 1) })
it('unregisterPostProcessor removes a previously registered processor', () => { const processor = (_el: HTMLElement) => { /* no-op */ } const before = getPostProcessorCount() MarkdownPreviewRenderer.registerPostProcessor(processor) expect(getPostProcessorCount()).toBe(before + 1) MarkdownPreviewRenderer.unregisterPostProcessor(processor) expect(getPostProcessorCount()).toBe(before) })
it('unregisterPostProcessor removes all matching entries', () => { const processor = (_el: HTMLElement) => { /* no-op */ } const before = getPostProcessorCount() MarkdownPreviewRenderer.registerPostProcessor(processor, 0) MarkdownPreviewRenderer.registerPostProcessor(processor, 10) expect(getPostProcessorCount()).toBe(before + 2) MarkdownPreviewRenderer.unregisterPostProcessor(processor) expect(getPostProcessorCount()).toBe(before) })
it('unregisterPostProcessor of an unknown function is a no-op', () => { const before = getPostProcessorCount() const unknown = (_el: HTMLElement) => { /* never registered */ } expect(() => MarkdownPreviewRenderer.unregisterPostProcessor(unknown)).not.toThrow() expect(getPostProcessorCount()).toBe(before) })
describe('createCodeBlockPostProcessor', () => { it('is a static method', () => { expect(typeof MarkdownPreviewRenderer.createCodeBlockPostProcessor).toBe('function') })
it('returns a MarkdownPostProcessor function', () => { const handler = vi.fn() const processor = MarkdownPreviewRenderer.createCodeBlockPostProcessor('javascript', handler) expect(typeof processor).toBe('function') })
it('processes code blocks with matching language class', () => { const handler = vi.fn() const processor = MarkdownPreviewRenderer.createCodeBlockPostProcessor('javascript', handler)
// Create a mock element with a code block const el = document.createElement('div') el.innerHTML = '<pre><code class="language-javascript">const x = 1;</code></pre>'
const ctx = { docId: 'test-doc', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: () => null, }
processor(el, ctx)
expect(handler).toHaveBeenCalledTimes(1) expect(handler).toHaveBeenCalledWith( 'const x = 1;', expect.any(HTMLElement), // The <pre> element ctx, ) })
it('ignores code blocks with different language class', () => { const handler = vi.fn() const processor = MarkdownPreviewRenderer.createCodeBlockPostProcessor('javascript', handler)
const el = document.createElement('div') el.innerHTML = '<pre><code class="language-python">x = 1</code></pre>'
const ctx = { docId: 'test-doc', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: () => null, }
processor(el, ctx)
expect(handler).not.toHaveBeenCalled() })
it('processes multiple matching code blocks', () => { const handler = vi.fn() const processor = MarkdownPreviewRenderer.createCodeBlockPostProcessor('javascript', handler)
const el = document.createElement('div') el.innerHTML = ` <pre><code class="language-javascript">const a = 1;</code></pre> <pre><code class="language-javascript">const b = 2;</code></pre> `
const ctx = { docId: 'test-doc', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: () => null, }
processor(el, ctx)
expect(handler).toHaveBeenCalledTimes(2) expect(handler).toHaveBeenNthCalledWith(1, 'const a = 1;', expect.any(HTMLElement), ctx) expect(handler).toHaveBeenNthCalledWith(2, 'const b = 2;', expect.any(HTMLElement), ctx) })
it('handles async handlers without blocking', async () => { let resolved = false const handler = vi.fn().mockImplementation(async () => { await new Promise((r) => setTimeout(r, 10)) resolved = true }) const processor = MarkdownPreviewRenderer.createCodeBlockPostProcessor('javascript', handler)
const el = document.createElement('div') el.innerHTML = '<pre><code class="language-javascript">const x = 1;</code></pre>'
const ctx = { docId: 'test-doc', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: () => null, }
// Processor returns synchronously (fire-and-forget) processor(el, ctx) expect(handler).toHaveBeenCalledTimes(1)
// Handler hasn't resolved yet expect(resolved).toBe(false)
// Wait for the async handler to complete await new Promise((r) => setTimeout(r, 20)) expect(resolved).toBe(true) })
it('handles empty source in code blocks', () => { const handler = vi.fn() const processor = MarkdownPreviewRenderer.createCodeBlockPostProcessor('javascript', handler)
const el = document.createElement('div') el.innerHTML = '<pre><code class="language-javascript"></code></pre>'
const ctx = { docId: 'test-doc', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: () => null, }
processor(el, ctx)
expect(handler).toHaveBeenCalledWith('', expect.any(HTMLElement), ctx) })
it('escapes CSS special characters in language to prevent selector injection', () => { const handler = vi.fn() // Language with CSS metacharacters that could alter selector meaning const processor = MarkdownPreviewRenderer.createCodeBlockPostProcessor( 'foo.bar', handler, )
const el = document.createElement('div') // Only the exact class "language-foo.bar" should match, not "language-foo" + ".bar" el.innerHTML = '<pre><code class="language-foo.bar">source</code></pre>'
const ctx = { docId: 'test-doc', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: () => null, }
processor(el, ctx)
expect(handler).toHaveBeenCalledTimes(1) expect(handler).toHaveBeenCalledWith('source', expect.any(HTMLElement), ctx) })
it('does not match unintended elements via selector injection', () => { const handler = vi.fn() // Attempt to inject a selector that would match other classes const processor = MarkdownPreviewRenderer.createCodeBlockPostProcessor( 'x"],.injected[data-x="', handler, )
const el = document.createElement('div') el.innerHTML = ` <pre><code class="language-x">real code</code></pre> <div class="injected" data-x="">should not match</div> `
const ctx = { docId: 'test-doc', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: () => null, }
processor(el, ctx)
// The injected selector should not match - CSS.escape prevents injection expect(handler).not.toHaveBeenCalled() })
it('catches synchronous handler throws without breaking', () => { const handler = vi.fn().mockImplementation(() => { throw new Error('sync error') }) const processor = MarkdownPreviewRenderer.createCodeBlockPostProcessor('javascript', handler)
const el = document.createElement('div') el.innerHTML = ` <pre><code class="language-javascript">const a = 1;</code></pre> <pre><code class="language-javascript">const b = 2;</code></pre> `
const ctx = { docId: 'test-doc', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: () => null, }
// Should not throw - errors are caught internally expect(() => processor(el, ctx)).not.toThrow()
// Both code blocks should be processed even though the first throws expect(handler).toHaveBeenCalledTimes(2) })
it('language matching is case-sensitive (CSS class behavior)', () => { const handler = vi.fn() const processor = MarkdownPreviewRenderer.createCodeBlockPostProcessor('JavaScript', handler)
const el = document.createElement('div') // lowercase class should NOT match uppercase language el.innerHTML = '<pre><code class="language-javascript">code</code></pre>'
const ctx = { docId: 'test-doc', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: () => null, }
processor(el, ctx)
expect(handler).not.toHaveBeenCalled() })
it('catches async handler rejections without breaking subsequent processing', async () => { const handler = vi.fn() .mockRejectedValueOnce(new Error('async error')) .mockResolvedValueOnce(undefined)
const processor = MarkdownPreviewRenderer.createCodeBlockPostProcessor('javascript', handler)
const el = document.createElement('div') el.innerHTML = ` <pre><code class="language-javascript">const a = 1;</code></pre> <pre><code class="language-javascript">const b = 2;</code></pre> `
const ctx = { docId: 'test-doc', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: () => null, }
// Should not throw - async rejections are caught internally processor(el, ctx)
// Both handlers should be called (fire-and-forget semantics) expect(handler).toHaveBeenCalledTimes(2)
// Wait for async handlers to settle await new Promise((r) => setTimeout(r, 10)) }) })
it('unregisterPostProcessor removes processors registered via Plugin.registerMarkdownPostProcessor (cross-path)', () => { // Verifies shared-registry behavior: both APIs use the same global registry, // so unregisterPostProcessor (by function-reference) works cross-path.
// Create minimal test infrastructure const createMockDocumentManager = (): DocumentManager => ({ createFile: vi.fn().mockReturnValue('automerge:file1'), createFolder: vi.fn().mockReturnValue('automerge:folder1'), readText: vi.fn(async (url: any) => ({ status: 'available' as const, documentId: url, heads: ['h0'], content: 'content' })), updateContent: vi.fn().mockResolvedValue(undefined), }) as unknown as DocumentManager
const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace)
const testManifest: PluginManifest = { id: 'cross-path-test-plugin', name: 'Cross Path Test Plugin', author: 'Test', version: '1.0.0', minAppVersion: '1.0.0', description: 'Test plugin for cross-path unregister semantics', }
class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load()
const before = getPostProcessorCount()
// Register via Plugin instance (stores with pluginId 'cross-path-test-plugin') const processor = (_el: HTMLElement) => { /* no-op */ } plugin.registerMarkdownPostProcessor(processor) expect(getPostProcessorCount()).toBe(before + 1)
// Unregister via MarkdownPreviewRenderer (removes by function identity, not pluginId) MarkdownPreviewRenderer.unregisterPostProcessor(processor) expect(getPostProcessorCount()).toBe(before)
// Clean up plugin.unload() })})
describe('instanceof checks work correctly', () => { it('plain objects are NOT instances of workspace classes (no throw)', () => { // Plugins doing `someValue instanceof WorkspaceSidedock` should get false, // not throw. The point is the class EXISTS so instanceof doesn't throw // "Right-hand side of 'instanceof' is not an object". const sidedock = new WorkspaceSidedock() const someOtherObject = { parent: null }
expect(sidedock instanceof WorkspaceSidedock).toBe(true) expect(someOtherObject instanceof WorkspaceSidedock).toBe(false) })
it('null/undefined leaf.parent does NOT throw on instanceof', () => { // Reams' WorkspaceLeaf does not assign `parent`. Plugins doing // `leaf.parent instanceof WorkspaceSidedock` get `false`, not a throw. const undefinedParent: unknown = undefined const nullParent: unknown = null expect(undefinedParent instanceof WorkspaceSidedock).toBe(false) expect(nullParent instanceof WorkspaceSidedock).toBe(false) })})
describe('CJS shim parity (plugin-api-global.ts)', () => { // The CJS shim must export the SAME class identities as the ESM compat // layer. Without this, plugins loaded via blob URLs would either get // `undefined` or get a different class identity (so instanceof against // the host's class fails). it('exports identical class references for all workspace compat classes', async () => { const { installPluginApiGlobal } = await import('../loader/plugin-api-global') installPluginApiGlobal()
const api = (window as { __REAMS_PLUGIN_API__?: Record<string, unknown> }) .__REAMS_PLUGIN_API__ expect(api).toBeDefined()
// Identity check for ALL classes — not just toBeDefined. A copy-paste // bug that aliased one class to another would pass toBeDefined and // silently break instanceof checks against the host's class. const expected: Record<string, unknown> = { WorkspaceItem, WorkspaceParent, WorkspaceSplit, WorkspaceFloating, WorkspaceSidedock, WorkspaceContainer, WorkspaceRoot, WorkspaceWindow, WorkspaceTabs, WorkspaceMobileDrawer, MarkdownPreviewView, MarkdownPreviewRenderer, } for (const [name, cls] of Object.entries(expected)) { expect(api?.[name], `${name} on CJS shim`).toBe(cls) } })})