Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329// src/plugin/commands/__tests__/command-registry-editor-context.test.ts//// #408 host-contract unit tests for `CommandRegistry` editor-context gating.//// Two layers://// 1. **Primitive-level tests** (the first describe block). Pass `null` /// non-null editor contexts directly to the registry to pin the// fundamental gating rule: editor-context commands require a non-null// context. These tests exercise the post-condition of the contract.//// 2. **Contract-path tests** (the second describe block). Drive the// context from a real `Workspace.activeEditor` and assert the registry// reports the right availability when the active leaf is a non-markdown// view vs. a markdown view. This exercises the actual path the host// contract describes — see `TypedViewHostContract` invariant 4.//// Adversary review S2: the previous file labelled itself "host-contract"// but only tested the primitive layer. Both layers matter.import { describe, it, expect, beforeEach, vi } from 'vitest'import { CommandRegistry } from '../command-registry'import { MarkdownView } from '../../views/markdown-view'import { makeAppForTest, provisionBinaryFile, provisionMarkdownFile, tinyPng,} from '../../__tests__/test-helpers'import type { App } from '../../app'
// `Notice` is constructed inside the registry's error-notification paths.// Mocking it avoids dragging the rest of the obsidian compat shim into a// pure-unit test.vi.mock('obsidian', () => ({ Notice: vi.fn(),}))
/** * Build the editor context the hotkey/palette path would build given the * current `workspace.activeEditor`. Mirrors the HotkeyManager wiring in * `App` so these tests exercise the production code path, not a fiction. */function buildEditorContext(app: App): { editor: unknown; view: unknown } | null { const editor = app.workspace.activeEditor return editor ? { editor: editor.editor, view: editor } : null}
describe('CommandRegistry editor-context behavior (#408)', () => { let registry: CommandRegistry
beforeEach(() => { registry = new CommandRegistry() })
describe('canExecuteCommand with null editorContext', () => { it('returns false for commands with editorCallback', () => { const cmd = registry.addCommand('test-plugin', { id: 'editor-cmd', name: 'Editor Command', editorCallback: vi.fn(), }) expect(registry.canExecuteCommand(cmd, null)).toBe(false) })
it('returns false for commands with editorCheckCallback', () => { const cmd = registry.addCommand('test-plugin', { id: 'editor-check-cmd', name: 'Editor Check Command', editorCheckCallback: vi.fn().mockReturnValue(true), }) expect(registry.canExecuteCommand(cmd, null)).toBe(false) })
it('returns true for commands with a simple callback', () => { const cmd = registry.addCommand('test-plugin', { id: 'simple-cmd', name: 'Simple Command', callback: vi.fn(), }) expect(registry.canExecuteCommand(cmd, null)).toBe(true) })
it('returns true for commands with a checkCallback that returns true', () => { const cmd = registry.addCommand('test-plugin', { id: 'check-cmd', name: 'Check Command', checkCallback: vi.fn().mockReturnValue(true), }) expect(registry.canExecuteCommand(cmd, null)).toBe(true) })
it('returns false for commands with a checkCallback that returns false', () => { const cmd = registry.addCommand('test-plugin', { id: 'check-cmd-false', name: 'Check Command False', checkCallback: vi.fn().mockReturnValue(false), }) expect(registry.canExecuteCommand(cmd, null)).toBe(false) }) })
describe('executeCommand with null editorContext', () => { it("returns 'editor-required' for editorCallback commands", () => { const cmd = registry.addCommand('test-plugin', { id: 'editor-cmd', name: 'Editor Command', editorCallback: vi.fn(), }) const result = registry.executeCommand(cmd, null) expect(result).toBe('editor-required') })
it("returns 'editor-required' for editorCheckCallback commands", () => { const cmd = registry.addCommand('test-plugin', { id: 'editor-check-cmd', name: 'Editor Check Command', editorCheckCallback: vi.fn().mockReturnValue(true), }) const result = registry.executeCommand(cmd, null) expect(result).toBe('editor-required') })
it('executes simple-callback commands successfully', () => { const callback = vi.fn() const cmd = registry.addCommand('test-plugin', { id: 'simple-cmd', name: 'Simple Command', callback, }) const result = registry.executeCommand(cmd, null) expect(result).toBe('executed') expect(callback).toHaveBeenCalled() }) })
describe('getAvailableCommands filters by editor context', () => { it('excludes editor-requiring commands when editorContext is null', () => { registry.addCommand('test-plugin', { id: 'editor-cmd', name: 'Editor Command', editorCallback: vi.fn(), }) registry.addCommand('test-plugin', { id: 'simple-cmd', name: 'Simple Command', callback: vi.fn(), })
const available = registry.getAvailableCommands(null) expect(available).toHaveLength(1) expect(available[0].id).toBe('test-plugin:simple-cmd') })
it('includes editor-requiring commands when editorContext is provided and check passes', () => { registry.addCommand('test-plugin', { id: 'editor-cmd', name: 'Editor Command', editorCallback: vi.fn(), }) registry.addCommand('test-plugin', { id: 'editor-check-cmd', name: 'Editor Check Command', editorCheckCallback: vi.fn().mockReturnValue(true), })
const editorContext = { editor: {}, view: {} } const available = registry.getAvailableCommands(editorContext) expect(available.map((c) => c.id).sort()).toEqual([ 'test-plugin:editor-check-cmd', 'test-plugin:editor-cmd', ]) }) })
describe('preflightCommandExecution gates on editor context', () => { it("reports 'editor-required' for editorCallback when context is null", () => { const cmd = registry.addCommand('test-plugin', { id: 'editor-cmd', name: 'Editor Command', editorCallback: vi.fn(), }) const result = registry.preflightCommandExecution(cmd, null, { notifyUser: false }) expect(result).toBe('editor-required') })
it("reports 'ready' for simple callbacks regardless of context", () => { const cmd = registry.addCommand('test-plugin', { id: 'simple-cmd', name: 'Simple Command', callback: vi.fn(), }) expect(registry.preflightCommandExecution(cmd, null, { notifyUser: false })).toBe('ready') expect( registry.preflightCommandExecution(cmd, { editor: {}, view: {} }, { notifyUser: false }), ).toBe('ready') }) })
describe('executeCommandByIdWithResult (no editor context available)', () => { it("reports 'editor-required' for editor-requiring commands", () => { registry.addCommand('test-plugin', { id: 'editor-cmd', name: 'Editor Command', editorCallback: vi.fn(), }) expect(registry.executeCommandByIdWithResult('test-plugin:editor-cmd')).toBe('editor-required') })
it("executes simple callbacks and reports 'executed'", () => { const callback = vi.fn() registry.addCommand('test-plugin', { id: 'simple-cmd', name: 'Simple Command', callback, }) expect(registry.executeCommandByIdWithResult('test-plugin:simple-cmd')).toBe('executed') expect(callback).toHaveBeenCalled() }) })})
describe('CommandRegistry follows the typed-view host contract through workspace.activeEditor (#408)', () => { it('treats editor-context commands as unavailable when the active leaf is non-markdown', async () => { const ctx = await makeAppForTest() const cmd = ctx.app.commands.addCommand('test-plugin', { id: 'editor-cmd', name: 'Editor Command', editorCallback: vi.fn(), })
// Open an image (or fallback) — anything non-markdown. const imageFile = await provisionBinaryFile(ctx, 'a.png', 'image/png', tinyPng()) const leaf = ctx.workspace.getLeaf(true) await leaf.openFile(imageFile) ctx.workspace.setActiveLeaf(leaf)
// Contract invariant 1: activeEditor is null for non-markdown leaves. expect(ctx.workspace.activeEditor).toBeNull()
// Contract invariant 4: editor-context commands are unavailable. const context = buildEditorContext(ctx.app) expect(context).toBeNull() expect(ctx.app.commands.canExecuteCommand(cmd, context, { notifyUser: false })).toBe(false)
// And the palette filter follows suit. expect(ctx.app.commands.getAvailableCommands(context).map(c => c.id)).not.toContain('test-plugin:editor-cmd') })
it('treats editor-context commands as available when the active leaf is markdown', async () => { const ctx = await makeAppForTest() const cmd = ctx.app.commands.addCommand('test-plugin', { id: 'editor-cmd-md', name: 'Editor Command (markdown)', editorCallback: vi.fn(), })
const mdFile = await provisionMarkdownFile(ctx, 'note.md', '# hi') const leaf = ctx.workspace.getLeaf(true) await leaf.openFile(mdFile) ctx.workspace.setActiveLeaf(leaf)
// Markdown leaf surfaces a non-null activeEditor. expect(ctx.workspace.activeEditor).not.toBeNull() expect(ctx.workspace.activeEditor).toBeInstanceOf(MarkdownView)
const context = buildEditorContext(ctx.app) expect(context).not.toBeNull() expect(ctx.app.commands.canExecuteCommand(cmd, context, { notifyUser: false })).toBe(true)
expect(ctx.app.commands.getAvailableCommands(context).map(c => c.id)).toContain('test-plugin:editor-cmd-md') })
it('flips availability on transition markdown → image → markdown', async () => { const ctx = await makeAppForTest() ctx.app.commands.addCommand('test-plugin', { id: 'transition-cmd', name: 'Transition Command', editorCallback: vi.fn(), })
const mdFile = await provisionMarkdownFile(ctx, 'tr.md', '# md') const imageFile = await provisionBinaryFile(ctx, 'tr.png', 'image/png', tinyPng())
const mdLeaf = ctx.workspace.getLeaf(true) await mdLeaf.openFile(mdFile) ctx.workspace.setActiveLeaf(mdLeaf) const cmd = ctx.app.commands.getCommand('test-plugin:transition-cmd')!
expect(ctx.workspace.activeEditor).not.toBeNull() expect(ctx.app.commands.canExecuteCommand(cmd, buildEditorContext(ctx.app), { notifyUser: false })).toBe(true)
// Open the image in a separate leaf and activate it. const imageLeaf = ctx.workspace.getLeaf(true) await imageLeaf.openFile(imageFile) ctx.workspace.setActiveLeaf(imageLeaf)
expect(ctx.workspace.activeEditor).toBeNull() expect(ctx.app.commands.canExecuteCommand(cmd, buildEditorContext(ctx.app), { notifyUser: false })).toBe(false)
// Switch back to the markdown leaf. ctx.workspace.setActiveLeaf(mdLeaf) expect(ctx.workspace.activeEditor).not.toBeNull() expect(ctx.app.commands.canExecuteCommand(cmd, buildEditorContext(ctx.app), { notifyUser: false })).toBe(true) })
it('non-editor-context commands stay available regardless of view type', async () => { // Commands without editorCallback / editorCheckCallback do NOT depend // on the active leaf — invariant 4's "out of scope" line. This pins // that explicitly so a future refactor that overgates command // availability fails this test instead of silently hiding plugin // commands when the user has an image open. const ctx = await makeAppForTest() ctx.app.commands.addCommand('test-plugin', { id: 'plain-cmd', name: 'Plain Command', callback: vi.fn(), })
const imageFile = await provisionBinaryFile(ctx, 'plain.png', 'image/png', tinyPng()) const leaf = ctx.workspace.getLeaf(true) await leaf.openFile(imageFile) ctx.workspace.setActiveLeaf(leaf)
const cmd = ctx.app.commands.getCommand('test-plugin:plain-cmd')! expect(ctx.workspace.activeEditor).toBeNull() expect(ctx.app.commands.canExecuteCommand(cmd, buildEditorContext(ctx.app), { notifyUser: false })).toBe(true) })})